From 42c64db90414c719df9c0566771a354c1be6c643 Mon Sep 17 00:00:00 2001 From: scawful Date: Fri, 3 Oct 2025 16:44:29 -0400 Subject: [PATCH] Add YAML support and enhance AI service context handling - Integrated yaml-cpp library into the project for YAML file parsing. - Updated ConversationalAgentService to set ROM context in AI services. - Extended AIService interface with SetRomContext method for context injection. - Implemented SetRomContext in GeminiAIService and OllamaAIService. - Enhanced PromptBuilder to load resource catalogues from YAML files. - Added functions to parse commands, tools, examples, and tile references from YAML. - Improved error handling for loading YAML files and added search paths for catalogues. - Updated CMake configuration to fetch yaml-cpp if not found. - Modified vcpkg.json to include yaml-cpp as a dependency. --- assets/agent/prompt_catalogue.yaml | 192 +++++ src/cli/agent.cmake | 1 + .../agent/conversational_agent_service.cc | 3 + src/cli/service/ai/ai_service.h | 7 + src/cli/service/ai/gemini_ai_service.cc | 9 +- src/cli/service/ai/gemini_ai_service.h | 1 + src/cli/service/ai/ollama_ai_service.cc | 10 +- src/cli/service/ai/ollama_ai_service.h | 2 + src/cli/service/ai/prompt_builder.cc | 677 ++++++++++++------ src/cli/service/ai/prompt_builder.h | 40 +- src/cli/z3ed.cmake | 34 + vcpkg.json | 3 +- 12 files changed, 746 insertions(+), 233 deletions(-) create mode 100644 assets/agent/prompt_catalogue.yaml diff --git a/assets/agent/prompt_catalogue.yaml b/assets/agent/prompt_catalogue.yaml new file mode 100644 index 00000000..f177cca3 --- /dev/null +++ b/assets/agent/prompt_catalogue.yaml @@ -0,0 +1,192 @@ +commands: + palette export: |- + Export palette data to JSON file + --group Palette group (overworld, dungeon, sprite) + --id Palette ID (0-based index) + --to Output JSON file path + palette import: |- + Import palette data from JSON file + --group Palette group (overworld, dungeon, sprite) + --id Palette ID (0-based index) + --from Input JSON file path + palette set-color: |- + Modify a color in palette JSON file + --file Palette JSON file to modify + --index Color index (0-15 per palette) + --color New color in hex (0xRRGGBB format) + overworld set-tile: |- + Place a tile in the overworld + --map Map ID (0-based) + --x X coordinate (0-63) + --y Y coordinate (0-63) + --tile Tile ID in hex (e.g., 0x02E for tree) + sprite set-position: |- + Move a sprite to a new position + --id Sprite ID + --x X coordinate + --y Y coordinate + dungeon set-room-tile: |- + Place a tile in a dungeon room + --room Room ID + --x X coordinate + --y Y coordinate + --tile Tile ID + rom validate: "Validate ROM integrity and structure" + +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." + arguments: + - name: type + description: "Resource category (dungeon, sprite, overworld, entrance, room, etc.)." + required: true + example: dungeon + - name: format + description: "Response format (json or table). Defaults to JSON if omitted." + 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." + arguments: + - name: room + description: "Room label or numeric ID (supports hex like 0x123)." + required: true + example: hyrule_castle_throne + - name: dungeon + description: "Optional dungeon ID when room names are ambiguous." + required: false + example: 0x00 + - name: format + description: "Response format (json or table). 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." + arguments: + - name: tile + description: "Tile16 ID to search for (accepts hex or decimal)." + required: true + example: 0x02E + - name: map + description: "Optional map ID filter (0=Light World, 1=Dark World, etc.)." + required: false + example: 0 + - name: format + description: "Response format (json or table). Defaults to JSON if omitted." + required: false + example: json + - name: overworld-describe-map + description: "Summarize metadata for an overworld map, including regions and labels." + usage_notes: "Use this before proposing edits to understand map properties and labels." + arguments: + - name: map + description: "Map ID or label to describe." + required: true + example: 0 + - name: format + description: "Response format (json or table). Defaults to JSON if omitted." + required: false + example: json + - name: overworld-list-warps + description: "List warp entrances and exits for overworld maps, including destinations." + usage_notes: "Helpful for navigation questions and verifying warp destinations." + arguments: + - name: map + description: "Optional map filter. Defaults to all maps when omitted." + required: false + example: 1 + - name: format + description: "Response format (json or table). Defaults to JSON if omitted." + required: false + example: json + +tile16_reference: + grass: 0x020 + dirt: 0x022 + tree: 0x02E + bush: 0x003 + rock: 0x004 + flower: 0x021 + sand: 0x023 + water_top: 0x14C + water_middle: 0x14D + water_bottom: 0x14E + deep_water: 0x14F + shallow_water: 0x150 + water_single: 0x038 + house_00: 0x0C0 + house_01: 0x0C1 + house_10: 0x0D0 + house_11: 0x0D1 + +examples: + - user_prompt: "Place a tree at position 10, 20 on the Light World map" + text_response: "Okay, I can place that tree for you. Here is the command:" + reasoning: "Single tile16 placement. Tree tile ID is 0x02E in vanilla ALTTP" + commands: + - "overworld set-tile --map 0 --x 10 --y 20 --tile 0x02E" + - user_prompt: "Create a 3x3 water pond at coordinates 15, 10" + text_response: "Creating a 3x3 pond requires nine `set-tile` commands. Here they are:" + reasoning: "Water areas use different edge tiles: 0x14C (top), 0x14D (middle), 0x14E (bottom)" + commands: + - "overworld set-tile --map 0 --x 15 --y 10 --tile 0x14C" + - "overworld set-tile --map 0 --x 16 --y 10 --tile 0x14D" + - "overworld set-tile --map 0 --x 17 --y 10 --tile 0x14C" + - "overworld set-tile --map 0 --x 15 --y 11 --tile 0x14D" + - "overworld set-tile --map 0 --x 16 --y 11 --tile 0x14D" + - "overworld set-tile --map 0 --x 17 --y 11 --tile 0x14D" + - "overworld set-tile --map 0 --x 15 --y 12 --tile 0x14E" + - "overworld set-tile --map 0 --x 16 --y 12 --tile 0x14E" + - "overworld set-tile --map 0 --x 17 --y 12 --tile 0x14E" + - user_prompt: "Add a dirt path from position 5,5 to 5,15" + text_response: "I will generate a `set-tile` command for each point along the path." + reasoning: "Linear paths are created by placing tiles sequentially. Dirt tile is 0x022" + commands: + - "overworld set-tile --map 0 --x 5 --y 5 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 6 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 7 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 8 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 9 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 10 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 11 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 12 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 13 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 14 --tile 0x022" + - "overworld set-tile --map 0 --x 5 --y 15 --tile 0x022" + - user_prompt: "Plant a row of trees horizontally at y=8 from x=20 to x=25" + text_response: "Here are the commands to plant that row of trees:" + reasoning: "Tree rows create natural barriers and visual boundaries" + commands: + - "overworld set-tile --map 0 --x 20 --y 8 --tile 0x02E" + - "overworld set-tile --map 0 --x 21 --y 8 --tile 0x02E" + - "overworld set-tile --map 0 --x 22 --y 8 --tile 0x02E" + - "overworld set-tile --map 0 --x 23 --y 8 --tile 0x02E" + - "overworld set-tile --map 0 --x 24 --y 8 --tile 0x02E" + - "overworld set-tile --map 0 --x 25 --y 8 --tile 0x02E" + - user_prompt: "Add 3 soldiers to the Eastern Palace entrance room" + text_response: "I've identified the dungeon and sprite IDs from your project's labels. Here are the commands:" + reasoning: "Dungeon ID 0x02 is Eastern Palace. Sprite 0x41 is soldier. Spread placement for balance" + commands: + - "dungeon add-sprite --dungeon 0x02 --room 0x00 --sprite 0x41 --x 5 --y 3" + - "dungeon add-sprite --dungeon 0x02 --room 0x00 --sprite 0x41 --x 10 --y 3" + - "dungeon add-sprite --dungeon 0x02 --room 0x00 --sprite 0x41 --x 7 --y 8" + - user_prompt: "Place a chest in the Hyrule Castle treasure room" + text_response: "Certainly. I will place a chest containing a small key in the center of the room." + reasoning: "Dungeon 0x00 is Hyrule Castle. Item 0x12 is a small key. Position centered in room" + commands: + - "dungeon add-chest --dungeon 0x00 --room 0x60 --x 7 --y 5 --item 0x12 --big false" + - user_prompt: "Check if my overworld changes are valid" + text_response: "Yes, I can validate the ROM for you." + reasoning: "Validation ensures ROM integrity after tile modifications" + commands: + - "rom validate" + - user_prompt: "What dungeons are in this project?" + text_response: "I can list the dungeons for you. Let me check the resource labels." + reasoning: "The user is asking a question. I need to use the `resource-list` tool to find the answer." + tool_calls: + - tool_name: resource-list + args: + type: dungeon diff --git a/src/cli/agent.cmake b/src/cli/agent.cmake index 3a91d99b..e3a77cfd 100644 --- a/src/cli/agent.cmake +++ b/src/cli/agent.cmake @@ -24,6 +24,7 @@ target_link_libraries(yaze_agent PUBLIC yaze_common ${ABSL_TARGETS} + yaml-cpp ) target_include_directories(yaze_agent diff --git a/src/cli/service/agent/conversational_agent_service.cc b/src/cli/service/agent/conversational_agent_service.cc index 4e002178..b46cc13c 100644 --- a/src/cli/service/agent/conversational_agent_service.cc +++ b/src/cli/service/agent/conversational_agent_service.cc @@ -152,6 +152,9 @@ ConversationalAgentService::ConversationalAgentService() { void ConversationalAgentService::SetRomContext(Rom* rom) { rom_context_ = rom; tool_dispatcher_.SetRomContext(rom_context_); + if (ai_service_) { + ai_service_->SetRomContext(rom_context_); + } } absl::StatusOr ConversationalAgentService::SendMessage( diff --git a/src/cli/service/ai/ai_service.h b/src/cli/service/ai/ai_service.h index c1f537a5..abd37cc7 100644 --- a/src/cli/service/ai/ai_service.h +++ b/src/cli/service/ai/ai_service.h @@ -9,6 +9,8 @@ #include "cli/service/ai/common.h" namespace yaze { +class Rom; + namespace cli { namespace agent { struct ChatMessage; @@ -18,6 +20,10 @@ class AIService { public: virtual ~AIService() = default; + // Provide the AI service with the active ROM so prompts can include + // project-specific context. + virtual void SetRomContext(Rom* rom) { (void)rom; } + // Generate a response from a single prompt. virtual absl::StatusOr GenerateResponse( const std::string& prompt) = 0; @@ -30,6 +36,7 @@ class AIService { // Mock implementation for testing class MockAIService : public AIService { public: + void SetRomContext(Rom* rom) override { (void)rom; } absl::StatusOr GenerateResponse( const std::string& prompt) override; absl::StatusOr GenerateResponse( diff --git a/src/cli/service/ai/gemini_ai_service.cc b/src/cli/service/ai/gemini_ai_service.cc index 0dd37494..adbb708c 100644 --- a/src/cli/service/ai/gemini_ai_service.cc +++ b/src/cli/service/ai/gemini_ai_service.cc @@ -21,7 +21,10 @@ namespace cli { GeminiAIService::GeminiAIService(const GeminiConfig& config) : config_(config) { // Load command documentation into prompt builder - prompt_builder_.LoadResourceCatalogue(""); // TODO: Pass actual yaml path when available + if (auto status = prompt_builder_.LoadResourceCatalogue(""); !status.ok()) { + std::cerr << "⚠️ Failed to load agent prompt catalogue: " + << status.message() << std::endl; + } if (config_.system_instruction.empty()) { // Use enhanced prompting by default @@ -39,6 +42,10 @@ std::string GeminiAIService::BuildSystemInstruction() { return prompt_builder_.BuildSystemInstruction(); } +void GeminiAIService::SetRomContext(Rom* rom) { + prompt_builder_.SetRom(rom); +} + absl::Status GeminiAIService::CheckAvailability() { #ifndef YAZE_WITH_JSON return absl::UnimplementedError( diff --git a/src/cli/service/ai/gemini_ai_service.h b/src/cli/service/ai/gemini_ai_service.h index b80107c1..19667c8b 100644 --- a/src/cli/service/ai/gemini_ai_service.h +++ b/src/cli/service/ai/gemini_ai_service.h @@ -27,6 +27,7 @@ struct GeminiConfig { class GeminiAIService : public AIService { public: explicit GeminiAIService(const GeminiConfig& config); + void SetRomContext(Rom* rom) override; // Primary interface absl::StatusOr GenerateResponse( diff --git a/src/cli/service/ai/ollama_ai_service.cc b/src/cli/service/ai/ollama_ai_service.cc index 7922b9c1..a166cb46 100644 --- a/src/cli/service/ai/ollama_ai_service.cc +++ b/src/cli/service/ai/ollama_ai_service.cc @@ -1,6 +1,7 @@ #include "cli/service/ai/ollama_ai_service.h" #include +#include #include "absl/strings/str_cat.h" #include "absl/strings/str_format.h" @@ -33,7 +34,10 @@ namespace cli { OllamaAIService::OllamaAIService(const OllamaConfig& config) : config_(config) { // Load command documentation into prompt builder - prompt_builder_.LoadResourceCatalogue(""); // TODO: Pass actual yaml path when available + if (auto status = prompt_builder_.LoadResourceCatalogue(""); !status.ok()) { + std::cerr << "⚠️ Failed to load agent prompt catalogue: " + << status.message() << std::endl; + } if (config_.system_prompt.empty()) { // Use enhanced prompting by default @@ -51,6 +55,10 @@ std::string OllamaAIService::BuildSystemPrompt() { return prompt_builder_.BuildSystemInstruction(); } +void OllamaAIService::SetRomContext(Rom* rom) { + prompt_builder_.SetRom(rom); +} + absl::Status OllamaAIService::CheckAvailability() { #if !YAZE_HAS_HTTPLIB || !YAZE_HAS_JSON return absl::UnimplementedError( diff --git a/src/cli/service/ai/ollama_ai_service.h b/src/cli/service/ai/ollama_ai_service.h index 5243de68..dc9793e2 100644 --- a/src/cli/service/ai/ollama_ai_service.h +++ b/src/cli/service/ai/ollama_ai_service.h @@ -25,6 +25,8 @@ struct OllamaConfig { class OllamaAIService : public AIService { public: explicit OllamaAIService(const OllamaConfig& config); + + void SetRomContext(Rom* rom) override; // Generate z3ed commands from natural language prompt absl::StatusOr GenerateResponse( diff --git a/src/cli/service/ai/prompt_builder.cc b/src/cli/service/ai/prompt_builder.cc index de7e2446..078e820e 100644 --- a/src/cli/service/ai/prompt_builder.cc +++ b/src/cli/service/ai/prompt_builder.cc @@ -1,190 +1,365 @@ #include "cli/service/ai/prompt_builder.h" #include "cli/service/agent/conversational_agent_service.h" +#include +#include #include +#include #include #include "absl/strings/str_cat.h" #include "absl/strings/str_join.h" +#include "nlohmann/json.hpp" +#include "yaml-cpp/yaml.h" namespace yaze { namespace cli { -PromptBuilder::PromptBuilder() { - LoadDefaultExamples(); +namespace { + +namespace fs = std::filesystem; + +nlohmann::json YamlToJson(const YAML::Node& node) { + if (!node) { + return nlohmann::json(); + } + + switch (node.Type()) { + case YAML::NodeType::Scalar: + return node.as(""); + case YAML::NodeType::Sequence: { + nlohmann::json array = nlohmann::json::array(); + for (const auto& item : node) { + array.push_back(YamlToJson(item)); + } + return array; + } + case YAML::NodeType::Map: { + nlohmann::json object = nlohmann::json::object(); + for (const auto& kv : node) { + object[kv.first.as()] = YamlToJson(kv.second); + } + return object; + } + default: + return nlohmann::json(); + } } -void PromptBuilder::LoadDefaultExamples() { - // ========================================================================== - // OVERWORLD TILE16 EDITING - Primary Focus - // ========================================================================== - - // Single tile placement - examples_.push_back({ - "Place a tree at position 10, 20 on the Light World map", - "Okay, I can place that tree for you. Here is the command:", - {"overworld set-tile --map 0 --x 10 --y 20 --tile 0x02E"}, - "Single tile16 placement. Tree tile ID is 0x02E in vanilla ALTTP"}); - - // Area/region editing - examples_.push_back({ - "Create a 3x3 water pond at coordinates 15, 10", - "Creating a 3x3 pond requires nine `set-tile` commands. Here they are:", - {"overworld set-tile --map 0 --x 15 --y 10 --tile 0x14C", - "overworld set-tile --map 0 --x 16 --y 10 --tile 0x14D", - "overworld set-tile --map 0 --x 17 --y 10 --tile 0x14C", - "overworld set-tile --map 0 --x 15 --y 11 --tile 0x14D", - "overworld set-tile --map 0 --x 16 --y 11 --tile 0x14D", - "overworld set-tile --map 0 --x 17 --y 11 --tile 0x14D", - "overworld set-tile --map 0 --x 15 --y 12 --tile 0x14E", - "overworld set-tile --map 0 --x 16 --y 12 --tile 0x14E", - "overworld set-tile --map 0 --x 17 --y 12 --tile 0x14E"}, - "Water areas use different edge tiles: 0x14C (top), 0x14D (middle), " - "0x14E (bottom)"}); - - // Path/line creation - examples_.push_back( - {"Add a dirt path from position 5,5 to 5,15", - "I will generate a `set-tile` command for each point along the path.", - {"overworld set-tile --map 0 --x 5 --y 5 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 6 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 7 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 8 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 9 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 10 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 11 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 12 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 13 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 14 --tile 0x022", - "overworld set-tile --map 0 --x 5 --y 15 --tile 0x022"}, - "Linear paths are created by placing tiles sequentially. Dirt tile is " - "0x022"}); +std::vector BuildCatalogueSearchPaths(const std::string& explicit_path) { + std::vector paths; + if (!explicit_path.empty()) { + paths.emplace_back(explicit_path); + } - // Forest/tree grouping - examples_.push_back( - {"Plant a row of trees horizontally at y=8 from x=20 to x=25", - "Here are the commands to plant that row of trees:", - {"overworld set-tile --map 0 --x 20 --y 8 --tile 0x02E", - "overworld set-tile --map 0 --x 21 --y 8 --tile 0x02E", - "overworld set-tile --map 0 --x 22 --y 8 --tile 0x02E", - "overworld set-tile --map 0 --x 23 --y 8 --tile 0x02E", - "overworld set-tile --map 0 --x 24 --y 8 --tile 0x02E", - "overworld set-tile --map 0 --x 25 --y 8 --tile 0x02E"}, - "Tree rows create natural barriers and visual boundaries"}); + if (const char* env_path = std::getenv("YAZE_AGENT_CATALOGUE")) { + if (*env_path != '\0') { + paths.emplace_back(env_path); + } + } - // ========================================================================== - // DUNGEON EDITING - Label-Aware Operations - // ========================================================================== + const std::vector defaults = { + "assets/agent/prompt_catalogue.yaml", + "../assets/agent/prompt_catalogue.yaml", + "../../assets/agent/prompt_catalogue.yaml", + "assets/z3ed/prompt_catalogue.yaml", + "../assets/z3ed/prompt_catalogue.yaml", + }; - // Sprite placement (label-aware) - examples_.push_back( - {"Add 3 soldiers to the Eastern Palace entrance room", - "I've identified the dungeon and sprite IDs from your project's " - "labels. Here are the commands:", - {"dungeon add-sprite --dungeon 0x02 --room 0x00 --sprite 0x41 --x 5 --y " - "3", - "dungeon add-sprite --dungeon 0x02 --room 0x00 --sprite 0x41 --x 10 " - "--y 3", - "dungeon add-sprite --dungeon 0x02 --room 0x00 --sprite 0x41 --x 7 --y " - "8"}, - "Dungeon ID 0x02 is Eastern Palace. Sprite 0x41 is soldier. Spread " - "placement for balance"}); + for (const auto& candidate : defaults) { + paths.emplace_back(candidate); + } - // Object placement - examples_.push_back( - {"Place a chest in the Hyrule Castle treasure room", - "Certainly. I will place a chest containing a small key in the center of " - "the room.", - {"dungeon add-chest --dungeon 0x00 --room 0x60 --x 7 --y 5 --item 0x12 " - "--big false"}, - "Dungeon 0x00 is Hyrule Castle. Item 0x12 is a small key. Position " - "centered in room"}); + return paths; +} - // ========================================================================== - // COMMON TILE16 REFERENCE (for AI knowledge) - // ========================================================================== - // Grass: 0x020 - // Dirt: 0x022 - // Tree: 0x02E - // Water (top): 0x14C - // Water (middle): 0x14D - // Water (bottom): 0x14E - // Bush: 0x003 - // Rock: 0x004 - // Flower: 0x021 - // Sand: 0x023 - // Deep Water: 0x14F - // Shallow Water: 0x150 - - // Validation example (still useful) - examples_.push_back( - {"Check if my overworld changes are valid", - "Yes, I can validate the ROM for you.", - {"rom validate"}, - "Validation ensures ROM integrity after tile modifications"}); +} // namespace - // ========================================================================== - // Q&A / Tool Use - // ========================================================================== - examples_.push_back( - {"What dungeons are in this project?", - "I can list the dungeons for you. Let me check the resource labels.", - {}, - "The user is asking a question. I need to use the `resource-list` tool " - "to find the answer.", - {{"resource-list", {{"type", "dungeon"}}}}}); +PromptBuilder::PromptBuilder() = default; + +void PromptBuilder::ClearCatalogData() { + command_docs_.clear(); + examples_.clear(); + tool_specs_.clear(); + tile_reference_.clear(); + catalogue_loaded_ = false; +} + +absl::StatusOr PromptBuilder::ResolveCataloguePath( + const std::string& yaml_path) const { + const auto search_paths = BuildCatalogueSearchPaths(yaml_path); + for (const auto& candidate : search_paths) { + fs::path resolved = candidate; + if (resolved.is_relative()) { + resolved = fs::absolute(resolved); + } + if (fs::exists(resolved)) { + return resolved.string(); + } + } + + return absl::NotFoundError( + absl::StrCat("Prompt catalogue not found. Checked paths: ", + absl::StrJoin(search_paths, ", ", + [](std::string* out, const fs::path& path) { + absl::StrAppend(out, path.string()); + }))); } absl::Status PromptBuilder::LoadResourceCatalogue(const std::string& yaml_path) { - // TODO: Parse z3ed-resources.yaml when available - // For now, use hardcoded command reference - - command_docs_["palette export"] = - "Export palette data to JSON file\n" - " --group Palette group (overworld, dungeon, sprite)\n" - " --id Palette ID (0-based index)\n" - " --to Output JSON file path"; - - command_docs_["palette import"] = - "Import palette data from JSON file\n" - " --group Palette group (overworld, dungeon, sprite)\n" - " --id Palette ID (0-based index)\n" - " --from Input JSON file path"; - - command_docs_["palette set-color"] = - "Modify a color in palette JSON file\n" - " --file Palette JSON file to modify\n" - " --index Color index (0-15 per palette)\n" - " --color New color in hex (0xRRGGBB format)"; - - command_docs_["overworld set-tile"] = - "Place a tile in the overworld\n" - " --map Map ID (0-based)\n" - " --x X coordinate (0-63)\n" - " --y Y coordinate (0-63)\n" - " --tile Tile ID in hex (e.g., 0x02E for tree)"; - - command_docs_["sprite set-position"] = - "Move a sprite to new position\n" - " --id Sprite ID\n" - " --x X coordinate\n" - " --y Y coordinate"; - - command_docs_["dungeon set-room-tile"] = - "Place a tile in dungeon room\n" - " --room Room ID\n" - " --x X coordinate\n" - " --y Y coordinate\n" - " --tile Tile ID"; - - command_docs_["rom validate"] = - "Validate ROM integrity and structure"; - + auto resolved_or = ResolveCataloguePath(yaml_path); + if (!resolved_or.ok()) { + ClearCatalogData(); + return resolved_or.status(); + } + + const std::string& resolved_path = resolved_or.value(); + + YAML::Node root; + try { + root = YAML::LoadFile(resolved_path); + } catch (const YAML::BadFile& e) { + ClearCatalogData(); + return absl::NotFoundError( + absl::StrCat("Unable to open prompt catalogue at ", resolved_path, + ": ", e.what())); + } catch (const YAML::ParserException& e) { + ClearCatalogData(); + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse prompt catalogue at ", resolved_path, + ": ", e.what())); + } + + nlohmann::json catalogue = YamlToJson(root); + ClearCatalogData(); + + if (catalogue.contains("commands")) { + if (auto status = ParseCommands(catalogue["commands"]); !status.ok()) { + return status; + } + } + + if (catalogue.contains("tools")) { + if (auto status = ParseTools(catalogue["tools"]); !status.ok()) { + return status; + } + } + + if (catalogue.contains("examples")) { + if (auto status = ParseExamples(catalogue["examples"]); !status.ok()) { + return status; + } + } + + if (catalogue.contains("tile16_reference")) { + ParseTileReference(catalogue["tile16_reference"]); + } + catalogue_loaded_ = true; return absl::OkStatus(); } -std::string PromptBuilder::BuildCommandReference() { +absl::Status PromptBuilder::ParseCommands(const nlohmann::json& commands) { + if (!commands.is_object()) { + return absl::InvalidArgumentError( + "commands section must be an object mapping command names to docs"); + } + + for (const auto& [name, value] : commands.items()) { + if (!value.is_string()) { + return absl::InvalidArgumentError( + absl::StrCat("Command entry for ", name, " must be a string")); + } + command_docs_[name] = value.get(); + } + + return absl::OkStatus(); +} + +absl::Status PromptBuilder::ParseTools(const nlohmann::json& tools) { + if (!tools.is_array()) { + return absl::InvalidArgumentError("tools section must be an array"); + } + + for (const auto& tool_json : tools) { + if (!tool_json.is_object()) { + return absl::InvalidArgumentError( + "Each tool entry must be an object with name/description"); + } + + ToolSpecification spec; + if (tool_json.contains("name") && tool_json["name"].is_string()) { + spec.name = tool_json["name"].get(); + } else { + return absl::InvalidArgumentError("Tool entry missing name"); + } + + if (tool_json.contains("description") && tool_json["description"].is_string()) { + spec.description = tool_json["description"].get(); + } + + if (tool_json.contains("usage_notes") && tool_json["usage_notes"].is_string()) { + spec.usage_notes = tool_json["usage_notes"].get(); + } + + if (tool_json.contains("arguments")) { + const auto& args = tool_json["arguments"]; + if (!args.is_array()) { + return absl::InvalidArgumentError( + absl::StrCat("Tool arguments for ", spec.name, " must be an array")); + } + for (const auto& arg_json : args) { + if (!arg_json.is_object()) { + return absl::InvalidArgumentError( + absl::StrCat("Argument entries for ", spec.name, + " must be objects")); + } + ToolArgument arg; + if (arg_json.contains("name") && arg_json["name"].is_string()) { + arg.name = arg_json["name"].get(); + } else { + return absl::InvalidArgumentError( + absl::StrCat("Argument entry for ", spec.name, + " is missing a name")); + } + if (arg_json.contains("description") && arg_json["description"].is_string()) { + arg.description = arg_json["description"].get(); + } + if (arg_json.contains("required")) { + if (!arg_json["required"].is_boolean()) { + return absl::InvalidArgumentError( + absl::StrCat("Argument 'required' flag for ", spec.name, + "::", arg.name, " must be boolean")); + } + arg.required = arg_json["required"].get(); + } + if (arg_json.contains("example") && arg_json["example"].is_string()) { + arg.example = arg_json["example"].get(); + } + spec.arguments.push_back(std::move(arg)); + } + } + + tool_specs_.push_back(std::move(spec)); + } + + return absl::OkStatus(); +} + +absl::Status PromptBuilder::ParseExamples(const nlohmann::json& examples) { + if (!examples.is_array()) { + return absl::InvalidArgumentError("examples section must be an array"); + } + + for (const auto& example_json : examples) { + if (!example_json.is_object()) { + return absl::InvalidArgumentError("Each example entry must be an object"); + } + + FewShotExample example; + if (example_json.contains("user_prompt") && + example_json["user_prompt"].is_string()) { + example.user_prompt = example_json["user_prompt"].get(); + } else { + return absl::InvalidArgumentError("Example missing user_prompt"); + } + + if (example_json.contains("text_response") && + example_json["text_response"].is_string()) { + example.text_response = example_json["text_response"].get(); + } + + if (example_json.contains("reasoning") && + example_json["reasoning"].is_string()) { + example.explanation = example_json["reasoning"].get(); + } + + if (example_json.contains("commands")) { + const auto& commands = example_json["commands"]; + if (!commands.is_array()) { + return absl::InvalidArgumentError( + absl::StrCat("Example commands for ", example.user_prompt, + " must be an array")); + } + for (const auto& cmd : commands) { + if (!cmd.is_string()) { + return absl::InvalidArgumentError( + absl::StrCat("Command entries for ", example.user_prompt, + " must be strings")); + } + example.expected_commands.push_back(cmd.get()); + } + } + + if (example_json.contains("tool_calls")) { + const auto& calls = example_json["tool_calls"]; + if (!calls.is_array()) { + return absl::InvalidArgumentError( + absl::StrCat("Tool calls for ", example.user_prompt, + " must be an array")); + } + for (const auto& call_json : calls) { + if (!call_json.is_object()) { + return absl::InvalidArgumentError( + absl::StrCat("Tool call entries for ", example.user_prompt, + " must be objects")); + } + ToolCall call; + if (call_json.contains("tool_name") && call_json["tool_name"].is_string()) { + call.tool_name = call_json["tool_name"].get(); + } else { + return absl::InvalidArgumentError( + absl::StrCat("Tool call missing tool_name in example: ", + example.user_prompt)); + } + if (call_json.contains("args")) { + const auto& args = call_json["args"]; + if (!args.is_object()) { + return absl::InvalidArgumentError( + absl::StrCat("Tool call args for ", example.user_prompt, + " must be an object")); + } + for (const auto& [key, value] : args.items()) { + if (!value.is_string()) { + return absl::InvalidArgumentError( + absl::StrCat("Tool call arg value for ", example.user_prompt, + " must be a string")); + } + call.args[key] = value.get(); + } + } + example.tool_calls.push_back(std::move(call)); + } + } + + example.explanation = example_json.value("explanation", example.explanation); + examples_.push_back(std::move(example)); + } + + return absl::OkStatus(); +} + +void PromptBuilder::ParseTileReference(const nlohmann::json& tile_reference) { + if (!tile_reference.is_object()) { + return; + } + + for (const auto& [alias, value] : tile_reference.items()) { + if (value.is_string()) { + tile_reference_[alias] = value.get(); + } + } +} + +std::string PromptBuilder::LookupTileId(const std::string& alias) const { + auto it = tile_reference_.find(alias); + if (it != tile_reference_.end()) { + return it->second; + } + return ""; +} + +std::string PromptBuilder::BuildCommandReference() const { std::ostringstream oss; oss << "# Available z3ed Commands\n\n"; @@ -197,82 +372,131 @@ std::string PromptBuilder::BuildCommandReference() { return oss.str(); } -std::string PromptBuilder::BuildFewShotExamplesSection() { - std::ostringstream oss; - - oss << "# Example Command Sequences\n\n"; - oss << "Here are proven examples of how to accomplish common tasks:\n\n"; - - for (const auto& example : examples_) { - oss << "**User Request:** \"" << example.user_prompt << "\"\n"; - oss << "**Commands:**\n"; - oss << "```json\n{"; - oss << " \"text_response\": \"" << example.text_response << "\",\n"; - oss << " \"tool_calls\": ["; - std::vector tool_calls; - for (const auto& call : example.tool_calls) { - std::vector args; - for (const auto& [key, value] : call.args) { - args.push_back("\"" + key + "\": \"" + value + "\""); - } - tool_calls.push_back("{\"tool_name\": \"" + call.tool_name + - "\", \"args\": {" + absl::StrJoin(args, ", ") + "}}"); - } - oss << absl::StrJoin(tool_calls, ", "); - oss << "],\n"; - oss << " \"commands\": ["; - - std::vector quoted_cmds; - for (const auto& cmd : example.expected_commands) { - quoted_cmds.push_back("\"" + cmd + "\""); - } - oss << absl::StrJoin(quoted_cmds, ", "); - - oss << "],\n"; - oss << " \"reasoning\": \"" << example.explanation << "\"\n"; - oss << "}\n```\n\n"; +std::string PromptBuilder::BuildToolReference() const { + if (tool_specs_.empty()) { + return ""; } - + + std::ostringstream oss; + oss << "# Available Agent Tools\n\n"; + + for (const auto& spec : tool_specs_) { + oss << "## " << spec.name << "\n"; + if (!spec.description.empty()) { + oss << spec.description << "\n\n"; + } + + if (!spec.arguments.empty()) { + oss << "| Argument | Required | Description | Example |\n"; + oss << "| --- | --- | --- | --- |\n"; + for (const auto& arg : spec.arguments) { + oss << "| `" << arg.name << "` | " << (arg.required ? "yes" : "no") + << " | " << arg.description << " | " + << (arg.example.empty() ? "" : "`" + arg.example + "`") + << " |\n"; + } + oss << "\n"; + } + + if (!spec.usage_notes.empty()) { + oss << "_Usage:_ " << spec.usage_notes << "\n\n"; + } + } + return oss.str(); } -std::string PromptBuilder::BuildConstraintsSection() { - return R"( +std::string PromptBuilder::BuildFewShotExamplesSection() const { + std::ostringstream oss; + + oss << "# Example Command Sequences\n\n"; + oss << "Here are proven examples of how to accomplish common tasks:\n\n"; + + for (const auto& example : examples_) { + oss << "**User Request:** \"" << example.user_prompt << "\"\n"; + oss << "**Structured Response:**\n"; + + nlohmann::json example_json = nlohmann::json::object(); + if (!example.text_response.empty()) { + example_json["text_response"] = example.text_response; + } + if (!example.expected_commands.empty()) { + example_json["commands"] = example.expected_commands; + } + if (!example.explanation.empty()) { + example_json["reasoning"] = example.explanation; + } + if (!example.tool_calls.empty()) { + nlohmann::json calls = nlohmann::json::array(); + for (const auto& call : example.tool_calls) { + nlohmann::json call_json; + call_json["tool_name"] = call.tool_name; + nlohmann::json args = nlohmann::json::object(); + for (const auto& [key, value] : call.args) { + args[key] = value; + } + call_json["args"] = std::move(args); + calls.push_back(std::move(call_json)); + } + example_json["tool_calls"] = std::move(calls); + } + + oss << "```json\n" << example_json.dump(2) << "\n```\n\n"; + } + + return oss.str(); +} + +std::string PromptBuilder::BuildConstraintsSection() const { + std::ostringstream oss; + oss << R"( # Critical Constraints 1. **Output Format:** You MUST respond with ONLY a JSON object with the following structure: - { - "text_response": "Your natural language reply to the user.", - "tool_calls": [{ "tool_name": "tool_name", "args": { "arg1": "value1" } }], - "commands": ["command1", "command2"], - "reasoning": "Your thought process." - } - - `text_response` is for conversational replies. - - `tool_calls` is for asking questions about the ROM. Use the available tools. - - `commands` is for generating commands to modify the ROM. - - All fields are optional. + { + "text_response": "Your natural language reply to the user.", + "tool_calls": [{ "tool_name": "tool_name", "args": { "arg1": "value1" } }], + "commands": ["command1", "command2"], + "reasoning": "Your thought process." + } + - `text_response` is for conversational replies. + - `tool_calls` is for asking questions about the ROM. Use the available tools. + - `commands` is for generating commands to modify the ROM. + - All fields are optional. 2. **Command Syntax:** Follow the exact syntax shown in examples - - Use correct flag names (--group, --id, --to, --from, etc.) - - Use hex format for colors (0xRRGGBB) and tile IDs (0xNNN) - - Coordinates are 0-based indices + - Use correct flag names (--group, --id, --to, --from, etc.) + - Use hex format for colors (0xRRGGBB) and tile IDs (0xNNN) + - Coordinates are 0-based indices 3. **Common Patterns:** - - Palette modifications: export → set-color → import - - Multiple tile placement: multiple overworld set-tile commands - - Validation: single rom validate command + - Palette modifications: export → set-color → import + - Multiple tile placement: multiple overworld set-tile commands + - Validation: single rom validate command -4. **Tile IDs Reference (ALTTP):** - - Tree: 0x02E - - House (2x2): 0x0C0, 0x0C1, 0x0D0, 0x0D1 - - Water: 0x038 - - Grass: 0x000 - -5. **Error Prevention:** - - Always export before modifying palettes - - Use temporary file names (temp_*.json) for intermediate files - - Validate coordinates are within bounds +4. **Error Prevention:** + - Always export before modifying palettes + - Use temporary file names (temp_*.json) for intermediate files + - Validate coordinates are within bounds )"; + + if (!tile_reference_.empty()) { + oss << "\n" << BuildTileReferenceSection(); + } + + return oss.str(); +} + +std::string PromptBuilder::BuildTileReferenceSection() const { + std::ostringstream oss; + oss << "# Tile16 Reference (ALTTP)\n\n"; + + for (const auto& [alias, value] : tile_reference_) { + oss << "- " << alias << ": " << value << "\n"; + } + + oss << "\n"; + return oss.str(); } std::string PromptBuilder::BuildContextSection(const RomContext& context) { @@ -322,7 +546,12 @@ std::string PromptBuilder::BuildSystemInstruction() { << "the user's request.\n\n"; if (catalogue_loaded_) { - oss << BuildCommandReference(); + if (!command_docs_.empty()) { + oss << BuildCommandReference(); + } + if (!tool_specs_.empty()) { + oss << BuildToolReference(); + } } oss << BuildConstraintsSection(); diff --git a/src/cli/service/ai/prompt_builder.h b/src/cli/service/ai/prompt_builder.h index 3bf5cf74..cf60de0e 100644 --- a/src/cli/service/ai/prompt_builder.h +++ b/src/cli/service/ai/prompt_builder.h @@ -1,11 +1,13 @@ #ifndef YAZE_CLI_SERVICE_PROMPT_BUILDER_H_ #define YAZE_CLI_SERVICE_PROMPT_BUILDER_H_ +#include #include #include -#include +#include "absl/status/status.h" #include "absl/status/statusor.h" +#include "nlohmann/json_fwd.hpp" #include "cli/service/ai/common.h" #include "cli/service/resources/resource_context_builder.h" #include "app/rom.h" @@ -26,6 +28,20 @@ struct FewShotExample { std::vector tool_calls; }; +struct ToolArgument { + std::string name; + std::string description; + bool required = false; + std::string example; +}; + +struct ToolSpecification { + std::string name; + std::string description; + std::vector arguments; + std::string usage_notes; +}; + // ROM context information to inject into prompts struct RomContext { std::string rom_path; @@ -65,22 +81,34 @@ class PromptBuilder { // Get few-shot examples for specific category std::vector GetExamplesForCategory( const std::string& category); + std::string LookupTileId(const std::string& alias) const; + const std::map& tile_reference() const { + return tile_reference_; + } // Set verbosity level (0=minimal, 1=standard, 2=verbose) void SetVerbosity(int level) { verbosity_ = level; } private: - std::string BuildCommandReference(); - std::string BuildFewShotExamplesSection(); + std::string BuildCommandReference() const; + std::string BuildFewShotExamplesSection() const; + std::string BuildToolReference() const; std::string BuildContextSection(const RomContext& context); - std::string BuildConstraintsSection(); - - void LoadDefaultExamples(); + std::string BuildConstraintsSection() const; + std::string BuildTileReferenceSection() const; + absl::StatusOr ResolveCataloguePath(const std::string& yaml_path) const; + void ClearCatalogData(); + absl::Status ParseCommands(const nlohmann::json& commands); + absl::Status ParseTools(const nlohmann::json& tools); + absl::Status ParseExamples(const nlohmann::json& examples); + void ParseTileReference(const nlohmann::json& tile_reference); Rom* rom_ = nullptr; std::unique_ptr resource_context_builder_; std::map command_docs_; // Command name -> docs std::vector examples_; + std::vector tool_specs_; + std::map tile_reference_; int verbosity_ = 1; bool catalogue_loaded_ = false; }; diff --git a/src/cli/z3ed.cmake b/src/cli/z3ed.cmake index 56829de8..67b6e344 100644 --- a/src/cli/z3ed.cmake +++ b/src/cli/z3ed.cmake @@ -11,6 +11,39 @@ if(NOT ftxui_POPULATED) add_subdirectory(${ftxui_SOURCE_DIR} ${ftxui_BINARY_DIR} EXCLUDE_FROM_ALL) endif() +find_package(yaml-cpp CONFIG) +if(NOT yaml-cpp_FOUND) + message(STATUS "yaml-cpp not found via package config, fetching from source") + FetchContent_Declare(yaml-cpp + GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git + GIT_TAG 0.8.0 + ) + FetchContent_GetProperties(yaml-cpp) + if(NOT yaml-cpp_POPULATED) + FetchContent_Populate(yaml-cpp) + + # Ensure compatibility with newer CMake versions by adjusting minimum requirement + set(_yaml_cpp_cmakelists "${yaml-cpp_SOURCE_DIR}/CMakeLists.txt") + if(EXISTS "${_yaml_cpp_cmakelists}") + file(READ "${_yaml_cpp_cmakelists}" _yaml_cpp_cmake_contents) + if(_yaml_cpp_cmake_contents MATCHES "cmake_minimum_required\\(VERSION 3\\.4\\)") + string(REPLACE "cmake_minimum_required(VERSION 3.4)" + "cmake_minimum_required(VERSION 3.5)" + _yaml_cpp_cmake_contents "${_yaml_cpp_cmake_contents}") + file(WRITE "${_yaml_cpp_cmakelists}" "${_yaml_cpp_cmake_contents}") + endif() + endif() + + set(YAML_CPP_BUILD_TESTS OFF CACHE BOOL "Disable yaml-cpp tests" FORCE) + set(YAML_CPP_BUILD_CONTRIB OFF CACHE BOOL "Disable yaml-cpp contrib" FORCE) + set(YAML_CPP_BUILD_TOOLS OFF CACHE BOOL "Disable yaml-cpp tools" FORCE) + set(YAML_CPP_INSTALL OFF CACHE BOOL "Disable yaml-cpp install" FORCE) + set(YAML_CPP_FORMAT_SOURCE OFF CACHE BOOL "Disable yaml-cpp format target" FORCE) + + add_subdirectory(${yaml-cpp_SOURCE_DIR} ${yaml-cpp_BINARY_DIR} EXCLUDE_FROM_ALL) + endif() +endif() + # Platform-specific file dialog sources if(APPLE) set(FILE_DIALOG_SRC @@ -128,6 +161,7 @@ target_link_libraries( z3ed PUBLIC asar-static yaze_agent + yaml-cpp ftxui::component ftxui::screen ftxui::dom diff --git a/vcpkg.json b/vcpkg.json index f387705e..e7a5f189 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -7,7 +7,8 @@ "name": "sdl2", "platform": "!uwp", "features": ["vulkan"] - } + }, + "yaml-cpp" ], "builtin-baseline": "4bee3f5aae7aefbc129ca81c33d6a062b02fcf3b", "overrides": [],