Skip to main content

basis/tools/
spawn.rs

1//! `spawn` — the model's only route to a command and to a subagent.
2//!
3//! ADR-0016. Two doors existed for *do something I cannot do by thinking* —
4//! mentra's `shell` builtin and its `task` intrinsic — and they were governed
5//! as if they were unrelated: two side-effect levels, two names at basis's
6//! [`ApprovalGate`](crate::approval::ApprovalGate), and therefore two rule
7//! namespaces for what an operator thinks of as one question. This is that one
8//! question, with one name on it.
9//!
10//! # The shape the model sees
11//!
12//! One string. A leading `!` means *run this*; anything else is a task for a
13//! subagent. A prompt that genuinely starts with `!` is written `!!`. The
14//! prefix is surface: it is read exactly once, at the boundary, and everything
15//! after that dispatches on the typed mode — which is why the sugar costs no
16//! type confusion between the approver, the rule store and the audit trail.
17//!
18//! A command may also say *where*: `!@<target> <command>` names an executor to
19//! run it on (ADR-0021) — though nothing currently registers one; see
20//! `docs/targets.md`. One more fact on the same act rather than a second
21//! tool, because a second tool would be a second name at the gate and a
22//! second rule namespace — the two doors ADR-0016 closed, rebuilt by hand.
23//! The prefix is read by the same parser, in the same pass, and the target it
24//! leaves behind is a typed field like the mode beside it.
25//!
26//! # What the approver sees
27//!
28//! [`ToolExecutor::authorization_preview`] is overridden rather than left at
29//! its default, which merely restates the static descriptor. A command reports
30//! [`ToolSideEffectLevel::Process`] and a delegation reports
31//! [`ToolSideEffectLevel::LocalState`]; both are consequential, so
32//! [`is_consequential`](crate::approval::is_consequential) never waves either
33//! through under the reads-are-never-asked rule. The preview's
34//! parser owns `{mode, body, target}`; after the tool context resolves the
35//! working directory, `structured_input` carries `{mode, body, cwd, target}` — the thing an
36//! approver renders, and the thing mentra globs a remembered rule's pattern against
37//! (`RuleStore::matching_rule`). A `RuleKey { tool_name: "spawn", pattern }` is
38//! therefore a command allowlist expressible as data. A delegation whose child
39//! policy ([`ChildSpec`], D4) overrode something adds a `child` key describing
40//! what the child will be — roster, model, whether the system prompt is
41//! replaced — and only then, so the four-key shape every existing rule was
42//! written against is untouched on a runtime with no policy.
43//!
44//! Order matters and is mentra's, not basis's: the orchestrator builds this
45//! preview and puts it to the authorizer *before* it calls `execute_mut`
46//! (`ToolRuntime::execute_registered_tool`), so nothing here runs ahead of the
47//! answer. A preview that returns `Err` is a refusal in its own right, reaching
48//! the model as `Tool execution denied: …` without the approver — or the rule
49//! store — ever being consulted. That is where the depth guard lives, because
50//! a structural floor is not a thing an allow-rule should be able to lift.
51//!
52//! # What this is not
53//!
54//! Approval is policy, not confinement (ADR-0013). An approved command runs
55//! with the full authority of this process, and `--no-shell` remains the only
56//! thing that shuts commands off entirely: it sets
57//! `RuntimePolicy::allow_shell_commands(false)`, and mentra's policy refuses on
58//! the same path `spawn` calls — after this tool has been authorized, before
59//! anything executes.
60
61mod child;
62mod depth;
63mod describe;
64mod execute;
65mod parse;
66#[cfg(feature = "a2a")]
67mod remote;
68
69use std::{collections::BTreeSet, sync::Arc};
70
71use async_trait::async_trait;
72use mentra::tool::{
73    ParallelToolContext, RuntimeToolDescriptor, ToolApprovalCategory, ToolAuthorizationPreview,
74    ToolCapability, ToolContext, ToolDefinition, ToolDurability, ToolExecutionCategory,
75    ToolExecutor, ToolResult, ToolSideEffectLevel,
76};
77use serde_json::{Value, json};
78
79use describe::{Taught, description, input_schema, listed};
80use parse::{Mode, Spawn, parse};
81
82// A guard that has to know whether a `spawn` call is a command must not
83// become a second reader of the `!` prefix — the module docs above make the
84// rule: it is read exactly once, here. Exported so a host's interceptor asks
85// this parser rather than writing its own.
86pub use parse::{
87    INPUT_FIELD as SPAWN_INPUT_FIELD, Mode as SpawnMode, Spawn as SpawnInput, classify_spawn_input,
88    parse as parse_spawn_input,
89};
90pub(crate) use parse::{LOCAL_TARGET, is_target_name};
91
92pub use child::{ChildContext, ChildSpec};
93pub use depth::DEFAULT_DELEGATION_DEPTH;
94
95pub(crate) use child::ChildPolicy;
96
97/// The name the model calls, an operator writes in a rule, and a
98/// `.basis/hooks.json` entry matches on.
99///
100/// Public because it is all three of those: a host writing a hook or a
101/// remembered rule needs the string, and a literal in their config that drifts
102/// from basis's fails by never matching rather than by erroring.
103pub const SPAWN: &str = "spawn";
104
105/// basis's own tool, and the first tool basis registers on a runtime it builds.
106///
107/// One instance serves a whole runtime: mentra's registry holds it behind an
108/// `Arc` and every subagent shares its parent's runtime handle, which is what
109/// lets the delegation depth ledger inside see the whole tree — and what makes
110/// the child policy apply at every depth, including to a child's own children.
111///
112/// Every agent a workspace mints or resumes is given a *copy* on mentra's
113/// exact-agent layer, bound to that open's live set of remote agents
114/// (ADR-0029 §4, ADR-0031 §3). Cloning shares the ledgers and the policy, so a
115/// copy is the same tool told about different targets, never a second depth
116/// count.
117#[derive(Default, Clone)]
118pub struct SpawnTool {
119    depth: depth::Depth,
120    /// The command targets this runtime can route to, sorted and deduplicated
121    /// so the description a model reads is the same string on every build.
122    /// Empty is the ordinary case, and the one in which the model is never
123    /// told the routing prefix exists.
124    targets: Vec<String>,
125    /// Who a delegated child is (D4, [`child`]'s module docs). `None` — the
126    /// ordinary case — is inherit-everything on the exact code path this tool
127    /// has always used.
128    policy: Option<ChildPolicy>,
129    /// The runtime's agent ledger
130    /// ([`crate::runtime::agents`]), read for two things a delegation cannot
131    /// ask mentra: what the delegating agent's own roster hides — mentra
132    /// exposes no reader for an agent's or a template's effective
133    /// `ToolProfile`, and `with_tool_profile` replaces it wholesale — and
134    /// where to record the child, so that the guard judging the parent judges
135    /// it too.
136    ///
137    /// `None` for a host that built this tool itself against mentra, which has
138    /// no basis workspaces to keep apart.
139    agents: Option<Arc<crate::runtime::agents::AgentRegistry>>,
140    /// The live set of remote agents this copy teaches (ADR-0031 §3), read
141    /// every time mentra captures the descriptor — at registration, and again
142    /// whenever the set says it changed. `None` on the runtime's own instance.
143    /// An empty set teaches nothing, which is what keeps an agent with no
144    /// remote agents from being taught a prefix that leads nowhere.
145    ///
146    /// Description only. *Routing* reads the calling agent's ledger row, which
147    /// is the one place that knows which open's agents — and credentials — a
148    /// call may use.
149    #[cfg(feature = "a2a")]
150    remote: Option<Arc<crate::a2a::remote::RemoteAgents>>,
151    /// Where the agent this copy belongs to keeps its remote conversations
152    /// (ADR-0029 §5): on disk by agent for the copy a workspace registers for
153    /// an agent it minted or resumed, under a host's key for one bound to it
154    /// (ADR-0035), and in memory for a delegated child's, which is not
155    /// resumed and so has nothing to pick back up.
156    #[cfg(feature = "a2a")]
157    remembers: crate::a2a::remote::Remembered,
158}
159
160/// The per-call facts both authorization preview and execution must decide.
161enum SpawnDecision {
162    Command,
163    Agent {
164        depth: usize,
165        spec: Box<ChildSpec>,
166        /// Resolved only when a child policy needed it.
167        workspace: Option<std::path::PathBuf>,
168    },
169    /// A task for a remote agent the calling agent's workspace connected.
170    #[cfg(feature = "a2a")]
171    Remote {
172        agents: Arc<crate::a2a::remote::RemoteAgents>,
173        name: String,
174    },
175}
176
177/// Hand-written for the policy field: a `dyn Fn` has no `Debug`, and presence
178/// is all it can honestly print.
179impl std::fmt::Debug for SpawnTool {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        f.debug_struct("SpawnTool")
182            .field("depth", &self.depth)
183            .field("targets", &self.targets)
184            .field("policy", &self.policy.as_ref().map(|_| "<child policy>"))
185            .field("agents", &self.agents.as_ref().map(|_| "<agent ledger>"))
186            .field("remote", &self.taught())
187            .finish()
188    }
189}
190
191impl SpawnTool {
192    /// The tool as every runtime had it before ADR-0021: commands run where
193    /// basis runs, and the model is told nothing about targets.
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// The same tool, told which command targets its runtime registered.
199    ///
200    /// The names are all this needs: `spawn` routes by putting the parsed
201    /// target on the request, and *which executor* that name resolves to is
202    /// the runtime's business, not the tool's (ADR-0018). What the names buy
203    /// is the two things only the tool can do with them — teach the prefix in
204    /// the description, and refuse a name nothing registered before the
205    /// approver is asked about it.
206    ///
207    /// [`DEFAULT_DELEGATION_DEPTH`]'s floor; call
208    /// [`with_targets_and_depth`](Self::with_targets_and_depth) for a
209    /// different one.
210    ///
211    /// Called by [`RuntimeBuilder`](crate::RuntimeBuilder) with the set it
212    /// collected; a host driving mentra directly calls it with whatever it
213    /// registered on its own executor. Names it does not recognise are refused
214    /// at the boundary, so passing a name here that no executor answers to
215    /// turns a working call into a refusal rather than into a silent local
216    /// run — which is the direction this has to fail in.
217    pub fn with_targets(targets: impl IntoIterator<Item = String>) -> Self {
218        Self::with_targets_and_depth(targets, depth::DEFAULT_DELEGATION_DEPTH)
219    }
220
221    /// [`with_targets`](Self::with_targets), with the delegation floor stated
222    /// explicitly (decision D9) instead of defaulted — what
223    /// [`RuntimeBuilder::with_delegation_depth`](crate::RuntimeBuilder::with_delegation_depth)
224    /// threads through at registration. The guard's shape does not change:
225    /// still basis's own ledger, still refusing *in the preview*, so a
226    /// remembered allow-rule cannot lift it whatever the floor is set to.
227    pub fn with_targets_and_depth(
228        targets: impl IntoIterator<Item = String>,
229        max_depth: usize,
230    ) -> Self {
231        Self {
232            depth: depth::Depth::new(max_depth),
233            targets: targets
234                .into_iter()
235                .collect::<BTreeSet<_>>()
236                .into_iter()
237                .collect(),
238            policy: None,
239            agents: None,
240            #[cfg(feature = "a2a")]
241            remote: None,
242            #[cfg(feature = "a2a")]
243            remembers: crate::a2a::remote::Remembered::Not,
244        }
245    }
246
247    /// Gives this tool's delegations a child policy (D4): consulted per
248    /// delegation with what `spawn` knows ([`ChildContext`]), answering which
249    /// of the child's inherited facts to override ([`ChildSpec`]).
250    ///
251    /// [`ChildSpec`]'s module docs carry the contract — inherit is the
252    /// default and byte-identical to no policy, bounds are not the spec's to
253    /// carry, the depth floor is checked before the policy runs, and the
254    /// policy is consulted both for the approver's preview and at execution,
255    /// so it should be a pure function of its context. A host driving mentra
256    /// directly calls this where basis's own builder calls it for
257    /// [`RuntimeBuilder::with_child_policy`](crate::RuntimeBuilder::with_child_policy).
258    #[must_use]
259    pub fn with_child_policy<F>(self, policy: F) -> Self
260    where
261        F: Fn(&ChildContext<'_>) -> ChildSpec + Send + Sync + 'static,
262    {
263        self.with_child_policy_arc(Arc::new(policy))
264    }
265
266    /// [`with_child_policy`](Self::with_child_policy) for a caller that
267    /// already holds the policy behind an `Arc` — basis's own
268    /// [`RuntimeBuilder`](crate::RuntimeBuilder), which stores one so its
269    /// `Debug` can say whether a policy is set. Wrapping that `Arc` in a
270    /// closure and a second `Arc` would be one more indirection per
271    /// delegation for nothing.
272    #[must_use]
273    pub(crate) fn with_child_policy_arc(self, policy: ChildPolicy) -> Self {
274        Self {
275            policy: Some(policy),
276            ..self
277        }
278    }
279
280    /// Lets this tool read what the delegating agent is denied, and record the
281    /// child it spawns under the same answer (see the
282    /// [`agents`](Self::agents) field). basis's own
283    /// [`RuntimeBuilder`](crate::RuntimeBuilder) always says this; a host
284    /// registering `SpawnTool` on its own mentra runtime has no basis
285    /// workspaces and says nothing.
286    #[must_use]
287    pub(crate) fn with_agents(self, agents: Arc<crate::runtime::agents::AgentRegistry>) -> Self {
288        Self {
289            agents: Some(agents),
290            ..self
291        }
292    }
293
294    /// A copy of this tool for one agent whose workspace holds `remote`,
295    /// teaching it that live set (ADR-0029 §4, ADR-0031 §3).
296    ///
297    /// Made whether or not the set is empty: an agent added later must reach
298    /// sessions already running, and the copy's description is computed from
299    /// the set each time mentra captures it, so an empty set reads as the
300    /// `spawn` every other agent sees, word for word.
301    #[cfg(feature = "a2a")]
302    pub(crate) fn for_remote_agents(&self, remote: &Arc<crate::a2a::remote::RemoteAgents>) -> Self {
303        Self {
304            remote: Some(Arc::clone(remote)),
305            remembers: crate::a2a::remote::Remembered::Not,
306            ..self.clone()
307        }
308    }
309
310    /// The remote agents this copy teaches, from the set as it is now:
311    /// configuration order, each with what its card says, or marked
312    /// unreachable when its card could not be fetched.
313    #[cfg(feature = "a2a")]
314    fn taught(&self) -> Vec<Taught> {
315        let Some(remote) = &self.remote else {
316            return Vec::new();
317        };
318        remote
319            .snapshot()
320            .agents()
321            .iter()
322            .map(|agent| Taught {
323                name: agent.name().to_string(),
324                summary: agent
325                    .client()
326                    .map(|client| mentra::a2a::card_summary(client.card())),
327            })
328            .collect()
329    }
330
331    #[cfg(not(feature = "a2a"))]
332    fn taught(&self) -> Vec<Taught> {
333        Vec::new()
334    }
335
336    /// The same copy, for an agent whose remote conversations are kept on disk
337    /// — one a workspace minted or resumed.
338    #[cfg(feature = "a2a")]
339    #[must_use]
340    pub(crate) fn remembering(self) -> Self {
341        Self {
342            remembers: crate::a2a::remote::Remembered::ByAgent,
343            ..self
344        }
345    }
346
347    /// The same copy, keeping its remote conversations under a host's `key`
348    /// rather than its agent's id (ADR-0035).
349    #[cfg(feature = "a2a")]
350    #[must_use]
351    pub(crate) fn remembering_under(self, key: String) -> Self {
352        Self {
353            remembers: crate::a2a::remote::Remembered::ByKey(key),
354            ..self
355        }
356    }
357
358    /// The live set of remote agents the calling agent's workspace holds,
359    /// read from its ledger row — `None` for an agent basis did not mint.
360    #[cfg(feature = "a2a")]
361    fn remote_agents_of(&self, agent_id: &str) -> Option<Arc<crate::a2a::remote::RemoteAgents>> {
362        self.agents.as_ref()?.of(agent_id)?.remote.clone()
363    }
364
365    /// Routes `@<name>` for `agent_id`, or says why it cannot go anywhere.
366    ///
367    /// A fact, not a judgement, so it is refused in the preview like an
368    /// unregistered command target: no approver is asked about an agent that
369    /// is not there, or one tried too recently to be worth trying again.
370    #[cfg(feature = "a2a")]
371    fn authorize_remote(&self, agent_id: &str, name: &str) -> Result<SpawnDecision, String> {
372        let Some(agents) = self.remote_agents_of(agent_id) else {
373            return Err(no_remote_agents(name));
374        };
375        remote::authorize(&agents, name)?;
376        Ok(SpawnDecision::Remote {
377            agents,
378            name: name.to_string(),
379        })
380    }
381
382    #[cfg(not(feature = "a2a"))]
383    fn authorize_remote(&self, _agent_id: &str, name: &str) -> Result<SpawnDecision, String> {
384        Err(no_remote_agents(name))
385    }
386
387    /// What the policy says this delegation's child is — asked identically on
388    /// the two paths that need it, the preview and the execution, so the
389    /// approver reads the child that will actually be spawned.
390    /// [`ChildSpec::inherit`] whenever there is no policy to ask.
391    fn child_spec(
392        &self,
393        prompt: &str,
394        parent_agent_id: &str,
395        workspace_dir: &std::path::Path,
396    ) -> ChildSpec {
397        match &self.policy {
398            Some(policy) => policy(&ChildContext::new(prompt, parent_agent_id, workspace_dir)),
399            None => ChildSpec::inherit(),
400        }
401    }
402
403    /// `Ok` when this call names a target this runtime can reach, and the
404    /// refusal the model should read instead when it does not.
405    ///
406    /// Asked only of a command: a delegation's target is a remote agent
407    /// (ADR-0029), which is the calling agent's workspace's to answer, not
408    /// this runtime's command targets.
409    fn authorize_target(&self, spawn: &Spawn) -> Result<(), String> {
410        let (Mode::Command, Some(target)) = (spawn.mode(), spawn.target()) else {
411            return Ok(());
412        };
413
414        if self.targets.iter().any(|name| name == target) {
415            return Ok(());
416        }
417
418        Err(if self.targets.is_empty() {
419            format!(
420                "spawn has no command targets registered, so `!@{target}` has nowhere to run; \
421                 write the command with no `@` to run it where basis is running"
422            )
423        } else {
424            format!(
425                "spawn has no command target named `{target}`; the registered targets are {}, \
426                 and a command with no `@` runs where basis is running",
427                listed(&self.targets)
428            )
429        })
430    }
431
432    /// Decides one parsed call. Both consumers invoke this independently:
433    /// preview so the approver sees the real decision, execution again so a
434    /// missing authorizer cannot remove a floor.
435    fn decide(
436        &self,
437        spawn: &Spawn,
438        agent_id: &str,
439        resolve_workspace: impl FnOnce() -> Result<std::path::PathBuf, String>,
440    ) -> Result<SpawnDecision, String> {
441        self.authorize_target(spawn)?;
442        match spawn.mode() {
443            Mode::Command => Ok(SpawnDecision::Command),
444            // Not an `if let` guard: those are newer than `rust-version`.
445            Mode::Agent => {
446                if let Some(name) = spawn.target() {
447                    return self.authorize_remote(agent_id, name);
448                }
449                let depth = self.depth.authorize_delegation(agent_id)?;
450                let (spec, workspace) = match self.policy.as_ref() {
451                    Some(_) => {
452                        let workspace = resolve_workspace()?;
453                        let spec = self.child_spec(spawn.body(), agent_id, &workspace);
454                        (spec, Some(workspace))
455                    }
456                    None => (ChildSpec::inherit(), None),
457                };
458                Ok(SpawnDecision::Agent {
459                    depth,
460                    spec: Box::new(spec),
461                    workspace,
462                })
463            }
464        }
465    }
466}
467
468/// The refusal for `@<name>` from an agent that can reach no remote agent.
469fn no_remote_agents(name: &str) -> String {
470    format!(
471        "spawn has no remote agents to hand `@{name}` to; write the task with no `@` to hand it \
472         to a subagent here, or `@@` to begin it with a literal `@`"
473    )
474}
475
476impl ToolDefinition for SpawnTool {
477    /// The static descriptor, which is what a reader that has no call in hand
478    /// sees. Its side-effect level is the *stronger* of the two modes on
479    /// purpose: a tool that can run commands should never describe itself as
480    /// something milder when asked in the abstract. Per-call precision is
481    /// [`ToolExecutor::authorization_preview`]'s job.
482    fn descriptor(&self) -> RuntimeToolDescriptor {
483        let taught = self.taught();
484        RuntimeToolDescriptor::builder(SPAWN)
485            .description(description(&self.targets, &taught))
486            .input_schema(input_schema(&self.targets, &taught))
487            .capabilities(vec![
488                ToolCapability::ProcessExec,
489                ToolCapability::FilesystemWrite,
490                ToolCapability::Delegation,
491            ])
492            .side_effect_level(ToolSideEffectLevel::Process)
493            .durability(ToolDurability::Ephemeral)
494            .execution_category(ToolExecutionCategory::ExclusiveLocalMutation)
495            .approval_category(ToolApprovalCategory::Process)
496            .build()
497    }
498}
499
500#[async_trait]
501impl ToolExecutor for SpawnTool {
502    fn authorization_preview(
503        &self,
504        ctx: &ParallelToolContext,
505        input: &Value,
506    ) -> Result<ToolAuthorizationPreview, String> {
507        let spawn = parse(input)?;
508        // Refused here, ahead of the approver and ahead of the rule store, so
509        // the floor cannot be lifted by an answer or by a remembered rule. A
510        // routing destination nothing registered is the same kind of fact as
511        // the depth floor: not a judgement call, so not a question for a
512        // person (ADR-0021). Both checks run before the child policy is
513        // consulted, so no host policy runs for a call the floor refuses.
514        let decision = self.decide(&spawn, &ctx.agent_id, || {
515            ctx.resolve_working_directory(None)
516        })?;
517        let (cwd, child) = match decision {
518            SpawnDecision::Command => (ctx.resolve_working_directory(None)?, None),
519            #[cfg(feature = "a2a")]
520            SpawnDecision::Remote { .. } => (ctx.resolve_working_directory(None)?, None),
521            SpawnDecision::Agent {
522                spec, workspace, ..
523            } => {
524                let cwd = match workspace {
525                    Some(workspace) => workspace,
526                    None => ctx.resolve_working_directory(None)?,
527                };
528                (cwd, spec.preview_value())
529            }
530        };
531
532        Ok(preview(&spawn, cwd, &self.descriptor(), input, child))
533    }
534
535    /// Both modes run in the exclusive lane — a command mutates the workspace
536    /// and a delegation borrows the agent — so this only ever refines *which*
537    /// exclusive category a call is, never whether it can be batched.
538    fn execution_category(&self, input: &Value) -> ToolExecutionCategory {
539        parse(input).map_or(ToolExecutionCategory::ExclusiveLocalMutation, |spawn| {
540            execution_category(spawn.mode())
541        })
542    }
543
544    async fn execute_mut(&self, mut ctx: ToolContext<'_>, input: Value) -> ToolResult {
545        let spawn = parse(&input)?;
546        // Deliberately decided again: preview is skipped when no authorizer is
547        // installed, and that must not remove target or depth floors.
548        let decision = self.decide(&spawn, &ctx.agent_id, || {
549            ctx.resolve_working_directory(None)
550        })?;
551
552        match decision {
553            SpawnDecision::Command => execute::command(&ctx, spawn.body(), spawn.target()).await,
554            #[cfg(feature = "a2a")]
555            SpawnDecision::Remote { agents, name } => {
556                remote::delegate(ctx, &agents, &name, spawn.body(), &self.remembers).await
557            }
558            SpawnDecision::Agent { depth, spec, .. } => {
559                execute::delegate(self, &mut ctx, spawn.body(), depth, *spec).await
560            }
561        }
562    }
563}
564
565/// Assembles the per-call preview: the parsed call, and the descriptor fields
566/// that this mode — rather than the tool in the abstract — earns.
567fn preview(
568    spawn: &Spawn,
569    cwd: std::path::PathBuf,
570    descriptor: &RuntimeToolDescriptor,
571    raw_input: &Value,
572    child: Option<Value>,
573) -> ToolAuthorizationPreview {
574    // The wire contract of ADR-0016, widened by ADR-0021 to
575    // `{mode, body, cwd, target}`, and still never the string the model wrote.
576    // What an approver renders, what a pattern rule globs against, and what the
577    // audit trail keeps.
578    //
579    // `target` is additive in the strict sense: the three older keys keep
580    // their spellings and their values, and it is written last here, so a
581    // rule an operator already wrote still matches exactly what it matched
582    // before. It reads `"local"` rather than `null` when no target was named,
583    // because *here* is a value an operator will want to write a rule about
584    // and a glob against a JSON null is a spelling nobody notices missing.
585    //
586    // Where in the serialization a key lands is not something to reason from,
587    // and an earlier version of this comment did: `serde_json` orders a map
588    // by insertion when `preserve_order` is on and alphabetically when it is
589    // not, and the shipped binary has it on — `agent-client-protocol` enables
590    // it, which is a transitive dependency's choice basis does not control
591    // and an embedder may not share. So `child` (D4) is additive on the one
592    // ground that holds either way: **it is absent unless a child policy
593    // actually overrode something.** Every delegation on a policy-free
594    // runtime, and every inherit answer on a policied one, serializes the
595    // same four keys it always did, whatever order this build puts them in.
596    // When the key is there it is the point: a remembered rule about
597    // delegation can match on what the child will be —
598    // `ChildSpec::preview_value` has the shape.
599    let mut structured_input = json!({
600        "mode": spawn.mode().as_str(),
601        "body": spawn.body(),
602        "cwd": cwd,
603        "target": spawn.target().unwrap_or(LOCAL_TARGET),
604    });
605    if let Some(child) = child {
606        structured_input
607            .as_object_mut()
608            .expect("built as an object two lines up")
609            .insert("child".to_string(), child);
610    }
611
612    ToolAuthorizationPreview {
613        working_directory: cwd,
614        capabilities: capabilities(spawn.mode()),
615        side_effect_level: side_effect_level(spawn),
616        durability: descriptor.durability,
617        execution_category: execution_category(spawn.mode()),
618        approval_category: approval_category(spawn.mode()),
619        raw_input: raw_input.clone(),
620        structured_input,
621    }
622}
623
624/// `Process` for a command, `LocalState` for a delegation here — the levels
625/// `shell` and `task` declared, now decided per call instead of per name — and
626/// `External` for a delegation to a remote agent, which hands work to a system
627/// this process does not control (ADR-0029 §2). None is `None`, so no call can
628/// present as read-only.
629fn side_effect_level(spawn: &Spawn) -> ToolSideEffectLevel {
630    match (spawn.mode(), spawn.target()) {
631        (Mode::Command, _) => ToolSideEffectLevel::Process,
632        (Mode::Agent, Some(_)) => ToolSideEffectLevel::External,
633        (Mode::Agent, None) => ToolSideEffectLevel::LocalState,
634    }
635}
636
637fn capabilities(mode: Mode) -> Vec<ToolCapability> {
638    match mode {
639        Mode::Command => vec![ToolCapability::ProcessExec, ToolCapability::FilesystemWrite],
640        Mode::Agent => vec![ToolCapability::Delegation],
641    }
642}
643
644const fn approval_category(mode: Mode) -> ToolApprovalCategory {
645    match mode {
646        Mode::Command => ToolApprovalCategory::Process,
647        Mode::Agent => ToolApprovalCategory::Delegation,
648    }
649}
650
651const fn execution_category(mode: Mode) -> ToolExecutionCategory {
652    match mode {
653        Mode::Command => ToolExecutionCategory::ExclusiveLocalMutation,
654        Mode::Agent => ToolExecutionCategory::Delegation,
655    }
656}
657
658#[cfg(test)]
659mod tests;