use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::agent::ToolsConfig;
use crate::core::{Message, TaskStatus};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invocation {
pub targets: Vec<Target>,
#[serde(default)]
pub context: ContextScope,
#[serde(default)]
pub join: Join,
#[serde(default)]
pub executor: ExecutorHint,
#[serde(default)]
pub tools: ToolPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Target {
pub agent: AgentRef,
pub message: Message,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executor: Option<ExecutorHint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentRef {
Named { agent_id: String },
AdHoc {
system_prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
tools: Option<ToolsConfig>,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ContextScope {
#[default]
Independent,
Inherited,
Shared,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Join {
#[default]
Single,
All,
Detached,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Executor {
Local,
Remote { runner: RunnerConfig },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunnerConfig {
pub kind: String,
#[serde(default = "default_config_value")]
pub config: serde_json::Value,
}
fn default_config_value() -> serde_json::Value {
serde_json::Value::Object(Default::default())
}
impl RunnerConfig {
pub fn new(kind: impl Into<String>) -> Self {
Self {
kind: kind.into(),
config: default_config_value(),
}
}
pub fn with_config(mut self, config: serde_json::Value) -> Self {
self.config = config;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExecutorHint {
#[default]
Auto,
Force(Executor),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ToolPolicy {
#[default]
Inherit,
Exact { tools: Vec<String> },
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentResult {
pub content: serde_json::Value,
pub task_id: String,
pub status: TaskStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvocationResult {
Scalar { result: AgentResult },
Vector { results: Vec<AgentResult> },
TaskIds { task_ids: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSnapshot {
pub task_id: String,
pub agent_id: String,
pub status: TaskStatus,
pub executor: Executor,
pub started_at: i64, pub last_event_at: i64,
pub ended_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvocationValidationError {
#[error("invocation requires at least one target")]
NoTargets,
#[error("Join::Single requires exactly 1 target, got {got}")]
SingleNeedsOneTarget { got: usize },
#[error("AdHoc target with empty system_prompt")]
AdHocEmptyPrompt,
#[error("Named target with empty agent_id")]
NamedEmptyAgentId,
}
impl Invocation {
pub fn validate(&self) -> Result<(), InvocationValidationError> {
if self.targets.is_empty() {
return Err(InvocationValidationError::NoTargets);
}
if matches!(self.join, Join::Single) && self.targets.len() != 1 {
return Err(InvocationValidationError::SingleNeedsOneTarget {
got: self.targets.len(),
});
}
for target in &self.targets {
match &target.agent {
AgentRef::Named { agent_id } if agent_id.is_empty() => {
return Err(InvocationValidationError::NamedEmptyAgentId);
}
AgentRef::AdHoc { system_prompt, .. } if system_prompt.is_empty() => {
return Err(InvocationValidationError::AdHocEmptyPrompt);
}
_ => {}
}
}
Ok(())
}
}
impl Target {
pub fn named(agent_id: impl Into<String>, message: Message) -> Self {
Self {
agent: AgentRef::Named {
agent_id: agent_id.into(),
},
message,
executor: None,
}
}
pub fn adhoc(system_prompt: impl Into<String>, message: Message) -> Self {
Self {
agent: AgentRef::AdHoc {
system_prompt: system_prompt.into(),
tools: None,
},
message,
executor: None,
}
}
}
impl Invocation {
pub fn single(target: Target) -> Self {
Self {
targets: vec![target],
context: ContextScope::default(),
join: Join::Single,
executor: ExecutorHint::default(),
tools: ToolPolicy::default(),
}
}
pub fn all(targets: Vec<Target>) -> Self {
Self {
targets,
context: ContextScope::default(),
join: Join::All,
executor: ExecutorHint::default(),
tools: ToolPolicy::default(),
}
}
pub fn detached(targets: Vec<Target>) -> Self {
Self {
targets,
context: ContextScope::default(),
join: Join::Detached,
executor: ExecutorHint::default(),
tools: ToolPolicy::default(),
}
}
pub fn with_context(mut self, context: ContextScope) -> Self {
self.context = context;
self
}
pub fn with_executor(mut self, executor: ExecutorHint) -> Self {
self.executor = executor;
self
}
pub fn with_tools(mut self, tools: ToolPolicy) -> Self {
self.tools = tools;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{MessageRole, Part};
fn msg(text: &str) -> Message {
Message::user(text.to_string(), None)
}
fn named(agent: &str) -> Target {
Target::named(agent, msg("hi"))
}
fn adhoc(prompt: &str) -> Target {
Target::adhoc(prompt, msg("hi"))
}
#[test]
fn validates_zero_targets() {
let inv = Invocation {
targets: vec![],
context: ContextScope::Independent,
join: Join::Single,
executor: ExecutorHint::Auto,
tools: ToolPolicy::Inherit,
};
assert_eq!(inv.validate(), Err(InvocationValidationError::NoTargets));
}
#[test]
fn validates_single_with_one_target_passes() {
let inv = Invocation::single(named("worker"));
assert!(inv.validate().is_ok());
}
#[test]
fn validates_single_with_two_targets_fails() {
let inv = Invocation {
targets: vec![named("a"), named("b")],
context: ContextScope::Independent,
join: Join::Single,
executor: ExecutorHint::Auto,
tools: ToolPolicy::Inherit,
};
assert_eq!(
inv.validate(),
Err(InvocationValidationError::SingleNeedsOneTarget { got: 2 })
);
}
#[test]
fn validates_all_with_one_target_passes() {
let inv = Invocation::all(vec![named("a")]);
assert!(inv.validate().is_ok());
}
#[test]
fn validates_all_with_many_targets_passes() {
let inv = Invocation::all(vec![named("a"), named("b"), named("c")]);
assert!(inv.validate().is_ok());
}
#[test]
fn validates_named_empty_agent_id_fails() {
let inv = Invocation::single(Target::named("", msg("x")));
assert_eq!(
inv.validate(),
Err(InvocationValidationError::NamedEmptyAgentId)
);
}
#[test]
fn validates_adhoc_empty_prompt_fails() {
let inv = Invocation::single(Target::adhoc("", msg("x")));
assert_eq!(
inv.validate(),
Err(InvocationValidationError::AdHocEmptyPrompt)
);
}
#[test]
fn defaults_are_sane() {
assert_eq!(ContextScope::default(), ContextScope::Independent);
assert_eq!(Join::default(), Join::Single);
assert!(matches!(ExecutorHint::default(), ExecutorHint::Auto));
assert!(matches!(ToolPolicy::default(), ToolPolicy::Inherit));
}
#[test]
fn single_builder_produces_valid_invocation() {
let inv = Invocation::single(named("w"));
assert_eq!(inv.targets.len(), 1);
assert!(matches!(inv.join, Join::Single));
assert!(inv.validate().is_ok());
}
#[test]
fn fluent_builders_chain() {
let inv = Invocation::all(vec![named("a"), named("b")])
.with_context(ContextScope::Inherited)
.with_executor(ExecutorHint::Force(Executor::Local))
.with_tools(ToolPolicy::Exact {
tools: vec!["Bash".into()],
});
assert!(matches!(inv.context, ContextScope::Inherited));
assert!(matches!(inv.tools, ToolPolicy::Exact { .. }));
assert!(inv.validate().is_ok());
}
#[test]
fn serde_roundtrip_minimal() {
let inv = Invocation::single(named("worker"));
let v = serde_json::to_value(&inv).unwrap();
let back: Invocation = serde_json::from_value(v).unwrap();
assert_eq!(back.targets.len(), 1);
}
#[test]
fn serde_uses_snake_case_for_enums() {
let inv = Invocation::detached(vec![adhoc("be a worker")]);
let v = serde_json::to_value(&inv).unwrap();
assert_eq!(v["join"], "detached");
assert_eq!(v["context"], "independent");
assert_eq!(v["targets"][0]["agent"]["type"], "ad_hoc");
}
#[test]
fn serde_executor_remote_carries_runner_config() {
let inv =
Invocation::single(named("w")).with_executor(ExecutorHint::Force(Executor::Remote {
runner: RunnerConfig::new("sandbox")
.with_config(serde_json::json!({ "image": "distri-cli:latest" })),
}));
let v = serde_json::to_value(&inv).unwrap();
assert_eq!(v["executor"]["kind"], "force");
assert_eq!(v["executor"]["type"], "remote");
assert_eq!(v["executor"]["runner"]["kind"], "sandbox");
assert_eq!(
v["executor"]["runner"]["config"]["image"],
"distri-cli:latest"
);
let back: Invocation = serde_json::from_value(v).unwrap();
match back.executor {
ExecutorHint::Force(Executor::Remote { runner }) => {
assert_eq!(runner.kind, "sandbox");
assert_eq!(runner.config["image"], "distri-cli:latest");
}
other => panic!("expected Force(Remote {{..}}); got {other:?}"),
}
}
#[test]
fn serde_invocation_result_scalar() {
let r = InvocationResult::Scalar {
result: AgentResult {
content: serde_json::json!({"text": "ok"}),
task_id: "t1".into(),
status: TaskStatus::Completed,
},
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["kind"], "scalar");
assert_eq!(v["result"]["task_id"], "t1");
}
#[test]
fn message_role_in_target_is_user() {
let t = Target::named("w", msg("hello"));
assert!(matches!(t.message.role, MessageRole::User));
let parts = &t.message.parts;
assert!(matches!(parts.first(), Some(Part::Text(_))));
}
}