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 };
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 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 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 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 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}