Skip to main content

autumn_plugin_agent/
plugin.rs

1//! The Autumn plugin: registration, configuration, and the handler extractor.
2//!
3//! Install with one line:
4//!
5//! ```rust,ignore
6//! use autumn_plugin_agent::plugin::AgentPlugin;
7//! use autumn_web::prelude::*;
8//!
9//! #[autumn_web::main]
10//! async fn main() {
11//!     autumn_web::app()
12//!         .plugin(AgentPlugin::new().configure(|config| {
13//!             config.max_steps = 12;
14//!         }))
15//!         .run()
16//!         .await;
17//! }
18//! ```
19//!
20//! The plugin declares the `[agent]` config section, registers the
21//! `agent_run` and `agent_resume` background jobs, installs a `list_models`
22//! health indicator, registers the heartbeat task when one is set, and
23//! builds the shared [`AgentRuntime`] in a
24//! startup hook. Handlers reach the agent through the [`AgentHandle`]
25//! extractor.
26//!
27//! An always-on setup adds memory, sessions, a heartbeat, and a delivery:
28//!
29//! ```rust,ignore
30//! use std::time::Duration;
31//! use autumn_plugin_agent::plugin::AgentPlugin;
32//! use autumn_plugin_agent::memory::InMemoryMemoryStore;
33//! use autumn_plugin_agent::proactive::Heartbeat;
34//!
35//! autumn_web::app()
36//!     .plugin(
37//!         AgentPlugin::new()
38//!             .memory_store(InMemoryMemoryStore::default().shared())
39//!             .heartbeat(Heartbeat::every(Duration::from_secs(1_800)).session("ops"))
40//!             .followups(Duration::from_secs(24 * 3_600))
41//!             .delivery(my_slack_delivery),
42//!     )
43//!     .run()
44//!     .await;
45//! ```
46
47use std::future::Future;
48use std::sync::{Arc, OnceLock};
49use std::time::Duration;
50
51use autumn_web::app::AppBuilder;
52use autumn_web::plugin::Plugin;
53use autumn_web::reexports::axum::extract::FromRequestParts;
54use autumn_web::{AppState, AutumnError};
55use http::request::Parts;
56
57use crate::agent::{Agent, AgentOutcome, AgentRuntime, AgentTurn, ApprovalDecision, RunState};
58use crate::client::client_from_config;
59use crate::config::AgentConfig;
60use crate::error::AgentError;
61use crate::health::AgentHealthIndicator;
62use crate::hooks::AgentHooks;
63use crate::ids::SessionId;
64use crate::memory::{MemoryScope, MemoryStore};
65use crate::policy::ToolPolicy;
66use crate::proactive::{Delivery, Heartbeat, HeartbeatSettings};
67use crate::session::{Compaction, SessionStore};
68use crate::skills::Skill;
69use crate::tools::Tool;
70
71/// The plugin. Register it with `AppBuilder::plugin`.
72///
73/// ```rust,ignore
74/// autumn_web::app().plugin(AgentPlugin::new()).run().await;
75/// ```
76pub struct AgentPlugin {
77    config: AgentConfig,
78    tools: Vec<Arc<dyn Tool>>,
79    hooks: Vec<Arc<dyn AgentHooks>>,
80    policy: Option<Arc<dyn ToolPolicy>>,
81    sessions: Option<Arc<dyn SessionStore>>,
82    memory: Option<Arc<dyn MemoryStore>>,
83    skills: Vec<Skill>,
84    compaction: Option<Compaction>,
85    delivery: Option<Arc<dyn Delivery>>,
86    heartbeat: Option<Heartbeat>,
87    followups: Option<Duration>,
88}
89
90impl std::fmt::Debug for AgentPlugin {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("AgentPlugin")
93            .field("config", &self.config)
94            .field(
95                "tools",
96                &self
97                    .tools
98                    .iter()
99                    .map(|tool| tool.name().to_owned())
100                    .collect::<Vec<_>>(),
101            )
102            .field("hooks", &self.hooks.len())
103            .field("policy", &self.policy)
104            .field("memory", &self.memory.is_some())
105            .field(
106                "skills",
107                &self
108                    .skills
109                    .iter()
110                    .map(|skill| skill.name.as_str())
111                    .collect::<Vec<_>>(),
112            )
113            .field("compaction", &self.compaction)
114            .field("heartbeat", &self.heartbeat)
115            .field("followups", &self.followups)
116            .finish_non_exhaustive()
117    }
118}
119
120impl AgentPlugin {
121    /// Create the plugin with default configuration.
122    ///
123    /// Defaults resolve from `autumn.toml`'s `[agent]` section and `AGENT_*`
124    /// environment variables at startup; see [`AgentConfig`].
125    #[must_use]
126    pub fn new() -> Self {
127        Self {
128            config: AgentConfig::default(),
129            tools: Vec::new(),
130            hooks: Vec::new(),
131            policy: None,
132            sessions: None,
133            memory: None,
134            skills: Vec::new(),
135            compaction: None,
136            delivery: None,
137            heartbeat: None,
138            followups: None,
139        }
140    }
141
142    /// Tune the configuration in code. Runs after file and env loading, so
143    /// values set here win over both.
144    #[must_use]
145    pub fn configure(mut self, configure: impl FnOnce(&mut AgentConfig)) -> Self {
146        configure(&mut self.config);
147        self
148    }
149
150    /// Register one tool the agent may call.
151    #[must_use]
152    pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
153        self.tools.push(tool);
154        self
155    }
156
157    /// Register several tools at once.
158    #[must_use]
159    pub fn tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
160        self.tools.extend(tools);
161        self
162    }
163
164    /// Add a lifecycle hook to every agent.
165    #[must_use]
166    pub fn hook(mut self, hook: Arc<dyn AgentHooks>) -> Self {
167        self.hooks.push(hook);
168        self
169    }
170
171    /// Decide per tool call whether agents may act. Defaults to allowing
172    /// every call.
173    #[must_use]
174    pub fn policy(mut self, policy: Arc<dyn ToolPolicy>) -> Self {
175        self.policy = Some(policy);
176        self
177    }
178
179    /// Persist session transcripts here. Defaults to an in-memory store,
180    /// which loses sessions on restart.
181    #[must_use]
182    pub fn session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
183        self.sessions = Some(store);
184        self
185    }
186
187    /// Give agents persistent memory in this store.
188    #[must_use]
189    pub fn memory_store(mut self, store: Arc<dyn MemoryStore>) -> Self {
190        self.memory = Some(store);
191        self
192    }
193
194    /// Offer one skill to every agent.
195    #[must_use]
196    pub fn skill(mut self, skill: Skill) -> Self {
197        self.skills.push(skill);
198        self
199    }
200
201    /// Offer several skills to every agent.
202    #[must_use]
203    pub fn skills(mut self, skills: Vec<Skill>) -> Self {
204        self.skills.extend(skills);
205        self
206    }
207
208    /// Summarize long session transcripts before a run.
209    #[must_use]
210    pub const fn compaction(mut self, compaction: Compaction) -> Self {
211        self.compaction = Some(compaction);
212        self
213    }
214
215    /// Where heartbeat, follow-up, and `deliver` job results go. Defaults to
216    /// logging them.
217    #[must_use]
218    pub fn delivery(mut self, delivery: Arc<dyn Delivery>) -> Self {
219        self.delivery = Some(delivery);
220        self
221    }
222
223    /// Wake the agent on a schedule. Registers the `agent_heartbeat` task.
224    #[must_use]
225    pub fn heartbeat(mut self, heartbeat: Heartbeat) -> Self {
226        self.heartbeat = Some(heartbeat);
227        self
228    }
229
230    /// Give agents the `schedule_followup` tool, so they can book their own
231    /// next run up to `max_delay` ahead.
232    #[must_use]
233    pub const fn followups(mut self, max_delay: Duration) -> Self {
234        self.followups = Some(max_delay);
235        self
236    }
237
238    /// Assemble the runtime from a loaded config and client.
239    fn runtime(
240        &self,
241        config: AgentConfig,
242        client: Arc<dyn crate::client::LlmClient>,
243    ) -> AgentRuntime {
244        let mut runtime = AgentRuntime::new(config, client, self.tools.clone())
245            .with_hooks(self.hooks.clone())
246            .with_skills(self.skills.clone());
247        if let Some(policy) = &self.policy {
248            runtime = runtime.with_policy(Arc::clone(policy));
249        }
250        if let Some(sessions) = &self.sessions {
251            runtime = runtime.with_sessions(Arc::clone(sessions));
252        }
253        if let Some(memory) = &self.memory {
254            runtime = runtime.with_memory(Arc::clone(memory));
255        }
256        if let Some(compaction) = self.compaction {
257            runtime = runtime.with_compaction(compaction);
258        }
259        if let Some(delivery) = &self.delivery {
260            runtime = runtime.with_delivery(Arc::clone(delivery));
261        }
262        if let Some(max_delay) = self.followups {
263            runtime = runtime.with_followups(max_delay);
264        }
265        runtime
266    }
267}
268
269impl Default for AgentPlugin {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275impl Plugin for AgentPlugin {
276    /// Wire the plugin into the app builder.
277    ///
278    /// Declares the `[agent]` config section, registers the `agent_run` and
279    /// `agent_resume` background jobs and the heartbeat task, adds the
280    /// provider health indicator, and installs a startup hook that fails the
281    /// boot fast when the config, the heartbeat, or the API key is unusable.
282    fn build(self, app: AppBuilder) -> AppBuilder {
283        let tasks: Vec<_> = self.heartbeat.iter().map(Heartbeat::task_info).collect();
284        let plugin = Arc::new(self);
285        // Shared with the health indicator: the startup hook installs the
286        // real runtime here once the layered config has loaded, so the
287        // health check always pings the provider the app actually uses.
288        let active = Arc::new(OnceLock::<Arc<AgentRuntime>>::new());
289        let indicator = AgentHealthIndicator::new(Arc::clone(&active));
290        app.config_section("agent")
291            .jobs(autumn_web::jobs![
292                crate::jobs::agent_jobs::run_agent,
293                crate::jobs::agent_jobs::resume_agent
294            ])
295            .tasks(tasks)
296            .health_indicator("agent", Arc::new(indicator))
297            .on_startup(move |state| {
298                let plugin = Arc::clone(&plugin);
299                let active = Arc::clone(&active);
300                async move {
301                    // Layered load: defaults < autumn.toml < AGENT_* env.
302                    // `configure()` values were baked in at build time and win.
303                    let mut loaded = AgentConfig::load().map_err(AgentError::into_autumn_error)?;
304                    apply_overrides(&mut loaded, &plugin.config);
305                    loaded.validate().map_err(AgentError::into_autumn_error)?;
306                    if let Some(heartbeat) = &plugin.heartbeat {
307                        heartbeat
308                            .validate()
309                            .map_err(AgentError::into_autumn_error)?;
310                        let heartbeat = heartbeat.clone();
311                        state.extension_or_insert_with(|| HeartbeatSettings(heartbeat));
312                    }
313                    let client =
314                        client_from_config(&loaded).map_err(AgentError::into_autumn_error)?;
315                    let provider_name = loaded.provider.as_str().to_owned();
316                    let runtime = Arc::new(plugin.runtime(loaded, client));
317                    if active.set(Arc::clone(&runtime)).is_err() {
318                        tracing::warn!("agent startup ran twice; keeping the first runtime");
319                    }
320                    state.extension_or_insert_with(|| (*runtime).clone());
321                    tracing::info!(provider = %provider_name, "agent plugin started");
322                    Ok(())
323                }
324            })
325    }
326}
327
328/// Overlay the `configure()` values on top of the file+env load.
329///
330/// Every field the builder explicitly set wins; fields left at default keep
331/// the loaded value. `Option` fields use `Some` as "explicitly set".
332fn apply_overrides(loaded: &mut AgentConfig, configured: &AgentConfig) {
333    let defaults = AgentConfig::default();
334    if configured.provider != defaults.provider {
335        loaded.provider = configured.provider;
336    }
337    if let Some(model) = &configured.model {
338        loaded.model = Some(model.clone());
339    }
340    if let Some(base_url) = &configured.base_url {
341        loaded.base_url = Some(base_url.clone());
342    }
343    if configured.request_timeout_secs != defaults.request_timeout_secs {
344        loaded.request_timeout_secs = configured.request_timeout_secs;
345    }
346    if configured.max_steps != defaults.max_steps {
347        loaded.max_steps = configured.max_steps;
348    }
349    if configured.max_tokens != defaults.max_tokens {
350        loaded.max_tokens = configured.max_tokens;
351    }
352    if let Some(prompt) = &configured.system_prompt {
353        loaded.system_prompt = Some(prompt.clone());
354    }
355    if configured.prompt_caching != defaults.prompt_caching {
356        loaded.prompt_caching = configured.prompt_caching;
357    }
358    if let Some(secs) = configured.max_run_secs {
359        loaded.max_run_secs = Some(secs);
360    }
361}
362
363/// Request extractor handing handlers a ready-to-run agent.
364///
365/// Autumn resolves it from app state before the handler runs:
366///
367/// ```rust,ignore
368/// use autumn_plugin_agent::plugin::AgentHandle;
369/// use autumn_web::prelude::*;
370///
371/// #[get("/ask")]
372/// async fn ask(agent: AgentHandle) -> String {
373///     agent
374///         .run("Summarize this quarter's plot trends.")
375///         .await
376///         .map(|outcome| format!("{outcome:?}"))
377///         .unwrap_or_else(|err| err.to_string())
378/// }
379/// ```
380///
381/// Fails with 503 when the plugin was never installed.
382#[derive(Debug, Clone)]
383pub struct AgentHandle {
384    runtime: Arc<AgentRuntime>,
385}
386
387impl AgentHandle {
388    /// Build an [`Agent`] with the plugin's client, tools, and budgets.
389    ///
390    /// Customize further with the builder methods before calling `run`.
391    #[must_use]
392    pub fn agent(&self) -> Agent {
393        self.runtime.agent()
394    }
395
396    /// Run the agent loop with the plugin's default settings.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`AgentError`] when a provider call fails. Budget exhaustion
401    /// is not an error: see [`Agent::run`].
402    pub async fn run(&self, prompt: &str) -> Result<AgentOutcome, AgentError> {
403        self.agent().run(prompt).await
404    }
405
406    /// Run one turn in a persisted session with the plugin's settings.
407    ///
408    /// # Errors
409    ///
410    /// See [`Agent::run_in_session`].
411    pub async fn chat(
412        &self,
413        session: impl Into<SessionId>,
414        input: &str,
415    ) -> Result<AgentTurn, AgentError> {
416        let session = session.into();
417        self.agent()
418            .run_in_session(self.runtime.sessions().as_ref(), &session, input)
419            .await
420    }
421
422    /// Like [`AgentHandle::chat`], with memory bound to `scope` (for
423    /// example the signed-in user).
424    ///
425    /// # Errors
426    ///
427    /// See [`Agent::run_in_session`].
428    pub async fn chat_as(
429        &self,
430        scope: MemoryScope,
431        session: impl Into<SessionId>,
432        input: &str,
433    ) -> Result<AgentTurn, AgentError> {
434        let session = session.into();
435        self.runtime
436            .agent_for(scope)
437            .run_in_session(self.runtime.sessions().as_ref(), &session, input)
438            .await
439    }
440
441    /// Continue a paused run with a reviewer's decisions, saving the
442    /// transcript when the run belongs to a session.
443    ///
444    /// # Errors
445    ///
446    /// See [`Agent::resume`].
447    pub async fn resume(
448        &self,
449        state: RunState,
450        decisions: Vec<ApprovalDecision>,
451    ) -> Result<AgentTurn, AgentError> {
452        let run_id = state.run_id.clone();
453        self.agent()
454            .run_id(run_id)
455            .resume_in_session(self.runtime.sessions().as_ref(), state, decisions)
456            .await
457    }
458
459    /// Like [`AgentHandle::resume`], with memory bound to `scope`. Use the
460    /// scope the paused run started with ([`AgentHandle::chat_as`]).
461    ///
462    /// # Errors
463    ///
464    /// See [`Agent::resume`].
465    pub async fn resume_as(
466        &self,
467        scope: MemoryScope,
468        state: RunState,
469        decisions: Vec<ApprovalDecision>,
470    ) -> Result<AgentTurn, AgentError> {
471        let run_id = state.run_id.clone();
472        self.runtime
473            .agent_for(scope)
474            .run_id(run_id)
475            .resume_in_session(self.runtime.sessions().as_ref(), state, decisions)
476            .await
477    }
478
479    /// The shared runtime, for stores and settings.
480    #[must_use]
481    pub fn runtime(&self) -> &AgentRuntime {
482        &self.runtime
483    }
484
485    /// Names of the tools registered on the plugin.
486    #[must_use]
487    pub fn tool_names(&self) -> Vec<&str> {
488        self.runtime.tool_names()
489    }
490}
491
492impl FromRequestParts<AppState> for AgentHandle {
493    type Rejection = AutumnError;
494
495    // Axum 0.8 declares this as `-> impl Future`; a plain fn returning an
496    // async block satisfies it without a synthetic `async` (there is nothing
497    // to await — the impl only reads shared state).
498    fn from_request_parts(
499        _parts: &mut Parts,
500        state: &AppState,
501    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
502        let runtime = state.extension::<AgentRuntime>();
503        async move {
504            runtime.map(|runtime| Self { runtime }).ok_or_else(|| {
505                AutumnError::service_unavailable_msg("agent plugin is not installed")
506            })
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests;