Input & Output Traits
This is a v2 design — not yet implemented. See Architecture v2 for context.
The problem
All agents currently use the same TurnInput / TurnOutput types. A chat agent, a code agent returning file diffs, and a robot agent returning motor commands all produce TurnOutput { response: String, ... }. Input modality is invisible — EmbodiedCore needs sensor frames, but TurnInput only has UserMessage.
Core traits
pub trait AgentInput: Send + Sync + Serialize + DeserializeOwned { fn input_schema() -> Value;}pub trait AgentOutput: Send + Sync + Serialize + DeserializeOwned { fn output_schema() -> Value; fn structured_output_format() -> Option<Value> { None }}
The existing concrete types implement these traits, preserving backward compatibility:
impl AgentInput for TurnInput { ... } // default for most agentsimpl AgentOutput for TurnOutput { ... } // default for most agentsimpl AgentOutput for String { ... } // simplest case
Updated AgentCore trait
AgentCore gains associated types with defaults:
pub trait AgentCore: Send + Sync { type Input: AgentInput = TurnInput; type Output: AgentOutput = TurnOutput; fn core_type(&self) -> &str; async fn run_turn(&mut self, input: Self::Input, ctx: &mut AgentContext) -> Result<Self::Output, AgentError>;}
ReActCore uses defaults — no change needed. Custom cores declare their types:
// Code agent with structured diffsimpl AgentCore for CodeReActCore { type Output = CodeOutput; // { explanation, diffs, tool_trace } // ...}// Robot agent with sensor inputimpl AgentCore for EmbodiedCore { type Input = EmbodiedInput; // { message, sensor_readings, position } // ...}
Type erasure at the runtime boundary
AgentRuntime stays simple — no generic parameters. The associated types are erased internally:
pub struct AgentRuntime { core: Box<dyn ErasedAgentCore>, // type-erased wrapper // ... other fields unchanged}trait ErasedAgentCore: Send + Sync { fn core_type(&self) -> &str; fn input_schema(&self) -> Value; fn output_schema(&self) -> Value; async fn run_turn_erased(&mut self, input: Value, ctx: &mut AgentContext) -> Result<Value, AgentError>;}// Blanket impl: any AgentCore can be erasedimpl<T: AgentCore> ErasedAgentCore for T { async fn run_turn_erased(&mut self, input: Value, ctx: &mut AgentContext) -> Result<Value, AgentError> { let typed_input: T::Input = serde_json::from_value(input)?; let output = self.run_turn(typed_input, ctx).await?; Ok(serde_json::to_value(output)?) }}
Inside the core: fully typed, compile-time safety. At the runtime boundary: Value in/out, no generics leak.
Typed tool I/O
Native Rust tools can optionally declare typed I/O:
pub trait TypedTool: ToolPlugin { type Input: Serialize + DeserializeOwned; type Output: Serialize + DeserializeOwned; async fn execute_typed(&self, input: Self::Input, ctx: &ToolContext) -> Result<Self::Output, ToolError>;}// Blanket impl handles serde automaticallyimpl<T: TypedTool> ToolPlugin for T { ... }
WASM and JS tools continue using Value — typed layer is only for native Rust tools.
Composition validation
With schemas on agents and tools, the runtime can validate skill chains at registration time:
impl AgentRuntime { pub fn validate_skill(&self, skill: &SkillManifest) -> Result<(), ValidationError> { for (i, step) in skill.steps.iter().enumerate() { if i == 0 { continue; } let prev_output = self.registry.tool_output_schema(&skill.steps[i-1].tool)?; let curr_input = self.registry.tool_input_schema(&step.tool)?; if !schema_compatible(&prev_output, &curr_input) { return Err(ValidationError::IncompatibleChain { ... }); } } Ok(()) }}
Design principles
- Two layers, not three — one trait for schema, one associated type for data (avoids AutoAgents' three-layer complexity)
- Trait objects over generics —
AgentRuntimeAPI stays simple - Incremental adoption —
TurnInput/TurnOutputremain the default; traits are opt-in - Defaults mean zero change —
ReActCoreworks identically without modification