Skip to main content

autumn_plugin_agent/
agent.rs

1//! The agent loop: prompt the model, run its tool calls, repeat.
2//!
3//! [`Agent`] drives the loop. It stops on a final answer, when a budget runs
4//! out, when the [`LoopGuard`] sees the model repeat itself, or when the
5//! [`ToolPolicy`] wants a person to approve a call. Each of those is a normal
6//! [`AgentOutcome`], never an error, so callers always get a structured
7//! answer back.
8//!
9//! A run can be one-shot ([`Agent::run`]), continue a transcript
10//! ([`Agent::run_turn`]), or live in a persisted session
11//! ([`Agent::run_in_session`]). A run paused for approval resumes with
12//! [`Agent::resume`].
13//!
14//! [`enqueue_agent_run`] exposes the same loop as an Autumn background job.
15
16use 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/// Rough tokens-per-byte heuristic for budget accounting.
38///
39/// Providers report exact usage after each call; between calls the loop
40/// estimates with `bytes / 4`. It errs on the cheap side for code and the
41/// expensive side for CJK text — good enough for a guardrail, never for
42/// billing.
43#[must_use]
44pub fn estimate_tokens(text: &str) -> u32 {
45    // `len() / 4` on bytes: ASCII text dominates prompts, and byte length
46    // only over-counts multi-byte text, which errs toward safety.
47    // Saturate instead of truncating on 16-bit targets.
48    u32::try_from(text.len() / 4).unwrap_or(u32::MAX)
49}
50
51/// Estimate the tokens in a whole message history.
52#[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        ) // per-message framing overhead
76}
77
78/// Shrink a history to fit a token budget.
79///
80/// Keeps every system message and, when it fits, the first non-system
81/// message if it is a user message: the task the run serves. Then keeps the
82/// newest messages that fit. Order is preserved.
83///
84/// The kept tail never starts with a tool-result message: a result whose
85/// call was cut would make providers reject the request, so such leading
86/// results are dropped too.
87///
88/// When even the system prompt alone exceeds the budget, the system messages
89/// come back anyway and the caller treats that as budget exhaustion.
90#[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    // The task anchor: the first non-system message, when it is the user's.
102    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    // A tool result at the front of the tail lost its call (the assistant
136    // message before it was cut): drop it.
137    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/// Which budget stopped the run.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157#[non_exhaustive]
158pub enum BudgetKind {
159    /// `max_steps` iterations ran out.
160    Steps,
161    /// `max_tokens` ran out.
162    Tokens,
163    /// The wall-clock limit (`max_duration`) passed.
164    Deadline,
165}
166
167/// How a paused run treats one tool call when it resumes.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(tag = "status", rename_all = "snake_case")]
170pub enum PendingStatus {
171    /// The policy allowed the call; it runs on resume.
172    Allowed,
173    /// The call waits for a person.
174    NeedsApproval {
175        /// Why the policy asked, for the reviewer.
176        reason: String,
177    },
178    /// The policy refused the call; the model sees the reason on resume.
179    Denied {
180        /// Why the call was refused.
181        reason: String,
182    },
183}
184
185/// One tool call in a paused run.
186#[allow(clippy::derive_partial_eq_without_eq)]
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188pub struct PendingCall {
189    /// The call the model made.
190    pub call: ToolCall,
191    /// What happens to it on resume.
192    pub status: PendingStatus,
193}
194
195/// A reviewer's answer for one call that needed approval.
196#[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    /// Run the call as the model asked.
201    Approve,
202    /// Run the call with these arguments instead.
203    Edit {
204        /// Replacement arguments.
205        arguments: serde_json::Value,
206    },
207    /// Do not run the call. The model sees the reason.
208    Reject {
209        /// Why, for the model.
210        reason: String,
211    },
212}
213
214/// A reviewer's answer, addressed to one call id.
215#[allow(clippy::derive_partial_eq_without_eq)]
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct ApprovalDecision {
218    /// The `id` of the [`ToolCall`] this answers.
219    pub call_id: String,
220    /// The answer.
221    pub approval: Approval,
222}
223
224impl ApprovalDecision {
225    /// Approve one call.
226    #[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    /// Reject one call with a reason the model will see.
235    #[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/// Everything needed to continue a paused run, in serializable form.
247///
248/// Store it as JSON (a database row, a tracked job result), show
249/// [`RunState::awaiting`] to a person, then pass it to [`Agent::resume`]
250/// with their decisions. The system prompt is not part of the state: the
251/// resuming agent rebuilds it.
252#[allow(clippy::derive_partial_eq_without_eq)]
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct RunState {
255    /// The paused run's id. The resumed run keeps it.
256    pub run_id: RunId,
257    /// The session the run belongs to, if any.
258    pub session_id: Option<SessionId>,
259    /// The transcript so far, ending with the assistant's tool calls.
260    pub messages: Vec<ChatMessage>,
261    /// Provider-reported tokens spent so far.
262    pub usage: TokenUsage,
263    /// Tool-execution rounds used so far, the paused round included.
264    pub steps_used: u32,
265    /// The calls of the paused round.
266    pub pending: Vec<PendingCall>,
267    /// Loop-guard fingerprints, so a resumed run keeps its loop history.
268    #[serde(default)]
269    pub recent_calls: Vec<u64>,
270}
271
272impl RunState {
273    /// The calls that wait for a person.
274    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/// The result of one agent run.
282///
283/// Marked `#[non_exhaustive]`: match with a wildcard arm so new stop
284/// reasons do not break your build.
285#[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    /// The model produced a final answer.
291    Completed {
292        /// The model's final text.
293        text: String,
294        /// Tool-execution rounds used.
295        steps_used: u32,
296        /// Provider-reported token totals.
297        usage: TokenUsage,
298    },
299    /// A budget ran out before the model finished.
300    BudgetExhausted {
301        /// Which budget ran out.
302        reason: BudgetKind,
303        /// Tool-execution rounds used.
304        steps_used: u32,
305        /// Provider-reported token totals.
306        usage: TokenUsage,
307    },
308    /// The model repeated the same tool call with the same result too often.
309    LoopDetected {
310        /// The repeated tool.
311        tool: String,
312        /// How often the call repeated inside the guard window.
313        repeats: u32,
314        /// Tool-execution rounds used.
315        steps_used: u32,
316        /// Provider-reported token totals.
317        usage: TokenUsage,
318    },
319    /// The policy wants a person to approve one or more calls.
320    AwaitingApproval {
321        /// The paused run. Pass it to [`Agent::resume`].
322        state: Box<RunState>,
323    },
324}
325
326impl AgentOutcome {
327    /// Tool-execution rounds consumed, whatever the outcome.
328    #[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    /// Provider-reported token totals, whatever the outcome.
339    #[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    /// `true` when the model produced a final answer.
350    #[must_use]
351    pub const fn is_completed(&self) -> bool {
352        matches!(self, Self::Completed { .. })
353    }
354
355    /// The final answer, when the model produced one.
356    #[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/// One finished (or paused) run plus its transcript.
366#[derive(Debug, Clone, PartialEq)]
367pub struct AgentTurn {
368    /// How the run ended.
369    pub outcome: AgentOutcome,
370    /// The transcript after the run, without the system prompt. Feed it to
371    /// the next [`Agent::run_turn`] to continue the conversation.
372    pub messages: Vec<ChatMessage>,
373    /// Set when the session transcript was compacted before the run.
374    pub compaction: Option<CompactionReport>,
375}
376
377/// How the loop treats one call of a round.
378#[derive(Debug, Clone)]
379enum Gate {
380    Allowed,
381    Approved(Option<serde_json::Value>),
382    Denied(String),
383    Rejected(String),
384}
385
386/// Per-run mutable state.
387#[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/// Per-run fixed inputs: the rendered system prompt and the tool list.
398#[derive(Debug)]
399struct Prepared {
400    system: Option<String>,
401    tools: Vec<Arc<dyn Tool>>,
402}
403
404/// The agent loop.
405///
406/// ```rust,no_run
407/// # use autumn_plugin_agent::{Agent, AgentOutcome};
408/// # async fn demo(agent: Agent) -> Result<(), autumn_plugin_agent::AgentError> {
409/// match agent.run("What is the weather in Chicago?").await? {
410///     AgentOutcome::Completed { text, .. } => println!("answer: {text}"),
411///     other => println!("stopped: {other:?}"),
412/// }
413/// # Ok(())
414/// # }
415/// ```
416#[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    /// Minimum remaining budget (tokens) that still justifies another model call.
461    const MIN_USEFUL_REMAINING: u32 = 512;
462    /// Per-call output cap ceiling; the loop never asks for more than this.
463    const MAX_CALL_TOKENS: u32 = 4096;
464
465    /// Start building an agent around a provider client.
466    #[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    /// Tools the model may call. Replaces any earlier list.
489    #[must_use]
490    pub fn tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
491        self.tools = tools;
492        self
493    }
494
495    /// Add one tool to the list.
496    #[must_use]
497    pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
498        self.tools.push(tool);
499        self
500    }
501
502    /// System prompt prepended to every run.
503    #[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    /// Maximum tool-execution rounds before the run stops with
510    /// [`BudgetKind::Steps`].
511    ///
512    /// A step is one round in which the model requested tool calls; the final
513    /// answering call does not consume a step.
514    #[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    /// Token budget for the whole run (estimated + provider-reported).
521    #[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    /// Wall-clock limit for one run (or one resume). Model and tool calls
528    /// still in flight at the deadline are cancelled; the run ends with
529    /// [`BudgetKind::Deadline`].
530    #[must_use]
531    pub const fn max_duration(mut self, limit: Duration) -> Self {
532        self.max_duration = Some(limit);
533        self
534    }
535
536    /// Truncate any single tool output to this many characters.
537    ///
538    /// One chatty tool must not eat the whole context window.
539    #[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    /// Sampling temperature for model calls.
546    #[must_use]
547    pub const fn temperature(mut self, temperature: f32) -> Self {
548        self.temperature = Some(temperature);
549        self
550    }
551
552    /// Add a lifecycle hook. Hooks run in the order they were added.
553    #[must_use]
554    pub fn hook(mut self, hook: Arc<dyn AgentHooks>) -> Self {
555        self.hooks.push(hook);
556        self
557    }
558
559    /// Decide per call whether the agent may act. Defaults to
560    /// [`AllowAll`].
561    #[must_use]
562    pub fn policy(mut self, policy: Arc<dyn ToolPolicy>) -> Self {
563        self.policy = policy;
564        self
565    }
566
567    /// Loop-detection thresholds. Use [`LoopGuard::disabled`] to turn it off.
568    #[must_use]
569    pub const fn loop_guard(mut self, guard: LoopGuard) -> Self {
570        self.loop_guard = guard;
571        self
572    }
573
574    /// Run the calls of one round concurrently (the default) or one by one.
575    #[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    /// Give the agent persistent memory: a frozen snapshot in the system
582    /// prompt plus the `memory` tool, both bound to `scope`.
583    #[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    /// Offer skills: an index in the system prompt plus the `load_skill`
590    /// tool.
591    #[must_use]
592    pub fn skills(mut self, skills: impl Into<Arc<[Skill]>>) -> Self {
593        self.skills = skills.into();
594        self
595    }
596
597    /// Summarize long session transcripts before a run. Applies to
598    /// [`Agent::run_in_session`].
599    #[must_use]
600    pub const fn compaction(mut self, compaction: Compaction) -> Self {
601        self.compaction = Some(compaction);
602        self
603    }
604
605    /// Use this id for the next run instead of generating one. Background
606    /// jobs set it so a retried job keeps the same run id.
607    #[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    /// Run the agent loop against one user message, with no prior history.
614    ///
615    /// # Errors
616    ///
617    /// Returns [`AgentError`] when a provider call fails (transport, auth,
618    /// rate limit, malformed response) or memory cannot load. Budget
619    /// exhaustion, loops, and approval pauses are not errors: they come back
620    /// as [`AgentOutcome`] variants.
621    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    /// Continue a transcript with one user message.
626    ///
627    /// `history` is a transcript from an earlier [`AgentTurn::messages`]
628    /// (no system messages needed: the agent adds its own).
629    ///
630    /// # Errors
631    ///
632    /// See [`Agent::run`].
633    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    /// Run one turn inside a persisted session.
642    ///
643    /// Loads the transcript, compacts it when [`Agent::compaction`] is set
644    /// and the transcript is long, runs, then saves the new transcript. A run
645    /// that pauses for approval saves nothing: [`Agent::resume_in_session`]
646    /// saves once the run finishes. A new message sent to the session while
647    /// a run waits for approval continues from the last saved transcript.
648    ///
649    /// # Errors
650    ///
651    /// See [`Agent::run`]; also fails when the store or the compaction call
652    /// fails.
653    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    /// Continue a run that paused with [`AgentOutcome::AwaitingApproval`].
675    ///
676    /// Calls the policy allowed run; calls it denied report their reason;
677    /// calls that needed approval follow `decisions` — a call with no
678    /// decision counts as rejected. The deadline restarts on resume.
679    ///
680    /// # Errors
681    ///
682    /// Returns [`ErrorKind::Config`] when a decision names a call that is
683    /// not waiting for approval, plus every error of [`Agent::run`].
684    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    /// [`Agent::resume`], then save the transcript to the run's session.
748    ///
749    /// # Errors
750    ///
751    /// See [`Agent::resume`]; also fails when the store fails.
752    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    /// Start a fresh run on top of `history`.
773    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    /// Render the system prompt (base + skills + memory snapshot) and
796    /// assemble the run's tool list.
797    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    /// Fire the `on_outcome` hooks and package the turn.
831    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    /// Check the step and token budgets before another model call.
844    ///
845    /// Returns the remaining token budget, or the [`AgentOutcome`] to return
846    /// when a budget is already exhausted.
847    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    /// The loop: call the model, gate and run its tool calls, repeat.
868    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            // Reserve the estimated input cost before sizing the output cap:
901            // the provider counts the history against the same budget.
902            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    /// Ask the policy about each call of a round.
967    ///
968    /// Returns the gates to run with now and the same calls in pending form,
969    /// for a pause.
970    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    /// Fold one provider response into the loop state.
1007    ///
1008    /// Adds the exact provider-reported usage, then either yields the run's
1009    /// final [`AgentOutcome`] — a final answer, or a token overshoot, which
1010    /// is exhaustion even on an otherwise-final response — or records the
1011    /// assistant turn and returns the tool calls to gate and run.
1012    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            // Tool calls without results would corrupt the transcript, so the
1065            // overshooting turn is not recorded.
1066            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    /// Run one round of gated calls, append the results, and apply the loop
1081    /// guard. Returns an outcome when the guard stops the run.
1082    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        // The round's assistant message was step `steps - 1`.
1090        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    /// Run one gated call through the hooks and render its result.
1155    ///
1156    /// Unknown tools, failures, blocks, denials, and rejections become error
1157    /// payloads the model can see and recover from; they never abort the run.
1158    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    /// Apply the `before_tool` hooks, then execute the tool.
1186    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
1245/// Truncate a string to a character limit, marking the cut.
1246fn 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/// Shared agent state installed on [`AppState`](autumn_web::AppState) by the
1255/// plugin.
1256///
1257/// Handlers reach it through the
1258/// [`AgentHandle`](crate::plugin::AgentHandle) extractor; the background
1259/// jobs and the heartbeat read it directly.
1260#[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    /// Bundle a client, tools, and config into the shared runtime.
1277    ///
1278    /// Starts with an in-memory session store, no memory, no skills, the
1279    /// [`AllowAll`] policy, and [`LogDelivery`]. Adjust with the `with_*`
1280    /// methods.
1281    #[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    /// Add lifecycle hooks to every agent the runtime builds.
1299    #[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    /// Set the tool policy for every agent the runtime builds.
1306    #[must_use]
1307    pub fn with_policy(mut self, policy: Arc<dyn ToolPolicy>) -> Self {
1308        self.policy = policy;
1309        self
1310    }
1311
1312    /// Replace the session store.
1313    #[must_use]
1314    pub fn with_sessions(mut self, sessions: Arc<dyn SessionStore>) -> Self {
1315        self.sessions = sessions;
1316        self
1317    }
1318
1319    /// Give every agent persistent memory in this store.
1320    #[must_use]
1321    pub fn with_memory(mut self, memory: Arc<dyn MemoryStore>) -> Self {
1322        self.memory = Some(memory);
1323        self
1324    }
1325
1326    /// Offer these skills to every agent.
1327    #[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    /// Compact long session transcripts.
1334    #[must_use]
1335    pub const fn with_compaction(mut self, compaction: Compaction) -> Self {
1336        self.compaction = Some(compaction);
1337        self
1338    }
1339
1340    /// Where background results go.
1341    #[must_use]
1342    pub fn with_delivery(mut self, delivery: Arc<dyn Delivery>) -> Self {
1343        self.delivery = delivery;
1344        self
1345    }
1346
1347    /// Give agents the `schedule_followup` tool, with this maximum delay.
1348    #[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    /// Build an [`Agent`] with the runtime's client, tools, budgets, hooks,
1355    /// policy, skills, and memory (scope [`MemoryScope::agent`]).
1356    #[must_use]
1357    pub fn agent(&self) -> Agent {
1358        self.agent_for(MemoryScope::agent())
1359    }
1360
1361    /// Like [`AgentRuntime::agent`], with memory bound to `scope` (for
1362    /// example one scope per user).
1363    #[must_use]
1364    pub fn agent_for(&self, scope: MemoryScope) -> Agent {
1365        self.agent_with(scope, 0)
1366    }
1367
1368    /// Build an agent for a run that is `followup_depth` follow-ups deep.
1369    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    /// The plugin configuration backing this runtime.
1402    #[must_use]
1403    pub const fn config(&self) -> &AgentConfig {
1404        &self.config
1405    }
1406
1407    /// The provider client this runtime runs agents with.
1408    ///
1409    /// The health indicator uses the same client, so `/actuator/health`
1410    /// always reflects the provider the app actually talks to.
1411    #[must_use]
1412    pub fn client(&self) -> Arc<dyn LlmClient> {
1413        Arc::clone(&self.client)
1414    }
1415
1416    /// The tool policy agents run under.
1417    #[must_use]
1418    pub fn policy(&self) -> Arc<dyn ToolPolicy> {
1419        Arc::clone(&self.policy)
1420    }
1421
1422    /// The session store.
1423    #[must_use]
1424    pub fn sessions(&self) -> Arc<dyn SessionStore> {
1425        Arc::clone(&self.sessions)
1426    }
1427
1428    /// The memory store, when memory is on.
1429    #[must_use]
1430    pub fn memory(&self) -> Option<Arc<dyn MemoryStore>> {
1431        self.memory.clone()
1432    }
1433
1434    /// Where background results go.
1435    #[must_use]
1436    pub fn delivery(&self) -> Arc<dyn Delivery> {
1437        Arc::clone(&self.delivery)
1438    }
1439
1440    /// Names of the registered tools.
1441    #[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;