Skip to main content

agentd/agentloop/
runner.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The ReAct agentic loop.
3//!
4//! A turn: assemble the request (system + instruction + transcript + the
5//! scoped tool catalogue) → call intelligence → if the model requested tools,
6//! run them via MCP and feed the results back as observations; otherwise the
7//! text is the final answer. Stopping is a disjunction of cheap checks, each
8//! with a distinct [`TerminalStatus`], and the loop enforces the
9//! step/token/deadline budget itself rather than trusting the model to stop.
10//! This loop produces neither `Stalled` nor `LoopDetected`: it has no
11//! no-progress or repeated-tool detector, so a spinning agent is bounded by the
12//! step and token budget instead.
13//!
14//! The root agent runs as a subagent process behind the control channel; the
15//! loop body here is identical whether driven by the root or a nested child, so
16//! there is one code path to reason about regardless of tree position.
17
18use crate::agentloop::action::{SelfHandler, ToolClass};
19use crate::agentloop::stop::{Outcome, TerminalStatus};
20use crate::intel::client::IntelClient;
21use crate::mcp::client::McpClient;
22use crate::obs::log::Logger;
23use crate::subagent::protocol::ALLOWED_TOOLS_ROLE;
24use crate::supervisor::budget::Budget;
25use crate::wire::intel::{Message, Request, ToolDef, Usage};
26use serde_json::{Value, json};
27use std::collections::HashMap;
28use std::fmt;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::time::Instant;
32
33/// Per-response token cap (distinct from the cumulative run budget).
34const PER_CALL_MAX_TOKENS: u32 = 4096;
35
36const SYSTEM_PROMPT: &str = "You are agentd, an autonomous agent. Accomplish the user's \
37instruction by calling the available tools and reasoning over their results. Call a tool when you \
38need information or need to act. When the task is complete, reply with your final answer and do \
39NOT call a tool. If the task cannot be done, say so plainly. Be concise and factual.";
40
41/// A fatal infrastructure failure that aborts the run; the caller maps it to
42/// exit 4 or 6. Tool-domain errors are *not* aborts — a tool that returns an
43/// error is fed back to the model as an observation so it can adapt, because
44/// only the infrastructure being gone makes further progress impossible.
45#[derive(Debug)]
46pub enum LoopAbort {
47    /// The intelligence endpoint is unreachable / erroring (exit 4).
48    Intel(String),
49    /// A required MCP server failed (exit 6).
50    Mcp(String),
51}
52
53impl fmt::Display for LoopAbort {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            LoopAbort::Intel(m) => write!(f, "intelligence: {m}"),
57            LoopAbort::Mcp(m) => write!(f, "mcp: {m}"),
58        }
59    }
60}
61
62/// The explicit inputs the loop needs, independent of where they came from
63/// (CLI `Config` for once-mode, or a `SpawnPayload` for a subagent). This is
64/// the seam that lets the same loop body run in-process or in a child.
65pub struct LoopInput {
66    pub instruction: String,
67    pub output_contract: Option<String>,
68    /// Narrowed context seed as (role, content) pairs (role ∈
69    /// system|user|assistant|tool).
70    pub seed: Vec<(String, String)>,
71    pub model: String,
72    pub max_steps: u32,
73    pub max_tokens: u64,
74    pub deadline: Instant,
75    /// A cooperative cancel flag checked at each turn boundary (set by a
76    /// subagent's control thread on `ControlMsg::Cancel`). `None` for a run with
77    /// no external canceller.
78    pub cancel: Option<Arc<AtomicBool>>,
79}
80
81/// The durable state of an agent session: the scoped tool catalogue, the
82/// resource-awareness map, and the **conversation transcript** — everything that
83/// persists *across turns*. A once-mode / per-event run is a session of exactly
84/// one turn ([`run_loop`]); a **warm** continue-session runs many turns over the
85/// same transcript, each new event appended via [`Session::deliver`] before
86/// another [`Session::run_turn`].
87pub struct Session<'a> {
88    servers: &'a [McpClient],
89    tools: Vec<ToolDef>,
90    tool_to_server: HashMap<String, usize>,
91    resources: ResourceCatalogue,
92    model: String,
93    messages: Vec<Message>,
94    /// The narrowed tool GRANTS this session runs under: a parent's
95    /// `subagent.run` `tools:` list, carried on the seed under
96    /// [`ALLOWED_TOOLS_ROLE`]. Each element is one grant's pattern list, and a
97    /// tool must satisfy EVERY grant: a grant only ever narrows, so intersecting
98    /// is the only safe way to combine two. Empty = ungranted = the full
99    /// catalogue (a root / embedded run, which is nobody's subagent).
100    allowed: Vec<Vec<String>>,
101}
102
103impl<'a> Session<'a> {
104    /// Assemble a session: the tool catalogue (MCP tools + self-tools, plus
105    /// `resource.read` when resources exist), the resource awareness note, and
106    /// the opening transcript (system prompt + seed + the instruction as the
107    /// first user turn). Resources are split deliberately: listing them makes
108    /// the model aware of what exists, while reading one is an explicit tool
109    /// call, so a large resource enters the context only when actually wanted.
110    pub fn prepare(
111        servers: &'a [McpClient],
112        input: &LoopInput,
113        self_handler: &mut dyn SelfHandler,
114    ) -> Result<Session<'a>, LoopAbort> {
115        let allowed = seed_grants(&input.seed);
116        let (mut tools, mut tool_to_server) = build_catalogue(servers)?;
117        // CODE-REGISTERED tools win a name collision: drop the MCP entry so the
118        // catalogue offers ONE def per name, and it is the one dispatch will
119        // actually run. Otherwise the model would be shown a schema it is not
120        // calling, and a server could shadow a first-party tool.
121        let code = crate::tools::defs();
122        if !code.is_empty() {
123            tools.retain(|t| !crate::tools::is_registered(&t.name));
124            tools.extend(code);
125        }
126        tools.extend(self_handler.tools());
127        let resources = collect_resources(servers);
128        // Offer `resource.read` when there are MCP resources OR the handler
129        // serves agentd:// self-resources (e.g. async-child completions).
130        if !resources.owner.is_empty() || self_handler.serves_self_resources() {
131            tools.push(resource_read_tool_def());
132        }
133        // The parent's narrowed grant lands LAST, over the whole assembled
134        // catalogue (MCP + code + self-tools + `resource.read`) and over the
135        // routing map that governs dispatch. Scope narrows monotonically, so a
136        // grant of `["a"]` must mean `a` and nothing else — not "a, plus
137        // everything merged in after the grant was applied".
138        narrow_catalogue(&allowed, &mut tools, &mut tool_to_server);
139        let mut messages = vec![Message::system(system_prompt(
140            input.output_contract.as_deref(),
141        ))];
142        if let Some(note) = resources.catalogue_note() {
143            messages.push(Message::system(note));
144        }
145        for (role, content) in &input.seed {
146            // The grant is policy, not conversation: it never enters the
147            // transcript (and so never reaches the model as a suggestion).
148            if role == ALLOWED_TOOLS_ROLE {
149                continue;
150            }
151            messages.push(seed_message(role, content));
152        }
153        messages.push(Message::user(&input.instruction));
154        Ok(Session {
155            servers,
156            tools,
157            tool_to_server,
158            resources,
159            model: input.model.clone(),
160            messages,
161            allowed,
162        })
163    }
164
165    /// Rebuild the MCP side of the tool catalogue from the servers' CURRENT
166    /// `tools/list`. Called at a turn boundary after an inbound
167    /// `notifications/tools/list_changed`, so a long-lived continue-session
168    /// tracks a server whose tool set changed instead of holding a stale
169    /// catalogue for its whole life. Self-tools and `resource.read` are
170    /// re-merged and the transcript is untouched, so a refresh costs no context.
171    pub fn refresh_tools(&mut self, self_handler: &mut dyn SelfHandler) -> Result<(), LoopAbort> {
172        let (mut tools, mut tool_to_server) = build_catalogue(self.servers)?;
173        // Same code-tool precedence as `prepare`: first-party wins the name.
174        let code = crate::tools::defs();
175        if !code.is_empty() {
176            tools.retain(|t| !crate::tools::is_registered(&t.name));
177            tools.extend(code);
178        }
179        tools.extend(self_handler.tools());
180        if !self.resources.owner.is_empty() || self_handler.serves_self_resources() {
181            tools.push(resource_read_tool_def());
182        }
183        // Re-narrow: a server that ADDS a tool mid-session must not widen a
184        // grant the parent already bounded. A refresh rebuilds the catalogue; it
185        // is never a re-grant, so the child's scope can only stay the same or
186        // shrink across one.
187        narrow_catalogue(&self.allowed, &mut tools, &mut tool_to_server);
188        self.tools = tools;
189        self.tool_to_server = tool_to_server;
190        Ok(())
191    }
192
193    /// The current catalogue size (observability for the live refresh).
194    pub fn tools_len(&self) -> usize {
195        self.tools.len()
196    }
197
198    /// Classify a catalogue tool by its seam: a name routed to an MCP server is
199    /// [`ToolClass::Mcp`], dispatched back to that server; every other catalogue
200    /// entry is agentd's own [`ToolClass::SelfControl`] surface — the self-tools
201    /// plus `resource.read`. The routing map IS the MCP-tool set, and the two
202    /// classes are assembled by different code paths ([`build_catalogue`] versus
203    /// the [`SelfHandler`] merge), which makes this an authoritative and testable
204    /// boundary between "tools from a registered server" and "agentd's own
205    /// orchestration primitives".
206    ///
207    /// Callers must pass a name drawn from [`Session::tools`]: a name absent from
208    /// the catalogue classifies as `SelfControl`, since it is by definition not a
209    /// routed server tool, so classifying an arbitrary string is meaningless.
210    pub fn tool_class(&self, name: &str) -> ToolClass {
211        // A code-registered name classifies `Code` even when an MCP server
212        // publishes the same name, matching what dispatch does: code wins, so a
213        // remote server cannot steal a registered tool's calls. Registration
214        // refuses self/control names, so `Code` never claims that class.
215        if crate::tools::is_registered(name) {
216            ToolClass::Code
217        } else if self.tool_to_server.contains_key(name) {
218            ToolClass::Mcp
219        } else {
220            ToolClass::SelfControl
221        }
222    }
223
224    /// Whether this session's GRANT admits `name`. Every grant must admit it; an
225    /// ungranted session — one with no parent narrowing — admits everything.
226    /// The catalogue is already filtered, so this is a second gate at dispatch
227    /// time, for the model that names a tool anyway: a hallucinated name, or one
228    /// remembered from a transcript written when the catalogue was wider.
229    pub fn tool_permitted(&self, name: &str) -> bool {
230        grant_permits(&self.allowed, name)
231    }
232
233    /// Append the next event as a new user turn — the delivery point for a warm
234    /// continue-session. The transcript, which is the model's memory of the
235    /// session, carries forward, so the next turn continues the conversation
236    /// rather than starting over.
237    pub fn deliver(&mut self, content: &str) {
238        self.messages.push(Message::user(content));
239    }
240
241    /// Adopt a new model for subsequent turns. The transcript is UNTOUCHED —
242    /// only the model dialed for the NEXT turn changes, and a turn already in
243    /// flight runs to completion on the model it started with. The `model` is
244    /// what each request's `model` field carries.
245    pub fn set_model(&mut self, model: &str) {
246        self.model = model.to_string();
247    }
248
249    /// The current model dialed for the next turn. Used to decide whether a
250    /// pending swap actually changes the model: an endpoint repoint that leaves
251    /// the model alone needs no turn restart, since the output would match.
252    pub fn model(&self) -> &str {
253        &self.model
254    }
255
256    /// The number of transcript messages so far — a cheap pre-turn marker for the
257    /// `restart-turn` policy: snapshot this before a turn, then
258    /// [`truncate_transcript`](Session::truncate_transcript) back to it to discard
259    /// the swapped turn's appended messages and re-run from the same pre-turn state.
260    pub fn transcript_len(&self) -> usize {
261        self.messages.len()
262    }
263
264    /// Truncate the transcript back to `len` for a `restart-turn`: drop every
265    /// message a discarded turn appended, restoring the exact pre-turn
266    /// transcript so the turn can be re-run on the new model. A no-op when `len`
267    /// is already at or past the current length — this never grows the
268    /// transcript, so a stale marker cannot resurrect dropped messages.
269    pub fn truncate_transcript(&mut self, len: usize) {
270        if len < self.messages.len() {
271            self.messages.truncate(len);
272        }
273    }
274
275    /// Run one turn: the ReAct loop over the persistent transcript until a
276    /// terminal status, bounded by `budget`. `cancel` is polled at each turn
277    /// boundary. Every assistant/tool message (including the final answer) is
278    /// appended to the transcript, so a subsequent turn continues the same
279    /// conversation.
280    ///
281    /// Returns the turn's [`Outcome`] together with the turn's token [`Usage`] —
282    /// the sum of every model call in this turn. The control layer rolls this
283    /// DELTA up to the supervisor as
284    /// [`crate::subagent::protocol::AgentMsg::Usage`], which is what makes
285    /// hierarchical token accounting (`agentd_tokens_total`) add up. The loop
286    /// itself never touches the control channel: the `up` handle stays in
287    /// `control.rs`, so the loop stays runnable in-process and in a child alike.
288    pub fn run_turn(
289        &mut self,
290        intel: &IntelClient,
291        self_handler: &mut dyn SelfHandler,
292        log: &Logger,
293        budget: &mut Budget,
294        cancel: Option<&Arc<AtomicBool>>,
295    ) -> Result<(Outcome, Usage), LoopAbort> {
296        let mut last_text: Option<String> = None;
297        // otel run trace: the `invoke_agent` span plus a `chat` child per model
298        // call and an `execute_tool` child per tool call. No-op without
299        // `--features otel`, so the wiring carries no `cfg`. One trace per turn.
300        let run_start = crate::obs::otel::now_unix_nanos();
301        let mut run_span = crate::obs::otel::run_begin(log.ctx().trace_id.as_deref(), run_start);
302        let (mut tok_in, mut tok_out) = (0u64, 0u64);
303
304        log.info(
305            "loop.start",
306            json!({"tools": self.tools.len(), "servers": self.servers.len(), "resources": self.resources.owner.len(), "max_steps": budget.max_steps()}),
307        );
308
309        loop {
310            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
311                log.warn(
312                    "loop.final",
313                    json!({"status": "cancelled", "steps": budget.steps()}),
314                );
315                run_span.finish(&self.model, tok_in, tok_out, false);
316                return Ok((
317                    Outcome {
318                        status: TerminalStatus::Cancelled,
319                        partial: last_text.is_some(),
320                        result: json!(last_text.unwrap_or_default()),
321                        scheduled: self_handler.take_scheduled(),
322                        subscriptions: self_handler.take_subscriptions(),
323                    },
324                    Usage {
325                        input_tokens: tok_in,
326                        output_tokens: tok_out,
327                    },
328                ));
329            }
330            if let Some(status) = budget.exceeded() {
331                log.warn("loop.final", json!({"status": status.as_str(), "steps": budget.steps(), "tokens": budget.tokens()}));
332                run_span.finish(&self.model, tok_in, tok_out, false);
333                return Ok((
334                    Outcome {
335                        status,
336                        partial: last_text.is_some(),
337                        result: json!(last_text.unwrap_or_default()),
338                        scheduled: self_handler.take_scheduled(),
339                        subscriptions: self_handler.take_subscriptions(),
340                    },
341                    Usage {
342                        input_tokens: tok_in,
343                        output_tokens: tok_out,
344                    },
345                ));
346            }
347
348            // Per-turn audit anchor: the running budget snapshot at the head of
349            // each ReAct step, distinct from the LLM-call event below so the two
350            // can be counted separately when reading a trace.
351            log.debug(
352                "loop.step",
353                json!({"step": budget.steps(), "tokens": budget.tokens(), "messages": self.messages.len()}),
354            );
355
356            let req = Request {
357                model: self.model.clone(),
358                messages: self.messages.clone(),
359                tools: self.tools.clone(),
360                max_tokens: PER_CALL_MAX_TOKENS,
361                temperature: Some(0.0),
362            };
363
364            log.debug(
365                "intel.call",
366                json!({"step": budget.steps(), "messages": self.messages.len()}),
367            );
368            let chat_start = crate::obs::otel::now_unix_nanos();
369            let resp = intel
370                .complete(&req)
371                .map_err(|e| LoopAbort::Intel(e.to_string()))?;
372            budget.record_usage(resp.usage);
373            budget.record_step();
374            tok_in += resp.usage.input_tokens;
375            tok_out += resp.usage.output_tokens;
376            run_span.record_chat(
377                &self.model,
378                resp.usage.input_tokens,
379                resp.usage.output_tokens,
380                true,
381                chat_start,
382            );
383            log.debug(
384                "intel.result",
385                json!({"tool_calls": resp.tool_calls.len(), "tokens_in": resp.usage.input_tokens, "tokens_out": resp.usage.output_tokens}),
386            );
387
388            if resp.wants_tools() {
389                if let Some(t) = resp.text.as_deref().filter(|t| !t.is_empty()) {
390                    last_text = Some(t.to_string());
391                }
392                let tool_calls = resp.tool_calls.clone();
393                self.messages.push(Message::Assistant {
394                    text: resp.text,
395                    tool_calls: tool_calls.clone(),
396                });
397
398                for tc in &tool_calls {
399                    let mut call = json!({"tool": tc.name, "id": tc.id});
400                    // Content capture is opt-in: by default only the tool name
401                    // and body length are logged, because arguments routinely
402                    // carry sensitive data. `--log-content` adds the truncated
403                    // arguments and result body for debugging.
404                    if log.content_capture() {
405                        call["args"] = json!(truncate_for_log(&tc.arguments.to_string()));
406                    }
407                    log.info("tool.call", call);
408                    let tool_start = crate::obs::otel::now_unix_nanos();
409                    let (content, is_error) = if !self.tool_permitted(&tc.name) {
410                        // Refused, never served: the grant binds the DISPATCH,
411                        // not just the definitions offered. A model that names a
412                        // narrowed-away tool gets an error observation it can
413                        // adapt to, exactly like an unknown tool.
414                        (
415                            format!(
416                                "error: tool '{}' is not in this subagent's allowed tools",
417                                tc.name
418                            ),
419                            true,
420                        )
421                    } else if tc.name == "resource.read" {
422                        // An `agentd://` URI reads agentd's own state (e.g. an
423                        // async child's completion) via the self-handler; any
424                        // other URI is an MCP-server resource.
425                        let uri = tc
426                            .arguments
427                            .get("uri")
428                            .and_then(Value::as_str)
429                            .unwrap_or("")
430                            .trim();
431                        if uri.starts_with("agentd://") || uri.starts_with("agent://") {
432                            self_handler.read_resource(uri).unwrap_or_else(|| {
433                                (format!("unknown agentd resource: {uri}"), true)
434                            })
435                        } else {
436                            read_resource_tool(self.servers, &self.resources.owner, &tc.arguments)
437                        }
438                    } else {
439                        match self_handler.handle(&tc.name, &tc.arguments) {
440                            Some(r) => r, // a self-tool (e.g. subagent.spawn)
441                            // Code-registered tools next: first-party beats a
442                            // colliding remote name.
443                            None => match crate::tools::dispatch(&tc.name, &tc.arguments) {
444                                Some(r) => r,
445                                None => dispatch_tool(
446                                    self.servers,
447                                    &self.tool_to_server,
448                                    &tc.name,
449                                    &tc.arguments,
450                                ),
451                            },
452                        }
453                    };
454                    run_span.record_tool(&tc.name, !is_error, tool_start);
455                    let mut result =
456                        json!({"tool": tc.name, "is_error": is_error, "bytes": content.len()});
457                    if log.content_capture() {
458                        result["content"] = json!(truncate_for_log(&content));
459                    }
460                    log.info("tool.result", result);
461                    self.messages
462                        .push(Message::tool_result(&tc.id, content, is_error));
463                }
464                continue;
465            }
466
467            // No tool calls → the model's text is the final answer for this turn.
468            // Record it in the transcript so a warm session's next turn sees its
469            // own prior reply (invisible to once-mode, which discards the session).
470            let text = resp.text.clone().or(last_text).unwrap_or_default();
471            self.messages.push(Message::Assistant {
472                text: Some(text.clone()),
473                tool_calls: Vec::new(),
474            });
475            log.info(
476                "loop.final",
477                json!({"status": "completed", "steps": budget.steps(), "tokens": budget.tokens()}),
478            );
479            run_span.finish(&self.model, tok_in, tok_out, true);
480            return Ok((
481                Outcome {
482                    status: TerminalStatus::Completed,
483                    partial: false,
484                    result: json!(text),
485                    scheduled: self_handler.take_scheduled(),
486                    subscriptions: self_handler.take_subscriptions(),
487                },
488                Usage {
489                    input_tokens: tok_in,
490                    output_tokens: tok_out,
491                },
492            ));
493        }
494    }
495}
496
497/// The agentic loop over explicit inputs — one session, one turn. Used by
498/// once-mode (`run_root`) and a per-event subagent run (`subagent::control`).
499/// `self_handler` supplies agentd's in-process self-tools (e.g. `subagent.spawn`);
500/// the loop tries it before MCP. A warm continue-session instead drives
501/// [`Session`] directly across many turns.
502///
503/// Returns the run's [`Outcome`] together with the run's total token [`Usage`].
504/// A one-shot run is exactly one turn, so the run total IS that turn's usage,
505/// and the control layer emits it once per run as a single
506/// [`crate::subagent::protocol::AgentMsg::Usage`]. Emitting both a cumulative
507/// and a per-turn figure would double-count against the tree's token ceiling.
508pub fn run_loop(
509    intel: &IntelClient,
510    servers: &[McpClient],
511    input: &LoopInput,
512    self_handler: &mut dyn SelfHandler,
513    log: &Logger,
514) -> Result<(Outcome, Usage), LoopAbort> {
515    let mut session = Session::prepare(servers, input, self_handler)?;
516    let mut budget = Budget::new(input.max_steps, input.max_tokens, input.deadline);
517    session.run_turn(intel, self_handler, log, &mut budget, input.cancel.as_ref())
518}
519
520/// Max characters of tool content recorded under `--log-content`. Bounds a log
521/// line so a large tool body can't bloat the telemetry stream; the full body
522/// still flows to the model as the observation.
523const CONTENT_LOG_CAP: usize = 4096;
524
525/// Truncate a body for content-capture logging, appending a byte-count marker
526/// when clipped. Char-based so a multi-byte boundary is never split.
527fn truncate_for_log(s: &str) -> String {
528    if s.chars().count() <= CONTENT_LOG_CAP {
529        return s.to_string();
530    }
531    let mut t: String = s.chars().take(CONTENT_LOG_CAP).collect();
532    t.push_str(&format!(
533        "…(+{} more bytes)",
534        s.len().saturating_sub(t.len())
535    ));
536    t
537}
538
539/// Build the model's tool catalogue from every connected server, plus a
540/// name→server-index routing map. On a name collision the FIRST server in
541/// configuration order wins, so routing is deterministic and a later server
542/// cannot capture a name an earlier one already publishes. A call to a name no
543/// server publishes is reported as unknown at dispatch time.
544fn build_catalogue(
545    servers: &[McpClient],
546) -> Result<(Vec<ToolDef>, HashMap<String, usize>), LoopAbort> {
547    let mut tools = Vec::new();
548    let mut routing = HashMap::new();
549    for (i, server) in servers.iter().enumerate() {
550        let listed = server
551            .list_tools()
552            .map_err(|e| LoopAbort::Mcp(e.to_string()))?;
553        for t in listed {
554            routing.entry(t.name.clone()).or_insert(i);
555            tools.push(ToolDef {
556                name: t.name,
557                description: t.description.unwrap_or_default(),
558                input_schema: t.input_schema,
559            });
560        }
561    }
562    Ok((tools, routing))
563}
564
565/// The narrowed tool grants a spawn payload carried on its context seed: one
566/// pattern list per [`ALLOWED_TOOLS_ROLE`] entry. Normally zero, meaning no
567/// narrowing, or one, since the supervisor mints exactly one grant per child.
568fn seed_grants(seed: &[(String, String)]) -> Vec<Vec<String>> {
569    seed.iter()
570        .filter(|(role, _)| role == ALLOWED_TOOLS_ROLE)
571        .map(|(_, content)| crate::subagent::protocol::parse_allowed_tools(content))
572        .collect()
573}
574
575/// Whether every grant admits `name` — patterns are the registry's (`*`, an
576/// exact name, `prefix*`), so a `tools:` list reads the same here as it does in
577/// a workflow `agent` step. No grants ⇒ admitted.
578fn grant_permits(grants: &[Vec<String>], name: &str) -> bool {
579    grants
580        .iter()
581        .all(|g| g.iter().any(|p| crate::registry::pattern_matches(p, name)))
582}
583
584/// Drop everything the grants exclude from an assembled catalogue AND from the
585/// routing map — the map is what `dispatch_tool` consults, so filtering both is
586/// what makes an excluded MCP tool unreachable rather than merely unadvertised.
587fn narrow_catalogue(
588    grants: &[Vec<String>],
589    tools: &mut Vec<ToolDef>,
590    routing: &mut HashMap<String, usize>,
591) {
592    if grants.is_empty() {
593        return;
594    }
595    tools.retain(|t| grant_permits(grants, &t.name));
596    routing.retain(|name, _| grant_permits(grants, name));
597}
598
599/// Route one tool call to its owning server. A transport error comes back as an
600/// error *observation* (`is_error = true`) rather than an abort, so the model
601/// can adapt or try another route; a server that stays wedged is ultimately
602/// bounded by the step and deadline budget rather than by this call.
603fn dispatch_tool(
604    servers: &[McpClient],
605    routing: &HashMap<String, usize>,
606    name: &str,
607    arguments: &Value,
608) -> (String, bool) {
609    match routing.get(name) {
610        Some(&i) => {
611            // A flat subagent paces its own calls against the per-server rate
612            // limit too, not just the reactor: its pacing registry was seeded
613            // when it dialed its granted servers, so a fan-out of children
614            // cannot collectively outrun a server's declared rate.
615            if let Err(e) = crate::mcp::pace::take(servers[i].name()) {
616                return (e, true);
617            }
618            match servers[i].call_tool(name, Some(arguments.clone())) {
619                Ok(res) => (res.text(), res.is_error()),
620                Err(e) => (format!("tool transport error: {e}"), true),
621            }
622        }
623        None => (format!("error: no such tool '{name}'"), true),
624    }
625}
626
627/// The system prompt, with the delegation output contract appended when the
628/// spawn payload carried one, so a child sees the shape its result must take.
629fn system_prompt(contract: Option<&str>) -> String {
630    match contract {
631        Some(c) if !c.is_empty() => format!("{SYSTEM_PROMPT}\n\nOutput contract:\n{c}"),
632        _ => SYSTEM_PROMPT.to_string(),
633    }
634}
635
636/// Map a seed (role, content) pair to a loop message. A `tool` seed has no
637/// tool-call id to replay against, so it degrades to a user note.
638fn seed_message(role: &str, content: &str) -> Message {
639    match role {
640        "system" => Message::system(content),
641        "assistant" => Message::Assistant {
642            text: Some(content.to_string()),
643            tool_calls: Vec::new(),
644        },
645        _ => Message::user(content),
646    }
647}
648
649/// Cap on the injected resource catalogue (URIs only; bodies are pulled on
650/// demand). A server exposing thousands is truncated with a note.
651const RESOURCE_CAP: usize = 50;
652
653/// The compact resource awareness catalogue plus a uri→owning-server map for
654/// `resource.read`. Listing makes the model aware a resource exists; reading its
655/// body is a separate tool call, so nothing large enters the context unasked.
656struct ResourceCatalogue {
657    owner: HashMap<String, usize>,
658    entries: Vec<(String, String)>, // (uri, label)
659    truncated: bool,
660}
661
662impl ResourceCatalogue {
663    /// The system note listing readable resources (never their bodies).
664    fn catalogue_note(&self) -> Option<String> {
665        if self.entries.is_empty() {
666            return None;
667        }
668        let mut s = String::from(
669            "Available MCP resources — read the current content of any with the resource.read tool:\n",
670        );
671        for (uri, label) in &self.entries {
672            if label.is_empty() {
673                s.push_str(&format!("- {uri}\n"));
674            } else {
675                s.push_str(&format!("- {uri} — {label}\n"));
676            }
677        }
678        if self.truncated {
679            s.push_str(&format!(
680                "(… more than {RESOURCE_CAP} resources; list truncated)\n"
681            ));
682        }
683        Some(s)
684    }
685}
686
687/// List resources from every server (first owner wins for a duplicate URI),
688/// capped. `resources/list` is capability-gated in the client (empty if unsupported).
689fn collect_resources(servers: &[McpClient]) -> ResourceCatalogue {
690    let mut owner = HashMap::new();
691    let mut entries = Vec::new();
692    let mut truncated = false;
693    'outer: for (i, s) in servers.iter().enumerate() {
694        let Ok(list) = s.list_resources() else {
695            continue;
696        };
697        for r in list {
698            if entries.len() >= RESOURCE_CAP {
699                truncated = true;
700                break 'outer;
701            }
702            if !owner.contains_key(&r.uri) {
703                let label = r.title.or(r.name).or(r.description).unwrap_or_default();
704                owner.insert(r.uri.clone(), i);
705                entries.push((r.uri, label));
706            }
707        }
708    }
709    ResourceCatalogue {
710        owner,
711        entries,
712        truncated,
713    }
714}
715
716fn resource_read_tool_def() -> ToolDef {
717    ToolDef {
718        name: "resource.read".into(),
719        description: "Read the current content of an available MCP resource by its uri (see the \
720            resource catalogue). Use this to pull a resource's body when you need it."
721            .into(),
722        input_schema: json!({
723            "type": "object",
724            "properties": {"uri": {"type": "string", "description": "the resource uri to read"}},
725            "required": ["uri"]
726        }),
727    }
728}
729
730/// Handle a `resource.read` call against the connected servers: read from the
731/// owning server (or try each), returning the text as the observation.
732fn read_resource_tool(
733    servers: &[McpClient],
734    owner: &HashMap<String, usize>,
735    args: &Value,
736) -> (String, bool) {
737    let uri = args.get("uri").and_then(Value::as_str).unwrap_or("").trim();
738    if uri.is_empty() {
739        return ("error: resource.read requires a 'uri'".into(), true);
740    }
741    let candidates: Vec<usize> = match owner.get(uri) {
742        Some(i) => vec![*i],
743        None => (0..servers.len()).collect(), // a templated/unlisted uri — try all
744    };
745    for i in candidates {
746        if let Ok(r) = servers[i].read_resource(uri) {
747            return (r.text(), false);
748        }
749    }
750    (format!("resource.read: no server could read '{uri}'"), true)
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn resource_catalogue_note_lists_uris() {
759        let c = ResourceCatalogue {
760            owner: HashMap::new(),
761            entries: vec![
762                ("file:///a.json".into(), "inbox".into()),
763                ("db://orders".into(), String::new()),
764            ],
765            truncated: false,
766        };
767        let note = c.catalogue_note().unwrap();
768        assert!(note.contains("resource.read"));
769        assert!(note.contains("file:///a.json — inbox"));
770        assert!(note.contains("- db://orders\n"));
771    }
772
773    #[test]
774    fn empty_catalogue_is_no_note() {
775        let c = ResourceCatalogue {
776            owner: HashMap::new(),
777            entries: vec![],
778            truncated: false,
779        };
780        assert!(c.catalogue_note().is_none());
781    }
782
783    #[test]
784    fn resource_read_rejects_missing_uri() {
785        let (msg, err) = read_resource_tool(&[], &HashMap::new(), &json!({}));
786        assert!(err);
787        assert!(msg.contains("uri"));
788    }
789
790    #[test]
791    fn resource_read_no_server_is_an_error_observation() {
792        let (msg, err) = read_resource_tool(&[], &HashMap::new(), &json!({"uri": "file:///x"}));
793        assert!(err);
794        assert!(msg.contains("file:///x"));
795    }
796
797    #[test]
798    fn system_prompt_appends_contract() {
799        let p = system_prompt(Some("Return JSON."));
800        assert!(p.contains("Output contract:"));
801        assert!(p.contains("Return JSON."));
802        assert_eq!(system_prompt(None), SYSTEM_PROMPT);
803    }
804
805    #[test]
806    fn a_code_registered_tool_classifies_code_and_wins_a_name_collision() {
807        // A first-party (code-registered) tool beats a remote MCP tool of the
808        // same name, in classification and therefore in dispatch. Tool names
809        // here must be unique: the registry is process-global and tests share a
810        // process.
811        let _guard = crate::tools::test_registry_guard();
812        crate::tools::register(crate::tools::CodeTool::new(
813            "runner.code_tool",
814            "a native tool",
815            json!({"type": "object"}),
816            |_| Ok(json!("native")),
817        ))
818        .expect("register");
819        let mut tool_to_server = HashMap::new();
820        // The MCP side ALSO publishes the colliding name (a rogue/coincidental server).
821        tool_to_server.insert("runner.code_tool".to_string(), 0usize);
822        let sess = Session {
823            servers: &[],
824            tools: vec![],
825            tool_to_server,
826            resources: ResourceCatalogue {
827                owner: HashMap::new(),
828                entries: vec![],
829                truncated: false,
830            },
831            model: "m".into(),
832            messages: vec![],
833            allowed: Vec::new(),
834        };
835        assert_eq!(
836            sess.tool_class("runner.code_tool"),
837            ToolClass::Code,
838            "code wins the collision — a server cannot steal a registered tool's calls"
839        );
840        // And the dispatch agrees with the classification.
841        let (content, is_err) =
842            crate::tools::dispatch("runner.code_tool", &json!({})).expect("code tool dispatches");
843        assert!(!is_err);
844        assert_eq!(content, "native");
845        assert!(crate::tools::unregister("runner.code_tool"));
846    }
847
848    #[test]
849    fn catalogue_partitions_into_mcp_and_self_control_classes() {
850        use crate::agentloop::action::SELF_CONTROL_TOOLS;
851        // A catalogue: two MCP-server tools (routed) plus agentd's full
852        // self/control surface and resource.read. Every entry must classify into
853        // exactly one class — the MCP side is precisely the routed set, and the
854        // rest is agentd's own control surface — and no self/control tool is a
855        // local-execution primitive.
856        let mcp = ["db.query", "http.get"];
857        let mut tool_to_server = HashMap::new();
858        let mut tools: Vec<ToolDef> = Vec::new();
859        for n in mcp {
860            tool_to_server.insert(n.to_string(), 0usize);
861            tools.push(ToolDef {
862                name: n.into(),
863                description: String::new(),
864                input_schema: json!({}),
865            });
866        }
867        // The full self/control surface a root handler with peers advertises, plus
868        // the runner-added resource.read — i.e. the whole named class.
869        for n in SELF_CONTROL_TOOLS {
870            tools.push(ToolDef {
871                name: (*n).into(),
872                description: String::new(),
873                input_schema: json!({}),
874            });
875        }
876        let sess = Session {
877            servers: &[],
878            tools,
879            tool_to_server,
880            resources: ResourceCatalogue {
881                owner: HashMap::new(),
882                entries: vec![],
883                truncated: false,
884            },
885            model: "m".into(),
886            messages: vec![],
887            allowed: Vec::new(),
888        };
889        // Routed names → Mcp; every self/control name → SelfControl.
890        for n in mcp {
891            assert_eq!(sess.tool_class(n), ToolClass::Mcp, "{n} is an MCP tool");
892        }
893        for n in SELF_CONTROL_TOOLS {
894            assert_eq!(
895                sess.tool_class(n),
896                ToolClass::SelfControl,
897                "{n} is self/control"
898            );
899        }
900        // The classes EXACTLY cover the catalogue (no unclassified tool; no
901        // code tools are registered in this test, so `Code` counts zero).
902        let (mut n_mcp, mut n_self, mut n_code) = (0usize, 0usize, 0usize);
903        for t in &sess.tools {
904            match sess.tool_class(&t.name) {
905                ToolClass::Mcp => n_mcp += 1,
906                ToolClass::SelfControl => n_self += 1,
907                ToolClass::Code => n_code += 1,
908            }
909        }
910        assert_eq!(n_code, 0, "no code tools registered here");
911        assert_eq!(n_mcp, mcp.len(), "every MCP tool classified");
912        assert_eq!(
913            n_self,
914            SELF_CONTROL_TOOLS.len(),
915            "every self tool classified"
916        );
917        // The self/control class holds NO local-execution primitive.
918        for bad in [
919            "exec", "shell", "bash", "sh", "command", "system", "eval", "run",
920        ] {
921            assert!(
922                !SELF_CONTROL_TOOLS.contains(&bad),
923                "no local-exec self-tool: {bad}"
924            );
925        }
926    }
927
928    #[test]
929    fn dispatch_unknown_tool_is_error_observation() {
930        let routing = HashMap::new();
931        let (content, is_error) = dispatch_tool(&[], &routing, "ghost", &Value::Null);
932        assert!(is_error);
933        assert!(content.contains("ghost"));
934    }
935
936    #[test]
937    fn loop_abort_display() {
938        assert!(LoopAbort::Intel("down".into()).to_string().contains("down"));
939    }
940
941    #[test]
942    fn truncate_for_log_caps_and_marks() {
943        let short = "{\"a\":1}";
944        assert_eq!(truncate_for_log(short), short); // under the cap: verbatim
945        let big = "x".repeat(CONTENT_LOG_CAP + 500);
946        let out = truncate_for_log(&big);
947        assert!(out.len() < big.len());
948        assert!(
949            out.contains("more bytes"),
950            "truncation is marked: {}",
951            &out[out.len() - 32..]
952        );
953        // multi-byte safety: never panics on a char boundary
954        let multi = "é".repeat(CONTENT_LOG_CAP + 10);
955        let _ = truncate_for_log(&multi);
956    }
957
958    #[test]
959    fn refresh_tools_picks_up_a_changed_handler_catalogue() {
960        // A handler whose advertised tool set CHANGES between turns: refresh
961        // rebuilds the catalogue in place and leaves the transcript untouched.
962        // Hold the registry guard: `tools_len()` reads the process-global
963        // code-tool registry, so a concurrent register/unregister in another
964        // test must not perturb the exact +1 delta asserted below.
965        let _guard = crate::tools::test_registry_guard();
966        struct GrowingHandler {
967            grown: bool,
968        }
969        impl SelfHandler for GrowingHandler {
970            fn tools(&self) -> Vec<ToolDef> {
971                let mut t = vec![ToolDef {
972                    name: "alpha".into(),
973                    description: String::new(),
974                    input_schema: Value::Null,
975                }];
976                if self.grown {
977                    t.push(ToolDef {
978                        name: "beta".into(),
979                        description: String::new(),
980                        input_schema: Value::Null,
981                    });
982                }
983                t
984            }
985            fn handle(&mut self, _name: &str, _args: &Value) -> Option<(String, bool)> {
986                None
987            }
988        }
989        let input = LoopInput {
990            instruction: "x".into(),
991            output_contract: None,
992            seed: Vec::new(),
993            model: "m".into(),
994            max_steps: 5,
995            max_tokens: 1000,
996            deadline: std::time::Instant::now() + std::time::Duration::from_secs(5),
997            cancel: None,
998        };
999        let mut handler = GrowingHandler { grown: false };
1000        let mut session = Session::prepare(&[], &input, &mut handler).unwrap();
1001        let before = session.tools_len();
1002        let transcript = session.transcript_len();
1003        handler.grown = true;
1004        session.refresh_tools(&mut handler).unwrap();
1005        assert_eq!(session.tools_len(), before + 1, "the new tool is live");
1006        assert_eq!(session.transcript_len(), transcript, "transcript untouched");
1007        // And the class boundary still holds: a self-tool is SelfControl.
1008        assert_eq!(session.tool_class("beta"), ToolClass::SelfControl);
1009    }
1010
1011    #[test]
1012    fn a_seed_grant_narrows_the_catalogue_the_dispatch_and_nothing_else() {
1013        // With the `subagent.run` `tools:` grant, a child granted ["alpha"] sees
1014        // ONLY alpha: the grant filters the assembled catalogue, and dispatch
1015        // refuses a name the model produces anyway. The grant itself is policy,
1016        // so it never lands in the transcript.
1017        let _guard = crate::tools::test_registry_guard();
1018        struct TwoTools;
1019        impl SelfHandler for TwoTools {
1020            fn tools(&self) -> Vec<ToolDef> {
1021                ["alpha", "beta"]
1022                    .into_iter()
1023                    .map(|n| ToolDef {
1024                        name: n.into(),
1025                        description: String::new(),
1026                        input_schema: Value::Null,
1027                    })
1028                    .collect()
1029            }
1030            fn handle(&mut self, _name: &str, _args: &Value) -> Option<(String, bool)> {
1031                Some(("served".into(), false))
1032            }
1033        }
1034        let grant = LoopInput {
1035            instruction: "x".into(),
1036            output_contract: None,
1037            seed: vec![
1038                (
1039                    crate::subagent::protocol::ALLOWED_TOOLS_ROLE.to_string(),
1040                    "[\"alpha\"]".to_string(),
1041                ),
1042                ("user".to_string(), "a real seed message".to_string()),
1043            ],
1044            model: "m".into(),
1045            max_steps: 5,
1046            max_tokens: 1000,
1047            deadline: std::time::Instant::now() + std::time::Duration::from_secs(5),
1048            cancel: None,
1049        };
1050        let mut handler = TwoTools;
1051        let narrowed = Session::prepare(&[], &grant, &mut handler).unwrap();
1052        assert_eq!(narrowed.tools_len(), 1, "only the granted tool is offered");
1053        assert!(narrowed.tool_permitted("alpha"));
1054        assert!(
1055            !narrowed.tool_permitted("beta"),
1056            "a filtered-out tool is refused at dispatch, not served"
1057        );
1058        // The grant is not conversation: system prompt + the real seed + the
1059        // instruction — the marker is gone.
1060        assert_eq!(narrowed.transcript_len(), 3);
1061
1062        // The same payload WITHOUT the grant is the unnarrowed baseline.
1063        let mut plain = grant;
1064        plain.seed.remove(0);
1065        let wide = Session::prepare(&[], &plain, &mut handler).unwrap();
1066        assert_eq!(wide.tools_len(), 2);
1067        assert!(wide.tool_permitted("beta"));
1068    }
1069
1070    // ---- the run_turn / run_loop token-usage producer ----
1071    //
1072    // `run_turn` and `run_loop` return the turn's / run's `Usage` so `control.rs`
1073    // can roll it up to the supervisor as `AgentMsg::Usage`: they are the
1074    // producer end of the producer → consumer → `agentd_tokens_total` chain, and
1075    // a zero here silently zeroes the whole chain. These tests drive the *real*
1076    // loop against the built-in mock LLM and assert the returned `Usage` carries
1077    // the model's reported tokens. The consumer half is covered by the
1078    // `obs::metrics` `record_tokens` tests, and end to end by the reactive
1079    // `/metrics` scrape in `reactive_e2e`.
1080    #[cfg(unix)]
1081    mod usage_producer {
1082        use super::*;
1083        use crate::intel::client::IntelClient;
1084        use crate::obs::log::{Comp, Level, LogCtx, Logger};
1085        use std::time::{Duration, Instant};
1086
1087        /// A SelfHandler that advertises no self-tools and handles nothing — the
1088        /// loop falls through to MCP (here: no servers), so a `final` script's
1089        /// answer ends the turn at once.
1090        struct NoopHandler;
1091        impl SelfHandler for NoopHandler {
1092            fn tools(&self) -> Vec<ToolDef> {
1093                Vec::new()
1094            }
1095            fn handle(&mut self, _name: &str, _args: &Value) -> Option<(String, bool)> {
1096                None
1097            }
1098        }
1099
1100        fn test_log() -> Logger {
1101            Logger::new(
1102                LogCtx {
1103                    run_id: "r".into(),
1104                    agent_id: "0".into(),
1105                    agent_path: "0".into(),
1106                    comp: Comp::Agent,
1107                    pid: 0,
1108                    trace_id: None,
1109                },
1110                Level::Error, // keep the test quiet
1111            )
1112        }
1113
1114        /// Run the built-in mock LLM with `script` on a background thread, and
1115        /// return its `http://<addr>` intelligence URL. Blocks until the server
1116        /// has announced its address through `addr_file`, so the first
1117        /// `complete()` connects instead of racing the bind.
1118        fn start_mock_llm(addr_file: &std::path::Path, script: &'static str) -> String {
1119            let s = addr_file.to_str().unwrap().to_string();
1120            std::thread::spawn(move || {
1121                crate::intel::mock::run(&s, script);
1122            });
1123            let deadline = Instant::now() + Duration::from_secs(3);
1124            while !addr_file.exists() {
1125                assert!(Instant::now() < deadline, "mock-llm never announced");
1126                std::thread::sleep(Duration::from_millis(10));
1127            }
1128            let addr = std::fs::read_to_string(addr_file).expect("read mock-llm addr-file");
1129            format!("http://{}", addr.trim())
1130        }
1131
1132        fn input(instruction: &str) -> LoopInput {
1133            LoopInput {
1134                instruction: instruction.into(),
1135                output_contract: None,
1136                seed: Vec::new(),
1137                model: "mock".into(),
1138                max_steps: 8,
1139                max_tokens: 100_000,
1140                deadline: Instant::now() + Duration::from_secs(10),
1141                cancel: None,
1142            }
1143        }
1144
1145        #[test]
1146        fn run_turn_returns_the_turns_token_usage() {
1147            // The `final` script answers in one model call reporting
1148            // usage{prompt_tokens: 11, completion_tokens: 5} (intel::mock). The
1149            // turn must surface exactly that split, non-zero, since it is the
1150            // value control.rs emits upward.
1151            let dir = tempfile::tempdir().unwrap();
1152            let sock = dir.path().join("llm.addr");
1153            let url = start_mock_llm(&sock, "final");
1154
1155            let intel = IntelClient::from_parts(&url, None).unwrap();
1156            let inp = input("do the thing");
1157            let mut handler = NoopHandler;
1158            let mut session = Session::prepare(&[], &inp, &mut handler).unwrap();
1159            let mut budget = Budget::new(inp.max_steps, inp.max_tokens, inp.deadline);
1160
1161            let (outcome, usage) = session
1162                .run_turn(&intel, &mut handler, &test_log(), &mut budget, None)
1163                .expect("turn runs against the mock LLM");
1164
1165            assert_eq!(outcome.status, TerminalStatus::Completed);
1166            // The producer half: the turn's reported tokens, non-zero, so the
1167            // AgentMsg::Usage control.rs sends carries real tokens.
1168            assert_eq!(
1169                usage.input_tokens, 11,
1170                "input tokens surfaced from the model"
1171            );
1172            assert_eq!(
1173                usage.output_tokens, 5,
1174                "output tokens surfaced from the model"
1175            );
1176            assert!(usage.total() > 0, "the rolled-up Usage is non-zero");
1177        }
1178
1179        #[test]
1180        fn run_loop_returns_the_runs_total_token_usage() {
1181            // The one-shot path: run_loop is a single turn, so its returned Usage IS
1182            // that turn's usage — one Usage per run (no double-count). The `read`
1183            // script makes a tool call then answers: two model calls, so the run
1184            // total SUMS both turns' tokens (each reports 11 in; 7 then 5 out).
1185            let dir = tempfile::tempdir().unwrap();
1186            let sock = dir.path().join("llm.addr");
1187            let url = start_mock_llm(&sock, "read");
1188
1189            let intel = IntelClient::from_parts(&url, None).unwrap();
1190            let inp = input("read the resource");
1191            let mut handler = NoopHandler;
1192
1193            let (outcome, usage) =
1194                run_loop(&intel, &[], &inp, &mut handler, &test_log()).expect("one-shot run");
1195
1196            assert_eq!(outcome.status, TerminalStatus::Completed);
1197            // Two model calls in the run (tool call then final answer) — the run
1198            // total accumulates both, proving run_loop sums across its turns' calls.
1199            assert_eq!(usage.input_tokens, 22, "summed input over both model calls");
1200            assert_eq!(
1201                usage.output_tokens, 12,
1202                "summed output over both model calls"
1203            );
1204        }
1205    }
1206}