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> {
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
73pub(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 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 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 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 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 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 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 if r.tool_calls.is_empty()
193 || r.finish_reason != Some(atomr_infer_core::tokens::FinishReason::ToolCalls)
194 {
195 break;
196 }
197 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 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 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 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 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}