1use crate::context::{Msg, tokens};
21use crate::intel::client::IntelClient;
22use crate::jsonschema;
23use crate::mcp::client::McpClient;
24use crate::obs::log::Logger;
25use crate::subagent::control::{Up, send_up};
26use crate::subagent::protocol::{AgentMsg, SpawnPayload, TurnKind, TurnResult, TurnSpec};
27use crate::subagent::replies::{Replies, Reply};
28use crate::wire::intel::{Message, Request, ToolCall, Usage};
29use serde_json::{Value, json};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::time::{Duration, Instant};
33
34pub const DEFAULT_MAX_TOKENS_PER_CALL: u32 = 4096;
36pub const SCHEMA_REASKS: u32 = 2;
38pub const LOOP_REPEATS: usize = 4;
40pub const TOOL_CALL_CAP: Duration = Duration::from_secs(600);
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct BudgetReply {
46 pub ok: bool,
47 pub wait_ms: u64,
48 pub model: Option<String>,
49 pub reason: Option<String>,
50}
51
52pub trait Bridge {
54 fn tool_request(
57 &mut self,
58 name: &str,
59 args: &Value,
60 deadline: Instant,
61 ) -> Option<(Value, bool)>;
62 fn budget_request(&mut self, estimate: u64, deadline: Instant) -> Option<BudgetReply>;
64 fn cancelled(&self) -> bool;
65 fn progress(&mut self, _event: &str, _fields: Value) {}
67}
68
69pub struct ChildBridge<'a> {
71 pub up: &'a Up,
72 pub replies: &'a Arc<Replies>,
73 pub cancel: &'a Arc<AtomicBool>,
74}
75
76impl Bridge for ChildBridge<'_> {
77 fn tool_request(
78 &mut self,
79 name: &str,
80 args: &Value,
81 deadline: Instant,
82 ) -> Option<(Value, bool)> {
83 let id = self.replies.next_id();
84 send_up(
85 self.up,
86 &AgentMsg::ToolRequest {
87 id,
88 name: name.to_string(),
89 args: args.clone(),
90 },
91 );
92 match self.replies.wait(id, deadline, self.cancel)? {
93 Reply::Tool { result, is_error } => Some((result, is_error)),
94 Reply::Budget { .. } => None,
95 }
96 }
97 fn budget_request(&mut self, estimate: u64, deadline: Instant) -> Option<BudgetReply> {
98 let id = self.replies.next_id();
99 send_up(self.up, &AgentMsg::BudgetRequest { id, estimate });
100 match self.replies.wait(id, deadline, self.cancel)? {
101 Reply::Budget {
102 ok,
103 wait_ms,
104 model,
105 reason,
106 } => Some(BudgetReply {
107 ok,
108 wait_ms,
109 model,
110 reason,
111 }),
112 Reply::Tool { .. } => None,
113 }
114 }
115 fn cancelled(&self) -> bool {
116 self.cancel.load(Ordering::Relaxed)
117 }
118 fn progress(&mut self, event: &str, fields: Value) {
119 send_up(
120 self.up,
121 &AgentMsg::Event {
122 event: event.to_string(),
123 fields,
124 },
125 );
126 }
127}
128
129pub trait McpCaller {
131 fn call(
134 &self,
135 server: &str,
136 tool: &str,
137 args: Value,
138 meta: Value,
139 timeout: Duration,
140 ) -> Result<(Value, bool), String>;
141}
142
143pub struct McpClients<'a>(pub &'a [McpClient]);
145
146impl McpCaller for McpClients<'_> {
147 fn call(
148 &self,
149 server: &str,
150 tool: &str,
151 args: Value,
152 meta: Value,
153 timeout: Duration,
154 ) -> Result<(Value, bool), String> {
155 let c = self
156 .0
157 .iter()
158 .find(|c| c.name() == server)
159 .ok_or_else(|| format!("mcp server {server:?} is not connected"))?;
160 let res = c
161 .call_tool_with_meta_within(tool, Some(args), meta, timeout)
162 .map_err(|e| e.to_string())?;
163 Ok((tool_result_value(&res), res.is_error()))
164 }
165}
166
167pub fn tool_result_value(res: &::mcp::wire::CallToolResult) -> Value {
170 if let Some(sc) = &res.structured_content {
171 return sc.clone();
172 }
173 let text = res.text();
174 serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text))
175}
176
177#[derive(Debug, Clone)]
179pub struct TurnLimits {
180 pub max_rounds: u32,
181 pub max_tokens: u64,
182 pub deadline: Instant,
183 pub model: String,
184}
185
186pub fn run_turn(
188 spec: &TurnSpec,
189 limits: &TurnLimits,
190 intel: &IntelClient,
191 mcp: &dyn McpCaller,
192 bridge: &mut dyn Bridge,
193 log: &Logger,
194) -> TurnResult {
195 let start = Instant::now();
196 let mut result = TurnResult {
197 status: "completed".into(),
198 ..Default::default()
199 };
200 let mut messages: Vec<Message> = Vec::with_capacity(spec.messages.len() + 2);
201 if !spec.system.trim().is_empty() {
202 messages.push(Message::System(spec.system.clone()));
203 }
204 messages.extend(spec.messages.iter().map(Msg::to_wire));
205 let tools = if spec.kind == TurnKind::Think {
206 Vec::new()
207 } else {
208 spec.tools.clone()
209 };
210 let mut model = limits.model.clone();
211 let max_rounds = if spec.max_rounds > 0 {
212 spec.max_rounds
213 } else if limits.max_rounds > 0 {
214 limits.max_rounds
215 } else {
216 u32::MAX
217 };
218 let max_tokens_per_call = if spec.max_tokens_per_call > 0 {
219 spec.max_tokens_per_call
220 } else {
221 DEFAULT_MAX_TOKENS_PER_CALL
222 };
223 let mut reasks = 0u32;
224 let mut recent_calls: Vec<String> = Vec::new();
225 let mut usage_total = Usage::default();
226 let mut delta: Vec<Msg> = Vec::new();
227 let mut last_text: Option<String> = None;
228 let mut run_span = Some(crate::obs::otel::run_begin(
233 log.ctx().trace_id.as_deref(),
234 crate::obs::otel::now_unix_nanos(),
235 ));
236
237 log.info("turn.start", json!({"turn": spec.turn_id, "kind": spec.kind, "messages": messages.len(), "tools": tools.len()}));
238
239 macro_rules! finish_with {
240 ($status:expr) => {{
241 result.status = $status.to_string();
242 result.messages = delta;
243 result.usage = usage_total;
244 result.text = last_text.clone();
245 if let Some(rs) = run_span.take() {
246 rs.finish(&model, usage_total.input_tokens, usage_total.output_tokens, $status == "completed");
247 }
248 log.info("turn.done", json!({"turn": spec.turn_id, "status": result.status, "rounds": result.rounds, "tool_calls": result.tool_calls, "tokens_in": usage_total.input_tokens, "tokens_out": usage_total.output_tokens}));
249 return result;
250 }};
251 }
252
253 loop {
254 if bridge.cancelled() {
255 finish_with!("cancelled");
256 }
257 if Instant::now() >= limits.deadline {
258 finish_with!("deadline");
259 }
260 if result.rounds >= max_rounds {
261 finish_with!("exhausted_steps");
262 }
263 if limits.max_tokens > 0 && usage_total.total() >= limits.max_tokens {
264 finish_with!("exhausted_tokens");
265 }
266 if spec.budget_admission {
268 let estimate: u64 = messages
269 .iter()
270 .map(|m| tokens::estimate(&render_len(m)) + tokens::MESSAGE_OVERHEAD)
271 .sum::<u64>()
272 + tools
273 .iter()
274 .map(|t| tokens::estimate(&t.name) + tokens::estimate_value(&t.input_schema))
275 .sum::<u64>()
276 + max_tokens_per_call as u64;
277 loop {
278 match bridge.budget_request(estimate, limits.deadline) {
279 None => finish_with!("cancelled"),
280 Some(BudgetReply {
281 ok: true, model: m, ..
282 }) => {
283 if let Some(m) = m
284 && m != model
285 {
286 log.info(
287 "turn.model_degraded",
288 json!({"turn": spec.turn_id, "from": model, "to": m}),
289 );
290 model = m;
291 }
292 break;
293 }
294 Some(BudgetReply {
295 ok: false,
296 wait_ms,
297 reason,
298 ..
299 }) => {
300 if let Some(r) = reason {
301 result.error = Some(format!("budget: {r}"));
302 finish_with!(if r.contains("refus") {
303 "refused"
304 } else {
305 "exhausted_tokens"
306 });
307 }
308 let remaining = limits.deadline.saturating_duration_since(Instant::now());
309 let wait = Duration::from_millis(wait_ms.max(50)).min(remaining);
310 if wait.is_zero() {
311 finish_with!("deadline");
312 }
313 log.info(
314 "turn.budget_wait",
315 json!({"turn": spec.turn_id, "wait_ms": wait.as_millis() as u64}),
316 );
317 std::thread::sleep(wait);
318 if bridge.cancelled() {
319 finish_with!("cancelled");
320 }
321 }
322 }
323 }
324 }
325 bridge.progress(
329 "turn.think",
330 json!({"turn": spec.turn_id, "round": result.rounds + 1}),
331 );
332 let req = Request {
333 model: model.clone(),
334 messages: messages.clone(),
335 tools: tools.clone(),
336 max_tokens: max_tokens_per_call,
337 temperature: spec.temperature.or(Some(0.0)),
338 };
339 let chat_start = crate::obs::otel::now_unix_nanos();
340 let resp = match intel.complete(&req) {
341 Ok(r) => r,
342 Err(e) => {
343 if let Some(rs) = run_span.as_mut() {
344 rs.record_chat(&model, 0, 0, false, chat_start);
345 }
346 result.error = Some(format!("intel: {e}"));
347 finish_with!("failed");
348 }
349 };
350 result.rounds += 1;
351 usage_total.input_tokens += resp.usage.input_tokens;
352 usage_total.output_tokens += resp.usage.output_tokens;
353 if let Some(rs) = run_span.as_mut() {
354 rs.record_chat(
355 &model,
356 resp.usage.input_tokens,
357 resp.usage.output_tokens,
358 true,
359 chat_start,
360 );
361 }
362 bridge.progress("turn.round", json!({"turn": spec.turn_id, "round": result.rounds, "tool_calls": resp.tool_calls.len(), "tokens_in": resp.usage.input_tokens, "tokens_out": resp.usage.output_tokens}));
366 log.debug("turn.round", json!({"turn": spec.turn_id, "round": result.rounds, "tokens_in": resp.usage.input_tokens, "tokens_out": resp.usage.output_tokens, "tool_calls": resp.tool_calls.len()}));
367
368 if resp.wants_tools() {
369 if let Some(t) = &resp.text
370 && !t.is_empty()
371 {
372 last_text = Some(t.clone());
373 }
374 let assistant = Msg::assistant(resp.text.clone(), resp.tool_calls.clone());
375 messages.push(assistant.to_wire());
376 delta.push(assistant);
377 let mut finish_seen = None;
378 for (i, tc) in resp.tool_calls.iter().enumerate() {
379 if bridge.cancelled() {
380 seal_unanswered(&mut delta, &resp.tool_calls[i..], "cancelled");
381 finish_with!("cancelled");
382 }
383 result.tool_calls += 1;
384 let sig = format!("{}:{}", tc.name, tc.arguments);
386 recent_calls.push(sig.clone());
387 if recent_calls.iter().filter(|s| **s == sig).count() >= LOOP_REPEATS {
388 result.error = Some(format!(
389 "the model repeated {} with identical arguments {LOOP_REPEATS} times",
390 tc.name
391 ));
392 seal_unanswered(&mut delta, &resp.tool_calls[i..], "loop_detected");
393 finish_with!("loop_detected");
394 }
395 let call_start = Instant::now();
396 let tool_span_start = crate::obs::otel::now_unix_nanos();
397 bridge.progress(
401 "turn.tool",
402 json!({"turn": spec.turn_id, "tool": tc.name, "i": i + 1, "of": resp.tool_calls.len()}),
403 );
404 let (content, is_error) = execute_call(spec, limits, tc, i, mcp, bridge, log);
405 if let Some(rs) = run_span.as_mut() {
406 rs.record_tool(&tc.name, !is_error, tool_span_start);
407 }
408 log.info("tool.result", json!({"turn": spec.turn_id, "tool": tc.name, "is_error": is_error, "ms": call_start.elapsed().as_millis() as u64}));
409 let msg = Msg::tool(tc.id.clone(), tc.name.clone(), content, is_error);
410 messages.push(msg.to_wire());
411 delta.push(msg);
412 if tc.name == "finish" && !is_error {
413 finish_seen = Some(tc.arguments.clone());
414 }
415 }
416 if let Some(f) = finish_seen {
417 result.finish = Some(f);
418 last_text = last_text.or_else(|| {
419 result
420 .finish
421 .as_ref()
422 .and_then(|f| f.get("output"))
423 .map(|o| match o {
424 Value::String(s) => s.clone(),
425 other => other.to_string(),
426 })
427 });
428 finish_with!("completed");
429 }
430 continue;
431 }
432
433 let text = resp
435 .text
436 .clone()
437 .or_else(|| last_text.clone())
438 .unwrap_or_default();
439 let assistant = Msg::assistant(Some(text.clone()), Vec::new());
440 let wants_object = spec.kind == TurnKind::Think || spec.output_schema.is_some();
442 if wants_object {
443 match parse_json_answer(&text) {
444 Ok(v) => {
445 let check = match &spec.output_schema {
446 Some(schema) => {
447 jsonschema::validate(schema, &v).map_err(|e| jsonschema::explain(&e))
448 }
449 None => Ok(()),
450 };
451 match check {
452 Ok(()) => {
453 messages.push(assistant.to_wire());
454 delta.push(assistant);
455 last_text = Some(text);
456 result.value = Some(v);
457 finish_with!("completed");
458 }
459 Err(e) => {
460 if reasks < SCHEMA_REASKS {
461 reasks += 1;
462 log.info("turn.reask", json!({"turn": spec.turn_id, "reason": e}));
463 messages.push(assistant.to_wire());
464 delta.push(assistant);
465 let ask = Msg::user(
466 format!(
467 "Your answer did not match the required schema: {e}. Reply again with ONLY one JSON object that matches the schema."
468 ),
469 None,
470 );
471 messages.push(ask.to_wire());
472 delta.push(ask);
473 continue;
474 }
475 result.error =
476 Some(format!("answer does not match the output schema: {e}"));
477 messages.push(assistant.to_wire());
478 delta.push(assistant);
479 last_text = Some(text);
480 finish_with!("failed");
481 }
482 }
483 }
484 Err(e) => {
485 if reasks < SCHEMA_REASKS {
486 reasks += 1;
487 log.info("turn.reask", json!({"turn": spec.turn_id, "reason": e}));
488 messages.push(assistant.to_wire());
489 delta.push(assistant);
490 let ask = Msg::user(format!("{e}. Reply with ONLY one JSON object."), None);
491 messages.push(ask.to_wire());
492 delta.push(ask);
493 continue;
494 }
495 result.error = Some(e);
496 delta.push(assistant);
497 last_text = Some(text);
498 finish_with!("failed");
499 }
500 }
501 }
502 delta.push(assistant);
503 last_text = Some(text);
504 let _ = start;
505 finish_with!("completed");
506 }
507}
508
509fn render_len(m: &Message) -> String {
510 match m {
511 Message::System(s) | Message::User(s) => s.clone(),
512 Message::Assistant { text, tool_calls } => format!(
513 "{}{}",
514 text.as_deref().unwrap_or(""),
515 tool_calls
516 .iter()
517 .map(|c| c.arguments.to_string())
518 .collect::<String>()
519 ),
520 Message::ToolResult { content, .. } => content.clone(),
521 }
522}
523
524fn seal_unanswered(delta: &mut Vec<Msg>, unanswered: &[ToolCall], status: &str) {
540 for tc in unanswered {
541 delta.push(Msg::tool(
542 tc.id.clone(),
543 tc.name.clone(),
544 Value::String(format!(
545 "{}: not executed — the turn ended ({status}) before this call ran",
546 tc.name
547 )),
548 true,
549 ));
550 }
551}
552
553fn execute_call(
555 spec: &TurnSpec,
556 limits: &TurnLimits,
557 tc: &ToolCall,
558 index: usize,
559 mcp: &dyn McpCaller,
560 bridge: &mut dyn Bridge,
561 log: &Logger,
562) -> (Value, bool) {
563 let name = tc.name.as_str();
564 log.info(
565 "tool.call",
566 json!({"turn": spec.turn_id, "tool": name, "id": tc.id}),
567 );
568 if spec.internal.iter().any(|n| n == name) {
569 return match bridge.tool_request(name, &tc.arguments, limits.deadline) {
570 Some(r) => r,
571 None => (
572 Value::String(format!(
573 "{name}: no answer from the supervisor (cancelled or deadline)"
574 )),
575 true,
576 ),
577 };
578 }
579 if let Some((server, tool)) = spec.mcp_routes.get(name) {
580 let remaining = limits.deadline.saturating_duration_since(Instant::now());
581 let timeout = remaining.min(TOOL_CALL_CAP).max(Duration::from_millis(100));
582 let mut meta = spec.tool_meta.clone().unwrap_or_else(|| json!({}));
583 if !spec.idempotency_prefix.is_empty() {
584 meta["agent/idempotency_key"] = json!(format!(
585 "{}#{}.{}",
586 spec.idempotency_prefix, spec.turn_id, index
587 ));
588 }
589 return match mcp.call(server, tool, tc.arguments.clone(), meta, timeout) {
590 Ok(r) => r,
591 Err(e) => (Value::String(format!("tool transport error: {e}")), true),
592 };
593 }
594 if let Some(r) = crate::tools::call(name, &tc.arguments) {
595 return match r {
596 Ok(v) => (v, false),
597 Err(e) => (Value::String(e), true),
598 };
599 }
600 (Value::String(format!("error: no such tool '{name}'")), true)
601}
602
603pub fn parse_json_answer(answer: &str) -> Result<Value, String> {
605 let t = answer.trim();
606 if let Ok(v) = serde_json::from_str::<Value>(t) {
607 return Ok(v);
608 }
609 let t2 = t
610 .trim_start_matches("```json")
611 .trim_start_matches("```")
612 .trim_end_matches("```")
613 .trim();
614 if let Ok(v) = serde_json::from_str::<Value>(t2) {
615 return Ok(v);
616 }
617 if let (Some(a), Some(b)) = (t.find('{'), t.rfind('}'))
618 && b > a
619 && let Ok(v) = serde_json::from_str::<Value>(&t[a..=b])
620 {
621 return Ok(v);
622 }
623 if let (Some(a), Some(b)) = (t.find('['), t.rfind(']'))
624 && b > a
625 && let Ok(v) = serde_json::from_str::<Value>(&t[a..=b])
626 {
627 return Ok(v);
628 }
629 Err(format!(
630 "the answer was not valid JSON: {}",
631 t.chars().take(120).collect::<String>()
632 ))
633}
634
635pub fn run_turn_child(
638 payload: &SpawnPayload,
639 intel: &IntelClient,
640 servers: &[McpClient],
641 up: &Up,
642 cancel: &Arc<AtomicBool>,
643 replies: &Arc<Replies>,
644 log: &Logger,
645) -> i32 {
646 let Some(spec) = payload.turn.as_deref() else {
647 send_up(
648 up,
649 &AgentMsg::Failed {
650 error: "role is turn but the payload carries no turn spec".into(),
651 },
652 );
653 return crate::exit::USAGE;
654 };
655 let limits = TurnLimits {
656 max_rounds: if spec.max_rounds > 0 {
657 spec.max_rounds
658 } else {
659 payload.limits.max_steps
660 },
661 max_tokens: payload.limits.max_tokens,
662 deadline: Instant::now() + Duration::from_millis(payload.limits.deadline_ms.max(1)),
663 model: payload.intelligence.model.clone().unwrap_or_default(),
664 };
665 let mut bridge = ChildBridge {
666 up,
667 replies,
668 cancel,
669 };
670 let result = run_turn(spec, &limits, intel, &McpClients(servers), &mut bridge, log);
671 let code = match result.status.as_str() {
672 "completed" => crate::exit::SUCCESS,
673 "failed"
674 if result
675 .error
676 .as_deref()
677 .is_some_and(|e| e.starts_with("intel:")) =>
678 {
679 crate::exit::INTEL_UNAVAILABLE
680 }
681 "failed" | "cancelled" => crate::exit::GENERIC,
682 "refused" => crate::exit::REFUSED,
683 "exhausted_steps" | "exhausted_tokens" => crate::exit::BUDGET,
684 "deadline" => crate::exit::DEADLINE,
685 _ => crate::exit::PARTIAL,
686 };
687 send_up(up, &AgentMsg::Usage(result.usage));
688 send_up(
689 up,
690 &AgentMsg::TurnDone {
691 turn: Box::new(result),
692 },
693 );
694 code
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::obs::log::{Comp, Level, LogCtx};
701 use crate::wire::intel::ToolDef;
702 use std::collections::BTreeMap;
703
704 fn log() -> Logger {
705 Logger::new(
706 LogCtx {
707 run_id: "t".into(),
708 agent_id: "0".into(),
709 agent_path: "0".into(),
710 comp: Comp::Agent,
711 pid: 0,
712 trace_id: None,
713 },
714 Level::Warn,
715 )
716 }
717
718 fn mock_intel(playbook: &Value) -> IntelClient {
720 let dir = std::env::temp_dir();
721 let n = std::process::id() as u64 * 1000
722 + std::time::SystemTime::now()
723 .duration_since(std::time::UNIX_EPOCH)
724 .unwrap()
725 .subsec_nanos() as u64
726 % 1000;
727 let pb = dir.join(format!("agentd-worker-pb-{n}.json"));
728 std::fs::write(&pb, playbook.to_string()).unwrap();
729 let addr_file = dir.join(format!("agentd-worker-mock-{n}.addr"));
730 let _ = std::fs::remove_file(&addr_file);
731 let af = addr_file.to_string_lossy().to_string();
732 let script = format!("file:{}", pb.to_string_lossy());
733 std::thread::spawn(move || crate::intel::mock::run(&af, &script));
734 let deadline = Instant::now() + Duration::from_secs(5);
735 while !addr_file.exists() {
736 assert!(Instant::now() < deadline, "mock never announced");
737 std::thread::sleep(Duration::from_millis(5));
738 }
739 let addr = std::fs::read_to_string(&addr_file).unwrap();
740 IntelClient::from_parts(&format!("http://{}", addr.trim()), None).unwrap()
741 }
742
743 struct FakeBridge {
744 calls: Vec<(String, Value)>,
745 answers: BTreeMap<String, (Value, bool)>,
746 budget: Vec<BudgetReply>,
747 cancel: bool,
748 }
749 impl Bridge for FakeBridge {
750 fn tool_request(&mut self, name: &str, args: &Value, _d: Instant) -> Option<(Value, bool)> {
751 self.calls.push((name.to_string(), args.clone()));
752 Some(
753 self.answers
754 .get(name)
755 .cloned()
756 .unwrap_or((json!({"ok": true}), false)),
757 )
758 }
759 fn budget_request(&mut self, _e: u64, _d: Instant) -> Option<BudgetReply> {
760 if self.budget.is_empty() {
761 Some(BudgetReply {
762 ok: true,
763 wait_ms: 0,
764 model: None,
765 reason: None,
766 })
767 } else {
768 Some(self.budget.remove(0))
769 }
770 }
771 fn cancelled(&self) -> bool {
772 self.cancel
773 }
774 }
775 struct FakeMcp;
776 impl McpCaller for FakeMcp {
777 fn call(
778 &self,
779 server: &str,
780 tool: &str,
781 args: Value,
782 meta: Value,
783 _t: Duration,
784 ) -> Result<(Value, bool), String> {
785 if server == "down" {
786 return Err("connection refused".into());
787 }
788 Ok((
789 json!({"server": server, "tool": tool, "args": args, "idem": meta["agent/idempotency_key"]}),
790 tool == "boom",
791 ))
792 }
793 }
794
795 fn limits() -> TurnLimits {
796 TurnLimits {
797 max_rounds: 8,
798 max_tokens: 0,
799 deadline: Instant::now() + Duration::from_secs(20),
800 model: "mock".into(),
801 }
802 }
803
804 fn spec(kind: TurnKind) -> TurnSpec {
805 TurnSpec {
806 kind,
807 system: "You are a test agent.".into(),
808 messages: vec![Msg::user("do the thing", None)],
809 tools: vec![
810 ToolDef {
811 name: "memory.set".into(),
812 description: "".into(),
813 input_schema: json!({"type": "object"}),
814 },
815 ToolDef {
816 name: "fs.read".into(),
817 description: "".into(),
818 input_schema: json!({"type": "object"}),
819 },
820 ToolDef {
821 name: "finish".into(),
822 description: "".into(),
823 input_schema: json!({"type": "object"}),
824 },
825 ],
826 internal: vec!["memory.set".into(), "finish".into()],
827 mcp_routes: [
828 (
829 "fs.read".to_string(),
830 ("fs".to_string(), "read".to_string()),
831 ),
832 ("boom".to_string(), ("fs".to_string(), "boom".to_string())),
833 ]
834 .into_iter()
835 .collect(),
836 idempotency_prefix: "ctx/1".into(),
837 turn_id: "t1".into(),
838 ..Default::default()
839 }
840 }
841
842 #[test]
843 fn a_turn_round_trips_internal_tools_calls_mcp_itself_and_finishes() {
844 let intel = mock_intel(&json!({"turns": [
847 {"tool_calls": [{"name": "memory.set", "arguments": {"key": "k", "value": 1}}, {"name": "fs.read", "arguments": {"path": "/x"}}, {"name": "boom", "arguments": {}}]},
848 {"content": "unused"},
849 {"content": "unused"},
850 {"content": "all done", "usage": {"prompt_tokens": 100, "completion_tokens": 20}}
851 ]}));
852 let mut bridge = FakeBridge {
853 calls: vec![],
854 answers: BTreeMap::new(),
855 budget: vec![],
856 cancel: false,
857 };
858 let r = run_turn(
859 &spec(TurnKind::Turn),
860 &limits(),
861 &intel,
862 &FakeMcp,
863 &mut bridge,
864 &log(),
865 );
866 assert_eq!(r.status, "completed", "{:?}", r.error);
867 assert_eq!(r.text.as_deref(), Some("all done"));
868 assert_eq!(r.rounds, 2);
869 assert_eq!(r.tool_calls, 3);
870 assert_eq!(
871 bridge.calls,
872 vec![("memory.set".to_string(), json!({"key": "k", "value": 1}))],
873 "only the internal tool round-tripped"
874 );
875 assert_eq!(r.messages.len(), 5);
877 match &r.messages[2] {
878 Msg::Tool {
879 name,
880 content,
881 is_error,
882 ..
883 } => {
884 assert_eq!(name, "fs.read");
885 assert!(!is_error);
886 assert_eq!(content["server"], json!("fs"));
887 assert_eq!(
888 content["idem"],
889 json!("ctx/1#t1.1"),
890 "idempotency key stamped per call"
891 );
892 }
893 other => panic!("{other:?}"),
894 }
895 assert!(matches!(&r.messages[3], Msg::Tool { is_error: true, .. }));
896 assert_eq!(r.usage.input_tokens, 11 + 100);
897 assert_eq!(r.usage.output_tokens, 7 + 20);
898 }
899
900 #[test]
901 fn finish_ends_the_turn_and_a_think_returns_an_object_with_reasks() {
902 let intel = mock_intel(&json!({"turns": [
903 {"tool_calls": [{"name": "finish", "arguments": {"status": "completed", "output": {"n": 3}}}]},
904 {"content": "never reached"}
905 ]}));
906 let mut bridge = FakeBridge {
907 calls: vec![],
908 answers: BTreeMap::new(),
909 budget: vec![],
910 cancel: false,
911 };
912 let r = run_turn(
913 &spec(TurnKind::Agent),
914 &limits(),
915 &intel,
916 &FakeMcp,
917 &mut bridge,
918 &log(),
919 );
920 assert_eq!(r.status, "completed");
921 assert_eq!(r.finish.as_ref().unwrap()["output"]["n"], json!(3));
922 assert_eq!(r.rounds, 1);
923 let intel = mock_intel(&json!({"turns": [
925 {"content": "Sure: {\"intent\": \"maybe\"}"},
926 {"content": "```json\n{\"intent\": \"task\", \"needs_plan\": true}\n```"}
927 ]}));
928 let mut s = spec(TurnKind::Think);
929 s.output_schema = Some(
930 json!({"type": "object", "properties": {"intent": {"enum": ["chat", "task"]}, "needs_plan": {"type": "boolean"}}, "required": ["intent"]}),
931 );
932 let intel2 = mock_intel(
935 &json!({"turns": [{"content": "Sure: {\"intent\": \"maybe\"}"}], "match": [{"when_contains": "did not match the required schema", "content": {"intent": "task", "needs_plan": true}}]}),
936 );
937 drop(intel);
938 let r = run_turn(&s, &limits(), &intel2, &FakeMcp, &mut bridge, &log());
939 assert_eq!(r.status, "completed", "{:?}", r.error);
940 assert_eq!(r.value.as_ref().unwrap()["intent"], json!("task"));
941 assert_eq!(r.rounds, 2);
942 let intel3 = mock_intel(&json!({"turns": [{"content": "not json at all"}]}));
944 let r = run_turn(&s, &limits(), &intel3, &FakeMcp, &mut bridge, &log());
945 assert_eq!(r.status, "failed");
946 assert_eq!(r.rounds, 1 + SCHEMA_REASKS);
947 }
948
949 #[test]
950 fn loops_budget_waits_and_cancel_are_bounded() {
951 let intel = mock_intel(
953 &json!({"turns": [{"tool_calls": [{"name": "fs.read", "arguments": {"path": "/same"}}]}]}),
954 );
955 let mut bridge = FakeBridge {
956 calls: vec![],
957 answers: BTreeMap::new(),
958 budget: vec![],
959 cancel: false,
960 };
961 let r = run_turn(
962 &spec(TurnKind::Turn),
963 &limits(),
964 &intel,
965 &FakeMcp,
966 &mut bridge,
967 &log(),
968 );
969 assert_eq!(r.status, "loop_detected");
970 assert_eq!(r.tool_calls, LOOP_REPEATS as u32);
971 let mut l = limits();
973 l.max_rounds = 2;
974 let mut s = spec(TurnKind::Turn);
975 s.mcp_routes
976 .insert("fs.read".into(), ("down".into(), "read".into()));
977 let intel = mock_intel(&json!({"turns": [
978 {"tool_calls": [{"name": "fs.read", "arguments": {"path": "/a"}}]},
979 {"tool_calls": [{"name": "fs.read", "arguments": {"path": "/b"}}]},
980 {"tool_calls": [{"name": "fs.read", "arguments": {"path": "/c"}}]}
981 ]}));
982 let r = run_turn(&s, &l, &intel, &FakeMcp, &mut bridge, &log());
983 assert_eq!(r.status, "exhausted_steps");
984 assert!(
985 matches!(&r.messages[1], Msg::Tool { is_error: true, content, .. } if content.as_str().unwrap().contains("transport error"))
986 );
987 let intel = mock_intel(&json!({"turns": [{"content": "ok"}]}));
989 let mut s = spec(TurnKind::Turn);
990 s.budget_admission = true;
991 let mut b = FakeBridge {
992 calls: vec![],
993 answers: BTreeMap::new(),
994 budget: vec![
995 BudgetReply {
996 ok: false,
997 wait_ms: 10,
998 model: None,
999 reason: None,
1000 },
1001 BudgetReply {
1002 ok: true,
1003 wait_ms: 0,
1004 model: Some("cheap".into()),
1005 reason: None,
1006 },
1007 ],
1008 cancel: false,
1009 };
1010 let r = run_turn(&s, &limits(), &intel, &FakeMcp, &mut b, &log());
1011 assert_eq!(r.status, "completed");
1012 let mut b = FakeBridge {
1013 calls: vec![],
1014 answers: BTreeMap::new(),
1015 budget: vec![BudgetReply {
1016 ok: false,
1017 wait_ms: 0,
1018 model: None,
1019 reason: Some("budget refused: window exhausted".into()),
1020 }],
1021 cancel: false,
1022 };
1023 let r = run_turn(&s, &limits(), &intel, &FakeMcp, &mut b, &log());
1024 assert_eq!(r.status, "refused");
1025 let mut b = FakeBridge {
1027 calls: vec![],
1028 answers: BTreeMap::new(),
1029 budget: vec![],
1030 cancel: true,
1031 };
1032 let r = run_turn(
1033 &spec(TurnKind::Turn),
1034 &limits(),
1035 &intel,
1036 &FakeMcp,
1037 &mut b,
1038 &log(),
1039 );
1040 assert_eq!(r.status, "cancelled");
1041 assert_eq!(r.rounds, 0);
1042 let dead = IntelClient::from_parts("http://127.0.0.1:9", None).unwrap();
1044 let r = run_turn(
1045 &spec(TurnKind::Turn),
1046 &limits(),
1047 &dead,
1048 &FakeMcp,
1049 &mut b,
1050 &log(),
1051 );
1052 assert_eq!(r.status, "cancelled", "cancel wins first");
1053 b.cancel = false;
1054 let r = run_turn(
1055 &spec(TurnKind::Turn),
1056 &limits(),
1057 &dead,
1058 &FakeMcp,
1059 &mut b,
1060 &log(),
1061 );
1062 assert_eq!(r.status, "failed");
1063 assert!(r.error.as_deref().unwrap().starts_with("intel:"));
1064 }
1065
1066 #[test]
1067 fn an_exit_inside_the_tool_loop_answers_every_call_it_persisted() {
1068 struct CancelAfterOneCall(usize);
1074 impl Bridge for CancelAfterOneCall {
1075 fn tool_request(&mut self, _n: &str, _a: &Value, _d: Instant) -> Option<(Value, bool)> {
1076 self.0 += 1;
1077 Some((json!({"ok": true}), false))
1078 }
1079 fn budget_request(&mut self, _e: u64, _d: Instant) -> Option<BudgetReply> {
1080 None
1081 }
1082 fn cancelled(&self) -> bool {
1083 self.0 > 0
1084 }
1085 }
1086 let intel = mock_intel(&json!({"turns": [{"tool_calls": [
1088 {"name": "memory.set", "arguments": {"key": "a"}},
1089 {"name": "memory.set", "arguments": {"key": "b"}},
1090 {"name": "memory.set", "arguments": {"key": "c"}}
1091 ]}]}));
1092 let mut bridge = CancelAfterOneCall(0);
1093 let r = run_turn(
1094 &spec(TurnKind::Turn),
1095 &limits(),
1096 &intel,
1097 &FakeMcp,
1098 &mut bridge,
1099 &log(),
1100 );
1101 assert_eq!(r.status, "cancelled");
1102 assert_eq!(r.messages.len(), 4, "{:?}", r.messages);
1104 let answered: Vec<&str> = r
1105 .messages
1106 .iter()
1107 .filter_map(|m| match m {
1108 Msg::Tool { id, .. } => Some(id.as_str()),
1109 _ => None,
1110 })
1111 .collect();
1112 match &r.messages[0] {
1113 Msg::Assistant { tool_calls, .. } => {
1114 assert_eq!(tool_calls.len(), 3);
1115 for tc in tool_calls {
1116 assert!(
1117 answered.contains(&tc.id.as_str()),
1118 "tool_call {} has no result: {:?}",
1119 tc.id,
1120 r.messages
1121 );
1122 }
1123 }
1124 other => panic!("{other:?}"),
1125 }
1126 assert!(
1127 matches!(&r.messages[3], Msg::Tool { is_error: true, content, .. }
1128 if content.as_str().unwrap_or_default().contains("not executed")),
1129 "the sealed result says the call did not happen: {:?}",
1130 r.messages[3]
1131 );
1132 }
1133
1134 #[test]
1135 fn json_answers_are_parsed_tolerantly() {
1136 assert_eq!(parse_json_answer("{\"a\":1}").unwrap(), json!({"a": 1}));
1137 assert_eq!(
1138 parse_json_answer("```json\n{\"a\":1}\n```").unwrap(),
1139 json!({"a": 1})
1140 );
1141 assert_eq!(
1142 parse_json_answer("Here: {\"a\":1}. Done.").unwrap(),
1143 json!({"a": 1})
1144 );
1145 assert_eq!(parse_json_answer("[1,2]").unwrap(), json!([1, 2]));
1146 assert!(parse_json_answer("nope").is_err());
1147 let res = ::mcp::wire::CallToolResult {
1148 content: vec![json!({"type": "text", "text": "{\"x\": 2}"})],
1149 is_error: None,
1150 structured_content: None,
1151 };
1152 assert_eq!(tool_result_value(&res), json!({"x": 2}));
1153 let res = ::mcp::wire::CallToolResult {
1154 content: vec![json!({"type": "text", "text": "plain"})],
1155 is_error: None,
1156 structured_content: Some(json!({"s": 1})),
1157 };
1158 assert_eq!(tool_result_value(&res), json!({"s": 1}));
1159 }
1160}