Skip to main content

atomr_agents_agent/
boxed.rs

1//! Object-erased agent: a config-driven counterpart to the typed
2//! `Agent<I,T,Ms,Sk>`. Useful when the strategy concrete types
3//! aren't known at the construction site (e.g. Python config
4//! loaders or registry-driven instantiation).
5//!
6//! The hot path is unchanged for the typed `Agent<I,T,Ms,Sk>` —
7//! both forms funnel into [`crate::pipeline::run_turn_impl`]. Only
8//! the strategy method calls inside that impl become indirect, and
9//! they happen ~4 times per turn (not in a tight loop).
10
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use atomr_agents_core::{AgentId, CallCtx, Result};
15use atomr_agents_instruction::InstructionStrategy;
16use atomr_agents_observability::EventBus;
17use atomr_agents_strategy::{MemoryStrategy, SkillStrategy, ToolStrategy};
18
19use crate::inference::{InferenceClient, TurnResult};
20use crate::pipeline::{run_turn_impl, AgentBudgets};
21use crate::r#trait::AgentDispatch;
22
23/// Fully-erased agent. Mirrors the field shape of
24/// [`crate::Agent`] but stores each strategy as a trait object so
25/// callers without the concrete strategy types can still construct
26/// a runnable agent.
27pub struct BoxedAgent {
28    pub id: AgentId,
29    pub model: String,
30    pub instructions: Box<dyn InstructionStrategy>,
31    pub tools: Box<dyn ToolStrategy>,
32    pub memory: Box<dyn MemoryStrategy>,
33    pub skills: Box<dyn SkillStrategy>,
34    pub inference: Arc<dyn InferenceClient>,
35    pub bus: EventBus,
36    pub max_tool_iterations: u32,
37}
38
39impl BoxedAgent {
40    /// One full agent turn — see [`crate::Agent::run_turn`].
41    pub async fn run_turn(&self, user: String, budgets: AgentBudgets) -> Result<TurnResult> {
42        run_turn_impl(
43            &self.id,
44            &self.model,
45            &*self.instructions,
46            &*self.tools,
47            &*self.memory,
48            &*self.skills,
49            &self.inference,
50            &self.bus,
51            self.max_tool_iterations,
52            user,
53            budgets,
54        )
55        .await
56    }
57}
58
59#[async_trait]
60impl AgentDispatch for BoxedAgent {
61    async fn dispatch(&self, user: String, ctx: CallCtx) -> Result<TurnResult> {
62        self.run_turn(
63            user,
64            AgentBudgets {
65                tokens: ctx.tokens,
66                time: ctx.time,
67                money: ctx.money,
68                iterations: ctx.iterations,
69            },
70        )
71        .await
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    use async_trait::async_trait;
80    use atomr_agents_core::{
81        InvokeCtx, IterationBudget, MoneyBudget, TimeBudget, TokenBudget, ToolId, Value,
82    };
83    use atomr_agents_instruction::{ComposedInstructionStrategy, StaticBehaviorStrategy, StaticTaskStrategy};
84    use atomr_agents_memory::{InMemoryStore, RecencyMemoryStrategy};
85    use atomr_agents_persona::StaticPersonaStrategy;
86    use atomr_agents_skill::StaticSkillStrategy;
87    use atomr_agents_tool::{DynTool, Provider, StaticToolStrategy, Tool, ToolDescriptor, ToolSchema};
88
89    use crate::inference::LocalRunnerClient;
90    use atomr_infer_testkit::{MockRunner, MockScript};
91
92    /// Trivial calculator for the BoxedAgent end-to-end test (mirrors
93    /// the typed `Agent` test fixture in `pipeline.rs`).
94    struct CalculatorTool {
95        d: ToolDescriptor,
96    }
97    impl CalculatorTool {
98        fn new() -> Self {
99            Self {
100                d: ToolDescriptor {
101                    id: ToolId::from("calculator"),
102                    name: "calculator".into(),
103                    description: "evaluate simple arithmetic".into(),
104                    schema: ToolSchema::empty_object(),
105                },
106            }
107        }
108    }
109    #[async_trait]
110    impl Tool for CalculatorTool {
111        fn descriptor(&self) -> &ToolDescriptor {
112            &self.d
113        }
114        async fn invoke(&self, args: Value, _ctx: &InvokeCtx) -> Result<Value> {
115            let a = args.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
116            let b = args.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
117            Ok(serde_json::json!({"sum": a + b}))
118        }
119    }
120
121    fn build_boxed_agent(runner: MockRunner) -> BoxedAgent {
122        let store = Arc::new(InMemoryStore::new());
123        let mem = RecencyMemoryStrategy::new(store, 5, 30);
124        let tools: Vec<DynTool> = vec![Arc::new(CalculatorTool::new())];
125        let tool_strat = StaticToolStrategy::new(tools);
126        let instr = ComposedInstructionStrategy::new(
127            StaticPersonaStrategy::new("You are a calculator assistant."),
128            StaticTaskStrategy("Use tools to answer arithmetic questions.".into()),
129            StaticBehaviorStrategy("Reply tersely.".into()),
130        );
131        let skill_strat = StaticSkillStrategy::new(vec![]);
132        let inference: Arc<dyn InferenceClient> = Arc::new(LocalRunnerClient::new(runner, Provider::OpenAi));
133        BoxedAgent {
134            id: AgentId::from("boxed-1"),
135            model: "mock".into(),
136            instructions: Box::new(instr),
137            tools: Box::new(tool_strat),
138            memory: Box::new(mem),
139            skills: Box::new(skill_strat),
140            inference,
141            bus: EventBus::new(),
142            max_tool_iterations: 3,
143        }
144    }
145
146    #[tokio::test]
147    async fn boxed_agent_runs_simple_text_turn() {
148        let runner = MockRunner::new(MockScript::from_text(["the answer is ", "42"]));
149        let agent = build_boxed_agent(runner);
150        let r = agent
151            .run_turn(
152                "what's 1+2".into(),
153                AgentBudgets {
154                    tokens: TokenBudget::new(10_000),
155                    time: TimeBudget::new(std::time::Duration::from_secs(30)),
156                    money: MoneyBudget::from_usd(1.0),
157                    iterations: IterationBudget::new(5),
158                },
159            )
160            .await
161            .unwrap();
162        assert!(r.text.contains("42"));
163        assert_eq!(r.usage.output_tokens, 2);
164    }
165
166    #[tokio::test]
167    async fn boxed_agent_dispatches_through_trait() {
168        let runner = MockRunner::new(MockScript::from_text(["pong"]));
169        let agent = build_boxed_agent(runner);
170        let ctx = CallCtx {
171            agent_id: Some(agent.id.clone()),
172            tokens: TokenBudget::new(10_000),
173            time: TimeBudget::new(std::time::Duration::from_secs(30)),
174            money: MoneyBudget::from_usd(1.0),
175            iterations: IterationBudget::new(5),
176            trace: vec![],
177        };
178        let r = AgentDispatch::dispatch(&agent, "ping".into(), ctx).await.unwrap();
179        assert_eq!(r.text, "pong");
180    }
181}