#include "cli/service/agent/conversational_agent_service.h" #include #include #include #include #include #include #include #include "absl/flags/declare.h" #include "absl/flags/flag.h" #include "absl/status/status.h" #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" #include "cli/util/terminal_colors.h" #include "nlohmann/json.hpp" ABSL_DECLARE_FLAG(std::string, ai_provider); namespace yaze { namespace cli { namespace agent { namespace { std::string TrimWhitespace(const std::string& input) { auto begin = std::find_if_not(input.begin(), input.end(), [](unsigned char c) { return std::isspace(c); }); auto end = std::find_if_not(input.rbegin(), input.rend(), [](unsigned char c) { return std::isspace(c); }) .base(); if (begin >= end) { return ""; } return std::string(begin, end); } std::string JsonValueToString(const nlohmann::json& value) { if (value.is_string()) { return value.get(); } if (value.is_boolean()) { return value.get() ? "true" : "false"; } if (value.is_number()) { return value.dump(); } if (value.is_null()) { return "null"; } return value.dump(); } std::set CollectObjectKeys(const nlohmann::json& array) { std::set keys; for (const auto& item : array) { if (!item.is_object()) { continue; } for (const auto& [key, _] : item.items()) { keys.insert(key); } } return keys; } std::optional BuildTableData(const nlohmann::json& data) { using TableData = ChatMessage::TableData; if (data.is_object()) { TableData table; table.headers = {"Key", "Value"}; table.rows.reserve(data.size()); for (const auto& [key, value] : data.items()) { table.rows.push_back({key, JsonValueToString(value)}); } return table; } if (data.is_array()) { TableData table; if (data.empty()) { table.headers = {"Value"}; return table; } const bool all_objects = std::all_of(data.begin(), data.end(), [](const nlohmann::json& item) { return item.is_object(); }); if (all_objects) { auto keys = CollectObjectKeys(data); if (keys.empty()) { table.headers = {"Value"}; for (const auto& item : data) { table.rows.push_back({JsonValueToString(item)}); } return table; } table.headers.assign(keys.begin(), keys.end()); table.rows.reserve(data.size()); for (const auto& item : data) { std::vector row; row.reserve(table.headers.size()); for (const auto& key : table.headers) { if (item.contains(key)) { row.push_back(JsonValueToString(item.at(key))); } else { row.emplace_back("-"); } } table.rows.push_back(std::move(row)); } return table; } table.headers = {"Value"}; table.rows.reserve(data.size()); for (const auto& item : data) { table.rows.push_back({JsonValueToString(item)}); } return table; } return std::nullopt; } bool IsExecutableCommand(absl::string_view command) { return !command.empty() && command.front() != '#'; } int CountExecutableCommands(const std::vector& 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; message.message = content; message.timestamp = absl::Now(); if (sender == ChatMessage::Sender::kAgent) { const std::string trimmed = TrimWhitespace(content); if (!trimmed.empty() && (trimmed.front() == '{' || trimmed.front() == '[')) { try { nlohmann::json parsed = nlohmann::json::parse(trimmed); message.table_data = BuildTableData(parsed); message.json_pretty = parsed.dump(2); } catch (const nlohmann::json::parse_error&) { // Ignore parse errors, fall back to raw text. } } } return message; } } // namespace ConversationalAgentService::ConversationalAgentService() { ai_service_ = CreateAIService(); } ConversationalAgentService::ConversationalAgentService(const AgentConfig& config) : config_(config) { ai_service_ = CreateAIService(); } void ConversationalAgentService::SetRomContext(Rom* rom) { rom_context_ = rom; tool_dispatcher_.SetRomContext(rom_context_); if (ai_service_) { ai_service_->SetRomContext(rom_context_); } } 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(metrics_.turns_completed) : 0.0; return snapshot; } ChatMessage::SessionMetrics ConversationalAgentService::GetMetrics() const { return BuildMetricsSnapshot(); } absl::StatusOr ConversationalAgentService::SendMessage( const std::string& message) { if (message.empty() && history_.empty()) { return absl::InvalidArgumentError( "Conversation must start with a non-empty message."); } 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)")); util::PrintInfo(absl::StrCat("History size: ", history_.size(), " messages")); } for (int iteration = 0; iteration < max_iterations; ++iteration) { if (config_.verbose) { util::PrintSeparator(); std::cout << util::colors::kCyan << "Iteration " << (iteration + 1) << "/" << max_iterations << util::colors::kReset << std::endl; } // Show loading indicator while waiting for AI response util::LoadingIndicator loader( waiting_for_text_response ? "Generating final response..." : "Thinking...", !config_.verbose); // Hide spinner in verbose mode loader.Start(); auto response_or = ai_service_->GenerateResponse(history_); loader.Stop(); if (!response_or.ok()) { util::PrintError(absl::StrCat( "Failed to get AI response: ", response_or.status().message())); return absl::InternalError(absl::StrCat( "Failed to get AI response: ", response_or.status().message())); } const auto& agent_response = response_or.value(); if (config_.verbose) { util::PrintInfo("Received agent response:"); std::cout << util::colors::kDim << " - Tool calls: " << agent_response.tool_calls.size() << util::colors::kReset << std::endl; std::cout << util::colors::kDim << " - Commands: " << agent_response.commands.size() << util::colors::kReset << std::endl; std::cout << util::colors::kDim << " - Text response: " << (agent_response.text_response.empty() ? "empty" : "present") << util::colors::kReset << std::endl; if (!agent_response.reasoning.empty() && config_.show_reasoning) { std::cout << util::colors::kYellow << " 💭 Reasoning: " << util::colors::kDim << agent_response.reasoning << util::colors::kReset << std::endl; } } if (!agent_response.tool_calls.empty()) { // Check if we were waiting for a text response but got more tool calls instead if (waiting_for_text_response) { util::PrintWarning( absl::StrCat("LLM called tools again instead of providing final response (Iteration: ", iteration + 1, "/", max_iterations, ")")); } bool executed_tool = false; for (const auto& tool_call : agent_response.tool_calls) { // Format tool arguments for display std::vector arg_parts; for (const auto& [key, value] : tool_call.args) { arg_parts.push_back(absl::StrCat(key, "=", value)); } std::string args_str = absl::StrJoin(arg_parts, ", "); util::PrintToolCall(tool_call.tool_name, args_str); auto tool_result_or = tool_dispatcher_.Dispatch(tool_call); if (!tool_result_or.ok()) { util::PrintError(absl::StrCat( "Tool execution failed: ", tool_result_or.status().message())); return absl::InternalError(absl::StrCat( "Tool execution failed: ", tool_result_or.status().message())); } 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):" << util::colors::kReset << std::endl; std::string preview = tool_output.substr(0, std::min(size_t(200), tool_output.size())); if (tool_output.size() > 200) preview += "..."; std::cout << util::colors::kDim << preview << util::colors::kReset << std::endl; } // Add tool result with a clear marker for the LLM // Format as plain text to avoid confusing the LLM with nested JSON std::string marked_output = absl::StrCat( "[TOOL RESULT for ", tool_call.tool_name, "]\n", "The tool returned the following data:\n", tool_output, "\n\n", "Please provide a text_response field in your JSON to summarize this information for the user."); auto tool_result_msg = CreateMessage(ChatMessage::Sender::kUser, marked_output); tool_result_msg.is_internal = true; // Don't show this to the human user history_.push_back(tool_result_msg); } executed_tool = true; } if (executed_tool) { // Now we're waiting for the LLM to provide a text response waiting_for_text_response = true; // Re-query the AI with updated context. continue; } } // Check if we received a text response after tool execution if (waiting_for_text_response && agent_response.text_response.empty() && agent_response.commands.empty()) { util::PrintWarning( absl::StrCat("LLM did not provide text_response after receiving tool results (Iteration: ", iteration + 1, "/", max_iterations, ")")); // Continue to give it another chance continue; } std::optional proposal_result; absl::Status proposal_status = absl::OkStatus(); bool attempted_proposal = false; if (!agent_response.commands.empty()) { attempted_proposal = true; if (rom_context_ == nullptr) { proposal_status = absl::FailedPreconditionError( "No ROM context available for proposal creation"); util::PrintWarning( "Cannot create proposal because no ROM context is active."); } else if (!rom_context_->is_loaded()) { proposal_status = absl::FailedPreconditionError( "ROM context is not loaded"); util::PrintWarning( "Cannot create proposal because the ROM context is not loaded."); } else { ProposalCreationRequest request; request.prompt = message; request.response = &agent_response; request.rom = rom_context_; request.sandbox_label = "agent-chat"; request.ai_provider = absl::GetFlag(FLAGS_ai_provider); auto creation_or = CreateProposalFromAgentResponse(request); if (!creation_or.ok()) { proposal_status = creation_or.status(); util::PrintError(absl::StrCat( "Failed to create proposal: ", proposal_status.message())); } else { proposal_result = std::move(creation_or.value()); if (config_.verbose) { util::PrintSuccess(absl::StrCat( "Created proposal ", proposal_result->metadata.id, " with ", proposal_result->change_count, " change(s).")); } } } } std::string response_text = agent_response.text_response; if (!agent_response.reasoning.empty()) { if (!response_text.empty()) { response_text.append("\n\n"); } 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"); } 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; if (!response_text.empty()) { response_text.append("\n\n"); } response_text.append(absl::StrFormat( "✅ Proposal %s ready with %d change%s (%d command%s).\n" "Review it in the Proposal drawer or run `z3ed agent diff --proposal-id %s`.\n" "Sandbox ROM: %s\nProposal JSON: %s", metadata.id, proposal_result->change_count, proposal_result->change_count == 1 ? "" : "s", proposal_result->executed_commands, 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"); } response_text.append(absl::StrCat( "⚠️ Failed to prepare a proposal automatically: ", proposal_status.message())); } ChatMessage chat_response = CreateMessage(ChatMessage::Sender::kAgent, response_text); if (proposal_result.has_value()) { ChatMessage::ProposalSummary summary; summary.id = proposal_result->metadata.id; summary.change_count = proposal_result->change_count; summary.executed_commands = proposal_result->executed_commands; summary.sandbox_rom_path = proposal_result->metadata.sandbox_rom_path; summary.proposal_json_path = proposal_result->proposal_json_path; chat_response.proposal = summary; } ++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; } return absl::InternalError( "Agent did not produce a response after executing tools."); } const std::vector& ConversationalAgentService::GetHistory() const { return history_; } void ConversationalAgentService::ReplaceHistory( std::vector history) { history_ = std::move(history); TrimHistoryIfNeeded(); RebuildMetricsFromHistory(); } void ConversationalAgentService::RebuildMetricsFromHistory() { metrics_ = InternalMetrics{}; ChatMessage::SessionMetrics snapshot{}; bool has_snapshot = false; for (const auto& message : history_) { if (message.sender == ChatMessage::Sender::kUser) { ++metrics_.user_messages; } else if (message.sender == ChatMessage::Sender::kAgent) { ++metrics_.agent_messages; ++metrics_.turns_completed; } if (message.proposal.has_value()) { ++metrics_.proposals_created; } if (message.metrics.has_value()) { snapshot = *message.metrics; has_snapshot = true; } } if (has_snapshot) { metrics_.user_messages = snapshot.total_user_messages; metrics_.agent_messages = snapshot.total_agent_messages; metrics_.tool_calls = snapshot.total_tool_calls; metrics_.commands_generated = snapshot.total_commands; metrics_.proposals_created = snapshot.total_proposals; metrics_.turns_completed = snapshot.turn_index; metrics_.total_latency = absl::Seconds(snapshot.total_elapsed_seconds); } } } // namespace agent } // namespace cli } // namespace yaze