Docs
Advanced Subsystems
Input & Output Traits

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 agents
impl AgentOutput for TurnOutput { ... } // default for most agents
impl 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 diffs
impl AgentCore for CodeReActCore {
type Output = CodeOutput; // { explanation, diffs, tool_trace }
// ...
}
// Robot agent with sensor input
impl 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 erased
impl<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 automatically
impl<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

  1. Two layers, not three — one trait for schema, one associated type for data (avoids AutoAgents' three-layer complexity)
  2. Trait objects over genericsAgentRuntime API stays simple
  3. Incremental adoptionTurnInput/TurnOutput remain the default; traits are opt-in
  4. Defaults mean zero changeReActCore works identically without modification