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                extensions: Default::default(),
220            };
221            let handle = tool_ref.handle.clone();
222            let name = call.name.clone();
223            let args_for_task = args.clone();
224            handles.push(tokio::spawn(async move {
225                let t0 = Instant::now();
226                let result = handle.call(args_for_task.clone(), invoke_ctx).await?;
227                Ok::<_, AgentError>((
228                    idx,
229                    name,
230                    result,
231                    hash_value(&args_for_task),
232                    t0.elapsed().as_millis() as u64,
233                ))
234            }));
235        }
236        let mut results: Vec<(usize, String, Json::Value, u64, u64)> = Vec::with_capacity(handles.len());
237        for h in handles {
238            let pair = h.await.map_err(|e| AgentError::Internal(e.to_string()))??;
239            results.push(pair);
240        }
241        results.sort_by_key(|(i, _, _, _, _)| *i);
242        for (_, name, result, args_hash, elapsed_ms) in results {
243            bus.emit(Event::ToolInvoked {
244                tool_id: ToolId::from(name.as_str()),
245                args_hash,
246                elapsed_ms,
247                ok: true,
248            });
249            messages.push(InferMsg {
250                role: Role::Tool,
251                content: MessageContent::Text(serde_json::to_string(&result).unwrap_or_default()),
252            });
253        }
254    }
255
256    // 6. Memory store.
257    let item = MemoryItem {
258        id: format!("turn-{}", uuid_str()),
259        kind: MemoryKind::Episodic,
260        namespace: MemoryNamespace::Agent(id.clone()),
261        payload: serde_json::json!({"user": user, "assistant": final_text}),
262        timestamp_ms: chrono::Utc::now().timestamp_millis(),
263        tags: vec![],
264    };
265    memory.store(item).await.ok();
266
267    // 7. Emit AgentTurn event.
268    bus.emit(Event::AgentTurn {
269        agent_id: id.clone(),
270        input_tokens: final_usage.input_tokens,
271        output_tokens: final_usage.output_tokens,
272        reasoning_tokens: final_usage.reasoning_tokens,
273        cached_tokens: final_usage.cached_tokens,
274        finish_reason: final_finish,
275        elapsed_ms: start.elapsed().as_millis() as u64,
276    });
277
278    Ok(TurnResult {
279        text: final_text,
280        usage: final_usage,
281        finish_reason: final_finish,
282        tool_calls: all_tool_calls,
283    })
284}
285
286#[async_trait]
287impl<I, T, Ms, Sk> AgentDispatch for Agent<I, T, Ms, Sk>
288where
289    I: InstructionStrategy,
290    T: ToolStrategy,
291    Ms: MemoryStrategy,
292    Sk: SkillStrategy,
293{
294    async fn dispatch(&self, user: String, ctx: CallCtx) -> Result<TurnResult> {
295        self.run_turn(
296            user,
297            AgentBudgets {
298                tokens: ctx.tokens,
299                time: ctx.time,
300                money: ctx.money,
301                iterations: ctx.iterations,
302            },
303        )
304        .await
305    }
306}
307
308#[derive(Debug, Clone, Copy)]
309pub struct AgentBudgets {
310    pub tokens: TokenBudget,
311    pub time: TimeBudget,
312    pub money: MoneyBudget,
313    pub iterations: IterationBudget,
314}
315
316fn uuid_str() -> String {
317    use std::sync::atomic::{AtomicU64, Ordering};
318    static N: AtomicU64 = AtomicU64::new(0);
319    format!("{:016x}", N.fetch_add(1, Ordering::Relaxed))
320}
321
322fn hash_value(v: &Json::Value) -> u64 {
323    use std::hash::{Hash, Hasher};
324    let mut h = std::collections::hash_map::DefaultHasher::new();
325    serde_json::to_string(v).unwrap_or_default().hash(&mut h);
326    h.finish()
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    use std::sync::Arc;
334
335    use async_trait::async_trait;
336    use atomr_agents_core::{InvokeCtx, Value};
337    use atomr_agents_instruction::{ComposedInstructionStrategy, StaticBehaviorStrategy, StaticTaskStrategy};
338    use atomr_agents_memory::{InMemoryStore, RecencyMemoryStrategy};
339    use atomr_agents_persona::StaticPersonaStrategy;
340    use atomr_agents_skill::StaticSkillStrategy;
341    use atomr_agents_tool::{DynTool, Provider, StaticToolStrategy, Tool, ToolDescriptor, ToolSchema};
342
343    use crate::inference::LocalRunnerClient;
344    use atomr_infer_testkit::{MockRunner, MockScript};
345
346    struct CalculatorTool {
347        d: ToolDescriptor,
348    }
349    impl CalculatorTool {
350        fn new() -> Self {
351            Self {
352                d: ToolDescriptor {
353                    id: ToolId::from("calculator"),
354                    name: "calculator".into(),
355                    description: "evaluate simple arithmetic".into(),
356                    schema: ToolSchema::empty_object(),
357                },
358            }
359        }
360    }
361    #[async_trait]
362    impl Tool for CalculatorTool {
363        fn descriptor(&self) -> &ToolDescriptor {
364            &self.d
365        }
366        async fn invoke(&self, args: Value, _ctx: &InvokeCtx) -> Result<Value> {
367            let a = args.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
368            let b = args.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
369            Ok(serde_json::json!({"sum": a + b}))
370        }
371    }
372
373    fn build_agent(
374        runner: MockRunner,
375    ) -> Agent<
376        ComposedInstructionStrategy<StaticPersonaStrategy, StaticTaskStrategy, StaticBehaviorStrategy>,
377        StaticToolStrategy,
378        RecencyMemoryStrategy,
379        StaticSkillStrategy,
380    > {
381        let store = Arc::new(InMemoryStore::new());
382        let mem = RecencyMemoryStrategy::new(store, 5, 30);
383        let tools: Vec<DynTool> = vec![Arc::new(CalculatorTool::new())];
384        let tool_strat = StaticToolStrategy::new(tools);
385        let instr = ComposedInstructionStrategy::new(
386            StaticPersonaStrategy::new("You are a calculator assistant."),
387            StaticTaskStrategy("Use tools to answer arithmetic questions.".into()),
388            StaticBehaviorStrategy("Reply tersely.".into()),
389        );
390        let skill_strat = StaticSkillStrategy::new(vec![]);
391        let inference: Arc<dyn InferenceClient> = Arc::new(LocalRunnerClient::new(runner, Provider::OpenAi));
392        Agent {
393            id: AgentId::from("a-1"),
394            model: "mock".into(),
395            instructions: instr,
396            tools: tool_strat,
397            memory: mem,
398            skills: skill_strat,
399            inference,
400            bus: EventBus::new(),
401            max_tool_iterations: 3,
402        }
403    }
404
405    #[tokio::test]
406    async fn agent_runs_simple_text_turn() {
407        let runner = MockRunner::new(MockScript::from_text(["the answer is ", "42"]));
408        let agent = build_agent(runner);
409        let r = agent
410            .run_turn(
411                "what's 1+2".into(),
412                AgentBudgets {
413                    tokens: TokenBudget::new(10_000),
414                    time: TimeBudget::new(std::time::Duration::from_secs(30)),
415                    money: MoneyBudget::from_usd(1.0),
416                    iterations: IterationBudget::new(5),
417                },
418            )
419            .await
420            .unwrap();
421        assert!(r.text.contains("42"));
422        assert_eq!(r.usage.output_tokens, 2);
423    }
424
425    // ----- Tool-call loop test --------------------------------------
426
427    use std::sync::Mutex as StdMutex;
428
429    use atomr_infer_core::batch::ExecuteBatch as IBatch;
430    use atomr_infer_core::error::{InferenceError, InferenceResult};
431    use atomr_infer_core::runner::{ModelRunner, RunHandle, SessionRebuildCause};
432    use atomr_infer_core::runtime::{RuntimeKind, TransportKind};
433    use atomr_infer_core::tokens::{FinishReason, TokenChunk, TokenUsage as IUsage};
434    use futures::stream::{self, BoxStream, StreamExt};
435
436    /// Two-step mock: first call returns a tool-call asking calculator(2,3);
437    /// second call returns the final text "answer: 5".
438    struct ToolLoopMock {
439        step: StdMutex<u32>,
440    }
441    impl ToolLoopMock {
442        fn new() -> Self {
443            Self {
444                step: StdMutex::new(0),
445            }
446        }
447    }
448    #[async_trait]
449    impl ModelRunner for ToolLoopMock {
450        async fn execute(&mut self, batch: IBatch) -> InferenceResult<RunHandle> {
451            let mut s = self.step.lock().unwrap();
452            *s += 1;
453            let request_id = batch.request_id.clone();
454            let chunks: Vec<TokenChunk> = if *s == 1 {
455                vec![TokenChunk {
456                    request_id: request_id.clone(),
457                    text_delta: String::new(),
458                    tool_call_delta: Some(serde_json::json!({
459                        "tool_calls": [{
460                            "index": 0,
461                            "id": "call_1",
462                            "type": "function",
463                            "function": {"name": "calculator", "arguments": "{\"a\":2,\"b\":3}"}
464                        }]
465                    })),
466                    usage: Some(IUsage {
467                        input_tokens: 5,
468                        output_tokens: 0,
469                        ..Default::default()
470                    }),
471                    finish_reason: Some(FinishReason::ToolCalls),
472                }]
473            } else {
474                vec![TokenChunk {
475                    request_id: request_id.clone(),
476                    text_delta: "answer: 5".into(),
477                    tool_call_delta: None,
478                    usage: Some(IUsage {
479                        input_tokens: 5,
480                        output_tokens: 3,
481                        ..Default::default()
482                    }),
483                    finish_reason: Some(FinishReason::Stop),
484                }]
485            };
486            let stream: BoxStream<'static, InferenceResult<TokenChunk>> =
487                stream::iter(chunks.into_iter().map(Ok::<_, InferenceError>)).boxed();
488            Ok(RunHandle::streaming(stream))
489        }
490        async fn rebuild_session(&mut self, _: SessionRebuildCause) -> InferenceResult<()> {
491            Ok(())
492        }
493        fn runtime_kind(&self) -> RuntimeKind {
494            RuntimeKind::Custom("tool-loop-mock".into())
495        }
496        fn transport_kind(&self) -> TransportKind {
497            TransportKind::LocalGpu
498        }
499    }
500
501    #[tokio::test]
502    async fn agent_drives_tool_call_loop() {
503        let store = Arc::new(InMemoryStore::new());
504        let mem = RecencyMemoryStrategy::new(store, 5, 30);
505        let tools: Vec<DynTool> = vec![Arc::new(CalculatorTool::new())];
506        let tool_strat = StaticToolStrategy::new(tools);
507        let instr = ComposedInstructionStrategy::new(
508            StaticPersonaStrategy::new("You are a calculator assistant."),
509            StaticTaskStrategy("Use tools.".into()),
510            StaticBehaviorStrategy("Reply tersely.".into()),
511        );
512        let skill_strat = StaticSkillStrategy::new(vec![]);
513        let inference: Arc<dyn InferenceClient> =
514            Arc::new(LocalRunnerClient::new(ToolLoopMock::new(), Provider::OpenAi));
515        let agent: Agent<_, _, _, _> = Agent {
516            id: AgentId::from("a-2"),
517            model: "mock".into(),
518            instructions: instr,
519            tools: tool_strat,
520            memory: mem,
521            skills: skill_strat,
522            inference,
523            bus: EventBus::new(),
524            max_tool_iterations: 3,
525        };
526        let r = agent
527            .run_turn(
528                "what is 2+3".into(),
529                AgentBudgets {
530                    tokens: TokenBudget::new(10_000),
531                    time: TimeBudget::new(std::time::Duration::from_secs(30)),
532                    money: MoneyBudget::from_usd(1.0),
533                    iterations: IterationBudget::new(5),
534                },
535            )
536            .await
537            .unwrap();
538        assert_eq!(r.text, "answer: 5");
539        assert_eq!(r.finish_reason, Some(FinishReason::Stop));
540    }
541}