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:
scawful
2025-10-04 12:00:51 -04:00
parent acada1bec5
commit 4b61b213c0
15 changed files with 844 additions and 68 deletions

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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") {

View File

@@ -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;

View File

@@ -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"
};
}

View File

@@ -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();
}

View File

@@ -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