1use std::sync::Arc;
19
20use tracing::{debug, error, info, instrument, warn};
21
22use punch_memory::{BoutId, MemorySubstrate};
23use punch_types::{
24 AgentCoordinator, FighterId, FighterManifest, Message, PolicyEngine, PunchError, PunchResult,
25 Role, SandboxEnforcer, ShellBleedDetector, ToolCallResult, ToolDefinition,
26};
27
28use crate::context_budget::ContextBudget;
29use crate::driver::{CompletionRequest, LlmDriver, StopReason, TokenUsage};
30use crate::guard::{GuardConfig, LoopGuard, LoopGuardVerdict};
31use crate::session_repair;
32use crate::tool_executor::{self, ToolExecutionContext};
33
34const MAX_CONTINUATION_LOOPS: usize = 5;
36
37const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 120;
39
40pub struct FighterLoopParams {
42 pub manifest: FighterManifest,
44 pub user_message: String,
46 pub bout_id: BoutId,
48 pub fighter_id: FighterId,
50 pub memory: Arc<MemorySubstrate>,
52 pub driver: Arc<dyn LlmDriver>,
54 pub available_tools: Vec<ToolDefinition>,
56 pub max_iterations: Option<usize>,
58 pub context_window: Option<usize>,
60 pub tool_timeout_secs: Option<u64>,
62 pub coordinator: Option<Arc<dyn AgentCoordinator>>,
64 pub approval_engine: Option<Arc<PolicyEngine>>,
67 pub sandbox: Option<Arc<SandboxEnforcer>>,
70}
71
72#[derive(Debug, Clone)]
74pub struct FighterLoopResult {
75 pub response: String,
77 pub usage: TokenUsage,
79 pub iterations: usize,
81 pub tool_calls_made: usize,
83}
84
85#[instrument(
97 skip(params),
98 fields(
99 fighter = %params.fighter_id,
100 bout = %params.bout_id,
101 fighter_name = %params.manifest.name,
102 )
103)]
104pub async fn run_fighter_loop(params: FighterLoopParams) -> PunchResult<FighterLoopResult> {
105 let max_iterations = params.max_iterations.unwrap_or(50);
106 let context_window = params.context_window.unwrap_or(200_000);
107 let tool_timeout = params
108 .tool_timeout_secs
109 .unwrap_or(DEFAULT_TOOL_TIMEOUT_SECS);
110
111 let budget = ContextBudget::new(context_window);
112 let mut guard = LoopGuard::with_config(GuardConfig {
113 max_iterations,
114 ..Default::default()
115 });
116 let mut total_usage = TokenUsage::default();
117 let mut tool_calls_made: usize = 0;
118 let mut continuation_count: usize = 0;
119
120 let mut messages = params.memory.load_messages(¶ms.bout_id).await?;
122 debug!(history_len = messages.len(), "loaded bout message history");
123
124 let repair_stats = session_repair::repair_session(&mut messages);
126 if repair_stats.any_repairs() {
127 info!(repairs = %repair_stats, "repaired loaded message history");
128 }
129
130 let user_msg = Message::new(Role::User, ¶ms.user_message);
132 params
133 .memory
134 .save_message(¶ms.bout_id, &user_msg)
135 .await?;
136 messages.push(user_msg);
137
138 let system_prompt =
140 build_system_prompt(¶ms.manifest, ¶ms.fighter_id, ¶ms.memory).await;
141
142 let tool_context = ToolExecutionContext {
144 working_dir: std::env::current_dir().unwrap_or_default(),
145 fighter_id: params.fighter_id,
146 memory: Arc::clone(¶ms.memory),
147 coordinator: params.coordinator.clone(),
148 approval_engine: params.approval_engine.clone(),
149 sandbox: params.sandbox.clone(),
150 bleed_detector: Some(Arc::new(ShellBleedDetector::new())),
151 browser_pool: None,
152 };
153
154 loop {
156 if let Some(trim_action) = budget.check_trim_needed(&messages, ¶ms.available_tools) {
158 budget.apply_trim(&mut messages, trim_action);
159
160 let post_trim_repair = session_repair::repair_session(&mut messages);
162 if post_trim_repair.any_repairs() {
163 debug!(repairs = %post_trim_repair, "repaired after context trim");
164 }
165 }
166
167 budget.apply_context_guard(&mut messages);
169
170 let request = CompletionRequest {
172 model: params.manifest.model.model.clone(),
173 messages: messages.clone(),
174 tools: params.available_tools.clone(),
175 max_tokens: params.manifest.model.max_tokens.unwrap_or(
176 match params.manifest.model.provider {
180 punch_types::Provider::Ollama => 16384,
181 _ => 4096,
182 }
183 ),
184 temperature: params.manifest.model.temperature,
185 system_prompt: Some(system_prompt.clone()),
186 };
187
188 let completion = match params.driver.complete(request).await {
190 Ok(c) => c,
191 Err(e) => {
192 error!(error = %e, "LLM completion failed");
193 return Err(e);
194 }
195 };
196 total_usage.accumulate(&completion.usage);
197
198 debug!(
199 stop_reason = ?completion.stop_reason,
200 input_tokens = completion.usage.input_tokens,
201 output_tokens = completion.usage.output_tokens,
202 tool_calls = completion.message.tool_calls.len(),
203 "LLM completion received"
204 );
205
206 match completion.stop_reason {
207 StopReason::EndTurn => {
208 if completion.message.content.is_empty() && completion.message.tool_calls.is_empty()
210 {
211 if guard.iterations() == 0 {
212 warn!("empty response on first iteration, retrying once");
214 guard.record_iteration();
215 continue;
216 }
217
218 let has_prior_tools = messages.iter().any(|m| m.role == Role::Tool);
220
221 if has_prior_tools {
222 warn!("empty response after tool use, inserting fallback");
223 let fallback_msg = Message::new(
224 Role::Assistant,
225 "I completed the requested operations. The tool results above \
226 contain the output.",
227 );
228 params
229 .memory
230 .save_message(¶ms.bout_id, &fallback_msg)
231 .await?;
232 messages.push(fallback_msg.clone());
233
234 return Ok(FighterLoopResult {
235 response: fallback_msg.content,
236 usage: total_usage,
237 iterations: guard.iterations(),
238 tool_calls_made,
239 });
240 }
241 }
242
243 params
245 .memory
246 .save_message(¶ms.bout_id, &completion.message)
247 .await?;
248 messages.push(completion.message.clone());
249
250 let response = completion.message.content.clone();
251
252 info!(
253 iterations = guard.iterations(),
254 tool_calls = tool_calls_made,
255 total_tokens = total_usage.total(),
256 "fighter loop complete"
257 );
258
259 if let Ok(Some(mut creed)) = params.memory.load_creed_by_name(¶ms.manifest.name).await {
262 creed.record_bout();
263 creed.record_messages(guard.iterations() as u64 + 1); creed.fighter_id = Some(params.fighter_id);
266 if let Err(e) = params.memory.save_creed(&creed).await {
267 warn!(error = %e, "failed to update creed after bout");
268 } else {
269 debug!(fighter = %params.manifest.name, bout_count = creed.bout_count, "creed evolved");
270 }
271 }
272
273 return Ok(FighterLoopResult {
274 response,
275 usage: total_usage,
276 iterations: guard.iterations(),
277 tool_calls_made,
278 });
279 }
280
281 StopReason::MaxTokens => {
282 params
284 .memory
285 .save_message(¶ms.bout_id, &completion.message)
286 .await?;
287 messages.push(completion.message.clone());
288
289 continuation_count += 1;
290
291 if continuation_count > MAX_CONTINUATION_LOOPS {
292 warn!(
293 continuation_count = continuation_count,
294 "max continuation loops exceeded, returning partial response"
295 );
296 return Ok(FighterLoopResult {
297 response: completion.message.content,
298 usage: total_usage,
299 iterations: guard.iterations(),
300 tool_calls_made,
301 });
302 }
303
304 info!(
305 continuation = continuation_count,
306 max = MAX_CONTINUATION_LOOPS,
307 "MaxTokens hit, appending continuation prompt"
308 );
309
310 let continue_msg =
312 Message::new(Role::User, "Please continue from where you left off.");
313 params
314 .memory
315 .save_message(¶ms.bout_id, &continue_msg)
316 .await?;
317 messages.push(continue_msg);
318
319 guard.record_iteration();
320 continue;
321 }
322
323 StopReason::ToolUse => {
324 continuation_count = 0;
326
327 let verdict = guard.record_tool_calls(&completion.message.tool_calls);
329 match verdict {
330 LoopGuardVerdict::Break(reason) => {
331 warn!(reason = %reason, "loop guard triggered");
332
333 params
335 .memory
336 .save_message(¶ms.bout_id, &completion.message)
337 .await?;
338 messages.push(completion.message.clone());
339
340 let guard_response = format!(
341 "{}\n\n[Loop terminated: {}]",
342 completion.message.content, reason
343 );
344
345 return Ok(FighterLoopResult {
346 response: guard_response,
347 usage: total_usage,
348 iterations: guard.iterations(),
349 tool_calls_made,
350 });
351 }
352 LoopGuardVerdict::Continue => {}
353 }
354
355 params
357 .memory
358 .save_message(¶ms.bout_id, &completion.message)
359 .await?;
360 messages.push(completion.message.clone());
361
362 let mut tool_results = Vec::new();
364
365 for tc in &completion.message.tool_calls {
366 debug!(tool = %tc.name, id = %tc.id, "executing tool call");
367
368 let call_verdict = guard.evaluate_call(tc);
370 if let crate::guard::GuardVerdict::Block(reason) = &call_verdict {
371 warn!(tool = %tc.name, reason = %reason, "tool call blocked by guard");
372 tool_results.push(ToolCallResult {
373 id: tc.id.clone(),
374 content: format!("Error: {}", reason),
375 is_error: true,
376 });
377 tool_calls_made += 1;
378 continue;
379 }
380
381 let result = tokio::time::timeout(
382 std::time::Duration::from_secs(tool_timeout),
383 tool_executor::execute_tool(
384 &tc.name,
385 &tc.input,
386 ¶ms.manifest.capabilities,
387 &tool_context,
388 ),
389 )
390 .await;
391
392 let tool_call_result = match result {
393 Ok(Ok(tool_result)) => {
394 let content = if tool_result.success {
395 tool_result.output.to_string()
396 } else {
397 tool_result
398 .error
399 .unwrap_or_else(|| "tool execution failed".to_string())
400 };
401
402 guard.record_outcome(tc, &content);
404
405 let cap = budget.per_result_cap().min(budget.single_result_max());
407 let content = if content.len() > cap {
408 debug!(
409 tool = %tc.name,
410 original_len = content.len(),
411 cap = cap,
412 "truncating tool result"
413 );
414 ContextBudget::truncate_result(&content, cap)
415 } else {
416 content
417 };
418
419 ToolCallResult {
420 id: tc.id.clone(),
421 content,
422 is_error: !tool_result.success,
423 }
424 }
425 Ok(Err(e)) => {
426 error!(tool = %tc.name, error = %e, "tool execution error");
427 ToolCallResult {
428 id: tc.id.clone(),
429 content: format!("Error: {}", e),
430 is_error: true,
431 }
432 }
433 Err(_) => {
434 error!(
435 tool = %tc.name,
436 timeout_secs = tool_timeout,
437 "tool execution timed out"
438 );
439 ToolCallResult {
440 id: tc.id.clone(),
441 content: format!(
442 "Error: tool '{}' timed out after {}s",
443 tc.name, tool_timeout
444 ),
445 is_error: true,
446 }
447 }
448 };
449
450 tool_results.push(tool_call_result);
451 tool_calls_made += 1;
452 }
453
454 let tool_msg = Message {
456 role: Role::Tool,
457 content: String::new(),
458 tool_calls: Vec::new(),
459 tool_results,
460 timestamp: chrono::Utc::now(),
461 };
462
463 params
464 .memory
465 .save_message(¶ms.bout_id, &tool_msg)
466 .await?;
467 messages.push(tool_msg);
468
469 }
471
472 StopReason::Error => {
473 error!("LLM returned error stop reason");
474 return Err(PunchError::Provider {
475 provider: params.manifest.model.provider.to_string(),
476 message: "model returned an error".to_string(),
477 });
478 }
479 }
480 }
481}
482
483async fn build_system_prompt(
486 manifest: &FighterManifest,
487 fighter_id: &FighterId,
488 memory: &MemorySubstrate,
489) -> String {
490 let mut prompt = manifest.system_prompt.clone();
491
492 match memory.load_creed_by_name(&manifest.name).await {
496 Ok(Some(creed)) => {
497 prompt.push_str("\n\n");
498 prompt.push_str(&creed.render());
499 }
500 Ok(None) => {
501 }
503 Err(e) => {
504 warn!(error = %e, "failed to load creed for fighter");
505 }
506 }
507
508 match memory.recall_memories(fighter_id, "", 10).await {
510 Ok(memories) if !memories.is_empty() => {
511 prompt.push_str("\n\n## Recalled Memories\n");
512 for mem in &memories {
513 prompt.push_str(&format!(
514 "- **{}**: {} (confidence: {:.0}%)\n",
515 mem.key,
516 mem.value,
517 mem.confidence * 100.0
518 ));
519 }
520 }
521 Ok(_) => {
522 }
524 Err(e) => {
525 warn!(error = %e, "failed to recall memories for system prompt");
526 }
527 }
528
529 prompt
530}