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