Skip to main content

agentd/runtime/
worker.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **turn worker** (RFC 0026 §2, §3.2): a child process (`Role::Turn`) that
3//! runs ONE turn over the context slice the supervisor handed it — a root /
4//! conversation turn, a bounded `agent` step, or a structured `think` — and
5//! reports the transcript delta, the usage and the outcome (`TurnDone`).
6//!
7//! It calls the model and MCP tools itself; **internal** tools (memory, plan,
8//! subagents, sleep, finish, …) are **round-tripped** to the supervisor
9//! (`ToolRequest` → `ToolResult`) so state changes are made by the state
10//! owner. Before each model call it may ask for budget admission
11//! (`BudgetRequest` → `BudgetGrant`).
12//!
13//! Compared with the 1.x ReAct loop this turn keeps **structured** tool
14//! results (`structuredContent` first, text-JSON second, text last),
15//! validates a schema'd final answer and re-asks on a miss, keeps a
16//! serializable transcript, estimates tokens for admission, and detects
17//! call loops. The loop body is [`run_turn`], generic over a [`Bridge`] so it
18//! is unit-testable in-process; [`run_turn_child`] is the process entry.
19
20use 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
34/// Default per-response completion cap.
35pub const DEFAULT_MAX_TOKENS_PER_CALL: u32 = 4096;
36/// Re-asks when a schema'd answer misses.
37pub const SCHEMA_REASKS: u32 = 2;
38/// The same tool call (name + args) this many times in one turn is a loop.
39pub const LOOP_REPEATS: usize = 4;
40/// Longest a single MCP tool call may take inside a turn.
41pub const TOOL_CALL_CAP: Duration = Duration::from_secs(600);
42
43/// A budget grant as the worker sees it.
44#[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
52/// The supervisor link: internal-tool and budget round-trips + cancellation.
53pub trait Bridge {
54    /// Execute an internal tool via the supervisor. `None` = no answer
55    /// (cancelled / channel gone / deadline).
56    fn tool_request(
57        &mut self,
58        name: &str,
59        args: &Value,
60        deadline: Instant,
61    ) -> Option<(Value, bool)>;
62    /// Ask for budget admission. `None` = no answer.
63    fn budget_request(&mut self, estimate: u64, deadline: Instant) -> Option<BudgetReply>;
64    fn cancelled(&self) -> bool;
65    /// A progress event (liveness).
66    fn progress(&mut self, _event: &str, _fields: Value) {}
67}
68
69/// The process bridge: frames over the control channel.
70pub 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
129/// How MCP tools are reached from the turn.
130pub trait McpCaller {
131    /// Call `tool` on `server` with `args` and extra `_meta`; `(content, is_error)`
132    /// where content is the structured result when the server gave one.
133    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
143/// The connected MCP clients as an [`McpCaller`].
144pub 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
167/// The value of an MCP tool result: `structuredContent`, else the text parsed
168/// as JSON, else the text.
169pub 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/// The knobs of one turn (from the payload).
178#[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
186/// Run ONE turn. Never panics; every failure is a `TurnResult` status.
187pub 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    // The OTEL `invoke_agent` span for this turn (plan §3.11) — a no-op handle
229    // without the `otel` feature / an endpoint. `Option` so `finish` (which
230    // consumes the span) can be `take`n from the return-macro without a move
231    // across the loop.
232    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        // Budget admission (RFC 0026 §7).
267        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        // The model call. Announce it first (RFC 0032 §17): the supervisor turns
326        // this into the display clients' live activity — `thinking` from here
327        // until the response lands.
328        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        // Carry the round's token usage upward too — the supervisor attributes
363        // it to this turn's live activity (the instance counters are settled
364        // separately from the terminal usage, so this never double-counts).
365        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                // Loop detection: the same call repeated.
385                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                // The one signal that says WHAT it is doing right now — for MCP
398                // tools the supervisor never sees the call otherwise (the child
399                // holds its own MCP connections).
400                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        // A final answer.
434        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        // Structured answers: parse + validate, re-ask on a miss.
441        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
524/// Answer the tool calls the turn never got to run, so the transcript delta it
525/// reports is self-consistent.
526///
527/// Every provider dialect requires one tool result per `tool_calls` id on the
528/// preceding assistant message, and the tool loop has exits that fire BETWEEN
529/// pushing that assistant message and pushing its results (cancellation between
530/// calls, loop detection on the offending call). The delta is appended verbatim
531/// to the DURABLE context, so an unanswered id is not one lost result: every
532/// later turn and every restart replays the same malformed context and the
533/// provider rejects it with a fatal 400 forever — reported as a retryable
534/// `intel:` failure (exit `INTEL_UNAVAILABLE`), so an external scheduler keeps
535/// retrying a request agentd itself malformed. The synthetic result is an error
536/// result, the same shape [`execute_call`] uses when the supervisor never
537/// answers, so the model sees the call did not happen rather than a made-up
538/// success.
539fn 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
553/// Dispatch one tool call: internal (round-trip) → MCP (own call) → code.
554fn 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
603/// Parse a model's JSON answer tolerantly (fences, prose around the object).
604pub 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
635/// The process entry for a `Role::Turn` child: run the turn, report
636/// `Usage` + `TurnDone`, return the exit code.
637pub 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    /// Start the in-process mock LLM with a playbook file; returns the client.
719    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        // The playbook indexes turns by the number of tool results in the
845        // transcript: one round with three calls, then the final answer.
846        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        // Delta: assistant, tool, tool, tool, assistant.
876        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        // A think: the first answer misses the schema, the re-ask fixes it.
924        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        // The mock indexes turns by tool results; a think has none, so it always
933        // answers turn 0 unless a match rule catches the re-ask.
934        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        // Never valid ⇒ failed after the re-asks.
943        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        // The model repeats the same call forever ⇒ loop_detected.
952        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        // Round cap.
972        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        // Budget: one wait then ok with a degraded model; then a refusal.
988        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        // Cancel before the first call.
1026        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        // A dead intelligence is `failed` with an intel error.
1043        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        // Cancellation landing BETWEEN two calls of one assistant message (the
1069        // other early exit, loop detection, is covered end to end in
1070        // `wedged_context_e2e`). The delta is appended verbatim to the DURABLE
1071        // context, so leaving an id unanswered would malform that context for
1072        // every later turn and every restart — not just lose one result.
1073        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        // Three DISTINCT calls so loop detection does not fire first.
1087        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        // assistant + the one executed result + two sealed ones.
1103        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}