Skip to main content

autumn_plugin_agent/
jobs.rs

1//! Background runs on Autumn's job queue.
2//!
3//! Two jobs carry agent work off the request path:
4//!
5//! * `agent_run` — run the loop for one prompt, optionally inside a session.
6//!   Enqueue with [`enqueue_agent_run`], or with [`enqueue_agent_run_tracked`]
7//!   to get a token the caller can poll at Autumn's job-status route. A
8//!   tracked run reports progress per loop step and stores the
9//!   [`AgentOutcome`] as its JSON result.
10//! * `agent_resume` — continue a run that paused for approval, with a
11//!   reviewer's decisions. It never retries: the approved calls may have
12//!   side effects.
13//!
14//! With `deliver` set, a finished run hands its answer to the plugin's
15//! [`Delivery`](crate::proactive::Delivery) (heartbeats and follow-ups set
16//! it).
17
18use std::sync::Arc;
19use std::time::Duration;
20
21use autumn_web::job::{JobContext, TrackedJobHandle};
22use autumn_web::{AppState, AutumnError, AutumnResult};
23use futures::future::BoxFuture;
24use serde::{Deserialize, Serialize};
25
26use crate::agent::{AgentOutcome, AgentRuntime, ApprovalDecision, RunState};
27use crate::error::AgentError;
28use crate::hooks::{AgentHooks, RunInfo};
29use crate::ids::{RunId, SessionId};
30use crate::memory::MemoryScope;
31use crate::policy::ToolPolicy;
32use crate::proactive::{ReportSource, deliver_outcome};
33
34/// Why a background run started.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum RunOrigin {
38    /// The app enqueued it (a handler, a listener, a task).
39    #[default]
40    Request,
41    /// The agent scheduled it with the `schedule_followup` tool.
42    Followup,
43    /// The heartbeat started it.
44    Heartbeat,
45}
46
47impl From<RunOrigin> for ReportSource {
48    fn from(origin: RunOrigin) -> Self {
49        match origin {
50            RunOrigin::Request => Self::Job,
51            RunOrigin::Followup => Self::Followup,
52            RunOrigin::Heartbeat => Self::Heartbeat,
53        }
54    }
55}
56
57/// Arguments for the `agent_run` background job.
58///
59/// Build with [`AgentRunArgs::new`] and the builder methods; the struct is
60/// `#[non_exhaustive]` so new options do not break callers.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[non_exhaustive]
63pub struct AgentRunArgs {
64    /// The user message that starts the run.
65    pub prompt: String,
66    /// Overrides the configured system prompt for this run.
67    #[serde(default)]
68    pub system_prompt: Option<String>,
69    /// Overrides the configured step budget for this run.
70    #[serde(default)]
71    pub max_steps: Option<u32>,
72    /// Run inside this session (load, run, save).
73    #[serde(default)]
74    pub session: Option<SessionId>,
75    /// Bind memory to this scope instead of [`MemoryScope::agent`].
76    #[serde(default)]
77    pub memory_scope: Option<MemoryScope>,
78    /// Hand the answer to the plugin's delivery when the run ends.
79    #[serde(default)]
80    pub deliver: bool,
81    /// Why the run started.
82    #[serde(default)]
83    pub origin: RunOrigin,
84    /// The run id. Fixed at enqueue time, so a retried job keeps it and
85    /// tools can use it as an idempotency key.
86    #[serde(default = "RunId::generate")]
87    pub run_id: RunId,
88    /// How many follow-ups led to this run (0 for a run the app started).
89    /// The `schedule_followup` tool refuses to extend a chain past
90    /// [`FollowupTool::MAX_CHAIN`](crate::proactive::FollowupTool::MAX_CHAIN).
91    #[serde(default)]
92    pub followup_depth: u32,
93}
94
95impl AgentRunArgs {
96    /// A run for one prompt, with every option at its default.
97    #[must_use]
98    pub fn new(prompt: impl Into<String>) -> Self {
99        Self {
100            prompt: prompt.into(),
101            system_prompt: None,
102            max_steps: None,
103            session: None,
104            memory_scope: None,
105            deliver: false,
106            origin: RunOrigin::Request,
107            run_id: RunId::generate(),
108            followup_depth: 0,
109        }
110    }
111
112    /// Override the system prompt.
113    #[must_use]
114    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
115        self.system_prompt = Some(prompt.into());
116        self
117    }
118
119    /// Override the step budget.
120    #[must_use]
121    pub const fn max_steps(mut self, max_steps: u32) -> Self {
122        self.max_steps = Some(max_steps);
123        self
124    }
125
126    /// Run inside a session.
127    #[must_use]
128    pub fn session(mut self, session: impl Into<Option<SessionId>>) -> Self {
129        self.session = session.into();
130        self
131    }
132
133    /// Bind memory to a scope.
134    #[must_use]
135    pub fn memory_scope(mut self, scope: impl Into<Option<MemoryScope>>) -> Self {
136        self.memory_scope = scope.into();
137        self
138    }
139
140    /// Deliver the answer when the run ends.
141    #[must_use]
142    pub const fn deliver(mut self, deliver: bool) -> Self {
143        self.deliver = deliver;
144        self
145    }
146
147    /// Record why the run started.
148    #[must_use]
149    pub const fn origin(mut self, origin: RunOrigin) -> Self {
150        self.origin = origin;
151        self
152    }
153}
154
155/// Arguments for the `agent_resume` background job.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[non_exhaustive]
158pub struct AgentResumeArgs {
159    /// The paused run.
160    pub state: RunState,
161    /// The reviewer's decisions.
162    pub decisions: Vec<ApprovalDecision>,
163    /// Bind memory to this scope instead of [`MemoryScope::agent`].
164    #[serde(default)]
165    pub memory_scope: Option<MemoryScope>,
166    /// Hand the answer to the plugin's delivery when the run ends.
167    #[serde(default)]
168    pub deliver: bool,
169}
170
171impl AgentResumeArgs {
172    /// Resume `state` with `decisions`.
173    #[must_use]
174    pub const fn new(state: RunState, decisions: Vec<ApprovalDecision>) -> Self {
175        Self {
176            state,
177            decisions,
178            memory_scope: None,
179            deliver: false,
180        }
181    }
182
183    /// Bind memory to a scope.
184    #[must_use]
185    pub fn memory_scope(mut self, scope: impl Into<Option<MemoryScope>>) -> Self {
186        self.memory_scope = scope.into();
187        self
188    }
189
190    /// Deliver the answer when the run ends.
191    #[must_use]
192    pub const fn deliver(mut self, deliver: bool) -> Self {
193        self.deliver = deliver;
194        self
195    }
196}
197
198/// Reports loop progress to Autumn's tracked-job store. A no-op for
199/// untracked jobs and outside jobs.
200#[derive(Debug)]
201struct JobProgress;
202
203impl AgentHooks for JobProgress {
204    fn before_model<'a>(
205        &'a self,
206        _request: &'a mut crate::client::ChatRequest,
207        info: &'a RunInfo,
208    ) -> BoxFuture<'a, ()> {
209        Box::pin(async move {
210            let context = JobContext::current();
211            if !context.is_tracked() {
212                return;
213            }
214            let max = info.max_steps.max(1);
215            let pct = (info.steps_used.min(max).saturating_mul(99) / max).min(99);
216            let pct = u8::try_from(pct).unwrap_or(99);
217            let message = format!("step {} of {}", info.steps_used, info.max_steps);
218            if let Err(err) = context.set_progress(pct, Some(&message)).await {
219                tracing::debug!(error = %err, "cannot record agent job progress");
220            }
221        })
222    }
223}
224
225/// Store the outcome as the tracked job's result and log it.
226fn record_outcome(run_id: &RunId, outcome: &AgentOutcome) {
227    match serde_json::to_value(outcome) {
228        Ok(value) => JobContext::current().set_result(value),
229        Err(err) => tracing::warn!(error = %err, "cannot serialize the agent outcome"),
230    }
231    let usage = outcome.usage();
232    tracing::info!(
233        run_id = %run_id,
234        status = outcome_status(outcome),
235        steps_used = outcome.steps_used(),
236        input_tokens = usage.input_tokens,
237        output_tokens = usage.output_tokens,
238        cache_read_tokens = usage.cache_read_tokens,
239        "background agent run finished"
240    );
241}
242
243const fn outcome_status(outcome: &AgentOutcome) -> &'static str {
244    match outcome {
245        AgentOutcome::Completed { .. } => "completed",
246        AgentOutcome::BudgetExhausted { .. } => "budget_exhausted",
247        AgentOutcome::LoopDetected { .. } => "loop_detected",
248        AgentOutcome::AwaitingApproval { .. } => "awaiting_approval",
249    }
250}
251
252/// Run one `agent_run` payload against a runtime.
253///
254/// Shared by the job, the heartbeat, and tests. `policy` overrides the
255/// runtime policy (the heartbeat passes a read-only one).
256pub(crate) async fn execute_run(
257    runtime: &AgentRuntime,
258    args: AgentRunArgs,
259    policy: Option<Arc<dyn ToolPolicy>>,
260) -> Result<AgentOutcome, AgentError> {
261    let scope = args.memory_scope.clone().unwrap_or_else(MemoryScope::agent);
262    let mut agent = runtime
263        .agent_with(scope, args.followup_depth)
264        .run_id(args.run_id.clone())
265        .hook(Arc::new(JobProgress));
266    if let Some(system) = &args.system_prompt {
267        agent = agent.system_prompt(system.clone());
268    }
269    if let Some(max_steps) = args.max_steps {
270        agent = agent.max_steps(max_steps);
271    }
272    if let Some(policy) = policy {
273        agent = agent.policy(policy);
274    }
275    let turn = match &args.session {
276        Some(session) => {
277            agent
278                .run_in_session(runtime.sessions().as_ref(), session, &args.prompt)
279                .await?
280        }
281        None => agent.run_turn(Vec::new(), &args.prompt).await?,
282    };
283    record_outcome(&args.run_id, &turn.outcome);
284    if args.deliver {
285        deliver_outcome(
286            runtime.delivery().as_ref(),
287            args.origin.into(),
288            &args.run_id,
289            args.session.as_ref(),
290            &turn.outcome,
291        )
292        .await;
293    }
294    Ok(turn.outcome)
295}
296
297/// Run one `agent_resume` payload against a runtime.
298pub(crate) async fn execute_resume(
299    runtime: &AgentRuntime,
300    args: AgentResumeArgs,
301) -> Result<AgentOutcome, AgentError> {
302    let scope = args.memory_scope.clone().unwrap_or_else(MemoryScope::agent);
303    let run_id = args.state.run_id.clone();
304    let session = args.state.session_id.clone();
305    let agent = runtime
306        .agent_for(scope)
307        .run_id(run_id.clone())
308        .hook(Arc::new(JobProgress));
309    let turn = agent
310        .resume_in_session(runtime.sessions().as_ref(), args.state, args.decisions)
311        .await?;
312    record_outcome(&run_id, &turn.outcome);
313    if args.deliver {
314        deliver_outcome(
315            runtime.delivery().as_ref(),
316            ReportSource::Resume,
317            &run_id,
318            session.as_ref(),
319            &turn.outcome,
320        )
321        .await;
322    }
323    Ok(turn.outcome)
324}
325
326fn runtime_from(state: &AppState) -> AutumnResult<Arc<AgentRuntime>> {
327    state
328        .extension::<AgentRuntime>()
329        .ok_or_else(|| AutumnError::service_unavailable_msg("agent plugin is not installed"))
330}
331
332/// The `agent_run` and `agent_resume` background jobs.
333///
334/// Lives in a `pub(crate)` module: the `#[job]` macro emits undocumented
335/// `*Job` handles, and keeping the module crate-internal keeps them out of
336/// the public API (and out of `missing_docs`' reach). Callers use the
337/// `enqueue_*` functions.
338#[doc(hidden)]
339#[allow(missing_docs)]
340pub(crate) mod agent_jobs {
341    use super::{AgentResumeArgs, AgentRunArgs, execute_resume, execute_run, runtime_from};
342    use autumn_web::{AppState, AutumnResult};
343
344    /// Run the agent loop as a background job. Provider failures fail the
345    /// job, so the queue's retry/backoff policy applies; the run id stays
346    /// the same across attempts.
347    #[autumn_web::job(name = "agent_run", max_attempts = 3, backoff_ms = 1_000)]
348    pub async fn run_agent(state: AppState, args: AgentRunArgs) -> AutumnResult<()> {
349        let runtime = runtime_from(&state)?;
350        execute_run(&runtime, args, None)
351            .await
352            .map(|_| ())
353            .map_err(|err| {
354                tracing::error!(error = %err, "background agent run failed");
355                err.into_autumn_error()
356            })
357    }
358
359    /// Resume a paused run. One attempt only: approved calls may have side
360    /// effects that must not repeat.
361    #[autumn_web::job(name = "agent_resume", max_attempts = 1)]
362    pub async fn resume_agent(state: AppState, args: AgentResumeArgs) -> AutumnResult<()> {
363        let runtime = runtime_from(&state)?;
364        execute_resume(&runtime, args)
365            .await
366            .map(|_| ())
367            .map_err(|err| {
368                tracing::error!(error = %err, "background agent resume failed");
369                err.into_autumn_error()
370            })
371    }
372}
373
374/// Enqueue an `agent_run` background job.
375///
376/// # Errors
377///
378/// Returns [`AutumnError`] when the arguments fail to serialize or the job
379/// cannot be enqueued (for example, the job runtime is not running).
380pub async fn enqueue_agent_run(args: AgentRunArgs) -> AutumnResult<()> {
381    agent_jobs::RunAgentJob::enqueue(args).await
382}
383
384/// Enqueue an `agent_run` job that runs once after `delay`.
385///
386/// # Errors
387///
388/// See [`enqueue_agent_run`].
389pub async fn enqueue_agent_run_in(args: AgentRunArgs, delay: Duration) -> AutumnResult<()> {
390    agent_jobs::RunAgentJob::enqueue_in(args, delay).await
391}
392
393/// Enqueue an `agent_run` job and get a pollable handle.
394///
395/// The handle's token is a capability: anyone holding it may poll
396/// [`TrackedJobHandle::status_path`]. The tracked result is the serialized
397/// [`AgentOutcome`]; a paused run's result carries the [`RunState`] to
398/// resume.
399///
400/// # Errors
401///
402/// See [`enqueue_agent_run`].
403pub async fn enqueue_agent_run_tracked(args: AgentRunArgs) -> AutumnResult<TrackedJobHandle> {
404    agent_jobs::RunAgentJob::enqueue_tracked(args).await
405}
406
407/// Enqueue an `agent_resume` job.
408///
409/// # Errors
410///
411/// See [`enqueue_agent_run`].
412pub async fn enqueue_agent_resume(args: AgentResumeArgs) -> AutumnResult<()> {
413    agent_jobs::ResumeAgentJob::enqueue(args).await
414}
415
416/// Enqueue an `agent_resume` job and get a pollable handle.
417///
418/// # Errors
419///
420/// See [`enqueue_agent_run`].
421pub async fn enqueue_agent_resume_tracked(args: AgentResumeArgs) -> AutumnResult<TrackedJobHandle> {
422    agent_jobs::ResumeAgentJob::enqueue_tracked(args).await
423}
424
425#[cfg(test)]
426mod tests;