1use 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
20pub 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 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 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 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 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 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 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 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 if r.tool_calls.is_empty()
154 || r.finish_reason != Some(atomr_infer_core::tokens::FinishReason::ToolCalls)
155 {
156 break;
157 }
158 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 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 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 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 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}