Skip to main content

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}