netget/llm/conversation.rs
1//! Conversation-based LLM interaction with tool calling and retry logic
2//!
3//! This module provides a unified conversation handler that:
4//! 1. Maintains conversation history (system, user, assistant messages)
5//! 2. Handles multi-turn tool calling automatically
6//! 3. Retries malformed responses with corrective feedback
7//! 4. Works for both user input and network events
8
9use crate::llm::actions::{execute_tool, ActionDefinition, ActionResponse, ToolAction, ToolResult};
10use crate::llm::conversation_state::ConversationState;
11use crate::llm::ollama_client::{ChatRequest, ChatResponse, Message, OllamaClient};
12use crate::llm::{RateLimiter, RequestSource};
13use crate::logging::emit::Log;
14use crate::state::app_state::{AppState, ConversationSource, WebApprovalRequest, WebSearchMode};
15use anyhow::{Context, Result};
16use std::sync::{Arc, Mutex};
17use tracing::{debug, error, info, trace, warn};
18
19/// Extract reasoning from LLM response and return (reasoning, cleaned_response)
20///
21/// Looks for `<reasoning>...</reasoning>` tags in the response, extracts the content,
22/// and returns a cleaned response with the tags removed.
23///
24/// # Arguments
25/// * `response` - The raw LLM response text
26///
27/// # Returns
28/// * `(Option<String>, String)` - Reasoning content (if found) and cleaned response
29/// Whether any value in this action still points at a tool result rather than
30/// carrying one.
31///
32/// Models write these unprompted — `"block_hex": "{{tools[0].result}}"`,
33/// `"token": "{{tool_results[1]}}"` — reasoning that the tool call they issued in
34/// the same response will be substituted in. Nothing substitutes it:
35/// `reference_parser` resolves `<tagname>` XML references, and there is no
36/// `{{...}}` mechanism on the LLM path at all. The placeholder would reach the
37/// wire as literal text.
38///
39/// Deliberately narrow. It matches only `{{…}}` whose body mentions a tool, so
40/// `{{event.field}}` — which *is* substituted, later and elsewhere, inside static
41/// event-handler definitions — is left alone.
42fn action_references_tool_result(action: &serde_json::Value) -> bool {
43 fn mentions_tool_placeholder(text: &str) -> bool {
44 let mut rest = text;
45 while let Some(open) = rest.find("{{") {
46 let after = &rest[open + 2..];
47 let Some(close) = after.find("}}") else { break };
48 if after[..close].to_lowercase().contains("tool") {
49 return true;
50 }
51 rest = &after[close + 2..];
52 }
53 false
54 }
55
56 match action {
57 serde_json::Value::String(s) => mentions_tool_placeholder(s),
58 serde_json::Value::Array(items) => items.iter().any(action_references_tool_result),
59 serde_json::Value::Object(map) => map.values().any(action_references_tool_result),
60 _ => false,
61 }
62}
63
64fn extract_reasoning(response: &str) -> (Option<String>, String) {
65 let reasoning_start = response.find("<reasoning>");
66 let reasoning_end = response.find("</reasoning>");
67
68 match (reasoning_start, reasoning_end) {
69 (Some(start), Some(end)) if end > start => {
70 // Extract reasoning content (between tags)
71 let reasoning_content = response[start + 11..end].trim().to_string();
72
73 // Remove the entire reasoning tag (including tags themselves)
74 let before = &response[..start];
75 let after = &response[end + 12..];
76 let cleaned = format!("{}{}", before, after).trim().to_string();
77
78 (Some(reasoning_content), cleaned)
79 }
80 _ => (None, response.to_string()),
81 }
82}
83
84/// Default cap on the number of messages kept in the in-flight conversation.
85///
86/// A legitimate run is bounded by `max_tool_iterations` (5), each iteration contributing at
87/// most an assistant response, an action acknowledgement and a tool-results message — 15,
88/// plus the system message and the initial trigger. 24 leaves room for a couple of
89/// corrections on top of a full-length legitimate run and clips only pathological ones.
90pub const DEFAULT_MAX_HISTORY_MESSAGES: usize = 24;
91
92/// Default cap on the total characters of the non-system messages sent to the model.
93///
94/// Roughly 8k tokens. `format_tool_results` already caps each tool result at 2000 chars, so
95/// this holds a full 5-iteration run's worth of tool output plus the surrounding turns. It
96/// is deliberately larger than `ConversationState`'s 8000-char cross-*call* window, which
97/// bounds a different structure and never bounded what is actually sent.
98pub const DEFAULT_MAX_HISTORY_CHARS: usize = 32_000;
99
100/// Stand-in message left in place of history dropped by [`ConversationHandler::trim_history`].
101const TRIM_NOTICE: &str = "[Earlier turns of this conversation were omitted to bound the prompt size. The system message and the original request above are intact.]";
102
103/// Conversation handler for multi-turn LLM interactions
104pub struct ConversationHandler {
105 /// Unique conversation ID for tracking
106 conversation_id: String,
107
108 /// Conversation messages (system, user, assistant, tool)
109 messages: Vec<Message>,
110
111 /// Conversation state for history tracking with token limits
112 conversation_state: Arc<Mutex<ConversationState>>,
113
114 /// Ollama client for chat API calls
115 client: Arc<OllamaClient>,
116
117 /// Model name (e.g., "qwen3-coder:30b")
118 model: String,
119
120 /// Maximum number of retries for malformed responses
121 max_retries: usize,
122
123 /// Maximum tool calling iterations
124 max_tool_iterations: usize,
125
126 /// Status channel for user-visible logs
127 status_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
128
129 /// Index of last logged message (to avoid re-logging entire conversation)
130 last_logged_index: usize,
131
132 /// Whether protocol documentation has been read in this conversation (enables open_server and open_client)
133 protocol_docs_read: bool,
134
135 /// Whether server documentation has been read in this conversation (enables open_server)
136 _server_docs_read: bool,
137
138 /// Whether client documentation has been read in this conversation (enables open_client)
139 _client_docs_read: bool,
140
141 /// Application state (for conversation tracking)
142 state: Option<AppState>,
143
144 /// Source of this conversation (for UI display)
145 source: Option<ConversationSource>,
146
147 /// Details text for UI display
148 details: Option<String>,
149
150 /// Whether this conversation has been registered (to avoid duplicate registration)
151 registered: bool,
152
153 /// Rate limiter for controlling LLM call frequency and token usage
154 rate_limiter: RateLimiter,
155
156 /// Source of the request (User or Network) for rate limiting behavior
157 request_source: RequestSource,
158
159 /// Native tool schemas for chat_with_tools() API (empty = use prompt-based fallback)
160 tool_schemas: Vec<serde_json::Value>,
161
162 /// Whether to use native tool calling (chat_with_tools) vs prompt-based (generate_with_format)
163 use_native_tools: bool,
164
165 /// The actions the current `generate_with_tools_and_retry` call offers. Handed to the
166 /// client with every request so a bridge host can show them as structured data.
167 offered_actions: Vec<ActionDefinition>,
168
169 /// Structured metadata for a single network event, retained across all retries.
170 bridge_event: Option<crate::llm::bridge::BridgeEventContext>,
171
172 /// Conversation-local policy retained once credential parameters are offered.
173 private_payloads: bool,
174
175 /// Cap on `messages.len()` — see [`DEFAULT_MAX_HISTORY_MESSAGES`]
176 max_history_messages: usize,
177
178 /// Cap on the total characters of the non-system messages — see [`DEFAULT_MAX_HISTORY_CHARS`]
179 max_history_chars: usize,
180
181 /// Bumped every time [`Self::trim_history`] actually removes messages.
182 ///
183 /// Trimming shifts every index after the protected prefix, so code holding a message
184 /// index across a possible trim compares this first and gives up on a mismatch rather
185 /// than draining the wrong range.
186 trim_generation: u64,
187}
188
189impl ConversationHandler {
190 /// Create a new conversation handler with a system message
191 pub fn new(
192 system_message: String,
193 client: Arc<OllamaClient>,
194 model: String,
195 rate_limiter: RateLimiter,
196 request_source: RequestSource,
197 ) -> Self {
198 let messages = vec![Message::system(system_message)];
199
200 // Generate unique conversation ID using timestamp and random bytes
201 let conversation_id = Self::generate_conversation_id();
202
203 // Create conversation state with default token limit (8000 characters)
204 // This can be made configurable later
205 let conversation_state = Arc::new(Mutex::new(ConversationState::new(8000)));
206
207 Self {
208 conversation_id,
209 messages,
210 conversation_state,
211 client,
212 model,
213 max_retries: 1,
214 max_tool_iterations: 5,
215 status_tx: None,
216 last_logged_index: 0, // No messages logged yet
217 protocol_docs_read: false,
218 _server_docs_read: false,
219 _client_docs_read: false,
220 state: None,
221 source: None,
222 details: None,
223 registered: false,
224 rate_limiter,
225 request_source,
226 tool_schemas: Vec::new(),
227 use_native_tools: false,
228 offered_actions: Vec::new(),
229 bridge_event: None,
230 private_payloads: false,
231 max_history_messages: DEFAULT_MAX_HISTORY_MESSAGES,
232 max_history_chars: DEFAULT_MAX_HISTORY_CHARS,
233 trim_generation: 0,
234 }
235 }
236
237 /// Attach the event for a bridge host without making it recover context from prompt text.
238 pub fn with_bridge_event(
239 mut self,
240 server_id: crate::state::ServerId,
241 connection_id: Option<crate::server::connection::ConnectionId>,
242 protocol: &str,
243 event: &crate::protocol::Event,
244 ) -> Self {
245 // Native transports already render event data into the prompt and never consume
246 // this metadata. Avoid another potentially large clone for every native request.
247 if self.client.backend_type() != "bridge" {
248 return self;
249 }
250 self.bridge_event = Some(crate::llm::bridge::BridgeEventContext {
251 token: self.conversation_id.clone(),
252 server_id: server_id.as_u32(),
253 connection_id: connection_id.map(|id| id.as_u32()),
254 protocol: protocol.to_string(),
255 event_type: event.id().to_string(),
256 data: event.data.clone(),
257 });
258 self
259 }
260
261 /// Override the conversation-history caps (see [`DEFAULT_MAX_HISTORY_MESSAGES`] and
262 /// [`DEFAULT_MAX_HISTORY_CHARS`]).
263 pub fn with_history_limits(mut self, max_messages: usize, max_chars: usize) -> Self {
264 self.max_history_messages = max_messages.max(3);
265 self.max_history_chars = max_chars;
266 self
267 }
268
269 /// Dual-sink log facade bound to this conversation's status channel.
270 ///
271 /// The conversation layer owns the *semantic* narration of a round-trip — which
272 /// attempt, that a response arrived, the outcome — and is the only layer that
273 /// narrates it to the TUI. Wire facts (sizes, tokens) belong to the transport
274 /// (`OllamaClient`) and stay file-only. Full payloads are TRACE/file-only.
275 fn log(&self) -> Log<'_> {
276 Log::new(self.status_tx.as_ref()).with_payloads(!self.private_payloads)
277 }
278
279 fn display_payload(&self, text: &str) -> String {
280 if self.private_payloads {
281 crate::utils::redact::REDACTED.into()
282 } else {
283 text.into()
284 }
285 }
286
287 /// Total characters of everything but the system message.
288 fn history_chars(&self) -> usize {
289 self.messages.iter().skip(1).map(|m| m.content.len()).sum()
290 }
291
292 /// Whether the conversation exceeds either history cap.
293 fn over_history_budget(&self) -> bool {
294 self.messages.len() > self.max_history_messages
295 || self.history_chars() > self.max_history_chars
296 }
297
298 /// Bound the conversation that is actually sent to the model.
299 ///
300 /// Without this, `messages` only ever grows: five tool iterations each append an
301 /// assistant response, an acknowledgement and a tool-results block, and every failed
302 /// attempt leaves its response plus a correction behind for the rest of the
303 /// conversation. `ConversationState`'s 8000-char window is a different structure and
304 /// bounds only cross-call history, never the request.
305 ///
306 /// The system message and the first user message — the instruction and the request the
307 /// whole conversation is about — are never dropped, nor are the two most recent
308 /// messages. Everything between is dropped oldest-first and replaced by a single
309 /// [`TRIM_NOTICE`], so the model is told its history was cut rather than left to
310 /// reference turns that silently vanished.
311 ///
312 /// Called before every request; public so the bound is directly assertable.
313 pub fn trim_history(&mut self) {
314 if !self.over_history_budget() {
315 return;
316 }
317
318 let protected = if self.messages.len() > 1 && self.messages[1].role == "user" {
319 2
320 } else {
321 1
322 };
323 // Keep at least the two most recent messages after the protected prefix.
324 let min_len = protected + 2;
325
326 let mut dropped_messages = 0usize;
327 let mut dropped_chars = 0usize;
328 let mut removed_any = false;
329 while self.messages.len() > min_len && self.over_history_budget() {
330 let removed = self.messages.remove(protected);
331 removed_any = true;
332 // A notice left by an earlier trim is replaced, not counted as lost content.
333 if removed.content != TRIM_NOTICE {
334 dropped_messages += 1;
335 dropped_chars += removed.content.len();
336 }
337 }
338
339 if !removed_any {
340 return;
341 }
342
343 // Any removal shifts every index after the protected prefix.
344 self.trim_generation += 1;
345 self.messages
346 .insert(protected, Message::user(TRIM_NOTICE.to_string()));
347 // The notice itself counts against the message cap.
348 if self.messages.len() > self.max_history_messages && self.messages.len() > min_len + 1 {
349 let removed = self.messages.remove(protected + 1);
350 dropped_messages += 1;
351 dropped_chars += removed.content.len();
352 }
353
354 // Trimming is a DEBUG summary — file-only, off the TUI stream.
355 self.log().debug(format!(
356 "Trimmed conversation history: dropped {} message(s) / {} chars, {} message(s) and \
357 {} chars remain (caps: {} messages, {} chars)",
358 dropped_messages,
359 dropped_chars,
360 self.messages.len(),
361 self.history_chars(),
362 self.max_history_messages,
363 self.max_history_chars
364 ));
365 }
366
367 /// Generate a unique conversation ID
368 fn generate_conversation_id() -> String {
369 use crate::utils::clock::SystemTime;
370 let timestamp = SystemTime::now()
371 .duration_since(SystemTime::UNIX_EPOCH)
372 .unwrap()
373 .as_millis();
374 let random: u32 = rand::random();
375 format!("conv-{}-{:x}", timestamp, random)
376 }
377
378 /// Hide incidental payload/error diagnostics when structured request input
379 /// contains credentials. Sticky only within this conversation's history.
380 pub fn with_private_payloads(mut self, private: bool) -> Self {
381 self.private_payloads |= private;
382 self
383 }
384
385 /// Set the status channel for user-visible logs
386 pub fn with_status_tx(mut self, tx: tokio::sync::mpsc::UnboundedSender<String>) -> Self {
387 self.status_tx = Some(tx);
388 self
389 }
390
391 /// Set an existing conversation state
392 pub fn with_conversation_state(mut self, state: Arc<Mutex<ConversationState>>) -> Self {
393 self.conversation_state = state;
394 self
395 }
396
397 /// Set conversation tracking information
398 pub fn with_tracking(
399 mut self,
400 state: AppState,
401 source: ConversationSource,
402 details: String,
403 ) -> Self {
404 self.state = Some(state);
405 self.source = Some(source);
406 self.details = Some(details);
407 self
408 }
409
410 /// Enable native tool calling with the given action definitions
411 ///
412 /// When set, `generate_with_retry()` will use `chat_with_tools()` instead of
413 /// `generate_with_format()`, sending structured messages and tool schemas.
414 /// The response is converted back to ActionResponse text format so the existing
415 /// tool calling loop in `generate_with_tools_and_retry()` works unchanged.
416 ///
417 /// Also strips redundant action/tool descriptions and JSON format instructions from
418 /// the system message to reduce token usage (these are now handled by native tool schemas).
419 pub fn with_native_tools(mut self, actions: &[ActionDefinition]) -> Self {
420 self.tool_schemas = actions.iter().map(|a| a.to_tool_schema()).collect();
421 self.use_native_tools = !self.tool_schemas.is_empty();
422
423 // Strip redundant sections from system message when native tools are active
424 if self.use_native_tools && !self.messages.is_empty() && self.messages[0].role == "system" {
425 let content = &self.messages[0].content;
426 let mut cleaned = content.clone();
427
428 // Remove "# Available Tools" section (text descriptions of tools)
429 if let Some(start) = cleaned.find("# Available Tools") {
430 if let Some(next_section) = cleaned[start + 1..].find("\n# ") {
431 cleaned = format!(
432 "{}{}",
433 &cleaned[..start],
434 &cleaned[start + 1 + next_section..]
435 );
436 }
437 }
438
439 // Remove "# Available Actions" section (text descriptions of actions)
440 if let Some(start) = cleaned.find("# Available Actions") {
441 if let Some(next_section) = cleaned[start + 1..].find("\n# ") {
442 cleaned = format!(
443 "{}{}",
444 &cleaned[..start],
445 &cleaned[start + 1 + next_section..]
446 );
447 }
448 }
449
450 // Remove "# Response Format" section (JSON format instructions)
451 if let Some(start) = cleaned.find("# Response Format") {
452 if let Some(next_section) = cleaned[start + 1..].find("\n# ") {
453 cleaned = format!(
454 "{}{}",
455 &cleaned[..start],
456 &cleaned[start + 1 + next_section..]
457 );
458 }
459 }
460
461 // Remove the "CRITICAL - READ THIS FIRST" JSON format block from task.hbs
462 if let Some(start) = cleaned.find("**⚠️ CRITICAL - READ THIS FIRST ⚠️**") {
463 // Find the end of this block (next "---" or "## ")
464 if let Some(end_marker) = cleaned[start..].find("\n---\n") {
465 let end = start + end_marker + 5; // include the "---\n"
466 cleaned = format!("{}{}", &cleaned[..start], &cleaned[end..]);
467 }
468 }
469
470 if cleaned.len() < content.len() {
471 debug!(
472 "Stripped {} chars of redundant action/format descriptions from system message (native tools active)",
473 content.len() - cleaned.len()
474 );
475 self.messages[0] = Message::system(cleaned);
476 }
477 }
478
479 self
480 }
481
482 /// Update the native tool schemas (e.g., after documentation is read and new tools are enabled)
483 pub fn update_tool_schemas(&mut self, actions: &[ActionDefinition]) {
484 self.tool_schemas = actions.iter().map(|a| a.to_tool_schema()).collect();
485 self.use_native_tools = !self.tool_schemas.is_empty();
486 }
487
488 /// Convert a ChatResponse (native tool calls) into ActionResponse-compatible text
489 ///
490 /// This bridges native tool calling with the existing text-based parsing pipeline.
491 /// Each tool_call is converted to a JSON object with "type" field matching the function name,
492 /// then separated into tools vs actions arrays.
493 fn chat_response_to_action_text(response: &ChatResponse) -> String {
494 let mut tools = Vec::new();
495 let mut actions = Vec::new();
496
497 for tc in &response.tool_calls {
498 // Build action JSON: merge function_name as "type" with arguments
499 let obj = if let Some(map) = tc.arguments.as_object() {
500 let mut new_map = serde_json::Map::new();
501 new_map.insert(
502 "type".to_string(),
503 serde_json::Value::String(tc.function_name.clone()),
504 );
505 for (k, v) in map {
506 new_map.insert(k.clone(), v.clone());
507 }
508 serde_json::Value::Object(new_map)
509 } else {
510 serde_json::json!({"type": tc.function_name})
511 };
512
513 // Separate into tools vs actions using existing classification
514 if ToolAction::is_tool_action(&obj) {
515 tools.push(obj);
516 } else {
517 actions.push(obj);
518 }
519 }
520
521 // If there's text content and no tool calls, convert to show_message
522 if let Some(content) = &response.content {
523 if !content.is_empty() && tools.is_empty() && actions.is_empty() {
524 actions.push(serde_json::json!({"type": "show_message", "message": content}));
525 }
526 }
527
528 serde_json::to_string(&serde_json::json!({
529 "tools": tools,
530 "actions": actions
531 }))
532 .unwrap_or_default()
533 }
534
535 /// Manually end conversation tracking (for error paths where generate_with_tools_and_retry doesn't complete)
536 pub async fn end_tracking(&self) {
537 if let Some(state) = &self.state {
538 state.end_conversation(&self.conversation_id).await;
539 }
540 }
541
542 /// Check if protocol documentation has been read in this conversation
543 pub fn is_protocol_docs_read(&self) -> bool {
544 self.protocol_docs_read
545 }
546
547 /// Mark protocol documentation as read in this conversation (enables open_server and open_client)
548 /// This also updates the system message to enable the open_server and open_client actions
549 fn mark_protocol_docs_read(&mut self, available_actions: &[ActionDefinition]) {
550 self.protocol_docs_read = true;
551 debug!("Protocol documentation read in conversation - open_server and open_client actions are now enabled");
552
553 // Enable open_server and open_client in the available actions
554 // by filtering out the disabled versions and regenerating them with enabled flag
555 let mut enabled_actions = Vec::new();
556
557 for action in available_actions {
558 // Skip the disabled open_server and open_client actions - we'll add enabled versions
559 if action.name == "open_server" && action.parameters.is_empty() {
560 // This is the disabled version - skip it
561 continue;
562 }
563 if action.name == "open_client" && action.parameters.is_empty() {
564 // This is the disabled version - skip it
565 continue;
566 }
567 enabled_actions.push(action.clone());
568 }
569
570 // We need to regenerate the enabled actions, but since we don't have access to the
571 // state/env here, we'll add placeholder descriptions in the actions.
572 // The real solution would be to pass state/env to this function, but that's more invasive.
573 // For now, let's just update the existing disabled ones to be enabled by replacing them
574 // with newly built ones that have full parameters.
575
576 // Since we can't easily regenerate here without state/env, let's just pass the
577 // existing actions. The update_actions_section function will render them as-is.
578 // The actions will remain disabled in this iteration, but the next iteration will
579 // have them enabled because the conversation will have a new set of actions built
580 // with the enabled flags.
581
582 // Rebuild the actions section in the system prompt
583 self.update_actions_section(available_actions);
584 }
585
586 /// Mark server documentation as read in this conversation (enables open_server)
587 #[allow(dead_code)]
588 fn _mark_server_docs_read(&mut self, available_actions: &[ActionDefinition]) {
589 self._server_docs_read = true;
590 debug!("Server documentation read in conversation - open_server action is now enabled");
591
592 // Rebuild the actions section in the system prompt
593 self.update_actions_section(available_actions);
594 }
595
596 /// Mark client documentation as read in this conversation (enables open_client)
597 #[allow(dead_code)]
598 fn _mark_client_docs_read(&mut self, available_actions: &[ActionDefinition]) {
599 self._client_docs_read = true;
600 debug!("Client documentation read in conversation - open_client action is now enabled");
601
602 // Rebuild the actions section in the system prompt
603 self.update_actions_section(available_actions);
604 }
605
606 /// Update the "Available Actions" section in the system message
607 ///
608 /// This is used after read_base_stack_docs is called to enable the open_server action.
609 fn update_actions_section(&mut self, available_actions: &[ActionDefinition]) {
610 use crate::llm::prompt::PromptBuilder;
611
612 if self.messages.is_empty() {
613 warn!("Cannot update actions section: no system message found");
614 return;
615 }
616
617 // Get the system message (first message)
618 let system_msg = &self.messages[0];
619 if system_msg.role != "system" {
620 warn!("First message is not a system message, cannot update actions section");
621 return;
622 }
623
624 let old_content = &system_msg.content;
625
626 // Find the "# Available Tools" or "# Available Actions" section
627 let section_start = if let Some(pos) = old_content.find("# Available Tools") {
628 Some(pos)
629 } else {
630 old_content.find("# Available Actions")
631 };
632
633 if let Some(start_pos) = section_start {
634 // Find where this section ends (next "# " or "---" or end of string)
635 let content_after_section = &old_content[start_pos..];
636
637 // Find the next major section marker
638 let mut end_pos = start_pos;
639 let mut found_end = false;
640
641 // Skip past the section header
642 if let Some(first_newline) = content_after_section.find('\n') {
643 let search_start = start_pos + first_newline + 1;
644 let remaining = &old_content[search_start..];
645
646 // Look for next section (starts with "# " at line start or "---")
647 if let Some(next_section) = remaining.find("\n# ") {
648 end_pos = search_start + next_section;
649 found_end = true;
650 } else if let Some(divider) = remaining.find("\n---") {
651 end_pos = search_start + divider;
652 found_end = true;
653 }
654 }
655
656 if !found_end {
657 end_pos = old_content.len();
658 }
659
660 // Build new actions section using PromptBuilder
661 let new_actions_section =
662 PromptBuilder::build_actions_section_public(available_actions);
663
664 // Replace the old actions section with the new one
665 let mut new_content = String::new();
666 new_content.push_str(&old_content[..start_pos]);
667 new_content.push_str(&new_actions_section);
668 if end_pos < old_content.len() {
669 new_content.push_str(&old_content[end_pos..]);
670 }
671
672 // Update the system message
673 self.messages[0] = Message::system(new_content);
674
675 debug!("Updated Available Actions section in system message with open_server enabled");
676 } else {
677 warn!("Could not find '# Available Tools' or '# Available Actions' section in system message");
678 }
679 }
680
681 /// Add a user message to the conversation
682 pub fn add_user_message(&mut self, content: String) {
683 // Track in conversation state
684 if let Ok(mut state) = self.conversation_state.lock() {
685 state.add_user_input(content.clone());
686 }
687
688 self.messages.push(Message::user(content));
689 }
690
691 /// Generate response with tool calling and retry logic
692 ///
693 /// This is the main entry point that handles:
694 /// 1. Multi-turn tool calling loop
695 /// 2. Automatic retry on parse/validation errors
696 /// 3. Tool execution with result feedback
697 ///
698 /// # Arguments
699 /// * `approval_tx` - Optional channel for web search approval
700 /// * `web_search_mode` - Web search configuration
701 /// * `available_actions` - List of actions available in this context
702 ///
703 /// # Returns
704 /// * `Ok(Vec<serde_json::Value>)` - Array of non-tool actions to execute
705 pub async fn generate_with_tools_and_retry(
706 &mut self,
707 approval_tx: Option<tokio::sync::mpsc::UnboundedSender<WebApprovalRequest>>,
708 web_search_mode: WebSearchMode,
709 available_actions: Vec<ActionDefinition>,
710 ) -> Result<Vec<serde_json::Value>> {
711 self.private_payloads |= crate::utils::redact::actions_have_credentials(&available_actions);
712 // This handle belongs to this generation, including its tool/retry loop.
713 // Dropping the future (e.g. stopping a server) also clears its activity.
714 let _activity = self
715 .state
716 .as_ref()
717 .zip(self.source.as_ref())
718 .map(|(state, source)| {
719 state.llm_activity.begin(
720 source.clone(),
721 self.display_payload(self.details.as_deref().unwrap_or_default()),
722 )
723 });
724 let result = self
725 .generate_until_final_response(approval_tx, web_search_mode, available_actions)
726 .await;
727 if result.is_err() {
728 // Rejected/exhausted responses must not leave a live conversation in the UI.
729 self.end_tracking().await;
730 }
731 result
732 }
733
734 async fn generate_until_final_response(
735 &mut self,
736 approval_tx: Option<tokio::sync::mpsc::UnboundedSender<WebApprovalRequest>>,
737 web_search_mode: WebSearchMode,
738 available_actions: Vec<ActionDefinition>,
739 ) -> Result<Vec<serde_json::Value>> {
740 // Register conversation if tracking is enabled and not already registered
741 if !self.registered {
742 if let (Some(state), Some(source), Some(details)) =
743 (&self.state, &self.source, &self.details)
744 {
745 state
746 .register_conversation(
747 self.conversation_id.clone(),
748 source.clone(),
749 details.clone(),
750 )
751 .await;
752 self.registered = true;
753 }
754 }
755
756 let mut final_actions = None;
757 let mut tool_results = Vec::new();
758 // Index of the oldest rejected assistant response not yet superseded by a valid one.
759 // Everything from there up to the current response is dropped once the model gets it
760 // right, so a correction round-trip does not stay in the prompt for the rest of the
761 // conversation.
762 let mut rejected_block_start: Option<(usize, u64)> = None;
763 let mut consecutive_tool_failures = 0;
764 let mut unknown_action_retries = 0;
765 let mut malformed_action_retries = 0;
766 const MAX_CONSECUTIVE_FAILURES: usize = 2;
767 const MAX_UNKNOWN_ACTION_RETRIES: usize = 2;
768 const MAX_MALFORMED_ACTION_RETRIES: usize = 2;
769
770 // Build set of valid action names for validation (mutable to allow updates after docs read)
771 let mut valid_action_names: std::collections::HashSet<String> =
772 available_actions.iter().map(|a| a.name.clone()).collect();
773 let mut valid_action_names_list: Vec<String> =
774 available_actions.iter().map(|a| a.name.clone()).collect();
775 // History can retain a credential after the currently offered actions are narrowed.
776 self.private_payloads |= crate::utils::redact::actions_have_credentials(&available_actions);
777 self.offered_actions = available_actions.clone();
778
779 for iteration in 1..=self.max_tool_iterations {
780 debug!(
781 "Conversation iteration {}/{}",
782 iteration, self.max_tool_iterations
783 );
784
785 // Generate response from LLM
786 let (original_response, cleaned_response) = self
787 .generate_with_retry()
788 .await
789 .context("✗ LLM failed to generate valid response after retries.")?;
790
791 // Add assistant's response to conversation history (with reasoning preserved)
792 let assistant_idx = self.messages.len();
793 self.messages
794 .push(Message::assistant(original_response.clone()));
795
796 // Extract XML references from response (scripts, configs, large content)
797 let (json_only, references) =
798 crate::llm::reference_parser::extract_references(&cleaned_response)
799 .context("Failed to extract XML references from response")?;
800
801 if !references.is_empty() && !self.private_payloads {
802 debug!(
803 "Extracted {} XML references from LLM response",
804 references.len()
805 );
806 for (tag_name, content) in &references {
807 trace!(" Reference <{}>: {} chars", tag_name, content.len());
808 }
809 }
810
811 // Parse as action response (using JSON-only portion)
812 let action_response = ActionResponse::from_str(&json_only)
813 .context("Failed to parse action response (should not happen after retry)")?;
814
815 // Resolve references in both tools and actions (replace <tagname> placeholders with actual content)
816 let resolve_refs = |item: serde_json::Value| {
817 let mut resolved_item = item;
818 // Convert to JSON string, resolve references, parse back
819 if let Ok(item_json) = serde_json::to_string(&resolved_item) {
820 if crate::llm::reference_parser::contains_references(&item_json) {
821 let resolved_json = crate::llm::reference_parser::resolve_references(
822 &item_json,
823 &references,
824 );
825 if let Ok(new_item) = serde_json::from_str(&resolved_json) {
826 resolved_item = new_item;
827 }
828 }
829 }
830 resolved_item
831 };
832
833 let tools_with_refs: Vec<_> = action_response
834 .tools
835 .into_iter()
836 .map(&resolve_refs)
837 .collect();
838
839 let actions_with_refs: Vec<_> = action_response
840 .actions
841 .into_iter()
842 .map(&resolve_refs)
843 .collect();
844
845 // Create new action response with resolved references
846 let action_response = ActionResponse {
847 tools: tools_with_refs,
848 actions: actions_with_refs,
849 };
850
851 // Validate action names against available actions
852 let unknown_actions: Vec<String> = action_response
853 .actions
854 .iter()
855 .chain(action_response.tools.iter())
856 .filter_map(|action| {
857 let Some(action_type) = action.get("type").and_then(|v| v.as_str()) else {
858 return Some("(missing or non-string action type)".to_string());
859 };
860 // Check if action exists in valid actions
861 if !valid_action_names.contains(action_type) {
862 Some(action_type.to_string())
863 } else {
864 None
865 }
866 })
867 .collect();
868
869 if !unknown_actions.is_empty() {
870 unknown_action_retries += 1;
871 let shown_unknown_actions = if self.private_payloads {
872 vec![crate::utils::redact::REDACTED.to_string(); unknown_actions.len()]
873 } else {
874 unknown_actions.clone()
875 };
876
877 // Log warning with the actual response
878 warn!(
879 "LLM returned unknown action(s): {:?}. Response was: {}",
880 shown_unknown_actions,
881 self.display_payload(&crate::utils::truncate_for_log(&cleaned_response, 500))
882 );
883
884 if let Some(ref tx) = self.status_tx {
885 let _ = tx.send(format!(
886 "[WARN] LLM returned unknown action(s): {:?}",
887 shown_unknown_actions
888 ));
889 }
890
891 if unknown_action_retries >= MAX_UNKNOWN_ACTION_RETRIES {
892 // All retries exhausted - log error
893 error!(
894 "LLM failed to use valid actions after {} retries. Unknown actions: {:?}",
895 unknown_action_retries, shown_unknown_actions
896 );
897 if let Some(ref tx) = self.status_tx {
898 let _ = tx.send(format!(
899 "[ERROR] LLM failed to use valid actions after {} retries",
900 unknown_action_retries
901 ));
902 }
903 // Return error instead of silently continuing
904 anyhow::bail!(
905 "LLM returned unknown action(s) after {} retries: {:?}",
906 unknown_action_retries,
907 shown_unknown_actions
908 );
909 }
910
911 // Build retry prompt for unknown actions
912 let correction =
913 crate::llm::prompt::PromptBuilder::build_unknown_action_retry_prompt(
914 &unknown_actions,
915 &valid_action_names_list,
916 );
917
918 // Log the correction before adding to messages
919 trace!(
920 "→ Sending unknown action correction and retrying (attempt {})...",
921 unknown_action_retries + 1
922 );
923 if let Some(ref tx) = self.status_tx {
924 let _ = tx.send(
925 "[TRACE] → Sending correction to LLM for unknown action...".to_string(),
926 );
927 // Show the correction message being sent (indented and dimmed)
928 for line in crate::llm::format_indented_dimmed_lines(
929 &self.display_payload(&correction),
930 8,
931 ) {
932 let _ = tx.send(format!("[TRACE] {}", line));
933 }
934 }
935
936 // The rejected response is already in `messages` (pushed above); pushing it a
937 // second time only made the model read its own mistake twice.
938 rejected_block_start.get_or_insert((assistant_idx, self.trim_generation));
939
940 // Add correction as user message
941 self.messages.push(Message::user(correction));
942
943 // Continue to next iteration to retry
944 continue;
945 }
946
947 // Get tool calls and regular actions from separate fields
948 // Note: action_response.tools and action_response.actions are already separated
949 // by the parsing logic (with backward compatibility for old format)
950 let tools = action_response.tools;
951 let regular = action_response.actions;
952
953 // Validate regular actions by trying to parse them as CommonAction
954 // This catches missing required parameters before execution
955 let mut malformed_actions: Vec<(serde_json::Value, String)> = Vec::new();
956 let mut valid_regular: Vec<serde_json::Value> = Vec::new();
957
958 for action in regular {
959 let action_type = action
960 .get("type")
961 .and_then(|t| t.as_str())
962 .unwrap_or("unknown");
963
964 // Skip validation for actions that don't map to CommonAction (protocol-specific actions)
965 // These will be validated later by their respective handlers
966 let is_common_action = matches!(
967 action_type,
968 "open_server"
969 | "open_client"
970 | "close_server"
971 | "close_client"
972 | "close_all_servers"
973 | "close_all_clients"
974 | "update_instruction"
975 | "change_model"
976 | "set_memory"
977 | "schedule_task"
978 | "cancel_task"
979 | "show_message"
980 | "close_connection_by_id"
981 | "reconnect_client"
982 | "update_client_instruction"
983 );
984
985 if is_common_action {
986 // Try to parse as CommonAction to validate
987 match crate::llm::actions::common::CommonAction::from_json(&action) {
988 Ok(_) => valid_regular.push(action),
989 Err(e) => {
990 let error = if self.private_payloads {
991 "invalid credential-bearing action".into()
992 } else {
993 e.to_string()
994 };
995 malformed_actions.push((action, error));
996 }
997 }
998 } else {
999 // Non-common actions pass through without validation here
1000 valid_regular.push(action);
1001 }
1002 }
1003
1004 // If we have malformed actions, trigger retry
1005 if !malformed_actions.is_empty() {
1006 malformed_action_retries += 1;
1007
1008 // Log error with the actual malformed actions
1009 for (action_json, error) in &malformed_actions {
1010 error!(
1011 "LLM returned malformed action: {}. Error: {}. JSON: {}",
1012 action_json
1013 .get("type")
1014 .and_then(|t| t.as_str())
1015 .unwrap_or("unknown"),
1016 error,
1017 self.display_payload(
1018 &serde_json::to_string(action_json)
1019 .unwrap_or_else(|_| action_json.to_string())
1020 )
1021 );
1022 }
1023
1024 if let Some(ref tx) = self.status_tx {
1025 for (action_json, error) in &malformed_actions {
1026 let action_type = action_json
1027 .get("type")
1028 .and_then(|t| t.as_str())
1029 .unwrap_or("unknown");
1030 let _ = tx.send(format!(
1031 "[ERROR] Malformed action '{}': {}",
1032 action_type, error
1033 ));
1034 // Show the actual JSON that was returned
1035 let _ = tx.send(format!(
1036 "[ERROR] JSON: {}",
1037 self.display_payload(
1038 &serde_json::to_string(action_json)
1039 .unwrap_or_else(|_| action_json.to_string())
1040 )
1041 ));
1042 }
1043 }
1044
1045 if malformed_action_retries >= MAX_MALFORMED_ACTION_RETRIES {
1046 // All retries exhausted - return error
1047 let action_types: Vec<_> = malformed_actions
1048 .iter()
1049 .map(|(a, _)| a.get("type").and_then(|t| t.as_str()).unwrap_or("unknown"))
1050 .collect();
1051 error!(
1052 "LLM failed to provide valid actions after {} retries. Malformed: {:?}",
1053 malformed_action_retries, action_types
1054 );
1055 if let Some(ref tx) = self.status_tx {
1056 let _ = tx.send(format!(
1057 "[ERROR] LLM failed to provide valid actions after {} retries",
1058 malformed_action_retries
1059 ));
1060 }
1061 anyhow::bail!(
1062 "LLM returned malformed action(s) after {} retries: {:?}",
1063 malformed_action_retries,
1064 action_types
1065 );
1066 }
1067
1068 // Build retry prompt for malformed actions
1069 let correction =
1070 crate::llm::prompt::PromptBuilder::build_malformed_action_retry_prompt(
1071 &malformed_actions,
1072 );
1073
1074 trace!(
1075 "→ Sending malformed action correction and retrying (attempt {})...",
1076 malformed_action_retries + 1
1077 );
1078 if let Some(ref tx) = self.status_tx {
1079 let _ = tx.send(format!(
1080 "[INFO] → Retrying due to malformed action(s) (attempt {}/{})...",
1081 malformed_action_retries, MAX_MALFORMED_ACTION_RETRIES
1082 ));
1083 }
1084
1085 // The rejected response is already in `messages` (pushed above); pushing it a
1086 // second time only made the model read its own mistake twice.
1087 rejected_block_start.get_or_insert((assistant_idx, self.trim_generation));
1088
1089 // Add correction as user message
1090 self.messages.push(Message::user(correction));
1091
1092 // Continue to next iteration to retry
1093 continue;
1094 }
1095
1096 // This response passed validation, so the rejected ones before it are superseded:
1097 // drop them and their corrections instead of re-sending them every iteration.
1098 if let Some((start, generation)) = rejected_block_start.take() {
1099 // A trim between the rejection and now would have shifted every index.
1100 if generation == self.trim_generation
1101 && assistant_idx > start
1102 && assistant_idx <= self.messages.len()
1103 {
1104 let dropped = assistant_idx - start;
1105 self.messages.drain(start..assistant_idx);
1106 self.last_logged_index = self.last_logged_index.min(self.messages.len());
1107 debug!(
1108 "Dropped {} superseded message(s) from rejected action attempt(s)",
1109 dropped
1110 );
1111 }
1112 }
1113
1114 // An action may not reference a tool result it has not been given yet.
1115 //
1116 // A model that emits a tool call and, in the *same* response, an action
1117 // whose parameter is `{{tools[0].result}}` is describing a substitution
1118 // nothing here performs: `reference_parser` resolves `<tagname>` XML
1119 // references, not `{{...}}`, so the placeholder survives verbatim. Without
1120 // this check the action was collected below, the tools ran, and the literal
1121 // string `{{tools[0].result}}` went out on the wire as if it were the value
1122 // — a BitTorrent `send_piece` whose block_hex is that text, a session token
1123 // that is that text. Silently wrong in the worst way: the action looked
1124 // well-formed at every layer that inspected it.
1125 //
1126 // The recovery is the one the model expects anyway: run the tools, hand it
1127 // the results, and let it re-emit the action with the real value. Nothing is
1128 // collected from this response, so the placeholder never reaches execution.
1129 let (deferred, ready): (Vec<_>, Vec<_>) = valid_regular
1130 .into_iter()
1131 .partition(|action| action_references_tool_result(action));
1132
1133 if !deferred.is_empty() {
1134 let names: Vec<&str> = deferred
1135 .iter()
1136 .filter_map(|a| a.get("type").and_then(|t| t.as_str()))
1137 .collect();
1138 // Dual-logged through the facade rather than a hand-written level
1139 // prefix; `tests/llm_log_prefix_guard_test.rs` fails the build on any
1140 // new one of those in src/llm/. (That guard counts substrings without
1141 // parsing, so even naming the pattern in a comment trips it.)
1142 Log::new(self.status_tx.as_ref())
1143 .with_payloads(!self.private_payloads)
1144 .warn(format!(
1145 "LLM action(s) {:?} reference a tool result that has not been produced \
1146 yet; holding them back until the tool(s) have run",
1147 names
1148 ));
1149
1150 if tools.is_empty() {
1151 // No tool call to wait for: the placeholder can never be filled in,
1152 // so this is simply a malformed action. Ask for a literal value.
1153 self.messages.push(Message::user(format!(
1154 "The action(s) {:?} contain a placeholder like \
1155 \"{{{{tools[0].result}}}}\". Placeholders are never substituted: \
1156 whatever you write is used literally. You did not call any tool \
1157 in that response either. Re-send the action with the actual \
1158 value written out in full.",
1159 names
1160 )));
1161 rejected_block_start.get_or_insert((assistant_idx, self.trim_generation));
1162 continue;
1163 }
1164
1165 // Tools are pending; they run below and their results are appended as a
1166 // user message, after which the model re-emits the action for real.
1167 self.messages.push(Message::user(format!(
1168 "Hold on: the action(s) {:?} referenced a tool result with a \
1169 placeholder, but placeholders are never substituted — whatever you \
1170 write is sent literally. Your tool call(s) are running now. Wait for \
1171 the results below, then re-send those action(s) with the actual \
1172 values written out in full.",
1173 names
1174 )));
1175 }
1176
1177 // Only a final response commits actions. An intermediate tool round can
1178 // contain a draft answer, which the next response may repeat or replace.
1179 // Keeping it would execute both drafts (for example, two IMAP greetings).
1180 if tools.is_empty() {
1181 final_actions = Some(ready);
1182 debug!("No tool calls in response, finishing conversation");
1183 break;
1184 }
1185
1186 if !ready.is_empty() {
1187 self.messages.push(Message::user(
1188 "Actions in a response with tool calls are drafts and have not been executed. \
1189 After reading the tool results, return all actions to execute in one \
1190 final response with no tool calls."
1191 .to_string(),
1192 ));
1193 }
1194
1195 // If this is the last iteration, warn about unused tool calls
1196 if iteration == self.max_tool_iterations {
1197 warn!(
1198 "Maximum iterations reached with {} pending tool calls",
1199 tools.len()
1200 );
1201 if let Some(ref tx) = self.status_tx {
1202 let _ = tx.send(format!(
1203 "[WARN] Maximum iterations ({}) reached with {} pending tool call(s)",
1204 self.max_tool_iterations,
1205 tools.len()
1206 ));
1207 }
1208 anyhow::bail!("Tool iteration limit reached before a final action response");
1209 }
1210
1211 // Execute tool calls
1212 debug!("Executing {} tool calls", tools.len());
1213 if let Some(ref tx) = self.status_tx {
1214 let _ = tx.send(format!("[INFO] Executing {} tool call(s)...", tools.len()));
1215 }
1216 tool_results.clear();
1217
1218 for tool_json in tools {
1219 match ToolAction::from_json(&tool_json) {
1220 Ok(tool_action) => {
1221 info!(
1222 "→ Executing tool: {}",
1223 self.display_payload(&tool_action.describe())
1224 );
1225 if let Some(ref tx) = self.status_tx {
1226 let _ = tx.send(format!(
1227 "[INFO] → Executing tool: {}",
1228 self.display_payload(&tool_action.describe())
1229 ));
1230 }
1231
1232 // Track tool call in conversation state
1233 if let Ok(mut state) = self.conversation_state.lock() {
1234 let tool_name = match &tool_action {
1235 ToolAction::ReadFile { .. } => "read_file",
1236 ToolAction::WebSearch { .. } => "web_search",
1237 ToolAction::ReadBaseStackDocs { .. } => "read_base_stack_docs",
1238 ToolAction::ReadServerDocumentation { .. } => {
1239 "read_server_documentation"
1240 }
1241 ToolAction::ReadClientDocumentation { .. } => {
1242 "read_client_documentation"
1243 }
1244 ToolAction::ReadDocumentation { .. } => "read_documentation",
1245 ToolAction::ListModels => "list_models",
1246 ToolAction::GenerateRandom { .. } => "generate_random",
1247 ToolAction::ListTasks => "list_tasks",
1248 #[cfg(feature = "sqlite")]
1249 ToolAction::ExecuteSql { .. } => "execute_sql",
1250 #[cfg(feature = "sqlite")]
1251 ToolAction::ListDatabases => "list_databases",
1252 };
1253 state.add_tool_call(
1254 tool_name.to_string(),
1255 self.display_payload(&tool_action.describe()),
1256 );
1257 }
1258
1259 // Check if this is a doc reading tool
1260 let is_read_server_docs =
1261 matches!(tool_action, ToolAction::ReadServerDocumentation { .. });
1262 let is_read_client_docs =
1263 matches!(tool_action, ToolAction::ReadClientDocumentation { .. });
1264 let is_read_base_docs =
1265 matches!(tool_action, ToolAction::ReadBaseStackDocs { .. });
1266 let is_read_docs =
1267 matches!(tool_action, ToolAction::ReadDocumentation { .. });
1268
1269 let execution =
1270 execute_tool(&tool_action, approval_tx.as_ref(), web_search_mode, None);
1271 let result = if self.private_payloads {
1272 use tracing::instrument::WithSubscriber;
1273 execution
1274 .with_subscriber(tracing::subscriber::NoSubscriber::default())
1275 .await
1276 } else {
1277 execution.await
1278 };
1279 info!(" Result: {}", self.display_payload(&result.summary()));
1280 if let Some(ref tx) = self.status_tx {
1281 let status = if result.success { "✓" } else { "✗" };
1282 let _ = tx.send(format!(
1283 "[INFO] {} {}",
1284 status,
1285 self.display_payload(&result.summary())
1286 ));
1287 }
1288
1289 // Mark protocol docs as read if the tool succeeded
1290 // This will update the system prompt to enable open_server/open_client actions
1291 if result.success {
1292 if is_read_server_docs {
1293 self.mark_protocol_docs_read(&available_actions);
1294 // Extract protocols and update AppState and ConversationState
1295 if let ToolAction::ReadServerDocumentation {
1296 protocols,
1297 protocol,
1298 } = &tool_action
1299 {
1300 let mut all_protocols = protocols.clone();
1301 if let Some(p) = protocol {
1302 if !all_protocols.contains(p) {
1303 all_protocols.push(p.clone());
1304 }
1305 }
1306 // Update ConversationState to persist documented protocols
1307 if let Ok(mut conv_state) = self.conversation_state.lock() {
1308 conv_state.mark_server_protocols_documented(&all_protocols);
1309 }
1310 // Update AppState (global persistence)
1311 if let Some(ref state) = self.state {
1312 let state_clone = state.clone();
1313 let protocols_clone = all_protocols.clone();
1314 tokio::spawn(async move {
1315 state_clone
1316 .mark_server_protocols_documented(&protocols_clone)
1317 .await;
1318 });
1319 }
1320 }
1321 // Enable open_server in valid actions
1322 if !valid_action_names.contains("open_server") {
1323 valid_action_names.insert("open_server".to_string());
1324 valid_action_names_list.push("open_server".to_string());
1325 }
1326 }
1327 if is_read_client_docs {
1328 self.mark_protocol_docs_read(&available_actions);
1329 // Extract protocols and update AppState and ConversationState
1330 if let ToolAction::ReadClientDocumentation {
1331 protocols,
1332 protocol,
1333 } = &tool_action
1334 {
1335 let mut all_protocols = protocols.clone();
1336 if let Some(p) = protocol {
1337 if !all_protocols.contains(p) {
1338 all_protocols.push(p.clone());
1339 }
1340 }
1341 // Update ConversationState to persist documented protocols
1342 if let Ok(mut conv_state) = self.conversation_state.lock() {
1343 conv_state.mark_client_protocols_documented(&all_protocols);
1344 }
1345 // Update AppState (global persistence)
1346 if let Some(ref state) = self.state {
1347 let state_clone = state.clone();
1348 let protocols_clone = all_protocols.clone();
1349 tokio::spawn(async move {
1350 state_clone
1351 .mark_client_protocols_documented(&protocols_clone)
1352 .await;
1353 });
1354 }
1355 }
1356 // Enable open_client in valid actions
1357 if !valid_action_names.contains("open_client") {
1358 valid_action_names.insert("open_client".to_string());
1359 valid_action_names_list.push("open_client".to_string());
1360 }
1361 }
1362 if is_read_base_docs {
1363 self.mark_protocol_docs_read(&available_actions);
1364 // Enable both open_server and open_client in valid actions
1365 if !valid_action_names.contains("open_server") {
1366 valid_action_names.insert("open_server".to_string());
1367 valid_action_names_list.push("open_server".to_string());
1368 }
1369 if !valid_action_names.contains("open_client") {
1370 valid_action_names.insert("open_client".to_string());
1371 valid_action_names_list.push("open_client".to_string());
1372 }
1373 }
1374 // Handle unified read_documentation tool
1375 if is_read_docs {
1376 self.mark_protocol_docs_read(&available_actions);
1377 // Extract protocols and update both server and client state
1378 if let ToolAction::ReadDocumentation {
1379 protocols,
1380 protocol,
1381 } = &tool_action
1382 {
1383 let mut all_protocols = protocols.clone();
1384 if let Some(p) = protocol {
1385 if !all_protocols.contains(p) {
1386 all_protocols.push(p.clone());
1387 }
1388 }
1389 // Update ConversationState to persist documented protocols (both server and client)
1390 if let Ok(mut conv_state) = self.conversation_state.lock() {
1391 conv_state.mark_server_protocols_documented(&all_protocols);
1392 conv_state.mark_client_protocols_documented(&all_protocols);
1393 }
1394 // Update AppState (global persistence for both server and client)
1395 if let Some(ref state) = self.state {
1396 let state_clone = state.clone();
1397 let protocols_clone = all_protocols.clone();
1398 tokio::spawn(async move {
1399 state_clone
1400 .mark_server_protocols_documented(&protocols_clone)
1401 .await;
1402 state_clone
1403 .mark_client_protocols_documented(&protocols_clone)
1404 .await;
1405 });
1406 }
1407 }
1408 // CRITICAL: Enable open_server and open_client in valid actions
1409 // so the LLM can use them in subsequent iterations
1410 if !valid_action_names.contains("open_server") {
1411 valid_action_names.insert("open_server".to_string());
1412 valid_action_names_list.push("open_server".to_string());
1413 debug!(
1414 "Enabled open_server action after reading documentation"
1415 );
1416 }
1417 if !valid_action_names.contains("open_client") {
1418 valid_action_names.insert("open_client".to_string());
1419 valid_action_names_list.push("open_client".to_string());
1420 debug!(
1421 "Enabled open_client action after reading documentation"
1422 );
1423 }
1424 }
1425 }
1426
1427 tool_results.push(result);
1428 }
1429 Err(e) => {
1430 let error = if self.private_payloads {
1431 "invalid credential-bearing tool action".to_string()
1432 } else {
1433 e.to_string()
1434 };
1435 error!("Failed to parse tool action: {}", error);
1436 if let Some(ref tx) = self.status_tx {
1437 let _ =
1438 tx.send(format!("[ERROR] Failed to parse tool action: {}", error));
1439 }
1440 tool_results.push(ToolResult::error(
1441 "unknown",
1442 "parse_error",
1443 format!("Failed to parse tool action: {}", error),
1444 ));
1445 }
1446 }
1447 }
1448
1449 // Check if all tools failed
1450 let all_failed = !tool_results.is_empty() && tool_results.iter().all(|r| !r.success);
1451 if all_failed {
1452 consecutive_tool_failures += 1;
1453 warn!(
1454 "All {} tool calls failed (consecutive failures: {})",
1455 tool_results.len(),
1456 consecutive_tool_failures
1457 );
1458 if let Some(ref tx) = self.status_tx {
1459 let _ = tx.send(format!(
1460 "[WARN] All {} tool calls failed (consecutive failures: {})",
1461 tool_results.len(),
1462 consecutive_tool_failures
1463 ));
1464 }
1465
1466 if consecutive_tool_failures >= MAX_CONSECUTIVE_FAILURES {
1467 error!(
1468 "Breaking tool calling loop after {} consecutive failures",
1469 consecutive_tool_failures
1470 );
1471 if let Some(ref tx) = self.status_tx {
1472 let _ = tx.send(format!(
1473 "[ERROR] Breaking tool loop after {} consecutive failures",
1474 consecutive_tool_failures
1475 ));
1476 }
1477 // Add a final message explaining the issue
1478 self.messages.push(Message::user(
1479 "CRITICAL: All tool calls are failing. Stop calling tools and respond with regular actions instead.".to_string()
1480 ));
1481 break;
1482 }
1483 } else {
1484 // Reset counter if at least one tool succeeded
1485 consecutive_tool_failures = 0;
1486 }
1487
1488 // Add tool results as a user message for the next iteration
1489 if !tool_results.is_empty() {
1490 let tool_results_text = self.format_tool_results(&tool_results);
1491 self.messages.push(Message::user(tool_results_text));
1492 }
1493
1494 debug!(
1495 "Completed iteration {}/{}, {} tool results provided for next iteration",
1496 iteration,
1497 self.max_tool_iterations,
1498 tool_results.len()
1499 );
1500 if let Some(ref tx) = self.status_tx {
1501 let _ = tx.send(format!(
1502 "[TRACE] Iteration {}/{} complete, continuing with {} tool result(s)...",
1503 iteration,
1504 self.max_tool_iterations,
1505 tool_results.len()
1506 ));
1507 }
1508 }
1509
1510 let all_actions = final_actions.context(
1511 "Conversation ended without a final action response; no draft actions executed",
1512 )?;
1513
1514 // Log details for each action (validation already happened above)
1515 for action in &all_actions {
1516 let action_type = action
1517 .get("type")
1518 .and_then(|t| t.as_str())
1519 .unwrap_or("unknown");
1520
1521 // Build action details string based on action type
1522 let details = match action_type {
1523 "open_server" => {
1524 let port = action.get("port").and_then(|p| p.as_u64());
1525 let base_stack = action.get("base_stack").and_then(|b| b.as_str());
1526 format!(
1527 "port={}, base_stack={}",
1528 port.map(|p| p.to_string())
1529 .unwrap_or_else(|| "auto".to_string()),
1530 base_stack.unwrap_or("?")
1531 )
1532 }
1533 "open_client" => {
1534 let protocol = action.get("protocol").and_then(|p| p.as_str());
1535 let remote_addr = action.get("remote_addr").and_then(|r| r.as_str());
1536 format!(
1537 "protocol={}, remote_addr={}",
1538 protocol.unwrap_or("?"),
1539 remote_addr.unwrap_or("?")
1540 )
1541 }
1542 "close_server" => {
1543 let server_id = action.get("server_id").and_then(|p| p.as_u64());
1544 format!(
1545 "server_id={}",
1546 server_id
1547 .map(|p| p.to_string())
1548 .unwrap_or_else(|| "?".to_string())
1549 )
1550 }
1551 "update_instruction" => {
1552 let instruction = action.get("instruction").and_then(|i| i.as_str());
1553 format!(
1554 "instruction={}",
1555 instruction
1556 .map(|i| {
1557 let preview: String = i.chars().take(30).collect();
1558 if i.len() > 30 {
1559 format!("\"{}...\"", preview)
1560 } else {
1561 format!("\"{}\"", i)
1562 }
1563 })
1564 .unwrap_or_else(|| "?".to_string())
1565 )
1566 }
1567 "show_message" => {
1568 let message = action.get("message").and_then(|m| m.as_str()).unwrap_or("");
1569 let preview: String = message.chars().take(50).collect();
1570 if message.len() > 50 {
1571 format!("\"{}...\"", preview)
1572 } else {
1573 format!("\"{}\"", preview)
1574 }
1575 }
1576 _ => {
1577 // For other actions, show first few keys
1578 let keys: Vec<&str> = action
1579 .as_object()
1580 .map(|obj| {
1581 obj.keys()
1582 .filter(|k| *k != "type")
1583 .take(3)
1584 .map(|s| s.as_str())
1585 .collect()
1586 })
1587 .unwrap_or_default();
1588 if keys.is_empty() {
1589 String::new()
1590 } else {
1591 format!("keys=[{}]", keys.join(", "))
1592 }
1593 }
1594 };
1595
1596 let details = self.display_payload(&details);
1597 let log_msg = if details.is_empty() {
1598 format!("[INFO] → {}", action_type)
1599 } else {
1600 format!("[INFO] → {} ({})", action_type, details)
1601 };
1602
1603 info!("Action: {} {}", action_type, details);
1604 if let Some(ref tx) = self.status_tx {
1605 let _ = tx.send(log_msg);
1606 }
1607 }
1608
1609 info!(
1610 "Conversation complete: {} total actions collected",
1611 all_actions.len()
1612 );
1613 if let Some(ref tx) = self.status_tx {
1614 let _ = tx.send(format!(
1615 "[INFO] ✓ Conversation complete: {} action(s)",
1616 all_actions.len()
1617 ));
1618 }
1619
1620 // End conversation tracking if enabled
1621 if let Some(state) = &self.state {
1622 state.end_conversation(&self.conversation_id).await;
1623 }
1624
1625 Ok(all_actions)
1626 }
1627
1628 /// Generate a response with automatic retry on parse errors
1629 ///
1630 /// Attempts to get a valid ActionResponse from the LLM. If parsing fails,
1631 /// sends a corrective message and retries once.
1632 ///
1633 /// Returns (original_response, cleaned_response):
1634 /// - original_response: Response with reasoning tags (for conversation history)
1635 /// - cleaned_response: Response with reasoning stripped (for JSON parsing)
1636 async fn generate_with_retry(&mut self) -> Result<(String, String)> {
1637 // Where this call's messages start. A parse failure appends the unparseable response
1638 // and a correction; once a valid response supersedes them they are pure noise that
1639 // would otherwise be re-sent for the rest of the conversation, so they are dropped.
1640 let attempt_block_start = self.messages.len();
1641 let attempt_block_generation = self.trim_generation;
1642
1643 for attempt in 1..=self.max_retries + 1 {
1644 // Bound what is actually sent before building the request.
1645 self.trim_history();
1646
1647 // ONE semantic line per request attempt, to BOTH channels. The transport
1648 // (OllamaClient) separately logs the wire facts (model, sizes) file-only, so
1649 // the round-trip is no longer announced twice per layer to each channel.
1650 self.log().info(format!(
1651 "LLM request (attempt {}/{})",
1652 attempt,
1653 self.max_retries + 1
1654 ));
1655
1656 // Message-count summary and the per-message payload dump are DEBUG/TRACE:
1657 // file-only, never streamed to the unbounded TUI channel.
1658 let new_message_count = self.messages.len().saturating_sub(self.last_logged_index);
1659 self.log().debug(format!(
1660 "Conversation state: {} messages, {} new since last call",
1661 self.messages.len(),
1662 new_message_count
1663 ));
1664
1665 if new_message_count > 0 {
1666 let log = self.log();
1667 log.trace("New messages:");
1668 for (idx, msg) in self
1669 .messages
1670 .iter()
1671 .enumerate()
1672 .skip(self.last_logged_index)
1673 {
1674 log.trace(format!(
1675 " Message {}: [{}] {}",
1676 idx + 1,
1677 msg.role,
1678 crate::utils::truncate_for_log(&msg.content, 200)
1679 ));
1680 }
1681 }
1682
1683 // Update the last logged index (track what's been sent, don't re-log)
1684 self.last_logged_index = self.messages.len();
1685
1686 // Acquire rate limiter permit (waits for user requests, discards network requests if limited)
1687 let permit = self
1688 .rate_limiter
1689 .acquire_permit(self.request_source)
1690 .await
1691 .context("Rate limit exceeded")?;
1692
1693 // Choose between native tool calling (chat API) and prompt-based (generate API)
1694 let response_text = if self.use_native_tools {
1695 // Native tool calling path: send structured messages with tool schemas
1696 let chat_request = ChatRequest {
1697 messages: self.messages.clone(),
1698 tools: self.tool_schemas.clone(),
1699 model: self.model.clone(),
1700 offered_actions: self.offered_actions.clone(),
1701 };
1702
1703 let chat_response = self
1704 .client
1705 .chat_with_tools(&chat_request, self.private_payloads)
1706 .await
1707 .context("Chat API call failed")?;
1708
1709 // Record token usage
1710 permit
1711 .record_usage(
1712 chat_response.token_usage.prompt_tokens,
1713 chat_response.token_usage.completion_tokens,
1714 )
1715 .await;
1716
1717 // Convert native tool_calls to ActionResponse text format
1718 // so the existing parsing/validation pipeline works unchanged
1719 if !chat_response.tool_calls.is_empty() {
1720 debug!(
1721 "Native tool calling: {} tool_calls received",
1722 chat_response.tool_calls.len()
1723 );
1724 Self::chat_response_to_action_text(&chat_response)
1725 } else if let Some(ref content) = chat_response.content {
1726 // No tool calls, just text - try to use it as-is
1727 // (may be JSON actions from prompt-based models that ignore tools param)
1728 content.clone()
1729 } else {
1730 // Empty response
1731 "{}".to_string()
1732 }
1733 } else {
1734 // Prompt-based fallback: concatenate messages into single prompt
1735 let mut full_prompt = String::new();
1736 for msg in &self.messages {
1737 match msg.role.as_str() {
1738 "system" => {
1739 full_prompt.push_str(&msg.content);
1740 full_prompt.push_str("\n\n");
1741 }
1742 "user" => {
1743 full_prompt.push_str(&msg.content);
1744 full_prompt.push_str("\n\n");
1745 }
1746 "assistant" => {
1747 // Include previous assistant responses in conversation
1748 full_prompt.push_str("Actions you have executed:\n");
1749 full_prompt.push_str(&msg.content);
1750 full_prompt.push_str("\n\n");
1751 }
1752 _ => {}
1753 }
1754 }
1755
1756 // Call generate API with concatenated prompt
1757 // We rely on prompt engineering for JSON responses rather than format enforcement,
1758 // as some models (e.g., gpt-oss) don't support Ollama's JSON format mode
1759 let generate_response = self
1760 .client
1761 .generate_offering_with_event(
1762 &self.model,
1763 &full_prompt,
1764 None,
1765 &self.offered_actions,
1766 self.bridge_event.as_ref(),
1767 self.private_payloads,
1768 )
1769 .await
1770 .context("Generate API call failed")?;
1771
1772 // Record token usage
1773 permit
1774 .record_usage(
1775 generate_response.token_usage.prompt_tokens,
1776 generate_response.token_usage.completion_tokens,
1777 )
1778 .await;
1779
1780 generate_response.text
1781 };
1782
1783 // ONE semantic line that a response arrived, to BOTH channels (the result
1784 // half of the round-trip). The transport already logged response size/tokens
1785 // file-only, and the full body goes to the file at TRACE below — it is never
1786 // streamed to the TUI.
1787 self.log().info(format!(
1788 "LLM response received (attempt {}): {} chars",
1789 attempt,
1790 response_text.len(),
1791 ));
1792
1793 // Extract reasoning if present (before normalization to preserve formatting)
1794 let (reasoning, cleaned_response) = extract_reasoning(&response_text);
1795
1796 // Reasoning is a full payload: TRACE, file-only.
1797 if let Some(ref reasoning_text) = reasoning {
1798 self.log()
1799 .trace(format!("LLM Reasoning: {}", reasoning_text));
1800 }
1801
1802 // Normalize the cleaned response: collapse whitespace and remove extra newlines
1803 // This handles cases where LLM returns formatted JSON with lots of whitespace
1804 let normalized_response = cleaned_response
1805 .lines()
1806 .map(|line| line.trim())
1807 .collect::<Vec<_>>()
1808 .join("");
1809
1810 // Normalized-response preview is a DEBUG summary and the full body a TRACE
1811 // payload — both file-only. The full normalized body used to be pushed to the
1812 // TUI here, duplicating the transport's own body dump; that is removed.
1813 let log = self.log();
1814 log.debug(format!(
1815 "Response (normalized): {}",
1816 crate::utils::truncate_for_log(&normalized_response, 200)
1817 ));
1818 log.payload(
1819 "LLM response (normalized)",
1820 &normalized_response,
1821 usize::MAX,
1822 );
1823
1824 // Try to parse as ActionResponse (use normalized version for better compatibility)
1825 match ActionResponse::from_str(&normalized_response) {
1826 Ok(action_response) => {
1827 // Valid response!
1828 // Track in conversation state
1829 if let Ok(mut state) = self.conversation_state.lock() {
1830 state.add_llm_response(
1831 normalized_response.clone(),
1832 Some(serde_json::json!(action_response)),
1833 );
1834 }
1835
1836 if attempt > 1 {
1837 info!(
1838 "✓ Retry successful! LLM provided valid format on attempt {}",
1839 attempt
1840 );
1841 if let Some(ref tx) = self.status_tx {
1842 let _ = tx
1843 .send(format!("[INFO] ✓ Retry successful on attempt {}", attempt));
1844 }
1845 // This response supersedes the failed attempts: drop them and their
1846 // corrections rather than carrying them for the rest of the
1847 // conversation. Nothing was appended on the success path, so the
1848 // block is exactly what the failed attempts added.
1849 if self.trim_generation == attempt_block_generation
1850 && self.messages.len() > attempt_block_start
1851 {
1852 let dropped = self.messages.len() - attempt_block_start;
1853 self.messages.truncate(attempt_block_start);
1854 self.last_logged_index =
1855 self.last_logged_index.min(self.messages.len());
1856 debug!(
1857 "Dropped {} superseded message(s) from {} failed parse attempt(s)",
1858 dropped,
1859 attempt - 1
1860 );
1861 }
1862 } else {
1863 info!("✓ Valid response format on first attempt");
1864 }
1865 // Return both original (with reasoning) and normalized (for parsing)
1866 return Ok((response_text.clone(), normalized_response));
1867 }
1868 Err(e) => {
1869 let e = if self.private_payloads {
1870 anyhow::anyhow!("invalid credential-bearing model response")
1871 } else {
1872 e
1873 };
1874 if attempt <= self.max_retries {
1875 // We have retries left, send corrective feedback
1876 warn!("✗ Parse error on attempt {}: {}", attempt, e);
1877 warn!(
1878 "Malformed response (raw): {}",
1879 if response_text.is_empty() {
1880 "(empty response)".to_string()
1881 } else {
1882 self.display_payload(&crate::utils::truncate_for_log(
1883 &response_text,
1884 500,
1885 ))
1886 }
1887 );
1888
1889 if let Some(ref tx) = self.status_tx {
1890 let error_preview = if normalized_response.is_empty() {
1891 "(empty)".to_string()
1892 } else {
1893 self.display_payload(&crate::utils::truncate_for_log(
1894 &normalized_response,
1895 100,
1896 ))
1897 };
1898 let _ = tx.send(format!(
1899 "[WARN] ✗ Invalid format (attempt {}): {}. Response: {}",
1900 attempt, e, error_preview
1901 ));
1902 }
1903
1904 // Add the malformed response as an assistant message (use normalized for conversation)
1905 self.messages
1906 .push(Message::assistant(normalized_response.clone()));
1907
1908 // Track invalid response in conversation state
1909 if let Ok(mut state) = self.conversation_state.lock() {
1910 state.add_llm_response(normalized_response.clone(), None);
1911 }
1912
1913 // Build corrective user message using minimal retry prompt
1914 let correction =
1915 crate::llm::prompt::PromptBuilder::build_retry_prompt(&e.to_string());
1916 debug!(
1917 "Correction message preview: {}",
1918 crate::utils::truncate_for_log(&correction, 200)
1919 );
1920
1921 // Track retry instruction in conversation state
1922 if let Ok(mut state) = self.conversation_state.lock() {
1923 state.add_retry_instruction(correction.clone());
1924 }
1925
1926 info!(
1927 "→ Sending correction and retrying (attempt {})...",
1928 attempt + 1
1929 );
1930 if let Some(ref tx) = self.status_tx {
1931 let _ = tx.send("[INFO] → Sending correction to LLM...".to_string());
1932 // Show the correction message being sent (indented and dimmed)
1933 for line in crate::llm::format_indented_dimmed_lines(
1934 &self.display_payload(&correction),
1935 8,
1936 ) {
1937 let _ = tx.send(format!("[INFO] {}", line));
1938 }
1939 }
1940
1941 self.messages.push(Message::user(correction));
1942 } else {
1943 // No more retries
1944 error!("✗ Failed to get valid response after {} attempts", attempt);
1945 if let Some(ref tx) = self.status_tx {
1946 let _ = tx.send(format!(
1947 "[ERROR] ✗ Failed after {} attempts: {}",
1948 attempt, e
1949 ));
1950 }
1951 return Err(e).context("LLM failed to provide valid format after retry");
1952 }
1953 }
1954 }
1955 }
1956
1957 unreachable!("Loop should always return or error")
1958 }
1959
1960 /// Format tool results for inclusion in the next message
1961 fn format_tool_results(&self, results: &[ToolResult]) -> String {
1962 let mut formatted = String::from("Tool execution results:\n\n");
1963
1964 for (i, result) in results.iter().enumerate() {
1965 formatted.push_str(&format!("{}. {}\n", i + 1, result.summary()));
1966 formatted.push_str(&format!(
1967 "Status: {}\n",
1968 if result.success { "Success" } else { "Error" }
1969 ));
1970
1971 // Truncate very long results on a char boundary, telling the model
1972 // explicitly how much was omitted so it can narrow its next request.
1973 let result_text = &result.result;
1974 formatted.push_str(&format!(
1975 "Result: {}\n",
1976 crate::utils::truncate_with_notice(result_text, 2000)
1977 ));
1978
1979 formatted.push('\n');
1980 }
1981
1982 formatted
1983 }
1984
1985 /// Get the current conversation messages (for debugging)
1986 pub fn messages(&self) -> &[Message] {
1987 &self.messages
1988 }
1989
1990 /// Get the number of messages in the conversation
1991 pub fn message_count(&self) -> usize {
1992 self.messages.len()
1993 }
1994
1995 /// Get the conversation ID
1996 pub fn conversation_id(&self) -> &str {
1997 &self.conversation_id
1998 }
1999
2000 /// Mark this conversation as already registered (to prevent duplicate registration)
2001 pub fn mark_registered(&mut self) {
2002 self.registered = true;
2003 }
2004
2005 /// Update the "Current State" section in the system message
2006 ///
2007 /// This is used after actions like `open_server` that modify application state.
2008 /// The system message is rebuilt with updated state so subsequent tool calls
2009 /// see the current state.
2010 ///
2011 /// # Arguments
2012 /// * `state` - Application state
2013 /// * `server_id` - Optional server context
2014 pub async fn update_current_state(
2015 &mut self,
2016 state: &crate::state::app_state::AppState,
2017 server_id: Option<crate::state::ServerId>,
2018 ) {
2019 use crate::llm::prompt::PromptBuilder;
2020
2021 if self.messages.is_empty() {
2022 warn!("Cannot update current state: no system message found");
2023 return;
2024 }
2025
2026 // Get the system message (first message)
2027 let system_msg = &self.messages[0];
2028 if system_msg.role != "system" {
2029 warn!("First message is not a system message, cannot update current state");
2030 return;
2031 }
2032
2033 let old_content = &system_msg.content;
2034
2035 // Find the "# Current State" section
2036 if let Some(state_start) = old_content.find("# Current State") {
2037 // Find the next section (starts with "# ")
2038 let state_content_start = state_start;
2039 let state_end = old_content[state_content_start..]
2040 .find("\n# ")
2041 .map(|pos| state_content_start + pos)
2042 .unwrap_or(old_content.len());
2043
2044 // Build new current state section
2045 let new_state_section =
2046 PromptBuilder::build_current_state_section_public(state, server_id).await;
2047
2048 // Replace the old state section with the new one
2049 let mut new_content = String::new();
2050 new_content.push_str(&old_content[..state_start]);
2051 new_content.push_str(&new_state_section);
2052 // Don't include the newline before next section, it's already in new_state_section
2053 if state_end < old_content.len() {
2054 new_content.push_str(&old_content[state_end..]);
2055 }
2056
2057 // Update the system message
2058 self.messages[0] = Message::system(new_content);
2059
2060 debug!("Updated Current State section in system message");
2061 } else {
2062 warn!("Could not find '# Current State' section in system message");
2063 }
2064 }
2065}