yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
conversational_agent_service.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cctype>
5#include <iostream>
6#include <optional>
7#include <set>
8#include <string>
9#include <vector>
10
11#include "absl/flags/declare.h"
12#include "absl/flags/flag.h"
13#include "absl/status/status.h"
14#include "absl/strings/str_cat.h"
15#include "absl/strings/str_format.h"
16#include "absl/strings/str_join.h"
17#include "absl/strings/string_view.h"
18#include "absl/time/clock.h"
19#include "absl/time/time.h"
20#include "app/rom.h"
24#include "nlohmann/json.hpp"
25
26ABSL_DECLARE_FLAG(std::string, ai_provider);
27
28namespace yaze {
29namespace cli {
30namespace agent {
31
32namespace {
33
34std::string TrimWhitespace(const std::string& input) {
35 auto begin = std::find_if_not(input.begin(), input.end(),
36 [](unsigned char c) { return std::isspace(c); });
37 auto end = std::find_if_not(input.rbegin(), input.rend(),
38 [](unsigned char c) { return std::isspace(c); })
39 .base();
40 if (begin >= end) {
41 return "";
42 }
43 return std::string(begin, end);
44}
45
46std::string JsonValueToString(const nlohmann::json& value) {
47 if (value.is_string()) {
48 return value.get<std::string>();
49 }
50 if (value.is_boolean()) {
51 return value.get<bool>() ? "true" : "false";
52 }
53 if (value.is_number()) {
54 return value.dump();
55 }
56 if (value.is_null()) {
57 return "null";
58 }
59 return value.dump();
60}
61
62std::set<std::string> CollectObjectKeys(const nlohmann::json& array) {
63 std::set<std::string> keys;
64 for (const auto& item : array) {
65 if (!item.is_object()) {
66 continue;
67 }
68 for (const auto& [key, _] : item.items()) {
69 keys.insert(key);
70 }
71 }
72 return keys;
73}
74
75std::optional<ChatMessage::TableData> BuildTableData(const nlohmann::json& data) {
76 using TableData = ChatMessage::TableData;
77
78 if (data.is_object()) {
79 TableData table;
80 table.headers = {"Key", "Value"};
81 table.rows.reserve(data.size());
82 for (const auto& [key, value] : data.items()) {
83 table.rows.push_back({key, JsonValueToString(value)});
84 }
85 return table;
86 }
87
88 if (data.is_array()) {
89 TableData table;
90 if (data.empty()) {
91 table.headers = {"Value"};
92 return table;
93 }
94
95 const bool all_objects = std::all_of(data.begin(), data.end(), [](const nlohmann::json& item) {
96 return item.is_object();
97 });
98
99 if (all_objects) {
100 auto keys = CollectObjectKeys(data);
101 if (keys.empty()) {
102 table.headers = {"Value"};
103 for (const auto& item : data) {
104 table.rows.push_back({JsonValueToString(item)});
105 }
106 return table;
107 }
108
109 table.headers.assign(keys.begin(), keys.end());
110 table.rows.reserve(data.size());
111 for (const auto& item : data) {
112 std::vector<std::string> row;
113 row.reserve(table.headers.size());
114 for (const auto& key : table.headers) {
115 if (item.contains(key)) {
116 row.push_back(JsonValueToString(item.at(key)));
117 } else {
118 row.emplace_back("-");
119 }
120 }
121 table.rows.push_back(std::move(row));
122 }
123 return table;
124 }
125
126 table.headers = {"Value"};
127 table.rows.reserve(data.size());
128 for (const auto& item : data) {
129 table.rows.push_back({JsonValueToString(item)});
130 }
131 return table;
132 }
133
134 return std::nullopt;
135}
136
137bool IsExecutableCommand(absl::string_view command) {
138 return !command.empty() && command.front() != '#';
139}
140
141int CountExecutableCommands(const std::vector<std::string>& commands) {
142 int count = 0;
143 for (const auto& command : commands) {
144 if (IsExecutableCommand(command)) {
145 ++count;
146 }
147 }
148 return count;
149}
150
151ChatMessage CreateMessage(ChatMessage::Sender sender, const std::string& content) {
152 ChatMessage message;
153 message.sender = sender;
154 message.message = content;
155 message.timestamp = absl::Now();
156
157 if (sender == ChatMessage::Sender::kAgent) {
158 const std::string trimmed = TrimWhitespace(content);
159 if (!trimmed.empty() && (trimmed.front() == '{' || trimmed.front() == '[')) {
160 try {
161 nlohmann::json parsed = nlohmann::json::parse(trimmed);
162 message.table_data = BuildTableData(parsed);
163 message.json_pretty = parsed.dump(2);
164 } catch (const nlohmann::json::parse_error&) {
165 // Ignore parse errors, fall back to raw text.
166 }
167 }
168 }
169
170 return message;
171}
172
173} // namespace
174
178
183
192
197
200 return;
201 }
202
203 while (history_.size() > config_.max_history_messages) {
204 history_.erase(history_.begin());
205 }
206}
207
224
228
229absl::StatusOr<ChatMessage> ConversationalAgentService::SendMessage(
230 const std::string& message) {
231 if (message.empty() && history_.empty()) {
232 return absl::InvalidArgumentError(
233 "Conversation must start with a non-empty message.");
234 }
235
236 if (!message.empty()) {
237 history_.push_back(CreateMessage(ChatMessage::Sender::kUser, message));
240 }
241
242 const int max_iterations = config_.max_tool_iterations;
243 bool waiting_for_text_response = false;
244 absl::Time turn_start = absl::Now();
245
246 if (config_.verbose) {
247 util::PrintInfo(absl::StrCat("Starting agent loop (max ", max_iterations, " iterations)"));
248 util::PrintInfo(absl::StrCat("History size: ", history_.size(), " messages"));
249 }
250
251 for (int iteration = 0; iteration < max_iterations; ++iteration) {
252 if (config_.verbose) {
254 std::cout << util::colors::kCyan << "Iteration " << (iteration + 1)
255 << "/" << max_iterations << util::colors::kReset << std::endl;
256 }
257
258 // Show loading indicator while waiting for AI response
260 waiting_for_text_response
261 ? "Generating final response..."
262 : "Thinking...",
263 !config_.verbose); // Hide spinner in verbose mode
264 loader.Start();
265
266 auto response_or = ai_service_->GenerateResponse(history_);
267 loader.Stop();
268
269 if (!response_or.ok()) {
270 util::PrintError(absl::StrCat(
271 "Failed to get AI response: ", response_or.status().message()));
272 return absl::InternalError(absl::StrCat(
273 "Failed to get AI response: ", response_or.status().message()));
274 }
275
276 const auto& agent_response = response_or.value();
277
278 if (config_.verbose) {
279 util::PrintInfo("Received agent response:");
280 std::cout << util::colors::kDim << " - Tool calls: "
281 << agent_response.tool_calls.size() << util::colors::kReset << std::endl;
282 std::cout << util::colors::kDim << " - Commands: "
283 << agent_response.commands.size() << util::colors::kReset << std::endl;
284 std::cout << util::colors::kDim << " - Text response: "
285 << (agent_response.text_response.empty() ? "empty" : "present")
286 << util::colors::kReset << std::endl;
287 if (!agent_response.reasoning.empty() && config_.show_reasoning) {
288 std::cout << util::colors::kYellow << " 💭 Reasoning: "
289 << util::colors::kDim << agent_response.reasoning
290 << util::colors::kReset << std::endl;
291 }
292 }
293
294 if (!agent_response.tool_calls.empty()) {
295 // Check if we were waiting for a text response but got more tool calls instead
296 if (waiting_for_text_response) {
298 absl::StrCat("LLM called tools again instead of providing final response (Iteration: ",
299 iteration + 1, "/", max_iterations, ")"));
300 }
301
302 bool executed_tool = false;
303 for (const auto& tool_call : agent_response.tool_calls) {
304 // Format tool arguments for display
305 std::vector<std::string> arg_parts;
306 for (const auto& [key, value] : tool_call.args) {
307 arg_parts.push_back(absl::StrCat(key, "=", value));
308 }
309 std::string args_str = absl::StrJoin(arg_parts, ", ");
310
311 util::PrintToolCall(tool_call.tool_name, args_str);
312
313 auto tool_result_or = tool_dispatcher_.Dispatch(tool_call);
314 if (!tool_result_or.ok()) {
315 util::PrintError(absl::StrCat(
316 "Tool execution failed: ", tool_result_or.status().message()));
317 return absl::InternalError(absl::StrCat(
318 "Tool execution failed: ", tool_result_or.status().message()));
319 }
320
321 const std::string& tool_output = tool_result_or.value();
322 if (!tool_output.empty()) {
323 util::PrintSuccess("Tool executed successfully");
325
326 if (config_.verbose) {
327 std::cout << util::colors::kDim << "Tool output (truncated):"
328 << util::colors::kReset << std::endl;
329 std::string preview = tool_output.substr(0, std::min(size_t(200), tool_output.size()));
330 if (tool_output.size() > 200) preview += "...";
331 std::cout << util::colors::kDim << preview << util::colors::kReset << std::endl;
332 }
333
334 // Add tool result with a clear marker for the LLM
335 // Format as plain text to avoid confusing the LLM with nested JSON
336 std::string marked_output = absl::StrCat(
337 "[TOOL RESULT for ", tool_call.tool_name, "]\n",
338 "The tool returned the following data:\n",
339 tool_output, "\n\n",
340 "Please provide a text_response field in your JSON to summarize this information for the user.");
341 auto tool_result_msg = CreateMessage(ChatMessage::Sender::kUser, marked_output);
342 tool_result_msg.is_internal = true; // Don't show this to the human user
343 history_.push_back(tool_result_msg);
344 }
345 executed_tool = true;
346 }
347
348 if (executed_tool) {
349 // Now we're waiting for the LLM to provide a text response
350 waiting_for_text_response = true;
351 // Re-query the AI with updated context.
352 continue;
353 }
354 }
355
356 // Check if we received a text response after tool execution
357 if (waiting_for_text_response && agent_response.text_response.empty() &&
358 agent_response.commands.empty()) {
360 absl::StrCat("LLM did not provide text_response after receiving tool results (Iteration: ",
361 iteration + 1, "/", max_iterations, ")"));
362 // Continue to give it another chance
363 continue;
364 }
365
366 std::optional<ProposalCreationResult> proposal_result;
367 absl::Status proposal_status = absl::OkStatus();
368 bool attempted_proposal = false;
369
370 if (!agent_response.commands.empty()) {
371 attempted_proposal = true;
372
373 if (rom_context_ == nullptr) {
374 proposal_status = absl::FailedPreconditionError(
375 "No ROM context available for proposal creation");
377 "Cannot create proposal because no ROM context is active.");
378 } else if (!rom_context_->is_loaded()) {
379 proposal_status = absl::FailedPreconditionError(
380 "ROM context is not loaded");
382 "Cannot create proposal because the ROM context is not loaded.");
383 } else {
385 request.prompt = message;
386 request.response = &agent_response;
387 request.rom = rom_context_;
388 request.sandbox_label = "agent-chat";
389 request.ai_provider = absl::GetFlag(FLAGS_ai_provider);
390
391 auto creation_or = CreateProposalFromAgentResponse(request);
392 if (!creation_or.ok()) {
393 proposal_status = creation_or.status();
394 util::PrintError(absl::StrCat(
395 "Failed to create proposal: ", proposal_status.message()));
396 } else {
397 proposal_result = std::move(creation_or.value());
398 if (config_.verbose) {
399 util::PrintSuccess(absl::StrCat(
400 "Created proposal ", proposal_result->metadata.id,
401 " with ", proposal_result->change_count, " change(s)."));
402 }
403 }
404 }
405 }
406
407 std::string response_text = agent_response.text_response;
408 if (!agent_response.reasoning.empty()) {
409 if (!response_text.empty()) {
410 response_text.append("\n\n");
411 }
412 response_text.append("Reasoning: ");
413 response_text.append(agent_response.reasoning);
414 }
415 const int executable_commands =
416 CountExecutableCommands(agent_response.commands);
417 if (!agent_response.commands.empty()) {
418 if (!response_text.empty()) {
419 response_text.append("\n\n");
420 }
421 response_text.append("Commands:\n");
422 response_text.append(absl::StrJoin(agent_response.commands, "\n"));
423 }
424 metrics_.commands_generated += executable_commands;
425
426 if (proposal_result.has_value()) {
427 const auto& metadata = proposal_result->metadata;
428 if (!response_text.empty()) {
429 response_text.append("\n\n");
430 }
431 response_text.append(absl::StrFormat(
432 "✅ Proposal %s ready with %d change%s (%d command%s).\n"
433 "Review it in the Proposal drawer or run `z3ed agent diff --proposal-id %s`.\n"
434 "Sandbox ROM: %s\nProposal JSON: %s",
435 metadata.id, proposal_result->change_count,
436 proposal_result->change_count == 1 ? "" : "s",
437 proposal_result->executed_commands,
438 proposal_result->executed_commands == 1 ? "" : "s",
439 metadata.id, metadata.sandbox_rom_path.string(),
440 proposal_result->proposal_json_path.string()));
442 } else if (attempted_proposal && !proposal_status.ok()) {
443 if (!response_text.empty()) {
444 response_text.append("\n\n");
445 }
446 response_text.append(absl::StrCat(
447 "⚠️ Failed to prepare a proposal automatically: ",
448 proposal_status.message()));
449 }
450 ChatMessage chat_response =
451 CreateMessage(ChatMessage::Sender::kAgent, response_text);
452 if (proposal_result.has_value()) {
454 summary.id = proposal_result->metadata.id;
455 summary.change_count = proposal_result->change_count;
456 summary.executed_commands = proposal_result->executed_commands;
457 summary.sandbox_rom_path = proposal_result->metadata.sandbox_rom_path;
458 summary.proposal_json_path = proposal_result->proposal_json_path;
459 chat_response.proposal = summary;
460 }
463 metrics_.total_latency += absl::Now() - turn_start;
464 chat_response.metrics = BuildMetricsSnapshot();
465 history_.push_back(chat_response);
467 return chat_response;
468 }
469
470 return absl::InternalError(
471 "Agent did not produce a response after executing tools.");
472}
473
474const std::vector<ChatMessage>& ConversationalAgentService::GetHistory() const {
475 return history_;
476}
477
479 std::vector<ChatMessage> history) {
480 history_ = std::move(history);
483}
484
487
489 bool has_snapshot = false;
490
491 for (const auto& message : history_) {
492 if (message.sender == ChatMessage::Sender::kUser) {
494 } else if (message.sender == ChatMessage::Sender::kAgent) {
497 }
498
499 if (message.proposal.has_value()) {
501 }
502
503 if (message.metrics.has_value()) {
504 snapshot = *message.metrics;
505 has_snapshot = true;
506 }
507 }
508
509 if (has_snapshot) {
510 metrics_.user_messages = snapshot.total_user_messages;
511 metrics_.agent_messages = snapshot.total_agent_messages;
512 metrics_.tool_calls = snapshot.total_tool_calls;
513 metrics_.commands_generated = snapshot.total_commands;
514 metrics_.proposals_created = snapshot.total_proposals;
515 metrics_.turns_completed = snapshot.turn_index;
516 metrics_.total_latency = absl::Seconds(snapshot.total_elapsed_seconds);
517 }
518}
519
520} // namespace agent
521} // namespace cli
522} // namespace yaze
The Rom class is used to load, save, and modify Rom data.
Definition rom.h:71
bool is_loaded() const
Definition rom.h:197
absl::StatusOr< ChatMessage > SendMessage(const std::string &message)
const std::vector< ChatMessage > & GetHistory() const
void ReplaceHistory(std::vector< ChatMessage > history)
absl::StatusOr< std::string > Dispatch(const ToolCall &tool_call)
ABSL_DECLARE_FLAG(std::string, ai_provider)
ChatMessage CreateMessage(ChatMessage::Sender sender, const std::string &content)
std::optional< ChatMessage::TableData > BuildTableData(const nlohmann::json &data)
absl::StatusOr< ProposalCreationResult > CreateProposalFromAgentResponse(const ProposalCreationRequest &request)
std::string TrimWhitespace(absl::string_view value)
constexpr const char * kDim
constexpr const char * kYellow
constexpr const char * kReset
constexpr const char * kCyan
void PrintWarning(const std::string &message)
void PrintToolCall(const std::string &tool_name, const std::string &details="")
void PrintInfo(const std::string &message)
void PrintSuccess(const std::string &message)
void PrintError(const std::string &message)
std::unique_ptr< AIService > CreateAIService()
Main namespace for the application.
std::optional< std::string > json_pretty
std::optional< ProposalSummary > proposal
std::optional< SessionMetrics > metrics