Skip to main content

distri_types/
invocation.rs

1//! The unified sub-agent invocation model.
2//!
3//! Replaces the older `CallMode` (`InProcess`/`Fork`/`Offload`/`Transfer`)
4//! enum which conflated three independent decisions — what context the
5//! child sees, how the parent waits, and which orchestrator runs the
6//! loop — into a single string mode. Each axis is now its own type.
7//!
8//! See `distri/docs/invocation-model.md` (TODO) for the full design notes.
9//! Quick summary:
10//!
11//! - [`ContextScope`] — Independent / Inherited / Shared.
12//! - [`Join`] — Single / All / Detached.
13//! - [`Executor`] — Local / Remote{runner}. The agent loop is always
14//!   server-side; the question is whether THIS orchestrator runs it or
15//!   another orchestrator does.
16//!
17//! `Invocation` carries `Vec<Target>` (1..N) so a single sub-agent call
18//! is just `targets.len() == 1`. Validation rejects combinations that
19//! don't make sense (e.g. `Join::Single` with 2 targets).
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::agent::ToolsConfig;
25use crate::core::{Message, TaskStatus};
26
27// ── Top-level invocation ──────────────────────────────────────────────────
28
29/// One agent dispatch — synchronous or asynchronous, single or fan-out,
30/// local or remote. The orchestrator validates this at the entry point and
31/// then stamps the resolved fields onto the child task row(s).
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Invocation {
34    /// 1..N targets. `Join::Single` requires exactly 1; the others accept
35    /// any positive count.
36    pub targets: Vec<Target>,
37
38    /// What the child task sees on its first turn.
39    #[serde(default)]
40    pub context: ContextScope,
41
42    /// How the parent waits.
43    #[serde(default)]
44    pub join: Join,
45
46    /// Which orchestrator runs the agent loop. `Auto` resolves at
47    /// invocation time from (agent.runtime ∩ caller.runtime ∩ available
48    /// runners). `Force` is for tests and debugging.
49    #[serde(default)]
50    pub executor: ExecutorHint,
51
52    /// Tool inheritance policy for the child. Defaults to `Inherit`
53    /// (`external = ["*"]` — child borrows the parent session's full
54    /// external tool pool, like claude-code's `useExactTools`).
55    #[serde(default)]
56    pub tools: ToolPolicy,
57}
58
59/// One leaf of a (possibly fan-out) invocation.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Target {
62    pub agent: AgentRef,
63    /// The user-facing message handed to the child as its first turn.
64    pub message: Message,
65    /// Per-target executor override. Falls back to `Invocation.executor`
66    /// when absent. Rare — used by tests and "force this one to a
67    /// specific sandbox" debugging cases.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub executor: Option<ExecutorHint>,
70}
71
72/// How to identify the agent for a target.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(tag = "type", rename_all = "snake_case")]
75pub enum AgentRef {
76    /// Named agent looked up by `agent_id` in the agent store.
77    Named { agent_id: String },
78    /// Ad-hoc agent built on the fly. The `system_prompt` is appended to
79    /// `_adhoc_base.md`'s body; tools (if `Some`) replace the seeded
80    /// ToolsConfig. Mirrors today's `call_agent({system_prompt, tools})`.
81    AdHoc {
82        system_prompt: String,
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        tools: Option<ToolsConfig>,
85    },
86}
87
88// ── Axis 1: ContextScope ──────────────────────────────────────────────────
89
90/// What the child task sees when it starts its first LLM turn.
91#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93pub enum ContextScope {
94    /// Fresh task, empty history. Self-contained workers (one-shot
95    /// summarisation, validation, single-purpose lookups). Replaces the
96    /// old `CallMode::InProcess`.
97    #[default]
98    Independent,
99
100    /// Fresh task, but parent's `task_messages` are copied in (with
101    /// orphan tool_calls filtered — see `universal_agent.rs`'s parent
102    /// history filter). The child sees the conversation up to the
103    /// invocation point. Used when the worker needs the parent's
104    /// conversational context to do its job (default for `run_skill`).
105    /// Replaces the old `CallMode::Fork`.
106    Inherited,
107
108    /// SAME task as the parent. Hard handover — the parent's loop ends
109    /// when the child finishes; the child's final result becomes the
110    /// parent's. Replaces the old `CallMode::Transfer`.
111    Shared,
112}
113
114// ── Axis 2: Join ──────────────────────────────────────────────────────────
115
116/// How the parent waits for the dispatched task(s).
117#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
118#[serde(rename_all = "snake_case")]
119pub enum Join {
120    /// Wait for the (single) target's terminal event. Result: scalar.
121    /// Validation: `targets.len() == 1`.
122    #[default]
123    Single,
124
125    /// Wait for ALL listed targets to terminate. Result: `Vec<Result>`
126    /// in input order. Validation: `targets.len() >= 1` (with len == 1
127    /// this is equivalent to Single but returns a Vec — use Single for
128    /// scalar). True fan-out join.
129    All,
130
131    /// Fire-and-forget. Returns `Vec<task_id>` immediately. Subsequent
132    /// turns can use the supervisor tools (`get_task` / `wait_task` /
133    /// `cancel_task`) to manage the dispatched tasks. Replaces the old
134    /// `CallMode::Offload`.
135    Detached,
136}
137
138// ── Axis 3: Executor ──────────────────────────────────────────────────────
139
140/// Which orchestrator runs the agent loop.
141///
142/// **Note**: the loop is ALWAYS server-side — clients (browser SDK,
143/// distri-cli) only execute external tools, not agent loops. So the only
144/// real distinction is "this orchestrator" vs "another orchestrator".
145///
146/// Note that the *kind* of remote runner (sandbox / loopback / k8s / fly /
147/// …) is NOT a closed enum here. Adding a new runner is purely an
148/// orchestrator-side concern — register a new
149/// [`RunnerInitializer`](crate::stores::dummy_phantom) under a fresh
150/// [`RunnerConfig::kind`] string and the schema is unchanged. The DB only
151/// records `remote = true|false`.
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(tag = "type", rename_all = "snake_case")]
154pub enum Executor {
155    /// THIS orchestrator runs the loop. Tools the agent calls execute on
156    /// this server (or are dispatched to whoever is driving the loop —
157    /// the JS client, the local distri-cli, etc. — via `is_external`
158    /// tool-result POSTs).
159    Local,
160
161    /// Another orchestrator runs the loop. The `RunnerConfig` selects
162    /// which runner (`kind` is the registry key) and carries the
163    /// implementation-specific config the registered
164    /// [`RunnerInitializer`] parses. We follow the runner's A2A stream
165    /// and relay events back onto our task's broadcaster.
166    Remote { runner: RunnerConfig },
167}
168
169/// How to start a remote runner. The `kind` field is dispatched against
170/// the orchestrator's `RunnerInitializer` registry; `config` is the
171/// initializer's private payload (image name, k8s namespace, sandbox
172/// flags, ...). The orchestrator does not interpret `config`.
173///
174/// Examples (the strings are conventions, not a closed set):
175/// - `{ "kind": "sandbox", "config": { "image": "..." } }` — browsr
176///   container running distri-cli.
177/// - `{ "kind": "loopback", "config": {} }` — loopback HTTP to another
178///   orchestrator instance (DEV_MODE / OSS distri-server).
179/// - `{ "kind": "k8s", "config": { "namespace": "...", "image": "..." } }` —
180///   future Kubernetes runner.
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182pub struct RunnerConfig {
183    /// Registry key for the [`RunnerInitializer`] that knows how to
184    /// start and talk to this runner.
185    pub kind: String,
186    /// Initializer-private payload. Default `{}` for runners that need
187    /// no config beyond their kind.
188    #[serde(default = "default_config_value")]
189    pub config: serde_json::Value,
190}
191
192fn default_config_value() -> serde_json::Value {
193    serde_json::Value::Object(Default::default())
194}
195
196impl RunnerConfig {
197    pub fn new(kind: impl Into<String>) -> Self {
198        Self {
199            kind: kind.into(),
200            config: default_config_value(),
201        }
202    }
203
204    pub fn with_config(mut self, config: serde_json::Value) -> Self {
205        self.config = config;
206        self
207    }
208}
209
210/// What the caller HINTS for axis 3. Final decision is the orchestrator's:
211/// it intersects `(agent.allowed_runtimes, caller.runtime_mode,
212/// available_runners)`.
213#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
214#[serde(tag = "kind", rename_all = "snake_case")]
215pub enum ExecutorHint {
216    /// Resolve from agent runtime + caller + available runners. Default.
217    #[default]
218    Auto,
219    /// Override the resolution. Rare — tests, debugging.
220    Force(Executor),
221}
222
223// ── Tool policy ───────────────────────────────────────────────────────────
224
225/// How the child inherits external tools from the parent session.
226#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
227#[serde(tag = "kind", rename_all = "snake_case")]
228pub enum ToolPolicy {
229    /// Child gets parent's external tools (`external = ["*"]`). Default
230    /// — matches claude-code's `useExactTools` semantics.
231    #[default]
232    Inherit,
233    /// Explicit tool list for the child. The orchestrator filters the
234    /// parent's tool pool to just these names.
235    Exact { tools: Vec<String> },
236    /// Child has only its own builtin tools; nothing inherited.
237    None,
238}
239
240// ── Result shape (mirrors Join) ───────────────────────────────────────────
241
242/// One agent's final result, returned to the parent's tool-call response.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct AgentResult {
245    /// The final text or structured payload the child produced via its
246    /// `final` tool call.
247    pub content: serde_json::Value,
248    /// Child's task_id — surfaced so the parent (or downstream
249    /// supervision tools) can join later events.
250    pub task_id: String,
251    /// Status at completion: `done` / `error` / `cancelled`. A successful
252    /// run produces `done`; an LLM error / failed final produces `error`;
253    /// an explicit cancel via `cancel_task` produces `cancelled`.
254    pub status: TaskStatus,
255}
256
257/// Result returned to the parent's tool call. Shape mirrors `Join`.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(tag = "kind", rename_all = "snake_case")]
260pub enum InvocationResult {
261    /// `Join::Single` → scalar.
262    Scalar { result: AgentResult },
263    /// `Join::All` → ordered Vec, positions match input target order.
264    Vector { results: Vec<AgentResult> },
265    /// `Join::Detached` → ordered Vec of task_ids, positions match input.
266    TaskIds { task_ids: Vec<String> },
267}
268
269// `TaskStatus` is re-exported from `crate::core::TaskStatus` — the same
270// enum the schema column `tasks.status` and the existing TaskStore /
271// A2AService stack uses. There's no separate Invocation-specific status
272// taxonomy; that drift would just produce two enums to keep in sync.
273
274/// Snapshot returned by the supervisor tools (`get_task`, `list_my_tasks`).
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct TaskSnapshot {
277    pub task_id: String,
278    pub agent_id: String,
279    pub status: TaskStatus,
280    pub executor: Executor,
281    pub started_at: i64, // ms epoch
282    pub last_event_at: i64,
283    pub ended_at: Option<i64>,
284    /// Optional — best-effort partial result (last assistant text) if
285    /// running, or final result content if done.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub preview: Option<String>,
288}
289
290// ── Validation ────────────────────────────────────────────────────────────
291
292/// Errors returned by `Invocation::validate`.
293#[derive(Debug, thiserror::Error, PartialEq, Eq)]
294pub enum InvocationValidationError {
295    #[error("invocation requires at least one target")]
296    NoTargets,
297    #[error("Join::Single requires exactly 1 target, got {got}")]
298    SingleNeedsOneTarget { got: usize },
299    #[error("AdHoc target with empty system_prompt")]
300    AdHocEmptyPrompt,
301    #[error("Named target with empty agent_id")]
302    NamedEmptyAgentId,
303}
304
305impl Invocation {
306    /// One-shot validation called at the orchestrator's entry point.
307    /// Downstream code can assume the invariants below hold:
308    ///
309    /// - `targets.len() >= 1`
310    /// - `Join::Single` ⇒ `targets.len() == 1`
311    /// - every target has a non-empty agent identity
312    pub fn validate(&self) -> Result<(), InvocationValidationError> {
313        if self.targets.is_empty() {
314            return Err(InvocationValidationError::NoTargets);
315        }
316        if matches!(self.join, Join::Single) && self.targets.len() != 1 {
317            return Err(InvocationValidationError::SingleNeedsOneTarget {
318                got: self.targets.len(),
319            });
320        }
321        for target in &self.targets {
322            match &target.agent {
323                AgentRef::Named { agent_id } if agent_id.is_empty() => {
324                    return Err(InvocationValidationError::NamedEmptyAgentId);
325                }
326                AgentRef::AdHoc { system_prompt, .. } if system_prompt.is_empty() => {
327                    return Err(InvocationValidationError::AdHocEmptyPrompt);
328                }
329                _ => {}
330            }
331        }
332        Ok(())
333    }
334}
335
336// ── Convenience constructors ──────────────────────────────────────────────
337
338impl Target {
339    pub fn named(agent_id: impl Into<String>, message: Message) -> Self {
340        Self {
341            agent: AgentRef::Named {
342                agent_id: agent_id.into(),
343            },
344            message,
345            executor: None,
346        }
347    }
348
349    pub fn adhoc(system_prompt: impl Into<String>, message: Message) -> Self {
350        Self {
351            agent: AgentRef::AdHoc {
352                system_prompt: system_prompt.into(),
353                tools: None,
354            },
355            message,
356            executor: None,
357        }
358    }
359}
360
361impl Invocation {
362    /// Build a `Join::Single` invocation. The simplest path; matches
363    /// today's default `call_agent({agent, prompt})`.
364    pub fn single(target: Target) -> Self {
365        Self {
366            targets: vec![target],
367            context: ContextScope::default(),
368            join: Join::Single,
369            executor: ExecutorHint::default(),
370            tools: ToolPolicy::default(),
371        }
372    }
373
374    /// Build a `Join::All` fan-out.
375    pub fn all(targets: Vec<Target>) -> Self {
376        Self {
377            targets,
378            context: ContextScope::default(),
379            join: Join::All,
380            executor: ExecutorHint::default(),
381            tools: ToolPolicy::default(),
382        }
383    }
384
385    /// Build a `Join::Detached` fire-and-forget. Cancellation cascades
386    /// from the parent (no opt-out yet).
387    pub fn detached(targets: Vec<Target>) -> Self {
388        Self {
389            targets,
390            context: ContextScope::default(),
391            join: Join::Detached,
392            executor: ExecutorHint::default(),
393            tools: ToolPolicy::default(),
394        }
395    }
396
397    /// Builder: set context scope.
398    pub fn with_context(mut self, context: ContextScope) -> Self {
399        self.context = context;
400        self
401    }
402
403    /// Builder: set executor hint.
404    pub fn with_executor(mut self, executor: ExecutorHint) -> Self {
405        self.executor = executor;
406        self
407    }
408
409    /// Builder: set tool policy.
410    pub fn with_tools(mut self, tools: ToolPolicy) -> Self {
411        self.tools = tools;
412        self
413    }
414}
415
416// ── Tests ─────────────────────────────────────────────────────────────────
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::core::{MessageRole, Part};
422
423    fn msg(text: &str) -> Message {
424        Message::user(text.to_string(), None)
425    }
426
427    fn named(agent: &str) -> Target {
428        Target::named(agent, msg("hi"))
429    }
430
431    fn adhoc(prompt: &str) -> Target {
432        Target::adhoc(prompt, msg("hi"))
433    }
434
435    // ── Validation ────────────────────────────────────────────────────────
436
437    #[test]
438    fn validates_zero_targets() {
439        let inv = Invocation {
440            targets: vec![],
441            context: ContextScope::Independent,
442            join: Join::Single,
443            executor: ExecutorHint::Auto,
444            tools: ToolPolicy::Inherit,
445        };
446        assert_eq!(inv.validate(), Err(InvocationValidationError::NoTargets));
447    }
448
449    #[test]
450    fn validates_single_with_one_target_passes() {
451        let inv = Invocation::single(named("worker"));
452        assert!(inv.validate().is_ok());
453    }
454
455    #[test]
456    fn validates_single_with_two_targets_fails() {
457        let inv = Invocation {
458            targets: vec![named("a"), named("b")],
459            context: ContextScope::Independent,
460            join: Join::Single,
461            executor: ExecutorHint::Auto,
462            tools: ToolPolicy::Inherit,
463        };
464        assert_eq!(
465            inv.validate(),
466            Err(InvocationValidationError::SingleNeedsOneTarget { got: 2 })
467        );
468    }
469
470    #[test]
471    fn validates_all_with_one_target_passes() {
472        let inv = Invocation::all(vec![named("a")]);
473        assert!(inv.validate().is_ok());
474    }
475
476    #[test]
477    fn validates_all_with_many_targets_passes() {
478        let inv = Invocation::all(vec![named("a"), named("b"), named("c")]);
479        assert!(inv.validate().is_ok());
480    }
481
482    #[test]
483    fn validates_named_empty_agent_id_fails() {
484        let inv = Invocation::single(Target::named("", msg("x")));
485        assert_eq!(
486            inv.validate(),
487            Err(InvocationValidationError::NamedEmptyAgentId)
488        );
489    }
490
491    #[test]
492    fn validates_adhoc_empty_prompt_fails() {
493        let inv = Invocation::single(Target::adhoc("", msg("x")));
494        assert_eq!(
495            inv.validate(),
496            Err(InvocationValidationError::AdHocEmptyPrompt)
497        );
498    }
499
500    // ── Defaults ──────────────────────────────────────────────────────────
501
502    #[test]
503    fn defaults_are_sane() {
504        assert_eq!(ContextScope::default(), ContextScope::Independent);
505        assert_eq!(Join::default(), Join::Single);
506        assert!(matches!(ExecutorHint::default(), ExecutorHint::Auto));
507        assert!(matches!(ToolPolicy::default(), ToolPolicy::Inherit));
508    }
509
510    // ── Builders ──────────────────────────────────────────────────────────
511
512    #[test]
513    fn single_builder_produces_valid_invocation() {
514        let inv = Invocation::single(named("w"));
515        assert_eq!(inv.targets.len(), 1);
516        assert!(matches!(inv.join, Join::Single));
517        assert!(inv.validate().is_ok());
518    }
519
520    #[test]
521    fn fluent_builders_chain() {
522        let inv = Invocation::all(vec![named("a"), named("b")])
523            .with_context(ContextScope::Inherited)
524            .with_executor(ExecutorHint::Force(Executor::Local))
525            .with_tools(ToolPolicy::Exact {
526                tools: vec!["Bash".into()],
527            });
528        assert!(matches!(inv.context, ContextScope::Inherited));
529        assert!(matches!(inv.tools, ToolPolicy::Exact { .. }));
530        assert!(inv.validate().is_ok());
531    }
532
533    // ── Serde round-trips ─────────────────────────────────────────────────
534
535    #[test]
536    fn serde_roundtrip_minimal() {
537        let inv = Invocation::single(named("worker"));
538        let v = serde_json::to_value(&inv).unwrap();
539        let back: Invocation = serde_json::from_value(v).unwrap();
540        assert_eq!(back.targets.len(), 1);
541    }
542
543    #[test]
544    fn serde_uses_snake_case_for_enums() {
545        let inv = Invocation::detached(vec![adhoc("be a worker")]);
546        let v = serde_json::to_value(&inv).unwrap();
547        assert_eq!(v["join"], "detached");
548        assert_eq!(v["context"], "independent");
549        assert_eq!(v["targets"][0]["agent"]["type"], "ad_hoc");
550    }
551
552    #[test]
553    fn serde_executor_remote_carries_runner_config() {
554        let inv =
555            Invocation::single(named("w")).with_executor(ExecutorHint::Force(Executor::Remote {
556                runner: RunnerConfig::new("sandbox")
557                    .with_config(serde_json::json!({ "image": "distri-cli:latest" })),
558            }));
559        let v = serde_json::to_value(&inv).unwrap();
560        assert_eq!(v["executor"]["kind"], "force");
561        assert_eq!(v["executor"]["type"], "remote");
562        assert_eq!(v["executor"]["runner"]["kind"], "sandbox");
563        assert_eq!(
564            v["executor"]["runner"]["config"]["image"],
565            "distri-cli:latest"
566        );
567        // Round-trip back to typed.
568        let back: Invocation = serde_json::from_value(v).unwrap();
569        match back.executor {
570            ExecutorHint::Force(Executor::Remote { runner }) => {
571                assert_eq!(runner.kind, "sandbox");
572                assert_eq!(runner.config["image"], "distri-cli:latest");
573            }
574            other => panic!("expected Force(Remote {{..}}); got {other:?}"),
575        }
576    }
577
578    #[test]
579    fn serde_invocation_result_scalar() {
580        let r = InvocationResult::Scalar {
581            result: AgentResult {
582                content: serde_json::json!({"text": "ok"}),
583                task_id: "t1".into(),
584                status: TaskStatus::Completed,
585            },
586        };
587        let v = serde_json::to_value(&r).unwrap();
588        assert_eq!(v["kind"], "scalar");
589        assert_eq!(v["result"]["task_id"], "t1");
590    }
591
592    // ── Sanity: Message construction works through the type system ───────
593
594    #[test]
595    fn message_role_in_target_is_user() {
596        let t = Target::named("w", msg("hello"));
597        assert!(matches!(t.message.role, MessageRole::User));
598        let parts = &t.message.parts;
599        assert!(matches!(parts.first(), Some(Part::Text(_))));
600    }
601}