use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use crate::client::{
ChatMessage, ChatRequest, ChatResponse, ChatRole, ContentPart, LlmClient, TokenUsage,
};
use crate::config::AgentConfig;
use crate::error::{AgentError, ErrorKind};
use crate::hooks::{AgentHooks, HookAction, RunInfo, ToolOutput};
use crate::ids::{RunId, SessionId};
use crate::loop_guard::{LoopGuard, LoopTracker, LoopVerdict, warning_note};
use crate::memory::{MemoryScope, MemoryStore, MemoryTool, render_snapshot};
use crate::policy::{AllowAll, ToolDecision, ToolPolicy};
use crate::proactive::{Delivery, FollowupTool, LogDelivery};
use crate::session::{Compaction, CompactionReport, SessionStore, compact};
use crate::skills::{Skill, SkillTool, render_index};
use crate::tools::{Tool, ToolCall, ToolContext};
#[must_use]
pub fn estimate_tokens(text: &str) -> u32 {
u32::try_from(text.len() / 4).unwrap_or(u32::MAX)
}
#[must_use]
pub fn estimate_messages(messages: &[ChatMessage]) -> u32 {
messages
.iter()
.map(|message| {
message
.content
.iter()
.map(|part| match part {
ContentPart::Text(text) => estimate_tokens(text),
ContentPart::ToolCall {
name, arguments, ..
} => estimate_tokens(name)
.saturating_add(estimate_tokens(&arguments.to_string())),
ContentPart::ToolResult { content, .. } => estimate_tokens(content),
})
.fold(0u32, u32::saturating_add)
})
.fold(0u32, u32::saturating_add)
.saturating_add(
u32::try_from(messages.len())
.unwrap_or(u32::MAX)
.saturating_mul(4),
) }
#[must_use]
pub fn truncate_history(messages: &[ChatMessage], budget: u32) -> Vec<ChatMessage> {
let cost = |message: &ChatMessage| estimate_messages(std::slice::from_ref(message));
let mut kept: Vec<usize> = Vec::new();
let mut used: u32 = 0;
for (index, message) in messages.iter().enumerate() {
if message.role == ChatRole::System {
used = used.saturating_add(cost(message));
kept.push(index);
}
}
let anchor = messages
.iter()
.position(|message| message.role != ChatRole::System)
.filter(|&index| {
messages
.get(index)
.is_some_and(|message| message.role == ChatRole::User)
})
.filter(|&index| {
messages
.get(index)
.is_some_and(|message| used.saturating_add(cost(message)) <= budget)
});
if let Some(index) = anchor
&& let Some(message) = messages.get(index)
{
used = used.saturating_add(cost(message));
kept.push(index);
}
let mut tail: Vec<usize> = Vec::new();
for (index, message) in messages.iter().enumerate().rev() {
if message.role == ChatRole::System || Some(index) == anchor {
continue;
}
let message_cost = cost(message);
if used.saturating_add(message_cost) <= budget {
used = used.saturating_add(message_cost);
tail.push(index);
} else {
break;
}
}
tail.reverse();
let orphans = tail
.iter()
.take_while(|&&index| {
messages
.get(index)
.is_some_and(|message| message.role == ChatRole::Tool)
})
.count();
tail.drain(..orphans);
kept.extend(tail);
kept.sort_unstable();
kept.dedup();
kept.into_iter()
.filter_map(|index| messages.get(index).cloned())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BudgetKind {
Steps,
Tokens,
Deadline,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum PendingStatus {
Allowed,
NeedsApproval {
reason: String,
},
Denied {
reason: String,
},
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PendingCall {
pub call: ToolCall,
pub status: PendingStatus,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "verdict", rename_all = "snake_case")]
pub enum Approval {
Approve,
Edit {
arguments: serde_json::Value,
},
Reject {
reason: String,
},
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ApprovalDecision {
pub call_id: String,
pub approval: Approval,
}
impl ApprovalDecision {
#[must_use]
pub fn approve(call_id: impl Into<String>) -> Self {
Self {
call_id: call_id.into(),
approval: Approval::Approve,
}
}
#[must_use]
pub fn reject(call_id: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
call_id: call_id.into(),
approval: Approval::Reject {
reason: reason.into(),
},
}
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunState {
pub run_id: RunId,
pub session_id: Option<SessionId>,
pub messages: Vec<ChatMessage>,
pub usage: TokenUsage,
pub steps_used: u32,
pub pending: Vec<PendingCall>,
#[serde(default)]
pub recent_calls: Vec<u64>,
}
impl RunState {
pub fn awaiting(&self) -> impl Iterator<Item = &PendingCall> {
self.pending
.iter()
.filter(|pending| matches!(pending.status, PendingStatus::NeedsApproval { .. }))
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AgentOutcome {
Completed {
text: String,
steps_used: u32,
usage: TokenUsage,
},
BudgetExhausted {
reason: BudgetKind,
steps_used: u32,
usage: TokenUsage,
},
LoopDetected {
tool: String,
repeats: u32,
steps_used: u32,
usage: TokenUsage,
},
AwaitingApproval {
state: Box<RunState>,
},
}
impl AgentOutcome {
#[must_use]
pub fn steps_used(&self) -> u32 {
match self {
Self::Completed { steps_used, .. }
| Self::BudgetExhausted { steps_used, .. }
| Self::LoopDetected { steps_used, .. } => *steps_used,
Self::AwaitingApproval { state } => state.steps_used,
}
}
#[must_use]
pub fn usage(&self) -> TokenUsage {
match self {
Self::Completed { usage, .. }
| Self::BudgetExhausted { usage, .. }
| Self::LoopDetected { usage, .. } => *usage,
Self::AwaitingApproval { state } => state.usage,
}
}
#[must_use]
pub const fn is_completed(&self) -> bool {
matches!(self, Self::Completed { .. })
}
#[must_use]
pub fn text(&self) -> Option<&str> {
match self {
Self::Completed { text, .. } => Some(text),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AgentTurn {
pub outcome: AgentOutcome,
pub messages: Vec<ChatMessage>,
pub compaction: Option<CompactionReport>,
}
#[derive(Debug, Clone)]
enum Gate {
Allowed,
Approved(Option<serde_json::Value>),
Denied(String),
Rejected(String),
}
#[derive(Debug)]
struct LoopState {
run_id: RunId,
session_id: Option<SessionId>,
messages: Vec<ChatMessage>,
usage: TokenUsage,
steps: u32,
tracker: LoopTracker,
}
#[derive(Debug)]
struct Prepared {
system: Option<String>,
tools: Vec<Arc<dyn Tool>>,
}
#[derive(Clone)]
pub struct Agent {
client: Arc<dyn LlmClient>,
tools: Vec<Arc<dyn Tool>>,
system_prompt: Option<String>,
max_steps: u32,
max_tokens: u32,
per_tool_output_limit: usize,
temperature: Option<f32>,
hooks: Vec<Arc<dyn AgentHooks>>,
policy: Arc<dyn ToolPolicy>,
loop_guard: LoopGuard,
max_duration: Option<Duration>,
parallel_tool_calls: bool,
memory: Option<(Arc<dyn MemoryStore>, MemoryScope)>,
skills: Arc<[Skill]>,
compaction: Option<Compaction>,
run_id: Option<RunId>,
}
impl std::fmt::Debug for Agent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Agent")
.field("client", &self.client)
.field(
"tools",
&self
.tools
.iter()
.map(|tool| tool.name().to_owned())
.collect::<Vec<_>>(),
)
.field("max_steps", &self.max_steps)
.field("max_tokens", &self.max_tokens)
.field("policy", &self.policy)
.field("loop_guard", &self.loop_guard)
.field("max_duration", &self.max_duration)
.field("memory", &self.memory.as_ref().map(|(_, scope)| scope))
.field("skills", &self.skills.len())
.finish_non_exhaustive()
}
}
impl Agent {
const MIN_USEFUL_REMAINING: u32 = 512;
const MAX_CALL_TOKENS: u32 = 4096;
#[must_use]
pub fn new(client: Arc<dyn LlmClient>) -> Self {
Self {
client,
tools: Vec::new(),
system_prompt: None,
max_steps: 10,
max_tokens: 32_000,
per_tool_output_limit: 8_000,
temperature: None,
hooks: Vec::new(),
policy: Arc::new(AllowAll),
loop_guard: LoopGuard::default(),
max_duration: None,
parallel_tool_calls: true,
memory: None,
skills: Arc::from(Vec::new()),
compaction: None,
run_id: None,
}
}
#[must_use]
pub fn tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
self.tools = tools;
self
}
#[must_use]
pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
self.tools.push(tool);
self
}
#[must_use]
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
#[must_use]
pub const fn max_steps(mut self, max_steps: u32) -> Self {
self.max_steps = max_steps;
self
}
#[must_use]
pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
#[must_use]
pub const fn max_duration(mut self, limit: Duration) -> Self {
self.max_duration = Some(limit);
self
}
#[must_use]
pub const fn per_tool_output_limit(mut self, limit: usize) -> Self {
self.per_tool_output_limit = limit;
self
}
#[must_use]
pub const fn temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
#[must_use]
pub fn hook(mut self, hook: Arc<dyn AgentHooks>) -> Self {
self.hooks.push(hook);
self
}
#[must_use]
pub fn policy(mut self, policy: Arc<dyn ToolPolicy>) -> Self {
self.policy = policy;
self
}
#[must_use]
pub const fn loop_guard(mut self, guard: LoopGuard) -> Self {
self.loop_guard = guard;
self
}
#[must_use]
pub const fn parallel_tool_calls(mut self, parallel: bool) -> Self {
self.parallel_tool_calls = parallel;
self
}
#[must_use]
pub fn memory(mut self, store: Arc<dyn MemoryStore>, scope: MemoryScope) -> Self {
self.memory = Some((store, scope));
self
}
#[must_use]
pub fn skills(mut self, skills: impl Into<Arc<[Skill]>>) -> Self {
self.skills = skills.into();
self
}
#[must_use]
pub const fn compaction(mut self, compaction: Compaction) -> Self {
self.compaction = Some(compaction);
self
}
#[must_use]
pub fn run_id(mut self, run_id: RunId) -> Self {
self.run_id = Some(run_id);
self
}
pub async fn run(&self, user_input: &str) -> Result<AgentOutcome, AgentError> {
Ok(self.run_turn(Vec::new(), user_input).await?.outcome)
}
pub async fn run_turn(
&self,
history: Vec<ChatMessage>,
user_input: &str,
) -> Result<AgentTurn, AgentError> {
self.start(history, user_input, None).await
}
pub async fn run_in_session(
&self,
store: &dyn SessionStore,
session_id: &SessionId,
user_input: &str,
) -> Result<AgentTurn, AgentError> {
let mut history = store.load(session_id).await?;
let compaction = match &self.compaction {
Some(settings) => compact(self.client.as_ref(), &mut history, settings).await?,
None => None,
};
let mut turn = self
.start(history, user_input, Some(session_id.clone()))
.await?;
if !matches!(turn.outcome, AgentOutcome::AwaitingApproval { .. }) {
store.save(session_id, &turn.messages).await?;
}
turn.compaction = compaction;
Ok(turn)
}
pub async fn resume(
&self,
state: RunState,
decisions: Vec<ApprovalDecision>,
) -> Result<AgentTurn, AgentError> {
for decision in &decisions {
if !state
.awaiting()
.any(|pending| pending.call.id == decision.call_id)
{
return Err(AgentError::new(
ErrorKind::Config,
format!(
"approval decision names call {:?}, which is not waiting for approval",
decision.call_id
),
));
}
}
let prepared = self.prepare().await?;
let deadline = self.deadline();
let gates: Vec<(ToolCall, Gate)> = state
.pending
.iter()
.map(|pending| {
let gate = match &pending.status {
PendingStatus::Allowed => Gate::Allowed,
PendingStatus::Denied { reason } => Gate::Denied(reason.clone()),
PendingStatus::NeedsApproval { .. } => decisions
.iter()
.find(|decision| decision.call_id == pending.call.id)
.map_or_else(
|| Gate::Rejected("no decision was given".to_owned()),
|decision| match &decision.approval {
Approval::Approve => Gate::Approved(None),
Approval::Edit { arguments } => {
Gate::Approved(Some(arguments.clone()))
}
Approval::Reject { reason } => Gate::Rejected(reason.clone()),
},
),
};
(pending.call.clone(), gate)
})
.collect();
let mut state_now = LoopState {
run_id: state.run_id,
session_id: state.session_id,
messages: state.messages,
usage: state.usage,
steps: state.steps_used,
tracker: LoopTracker::from_saved(state.recent_calls),
};
let stopped = self
.execute_round(&prepared, &mut state_now, gates, deadline)
.await;
let outcome = match stopped {
Some(outcome) => outcome,
None => self.drive(&prepared, &mut state_now, deadline).await?,
};
Ok(self.finish(outcome, state_now).await)
}
pub async fn resume_in_session(
&self,
store: &dyn SessionStore,
state: RunState,
decisions: Vec<ApprovalDecision>,
) -> Result<AgentTurn, AgentError> {
let session_id = state.session_id.clone();
let turn = self.resume(state, decisions).await?;
if let Some(session_id) = session_id
&& !matches!(turn.outcome, AgentOutcome::AwaitingApproval { .. })
{
store.save(&session_id, &turn.messages).await?;
}
Ok(turn)
}
fn deadline(&self) -> Option<Instant> {
self.max_duration.map(|limit| Instant::now() + limit)
}
async fn start(
&self,
mut history: Vec<ChatMessage>,
user_input: &str,
session_id: Option<SessionId>,
) -> Result<AgentTurn, AgentError> {
let prepared = self.prepare().await?;
let deadline = self.deadline();
history.retain(|message| message.role != ChatRole::System);
history.push(ChatMessage::text(ChatRole::User, user_input));
let mut state = LoopState {
run_id: self.run_id.clone().unwrap_or_else(RunId::generate),
session_id,
messages: history,
usage: TokenUsage::default(),
steps: 0,
tracker: LoopTracker::default(),
};
let outcome = self.drive(&prepared, &mut state, deadline).await?;
Ok(self.finish(outcome, state).await)
}
async fn prepare(&self) -> Result<Prepared, AgentError> {
let mut sections: Vec<String> = Vec::new();
if let Some(system) = &self.system_prompt {
sections.push(system.clone());
}
let mut tools = self.tools.clone();
if !self.skills.is_empty() {
sections.push(render_index(&self.skills));
tools.push(Arc::new(SkillTool::new(Arc::clone(&self.skills))));
}
if let Some((store, scope)) = &self.memory {
let blocks = store.load(scope).await?;
sections.push(render_snapshot(&blocks));
tools.push(Arc::new(MemoryTool::new(Arc::clone(store), scope.clone())));
}
let system = if sections.is_empty() {
None
} else {
Some(sections.join("\n\n"))
};
Ok(Prepared { system, tools })
}
fn info(&self, state: &LoopState) -> RunInfo {
RunInfo {
run_id: state.run_id.clone(),
session_id: state.session_id.clone(),
steps_used: state.steps,
max_steps: self.max_steps,
usage: state.usage,
}
}
async fn finish(&self, outcome: AgentOutcome, state: LoopState) -> AgentTurn {
let info = self.info(&state);
for hook in &self.hooks {
hook.on_outcome(&outcome, &info).await;
}
AgentTurn {
outcome,
messages: state.messages,
compaction: None,
}
}
fn check_budgets(&self, steps: u32, usage: TokenUsage) -> Result<u32, AgentOutcome> {
let exhausted = |reason| AgentOutcome::BudgetExhausted {
reason,
steps_used: steps,
usage,
};
if steps >= self.max_steps {
return Err(exhausted(BudgetKind::Steps));
}
let spent = usage.total();
if spent >= self.max_tokens {
return Err(exhausted(BudgetKind::Tokens));
}
let remaining = self.max_tokens - spent;
if remaining < Self::MIN_USEFUL_REMAINING {
return Err(exhausted(BudgetKind::Tokens));
}
Ok(remaining)
}
async fn drive(
&self,
prepared: &Prepared,
state: &mut LoopState,
deadline: Option<Instant>,
) -> Result<AgentOutcome, AgentError> {
loop {
let exhausted = |reason, state: &LoopState| AgentOutcome::BudgetExhausted {
reason,
steps_used: state.steps,
usage: state.usage,
};
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Ok(exhausted(BudgetKind::Deadline, state));
}
let remaining = match self.check_budgets(state.steps, state.usage) {
Ok(remaining) => remaining,
Err(outcome) => return Ok(outcome),
};
let mut full = Vec::with_capacity(state.messages.len().saturating_add(1));
if let Some(system) = &prepared.system {
full.push(ChatMessage::text(ChatRole::System, system.clone()));
}
full.extend(state.messages.iter().cloned());
let history = truncate_history(&full, remaining);
let history_tokens = estimate_messages(&history);
let has_turns = history
.iter()
.any(|message| message.role != ChatRole::System);
if history_tokens > remaining || !has_turns {
return Ok(exhausted(BudgetKind::Tokens, state));
}
let output_budget = remaining.saturating_sub(history_tokens);
if output_budget == 0 {
return Ok(exhausted(BudgetKind::Tokens, state));
}
let mut request = ChatRequest {
messages: history,
tools: prepared
.tools
.iter()
.map(|tool| tool.definition())
.collect(),
max_tokens: Some(output_budget.min(Self::MAX_CALL_TOKENS)),
temperature: self.temperature,
};
let info = self.info(state);
for hook in &self.hooks {
hook.before_model(&mut request, &info).await;
}
tracing::debug!(
run_id = %state.run_id,
step = state.steps,
remaining_tokens = remaining,
"agent loop calling model"
);
let response = match deadline {
Some(deadline) => {
match tokio::time::timeout_at(deadline, self.client.chat(&request)).await {
Ok(response) => response?,
Err(_) => return Ok(exhausted(BudgetKind::Deadline, state)),
}
}
None => self.client.chat(&request).await?,
};
for hook in &self.hooks {
hook.after_model(&response, &info).await;
}
let calls = match self.fold_response(state, response) {
Ok(calls) => calls,
Err(outcome) => return Ok(outcome),
};
let (gates, pending) = self.gate_calls(prepared, state, calls).await;
if pending
.iter()
.any(|pending| matches!(pending.status, PendingStatus::NeedsApproval { .. }))
{
tracing::info!(run_id = %state.run_id, "agent run paused for approval");
return Ok(AgentOutcome::AwaitingApproval {
state: Box::new(RunState {
run_id: state.run_id.clone(),
session_id: state.session_id.clone(),
messages: state.messages.clone(),
usage: state.usage,
steps_used: state.steps,
pending,
recent_calls: state.tracker.saved(),
}),
});
}
if let Some(outcome) = self.execute_round(prepared, state, gates, deadline).await {
return Ok(outcome);
}
}
}
async fn gate_calls(
&self,
prepared: &Prepared,
state: &LoopState,
calls: Vec<ToolCall>,
) -> (Vec<(ToolCall, Gate)>, Vec<PendingCall>) {
let info = self.info(state);
let mut gates = Vec::with_capacity(calls.len());
let mut pending = Vec::with_capacity(calls.len());
for call in calls {
let tool = prepared.tools.iter().find(|tool| tool.name() == call.name);
let decision = self
.policy
.decide(&call, tool.map(AsRef::as_ref), &info)
.await;
let (status, gate) = match decision {
ToolDecision::Allow => (PendingStatus::Allowed, Gate::Allowed),
ToolDecision::RequireApproval { reason } => {
(PendingStatus::NeedsApproval { reason }, Gate::Allowed)
}
ToolDecision::Deny { reason } => (
PendingStatus::Denied {
reason: reason.clone(),
},
Gate::Denied(reason),
),
};
pending.push(PendingCall {
call: call.clone(),
status,
});
gates.push((call, gate));
}
(gates, pending)
}
fn fold_response(
&self,
state: &mut LoopState,
response: ChatResponse,
) -> Result<Vec<ToolCall>, AgentOutcome> {
state.usage = state.usage.saturating_add(response.usage);
let calls: Vec<ToolCall> = response
.content
.iter()
.filter_map(|part| match part {
ContentPart::ToolCall {
id,
name,
arguments,
} => Some(ToolCall {
id: id.clone(),
name: name.clone(),
arguments: arguments.clone(),
}),
ContentPart::Text(_) | ContentPart::ToolResult { .. } => None,
})
.collect();
let overshoot = state.usage.total() > self.max_tokens;
if calls.is_empty() {
let text = response
.content
.iter()
.filter_map(|part| match part {
ContentPart::Text(text) => Some(text.as_str()),
ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } => None,
})
.collect::<Vec<_>>()
.join("");
state.messages.push(ChatMessage {
role: ChatRole::Assistant,
content: response.content,
});
return Err(if overshoot {
AgentOutcome::BudgetExhausted {
reason: BudgetKind::Tokens,
steps_used: state.steps,
usage: state.usage,
}
} else {
AgentOutcome::Completed {
text,
steps_used: state.steps,
usage: state.usage,
}
});
}
if overshoot {
return Err(AgentOutcome::BudgetExhausted {
reason: BudgetKind::Tokens,
steps_used: state.steps,
usage: state.usage,
});
}
state.steps += 1;
state.messages.push(ChatMessage {
role: ChatRole::Assistant,
content: response.content,
});
Ok(calls)
}
async fn execute_round(
&self,
prepared: &Prepared,
state: &mut LoopState,
gates: Vec<(ToolCall, Gate)>,
deadline: Option<Instant>,
) -> Option<AgentOutcome> {
let step = state.steps.saturating_sub(1);
let contexts: Vec<ToolContext> = gates
.iter()
.map(|(call, _)| ToolContext {
run_id: state.run_id.clone(),
call_id: call.id.clone(),
session_id: state.session_id.clone(),
step,
})
.collect();
let outputs: Vec<ToolOutput> = if self.parallel_tool_calls {
futures::future::join_all(
gates
.iter()
.zip(&contexts)
.map(|((call, gate), ctx)| self.run_call(prepared, call, gate, ctx, deadline)),
)
.await
} else {
let mut outputs = Vec::with_capacity(gates.len());
for ((call, gate), ctx) in gates.iter().zip(&contexts) {
outputs.push(self.run_call(prepared, call, gate, ctx, deadline).await);
}
outputs
};
let mut results = Vec::with_capacity(outputs.len());
let mut stopped = None;
for ((call, _), output) in gates.iter().zip(outputs) {
let mut content = output.content;
match state
.tracker
.record(&self.loop_guard, &call.name, &call.arguments, &content)
{
LoopVerdict::Ok => {}
LoopVerdict::Warn { repeats } => {
tracing::warn!(run_id = %state.run_id, tool = %call.name, repeats, "agent is repeating a tool call");
content.push_str(&warning_note(&call.name, repeats));
}
LoopVerdict::Stop { repeats } => {
if stopped.is_none() {
stopped = Some((call.name.clone(), repeats));
}
}
}
results.push(ContentPart::ToolResult {
tool_call_id: call.id.clone(),
content,
});
}
state.messages.push(ChatMessage {
role: ChatRole::Tool,
content: results,
});
stopped.map(|(tool, repeats)| {
tracing::warn!(run_id = %state.run_id, tool = %tool, repeats, "agent loop detected; stopping the run");
AgentOutcome::LoopDetected {
tool,
repeats,
steps_used: state.steps,
usage: state.usage,
}
})
}
async fn run_call(
&self,
prepared: &Prepared,
call: &ToolCall,
gate: &Gate,
ctx: &ToolContext,
deadline: Option<Instant>,
) -> ToolOutput {
let mut effective = call.clone();
let output = match gate {
Gate::Denied(reason) => error_output(&format!("denied by policy: {reason}")),
Gate::Rejected(reason) => {
error_output(&format!("a reviewer rejected this call: {reason}"))
}
Gate::Allowed | Gate::Approved(_) => {
if let Gate::Approved(Some(arguments)) = gate {
effective.arguments = arguments.clone();
}
self.invoke(prepared, &mut effective, ctx, deadline).await
}
};
for hook in &self.hooks {
hook.after_tool(&effective, &output, ctx).await;
}
output
}
async fn invoke(
&self,
prepared: &Prepared,
call: &mut ToolCall,
ctx: &ToolContext,
deadline: Option<Instant>,
) -> ToolOutput {
for hook in &self.hooks {
match hook.before_tool(call, ctx).await {
HookAction::Continue => {}
HookAction::Modify(arguments) => call.arguments = arguments,
HookAction::Block(reason) => {
return error_output(&format!("blocked by hook: {reason}"));
}
}
}
let Some(tool) = prepared.tools.iter().find(|tool| tool.name() == call.name) else {
tracing::warn!(
tool_call_id = %call.id,
tool = %call.name,
"model called an unknown tool"
);
return error_output(&format!("unknown tool {:?}", call.name));
};
let execution = tool.execute(call.arguments.clone(), ctx);
let result = match deadline {
Some(deadline) => match tokio::time::timeout_at(deadline, execution).await {
Ok(result) => result,
Err(_) => {
return error_output("the tool did not finish before the run deadline");
}
},
None => execution.await,
};
match result {
Ok(output) => ToolOutput {
content: truncate_chars(&output.to_string(), self.per_tool_output_limit),
is_error: false,
},
Err(err) => {
tracing::warn!(
tool_call_id = %call.id,
tool = %call.name,
error = %err,
"tool execution failed"
);
error_output(err.message())
}
}
}
}
fn error_output(message: &str) -> ToolOutput {
ToolOutput {
content: serde_json::json!({"error": message}).to_string(),
is_error: true,
}
}
fn truncate_chars(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_owned();
}
let kept: String = text.chars().take(limit).collect();
format!("{kept}…[truncated]")
}
#[derive(Debug, Clone)]
pub struct AgentRuntime {
client: Arc<dyn LlmClient>,
tools: Vec<Arc<dyn Tool>>,
config: AgentConfig,
hooks: Vec<Arc<dyn AgentHooks>>,
policy: Arc<dyn ToolPolicy>,
sessions: Arc<dyn SessionStore>,
memory: Option<Arc<dyn MemoryStore>>,
skills: Arc<[Skill]>,
compaction: Option<Compaction>,
delivery: Arc<dyn Delivery>,
followups: Option<Duration>,
}
impl AgentRuntime {
#[must_use]
pub fn new(config: AgentConfig, client: Arc<dyn LlmClient>, tools: Vec<Arc<dyn Tool>>) -> Self {
Self {
client,
tools,
config,
hooks: Vec::new(),
policy: Arc::new(AllowAll),
sessions: Arc::new(crate::session::InMemorySessionStore::new()),
memory: None,
skills: Arc::from(Vec::new()),
compaction: None,
delivery: Arc::new(LogDelivery),
followups: None,
}
}
#[must_use]
pub fn with_hooks(mut self, hooks: Vec<Arc<dyn AgentHooks>>) -> Self {
self.hooks.extend(hooks);
self
}
#[must_use]
pub fn with_policy(mut self, policy: Arc<dyn ToolPolicy>) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn with_sessions(mut self, sessions: Arc<dyn SessionStore>) -> Self {
self.sessions = sessions;
self
}
#[must_use]
pub fn with_memory(mut self, memory: Arc<dyn MemoryStore>) -> Self {
self.memory = Some(memory);
self
}
#[must_use]
pub fn with_skills(mut self, skills: impl Into<Arc<[Skill]>>) -> Self {
self.skills = skills.into();
self
}
#[must_use]
pub const fn with_compaction(mut self, compaction: Compaction) -> Self {
self.compaction = Some(compaction);
self
}
#[must_use]
pub fn with_delivery(mut self, delivery: Arc<dyn Delivery>) -> Self {
self.delivery = delivery;
self
}
#[must_use]
pub const fn with_followups(mut self, max_delay: Duration) -> Self {
self.followups = Some(max_delay);
self
}
#[must_use]
pub fn agent(&self) -> Agent {
self.agent_for(MemoryScope::agent())
}
#[must_use]
pub fn agent_for(&self, scope: MemoryScope) -> Agent {
self.agent_with(scope, 0)
}
pub(crate) fn agent_with(&self, scope: MemoryScope, followup_depth: u32) -> Agent {
let mut agent = Agent::new(Arc::clone(&self.client))
.tools(self.tools.clone())
.max_steps(self.config.max_steps)
.max_tokens(self.config.max_tokens)
.policy(Arc::clone(&self.policy))
.skills(Arc::clone(&self.skills));
for hook in &self.hooks {
agent = agent.hook(Arc::clone(hook));
}
if let Some(system) = &self.config.system_prompt {
agent = agent.system_prompt(system.clone());
}
if let Some(secs) = self.config.max_run_secs {
agent = agent.max_duration(Duration::from_secs(secs));
}
if let Some(max_delay) = self.followups {
agent = agent.tool(Arc::new(
FollowupTool::new(max_delay)
.memory_scope(scope.clone())
.depth(followup_depth),
));
}
if let Some(memory) = &self.memory {
agent = agent.memory(Arc::clone(memory), scope);
}
if let Some(compaction) = self.compaction {
agent = agent.compaction(compaction);
}
agent
}
#[must_use]
pub const fn config(&self) -> &AgentConfig {
&self.config
}
#[must_use]
pub fn client(&self) -> Arc<dyn LlmClient> {
Arc::clone(&self.client)
}
#[must_use]
pub fn policy(&self) -> Arc<dyn ToolPolicy> {
Arc::clone(&self.policy)
}
#[must_use]
pub fn sessions(&self) -> Arc<dyn SessionStore> {
Arc::clone(&self.sessions)
}
#[must_use]
pub fn memory(&self) -> Option<Arc<dyn MemoryStore>> {
self.memory.clone()
}
#[must_use]
pub fn delivery(&self) -> Arc<dyn Delivery> {
Arc::clone(&self.delivery)
}
#[must_use]
pub fn tool_names(&self) -> Vec<&str> {
self.tools.iter().map(|tool| tool.name()).collect()
}
}
pub use crate::jobs::{AgentRunArgs, enqueue_agent_run};
#[cfg(test)]
mod tests;