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:
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user