use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use crate::{
DelegateRegistry, DelegateTool, Instructions, KernelError, LocalTool, Tool, ToolRegistry,
ToolSchema,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentManifest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub instructions: Option<InstructionsSpec>,
#[serde(default)]
pub model: Option<ModelSpec>,
#[serde(default)]
pub tools: Vec<ToolSpec>,
#[serde(default)]
pub mcp_servers: Vec<McpServerSpec>,
#[serde(default)]
pub delegates: Vec<DelegateSpec>,
#[serde(default)]
pub knowledge: Option<KnowledgeSpec>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstructionsSpec {
pub system_prompt: String,
#[serde(default)]
pub response_schema: Option<serde_json::Value>,
#[serde(default)]
pub examples: Vec<[String; 2]>,
#[serde(default)]
pub metadata: serde_json::Value,
}
impl From<InstructionsSpec> for Instructions {
fn from(s: InstructionsSpec) -> Self {
let mut instructions = Instructions::new(s.system_prompt);
if let Some(schema) = s.response_schema {
instructions = instructions.with_response_schema(schema);
}
for [user, assistant] in s.examples {
instructions = instructions.with_example(user, assistant);
}
if !s.metadata.is_null() {
instructions = instructions.with_metadata(s.metadata);
}
instructions
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum ModelSpec {
Openai { name: String },
OpenaiCompatible {
base_url: String,
#[serde(default)]
api_key: String,
name: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ToolSpec {
Local { name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerSpec {
pub name: String,
pub command: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DelegateKind {
#[default]
Stub,
InProcess,
Remote,
Mcp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegateSpec {
pub name: String,
pub description: String,
#[serde(default)]
pub kind: DelegateKind,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub manifest: Option<String>,
}
impl DelegateSpec {
pub fn executor_key(&self) -> &str {
self.agent.as_deref().unwrap_or(&self.name)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KnowledgeSpec {
#[serde(default)]
pub patterns_path: Option<String>,
#[serde(default)]
pub metadata: serde_json::Value,
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("yaml parse: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("missing host tool '{0}' referenced by manifest")]
MissingHostTool(String),
#[error("missing delegate executor '{0}' referenced by manifest")]
MissingDelegateExecutor(String),
}
impl AgentManifest {
pub fn from_yaml(s: &str) -> Result<Self, ManifestError> {
Ok(serde_yaml::from_str(s)?)
}
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, ManifestError> {
let raw = std::fs::read_to_string(path)?;
Self::from_yaml(&raw)
}
}
pub fn materialize_local_and_delegate_tools(
tools: &[ToolSpec],
delegates: &[DelegateSpec],
host_tools: &ToolRegistry,
) -> Result<ToolRegistry, ManifestError> {
materialize_local_and_delegate_tools_with_delegates(
tools,
delegates,
host_tools,
&DelegateRegistry::new(),
)
}
pub fn materialize_local_and_delegate_tools_with_delegates(
tools: &[ToolSpec],
delegates: &[DelegateSpec],
host_tools: &ToolRegistry,
delegate_executors: &DelegateRegistry,
) -> Result<ToolRegistry, ManifestError> {
let agent_tools = ToolRegistry::new();
for tool in tools {
match tool {
ToolSpec::Local { name } => {
let registered = host_tools
.get(name)
.map_err(|_| ManifestError::MissingHostTool(name.clone()))?;
agent_tools.register(registered);
}
}
}
for delegate in delegates {
if let Some(executor) = delegate_executors.get(delegate.executor_key()) {
agent_tools.register(Arc::new(DelegateTool::new(
delegate.name.clone(),
delegate.description.clone(),
executor,
)));
} else if delegate.kind == DelegateKind::InProcess {
return Err(ManifestError::MissingDelegateExecutor(
delegate.executor_key().to_string(),
));
} else {
agent_tools.register(delegate_stub(delegate));
}
}
Ok(agent_tools)
}
pub fn delegate_stub(spec: &DelegateSpec) -> Arc<dyn Tool> {
let name = spec.name.clone();
let description = spec.description.clone();
let schema = ToolSchema {
name: name.clone(),
description,
args_schema: json!({"type": "object"}),
result_schema: json!({"type": "object"}),
};
let stub_name = name.clone();
Arc::new(LocalTool::new(schema, move |args| {
let stub_name = stub_name.clone();
async move {
Ok(json!({
"status": "delegate_unwired",
"delegate": stub_name,
"args": args,
"hint": "host has not wired delegate execution; see manifest delegates",
}))
}
}))
}
impl From<ManifestError> for KernelError {
fn from(value: ManifestError) -> Self {
KernelError::ToolFailed(value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const MIN_YAML: &str = r#"{
"name": "portable-agent",
"instructions": { "system_prompt": "You are portable." },
"model": {
"provider": "openai_compatible",
"base_url": "http://localhost:11434/v1",
"name": "llama3"
},
"delegates": [
{ "name": "child_agent", "description": "A child specialist" },
{
"name": "local_child",
"description": "An in-process child specialist",
"kind": "in_process",
"agent": "child"
}
]
}"#;
#[test]
fn parses_portable_manifest() {
let manifest = AgentManifest::from_yaml(MIN_YAML).expect("parse");
assert_eq!(manifest.name.as_deref(), Some("portable-agent"));
assert_eq!(manifest.delegates.len(), 2);
assert_eq!(manifest.delegates[1].kind, DelegateKind::InProcess);
assert_eq!(manifest.delegates[1].executor_key(), "child");
assert!(matches!(
manifest.model,
Some(ModelSpec::OpenaiCompatible { .. })
));
}
#[tokio::test]
async fn materializes_delegate_stub() {
let manifest = AgentManifest::from_yaml(MIN_YAML).expect("parse");
let registry = materialize_local_and_delegate_tools(
&manifest.tools,
&manifest.delegates[..1],
&ToolRegistry::new(),
)
.expect("tools");
let tool = registry.get("child_agent").expect("delegate");
let out = tool.invoke(json!({"x": 1})).await.expect("invoke");
assert_eq!(out["status"], "delegate_unwired");
assert_eq!(out["delegate"], "child_agent");
}
#[test]
fn missing_host_tool_is_an_error() {
let host = ToolRegistry::new();
let result = materialize_local_and_delegate_tools(
&[ToolSpec::Local {
name: "missing".into(),
}],
&[],
&host,
);
let Err(err) = result else {
panic!("expected missing host tool error");
};
assert!(matches!(err, ManifestError::MissingHostTool(ref n) if n == "missing"));
}
#[test]
fn missing_in_process_delegate_executor_is_an_error() {
let delegate = DelegateSpec {
name: "child_agent".into(),
description: "child".into(),
kind: DelegateKind::InProcess,
agent: Some("child".into()),
manifest: None,
};
let result = materialize_local_and_delegate_tools_with_delegates(
&[],
&[delegate],
&ToolRegistry::new(),
&DelegateRegistry::new(),
);
let Err(err) = result else {
panic!("expected missing delegate executor error");
};
assert!(matches!(err, ManifestError::MissingDelegateExecutor(ref n) if n == "child"));
}
}