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

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