Skip to main content

agentd/runtime/
tools.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Internal tool execution**: the runtime is the single place
3//! internal tools run — for a turn worker's `ToolRequest` (answered with
4//! `ToolResult`), for a workflow step (`tool` / `memory.*` / … kinds) and for
5//! A2A commands. Keeping them here means every state change is made by the
6//! state owner. Arguments are validated against the contract's input
7//! schema before dispatch and results against the output schema after
8//! (schema failure ⇒ a tool error, never a panic). Some tools are **deferred**
9//! (`sleep`, `subagent.run sync`, `subagent.await`, `await`, `think`,
10//! `context.compact`, `workflow.run wait`, `workflow.wait`): the request is
11//! parked in `pending` and answered when its wait resolves. Mapped tools
12//! (overrides) run on an executor thread against the runtime's own MCP
13//! connection.
14
15use super::children::ChildKind;
16use super::events::Event;
17use super::reactor::{PendingKind, PendingTool, Runtime, Target};
18use crate::context::ROOT;
19use crate::context::plan::{self, Plan};
20use crate::registry::{Registry, Route};
21use crate::state::now_ms;
22use crate::subagent::protocol::ControlMsg;
23use crate::supervisor::tree::NodeId;
24use serde_json::{Value, json};
25use std::time::Duration;
26
27/// Who is calling (derived from the child kind or the step).
28#[derive(Debug, Clone, Default)]
29pub(crate) struct ToolCaller {
30    pub node: Option<NodeId>,
31    pub req: u64,
32    pub ctx: Option<String>,
33    pub run: Option<String>,
34    pub step: Option<String>,
35    pub principal: Option<String>,
36    pub subagent: Option<String>,
37    /// The message-hop depth of the work this call belongs to (see
38    /// `RunState::msg_depth`). Carried so `message.send` can refuse to extend a
39    /// chain that has already gone too deep.
40    pub msg_depth: u32,
41}
42
43impl ToolCaller {
44    fn label(&self) -> String {
45        if let Some(s) = &self.subagent {
46            return format!("subagent:{s}");
47        }
48        if let (Some(r), Some(s)) = (&self.run, &self.step) {
49            return format!("step:{r}/{s}");
50        }
51        format!("ctx:{}", self.ctx.as_deref().unwrap_or(ROOT))
52    }
53    /// The context whose plan/skills a `plan.*`/`skills.*` call addresses.
54    pub(crate) fn context_id(&self) -> String {
55        self.ctx.clone().unwrap_or_else(|| ROOT.to_string())
56    }
57    fn ctx_value(&self, instance: &str) -> Value {
58        json!({"instance": instance, "ctx": self.ctx, "run": self.run, "step": self.step, "principal": self.principal, "subagent": self.subagent})
59    }
60}
61
62/// The result of executing a tool.
63pub(crate) enum ToolOutcome {
64    Ready(Value, bool),
65    Deferred(PendingKind),
66    /// Running on an executor thread; the reply arrives as an event.
67    Executing,
68}
69
70impl Runtime {
71    /// A child asked for an internal tool.
72    pub(crate) fn on_tool_request(&mut self, node: NodeId, id: u64, name: &str, args: Value) {
73        self.counters.tool_calls += 1;
74        let caller = match self.children.get(node).map(|c| c.kind.clone()) {
75            Some(ChildKind::RootTurn { ctx, msg_depth, .. }) => ToolCaller {
76                node: Some(node),
77                req: id,
78                ctx: Some(ctx.clone()),
79                principal: self.contexts.get(&ctx).and_then(|c| c.principal.clone()),
80                msg_depth,
81                ..Default::default()
82            },
83            Some(ChildKind::StepTurn { run, step, .. }) => ToolCaller {
84                node: Some(node),
85                req: id,
86                run: Some(run.clone()),
87                step: Some(step),
88                ctx: self.runs.get(&run).and_then(|r| r.conversation.clone()),
89                principal: self.runs.get(&run).and_then(|r| r.principal.clone()),
90                msg_depth: self.runs.get(&run).map(|r| r.msg_depth).unwrap_or(0),
91                ..Default::default()
92            },
93            Some(ChildKind::Subagent { handle }) => ToolCaller {
94                node: Some(node),
95                req: id,
96                subagent: Some(handle),
97                ..Default::default()
98            },
99            Some(ChildKind::Think { ctx, .. }) => ToolCaller {
100                node: Some(node),
101                req: id,
102                ctx,
103                ..Default::default()
104            },
105            None => return,
106        };
107        self.log.info("tool.request", json!({"node": node.0, "req": id, "tool": name, "caller": caller.label(), "args": if self.log.content_capture() { args.clone() } else { Value::Null }}));
108        match self.execute_tool(&caller, name, args) {
109            ToolOutcome::Ready(v, err) => self.reply_tool(node, id, v, err),
110            ToolOutcome::Deferred(kind) => {
111                // Report the unit as parked on a wait, not thinking, so the
112                // live-activity feed does not show it burning a model call.
113                self.activity_park(node, name);
114                self.push_pending(PendingTool {
115                    target: Target::Child(node, id),
116                    name: name.to_string(),
117                    kind,
118                    started_ms: now_ms(),
119                });
120            }
121            ToolOutcome::Executing => {}
122        }
123    }
124
125    /// Answer a child's tool request.
126    pub(crate) fn reply_tool(&mut self, node: NodeId, req: u64, result: Value, is_error: bool) {
127        self.log.debug(
128            "tool.reply",
129            json!({"node": node.0, "req": req, "is_error": is_error}),
130        );
131        if !self.children.send(
132            node,
133            &ControlMsg::ToolResult {
134                id: req,
135                result,
136                is_error,
137            },
138        ) {
139            self.log
140                .debug("tool.reply.dropped", json!({"node": node.0, "req": req}));
141        }
142    }
143
144    /// Answer a deferred request wherever it came from.
145    pub(crate) fn reply(&mut self, target: &Target, result: Value, is_error: bool) {
146        match target {
147            Target::Child(node, req) => self.reply_tool(*node, *req, result, is_error),
148            Target::Step(run, step) => {
149                let (run, step) = (run.clone(), step.clone());
150                let error = is_error.then(|| match &result {
151                    Value::String(s) => s.clone(),
152                    other => other.to_string(),
153                });
154                self.on_step_done(&run, &step, result, is_error, error, 0);
155            }
156        }
157    }
158
159    /// Execute an internal (or mapped) tool for `caller`.
160    pub(crate) fn execute_tool(
161        &mut self,
162        caller: &ToolCaller,
163        name: &str,
164        args: Value,
165    ) -> ToolOutcome {
166        // Grant + availability.
167        let allowed = match (&caller.subagent, &caller.run) {
168            (Some(_), _) => self
169                .registry
170                .allowed(&crate::registry::Caller::Subagent { allow: None }, name),
171            (None, Some(_)) => self
172                .registry
173                .allowed(&crate::registry::Caller::Workflow, name),
174            _ => self.registry.allowed(&crate::registry::Caller::Root, name),
175        };
176        if !allowed {
177            let reason = match self.registry.get(name) {
178                None => format!("no such tool {name:?}"),
179                Some(t) if t.disabled => format!("tool {name:?} is disabled by configuration"),
180                Some(t) if !t.is_available() => {
181                    format!("tool {name:?} has no implementation (map it with tools.overrides)")
182                }
183                Some(_) => format!("tool {name:?} is not granted to {}", caller.label()),
184            };
185            return ToolOutcome::Ready(Value::String(reason), true);
186        }
187        if let Err(e) = self.registry.validate_args(name, &args) {
188            return ToolOutcome::Ready(Value::String(e), true);
189        }
190        // The policy verdict, at the one chokepoint every call passes — and
191        // deliberately AFTER `validate_args`, so an argument guard judges
192        // arguments that already conform to the tool's schema rather than
193        // whatever the model happened to emit.
194        if !self.settings.security.policies.is_empty()
195            && let Some(outcome) = self.apply_policy(caller, name, &args)
196        {
197            return outcome;
198        }
199        let route = self.registry.route(name).map(|r| match r {
200            Route::Internal => RouteKind::Internal,
201            Route::Mapped(m) => RouteKind::Mapped(m.clone()),
202            Route::Code => RouteKind::Code,
203            Route::Mcp { server, tool } => RouteKind::Mcp(server.to_string(), tool.to_string()),
204            Route::Workflow { workflow, sync } => RouteKind::Workflow(workflow.to_string(), sync),
205        });
206        let out = match route {
207            None => {
208                ToolOutcome::Ready(Value::String(format!("tool {name:?} is unavailable")), true)
209            }
210            Some(RouteKind::Internal) => self.builtin(caller, name, args),
211            Some(RouteKind::Mapped(m)) => self.run_mapped(caller, name, &m, args),
212            Some(RouteKind::Code) => match crate::tools::call(name, &args) {
213                Some(Ok(v)) => ToolOutcome::Ready(v, false),
214                Some(Err(e)) => ToolOutcome::Ready(Value::String(e), true),
215                None => {
216                    ToolOutcome::Ready(Value::String(format!("code tool {name:?} vanished")), true)
217                }
218            },
219            Some(RouteKind::Mcp(server, tool)) => {
220                self.run_mcp_call(caller, name, &server, &tool, args)
221            }
222            // A workflow tool IS `workflow.run`, which is the point: the
223            // caller sees one typed verb while the engine supplies retry,
224            // breaker, idempotency, a human gate and restart-survival.
225            Some(RouteKind::Workflow(workflow, sync)) => {
226                let mut wargs = json!({"name": workflow, "inputs": args});
227                if sync {
228                    wargs["wait"] = json!(true);
229                }
230                self.workflow_tool(caller, "workflow.run", wargs)
231            }
232        };
233        // Output validation for ready results.
234        match out {
235            ToolOutcome::Ready(v, false) => match self.registry.validate_result(name, &v) {
236                Ok(()) => ToolOutcome::Ready(v, false),
237                Err(e) => {
238                    self.log
239                        .warn("tool.result.schema", json!({"tool": name, "err": e}));
240                    ToolOutcome::Ready(Value::String(e), true)
241                }
242            },
243            other => other,
244        }
245    }
246
247    // ---- executors ---------------------------------------------------------
248
249    /// A mapped (override) tool: render args → MCP call on an executor thread → map result.
250    fn run_mapped(
251        &mut self,
252        caller: &ToolCaller,
253        name: &str,
254        m: &crate::registry::Mapping,
255        args: Value,
256    ) -> ToolOutcome {
257        let ctx = caller.ctx_value(&self.instance);
258        let mcp_args = match Registry::map_args(m, &args, &ctx) {
259            Ok(a) => a,
260            Err(e) => return ToolOutcome::Ready(Value::String(e), true),
261        };
262        if let Err(e) = self.service_rate_take(&m.server) {
263            return ToolOutcome::Ready(Value::String(e), true);
264        }
265        let Some(client) = self.mcp.get(&m.server).cloned() else {
266            return ToolOutcome::Ready(
267                Value::String(format!("server {:?} for {name} is not connected", m.server)),
268                true,
269            );
270        };
271        let mapping = m.clone();
272        let tool_name = name.to_string();
273        // `_meta` carried run, step, instance, idempotency key and attempt —
274        // and nothing about WHO the work is for, so a server could neither
275        // authorize nor attribute per person.
276        let mut meta = json!({"agent/idempotency_key": format!("{}/{}#{}", self.instance, caller.label(), caller.req), "agent/instance": self.instance});
277        meta["agent/acting_for"] = json!(
278            caller.principal.clone().unwrap_or_else(|| self
279                .settings
280                .identity
281                .autonomous_id()
282                .to_string())
283        );
284        let labels = self.labels_of(caller.principal.as_deref());
285        if !labels.is_empty() {
286            meta["agent/labels"] = json!(labels);
287        }
288        let timeout = self
289            .settings
290            .mcp
291            .default_timeout
292            .map(|d| d.0)
293            .unwrap_or(Duration::from_secs(60));
294        let tx = self.events_tx.clone();
295        let target = ExecTarget::from(caller);
296        let call_ctx = ctx.clone();
297        std::thread::Builder::new()
298            .name(format!("tool:{tool_name}"))
299            .spawn(move || {
300                let res =
301                    client.call_tool_with_meta_within(&mapping.tool, Some(mcp_args), meta, timeout);
302                let (result, is_error) = match res {
303                    Ok(r) => {
304                        // The result mapping sees `result`, the original `args` and `ctx`.
305                        let mut ctx = crate::store::mcp::result_ctx(&r);
306                        ctx["args"] = args;
307                        ctx["ctx"] = call_ctx;
308                        if r.is_error() {
309                            (Value::String(format!("{tool_name}: {}", r.text())), true)
310                        } else {
311                            match Registry::map_result(&mapping, &ctx) {
312                                Ok(v) => (v, false),
313                                Err(e) => (Value::String(e), true),
314                            }
315                        }
316                    }
317                    Err(e) => (
318                        Value::String(format!("{tool_name}: transport error: {e}")),
319                        true,
320                    ),
321                };
322                target.send(&tx, result, is_error);
323            })
324            .ok();
325        ToolOutcome::Executing
326    }
327
328    /// A plain MCP tool called through the runtime (workflow steps / A2A commands).
329    fn run_mcp_call(
330        &mut self,
331        caller: &ToolCaller,
332        name: &str,
333        server: &str,
334        tool: &str,
335        args: Value,
336    ) -> ToolOutcome {
337        // Pace calls toward a rated catalog service. A dry bucket answers with
338        // a tool error the model can absorb and retry, never a hang.
339        if let Err(e) = self.service_rate_take(server) {
340            return ToolOutcome::Ready(Value::String(e), true);
341        }
342        let Some(client) = self.mcp.get(server).cloned() else {
343            return ToolOutcome::Ready(
344                Value::String(format!("server {server:?} for {name} is not connected")),
345                true,
346            );
347        };
348        let tool = tool.to_string();
349        // `_meta` carried run, step, instance, idempotency key and attempt —
350        // and nothing about WHO the work is for, so a server could neither
351        // authorize nor attribute per person.
352        let mut meta = json!({"agent/idempotency_key": format!("{}/{}#{}", self.instance, caller.label(), caller.req), "agent/instance": self.instance});
353        meta["agent/acting_for"] = json!(
354            caller.principal.clone().unwrap_or_else(|| self
355                .settings
356                .identity
357                .autonomous_id()
358                .to_string())
359        );
360        let labels = self.labels_of(caller.principal.as_deref());
361        if !labels.is_empty() {
362            meta["agent/labels"] = json!(labels);
363        }
364        let timeout = self
365            .settings
366            .mcp
367            .default_timeout
368            .map(|d| d.0)
369            .unwrap_or(Duration::from_secs(60));
370        let tx = self.events_tx.clone();
371        let target = ExecTarget::from(caller);
372        std::thread::Builder::new()
373            .name(format!("mcp:{server}.{tool}"))
374            .spawn(move || {
375                let (result, is_error) =
376                    match client.call_tool_with_meta_within(&tool, Some(args), meta, timeout) {
377                        Ok(r) => (super::worker::tool_result_value(&r), r.is_error()),
378                        Err(e) => (Value::String(format!("transport error: {e}")), true),
379                    };
380                target.send(&tx, result, is_error);
381            })
382            .ok();
383        ToolOutcome::Executing
384    }
385
386    // ---- built-ins ---------------------------------------------------------
387
388    fn builtin(&mut self, caller: &ToolCaller, name: &str, args: Value) -> ToolOutcome {
389        let ok = |v: Value| ToolOutcome::Ready(v, false);
390        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
391        let by = caller.label();
392        match name {
393            // ---- instruction ----
394            "instruction.read" => ok(
395                json!({"text": self.instruction.text, "source": self.instruction.source, "uri": self.instruction.uri, "version": self.instruction.version.to_string()}),
396            ),
397            "instruction.subscribe" => {
398                let uri = args
399                    .get("uri")
400                    .and_then(Value::as_str)
401                    .map(str::to_string)
402                    .or_else(|| self.instruction.uri.clone());
403                match uri {
404                    None => err(
405                        "instruction.subscribe: the instruction is static text; give a uri".into(),
406                    ),
407                    Some(u) => match self.subscribe_instruction(&u) {
408                        Ok(()) => ok(json!({"subscribed": true, "uri": u})),
409                        Err(e) => err(e),
410                    },
411                }
412            }
413            // ---- memory ----
414            "memory.get" => match self
415                .memory
416                .get(&self.durable, args["key"].as_str().unwrap_or(""))
417            {
418                Ok(v) => ok(v),
419                Err(e) => err(e),
420            },
421            "memory.set" => {
422                let ttl = match args.get("ttl").and_then(Value::as_str) {
423                    Some(t) => match crate::config::parse_duration(t) {
424                        Ok(d) => Some(d.as_millis() as u64),
425                        Err(e) => return err(format!("memory.set: ttl: {e}")),
426                    },
427                    None => None,
428                };
429                match self.memory.set(
430                    &self.durable,
431                    args["key"].as_str().unwrap_or(""),
432                    args["value"].clone(),
433                    ttl,
434                    Some(&by),
435                ) {
436                    Ok(v) => ok(v),
437                    Err(e) => err(e),
438                }
439            }
440            "memory.list" => match self.memory.list(
441                &self.durable,
442                args.get("prefix").and_then(Value::as_str),
443                args.get("limit")
444                    .and_then(Value::as_u64)
445                    .map(|l| l as usize),
446            ) {
447                Ok(v) => ok(v),
448                Err(e) => err(e),
449            },
450            "memory.push" => match self.memory.push(
451                &self.durable,
452                args["key"].as_str().unwrap_or(""),
453                args.get("value").cloned().unwrap_or(Value::Null),
454                Some(&by),
455            ) {
456                Ok(v) => ok(v),
457                Err(e) => err(e),
458            },
459            "memory.shift" => match self.memory.shift(
460                &self.durable,
461                args["key"].as_str().unwrap_or(""),
462                Some(&by),
463            ) {
464                Ok(v) => ok(v),
465                Err(e) => err(e),
466            },
467            "memory.pop" => {
468                match self
469                    .memory
470                    .pop(&self.durable, args["key"].as_str().unwrap_or(""), Some(&by))
471                {
472                    Ok(v) => ok(v),
473                    Err(e) => err(e),
474                }
475            }
476            "memory.delete" => match self
477                .memory
478                .delete(&self.durable, args["key"].as_str().unwrap_or(""))
479            {
480                Ok(v) => ok(v),
481                Err(e) => err(e),
482            },
483            // ---- artifacts ----
484            "artifact.create" => {
485                let content = match (
486                    args.get("content"),
487                    args.get("from_step").and_then(Value::as_str),
488                ) {
489                    (Some(c), _) => c.clone(),
490                    (None, Some(step)) => match caller
491                        .run
492                        .as_ref()
493                        .and_then(|r| self.runs.get(r))
494                        .and_then(|r| r.steps.get(step))
495                        .and_then(|s| s.output.clone())
496                    {
497                        Some(o) => o,
498                        None => {
499                            return err(format!(
500                                "artifact.create: from_step {step:?} has no output"
501                            ));
502                        }
503                    },
504                    (None, None) => {
505                        return err("artifact.create: content or from_step is required".into());
506                    }
507                };
508                let owner = caller.run.clone().or_else(|| caller.ctx.clone());
509                match self.artifacts.create(
510                    &self.durable,
511                    super::artifacts::NewArtifact {
512                        name: args["name"].as_str().unwrap_or(""),
513                        mime: args.get("mime").and_then(Value::as_str),
514                        content,
515                        created_by: Some(&by),
516                        sensitive: args
517                            .get("sensitive")
518                            .and_then(Value::as_bool)
519                            .unwrap_or(false),
520                        owner: owner.as_deref(),
521                    },
522                ) {
523                    Ok(v) => ok(v),
524                    Err(e) => err(e),
525                }
526            }
527            "artifact.get" => match self.artifacts.get_value(args["id"].as_str().unwrap_or("")) {
528                Ok(v) => ok(v),
529                Err(e) => err(e),
530            },
531            "artifact.delete" => match self
532                .artifacts
533                .delete(&self.durable, args["id"].as_str().unwrap_or(""))
534            {
535                Ok(v) => ok(v),
536                Err(e) => err(e),
537            },
538            "artifact.list" => ok(self.artifacts.list(
539                args.get("prefix").and_then(Value::as_str),
540                args.get("limit")
541                    .and_then(Value::as_u64)
542                    .map(|l| l as usize),
543                None,
544            )),
545            // ---- plan ----
546            "plan.create" => {
547                let ctx_id = caller.context_id();
548                let max = self
549                    .settings
550                    .context
551                    .plan
552                    .max_items
553                    .unwrap_or(plan::DEFAULT_MAX_ITEMS as u32) as usize;
554                let items: Vec<Value> = args["items"].as_array().cloned().unwrap_or_default();
555                match Plan::create(args["goal"].as_str().unwrap_or(""), &items, max) {
556                    Ok(p) => {
557                        let v = p.to_value();
558                        self.context_for(&ctx_id, caller.principal.as_deref()).plan = Some(p);
559                        self.context_for(&ctx_id, caller.principal.as_deref())
560                            .touch();
561                        self.log
562                            .info("plan.updated", json!({"ctx": ctx_id, "op": "create"}));
563                        ok(v)
564                    }
565                    Err(e) => err(e),
566                }
567            }
568            "plan.get" => {
569                let ctx_id = caller.context_id();
570                match self.contexts.get(&ctx_id).and_then(|c| c.plan.as_ref()) {
571                    Some(p) => ok(json!({"plan": p.to_value(), "progress": p.progress()})),
572                    None => ok(json!({"plan": null, "progress": "no plan"})),
573                }
574            }
575            "plan.update" => {
576                let ctx_id = caller.context_id();
577                let max = self
578                    .settings
579                    .context
580                    .plan
581                    .max_items
582                    .unwrap_or(plan::DEFAULT_MAX_ITEMS as u32) as usize;
583                let c = self.context_for(&ctx_id, caller.principal.as_deref());
584                match c.plan.as_mut() {
585                    None => err("plan.update: no plan (call plan.create first)".into()),
586                    Some(p) => match p.update(&args, max) {
587                        Ok(()) => {
588                            let mut v = p.to_value();
589                            v["progress"] = json!(p.progress());
590                            c.touch();
591                            self.log
592                                .info("plan.updated", json!({"ctx": ctx_id, "op": "update"}));
593                            ok(v)
594                        }
595                        Err(e) => err(e),
596                    },
597                }
598            }
599            "plan.clear" => {
600                let ctx_id = caller.context_id();
601                let c = self.context_for(&ctx_id, caller.principal.as_deref());
602                let had = c.plan.take().is_some();
603                c.touch();
604                self.log
605                    .info("plan.updated", json!({"ctx": ctx_id, "op": "clear"}));
606                ok(json!({"ok": had}))
607            }
608            // ---- skills ----
609            "skills.list" => ok(self.skills.list_value()),
610            "skills.load" => {
611                let ctx_id = caller.context_id();
612                let name = args["name"].as_str().unwrap_or("").to_string();
613                let mcp = self.mcp.clone();
614                let resolver = move |server: &str| -> Option<
615                    std::sync::Arc<dyn crate::context::skills::SkillServer>,
616                > {
617                    mcp.get(server).map(|c| {
618                        c.clone() as std::sync::Arc<dyn crate::context::skills::SkillServer>
619                    })
620                };
621                match self
622                    .skills
623                    .load(&name, args.get("arguments").cloned(), &resolver)
624                {
625                    Ok(body) => {
626                        let max_loaded = self.settings.skills.max_loaded.unwrap_or(8) as usize;
627                        let c = self.context_for(&ctx_id, caller.principal.as_deref());
628                        match c.load_skill(&name, &body.hash, max_loaded) {
629                            Ok(()) => ok(
630                                json!({"loaded": true, "name": name, "hash": body.hash, "body": body.body}),
631                            ),
632                            Err(e) => err(e),
633                        }
634                    }
635                    Err(e) => err(e),
636                }
637            }
638            "skills.unload" => {
639                let ctx_id = caller.context_id();
640                let c = self.context_for(&ctx_id, caller.principal.as_deref());
641                ok(json!({"ok": c.unload_skill(args["name"].as_str().unwrap_or(""))}))
642            }
643            // ---- status ----
644            "status" => ok(self.status_value()),
645            // ---- time ----
646            "sleep" => {
647                let d = match crate::config::parse_duration(args["duration"].as_str().unwrap_or(""))
648                {
649                    Ok(d) => d,
650                    Err(e) => return err(format!("sleep: {e}")),
651                };
652                let deadline = now_ms() + d.as_millis() as u64;
653                let owner = match caller.node {
654                    Some(n) => {
655                        json!({"kind": "tool", "node": n.0, "req": caller.req, "tool": "sleep"})
656                    }
657                    None => json!({"kind": "step", "run": caller.run, "step": caller.step}),
658                };
659                match self.timers.arm(
660                    &self.durable,
661                    deadline,
662                    owner,
663                    json!({"slept_ms": d.as_millis() as u64}),
664                ) {
665                    Ok(id) => ToolOutcome::Deferred(PendingKind::Timer { id }),
666                    Err(e) => err(format!("sleep: {e}")),
667                }
668            }
669            "await" => {
670                let cond = args["condition"].as_str().unwrap_or("").to_string();
671                if let Err(e) =
672                    crate::cel::compile_check(cond.trim().trim_start_matches("CEL:").trim())
673                {
674                    return err(format!("await: {e}"));
675                }
676                let timeout = args
677                    .get("timeout")
678                    .and_then(Value::as_str)
679                    .and_then(|t| crate::config::parse_duration(t).ok())
680                    .unwrap_or(Duration::from_secs(600));
681                ToolOutcome::Deferred(PendingKind::Await {
682                    condition: cond,
683                    deadline_ms: now_ms() + timeout.as_millis() as u64,
684                })
685            }
686            // ---- context ----
687            "context.compact" => {
688                let ctx_id = caller.context_id();
689                let keep_last = args
690                    .get("keep_last")
691                    .and_then(Value::as_u64)
692                    .map(|k| k as usize)
693                    .unwrap_or(self.settings.context.keep_last.unwrap_or(12) as usize);
694                let target = args.get("target_tokens").and_then(Value::as_u64);
695                match caller.node {
696                    Some(node) => {
697                        self.start_compaction(&ctx_id, keep_last, target, Some((node, caller.req)));
698                        ToolOutcome::Deferred(PendingKind::Think {
699                            child: NodeId(u64::MAX),
700                        })
701                    }
702                    None => err("context.compact needs a calling turn".into()),
703                }
704            }
705            "think" => match caller.node {
706                Some(node) => match self.start_think(caller, &args, Some((node, caller.req))) {
707                    Ok(child) => ToolOutcome::Deferred(PendingKind::Think { child }),
708                    Err(e) => err(e),
709                },
710                None => err("think as a step is the `think` kind".into()),
711            },
712            // ---- lifecycle ----
713            "finish" => {
714                // The turn worker records the finish itself and reports it in
715                // its `TurnDone`, so the runtime only acknowledges here. A
716                // workflow step finishes through the `finish` kind instead.
717                ok(json!({"ok": true}))
718            }
719            // Human-in-the-loop: gate through the interface, or apply the
720            // configured fallback (fail | wait | auto judge) when no human is
721            // attached.
722            "ask_human" => self.ask_human_tool(caller, args),
723            // ---- subagents ----
724            "subagent.run" | "subagent.send" | "subagent.kill" | "subagent.status"
725            | "subagent.await" | "subagent.list" | "subagent.retire" => {
726                self.subagent_tool(caller, name, args)
727            }
728            // ---- conversations ----
729            "message.send" => self.message_send_tool(caller, args),
730            // ---- workflows ----
731            "workflow.run" | "workflow.list" | "workflow.status" | "workflow.cancel"
732            | "workflow.wait" | "workflow.create" | "workflow.update" | "workflow.delete"
733            | "workflow.pause" | "workflow.resume" | "workflow.signal" => {
734                self.workflow_tool(caller, name, args)
735            }
736            // ---- guarded local command runner (default-OFF) ----
737            #[cfg(feature = "exec")]
738            "exec" => self.exec_tool(caller, args),
739            other => err(format!(
740                "internal tool {other:?} has no built-in implementation"
741            )),
742        }
743    }
744
745    /// Which policy caller this invocation counts as.
746    pub(crate) fn policy_caller(caller: &ToolCaller) -> crate::config::v2::PolicyCaller {
747        use crate::config::v2::PolicyCaller;
748        match (&caller.subagent, &caller.run) {
749            (Some(_), _) => PolicyCaller::Subagent,
750            (None, Some(_)) => PolicyCaller::Workflow,
751            _ => PolicyCaller::Root,
752        }
753    }
754
755    /// Apply the policy list to one call. `None` means proceed.
756    fn apply_policy(
757        &mut self,
758        caller: &ToolCaller,
759        name: &str,
760        args: &Value,
761    ) -> Option<ToolOutcome> {
762        use crate::config::v2::PolicyAction;
763        let tags = self
764            .registry
765            .tags_of(std::slice::from_ref(&name.to_string()));
766        let who = Self::policy_caller(caller);
767        let call = crate::sec::policy::Call {
768            tool: name,
769            tags: &tags,
770            caller: who,
771            principal: caller.principal.as_deref(),
772            args,
773        };
774        let verdict = match crate::sec::policy::evaluate(&self.settings.security.policies, &call) {
775            Ok(None) => return None,
776            Ok(Some(v)) => v,
777            Err(rule) => {
778                // Fail closed and say which rule could not be judged.
779                self.log.error(
780                    "tool.policy.error",
781                    json!({"tool": name, "rule": rule, "caller": caller.label()}),
782                );
783                return Some(ToolOutcome::Ready(
784                    Value::String(format!(
785                        "refused: security.policies[{rule}] has an argument guard that could not be evaluated"
786                    )),
787                    true,
788                ));
789            }
790        };
791        match verdict.action {
792            PolicyAction::Allow => None,
793            PolicyAction::Deny | PolicyAction::Shadow => {
794                let held = verdict.action == PolicyAction::Shadow;
795                self.log.info(
796                    "tool.policy.refused",
797                    json!({"tool": name, "rule": verdict.rule, "caller": caller.label(),
798                           "action": if held { "shadow" } else { "deny" }}),
799                );
800                self.audit(super::audit::AuditEvent {
801                    action: "tool.policy",
802                    target: json!({"tool": name, "rule": verdict.rule}),
803                    outcome: if held { "shadow" } else { "deny" },
804                    principal: caller.principal.as_deref(),
805                    role: None,
806                    request_id: None,
807                });
808                // Shadow mode says plainly that the call was HELD, never
809                // returning a synthetic success. A schema-conformant fake is
810                // reasoned over as real, and every later decision is then
811                // built on a fabricated observation — which is a strange thing
812                // for a fail-closed runtime to ship, and worse than refusing.
813                let msg = if held {
814                    format!(
815                        "held by security.policies[{}]: this call was NOT executed and no result exists. \
816                         Treat it as not done — do not assume an outcome.",
817                        verdict.rule
818                    )
819                } else {
820                    format!("denied by security.policies[{}]", verdict.rule)
821                };
822                Some(ToolOutcome::Ready(Value::String(msg), true))
823            }
824            PolicyAction::Ask => Some(self.policy_gate(caller, name, args, &verdict)),
825        }
826    }
827
828    /// An `action: ask` verdict: put it to a person.
829    ///
830    /// Deliberately NOT routed through `agent.approval`. That setting decides
831    /// how asks the MODEL requested are handled, and its `auto` mode answers
832    /// them with a model judge — letting the agent approve the operator's own
833    /// security gate. An operator-declared gate goes to a human or it does not
834    /// pass.
835    fn policy_gate(
836        &mut self,
837        caller: &ToolCaller,
838        name: &str,
839        args: &Value,
840        verdict: &crate::sec::policy::Verdict,
841    ) -> ToolOutcome {
842        use crate::config::v2::PolicyAction;
843        let question = verdict
844            .question
845            .clone()
846            .unwrap_or_else(|| {
847                format!(
848                    "{} wants to call {name} — allow?",
849                    crate::sec::policy::caller_name(Self::policy_caller(caller))
850                )
851            })
852            .replace("{{tool}}", name)
853            .replace(
854                "{{caller}}",
855                crate::sec::policy::caller_name(Self::policy_caller(caller)),
856            )
857            .replace("{{args}}", &args.to_string());
858        #[cfg(feature = "a2a")]
859        let available = self.settings.interface.enabled && self.a2a_sink.is_some();
860        #[cfg(not(feature = "a2a"))]
861        let available = false;
862        if available {
863            self.log.info(
864                "tool.policy.ask",
865                json!({"tool": name, "rule": verdict.rule, "caller": caller.label()}),
866            );
867            #[cfg(feature = "a2a")]
868            {
869                let deadline =
870                    now_ms() + verdict.timeout_ms.unwrap_or(super::human::ASK_TIMEOUT_MS);
871                // A policy gate has no addressee: it asks whoever is watching.
872                // Naming a decider for an operator-declared tool gate is the
873                // same feature, but it belongs on the policy rule rather than
874                // being invented here.
875                return self.human_gate(caller, question, deadline, None, None);
876            }
877        }
878        // Nobody to ask. `on_timeout` decides, and it defaults to deny: a gate
879        // that cannot be answered has not been approved, and quietly running
880        // the call because no interface happens to be attached would make the
881        // policy a suggestion.
882        let fallback = verdict.on_timeout;
883        // The question goes in the log even though nobody can answer it: an
884        // operator reading this needs to know what they were not asked.
885        self.log.warn(
886            "tool.policy.unanswerable",
887            json!({"tool": name, "rule": verdict.rule, "question": question,
888                   "fallback": format!("{fallback:?}").to_lowercase(),
889                   "note": "no human channel (interface.enabled is off)"}),
890        );
891        if fallback == PolicyAction::Allow {
892            return ToolOutcome::Ready(Value::Null, false);
893        }
894        ToolOutcome::Ready(
895            Value::String(format!(
896                "denied by security.policies[{}]: a person had to approve this call and no human channel is attached",
897                verdict.rule
898            )),
899            true,
900        )
901    }
902
903    /// `message.send`: deliver into one of this instance's own conversations.
904    ///
905    /// The mirror of the `message` node, for callers that are not a workflow
906    /// step — a subagent reporting something worth thinking about, or a turn
907    /// handing work to another context. It returns as soon as the delivery is
908    /// durable; the turn it causes runs on its own schedule, so a caller never
909    /// blocks on the agent it just woke.
910    ///
911    /// Two refusals matter. A caller may not deliver into the conversation it
912    /// is itself running in — that is a turn talking to itself, and it is a
913    /// loop whichever way the reply goes. And the hop cap applies here exactly
914    /// as it does to the node, so a chain routed through a subagent is not a
915    /// way around it.
916    fn message_send_tool(&mut self, caller: &ToolCaller, args: Value) -> ToolOutcome {
917        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
918        let text = args["text"].as_str().unwrap_or("").trim().to_string();
919        if text.is_empty() {
920            return err("message.send: text is required".into());
921        }
922        let to = args["to"].as_str().unwrap_or(ROOT).trim();
923        let ctx = if to.eq_ignore_ascii_case("new") {
924            format!("msg-{}", crate::state::ulid::new())
925        } else if to.is_empty() {
926            ROOT.to_string()
927        } else {
928            to.to_string()
929        };
930        if caller.ctx.as_deref() == Some(ctx.as_str()) {
931            return err(format!(
932                "message.send: {ctx:?} is this caller's own conversation — a turn cannot message itself"
933            ));
934        }
935        let depth = caller.msg_depth + 1;
936        let cap = self.settings.limits.message_depth();
937        if depth > cap {
938            self.log.warn(
939                "message.too_deep",
940                json!({"caller": caller.label(), "conversation": ctx, "depth": depth, "max": cap}),
941            );
942            return err(format!(
943                "message.send refused: {depth} chained deliveries exceeds limits.max_message_depth ({cap})"
944            ));
945        }
946        let payload = json!({"text": text, "context_id": ctx, "msg_depth": depth});
947        match self.accept_event(
948            super::events::kinds::A2A_MESSAGE,
949            caller.principal.clone(),
950            payload,
951        ) {
952            Ok(_) => ToolOutcome::Ready(
953                json!({"delivered": true, "conversation": ctx, "depth": depth}),
954                false,
955            ),
956            Err(e) => err(format!("message.send: {e}")),
957        }
958    }
959
960    /// The `exec` tool: run one allow-listed command with the `security.exec`
961    /// controls on an executor thread (never the reactor). Reached only when the
962    /// runner is enabled — otherwise `exec` is mapping-only and this never routes
963    /// here. Every guard is re-checked here (defense in depth), not just at build.
964    #[cfg(feature = "exec")]
965    fn exec_tool(&mut self, caller: &ToolCaller, args: Value) -> ToolOutcome {
966        use super::exec;
967        let cfg = self.settings.security.exec.clone();
968        if !cfg.enabled {
969            return ToolOutcome::Ready(
970                Value::String("exec: local execution is disabled (security.exec.enabled)".into()),
971                true,
972            );
973        }
974        let cmd = args["cmd"].as_str().unwrap_or_default().to_string();
975        if cmd.is_empty() {
976            return ToolOutcome::Ready(Value::String("exec: `cmd` is required".into()), true);
977        }
978        // Allow-list (argv[0]); empty allow-list denies everything.
979        if !cfg.allow.iter().any(|a| a == &cmd) {
980            return ToolOutcome::Ready(
981                Value::String(format!(
982                    "exec: command {cmd:?} is not in security.exec.allow"
983                )),
984                true,
985            );
986        }
987        let Some(workdir) = cfg.workdir.clone() else {
988            return ToolOutcome::Ready(
989                Value::String("exec: security.exec.workdir must be set".into()),
990                true,
991            );
992        };
993        let cwd = match exec::resolve_cwd(std::path::Path::new(&workdir), args["cwd"].as_str()) {
994            Ok(c) => c,
995            Err(e) => return ToolOutcome::Ready(Value::String(format!("exec: {e}")), true),
996        };
997        let argv: Vec<String> = args["args"]
998            .as_array()
999            .map(|a| {
1000                a.iter()
1001                    .filter_map(|v| v.as_str().map(String::from))
1002                    .collect()
1003            })
1004            .unwrap_or_default();
1005        let stdin = args["cmd_stdin"]
1006            .as_str()
1007            .or_else(|| args["stdin"].as_str())
1008            .map(String::from);
1009        // Timeout: min(requested, configured max); output cap; env passthrough.
1010        let max_timeout = cfg.timeout.map(|d| d.0).unwrap_or(Duration::from_secs(30));
1011        let req = args["timeout"]
1012            .as_str()
1013            .and_then(|s| crate::config::parse_duration(s).ok());
1014        let timeout = req.map(|d| d.min(max_timeout)).unwrap_or(max_timeout);
1015        let max_output = cfg.max_output.unwrap_or(1 << 20) as usize;
1016        let env_pass = cfg.env.clone();
1017
1018        self.log.info(
1019            "exec.run",
1020            json!({"cmd": cmd, "argc": argv.len(), "cwd": cwd.display().to_string(), "timeout_ms": timeout.as_millis() as u64, "caller": caller.label()}),
1021        );
1022        let tx = self.events_tx.clone();
1023        let target = ExecTarget::from(caller);
1024        std::thread::Builder::new()
1025            .name("tool:exec".into())
1026            .spawn(move || {
1027                let (result, is_error) = match exec::run_command(
1028                    &cmd,
1029                    &argv,
1030                    &cwd,
1031                    stdin.as_deref(),
1032                    timeout,
1033                    max_output,
1034                    &env_pass,
1035                ) {
1036                    Ok(v) => (v, false),
1037                    Err(e) => (Value::String(format!("exec: {e}")), true),
1038                };
1039                target.send(&tx, result, is_error);
1040            })
1041            .ok();
1042        ToolOutcome::Executing
1043    }
1044
1045    /// The context a caller addresses (created on demand).
1046    pub(crate) fn context_for(
1047        &mut self,
1048        ctx_id: &str,
1049        principal: Option<&str>,
1050    ) -> &mut crate::context::ContextState {
1051        if ctx_id == ROOT {
1052            self.contexts.root()
1053        } else {
1054            self.contexts.conversation(ctx_id, principal)
1055        }
1056    }
1057
1058    /// Launch a `think` child for a tool request / a step.
1059    pub(crate) fn start_think(
1060        &mut self,
1061        caller: &ToolCaller,
1062        args: &Value,
1063        reply_to: Option<(NodeId, u64)>,
1064    ) -> Result<NodeId, String> {
1065        let prompt = args["prompt"].as_str().unwrap_or("").to_string();
1066        if prompt.trim().is_empty() {
1067            return Err("think: prompt must be non-empty".into());
1068        }
1069        let ctx_id = caller.context_id();
1070        let mut messages = Vec::new();
1071        // `reads`: memory keys folded into the prompt.
1072        if let Some(reads) = args.get("reads").and_then(Value::as_array) {
1073            for k in reads.iter().filter_map(Value::as_str) {
1074                if let Ok(v) = self.memory.get(&self.durable, k)
1075                    && v["found"] == json!(true)
1076                {
1077                    messages.push(crate::context::Msg::system(format!(
1078                        "memory[{k}] = {}",
1079                        v["value"]
1080                    )));
1081                }
1082            }
1083        }
1084        messages.push(crate::context::Msg::user(prompt, None));
1085        let output_schema = args.get("output_schema").cloned();
1086        let system = format!(
1087            "You are the reasoning module of {}. Think carefully about the request and reply with {}. No tools are available.",
1088            self.instance,
1089            if output_schema.is_some() {
1090                "ONLY one JSON object matching the schema"
1091            } else {
1092                "your conclusion (a JSON object when the request asks for structure)"
1093            }
1094        );
1095        let spec = crate::subagent::protocol::TurnSpec {
1096            kind: crate::subagent::protocol::TurnKind::Think,
1097            system,
1098            messages,
1099            tools: Vec::new(),
1100            internal: Vec::new(),
1101            mcp_routes: Default::default(),
1102            output_schema,
1103            max_rounds: 3,
1104            budget_admission: self.governor.is_active(),
1105            idempotency_prefix: String::new(),
1106            tool_meta: None,
1107            temperature: Some(0.0),
1108            max_tokens_per_call: 0,
1109            turn_id: self.next_id("think"),
1110        };
1111        let launch = super::turns::TurnLaunch {
1112            spec,
1113            kind: ChildKind::Think {
1114                purpose: "tool".into(),
1115                ctx: Some(ctx_id.clone()),
1116                reply_to,
1117                extra: Value::Null,
1118                reservation: None,
1119            },
1120            servers: Vec::new(),
1121            max_steps: 4,
1122            max_tokens: self.settings.limits.run.tokens(),
1123            deadline_ms: 300_000,
1124            agent_path: format!("think/{ctx_id}"),
1125            model: None,
1126        };
1127        self.spawn_turn(launch)
1128    }
1129
1130    /// Resolve deferred requests: timers are answered on fire (`on_timer`),
1131    /// subagents on their result, thinks on their TurnDone; `await`
1132    /// conditions and run waits are polled here.
1133    pub(crate) fn poll_pending(&mut self) {
1134        if self.pending.is_empty() {
1135            return;
1136        }
1137        let now = now_ms();
1138        // Collect by TARGET, never by index: `reply` re-enters the reactor (a
1139        // step outcome cascades through `finish_step` into
1140        // `cancel_scoped_children`, which prunes `pending` itself), so an index
1141        // remembered across a reply addresses a different entry by the time we
1142        // get to it — or one past the end, panicking the reactor thread and
1143        // taking the daemon with it. Two `race` branches waiting on the same
1144        // deadline are the live case: both land in `done` in one pass, the
1145        // winner's reply cancels the loser's branch.
1146        let mut done: Vec<(Target, Value, bool)> = Vec::new();
1147        for p in self.pending.iter() {
1148            let t = &p.target;
1149            match &p.kind {
1150                PendingKind::Await { condition, deadline_ms } => {
1151                    let data = self.await_data();
1152                    let vars: Vec<(&str, &Value)> = data.iter().map(|(k, v)| (k.as_str(), v)).collect();
1153                    match crate::cel::eval_bool(condition.trim().trim_start_matches("CEL:").trim(), &vars) {
1154                        Ok(true) => done.push((t.clone(), json!({"satisfied": true}), false)),
1155                        Ok(false) if now >= *deadline_ms => done.push((t.clone(), json!({"satisfied": false, "timed_out": true}), false)),
1156                        Ok(false) => {}
1157                        Err(e) => done.push((t.clone(), Value::String(format!("await: {e}")), true)),
1158                    }
1159                }
1160                PendingKind::Run { run, deadline_ms } => match self.runs.get(run) {
1161                    Some(r) if r.status.is_terminal() => done.push((t.clone(), json!({"run": run, "status": r.status, "output": r.output, "error": r.error}), false)),
1162                    Some(_) if now >= *deadline_ms => done.push((t.clone(), json!({"run": run, "status": "running", "timed_out": true}), false)),
1163                    Some(_) => {}
1164                    None => done.push((t.clone(), Value::String(format!("run {run:?} does not exist")), true)),
1165                },
1166                PendingKind::Subagent { handle } => {
1167                    // A terminal child always resolves the wait; an instance
1168                    // child in `mode: sync` ALSO resolves as soon as its
1169                    // reporter delivers the declared workflow's first result,
1170                    // because the child then keeps running under its own
1171                    // lifecycle and would never reach a terminal status.
1172                    if let Some(s) = self.subagents.get(handle)
1173                        && (super::reactor::is_terminal_status(&s.status)
1174                            || (s.tier.as_deref() == Some("instance")
1175                                && s.mode == "sync"
1176                                && s.result.is_some()))
1177                    {
1178                        done.push((t.clone(), json!({"handle": handle, "status": s.status, "result": s.result, "error": s.error}), false));
1179                    }
1180                }
1181                // Human gates run their own pass (auto-judge + prune + timeout).
1182                PendingKind::Timer { .. }
1183                | PendingKind::Think { .. }
1184                | PendingKind::Human { .. } => {}
1185            }
1186        }
1187        for (target, v, e) in done {
1188            // A reentrant prune may already have removed (and cancelled) this
1189            // entry while we were replying to an earlier one. Such an entry is
1190            // not waiting on anything, so answering it would resurrect a
1191            // cancelled branch — skip it when the retain took nothing out.
1192            let len = self.pending.len();
1193            self.pending.retain(|p| p.target != target);
1194            if self.pending.len() == len {
1195                continue;
1196            }
1197            self.reply(&target, v, e);
1198        }
1199        self.poll_pending_human();
1200    }
1201
1202    /// The variables an `await` condition sees: memory (by key), runs, subagents.
1203    fn await_data(&self) -> crate::engine::template::Data {
1204        let mut d = crate::engine::template::Data::new();
1205        d.insert(
1206            "runs".into(),
1207            Value::Object(
1208                self.runs
1209                    .iter()
1210                    .map(|(k, r)| (k.clone(), json!({"status": r.status, "output": r.output})))
1211                    .collect(),
1212            ),
1213        );
1214        d.insert(
1215            "subagents".into(),
1216            Value::Object(
1217                self.subagents
1218                    .iter()
1219                    .map(|(k, s)| (k.clone(), json!({"status": s.status, "result": s.result})))
1220                    .collect(),
1221            ),
1222        );
1223        d.insert("now_ms".into(), json!(now_ms()));
1224        d
1225    }
1226
1227    /// A durable timer fired.
1228    pub(crate) fn on_timer(&mut self, t: crate::state::TimerRecord) {
1229        let owner = &t.owner;
1230        match owner["kind"].as_str() {
1231            Some("tool") => {
1232                let node = NodeId(owner["node"].as_u64().unwrap_or(0));
1233                let req = owner["req"].as_u64().unwrap_or(0);
1234                self.pending
1235                    .retain(|p| p.target != Target::Child(node, req));
1236                self.reply_tool(node, req, t.payload.clone(), false);
1237            }
1238            Some("step") | Some("step_budget") => {
1239                let run = owner["run"].as_str().unwrap_or("").to_string();
1240                let step = owner["step"].as_str().unwrap_or("").to_string();
1241                self.on_step_timer(
1242                    &run,
1243                    &step,
1244                    owner["kind"].as_str() == Some("step_budget"),
1245                    &t.payload,
1246                );
1247            }
1248            Some("goal") => self.on_goal_check(&t.payload),
1249            other => self
1250                .log
1251                .warn("timer.unknown_owner", json!({"id": t.id, "owner": other})),
1252        }
1253    }
1254
1255    /// An executor thread answered a child's mapped/MCP request.
1256    pub(crate) fn on_tool_done(&mut self, node: NodeId, req: u64, result: Value, is_error: bool) {
1257        self.reply_tool(node, req, result, is_error);
1258    }
1259
1260    /// Take one token from a catalogued service's `rate:` pacing bucket,
1261    /// erroring when the bucket is dry.
1262    /// Thin wrapper over the process-global registry [`crate::mcp::pace`],
1263    /// seeded at client construction — one mechanism for the reactor's step
1264    /// path, the mapped-tool path, and (in their own processes) the turn
1265    /// worker's and flat subagent's in-loop calls.
1266    pub(crate) fn service_rate_take(&self, server: &str) -> Result<(), String> {
1267        crate::mcp::pace::take(server)
1268    }
1269}
1270
1271#[derive(Debug, Clone)]
1272enum RouteKind {
1273    Internal,
1274    Mapped(crate::registry::Mapping),
1275    Code,
1276    Mcp(String, String),
1277    /// A workflow run: `(workflow name, wait for it)`.
1278    Workflow(String, bool),
1279}
1280
1281/// Where an executor thread's result goes.
1282enum ExecTarget {
1283    Tool { node: NodeId, req: u64 },
1284    Step { run: String, step: String },
1285}
1286
1287impl ExecTarget {
1288    fn from(caller: &ToolCaller) -> ExecTarget {
1289        match (caller.node, &caller.run, &caller.step) {
1290            (Some(node), _, _) => ExecTarget::Tool {
1291                node,
1292                req: caller.req,
1293            },
1294            (None, Some(run), Some(step)) => ExecTarget::Step {
1295                run: run.clone(),
1296                step: step.clone(),
1297            },
1298            _ => ExecTarget::Tool {
1299                node: NodeId(0),
1300                req: caller.req,
1301            },
1302        }
1303    }
1304    fn send(self, tx: &std::sync::mpsc::Sender<Event>, result: Value, is_error: bool) {
1305        let ev = match self {
1306            ExecTarget::Tool { node, req } => Event::ToolDone {
1307                node,
1308                req,
1309                result,
1310                is_error,
1311            },
1312            ExecTarget::Step { run, step } => Event::StepDone {
1313                run,
1314                step,
1315                error: is_error.then(|| result.to_string()),
1316                output: result,
1317                is_error,
1318                tokens: 0,
1319            },
1320        };
1321        let _ = tx.send(ev);
1322    }
1323}