Skip to main content

batuta/agent/
runtime.rs

1//! Agent runtime — the core perceive-reason-act loop.
2//!
3//! Orchestrates perceive (recall) → reason (LLM) → act (tools) → guard (Jidoka).
4//! See: arXiv:2512.10350 (loop dynamics), arXiv:2501.09136 (agentic RAG).
5
6use std::sync::Arc;
7
8use tokio::sync::mpsc;
9use tracing::{debug, info, instrument, warn};
10
11use super::capability::capability_matches;
12use super::driver::{
13    CompletionRequest, CompletionResponse, LlmDriver, Message, StreamEvent, ToolCall, ToolResultMsg,
14};
15use super::guard::{LoopGuard, LoopVerdict};
16use super::manifest::AgentManifest;
17use super::memory::{MemorySource, MemorySubstrate};
18use super::phase::LoopPhase;
19use super::result::{AgentError, AgentLoopResult, StopReason};
20use super::runtime_helpers::{call_with_retry, emit, truncate_messages};
21use super::tool::ToolRegistry;
22use crate::serve::context::{
23    ContextConfig, ContextManager, ContextWindow, TokenEstimator, TruncationStrategy,
24};
25
26/// Run the agent loop to completion (single-turn, no history).
27#[instrument(skip_all, fields(agent = %manifest.name, query_len = query.len()))]
28#[cfg_attr(
29    feature = "agents-contracts",
30    provable_contracts_macros::contract("agent-loop-v1", equation = "loop_termination")
31)]
32pub async fn run_agent_loop(
33    manifest: &AgentManifest,
34    query: &str,
35    driver: &dyn LlmDriver,
36    tools: &ToolRegistry,
37    memory: &dyn MemorySubstrate,
38    stream_tx: Option<mpsc::Sender<StreamEvent>>,
39) -> Result<AgentLoopResult, AgentError> {
40    let mut history = Vec::new();
41    run_agent_turn(manifest, &mut history, query, driver, tools, memory, stream_tx).await
42}
43
44/// PMAT-177: Agent loop with tool-use nudge. If first turn has no tool calls, retries once.
45pub async fn run_agent_loop_with_nudge(
46    manifest: &AgentManifest,
47    query: &str,
48    driver: &dyn LlmDriver,
49    tools: &ToolRegistry,
50    memory: &dyn MemorySubstrate,
51    stream_tx: Option<mpsc::Sender<StreamEvent>>,
52) -> Result<AgentLoopResult, AgentError> {
53    let mut history = Vec::new();
54    let r = run_agent_turn(manifest, &mut history, query, driver, tools, memory, stream_tx.clone())
55        .await?;
56    if r.tool_calls == 0 && tools.len() > 0 {
57        info!("no tool calls on first turn, nudging");
58        let nudge =
59            "Use a tool to answer. Emit a <tool_call> block with glob, file_read, or shell.";
60        return run_agent_turn(manifest, &mut history, nudge, driver, tools, memory, stream_tx)
61            .await;
62    }
63    Ok(r)
64}
65
66/// Run one agent turn with full conversation history (multi-turn).
67///
68/// Appends the new query and all resulting messages (tool calls, assistant
69/// response) to `history`. On next call, prior turns provide context so
70/// the agent can maintain coherent multi-turn conversation.
71///
72/// This is the core primitive for the REPL — each user prompt is one turn,
73/// and `history` accumulates across the session.
74#[instrument(skip_all, fields(agent = %manifest.name, query_len = query.len(), history_len = history.len()))]
75pub async fn run_agent_turn(
76    manifest: &AgentManifest,
77    history: &mut Vec<Message>,
78    query: &str,
79    driver: &dyn LlmDriver,
80    tools: &ToolRegistry,
81    memory: &dyn MemorySubstrate,
82    stream_tx: Option<mpsc::Sender<StreamEvent>>,
83) -> Result<AgentLoopResult, AgentError> {
84    // ═══ PRIVACY GATE (Poka-Yoke) ═══
85    // Defense-in-depth: block non-local MCP transports under Sovereign tier.
86    #[cfg(feature = "agents-mcp")]
87    validate_mcp_privacy(manifest)?;
88
89    let mut guard = LoopGuard::new(
90        manifest.resources.max_iterations,
91        manifest.resources.max_tool_calls,
92        manifest.resources.max_cost_usd,
93    )
94    .with_token_budget(manifest.resources.max_tokens_budget);
95
96    // ═══ PERCEIVE ═══
97    emit(stream_tx.as_ref(), StreamEvent::PhaseChange { phase: LoopPhase::Perceive }).await;
98
99    let system = build_system_prompt(manifest, query, memory).await;
100    let tool_defs = tools.definitions_for(&manifest.capabilities);
101    info!(
102        tools = tool_defs.len(),
103        capabilities = manifest.capabilities.len(),
104        history_messages = history.len(),
105        "agent turn initialized"
106    );
107    let context = build_context(driver, &system, &tool_defs, manifest);
108
109    // Build messages: prior history + new user query
110    let mut messages = history.clone();
111    messages.push(Message::User(query.to_string()));
112
113    let mut last_tool_sig: Option<String> = None; // PMAT-172: stuck-loop detection
114    let mut repeat_count: u32 = 0;
115
116    loop {
117        check_verdict(guard.check_iteration())?;
118        debug!(
119            iteration = guard.current_iteration(),
120            tool_calls = guard.total_tool_calls(),
121            "loop iteration start"
122        );
123
124        // ═══ REASON ═══
125        emit(stream_tx.as_ref(), StreamEvent::PhaseChange { phase: LoopPhase::Reason }).await;
126
127        let response =
128            reason_step(driver, &messages, &tool_defs, manifest, &system, &context).await?;
129        check_verdict(guard.record_usage(&response.usage))?;
130
131        // INV-005: Estimate cost and enforce budget (Muda)
132        let cost = driver.estimate_cost(&response.usage);
133        check_verdict(guard.record_cost(cost))?;
134
135        match response.stop_reason {
136            StopReason::EndTurn | StopReason::StopSequence => {
137                info!(
138                    iterations = guard.current_iteration(),
139                    tool_calls = guard.total_tool_calls(),
140                    "turn complete"
141                );
142                let new_start = history.len();
143                for msg in &messages[new_start..] {
144                    history.push(msg.clone());
145                }
146                // CCPA-m296: only retain GENUINE assistant prose in history. If the
147                // final text still carries un-parsed tool-call markup (a defense-in-
148                // depth residue the driver's parser+salvage couldn't recover), pushing
149                // it verbatim as Message::Assistant re-injects capability-breaking raw
150                // Markdown into the NEXT turn's prompt — the self-reinforcing text loop
151                // that erodes tool-calling. Strip the residue first; never re-prime prose.
152                let retained = retain_assistant_text(&response.text);
153                if !retained.is_empty() {
154                    history.push(Message::Assistant(retained));
155                }
156                return finish_loop(&response, &guard, manifest, query, memory, stream_tx.as_ref())
157                    .await;
158            }
159            StopReason::ToolUse => {
160                // PMAT-172: detect stuck loops (4+ identical tool calls → break)
161                let sig = response.tool_calls.first().map(|tc| format!("{}:{}", tc.name, tc.input));
162                if sig == last_tool_sig {
163                    repeat_count += 1;
164                } else {
165                    last_tool_sig = sig;
166                    repeat_count = 1;
167                }
168                if repeat_count >= 4 {
169                    warn!("stuck loop: same tool call repeated {repeat_count} times");
170                    return finish_loop(
171                        &response,
172                        &guard,
173                        manifest,
174                        query,
175                        memory,
176                        stream_tx.as_ref(),
177                    )
178                    .await;
179                }
180                debug!(num_calls = response.tool_calls.len(), "processing tool calls");
181                guard.reset_max_tokens();
182                handle_tool_calls(
183                    &response,
184                    &mut messages,
185                    &mut guard,
186                    manifest,
187                    tools,
188                    stream_tx.as_ref(),
189                )
190                .await?;
191            }
192            StopReason::MaxTokens => {
193                warn!("max tokens reached, continuing loop");
194                check_verdict(guard.record_max_tokens())?;
195                messages.push(Message::Assistant(response.text));
196            }
197        }
198    }
199}
200
201/// CCPA-m296: sanitize an assistant turn's text before it enters multi-turn
202/// history. Strips any lingering tool-call markup so a prior tool-using turn is
203/// NEVER re-rendered as capability-breaking raw Markdown that re-primes prose
204/// mode on the next turn.
205///
206/// Genuine text turns pass through unchanged. Turns whose entire content was
207/// tool-call markup collapse to empty (the structured `AssistantToolUse` /
208/// `ToolResult` messages already carry that turn's tool semantics into history).
209///
210/// Returns the trimmed prose with `<tool_call>...</tool_call>` / unclosed
211/// `<tool_call>` / `<tool_result>...</tool_result>` spans removed.
212fn retain_assistant_text(text: &str) -> String {
213    let mut out = text.to_string();
214
215    // Remove well-formed <tool_call>...</tool_call> spans.
216    out = strip_spans(&out, "<tool_call>", "</tool_call>");
217    // Remove <tool_result>...</tool_result> spans (model sometimes echoes them).
218    out = strip_spans(&out, "<tool_result>", "</tool_result>");
219
220    // Remove an unclosed trailing <tool_call> (small models omit the close tag):
221    // everything from the marker to end-of-string is tool-call residue, not prose.
222    if let Some(pos) = out.find("<tool_call>") {
223        out.truncate(pos);
224    }
225
226    out.trim().to_string()
227}
228
229/// Remove every `open..close` span (inclusive) from `text`. Spans without a
230/// matching close are left untouched (handled separately by the caller).
231fn strip_spans(text: &str, open: &str, close: &str) -> String {
232    let mut out = String::with_capacity(text.len());
233    let mut cursor = text;
234    loop {
235        let Some(start) = cursor.find(open) else {
236            out.push_str(cursor);
237            break;
238        };
239        let after_open = &cursor[start + open.len()..];
240        let Some(rel_end) = after_open.find(close) else {
241            // No closing tag — leave the remainder for the caller to handle.
242            out.push_str(cursor);
243            break;
244        };
245        out.push_str(&cursor[..start]);
246        cursor = &after_open[rel_end + close.len()..];
247    }
248    out
249}
250
251fn check_verdict(verdict: LoopVerdict) -> Result<(), AgentError> {
252    match verdict {
253        LoopVerdict::CircuitBreak(msg) | LoopVerdict::Block(msg) => {
254            Err(AgentError::CircuitBreak(msg))
255        }
256        LoopVerdict::Allow | LoopVerdict::Warn(_) => Ok(()),
257    }
258}
259
260async fn reason_step(
261    driver: &dyn LlmDriver,
262    messages: &[Message],
263    tool_defs: &[super::driver::ToolDefinition],
264    manifest: &AgentManifest,
265    system: &str,
266    context: &ContextManager,
267) -> Result<CompletionResponse, AgentError> {
268    let truncated_messages = truncate_messages(messages, context)?;
269
270    let request = CompletionRequest {
271        model: String::new(),
272        messages: truncated_messages,
273        tools: tool_defs.to_vec(),
274        max_tokens: manifest.model.max_tokens,
275        temperature: manifest.model.temperature,
276        system: Some(system.to_string()),
277    };
278
279    call_with_retry(driver, &request).await
280}
281
282async fn finish_loop(
283    response: &CompletionResponse,
284    guard: &LoopGuard,
285    manifest: &AgentManifest,
286    query: &str,
287    memory: &dyn MemorySubstrate,
288    stream_tx: Option<&mpsc::Sender<StreamEvent>>,
289) -> Result<AgentLoopResult, AgentError> {
290    let _ = memory
291        .remember(
292            &manifest.name,
293            &format!("Q: {query}\nA: {}", response.text),
294            MemorySource::Conversation,
295            None,
296        )
297        .await;
298
299    emit(stream_tx, StreamEvent::PhaseChange { phase: LoopPhase::Done }).await;
300
301    Ok(AgentLoopResult {
302        text: response.text.clone(),
303        usage: guard.usage().clone(),
304        iterations: guard.current_iteration(),
305        tool_calls: guard.total_tool_calls(),
306    })
307}
308
309async fn build_system_prompt(
310    manifest: &AgentManifest,
311    query: &str,
312    memory: &dyn MemorySubstrate,
313) -> String {
314    let memories = memory.recall(query, 5, None, None).await.unwrap_or_default();
315
316    let mut system = manifest.model.system_prompt.clone();
317    if !memories.is_empty() {
318        use std::fmt::Write;
319        system.push_str("\n\n## Recalled Context\n");
320        for m in &memories {
321            let _ = writeln!(system, "- {}", m.content);
322        }
323    }
324    system
325}
326
327fn build_context(
328    driver: &dyn LlmDriver,
329    system: &str,
330    tool_defs: &[super::driver::ToolDefinition],
331    manifest: &AgentManifest,
332) -> ContextManager {
333    let estimator = TokenEstimator::new();
334    let system_tokens = estimator.estimate(system);
335    let tool_json = serde_json::to_string(tool_defs).unwrap_or_default();
336    let tool_tokens = estimator.estimate(&tool_json);
337    let context_window = driver.context_window();
338    let effective_window = context_window.saturating_sub(system_tokens).saturating_sub(tool_tokens);
339    ContextManager::new(ContextConfig {
340        window: ContextWindow::new(effective_window, manifest.model.max_tokens as usize),
341        strategy: TruncationStrategy::SlidingWindow,
342        preserve_system: false,
343        min_messages: 2,
344    })
345}
346
347/// Process tool calls from a completion response.
348#[instrument(skip_all, fields(num_calls = response.tool_calls.len()))]
349async fn handle_tool_calls(
350    response: &CompletionResponse,
351    messages: &mut Vec<Message>,
352    guard: &mut LoopGuard,
353    manifest: &AgentManifest,
354    tools: &ToolRegistry,
355    stream_tx: Option<&mpsc::Sender<StreamEvent>>,
356) -> Result<(), AgentError> {
357    for call in &response.tool_calls {
358        let Some(tool) = tools.get(&call.name) else {
359            push_tool_error(messages, call, &format!("unknown tool: {}", call.name));
360            continue;
361        };
362
363        // Poka-Yoke: capability check
364        let cap = tool.required_capability();
365        if !capability_matches(&manifest.capabilities, &cap) {
366            push_tool_error(messages, call, &format!("capability denied for tool '{}'", call.name));
367            continue;
368        }
369
370        // Poka-Yoke: sovereign privacy blocks network egress
371        if manifest.privacy == crate::serve::backends::PrivacyTier::Sovereign
372            && matches!(cap, super::capability::Capability::Network { .. })
373        {
374            push_tool_error(messages, call, "sovereign privacy blocks network egress");
375            continue;
376        }
377
378        // Jidoka: loop guard check
379        match guard.check_tool_call(&call.name, &call.input) {
380            LoopVerdict::Allow | LoopVerdict::Warn(_) => {}
381            LoopVerdict::Block(msg) => {
382                push_tool_error(messages, call, &msg);
383                continue;
384            }
385            LoopVerdict::CircuitBreak(msg) => {
386                return Err(AgentError::CircuitBreak(msg));
387            }
388        }
389
390        // ═══ ACT ═══
391        let result = execute_tool(call, tool, stream_tx).await;
392
393        messages.push(Message::AssistantToolUse(ToolCall {
394            id: call.id.clone(),
395            name: call.name.clone(),
396            input: call.input.clone(),
397        }));
398        messages.push(Message::ToolResult(ToolResultMsg {
399            tool_use_id: call.id.clone(),
400            content: result.content,
401            is_error: result.is_error,
402        }));
403    }
404    Ok(())
405}
406
407/// Execute a single tool call with tracing, timeout, and sanitization.
408async fn execute_tool(
409    call: &ToolCall,
410    tool: &dyn super::tool::Tool,
411    stream_tx: Option<&mpsc::Sender<StreamEvent>>,
412) -> super::tool::ToolResult {
413    let tool_span = tracing::info_span!(
414        "tool_execute",
415        tool = %call.name,
416        id = %call.id,
417    );
418    let _enter = tool_span.enter();
419
420    emit(
421        stream_tx,
422        StreamEvent::PhaseChange { phase: LoopPhase::Act { tool_name: call.name.clone() } },
423    )
424    .await;
425
426    emit(stream_tx, StreamEvent::ToolUseStart { id: call.id.clone(), name: call.name.clone() })
427        .await;
428
429    let result = tokio::time::timeout(tool.timeout(), tool.execute(call.input.clone()))
430        .await
431        .unwrap_or_else(|elapsed| {
432            warn!(tool = %call.name, timeout = ?elapsed, "tool execution timed out");
433            super::tool::ToolResult::error(format!(
434                "tool '{}' timed out after {:?}",
435                call.name, elapsed
436            ))
437        })
438        .sanitized(); // Poka-Yoke: strip injection patterns from tool output
439
440    debug!(
441        tool = %call.name,
442        is_error = result.is_error,
443        output_len = result.content.len(),
444        "tool execution complete"
445    );
446
447    emit(
448        stream_tx,
449        StreamEvent::ToolUseEnd {
450            id: call.id.clone(),
451            name: call.name.clone(),
452            result: result.content.clone(),
453        },
454    )
455    .await;
456
457    result
458}
459
460fn push_tool_error(messages: &mut Vec<Message>, call: &ToolCall, error: &str) {
461    messages.push(Message::AssistantToolUse(ToolCall {
462        id: call.id.clone(),
463        name: call.name.clone(),
464        input: call.input.clone(),
465    }));
466    messages.push(Message::ToolResult(ToolResultMsg {
467        tool_use_id: call.id.clone(),
468        content: error.to_string(),
469        is_error: true,
470    }));
471}
472
473// truncate_messages, call_with_retry, emit, validate_mcp_privacy
474// extracted to runtime_helpers.rs (PMAT-190: keep under 500-line threshold)
475#[cfg(feature = "agents-mcp")]
476use super::runtime_helpers::validate_mcp_privacy;
477#[cfg(test)]
478#[path = "runtime_tests.rs"]
479mod tests;
480#[cfg(test)]
481#[path = "runtime_tests_advanced.rs"]
482mod tests_advanced;
483#[cfg(test)]
484#[path = "runtime_tests_guards.rs"]
485mod tests_guards;
486#[cfg(test)]
487#[path = "runtime_tests_multi_turn.rs"]
488mod tests_multi_turn;