Skip to main content

atomr_agents_agent/
pipeline.rs

1//! Per-turn pipeline implementation.
2
3use std::sync::Arc;
4use std::time::Instant;
5
6use async_trait::async_trait;
7use atomr_agents_context::{ContextAssembler, ContextFragment};
8use atomr_agents_core::{
9    AgentContext, AgentError, AgentId, CallCtx, Event, IterationBudget, Json, MemoryItem, MemoryKind,
10    MemoryNamespace, MoneyBudget, Result, TimeBudget, TokenBudget, ToolId, TurnInput,
11};
12use atomr_agents_instruction::InstructionStrategy;
13use atomr_agents_observability::EventBus;
14use atomr_agents_strategy::{MemoryStrategy, SkillStrategy, ToolStrategy};
15use atomr_infer_core::batch::{ExecuteBatch, Message as InferMsg, MessageContent, Role, SamplingParams};
16
17use crate::inference::{InferenceClient, TurnResult};
18use crate::r#trait::AgentDispatch;
19
20/// Generic agent. Strategy types are monomorphized for the hot
21/// path; a `BoxedAgent` form (using `Box<dyn>` for each slot) is
22/// produced from `AgentSpec`.
23pub struct Agent<I, T, Ms, Sk>
24where
25    I: InstructionStrategy,
26    T: ToolStrategy,
27    Ms: MemoryStrategy,
28    Sk: SkillStrategy,
29{
30    pub id: AgentId,
31    pub model: String,
32    pub instructions: I,
33    pub tools: T,
34    pub memory: Ms,
35    pub skills: Sk,
36    pub inference: Arc<dyn InferenceClient>,
37    pub bus: EventBus,
38    pub max_tool_iterations: u32,
39}
40
41impl<I, T, Ms, Sk> Agent<I, T, Ms, Sk>
42where
43    I: InstructionStrategy,
44    T: ToolStrategy,
45    Ms: MemoryStrategy,
46    Sk: SkillStrategy,
47{
48    /// One full agent turn. Drives the per-turn pipeline (memory +
49    /// skill + tool resolution → instruction render → context
50    /// assembly → inference → tool-call loop → memory store).
51    ///
52    /// Thin wrapper around [`run_turn_impl`]; the typed `Agent<I,T,Ms,Sk>`
53    /// stays monomorphic at the call site, only crossing into
54    /// dyn-dispatch at the `run_turn_impl` boundary.
55    pub async fn run_turn(&self, user: String, budgets: AgentBudgets) -> Result<TurnResult> {
56        run_turn_impl(
57            &self.id,
58            &self.model,
59            &self.instructions,
60            &self.tools,
61            &self.memory,
62            &self.skills,
63            &self.inference,
64            &self.bus,
65            self.max_tool_iterations,
66            user,
67            budgets,
68        )
69        .await
70    }
71}
72
73/// Shared per-turn pipeline body. Both the typed [`Agent`] and the
74/// fully-erased [`crate::BoxedAgent`] dispatch through this. The
75/// `&dyn` references mean each strategy method becomes an indirect
76/// call — fine here since strategies are invoked O(1) times per turn
77/// (not in a hot loop).
78pub(crate) async fn run_turn_impl(
79    id: &AgentId,
80    model: &str,
81    instructions: &dyn InstructionStrategy,
82    tools: &dyn ToolStrategy,
83    memory: &dyn MemoryStrategy,
84    skills: &dyn SkillStrategy,
85    inference: &Arc<dyn InferenceClient>,
86    bus: &EventBus,
87    max_tool_iterations: u32,
88    user: String,
89    budgets: AgentBudgets,
90) -> Result<TurnResult> {
91    let start = Instant::now();
92    let agent_ctx = AgentContext::for_agent(
93        id.clone(),
94        TurnInput {
95            user: user.clone(),
96            history: vec![],
97        },
98    );
99    let AgentBudgets {
100        mut tokens,
101        time,
102        money,
103        mut iterations,
104    } = budgets;
105
106    // 1. Parallel strategy resolution.
107    let mut subs = tokens.split(3);
108    let (mut bm, mut bs, mut bt) = (subs.remove(0), subs.remove(0), subs.remove(0));
109    let bm0 = bm.remaining;
110    let bs0 = bs.remaining;
111    let bt0 = bt.remaining;
112    let (mem, sk_res, tool_refs) = tokio::join!(
113        memory.retrieve(&agent_ctx, &mut bm),
114        skills.applicable(&agent_ctx, &mut bs),
115        tools.select(&agent_ctx, &mut bt),
116    );
117    let mem = mem?;
118    let _skills = sk_res?;
119    let tool_refs = tool_refs?;
120    let consumed = bm0.saturating_sub(bm.remaining)
121        + bs0.saturating_sub(bs.remaining)
122        + bt0.saturating_sub(bt.remaining);
123    tokens.consume(consumed.min(tokens.remaining)).ok();
124
125    // 2. Render instructions.
126    let mut instr_budget = tokens.split(2).remove(0);
127    let r_instr = instructions.render(&agent_ctx, &mut instr_budget).await?;
128    tokens
129        .consume(r_instr.estimated_tokens.min(tokens.remaining))
130        .ok();
131
132    // 3. Assemble final context (system prompt + recalled memory).
133    let mut frags = vec![ContextFragment {
134        source: "system",
135        priority: 9,
136        estimated_tokens: r_instr.estimated_tokens,
137        text: r_instr.system_prompt.clone(),
138    }];
139    for c in &mem {
140        frags.push(ContextFragment {
141            source: "memory",
142            priority: 5,
143            estimated_tokens: c.estimated_tokens,
144            text: c.text.clone(),
145        });
146    }
147    let assembled = ContextAssembler::assemble(frags, &mut tokens)?;
148
149    // 4. Build initial messages.
150    let mut messages: Vec<InferMsg> = Vec::new();
151    messages.push(InferMsg {
152        role: Role::System,
153        content: MessageContent::Text(assembled.join("\n\n")),
154    });
155    messages.push(InferMsg {
156        role: Role::User,
157        content: MessageContent::Text(user.clone()),
158    });
159
160    // 5. Tool-call loop.
161    let mut final_text = String::new();
162    let mut final_usage = atomr_infer_core::tokens::TokenUsage::default();
163    let mut final_finish = None;
164    let mut all_tool_calls: Vec<atomr_agents_tool::ParsedToolCall> = Vec::new();
165    for iter in 0..max_tool_iterations.max(1) {
166        iterations.consume_one()?;
167        let batch = ExecuteBatch {
168            request_id: format!("turn-{}", uuid_str()),
169            model: model.to_string(),
170            messages: messages.clone(),
171            sampling: SamplingParams::default(),
172            stream: true,
173            estimated_tokens: tokens.remaining,
174        };
175        let r = inference.run(batch).await?;
176        final_text = r.text.clone();
177        final_usage.add(r.usage);
178        final_finish = r.finish_reason;
179        // Surface every streamed tool call to observers before
180        // dispatch — distinct from the post-call ToolInvoked event.
181        for call in &r.tool_calls {
182            let args = call.arguments().unwrap_or(Json::Value::Null);
183            bus.emit(Event::ToolCallStreamed {
184                agent_id: id.clone(),
185                tool_name: call.name.clone(),
186                arguments_hash: hash_value(&args),
187                iteration: iter,
188            });
189        }
190        all_tool_calls.extend(r.tool_calls.iter().cloned());
191        // Stop conditions.
192        if r.tool_calls.is_empty()
193            || r.finish_reason != Some(atomr_infer_core::tokens::FinishReason::ToolCalls)
194        {
195            break;
196        }
197        // Append the assistant's tool-call turn (for provider
198        // history coherence) and dispatch each tool — concurrently
199        // when multiple are emitted, order-preserved on aggregation.
200        messages.push(InferMsg {
201            role: Role::Assistant,
202            content: MessageContent::Text(r.text.clone()),
203        });
204        let mut handles: Vec<tokio::task::JoinHandle<Result<(usize, String, Json::Value, u64, u64)>>> =
205            Vec::with_capacity(r.tool_calls.len());
206        for (idx, call) in r.tool_calls.iter().enumerate() {
207            let tool_ref = tool_refs
208                .iter()
209                .find(|t| t.name == call.name)
210                .ok_or_else(|| AgentError::Tool(format!("unknown tool: {}", call.name)))?;
211            let args = call.arguments().unwrap_or(Json::Value::Null);
212            let invoke_ctx = CallCtx {
213                agent_id: Some(id.clone()),
214                tokens,
215                time,
216                money,
217                iterations,
218                trace: vec![format!("tool:{}", call.name)],
219            };
220            let handle = tool_ref.handle.clone();
221            let name = call.name.clone();
222            let args_for_task = args.clone();
223            handles.push(tokio::spawn(async move {
224                let t0 = Instant::now();
225                let result = handle.call(args_for_task.clone(), invoke_ctx).await?;
226                Ok::<_, AgentError>((
227                    idx,
228                    name,
229                    result,
230                    hash_value(&args_for_task),
231                    t0.elapsed().as_millis() as u64,
232                ))
233            }));
234        }
235        let mut results: Vec<(usize, String, Json::Value, u64, u64)> = Vec::with_capacity(handles.len());
236        for h in handles {
237            let pair = h.await.map_err(|e| AgentError::Internal(e.to_string()))??;
238            results.push(pair);
239        }
240        results.sort_by_key(|(i, _, _, _, _)| *i);
241        for (_, name, result, args_hash, elapsed_ms) in results {
242            bus.emit(Event::ToolInvoked {
243                tool_id: ToolId::from(name.as_str()),
244                args_hash,
245                elapsed_ms,
246                ok: true,
247            });
248            messages.push(InferMsg {
249                role: Role::Tool,
250                content: MessageContent::Text(serde_json::to_string(&result).unwrap_or_default()),
251            });
252        }
253    }
254
255    // 6. Memory store.
256    let item = MemoryItem {
257        id: format!("turn-{}", uuid_str()),
258        kind: MemoryKind::Episodic,
259        namespace: MemoryNamespace::Agent(id.clone()),
260        payload: serde_json::json!({"user": user, "assistant": final_text}),
261        timestamp_ms: chrono::Utc::now().timestamp_millis(),
262        tags: vec![],
263    };
264    memory.store(item).await.ok();
265
266    // 7. Emit AgentTurn event.
267    bus.emit(Event::AgentTurn {
268        agent_id: id.clone(),
269        input_tokens: final_usage.input_tokens,
270        output_tokens: final_usage.output_tokens,
271        reasoning_tokens: final_usage.reasoning_tokens,
272        cached_tokens: final_usage.cached_tokens,
273        finish_reason: final_finish,
274        elapsed_ms: start.elapsed().as_millis() as u64,
275    });
276
277    Ok(TurnResult {
278        text: final_text,
279        usage: final_usage,
280        finish_reason: final_finish,
281        tool_calls: all_tool_calls,
282    })
283}
284
285#[async_trait]
286impl<I, T, Ms, Sk> AgentDispatch for Agent<I, T, Ms, Sk>
287where
288    I: InstructionStrategy,
289    T: ToolStrategy,
290    Ms: MemoryStrategy,
291    Sk: SkillStrategy,
292{
293    async fn dispatch(&self, user: String, ctx: CallCtx) -> Result<TurnResult> {
294        self.run_turn(
295            user,
296            AgentBudgets {
297                tokens: ctx.tokens,
298                time: ctx.time,
299                money: ctx.money,
300                iterations: ctx.iterations,
301            },
302        )
303        .await
304    }
305}
306
307#[derive(Debug, Clone, Copy)]
308pub struct AgentBudgets {
309    pub tokens: TokenBudget,
310    pub time: TimeBudget,
311    pub money: MoneyBudget,
312    pub iterations: IterationBudget,
313}
314
315fn uuid_str() -> String {
316    use std::sync::atomic::{AtomicU64, Ordering};
317    static N: AtomicU64 = AtomicU64::new(0);
318    format!("{:016x}", N.fetch_add(1, Ordering::Relaxed))
319}
320
321fn hash_value(v: &Json::Value) -> u64 {
322    use std::hash::{Hash, Hasher};
323    let mut h = std::collections::hash_map::DefaultHasher::new();
324    serde_json::to_string(v).unwrap_or_default().hash(&mut h);
325    h.finish()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    use std::sync::Arc;
333
334    use async_trait::async_trait;
335    use atomr_agents_core::{InvokeCtx, Value};
336    use atomr_agents_instruction::{ComposedInstructionStrategy, StaticBehaviorStrategy, StaticTaskStrategy};
337    use atomr_agents_memory::{InMemoryStore, RecencyMemoryStrategy};
338    use atomr_agents_persona::StaticPersonaStrategy;
339    use atomr_agents_skill::StaticSkillStrategy;
340    use atomr_agents_tool::{DynTool, Provider, StaticToolStrategy, Tool, ToolDescriptor, ToolSchema};
341
342    use crate::inference::LocalRunnerClient;
343    use atomr_infer_testkit::{MockRunner, MockScript};
344
345    struct CalculatorTool {
346        d: ToolDescriptor,
347    }
348    impl CalculatorTool {
349        fn new() -> Self {
350            Self {
351                d: ToolDescriptor {
352                    id: ToolId::from("calculator"),
353                    name: "calculator".into(),
354                    description: "evaluate simple arithmetic".into(),
355                    schema: ToolSchema::empty_object(),
356                },
357            }
358        }
359    }
360    #[async_trait]
361    impl Tool for CalculatorTool {
362        fn descriptor(&self) -> &ToolDescriptor {
363            &self.d
364        }
365        async fn invoke(&self, args: Value, _ctx: &InvokeCtx) -> Result<Value> {
366            let a = args.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
367            let b = args.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
368            Ok(serde_json::json!({"sum": a + b}))
369        }
370    }
371
372    fn build_agent(
373        runner: MockRunner,
374    ) -> Agent<
375        ComposedInstructionStrategy<StaticPersonaStrategy, StaticTaskStrategy, StaticBehaviorStrategy>,
376        StaticToolStrategy,
377        RecencyMemoryStrategy,
378        StaticSkillStrategy,
379    > {
380        let store = Arc::new(InMemoryStore::new());
381        let mem = RecencyMemoryStrategy::new(store, 5, 30);
382        let tools: Vec<DynTool> = vec![Arc::new(CalculatorTool::new())];
383        let tool_strat = StaticToolStrategy::new(tools);
384        let instr = ComposedInstructionStrategy::new(
385            StaticPersonaStrategy::new("You are a calculator assistant."),
386            StaticTaskStrategy("Use tools to answer arithmetic questions.".into()),
387            StaticBehaviorStrategy("Reply tersely.".into()),
388        );
389        let skill_strat = StaticSkillStrategy::new(vec![]);
390        let inference: Arc<dyn InferenceClient> = Arc::new(LocalRunnerClient::new(runner, Provider::OpenAi));
391        Agent {
392            id: AgentId::from("a-1"),
393            model: "mock".into(),
394            instructions: instr,
395            tools: tool_strat,
396            memory: mem,
397            skills: skill_strat,
398            inference,
399            bus: EventBus::new(),
400            max_tool_iterations: 3,
401        }
402    }
403
404    #[tokio::test]
405    async fn agent_runs_simple_text_turn() {
406        let runner = MockRunner::new(MockScript::from_text(["the answer is ", "42"]));
407        let agent = build_agent(runner);
408        let r = agent
409            .run_turn(
410                "what's 1+2".into(),
411                AgentBudgets {
412                    tokens: TokenBudget::new(10_000),
413                    time: TimeBudget::new(std::time::Duration::from_secs(30)),
414                    money: MoneyBudget::from_usd(1.0),
415                    iterations: IterationBudget::new(5),
416                },
417            )
418            .await
419            .unwrap();
420        assert!(r.text.contains("42"));
421        assert_eq!(r.usage.output_tokens, 2);
422    }
423
424    // ----- Tool-call loop test --------------------------------------
425
426    use std::sync::Mutex as StdMutex;
427
428    use atomr_infer_core::batch::ExecuteBatch as IBatch;
429    use atomr_infer_core::error::{InferenceError, InferenceResult};
430    use atomr_infer_core::runner::{ModelRunner, RunHandle, SessionRebuildCause};
431    use atomr_infer_core::runtime::{RuntimeKind, TransportKind};
432    use atomr_infer_core::tokens::{FinishReason, TokenChunk, TokenUsage as IUsage};
433    use futures::stream::{self, BoxStream, StreamExt};
434
435    /// Two-step mock: first call returns a tool-call asking calculator(2,3);
436    /// second call returns the final text "answer: 5".
437    struct ToolLoopMock {
438        step: StdMutex<u32>,
439    }
440    impl ToolLoopMock {
441        fn new() -> Self {
442            Self {
443                step: StdMutex::new(0),
444            }
445        }
446    }
447    #[async_trait]
448    impl ModelRunner for ToolLoopMock {
449        async fn execute(&mut self, batch: IBatch) -> InferenceResult<RunHandle> {
450            let mut s = self.step.lock().unwrap();
451            *s += 1;
452            let request_id = batch.request_id.clone();
453            let chunks: Vec<TokenChunk> = if *s == 1 {
454                vec![TokenChunk {
455                    request_id: request_id.clone(),
456                    text_delta: String::new(),
457                    tool_call_delta: Some(serde_json::json!({
458                        "tool_calls": [{
459                            "index": 0,
460                            "id": "call_1",
461                            "type": "function",
462                            "function": {"name": "calculator", "arguments": "{\"a\":2,\"b\":3}"}
463                        }]
464                    })),
465                    usage: Some(IUsage {
466                        input_tokens: 5,
467                        output_tokens: 0,
468                        ..Default::default()
469                    }),
470                    finish_reason: Some(FinishReason::ToolCalls),
471                }]
472            } else {
473                vec![TokenChunk {
474                    request_id: request_id.clone(),
475                    text_delta: "answer: 5".into(),
476                    tool_call_delta: None,
477                    usage: Some(IUsage {
478                        input_tokens: 5,
479                        output_tokens: 3,
480                        ..Default::default()
481                    }),
482                    finish_reason: Some(FinishReason::Stop),
483                }]
484            };
485            let stream: BoxStream<'static, InferenceResult<TokenChunk>> =
486                stream::iter(chunks.into_iter().map(Ok::<_, InferenceError>)).boxed();
487            Ok(RunHandle::streaming(stream))
488        }
489        async fn rebuild_session(&mut self, _: SessionRebuildCause) -> InferenceResult<()> {
490            Ok(())
491        }
492        fn runtime_kind(&self) -> RuntimeKind {
493            RuntimeKind::Custom("tool-loop-mock".into())
494        }
495        fn transport_kind(&self) -> TransportKind {
496            TransportKind::LocalGpu
497        }
498    }
499
500    #[tokio::test]
501    async fn agent_drives_tool_call_loop() {
502        let store = Arc::new(InMemoryStore::new());
503        let mem = RecencyMemoryStrategy::new(store, 5, 30);
504        let tools: Vec<DynTool> = vec![Arc::new(CalculatorTool::new())];
505        let tool_strat = StaticToolStrategy::new(tools);
506        let instr = ComposedInstructionStrategy::new(
507            StaticPersonaStrategy::new("You are a calculator assistant."),
508            StaticTaskStrategy("Use tools.".into()),
509            StaticBehaviorStrategy("Reply tersely.".into()),
510        );
511        let skill_strat = StaticSkillStrategy::new(vec![]);
512        let inference: Arc<dyn InferenceClient> =
513            Arc::new(LocalRunnerClient::new(ToolLoopMock::new(), Provider::OpenAi));
514        let agent: Agent<_, _, _, _> = Agent {
515            id: AgentId::from("a-2"),
516            model: "mock".into(),
517            instructions: instr,
518            tools: tool_strat,
519            memory: mem,
520            skills: skill_strat,
521            inference,
522            bus: EventBus::new(),
523            max_tool_iterations: 3,
524        };
525        let r = agent
526            .run_turn(
527                "what is 2+3".into(),
528                AgentBudgets {
529                    tokens: TokenBudget::new(10_000),
530                    time: TimeBudget::new(std::time::Duration::from_secs(30)),
531                    money: MoneyBudget::from_usd(1.0),
532                    iterations: IterationBudget::new(5),
533                },
534            )
535            .await
536            .unwrap();
537        assert_eq!(r.text, "answer: 5");
538        assert_eq!(r.finish_reason, Some(FinishReason::Stop));
539    }
540}