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;