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}