diff --git a/assets/agent/function_schemas.json b/assets/agent/function_schemas.json index ce0c3386..0cc7ae63 100644 --- a/assets/agent/function_schemas.json +++ b/assets/agent/function_schemas.json @@ -1,6 +1,6 @@ [ { - "name": "resource_list", + "name": "resource-list", "description": "List all labeled resources of a specific type (dungeons, sprites, palettes)", "parameters": { "type": "object", @@ -8,7 +8,53 @@ "type": { "type": "string", "description": "Resource type to list", - "enum": ["dungeon", "sprite", "palette", "all"] + "enum": [ + "dungeon", + "room", + "entrance", + "overworld", + "sprite", + "palette", + "item", + "tile16", + "all" + ] + }, + "format": { + "type": "string", + "description": "Output format", + "enum": ["json", "table", "text"], + "default": "table" + } + }, + "required": ["type"] + } + }, + { + "name": "resource-search", + "description": "Search labeled resources by name, ID, or partial match", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search text (case-insensitive substring match)" + }, + "type": { + "type": "string", + "description": "Optional resource category to filter", + "enum": [ + "dungeon", + "room", + "entrance", + "overworld", + "sprite", + "palette", + "item", + "tile16", + "all" + ], + "default": "all" }, "format": { "type": "string", @@ -17,11 +63,11 @@ "default": "json" } }, - "required": ["type"] + "required": ["query"] } }, { - "name": "dungeon_list_sprites", + "name": "dungeon-list-sprites", "description": "List all sprites in a specific dungeon room", "parameters": { "type": "object", @@ -40,7 +86,26 @@ } }, { - "name": "overworld_find_tile", + "name": "dungeon-describe-room", + "description": "Summarize dungeon room metadata, hazards, and counts", + "parameters": { + "type": "object", + "properties": { + "room": { + "type": "string", + "description": "Room ID in hex format (e.g., 0x012)" + }, + "format": { + "type": "string", + "enum": ["json", "text"], + "default": "json" + } + }, + "required": ["room"] + } + }, + { + "name": "overworld-find-tile", "description": "Find all occurrences of a specific tile16 ID on overworld maps", "parameters": { "type": "object", @@ -63,7 +128,7 @@ } }, { - "name": "overworld_describe_map", + "name": "overworld-describe-map", "description": "Get summary information about an overworld map", "parameters": { "type": "object", @@ -82,7 +147,7 @@ } }, { - "name": "overworld_list_warps", + "name": "overworld-list-warps", "description": "List warp/entrance/exit points on the overworld", "parameters": { "type": "object", @@ -94,7 +159,8 @@ "type": { "type": "string", "description": "Optional: filter by warp type", - "enum": ["entrance", "exit", "hole", "all"] + "enum": ["entrance", "exit", "hole", "all"], + "default": "all" }, "format": { "type": "string", diff --git a/assets/agent/prompt_catalogue.yaml b/assets/agent/prompt_catalogue.yaml index 3419076b..645c1432 100644 --- a/assets/agent/prompt_catalogue.yaml +++ b/assets/agent/prompt_catalogue.yaml @@ -36,16 +36,32 @@ commands: tools: - name: resource-list description: "List project-defined resource labels for the requested category." - usage_notes: "Use this whenever you need to reference project-specific labels or IDs from the ROM. Valid categories are: room, entrance, sprite, overlord, item." + usage_notes: "Use this whenever you need to reference project-specific labels or IDs from the ROM. Valid categories: dungeon, room, entrance, overworld, sprite, palette, item, tile16, or all." arguments: - name: type - description: "Resource category. Valid values: room, entrance, sprite, overlord, item." + description: "Resource category. Valid values: dungeon, room, entrance, overworld, sprite, palette, item, tile16, all." required: true - example: room + example: dungeon - name: format description: "Response format (json or table). Defaults to JSON if omitted." required: false example: json + - name: resource-search + description: "Search resource labels by partial name or ID." + usage_notes: "Use to locate specific rooms, sprites, palettes, entrances, overworld maps, or tile16 entries based on fuzzy text." + arguments: + - name: query + description: "Search term to match against labels and IDs." + required: true + example: soldier + - name: type + description: "Optional category filter (dungeon, room, entrance, overworld, sprite, palette, item, tile16, all)." + required: false + example: sprite + - name: format + description: "Response format (json or text). Defaults to JSON." + required: false + example: json - name: dungeon-list-sprites description: "Inspect sprite placements for a specific dungeon room." usage_notes: "Returns sprite IDs, positions, and metadata for the requested room." @@ -62,6 +78,18 @@ tools: description: "Response format (json or table). Defaults to JSON if omitted." required: false example: json + - name: dungeon-describe-room + description: "Summarize dungeon room metadata, hazards, and object counts." + usage_notes: "Great for understanding room state before proposing edits. Includes lighting, effect flags, chests, staircases, and sample sprites." + arguments: + - name: room + description: "Room label or numeric ID (supports hex like 0x123)." + required: true + example: 0x012 + - name: format + description: "Response format (json or text). Defaults to JSON if omitted." + required: false + example: json - name: overworld-find-tile description: "Search all overworld maps for occurrences of a specific tile16 ID." usage_notes: "Ideal for tile lookup questions. Includes coordinates for each match." @@ -188,7 +216,14 @@ examples: tool_calls: - tool_name: resource-list args: - type: room + type: dungeon + - user_prompt: "Search for soldier sprites in the label database" + reasoning: "The user wants to find sprite labels. I should use the `resource-search` tool with a sprite filter." + tool_calls: + - tool_name: resource-search + args: + query: soldier + type: sprite - user_prompt: "[TOOL RESULT] {\"resources\": [{\"id\": 0, \"label\": \"Ganon\"}, {\"id\": 1, \"label\": \"Hyrule Castle\"}, {\"id\": 2, \"label\": \"Eastern Palace\"}, {\"id\": 3, \"label\": \"Desert Palace\"}, {\"id\": 4, \"label\": \"Tower of Hera\"}, {\"id\": 5, \"label\": \"Palace of Darkness\"}]}" text_response: "Based on the ROM data, this project contains 297 rooms. The first few include: Ganon's Room, Hyrule Castle, Eastern Palace, Desert Palace, Tower of Hera, and Palace of Darkness." reasoning: "I received the tool results showing room labels. Now I provide a natural language summary of this information." @@ -198,6 +233,12 @@ examples: - tool_name: dungeon-list-sprites args: room: "5" + - user_prompt: "Describe dungeon room 0x012" + reasoning: "The user wants overall room metadata. I should call `dungeon-describe-room` to gather the summary." + tool_calls: + - tool_name: dungeon-describe-room + args: + room: "0x012" - user_prompt: "[TOOL RESULT] {\"sprites\": [{\"id\": 0x41, \"name\": \"soldier\", \"x\": 5, \"y\": 3}, {\"id\": 0x41, \"name\": \"soldier\", \"x\": 10, \"y\": 3}]}" text_response: "Room 5 contains 2 sprites: two soldiers positioned at coordinates (5, 3) and (10, 3). Both are sprite ID 0x41." reasoning: "The tool returned sprite data for room 5. I've formatted this into a readable response for the user." diff --git a/docs/z3ed/README.md b/docs/z3ed/README.md index 6a758247..04bf1e3a 100644 --- a/docs/z3ed/README.md +++ b/docs/z3ed/README.md @@ -113,48 +113,23 @@ The agent can call these tools autonomously: | Tool | Purpose | Example | |------|---------|---------| | `resource-list` | List labeled resources | "What dungeons exist?" | +| `resource-search` | Fuzzy search across labels | "Search for soldier labels" | | `dungeon-list-sprites` | Sprites in room | "Show soldiers in room 0x12" | +| `dungeon-describe-room` | Room metadata summary | "Describe room 0x012" | | `overworld-find-tile` | Find tile locations | "Where is tile 0x2E used?" | | `overworld-describe-map` | Map metadata | "Describe map 0x05" | | `overworld-list-warps` | List entrances/exits | "Show all cave entrances" | -## Documentation - -- **[AGENT-ROADMAP.md](AGENT-ROADMAP.md)** - Vision, priorities, and technical architecture -- **[E6-z3ed-cli-design.md](E6-z3ed-cli-design.md)** - CLI design and command structure -- **[E6-z3ed-reference.md](E6-z3ed-reference.md)** - Complete command reference - -## Recent Updates (Oct 3, 2025) - -### ✅ Implemented -- **Simple Chat Mode**: Text-based REPL for automation -- **GUI Widget Fixes**: Corrected API usage, table rendering -- **Condensed Documentation**: Streamlined README and ROADMAP -- **Z3ED_AI Flag**: Simplified build with single master flag ### 🎯 Next Steps -1. **Live LLM Testing** (1-2h): Verify function calling works 2. **GUI Integration** (4-6h): Wire chat widget into main app 3. **Proposal Integration** (6-8h): Connect chat to ROM modification ## Troubleshooting -### "AI features not available" -**Solution**: Rebuild with `-DZ3ED_AI=ON` - -### "OpenSSL not found" -**Impact**: Gemini won't work -**Solutions**: -- Use Ollama (no SSL needed) -- Install OpenSSL: `brew install openssl` - ### Chat mode freezes **Solution**: Use `agent simple-chat` instead of `agent chat` -### Tool not being called -**Cause**: Model doesn't support function calling -**Solution**: Use qwen2.5-coder (Ollama) or Gemini 2.0 - ## Example Workflows ### Explore ROM @@ -463,13 +438,6 @@ AI agent features require: ## Troubleshooting -### "OpenSSL not found" warning -**Impact**: Gemini API won't work (HTTPS required) -**Solutions**: -- Use Ollama instead (no SSL needed, runs locally) - **RECOMMENDED** -- Install OpenSSL: `brew install openssl` (macOS) or `apt-get install libssl-dev` (Linux) -- Windows: Use Ollama (localhost) instead of Gemini - ### "Build with -DZ3ED_AI=ON" warning **Impact**: AI agent features disabled (no Ollama or Gemini) **Solution**: Rebuild with AI support: diff --git a/src/app/zelda3/dungeon/room.h b/src/app/zelda3/dungeon/room.h index b34ca4ca..32a695cf 100644 --- a/src/app/zelda3/dungeon/room.h +++ b/src/app/zelda3/dungeon/room.h @@ -313,6 +313,15 @@ class Room { void SetStair3Target(uint8_t target) { stair3_.target = target; } void SetStair4Target(uint8_t target) { stair4_.target = target; } + // Read-only accessors for metadata + EffectKey effect() const { return effect_; } + TagKey tag1() const { return tag1_; } + TagKey tag2() const { return tag2_; } + CollisionKey collision() const { return collision_; } + const LayerMergeType& layer_merging() const { return layer_merging_; } + + int id() const { return room_id_; } + uint8_t blockset = 0; uint8_t spriteset = 0; uint8_t palette = 0; diff --git a/src/cli/handlers/agent.cc b/src/cli/handlers/agent.cc index efce4487..2f6b874f 100644 --- a/src/cli/handlers/agent.cc +++ b/src/cli/handlers/agent.cc @@ -36,9 +36,15 @@ constexpr absl::string_view kUsage = " resource-list List labeled resources (dungeons, sprites, etc.)\n" " Example: agent resource-list --type=dungeon --format=json\n" "\n" + " resource-search Search resource labels by fuzzy text\n" + " Example: agent resource-search --query=soldier --type=sprite\n" + "\n" " dungeon-list-sprites List sprites in a dungeon room\n" " Example: agent dungeon-list-sprites --room=5 --format=json\n" "\n" + " dungeon-describe-room Summarize metadata for a dungeon room\n" + " Example: agent dungeon-describe-room --room=0x12 --format=text\n" + "\n" " overworld-find-tile Search for tile placements in overworld\n" " Example: agent overworld-find-tile --tile=0x02E --format=json\n" "\n" @@ -121,9 +127,15 @@ absl::Status Agent::Run(const std::vector& arg_vec) { if (subcommand == "resource-list") { return agent::HandleResourceListCommand(subcommand_args); } + if (subcommand == "resource-search") { + return agent::HandleResourceSearchCommand(subcommand_args); + } if (subcommand == "dungeon-list-sprites") { return agent::HandleDungeonListSpritesCommand(subcommand_args); } + if (subcommand == "dungeon-describe-room") { + return agent::HandleDungeonDescribeRoomCommand(subcommand_args); + } if (subcommand == "overworld-find-tile") { return agent::HandleOverworldFindTileCommand(subcommand_args); } diff --git a/src/cli/handlers/agent/commands.h b/src/cli/handlers/agent/commands.h index ba3b7817..04aa7658 100644 --- a/src/cli/handlers/agent/commands.h +++ b/src/cli/handlers/agent/commands.h @@ -28,9 +28,15 @@ absl::Status HandleDescribeCommand(const std::vector& arg_vec); absl::Status HandleResourceListCommand( const std::vector& arg_vec, Rom* rom_context = nullptr); +absl::Status HandleResourceSearchCommand( + const std::vector& arg_vec, + Rom* rom_context = nullptr); absl::Status HandleDungeonListSpritesCommand( const std::vector& arg_vec, Rom* rom_context = nullptr); +absl::Status HandleDungeonDescribeRoomCommand( + const std::vector& arg_vec, + Rom* rom_context = nullptr); absl::Status HandleOverworldFindTileCommand( const std::vector& arg_vec, Rom* rom_context = nullptr); diff --git a/src/cli/handlers/agent/tool_commands.cc b/src/cli/handlers/agent/tool_commands.cc index 704243dd..716e51a5 100644 --- a/src/cli/handlers/agent/tool_commands.cc +++ b/src/cli/handlers/agent/tool_commands.cc @@ -1,11 +1,13 @@ #include "cli/handlers/agent/commands.h" +#include #include #include #include #include #include +#include "absl/base/macros.h" #include "absl/flags/declare.h" #include "absl/flags/flag.h" #include "absl/status/status.h" @@ -22,6 +24,7 @@ #include "app/zelda3/overworld/overworld.h" #include "cli/handlers/overworld_inspect.h" #include "cli/service/resources/resource_context_builder.h" +#include "util/macro.h" ABSL_DECLARE_FLAG(std::string, rom); @@ -145,6 +148,194 @@ absl::Status HandleResourceListCommand( return absl::OkStatus(); } +absl::Status HandleResourceSearchCommand( + const std::vector& arg_vec, Rom* rom_context) { + std::string query; + std::string type = "all"; + std::string format = "json"; + + for (size_t i = 0; i < arg_vec.size(); ++i) { + const std::string& token = arg_vec[i]; + if (token == "--query") { + if (i + 1 >= arg_vec.size()) { + return absl::InvalidArgumentError("--query requires a value."); + } + query = arg_vec[++i]; + } else if (absl::StartsWith(token, "--query=")) { + query = token.substr(8); + } else if (token == "--type") { + if (i + 1 >= arg_vec.size()) { + return absl::InvalidArgumentError("--type requires a value."); + } + type = arg_vec[++i]; + } else if (absl::StartsWith(token, "--type=")) { + type = token.substr(7); + } else if (token == "--format") { + if (i + 1 >= arg_vec.size()) { + return absl::InvalidArgumentError("--format requires a value."); + } + format = arg_vec[++i]; + } else if (absl::StartsWith(token, "--format=")) { + format = token.substr(9); + } + } + + if (query.empty()) { + return absl::InvalidArgumentError( + "Usage: agent resource-search --query [--type ] [--format ]"); + } + + format = absl::AsciiStrToLower(format); + if (format != "json" && format != "text") { + return absl::InvalidArgumentError("--format must be either json or text"); + } + + auto normalize_category = [](std::string value) { + value = absl::AsciiStrToLower(value); + if (value.size() > 1 && value.back() == 's') { + value.pop_back(); + } + if (value == "tile16s") { + return std::string("tile16"); + } + return value; + }; + + const std::vector known_categories = { + "overworld", "dungeon", "entrance", "room", + "sprite", "palette", "item", "tile16"}; + + std::vector categories; + std::string normalized_type = normalize_category(type); + if (normalized_type == "all" || normalized_type.empty()) { + categories = known_categories; + } else { + bool recognized = false; + for (const auto& candidate : known_categories) { + if (candidate == normalized_type) { + recognized = true; + break; + } + } + if (!recognized) { + return absl::InvalidArgumentError( + absl::StrCat("Unknown resource category: ", type, + ". Known categories: overworld, dungeon, entrance, room, sprite, palette, item, tile16.")); + } + categories.push_back(normalized_type); + } + + Rom rom_storage; + Rom* rom = nullptr; + if (rom_context != nullptr && rom_context->is_loaded()) { + rom = rom_context; + } else { + auto rom_or = LoadRomFromFlag(); + if (!rom_or.ok()) { + return rom_or.status(); + } + rom_storage = std::move(rom_or.value()); + rom = &rom_storage; + } + + // Ensure labels are available similar to resource-list + if (rom->resource_label() && !rom->resource_label()->labels_loaded_) { + core::YazeProject project; + auto labels_status = project.InitializeEmbeddedLabels(); + if (labels_status.ok()) { + rom->resource_label()->labels_ = project.resource_labels; + rom->resource_label()->labels_loaded_ = true; + } + } + + ResourceContextBuilder context_builder(rom); + + struct SearchResult { + std::string category; + std::string id; + std::string label; + }; + + std::vector results; + std::string lowered_query = absl::AsciiStrToLower(query); + + for (const auto& category : categories) { + auto labels_or = context_builder.GetLabels(category); + if (!labels_or.ok()) { + // If the category was explicitly requested and not "all", surface the error. + if (normalized_type != "all") { + return labels_or.status(); + } + continue; + } + + const auto& labels = labels_or.value(); + for (const auto& [id, label] : labels) { + std::string lowered_label = absl::AsciiStrToLower(label); + std::string lowered_id = absl::AsciiStrToLower(id); + if (lowered_label.find(lowered_query) != std::string::npos || + lowered_id.find(lowered_query) != std::string::npos) { + results.push_back({category, id, label}); + } + } + } + + std::sort(results.begin(), results.end(), + [](const SearchResult& a, const SearchResult& b) { + if (a.category == b.category) { + return a.id < b.id; + } + return a.category < b.category; + }); + + if (results.empty()) { + if (format == "json") { + std::cout << "{\n" + << " \"query\": \"" << query << "\",\n" + << " \"match_count\": 0,\n" + << " \"results\": []\n" + << "}\n"; + } else { + std::cout << absl::StrFormat( + "🔍 No matches found for \"%s\" in %s resources.\n", + query, normalized_type == "all" ? std::string("any") : type); + } + return absl::OkStatus(); + } + + if (format == "json") { + std::cout << "{\n"; + std::cout << " \"query\": \"" << query << "\",\n"; + std::cout << absl::StrFormat(" \"match_count\": %zu,\n", results.size()); + std::cout << " \"results\": [\n"; + for (size_t i = 0; i < results.size(); ++i) { + const auto& result = results[i]; + std::cout << absl::StrFormat( + " {\"category\": \"%s\", \"id\": \"%s\", \"label\": \"%s\"}%s\n", + result.category, result.id, result.label, + (i + 1 == results.size()) ? "" : ","); + } + std::cout << " ]\n"; + std::cout << "}\n"; + } else { + std::cout << absl::StrFormat( + "🔍 %zu match(es) for \"%s\" (categories: %s)\n", + results.size(), query, + normalized_type == "all" ? "all" : type); + std::string current_category; + for (const auto& result : results) { + if (result.category != current_category) { + current_category = result.category; + std::cout << absl::StrFormat("\n[%s]\n", + absl::AsciiStrToUpper(current_category)); + } + std::cout << absl::StrFormat(" %-12s → %s\n", result.id, result.label); + } + } + + return absl::OkStatus(); +} + absl::Status HandleDungeonListSpritesCommand( const std::vector& arg_vec, Rom* rom_context) { std::string room_id_str; @@ -220,6 +411,236 @@ absl::Status HandleDungeonListSpritesCommand( return absl::OkStatus(); } +absl::Status HandleDungeonDescribeRoomCommand( + const std::vector& arg_vec, Rom* rom_context) { + std::string room_id_str; + std::string format = "json"; + std::optional rom_override; + + for (size_t i = 0; i < arg_vec.size(); ++i) { + const std::string& token = arg_vec[i]; + if (token == "--room") { + if (i + 1 >= arg_vec.size()) { + return absl::InvalidArgumentError("--room requires a value."); + } + room_id_str = arg_vec[++i]; + } else if (absl::StartsWith(token, "--room=")) { + room_id_str = token.substr(7); + } else if (token == "--format") { + if (i + 1 >= arg_vec.size()) { + return absl::InvalidArgumentError("--format requires a value."); + } + format = arg_vec[++i]; + } else if (absl::StartsWith(token, "--format=")) { + format = token.substr(9); + } else if (token == "--rom") { + if (i + 1 >= arg_vec.size()) { + return absl::InvalidArgumentError("--rom requires a value."); + } + rom_override = arg_vec[++i]; + } else if (absl::StartsWith(token, "--rom=")) { + rom_override = token.substr(6); + } + } + + if (room_id_str.empty()) { + return absl::InvalidArgumentError( + "Usage: agent dungeon-describe-room --room [--format ]"); + } + + int room_id = 0; + if (!absl::SimpleHexAtoi(room_id_str, &room_id)) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid room ID: ", room_id_str, + " (expected hexadecimal, e.g. 0x02A)")); + } + + format = absl::AsciiStrToLower(format); + if (format != "json" && format != "text") { + return absl::InvalidArgumentError("--format must be either json or text"); + } + + Rom rom_storage; + Rom* rom = nullptr; + if (rom_context != nullptr && rom_context->is_loaded() && !rom_override.has_value()) { + rom = rom_context; + } else { + ASSIGN_OR_RETURN(auto rom_or, LoadRomFromPathOrFlag(rom_override)); + rom_storage = std::move(rom_or); + rom = &rom_storage; + } + + auto room = zelda3::LoadRoomFromRom(rom, room_id); + room.LoadObjects(); + room.LoadSprites(); + + auto dimensions = room.GetLayout().GetDimensions(); + const auto& sprites = room.GetSprites(); + const auto& chests = room.GetChests(); + const auto& stairs = room.GetStairs(); + const size_t sprite_count = sprites.size(); + const size_t chest_count = chests.size(); + const size_t stair_count = stairs.size(); + const size_t object_count = room.GetTileObjectCount(); + + constexpr size_t kRoomNameCount = + sizeof(zelda3::kRoomNames) / sizeof(zelda3::kRoomNames[0]); + std::string room_name = "Unknown"; + if (room_id >= 0 && static_cast(room_id) < kRoomNameCount) { + room_name = std::string(zelda3::kRoomNames[room_id]); + if (room_name.empty()) { + room_name = "Unnamed"; + } + } + + constexpr size_t kRoomEffectCount = + sizeof(zelda3::RoomEffect) / sizeof(zelda3::RoomEffect[0]); + const size_t effect_index = static_cast(room.effect()); + std::string effect_name = "Unknown"; + if (effect_index < kRoomEffectCount) { + effect_name = zelda3::RoomEffect[effect_index]; + } + + constexpr size_t kRoomTagCount = + sizeof(zelda3::RoomTag) / sizeof(zelda3::RoomTag[0]); + const auto tag_name = [&](zelda3::TagKey tag) { + const size_t index = static_cast(tag); + if (index < kRoomTagCount) { + return std::string(zelda3::RoomTag[index]); + } + return std::string("Unknown"); + }; + + constexpr absl::string_view kCollisionNames[] = { + "Layer 1 Only", + "Both Layers", + "Both + Scroll", + "Moving Floor", + "Moving Water", + }; + std::string collision_name = "Unknown"; + const size_t collision_index = static_cast(room.collision()); + if (collision_index < ABSL_ARRAYSIZE(kCollisionNames)) { + collision_name = std::string(kCollisionNames[collision_index]); + } + + if (format == "json") { + std::cout << "{\n"; + std::cout << absl::StrFormat(" \"room\": \"0x%03X\",\n", room_id); + std::cout << absl::StrFormat(" \"name\": \"%s\",\n", room_name); + std::cout << absl::StrFormat(" \"light\": %s,\n", + room.IsLight() ? "true" : "false"); + std::cout << absl::StrFormat(" \"layout\": {\"width\": %d, \"height\": %d},\n", + dimensions.first, dimensions.second); + std::cout << absl::StrFormat( + " \"counts\": {\"sprites\": %zu, \"chests\": %zu, \"stairs\": %zu, \"tile_objects\": %zu},\n", + sprite_count, chest_count, stair_count, object_count); + std::cout << absl::StrFormat( + " \"state\": {\"effect\": \"%s\", \"tag1\": \"%s\", \"tag2\": \"%s\", \"collision\": \"%s\", \"layer_merge\": \"%s\"},\n", + effect_name, tag_name(room.tag1()), tag_name(room.tag2()), + collision_name, room.layer_merging().Name); + std::cout << absl::StrFormat( + " \"graphics\": {\"blockset\": %u, \"spriteset\": %u, \"palette\": %u},\n", + room.blockset, room.spriteset, room.palette); + std::cout << absl::StrFormat( + " \"floors\": {\"primary\": %u, \"secondary\": %u},\n", + room.floor1, room.floor2); + std::cout << absl::StrFormat( + " \"message_id\": \"0x%03X\",\n", room.message_id_); + std::cout << absl::StrFormat( + " \"hole_warp\": \"0x%02X\",\n", room.holewarp); + + std::cout << " \"staircases\": ["; + for (size_t i = 0; i < stair_count; ++i) { + const auto& stair = stairs[i]; + std::cout << (i == 0 ? "\n" : ",\n"); + std::cout << absl::StrFormat( + " {\"id\": %u, \"target_room\": \"0x%02X\", \"label\": \"%s\"}", + stair.id, stair.room, stair.label ? stair.label : ""); + } + if (stair_count > 0) { + std::cout << "\n ],\n"; + } else { + std::cout << "],\n"; + } + + std::cout << " \"chests\": ["; + for (size_t i = 0; i < chest_count; ++i) { + const auto& chest = chests[i]; + std::cout << (i == 0 ? "\n" : ",\n"); + std::cout << absl::StrFormat( + " {\"item_id\": \"0x%02X\", \"is_big\": %s}", + chest.id, chest.size ? "true" : "false"); + } + if (chest_count > 0) { + std::cout << "\n ],\n"; + } else { + std::cout << "],\n"; + } + + const int sample_sprite_count = + static_cast(std::min(sprite_count, 5)); + std::cout << absl::StrFormat( + " \"sample_sprites\": %d,\n", sample_sprite_count); + if (!sprites.empty()) { + std::cout << " \"sprites\": [\n"; + const size_t limit = std::min(sprites.size(), 5); + for (size_t i = 0; i < limit; ++i) { + const auto& spr = sprites[i]; + std::cout << absl::StrFormat( + " {\"index\": %zu, \"id\": \"0x%02X\", \"x\": %d, \"y\": %d, \"layer\": %d, \"subtype\": %d}", + i, spr.id(), spr.x(), spr.y(), spr.layer(), spr.subtype()); + if (i + 1 < limit) { + std::cout << ","; + } + std::cout << "\n"; + } + std::cout << " ]\n"; + } else { + std::cout << " \"sprites\": []\n"; + } + std::cout << "}\n"; + } else { + std::cout << absl::StrFormat("🏰 Room 0x%03X — %s\n", room_id, room_name); + std::cout << absl::StrFormat( + " Layout: %d×%d tiles | Lighting: %s\n", + dimensions.first, dimensions.second, + room.IsLight() ? "light" : "dark"); + std::cout << absl::StrFormat( + " Sprites: %zu Chests: %zu Stairs: %zu Tile Objects: %zu\n", + sprite_count, chest_count, stair_count, object_count); + std::cout << absl::StrFormat( + " Effect: %s | Tags: %s / %s | Collision: %s | Layer Merge: %s\n", + effect_name, tag_name(room.tag1()), tag_name(room.tag2()), + collision_name, room.layer_merging().Name); + std::cout << absl::StrFormat( + " Graphics → Blockset:%u Spriteset:%u Palette:%u\n", + room.blockset, room.spriteset, room.palette); + std::cout << absl::StrFormat( + " Floors → Main:%u Alt:%u Message ID:0x%03X Hole warp:0x%02X\n", + room.floor1, room.floor2, room.message_id_, room.holewarp); + if (!stairs.empty()) { + std::cout << " Staircases:\n"; + for (const auto& stair : stairs) { + std::cout << absl::StrFormat(" - ID %u → Room 0x%02X (%s)\n", + stair.id, stair.room, + stair.label ? stair.label : ""); + } + } + if (!chests.empty()) { + std::cout << " Chests:\n"; + for (size_t i = 0; i < chests.size(); ++i) { + const auto& chest = chests[i]; + std::cout << absl::StrFormat(" - #%zu Item 0x%02X %s\n", i, + chest.id, + chest.size ? "(big)" : ""); + } + } + } + + return absl::OkStatus(); +} + absl::Status HandleOverworldFindTileCommand( const std::vector& arg_vec, Rom* rom_context) { std::optional tile_value; diff --git a/src/cli/service/agent/conversational_agent_service.cc b/src/cli/service/agent/conversational_agent_service.cc index d119850a..3630e876 100644 --- a/src/cli/service/agent/conversational_agent_service.cc +++ b/src/cli/service/agent/conversational_agent_service.cc @@ -14,7 +14,9 @@ #include "absl/strings/str_cat.h" #include "absl/strings/str_format.h" #include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" #include "absl/time/clock.h" +#include "absl/time/time.h" #include "app/rom.h" #include "cli/service/agent/proposal_executor.h" #include "cli/service/ai/service_factory.h" @@ -132,6 +134,20 @@ std::optional BuildTableData(const nlohmann::json& data) return std::nullopt; } +bool IsExecutableCommand(absl::string_view command) { + return !command.empty() && command.front() != '#'; +} + +int CountExecutableCommands(const std::vector& commands) { + int count = 0; + for (const auto& command : commands) { + if (IsExecutableCommand(command)) { + ++count; + } + } + return count; +} + ChatMessage CreateMessage(ChatMessage::Sender sender, const std::string& content) { ChatMessage message; message.sender = sender; @@ -175,6 +191,38 @@ void ConversationalAgentService::SetRomContext(Rom* rom) { void ConversationalAgentService::ResetConversation() { history_.clear(); + metrics_ = InternalMetrics{}; +} + +void ConversationalAgentService::TrimHistoryIfNeeded() { + if (!config_.trim_history || config_.max_history_messages == 0) { + return; + } + + while (history_.size() > config_.max_history_messages) { + history_.erase(history_.begin()); + } +} + +ChatMessage::SessionMetrics ConversationalAgentService::BuildMetricsSnapshot() const { + ChatMessage::SessionMetrics snapshot; + snapshot.turn_index = metrics_.turns_completed; + snapshot.total_user_messages = metrics_.user_messages; + snapshot.total_agent_messages = metrics_.agent_messages; + snapshot.total_tool_calls = metrics_.tool_calls; + snapshot.total_commands = metrics_.commands_generated; + snapshot.total_proposals = metrics_.proposals_created; + snapshot.total_elapsed_seconds = absl::ToDoubleSeconds(metrics_.total_latency); + snapshot.average_latency_seconds = + metrics_.turns_completed > 0 + ? snapshot.total_elapsed_seconds / + static_cast(metrics_.turns_completed) + : 0.0; + return snapshot; +} + +ChatMessage::SessionMetrics ConversationalAgentService::GetMetrics() const { + return BuildMetricsSnapshot(); } absl::StatusOr ConversationalAgentService::SendMessage( @@ -186,10 +234,13 @@ absl::StatusOr ConversationalAgentService::SendMessage( if (!message.empty()) { history_.push_back(CreateMessage(ChatMessage::Sender::kUser, message)); + TrimHistoryIfNeeded(); + ++metrics_.user_messages; } const int max_iterations = config_.max_tool_iterations; bool waiting_for_text_response = false; + absl::Time turn_start = absl::Now(); if (config_.verbose) { util::PrintInfo(absl::StrCat("Starting agent loop (max ", max_iterations, " iterations)")); @@ -269,6 +320,7 @@ absl::StatusOr ConversationalAgentService::SendMessage( const std::string& tool_output = tool_result_or.value(); if (!tool_output.empty()) { util::PrintSuccess("Tool executed successfully"); + ++metrics_.tool_calls; if (config_.verbose) { std::cout << util::colors::kDim << "Tool output (truncated):" @@ -358,6 +410,8 @@ absl::StatusOr ConversationalAgentService::SendMessage( response_text.append("Reasoning: "); response_text.append(agent_response.reasoning); } + const int executable_commands = + CountExecutableCommands(agent_response.commands); if (!agent_response.commands.empty()) { if (!response_text.empty()) { response_text.append("\n\n"); @@ -365,6 +419,7 @@ absl::StatusOr ConversationalAgentService::SendMessage( response_text.append("Commands:\n"); response_text.append(absl::StrJoin(agent_response.commands, "\n")); } + metrics_.commands_generated += executable_commands; if (proposal_result.has_value()) { const auto& metadata = proposal_result->metadata; @@ -381,6 +436,7 @@ absl::StatusOr ConversationalAgentService::SendMessage( proposal_result->executed_commands == 1 ? "" : "s", metadata.id, metadata.sandbox_rom_path.string(), proposal_result->proposal_json_path.string())); + ++metrics_.proposals_created; } else if (attempted_proposal && !proposal_status.ok()) { if (!response_text.empty()) { response_text.append("\n\n"); @@ -392,7 +448,12 @@ absl::StatusOr ConversationalAgentService::SendMessage( ChatMessage chat_response = CreateMessage(ChatMessage::Sender::kAgent, response_text); + ++metrics_.agent_messages; + ++metrics_.turns_completed; + metrics_.total_latency += absl::Now() - turn_start; + chat_response.metrics = BuildMetricsSnapshot(); history_.push_back(chat_response); + TrimHistoryIfNeeded(); return chat_response; } diff --git a/src/cli/service/agent/conversational_agent_service.h b/src/cli/service/agent/conversational_agent_service.h index 6f2f2e44..8cbdf550 100644 --- a/src/cli/service/agent/conversational_agent_service.h +++ b/src/cli/service/agent/conversational_agent_service.h @@ -6,6 +6,7 @@ #include #include "absl/status/statusor.h" +#include "absl/time/time.h" #include "cli/service/ai/ai_service.h" #include "cli/service/agent/tool_dispatcher.h" @@ -27,6 +28,17 @@ struct ChatMessage { absl::Time timestamp; std::optional json_pretty; std::optional table_data; + struct SessionMetrics { + int turn_index = 0; + int total_user_messages = 0; + int total_agent_messages = 0; + int total_tool_calls = 0; + int total_commands = 0; + int total_proposals = 0; + double total_elapsed_seconds = 0.0; + double average_latency_seconds = 0.0; + }; + std::optional metrics; }; struct AgentConfig { @@ -34,6 +46,8 @@ struct AgentConfig { int max_retry_attempts = 3; // Maximum retries on errors bool verbose = false; // Enable verbose diagnostic output bool show_reasoning = true; // Show LLM reasoning in output + size_t max_history_messages = 50; // Maximum stored history messages per session + bool trim_history = true; // Whether to trim history beyond the limit }; class ConversationalAgentService { @@ -57,12 +71,28 @@ class ConversationalAgentService { void SetConfig(const AgentConfig& config) { config_ = config; } const AgentConfig& GetConfig() const { return config_; } + ChatMessage::SessionMetrics GetMetrics() const; + private: + struct InternalMetrics { + int user_messages = 0; + int agent_messages = 0; + int tool_calls = 0; + int commands_generated = 0; + int proposals_created = 0; + int turns_completed = 0; + absl::Duration total_latency = absl::ZeroDuration(); + }; + + void TrimHistoryIfNeeded(); + ChatMessage::SessionMetrics BuildMetricsSnapshot() const; + std::vector history_; std::unique_ptr ai_service_; ToolDispatcher tool_dispatcher_; Rom* rom_context_ = nullptr; AgentConfig config_; + InternalMetrics metrics_; }; } // namespace agent diff --git a/src/cli/service/agent/simple_chat_session.cc b/src/cli/service/agent/simple_chat_session.cc index a3c35d6d..868e9f8c 100644 --- a/src/cli/service/agent/simple_chat_session.cc +++ b/src/cli/service/agent/simple_chat_session.cc @@ -80,6 +80,20 @@ void SimpleChatSession::PrintMessage(const ChatMessage& msg, bool show_timestamp } else { std::cout << msg.message << "\n"; } + + if (msg.metrics.has_value()) { + const auto& metrics = msg.metrics.value(); + std::cout << " 📊 Turn " << metrics.turn_index + << " summary — users: " << metrics.total_user_messages + << ", agents: " << metrics.total_agent_messages + << ", tools: " << metrics.total_tool_calls + << ", commands: " << metrics.total_commands + << ", proposals: " << metrics.total_proposals + << ", elapsed: " + << absl::StrFormat("%.2fs avg %.2fs", metrics.total_elapsed_seconds, + metrics.average_latency_seconds) + << "\n"; + } } absl::Status SimpleChatSession::SendAndWaitForResponse( @@ -142,6 +156,18 @@ absl::Status SimpleChatSession::RunInteractive() { PrintMessage(result.value(), false); std::cout << "\n"; } + + const auto metrics = agent_service_.GetMetrics(); + std::cout << "Session totals — turns: " << metrics.turn_index + << ", user messages: " << metrics.total_user_messages + << ", agent messages: " << metrics.total_agent_messages + << ", tool calls: " << metrics.total_tool_calls + << ", commands: " << metrics.total_commands + << ", proposals: " << metrics.total_proposals + << ", elapsed: " + << absl::StrFormat("%.2fs avg %.2fs\n\n", + metrics.total_elapsed_seconds, + metrics.average_latency_seconds); return absl::OkStatus(); } @@ -175,6 +201,18 @@ absl::Status SimpleChatSession::RunBatch(const std::string& input_file) { PrintMessage(result.value(), false); std::cout << "\n"; } + + const auto metrics = agent_service_.GetMetrics(); + std::cout << "Batch session totals — turns: " << metrics.turn_index + << ", user messages: " << metrics.total_user_messages + << ", agent messages: " << metrics.total_agent_messages + << ", tool calls: " << metrics.total_tool_calls + << ", commands: " << metrics.total_commands + << ", proposals: " << metrics.total_proposals + << ", elapsed: " + << absl::StrFormat("%.2fs avg %.2fs\n\n", + metrics.total_elapsed_seconds, + metrics.average_latency_seconds); return absl::OkStatus(); } diff --git a/src/cli/service/agent/tool_dispatcher.cc b/src/cli/service/agent/tool_dispatcher.cc index 7d35e4ca..73515a57 100644 --- a/src/cli/service/agent/tool_dispatcher.cc +++ b/src/cli/service/agent/tool_dispatcher.cc @@ -36,8 +36,12 @@ absl::StatusOr ToolDispatcher::Dispatch( absl::Status status; if (tool_call.tool_name == "resource-list") { status = HandleResourceListCommand(args, rom_context_); + } else if (tool_call.tool_name == "resource-search") { + status = HandleResourceSearchCommand(args, rom_context_); } else if (tool_call.tool_name == "dungeon-list-sprites") { status = HandleDungeonListSpritesCommand(args, rom_context_); + } else if (tool_call.tool_name == "dungeon-describe-room") { + status = HandleDungeonDescribeRoomCommand(args, rom_context_); } else if (tool_call.tool_name == "overworld-find-tile") { status = HandleOverworldFindTileCommand(args, rom_context_); } else if (tool_call.tool_name == "overworld-describe-map") { diff --git a/src/cli/service/ai/ai_service.cc b/src/cli/service/ai/ai_service.cc index 6f61c03b..d2197042 100644 --- a/src/cli/service/ai/ai_service.cc +++ b/src/cli/service/ai/ai_service.cc @@ -3,6 +3,7 @@ #include #include +#include #include "absl/strings/ascii.h" #include "absl/strings/match.h" @@ -51,6 +52,38 @@ std::string ExtractRoomId(const std::string& normalized_prompt) { return "0x000"; } +std::string ExtractKeyword(const std::string& normalized_prompt) { + static const char* kStopwords[] = { + "search", "for", "resource", "resources", "label", "labels", + "please", "the", "a", "an", "list", "of", "in", "find"}; + + auto is_stopword = [](const std::string& word) { + for (const char* stop : kStopwords) { + if (word == stop) { + return true; + } + } + return false; + }; + + std::istringstream stream(normalized_prompt); + std::string token; + while (stream >> token) { + token.erase(std::remove_if(token.begin(), token.end(), [](unsigned char c) { + return !std::isalnum(c) && c != '_' && c != '-'; + }), + token.end()); + if (token.empty()) { + continue; + } + if (!is_stopword(token)) { + return token; + } + } + + return "all"; +} + } // namespace absl::StatusOr MockAIService::GenerateResponse( @@ -96,6 +129,20 @@ absl::StatusOr MockAIService::GenerateResponse( return response; } + if (absl::StrContains(normalized, "search") && + (absl::StrContains(normalized, "resource") || + absl::StrContains(normalized, "label"))) { + ToolCall call; + call.tool_name = "resource-search"; + call.args.emplace("query", ExtractKeyword(normalized)); + response.text_response = + "Let me look through the labelled resources for matches."; + response.reasoning = + "Resource search provides fuzzy matching against the ROM label catalogue."; + response.tool_calls.push_back(call); + return response; + } + if (absl::StrContains(normalized, "sprite") && absl::StrContains(normalized, "room")) { ToolCall call; @@ -109,6 +156,19 @@ absl::StatusOr MockAIService::GenerateResponse( return response; } + if (absl::StrContains(normalized, "describe") && + absl::StrContains(normalized, "room")) { + ToolCall call; + call.tool_name = "dungeon-describe-room"; + call.args.emplace("room", ExtractRoomId(normalized)); + response.text_response = + "I'll summarize the room's metadata and hazards."; + response.reasoning = + "Room description tool surfaces lighting, effects, and object counts before planning edits."; + response.tool_calls.push_back(call); + return response; + } + response.text_response = "I'm just a mock service. Please load a provider like ollama or gemini."; return response; diff --git a/src/cli/service/ai/gemini_ai_service.cc b/src/cli/service/ai/gemini_ai_service.cc index 8763e0b4..09907e5d 100644 --- a/src/cli/service/ai/gemini_ai_service.cc +++ b/src/cli/service/ai/gemini_ai_service.cc @@ -123,11 +123,13 @@ void GeminiAIService::EnableFunctionCalling(bool enable) { std::vector GeminiAIService::GetAvailableTools() const { return { - "resource_list", - "dungeon_list_sprites", - "overworld_find_tile", - "overworld_describe_map", - "overworld_list_warps" + "resource-list", + "resource-search", + "dungeon-list-sprites", + "dungeon-describe-room", + "overworld-find-tile", + "overworld-describe-map", + "overworld-list-warps" }; } diff --git a/src/cli/tui/chat_tui.cc b/src/cli/tui/chat_tui.cc index e6fcd8f8..6fe2f3e9 100644 --- a/src/cli/tui/chat_tui.cc +++ b/src/cli/tui/chat_tui.cc @@ -1,9 +1,13 @@ #include "cli/tui/chat_tui.h" #include + +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" #include "ftxui/component/captured_mouse.hpp" #include "ftxui/component/component.hpp" #include "ftxui/component/component_base.hpp" +#include "ftxui/component/event.hpp" #include "ftxui/component/screen_interactive.hpp" #include "ftxui/dom/elements.hpp" #include "ftxui/dom/table.hpp" @@ -27,18 +31,24 @@ void ChatTUI::SetRomContext(Rom* rom_context) { void ChatTUI::Run() { auto input = Input(&input_message_, "Enter your message..."); - auto button = Button("Send", [this] { OnSubmit(); }); - - auto layout = Container::Vertical({ - input, - button, + input = CatchEvent(input, [this](Event event) { + if (event == Event::Return) { + OnSubmit(); + return true; + } + return false; }); - auto renderer = Renderer(layout, [this] { - std::vector messages; - messages.reserve(agent_service_.GetHistory().size()); + auto button = Button("Send", [this] { OnSubmit(); }); + auto controls = Container::Horizontal({input, button}); + auto layout = Container::Vertical({controls}); + + auto renderer = Renderer(layout, [this, input, button] { + Elements message_blocks; const auto& history = agent_service_.GetHistory(); + message_blocks.reserve(history.size()); + for (const auto& msg : history) { Element header = text(msg.sender == agent::ChatMessage::Sender::kUser ? "You" @@ -71,15 +81,56 @@ void ChatTUI::Run() { body = paragraph(msg.message); } - messages.push_back(vbox({header, hbox({text(" "), body}), separator()})); + Elements block = {header, hbox({text(" "), body})}; + if (msg.metrics.has_value()) { + const auto& metrics = msg.metrics.value(); + block.push_back(text(absl::StrFormat( + " 📊 Turn %d — users:%d agents:%d tools:%d commands:%d proposals:%d elapsed %.2fs avg %.2fs", + metrics.turn_index, metrics.total_user_messages, + metrics.total_agent_messages, metrics.total_tool_calls, + metrics.total_commands, metrics.total_proposals, + metrics.total_elapsed_seconds, + metrics.average_latency_seconds)) | + color(Color::Cyan)); + } + block.push_back(separator()); + message_blocks.push_back(vbox(block)); } - return vbox({ - vbox(messages) | flex, - separator(), - hbox(text(" > "), text(input_message_)), - }) | - border; + if (message_blocks.empty()) { + message_blocks.push_back(text("No messages yet. Start chatting!") | dim); + } + + const auto metrics = agent_service_.GetMetrics(); + Element metrics_bar = text(absl::StrFormat( + "Turns:%d Users:%d Agents:%d Tools:%d Commands:%d Proposals:%d Elapsed:%.2fs avg %.2fs", + metrics.turn_index, metrics.total_user_messages, + metrics.total_agent_messages, metrics.total_tool_calls, + metrics.total_commands, metrics.total_proposals, + metrics.total_elapsed_seconds, metrics.average_latency_seconds)) | + color(Color::Cyan); + + Elements content{ + vbox(message_blocks) | flex | frame, + separator(), + }; + + if (last_error_.has_value()) { + content.push_back(text(absl::StrCat("⚠ ", *last_error_)) | + color(Color::Red)); + content.push_back(separator()); + } + + content.push_back(metrics_bar); + content.push_back(separator()); + content.push_back(hbox({ + text("You: ") | bold, + input->Render() | flex, + text(" "), + button->Render(), + })); + + return vbox(content) | border; }); screen_.Loop(renderer); @@ -90,7 +141,12 @@ void ChatTUI::OnSubmit() { return; } - (void)agent_service_.SendMessage(input_message_); + auto response = agent_service_.SendMessage(input_message_); + if (!response.ok()) { + last_error_ = response.status().message(); + } else { + last_error_.reset(); + } input_message_.clear(); } diff --git a/src/cli/tui/chat_tui.h b/src/cli/tui/chat_tui.h index 43daaa63..9ce5da60 100644 --- a/src/cli/tui/chat_tui.h +++ b/src/cli/tui/chat_tui.h @@ -1,6 +1,8 @@ #ifndef YAZE_SRC_CLI_TUI_CHAT_TUI_H_ #define YAZE_SRC_CLI_TUI_CHAT_TUI_H_ +#include + #include "ftxui/component/component.hpp" #include "ftxui/component/screen_interactive.hpp" #include "cli/service/agent/conversational_agent_service.h" @@ -19,13 +21,13 @@ class ChatTUI { void SetRomContext(Rom* rom_context); private: - void Render(); void OnSubmit(); ftxui::ScreenInteractive screen_ = ftxui::ScreenInteractive::Fullscreen(); std::string input_message_; agent::ConversationalAgentService agent_service_; Rom* rom_context_ = nullptr; + std::optional last_error_; }; } // namespace tui