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}