1use 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#[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
44pub 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#[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 #[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 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 let mut messages = history.clone();
111 messages.push(Message::User(query.to_string()));
112
113 let mut last_tool_sig: Option<String> = None; 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 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 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 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 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
201fn retain_assistant_text(text: &str) -> String {
213 let mut out = text.to_string();
214
215 out = strip_spans(&out, "<tool_call>", "</tool_call>");
217 out = strip_spans(&out, "<tool_result>", "</tool_result>");
219
220 if let Some(pos) = out.find("<tool_call>") {
223 out.truncate(pos);
224 }
225
226 out.trim().to_string()
227}
228
229fn 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 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#[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 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 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 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 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
407async 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(); 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#[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;