feat: Add resource search and dungeon room description commands
- Implemented `resource-search` command to allow fuzzy searching of resource labels. - Added `dungeon-describe-room` command to summarize metadata for a specified dungeon room. - Enhanced `agent` command handler to support new commands and updated usage documentation. - Introduced read-only accessors for room metadata in the Room class. - Updated AI service to recognize and handle new commands for resource searching and room description. - Improved metrics tracking for user interactions, including command execution and response times. - Enhanced TUI to display command metrics and session summaries.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<std::string>& 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);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,15 @@ absl::Status HandleDescribeCommand(const std::vector<std::string>& arg_vec);
|
||||
absl::Status HandleResourceListCommand(
|
||||
const std::vector<std::string>& arg_vec,
|
||||
Rom* rom_context = nullptr);
|
||||
absl::Status HandleResourceSearchCommand(
|
||||
const std::vector<std::string>& arg_vec,
|
||||
Rom* rom_context = nullptr);
|
||||
absl::Status HandleDungeonListSpritesCommand(
|
||||
const std::vector<std::string>& arg_vec,
|
||||
Rom* rom_context = nullptr);
|
||||
absl::Status HandleDungeonDescribeRoomCommand(
|
||||
const std::vector<std::string>& arg_vec,
|
||||
Rom* rom_context = nullptr);
|
||||
absl::Status HandleOverworldFindTileCommand(
|
||||
const std::vector<std::string>& arg_vec,
|
||||
Rom* rom_context = nullptr);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#include "cli/handlers/agent/commands.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string>& 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 <text> [--type <category>] [--format <json|text>]");
|
||||
}
|
||||
|
||||
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<std::string> known_categories = {
|
||||
"overworld", "dungeon", "entrance", "room",
|
||||
"sprite", "palette", "item", "tile16"};
|
||||
|
||||
std::vector<std::string> 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<SearchResult> 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<std::string>& 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<std::string>& arg_vec, Rom* rom_context) {
|
||||
std::string room_id_str;
|
||||
std::string format = "json";
|
||||
std::optional<std::string> 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 <hex> [--format <json|text>]");
|
||||
}
|
||||
|
||||
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<size_t>(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<size_t>(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<size_t>(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<size_t>(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<int>(std::min<size_t>(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<size_t>(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<std::string>& arg_vec, Rom* rom_context) {
|
||||
std::optional<std::string> tile_value;
|
||||
|
||||
@@ -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<ChatMessage::TableData> BuildTableData(const nlohmann::json& data)
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool IsExecutableCommand(absl::string_view command) {
|
||||
return !command.empty() && command.front() != '#';
|
||||
}
|
||||
|
||||
int CountExecutableCommands(const std::vector<std::string>& 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<double>(metrics_.turns_completed)
|
||||
: 0.0;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
ChatMessage::SessionMetrics ConversationalAgentService::GetMetrics() const {
|
||||
return BuildMetricsSnapshot();
|
||||
}
|
||||
|
||||
absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
|
||||
@@ -186,10 +234,13 @@ absl::StatusOr<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> json_pretty;
|
||||
std::optional<TableData> 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<SessionMetrics> 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<ChatMessage> history_;
|
||||
std::unique_ptr<AIService> ai_service_;
|
||||
ToolDispatcher tool_dispatcher_;
|
||||
Rom* rom_context_ = nullptr;
|
||||
AgentConfig config_;
|
||||
InternalMetrics metrics_;
|
||||
};
|
||||
|
||||
} // namespace agent
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -36,8 +36,12 @@ absl::StatusOr<std::string> 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") {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
#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<AgentResponse> MockAIService::GenerateResponse(
|
||||
@@ -96,6 +129,20 @@ absl::StatusOr<AgentResponse> 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<AgentResponse> 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;
|
||||
|
||||
@@ -123,11 +123,13 @@ void GeminiAIService::EnableFunctionCalling(bool enable) {
|
||||
|
||||
std::vector<std::string> 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"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
#include "cli/tui/chat_tui.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#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<Element> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#ifndef YAZE_SRC_CLI_TUI_CHAT_TUI_H_
|
||||
#define YAZE_SRC_CLI_TUI_CHAT_TUI_H_
|
||||
|
||||
#include <optional>
|
||||
|
||||
#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<std::string> last_error_;
|
||||
};
|
||||
|
||||
} // namespace tui
|
||||
|
||||
Reference in New Issue
Block a user