1use std::sync::Arc;
17use std::time::Duration;
18
19use serde::{Deserialize, Serialize};
20use tokio::time::Instant;
21
22use crate::client::{
23 ChatMessage, ChatRequest, ChatResponse, ChatRole, ContentPart, LlmClient, TokenUsage,
24};
25use crate::config::AgentConfig;
26use crate::error::{AgentError, ErrorKind};
27use crate::hooks::{AgentHooks, HookAction, RunInfo, ToolOutput};
28use crate::ids::{RunId, SessionId};
29use crate::loop_guard::{LoopGuard, LoopTracker, LoopVerdict, warning_note};
30use crate::memory::{MemoryScope, MemoryStore, MemoryTool, render_snapshot};
31use crate::policy::{AllowAll, ToolDecision, ToolPolicy};
32use crate::proactive::{Delivery, FollowupTool, LogDelivery};
33use crate::session::{Compaction, CompactionReport, SessionStore, compact};
34use crate::skills::{Skill, SkillTool, render_index};
35use crate::tools::{Tool, ToolCall, ToolContext};
36
37#[must_use]
44pub fn estimate_tokens(text: &str) -> u32 {
45 u32::try_from(text.len() / 4).unwrap_or(u32::MAX)
49}
50
51#[must_use]
53pub fn estimate_messages(messages: &[ChatMessage]) -> u32 {
54 messages
55 .iter()
56 .map(|message| {
57 message
58 .content
59 .iter()
60 .map(|part| match part {
61 ContentPart::Text(text) => estimate_tokens(text),
62 ContentPart::ToolCall {
63 name, arguments, ..
64 } => estimate_tokens(name)
65 .saturating_add(estimate_tokens(&arguments.to_string())),
66 ContentPart::ToolResult { content, .. } => estimate_tokens(content),
67 })
68 .fold(0u32, u32::saturating_add)
69 })
70 .fold(0u32, u32::saturating_add)
71 .saturating_add(
72 u32::try_from(messages.len())
73 .unwrap_or(u32::MAX)
74 .saturating_mul(4),
75 ) }
77
78#[must_use]
91pub fn truncate_history(messages: &[ChatMessage], budget: u32) -> Vec<ChatMessage> {
92 let cost = |message: &ChatMessage| estimate_messages(std::slice::from_ref(message));
93 let mut kept: Vec<usize> = Vec::new();
94 let mut used: u32 = 0;
95 for (index, message) in messages.iter().enumerate() {
96 if message.role == ChatRole::System {
97 used = used.saturating_add(cost(message));
98 kept.push(index);
99 }
100 }
101 let anchor = messages
103 .iter()
104 .position(|message| message.role != ChatRole::System)
105 .filter(|&index| {
106 messages
107 .get(index)
108 .is_some_and(|message| message.role == ChatRole::User)
109 })
110 .filter(|&index| {
111 messages
112 .get(index)
113 .is_some_and(|message| used.saturating_add(cost(message)) <= budget)
114 });
115 if let Some(index) = anchor
116 && let Some(message) = messages.get(index)
117 {
118 used = used.saturating_add(cost(message));
119 kept.push(index);
120 }
121 let mut tail: Vec<usize> = Vec::new();
122 for (index, message) in messages.iter().enumerate().rev() {
123 if message.role == ChatRole::System || Some(index) == anchor {
124 continue;
125 }
126 let message_cost = cost(message);
127 if used.saturating_add(message_cost) <= budget {
128 used = used.saturating_add(message_cost);
129 tail.push(index);
130 } else {
131 break;
132 }
133 }
134 tail.reverse();
135 let orphans = tail
138 .iter()
139 .take_while(|&&index| {
140 messages
141 .get(index)
142 .is_some_and(|message| message.role == ChatRole::Tool)
143 })
144 .count();
145 tail.drain(..orphans);
146 kept.extend(tail);
147 kept.sort_unstable();
148 kept.dedup();
149 kept.into_iter()
150 .filter_map(|index| messages.get(index).cloned())
151 .collect()
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157#[non_exhaustive]
158pub enum BudgetKind {
159 Steps,
161 Tokens,
163 Deadline,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(tag = "status", rename_all = "snake_case")]
170pub enum PendingStatus {
171 Allowed,
173 NeedsApproval {
175 reason: String,
177 },
178 Denied {
180 reason: String,
182 },
183}
184
185#[allow(clippy::derive_partial_eq_without_eq)]
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct PendingCall {
189 pub call: ToolCall,
191 pub status: PendingStatus,
193}
194
195#[allow(clippy::derive_partial_eq_without_eq)]
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(tag = "verdict", rename_all = "snake_case")]
199pub enum Approval {
200 Approve,
202 Edit {
204 arguments: serde_json::Value,
206 },
207 Reject {
209 reason: String,
211 },
212}
213
214#[allow(clippy::derive_partial_eq_without_eq)]
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct ApprovalDecision {
218 pub call_id: String,
220 pub approval: Approval,
222}
223
224impl ApprovalDecision {
225 #[must_use]
227 pub fn approve(call_id: impl Into<String>) -> Self {
228 Self {
229 call_id: call_id.into(),
230 approval: Approval::Approve,
231 }
232 }
233
234 #[must_use]
236 pub fn reject(call_id: impl Into<String>, reason: impl Into<String>) -> Self {
237 Self {
238 call_id: call_id.into(),
239 approval: Approval::Reject {
240 reason: reason.into(),
241 },
242 }
243 }
244}
245
246#[allow(clippy::derive_partial_eq_without_eq)]
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct RunState {
255 pub run_id: RunId,
257 pub session_id: Option<SessionId>,
259 pub messages: Vec<ChatMessage>,
261 pub usage: TokenUsage,
263 pub steps_used: u32,
265 pub pending: Vec<PendingCall>,
267 #[serde(default)]
269 pub recent_calls: Vec<u64>,
270}
271
272impl RunState {
273 pub fn awaiting(&self) -> impl Iterator<Item = &PendingCall> {
275 self.pending
276 .iter()
277 .filter(|pending| matches!(pending.status, PendingStatus::NeedsApproval { .. }))
278 }
279}
280
281#[allow(clippy::derive_partial_eq_without_eq)]
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(tag = "status", rename_all = "snake_case")]
288#[non_exhaustive]
289pub enum AgentOutcome {
290 Completed {
292 text: String,
294 steps_used: u32,
296 usage: TokenUsage,
298 },
299 BudgetExhausted {
301 reason: BudgetKind,
303 steps_used: u32,
305 usage: TokenUsage,
307 },
308 LoopDetected {
310 tool: String,
312 repeats: u32,
314 steps_used: u32,
316 usage: TokenUsage,
318 },
319 AwaitingApproval {
321 state: Box<RunState>,
323 },
324}
325
326impl AgentOutcome {
327 #[must_use]
329 pub fn steps_used(&self) -> u32 {
330 match self {
331 Self::Completed { steps_used, .. }
332 | Self::BudgetExhausted { steps_used, .. }
333 | Self::LoopDetected { steps_used, .. } => *steps_used,
334 Self::AwaitingApproval { state } => state.steps_used,
335 }
336 }
337
338 #[must_use]
340 pub fn usage(&self) -> TokenUsage {
341 match self {
342 Self::Completed { usage, .. }
343 | Self::BudgetExhausted { usage, .. }
344 | Self::LoopDetected { usage, .. } => *usage,
345 Self::AwaitingApproval { state } => state.usage,
346 }
347 }
348
349 #[must_use]
351 pub const fn is_completed(&self) -> bool {
352 matches!(self, Self::Completed { .. })
353 }
354
355 #[must_use]
357 pub fn text(&self) -> Option<&str> {
358 match self {
359 Self::Completed { text, .. } => Some(text),
360 _ => None,
361 }
362 }
363}
364
365#[derive(Debug, Clone, PartialEq)]
367pub struct AgentTurn {
368 pub outcome: AgentOutcome,
370 pub messages: Vec<ChatMessage>,
373 pub compaction: Option<CompactionReport>,
375}
376
377#[derive(Debug, Clone)]
379enum Gate {
380 Allowed,
381 Approved(Option<serde_json::Value>),
382 Denied(String),
383 Rejected(String),
384}
385
386#[derive(Debug)]
388struct LoopState {
389 run_id: RunId,
390 session_id: Option<SessionId>,
391 messages: Vec<ChatMessage>,
392 usage: TokenUsage,
393 steps: u32,
394 tracker: LoopTracker,
395}
396
397#[derive(Debug)]
399struct Prepared {
400 system: Option<String>,
401 tools: Vec<Arc<dyn Tool>>,
402}
403
404#[derive(Clone)]
417pub struct Agent {
418 client: Arc<dyn LlmClient>,
419 tools: Vec<Arc<dyn Tool>>,
420 system_prompt: Option<String>,
421 max_steps: u32,
422 max_tokens: u32,
423 per_tool_output_limit: usize,
424 temperature: Option<f32>,
425 hooks: Vec<Arc<dyn AgentHooks>>,
426 policy: Arc<dyn ToolPolicy>,
427 loop_guard: LoopGuard,
428 max_duration: Option<Duration>,
429 parallel_tool_calls: bool,
430 memory: Option<(Arc<dyn MemoryStore>, MemoryScope)>,
431 skills: Arc<[Skill]>,
432 compaction: Option<Compaction>,
433 run_id: Option<RunId>,
434}
435
436impl std::fmt::Debug for Agent {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 f.debug_struct("Agent")
439 .field("client", &self.client)
440 .field(
441 "tools",
442 &self
443 .tools
444 .iter()
445 .map(|tool| tool.name().to_owned())
446 .collect::<Vec<_>>(),
447 )
448 .field("max_steps", &self.max_steps)
449 .field("max_tokens", &self.max_tokens)
450 .field("policy", &self.policy)
451 .field("loop_guard", &self.loop_guard)
452 .field("max_duration", &self.max_duration)
453 .field("memory", &self.memory.as_ref().map(|(_, scope)| scope))
454 .field("skills", &self.skills.len())
455 .finish_non_exhaustive()
456 }
457}
458
459impl Agent {
460 const MIN_USEFUL_REMAINING: u32 = 512;
462 const MAX_CALL_TOKENS: u32 = 4096;
464
465 #[must_use]
467 pub fn new(client: Arc<dyn LlmClient>) -> Self {
468 Self {
469 client,
470 tools: Vec::new(),
471 system_prompt: None,
472 max_steps: 10,
473 max_tokens: 32_000,
474 per_tool_output_limit: 8_000,
475 temperature: None,
476 hooks: Vec::new(),
477 policy: Arc::new(AllowAll),
478 loop_guard: LoopGuard::default(),
479 max_duration: None,
480 parallel_tool_calls: true,
481 memory: None,
482 skills: Arc::from(Vec::new()),
483 compaction: None,
484 run_id: None,
485 }
486 }
487
488 #[must_use]
490 pub fn tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
491 self.tools = tools;
492 self
493 }
494
495 #[must_use]
497 pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
498 self.tools.push(tool);
499 self
500 }
501
502 #[must_use]
504 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
505 self.system_prompt = Some(prompt.into());
506 self
507 }
508
509 #[must_use]
515 pub const fn max_steps(mut self, max_steps: u32) -> Self {
516 self.max_steps = max_steps;
517 self
518 }
519
520 #[must_use]
522 pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
523 self.max_tokens = max_tokens;
524 self
525 }
526
527 #[must_use]
531 pub const fn max_duration(mut self, limit: Duration) -> Self {
532 self.max_duration = Some(limit);
533 self
534 }
535
536 #[must_use]
540 pub const fn per_tool_output_limit(mut self, limit: usize) -> Self {
541 self.per_tool_output_limit = limit;
542 self
543 }
544
545 #[must_use]
547 pub const fn temperature(mut self, temperature: f32) -> Self {
548 self.temperature = Some(temperature);
549 self
550 }
551
552 #[must_use]
554 pub fn hook(mut self, hook: Arc<dyn AgentHooks>) -> Self {
555 self.hooks.push(hook);
556 self
557 }
558
559 #[must_use]
562 pub fn policy(mut self, policy: Arc<dyn ToolPolicy>) -> Self {
563 self.policy = policy;
564 self
565 }
566
567 #[must_use]
569 pub const fn loop_guard(mut self, guard: LoopGuard) -> Self {
570 self.loop_guard = guard;
571 self
572 }
573
574 #[must_use]
576 pub const fn parallel_tool_calls(mut self, parallel: bool) -> Self {
577 self.parallel_tool_calls = parallel;
578 self
579 }
580
581 #[must_use]
584 pub fn memory(mut self, store: Arc<dyn MemoryStore>, scope: MemoryScope) -> Self {
585 self.memory = Some((store, scope));
586 self
587 }
588
589 #[must_use]
592 pub fn skills(mut self, skills: impl Into<Arc<[Skill]>>) -> Self {
593 self.skills = skills.into();
594 self
595 }
596
597 #[must_use]
600 pub const fn compaction(mut self, compaction: Compaction) -> Self {
601 self.compaction = Some(compaction);
602 self
603 }
604
605 #[must_use]
608 pub fn run_id(mut self, run_id: RunId) -> Self {
609 self.run_id = Some(run_id);
610 self
611 }
612
613 pub async fn run(&self, user_input: &str) -> Result<AgentOutcome, AgentError> {
622 Ok(self.run_turn(Vec::new(), user_input).await?.outcome)
623 }
624
625 pub async fn run_turn(
634 &self,
635 history: Vec<ChatMessage>,
636 user_input: &str,
637 ) -> Result<AgentTurn, AgentError> {
638 self.start(history, user_input, None).await
639 }
640
641 pub async fn run_in_session(
654 &self,
655 store: &dyn SessionStore,
656 session_id: &SessionId,
657 user_input: &str,
658 ) -> Result<AgentTurn, AgentError> {
659 let mut history = store.load(session_id).await?;
660 let compaction = match &self.compaction {
661 Some(settings) => compact(self.client.as_ref(), &mut history, settings).await?,
662 None => None,
663 };
664 let mut turn = self
665 .start(history, user_input, Some(session_id.clone()))
666 .await?;
667 if !matches!(turn.outcome, AgentOutcome::AwaitingApproval { .. }) {
668 store.save(session_id, &turn.messages).await?;
669 }
670 turn.compaction = compaction;
671 Ok(turn)
672 }
673
674 pub async fn resume(
685 &self,
686 state: RunState,
687 decisions: Vec<ApprovalDecision>,
688 ) -> Result<AgentTurn, AgentError> {
689 for decision in &decisions {
690 if !state
691 .awaiting()
692 .any(|pending| pending.call.id == decision.call_id)
693 {
694 return Err(AgentError::new(
695 ErrorKind::Config,
696 format!(
697 "approval decision names call {:?}, which is not waiting for approval",
698 decision.call_id
699 ),
700 ));
701 }
702 }
703 let prepared = self.prepare().await?;
704 let deadline = self.deadline();
705 let gates: Vec<(ToolCall, Gate)> = state
706 .pending
707 .iter()
708 .map(|pending| {
709 let gate = match &pending.status {
710 PendingStatus::Allowed => Gate::Allowed,
711 PendingStatus::Denied { reason } => Gate::Denied(reason.clone()),
712 PendingStatus::NeedsApproval { .. } => decisions
713 .iter()
714 .find(|decision| decision.call_id == pending.call.id)
715 .map_or_else(
716 || Gate::Rejected("no decision was given".to_owned()),
717 |decision| match &decision.approval {
718 Approval::Approve => Gate::Approved(None),
719 Approval::Edit { arguments } => {
720 Gate::Approved(Some(arguments.clone()))
721 }
722 Approval::Reject { reason } => Gate::Rejected(reason.clone()),
723 },
724 ),
725 };
726 (pending.call.clone(), gate)
727 })
728 .collect();
729 let mut state_now = LoopState {
730 run_id: state.run_id,
731 session_id: state.session_id,
732 messages: state.messages,
733 usage: state.usage,
734 steps: state.steps_used,
735 tracker: LoopTracker::from_saved(state.recent_calls),
736 };
737 let stopped = self
738 .execute_round(&prepared, &mut state_now, gates, deadline)
739 .await;
740 let outcome = match stopped {
741 Some(outcome) => outcome,
742 None => self.drive(&prepared, &mut state_now, deadline).await?,
743 };
744 Ok(self.finish(outcome, state_now).await)
745 }
746
747 pub async fn resume_in_session(
753 &self,
754 store: &dyn SessionStore,
755 state: RunState,
756 decisions: Vec<ApprovalDecision>,
757 ) -> Result<AgentTurn, AgentError> {
758 let session_id = state.session_id.clone();
759 let turn = self.resume(state, decisions).await?;
760 if let Some(session_id) = session_id
761 && !matches!(turn.outcome, AgentOutcome::AwaitingApproval { .. })
762 {
763 store.save(&session_id, &turn.messages).await?;
764 }
765 Ok(turn)
766 }
767
768 fn deadline(&self) -> Option<Instant> {
769 self.max_duration.map(|limit| Instant::now() + limit)
770 }
771
772 async fn start(
774 &self,
775 mut history: Vec<ChatMessage>,
776 user_input: &str,
777 session_id: Option<SessionId>,
778 ) -> Result<AgentTurn, AgentError> {
779 let prepared = self.prepare().await?;
780 let deadline = self.deadline();
781 history.retain(|message| message.role != ChatRole::System);
782 history.push(ChatMessage::text(ChatRole::User, user_input));
783 let mut state = LoopState {
784 run_id: self.run_id.clone().unwrap_or_else(RunId::generate),
785 session_id,
786 messages: history,
787 usage: TokenUsage::default(),
788 steps: 0,
789 tracker: LoopTracker::default(),
790 };
791 let outcome = self.drive(&prepared, &mut state, deadline).await?;
792 Ok(self.finish(outcome, state).await)
793 }
794
795 async fn prepare(&self) -> Result<Prepared, AgentError> {
798 let mut sections: Vec<String> = Vec::new();
799 if let Some(system) = &self.system_prompt {
800 sections.push(system.clone());
801 }
802 let mut tools = self.tools.clone();
803 if !self.skills.is_empty() {
804 sections.push(render_index(&self.skills));
805 tools.push(Arc::new(SkillTool::new(Arc::clone(&self.skills))));
806 }
807 if let Some((store, scope)) = &self.memory {
808 let blocks = store.load(scope).await?;
809 sections.push(render_snapshot(&blocks));
810 tools.push(Arc::new(MemoryTool::new(Arc::clone(store), scope.clone())));
811 }
812 let system = if sections.is_empty() {
813 None
814 } else {
815 Some(sections.join("\n\n"))
816 };
817 Ok(Prepared { system, tools })
818 }
819
820 fn info(&self, state: &LoopState) -> RunInfo {
821 RunInfo {
822 run_id: state.run_id.clone(),
823 session_id: state.session_id.clone(),
824 steps_used: state.steps,
825 max_steps: self.max_steps,
826 usage: state.usage,
827 }
828 }
829
830 async fn finish(&self, outcome: AgentOutcome, state: LoopState) -> AgentTurn {
832 let info = self.info(&state);
833 for hook in &self.hooks {
834 hook.on_outcome(&outcome, &info).await;
835 }
836 AgentTurn {
837 outcome,
838 messages: state.messages,
839 compaction: None,
840 }
841 }
842
843 fn check_budgets(&self, steps: u32, usage: TokenUsage) -> Result<u32, AgentOutcome> {
848 let exhausted = |reason| AgentOutcome::BudgetExhausted {
849 reason,
850 steps_used: steps,
851 usage,
852 };
853 if steps >= self.max_steps {
854 return Err(exhausted(BudgetKind::Steps));
855 }
856 let spent = usage.total();
857 if spent >= self.max_tokens {
858 return Err(exhausted(BudgetKind::Tokens));
859 }
860 let remaining = self.max_tokens - spent;
861 if remaining < Self::MIN_USEFUL_REMAINING {
862 return Err(exhausted(BudgetKind::Tokens));
863 }
864 Ok(remaining)
865 }
866
867 async fn drive(
869 &self,
870 prepared: &Prepared,
871 state: &mut LoopState,
872 deadline: Option<Instant>,
873 ) -> Result<AgentOutcome, AgentError> {
874 loop {
875 let exhausted = |reason, state: &LoopState| AgentOutcome::BudgetExhausted {
876 reason,
877 steps_used: state.steps,
878 usage: state.usage,
879 };
880 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
881 return Ok(exhausted(BudgetKind::Deadline, state));
882 }
883 let remaining = match self.check_budgets(state.steps, state.usage) {
884 Ok(remaining) => remaining,
885 Err(outcome) => return Ok(outcome),
886 };
887 let mut full = Vec::with_capacity(state.messages.len().saturating_add(1));
888 if let Some(system) = &prepared.system {
889 full.push(ChatMessage::text(ChatRole::System, system.clone()));
890 }
891 full.extend(state.messages.iter().cloned());
892 let history = truncate_history(&full, remaining);
893 let history_tokens = estimate_messages(&history);
894 let has_turns = history
895 .iter()
896 .any(|message| message.role != ChatRole::System);
897 if history_tokens > remaining || !has_turns {
898 return Ok(exhausted(BudgetKind::Tokens, state));
899 }
900 let output_budget = remaining.saturating_sub(history_tokens);
903 if output_budget == 0 {
904 return Ok(exhausted(BudgetKind::Tokens, state));
905 }
906 let mut request = ChatRequest {
907 messages: history,
908 tools: prepared
909 .tools
910 .iter()
911 .map(|tool| tool.definition())
912 .collect(),
913 max_tokens: Some(output_budget.min(Self::MAX_CALL_TOKENS)),
914 temperature: self.temperature,
915 };
916 let info = self.info(state);
917 for hook in &self.hooks {
918 hook.before_model(&mut request, &info).await;
919 }
920 tracing::debug!(
921 run_id = %state.run_id,
922 step = state.steps,
923 remaining_tokens = remaining,
924 "agent loop calling model"
925 );
926 let response = match deadline {
927 Some(deadline) => {
928 match tokio::time::timeout_at(deadline, self.client.chat(&request)).await {
929 Ok(response) => response?,
930 Err(_) => return Ok(exhausted(BudgetKind::Deadline, state)),
931 }
932 }
933 None => self.client.chat(&request).await?,
934 };
935 for hook in &self.hooks {
936 hook.after_model(&response, &info).await;
937 }
938 let calls = match self.fold_response(state, response) {
939 Ok(calls) => calls,
940 Err(outcome) => return Ok(outcome),
941 };
942 let (gates, pending) = self.gate_calls(prepared, state, calls).await;
943 if pending
944 .iter()
945 .any(|pending| matches!(pending.status, PendingStatus::NeedsApproval { .. }))
946 {
947 tracing::info!(run_id = %state.run_id, "agent run paused for approval");
948 return Ok(AgentOutcome::AwaitingApproval {
949 state: Box::new(RunState {
950 run_id: state.run_id.clone(),
951 session_id: state.session_id.clone(),
952 messages: state.messages.clone(),
953 usage: state.usage,
954 steps_used: state.steps,
955 pending,
956 recent_calls: state.tracker.saved(),
957 }),
958 });
959 }
960 if let Some(outcome) = self.execute_round(prepared, state, gates, deadline).await {
961 return Ok(outcome);
962 }
963 }
964 }
965
966 async fn gate_calls(
971 &self,
972 prepared: &Prepared,
973 state: &LoopState,
974 calls: Vec<ToolCall>,
975 ) -> (Vec<(ToolCall, Gate)>, Vec<PendingCall>) {
976 let info = self.info(state);
977 let mut gates = Vec::with_capacity(calls.len());
978 let mut pending = Vec::with_capacity(calls.len());
979 for call in calls {
980 let tool = prepared.tools.iter().find(|tool| tool.name() == call.name);
981 let decision = self
982 .policy
983 .decide(&call, tool.map(AsRef::as_ref), &info)
984 .await;
985 let (status, gate) = match decision {
986 ToolDecision::Allow => (PendingStatus::Allowed, Gate::Allowed),
987 ToolDecision::RequireApproval { reason } => {
988 (PendingStatus::NeedsApproval { reason }, Gate::Allowed)
989 }
990 ToolDecision::Deny { reason } => (
991 PendingStatus::Denied {
992 reason: reason.clone(),
993 },
994 Gate::Denied(reason),
995 ),
996 };
997 pending.push(PendingCall {
998 call: call.clone(),
999 status,
1000 });
1001 gates.push((call, gate));
1002 }
1003 (gates, pending)
1004 }
1005
1006 fn fold_response(
1013 &self,
1014 state: &mut LoopState,
1015 response: ChatResponse,
1016 ) -> Result<Vec<ToolCall>, AgentOutcome> {
1017 state.usage = state.usage.saturating_add(response.usage);
1018 let calls: Vec<ToolCall> = response
1019 .content
1020 .iter()
1021 .filter_map(|part| match part {
1022 ContentPart::ToolCall {
1023 id,
1024 name,
1025 arguments,
1026 } => Some(ToolCall {
1027 id: id.clone(),
1028 name: name.clone(),
1029 arguments: arguments.clone(),
1030 }),
1031 ContentPart::Text(_) | ContentPart::ToolResult { .. } => None,
1032 })
1033 .collect();
1034 let overshoot = state.usage.total() > self.max_tokens;
1035 if calls.is_empty() {
1036 let text = response
1037 .content
1038 .iter()
1039 .filter_map(|part| match part {
1040 ContentPart::Text(text) => Some(text.as_str()),
1041 ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } => None,
1042 })
1043 .collect::<Vec<_>>()
1044 .join("");
1045 state.messages.push(ChatMessage {
1046 role: ChatRole::Assistant,
1047 content: response.content,
1048 });
1049 return Err(if overshoot {
1050 AgentOutcome::BudgetExhausted {
1051 reason: BudgetKind::Tokens,
1052 steps_used: state.steps,
1053 usage: state.usage,
1054 }
1055 } else {
1056 AgentOutcome::Completed {
1057 text,
1058 steps_used: state.steps,
1059 usage: state.usage,
1060 }
1061 });
1062 }
1063 if overshoot {
1064 return Err(AgentOutcome::BudgetExhausted {
1067 reason: BudgetKind::Tokens,
1068 steps_used: state.steps,
1069 usage: state.usage,
1070 });
1071 }
1072 state.steps += 1;
1073 state.messages.push(ChatMessage {
1074 role: ChatRole::Assistant,
1075 content: response.content,
1076 });
1077 Ok(calls)
1078 }
1079
1080 async fn execute_round(
1083 &self,
1084 prepared: &Prepared,
1085 state: &mut LoopState,
1086 gates: Vec<(ToolCall, Gate)>,
1087 deadline: Option<Instant>,
1088 ) -> Option<AgentOutcome> {
1089 let step = state.steps.saturating_sub(1);
1091 let contexts: Vec<ToolContext> = gates
1092 .iter()
1093 .map(|(call, _)| ToolContext {
1094 run_id: state.run_id.clone(),
1095 call_id: call.id.clone(),
1096 session_id: state.session_id.clone(),
1097 step,
1098 })
1099 .collect();
1100 let outputs: Vec<ToolOutput> = if self.parallel_tool_calls {
1101 futures::future::join_all(
1102 gates
1103 .iter()
1104 .zip(&contexts)
1105 .map(|((call, gate), ctx)| self.run_call(prepared, call, gate, ctx, deadline)),
1106 )
1107 .await
1108 } else {
1109 let mut outputs = Vec::with_capacity(gates.len());
1110 for ((call, gate), ctx) in gates.iter().zip(&contexts) {
1111 outputs.push(self.run_call(prepared, call, gate, ctx, deadline).await);
1112 }
1113 outputs
1114 };
1115 let mut results = Vec::with_capacity(outputs.len());
1116 let mut stopped = None;
1117 for ((call, _), output) in gates.iter().zip(outputs) {
1118 let mut content = output.content;
1119 match state
1120 .tracker
1121 .record(&self.loop_guard, &call.name, &call.arguments, &content)
1122 {
1123 LoopVerdict::Ok => {}
1124 LoopVerdict::Warn { repeats } => {
1125 tracing::warn!(run_id = %state.run_id, tool = %call.name, repeats, "agent is repeating a tool call");
1126 content.push_str(&warning_note(&call.name, repeats));
1127 }
1128 LoopVerdict::Stop { repeats } => {
1129 if stopped.is_none() {
1130 stopped = Some((call.name.clone(), repeats));
1131 }
1132 }
1133 }
1134 results.push(ContentPart::ToolResult {
1135 tool_call_id: call.id.clone(),
1136 content,
1137 });
1138 }
1139 state.messages.push(ChatMessage {
1140 role: ChatRole::Tool,
1141 content: results,
1142 });
1143 stopped.map(|(tool, repeats)| {
1144 tracing::warn!(run_id = %state.run_id, tool = %tool, repeats, "agent loop detected; stopping the run");
1145 AgentOutcome::LoopDetected {
1146 tool,
1147 repeats,
1148 steps_used: state.steps,
1149 usage: state.usage,
1150 }
1151 })
1152 }
1153
1154 async fn run_call(
1159 &self,
1160 prepared: &Prepared,
1161 call: &ToolCall,
1162 gate: &Gate,
1163 ctx: &ToolContext,
1164 deadline: Option<Instant>,
1165 ) -> ToolOutput {
1166 let mut effective = call.clone();
1167 let output = match gate {
1168 Gate::Denied(reason) => error_output(&format!("denied by policy: {reason}")),
1169 Gate::Rejected(reason) => {
1170 error_output(&format!("a reviewer rejected this call: {reason}"))
1171 }
1172 Gate::Allowed | Gate::Approved(_) => {
1173 if let Gate::Approved(Some(arguments)) = gate {
1174 effective.arguments = arguments.clone();
1175 }
1176 self.invoke(prepared, &mut effective, ctx, deadline).await
1177 }
1178 };
1179 for hook in &self.hooks {
1180 hook.after_tool(&effective, &output, ctx).await;
1181 }
1182 output
1183 }
1184
1185 async fn invoke(
1187 &self,
1188 prepared: &Prepared,
1189 call: &mut ToolCall,
1190 ctx: &ToolContext,
1191 deadline: Option<Instant>,
1192 ) -> ToolOutput {
1193 for hook in &self.hooks {
1194 match hook.before_tool(call, ctx).await {
1195 HookAction::Continue => {}
1196 HookAction::Modify(arguments) => call.arguments = arguments,
1197 HookAction::Block(reason) => {
1198 return error_output(&format!("blocked by hook: {reason}"));
1199 }
1200 }
1201 }
1202 let Some(tool) = prepared.tools.iter().find(|tool| tool.name() == call.name) else {
1203 tracing::warn!(
1204 tool_call_id = %call.id,
1205 tool = %call.name,
1206 "model called an unknown tool"
1207 );
1208 return error_output(&format!("unknown tool {:?}", call.name));
1209 };
1210 let execution = tool.execute(call.arguments.clone(), ctx);
1211 let result = match deadline {
1212 Some(deadline) => match tokio::time::timeout_at(deadline, execution).await {
1213 Ok(result) => result,
1214 Err(_) => {
1215 return error_output("the tool did not finish before the run deadline");
1216 }
1217 },
1218 None => execution.await,
1219 };
1220 match result {
1221 Ok(output) => ToolOutput {
1222 content: truncate_chars(&output.to_string(), self.per_tool_output_limit),
1223 is_error: false,
1224 },
1225 Err(err) => {
1226 tracing::warn!(
1227 tool_call_id = %call.id,
1228 tool = %call.name,
1229 error = %err,
1230 "tool execution failed"
1231 );
1232 error_output(err.message())
1233 }
1234 }
1235 }
1236}
1237
1238fn error_output(message: &str) -> ToolOutput {
1239 ToolOutput {
1240 content: serde_json::json!({"error": message}).to_string(),
1241 is_error: true,
1242 }
1243}
1244
1245fn truncate_chars(text: &str, limit: usize) -> String {
1247 if text.chars().count() <= limit {
1248 return text.to_owned();
1249 }
1250 let kept: String = text.chars().take(limit).collect();
1251 format!("{kept}…[truncated]")
1252}
1253
1254#[derive(Debug, Clone)]
1261pub struct AgentRuntime {
1262 client: Arc<dyn LlmClient>,
1263 tools: Vec<Arc<dyn Tool>>,
1264 config: AgentConfig,
1265 hooks: Vec<Arc<dyn AgentHooks>>,
1266 policy: Arc<dyn ToolPolicy>,
1267 sessions: Arc<dyn SessionStore>,
1268 memory: Option<Arc<dyn MemoryStore>>,
1269 skills: Arc<[Skill]>,
1270 compaction: Option<Compaction>,
1271 delivery: Arc<dyn Delivery>,
1272 followups: Option<Duration>,
1273}
1274
1275impl AgentRuntime {
1276 #[must_use]
1282 pub fn new(config: AgentConfig, client: Arc<dyn LlmClient>, tools: Vec<Arc<dyn Tool>>) -> Self {
1283 Self {
1284 client,
1285 tools,
1286 config,
1287 hooks: Vec::new(),
1288 policy: Arc::new(AllowAll),
1289 sessions: Arc::new(crate::session::InMemorySessionStore::new()),
1290 memory: None,
1291 skills: Arc::from(Vec::new()),
1292 compaction: None,
1293 delivery: Arc::new(LogDelivery),
1294 followups: None,
1295 }
1296 }
1297
1298 #[must_use]
1300 pub fn with_hooks(mut self, hooks: Vec<Arc<dyn AgentHooks>>) -> Self {
1301 self.hooks.extend(hooks);
1302 self
1303 }
1304
1305 #[must_use]
1307 pub fn with_policy(mut self, policy: Arc<dyn ToolPolicy>) -> Self {
1308 self.policy = policy;
1309 self
1310 }
1311
1312 #[must_use]
1314 pub fn with_sessions(mut self, sessions: Arc<dyn SessionStore>) -> Self {
1315 self.sessions = sessions;
1316 self
1317 }
1318
1319 #[must_use]
1321 pub fn with_memory(mut self, memory: Arc<dyn MemoryStore>) -> Self {
1322 self.memory = Some(memory);
1323 self
1324 }
1325
1326 #[must_use]
1328 pub fn with_skills(mut self, skills: impl Into<Arc<[Skill]>>) -> Self {
1329 self.skills = skills.into();
1330 self
1331 }
1332
1333 #[must_use]
1335 pub const fn with_compaction(mut self, compaction: Compaction) -> Self {
1336 self.compaction = Some(compaction);
1337 self
1338 }
1339
1340 #[must_use]
1342 pub fn with_delivery(mut self, delivery: Arc<dyn Delivery>) -> Self {
1343 self.delivery = delivery;
1344 self
1345 }
1346
1347 #[must_use]
1349 pub const fn with_followups(mut self, max_delay: Duration) -> Self {
1350 self.followups = Some(max_delay);
1351 self
1352 }
1353
1354 #[must_use]
1357 pub fn agent(&self) -> Agent {
1358 self.agent_for(MemoryScope::agent())
1359 }
1360
1361 #[must_use]
1364 pub fn agent_for(&self, scope: MemoryScope) -> Agent {
1365 self.agent_with(scope, 0)
1366 }
1367
1368 pub(crate) fn agent_with(&self, scope: MemoryScope, followup_depth: u32) -> Agent {
1370 let mut agent = Agent::new(Arc::clone(&self.client))
1371 .tools(self.tools.clone())
1372 .max_steps(self.config.max_steps)
1373 .max_tokens(self.config.max_tokens)
1374 .policy(Arc::clone(&self.policy))
1375 .skills(Arc::clone(&self.skills));
1376 for hook in &self.hooks {
1377 agent = agent.hook(Arc::clone(hook));
1378 }
1379 if let Some(system) = &self.config.system_prompt {
1380 agent = agent.system_prompt(system.clone());
1381 }
1382 if let Some(secs) = self.config.max_run_secs {
1383 agent = agent.max_duration(Duration::from_secs(secs));
1384 }
1385 if let Some(max_delay) = self.followups {
1386 agent = agent.tool(Arc::new(
1387 FollowupTool::new(max_delay)
1388 .memory_scope(scope.clone())
1389 .depth(followup_depth),
1390 ));
1391 }
1392 if let Some(memory) = &self.memory {
1393 agent = agent.memory(Arc::clone(memory), scope);
1394 }
1395 if let Some(compaction) = self.compaction {
1396 agent = agent.compaction(compaction);
1397 }
1398 agent
1399 }
1400
1401 #[must_use]
1403 pub const fn config(&self) -> &AgentConfig {
1404 &self.config
1405 }
1406
1407 #[must_use]
1412 pub fn client(&self) -> Arc<dyn LlmClient> {
1413 Arc::clone(&self.client)
1414 }
1415
1416 #[must_use]
1418 pub fn policy(&self) -> Arc<dyn ToolPolicy> {
1419 Arc::clone(&self.policy)
1420 }
1421
1422 #[must_use]
1424 pub fn sessions(&self) -> Arc<dyn SessionStore> {
1425 Arc::clone(&self.sessions)
1426 }
1427
1428 #[must_use]
1430 pub fn memory(&self) -> Option<Arc<dyn MemoryStore>> {
1431 self.memory.clone()
1432 }
1433
1434 #[must_use]
1436 pub fn delivery(&self) -> Arc<dyn Delivery> {
1437 Arc::clone(&self.delivery)
1438 }
1439
1440 #[must_use]
1442 pub fn tool_names(&self) -> Vec<&str> {
1443 self.tools.iter().map(|tool| tool.name()).collect()
1444 }
1445}
1446
1447pub use crate::jobs::{AgentRunArgs, enqueue_agent_run};
1448
1449#[cfg(test)]
1450mod tests;