use std::sync::Arc;
use std::time::Duration;
use autumn_web::job::{JobContext, TrackedJobHandle};
use autumn_web::{AppState, AutumnError, AutumnResult};
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use crate::agent::{AgentOutcome, AgentRuntime, ApprovalDecision, RunState};
use crate::error::AgentError;
use crate::hooks::{AgentHooks, RunInfo};
use crate::ids::{RunId, SessionId};
use crate::memory::MemoryScope;
use crate::policy::ToolPolicy;
use crate::proactive::{ReportSource, deliver_outcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunOrigin {
#[default]
Request,
Followup,
Heartbeat,
}
impl From<RunOrigin> for ReportSource {
fn from(origin: RunOrigin) -> Self {
match origin {
RunOrigin::Request => Self::Job,
RunOrigin::Followup => Self::Followup,
RunOrigin::Heartbeat => Self::Heartbeat,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AgentRunArgs {
pub prompt: String,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub max_steps: Option<u32>,
#[serde(default)]
pub session: Option<SessionId>,
#[serde(default)]
pub memory_scope: Option<MemoryScope>,
#[serde(default)]
pub deliver: bool,
#[serde(default)]
pub origin: RunOrigin,
#[serde(default = "RunId::generate")]
pub run_id: RunId,
#[serde(default)]
pub followup_depth: u32,
}
impl AgentRunArgs {
#[must_use]
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
system_prompt: None,
max_steps: None,
session: None,
memory_scope: None,
deliver: false,
origin: RunOrigin::Request,
run_id: RunId::generate(),
followup_depth: 0,
}
}
#[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 = Some(max_steps);
self
}
#[must_use]
pub fn session(mut self, session: impl Into<Option<SessionId>>) -> Self {
self.session = session.into();
self
}
#[must_use]
pub fn memory_scope(mut self, scope: impl Into<Option<MemoryScope>>) -> Self {
self.memory_scope = scope.into();
self
}
#[must_use]
pub const fn deliver(mut self, deliver: bool) -> Self {
self.deliver = deliver;
self
}
#[must_use]
pub const fn origin(mut self, origin: RunOrigin) -> Self {
self.origin = origin;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AgentResumeArgs {
pub state: RunState,
pub decisions: Vec<ApprovalDecision>,
#[serde(default)]
pub memory_scope: Option<MemoryScope>,
#[serde(default)]
pub deliver: bool,
}
impl AgentResumeArgs {
#[must_use]
pub const fn new(state: RunState, decisions: Vec<ApprovalDecision>) -> Self {
Self {
state,
decisions,
memory_scope: None,
deliver: false,
}
}
#[must_use]
pub fn memory_scope(mut self, scope: impl Into<Option<MemoryScope>>) -> Self {
self.memory_scope = scope.into();
self
}
#[must_use]
pub const fn deliver(mut self, deliver: bool) -> Self {
self.deliver = deliver;
self
}
}
#[derive(Debug)]
struct JobProgress;
impl AgentHooks for JobProgress {
fn before_model<'a>(
&'a self,
_request: &'a mut crate::client::ChatRequest,
info: &'a RunInfo,
) -> BoxFuture<'a, ()> {
Box::pin(async move {
let context = JobContext::current();
if !context.is_tracked() {
return;
}
let max = info.max_steps.max(1);
let pct = (info.steps_used.min(max).saturating_mul(99) / max).min(99);
let pct = u8::try_from(pct).unwrap_or(99);
let message = format!("step {} of {}", info.steps_used, info.max_steps);
if let Err(err) = context.set_progress(pct, Some(&message)).await {
tracing::debug!(error = %err, "cannot record agent job progress");
}
})
}
}
fn record_outcome(run_id: &RunId, outcome: &AgentOutcome) {
match serde_json::to_value(outcome) {
Ok(value) => JobContext::current().set_result(value),
Err(err) => tracing::warn!(error = %err, "cannot serialize the agent outcome"),
}
let usage = outcome.usage();
tracing::info!(
run_id = %run_id,
status = outcome_status(outcome),
steps_used = outcome.steps_used(),
input_tokens = usage.input_tokens,
output_tokens = usage.output_tokens,
cache_read_tokens = usage.cache_read_tokens,
"background agent run finished"
);
}
const fn outcome_status(outcome: &AgentOutcome) -> &'static str {
match outcome {
AgentOutcome::Completed { .. } => "completed",
AgentOutcome::BudgetExhausted { .. } => "budget_exhausted",
AgentOutcome::LoopDetected { .. } => "loop_detected",
AgentOutcome::AwaitingApproval { .. } => "awaiting_approval",
}
}
pub(crate) async fn execute_run(
runtime: &AgentRuntime,
args: AgentRunArgs,
policy: Option<Arc<dyn ToolPolicy>>,
) -> Result<AgentOutcome, AgentError> {
let scope = args.memory_scope.clone().unwrap_or_else(MemoryScope::agent);
let mut agent = runtime
.agent_with(scope, args.followup_depth)
.run_id(args.run_id.clone())
.hook(Arc::new(JobProgress));
if let Some(system) = &args.system_prompt {
agent = agent.system_prompt(system.clone());
}
if let Some(max_steps) = args.max_steps {
agent = agent.max_steps(max_steps);
}
if let Some(policy) = policy {
agent = agent.policy(policy);
}
let turn = match &args.session {
Some(session) => {
agent
.run_in_session(runtime.sessions().as_ref(), session, &args.prompt)
.await?
}
None => agent.run_turn(Vec::new(), &args.prompt).await?,
};
record_outcome(&args.run_id, &turn.outcome);
if args.deliver {
deliver_outcome(
runtime.delivery().as_ref(),
args.origin.into(),
&args.run_id,
args.session.as_ref(),
&turn.outcome,
)
.await;
}
Ok(turn.outcome)
}
pub(crate) async fn execute_resume(
runtime: &AgentRuntime,
args: AgentResumeArgs,
) -> Result<AgentOutcome, AgentError> {
let scope = args.memory_scope.clone().unwrap_or_else(MemoryScope::agent);
let run_id = args.state.run_id.clone();
let session = args.state.session_id.clone();
let agent = runtime
.agent_for(scope)
.run_id(run_id.clone())
.hook(Arc::new(JobProgress));
let turn = agent
.resume_in_session(runtime.sessions().as_ref(), args.state, args.decisions)
.await?;
record_outcome(&run_id, &turn.outcome);
if args.deliver {
deliver_outcome(
runtime.delivery().as_ref(),
ReportSource::Resume,
&run_id,
session.as_ref(),
&turn.outcome,
)
.await;
}
Ok(turn.outcome)
}
fn runtime_from(state: &AppState) -> AutumnResult<Arc<AgentRuntime>> {
state
.extension::<AgentRuntime>()
.ok_or_else(|| AutumnError::service_unavailable_msg("agent plugin is not installed"))
}
#[doc(hidden)]
#[allow(missing_docs)]
pub(crate) mod agent_jobs {
use super::{AgentResumeArgs, AgentRunArgs, execute_resume, execute_run, runtime_from};
use autumn_web::{AppState, AutumnResult};
#[autumn_web::job(name = "agent_run", max_attempts = 3, backoff_ms = 1_000)]
pub async fn run_agent(state: AppState, args: AgentRunArgs) -> AutumnResult<()> {
let runtime = runtime_from(&state)?;
execute_run(&runtime, args, None)
.await
.map(|_| ())
.map_err(|err| {
tracing::error!(error = %err, "background agent run failed");
err.into_autumn_error()
})
}
#[autumn_web::job(name = "agent_resume", max_attempts = 1)]
pub async fn resume_agent(state: AppState, args: AgentResumeArgs) -> AutumnResult<()> {
let runtime = runtime_from(&state)?;
execute_resume(&runtime, args)
.await
.map(|_| ())
.map_err(|err| {
tracing::error!(error = %err, "background agent resume failed");
err.into_autumn_error()
})
}
}
pub async fn enqueue_agent_run(args: AgentRunArgs) -> AutumnResult<()> {
agent_jobs::RunAgentJob::enqueue(args).await
}
pub async fn enqueue_agent_run_in(args: AgentRunArgs, delay: Duration) -> AutumnResult<()> {
agent_jobs::RunAgentJob::enqueue_in(args, delay).await
}
pub async fn enqueue_agent_run_tracked(args: AgentRunArgs) -> AutumnResult<TrackedJobHandle> {
agent_jobs::RunAgentJob::enqueue_tracked(args).await
}
pub async fn enqueue_agent_resume(args: AgentResumeArgs) -> AutumnResult<()> {
agent_jobs::ResumeAgentJob::enqueue(args).await
}
pub async fn enqueue_agent_resume_tracked(args: AgentResumeArgs) -> AutumnResult<TrackedJobHandle> {
agent_jobs::ResumeAgentJob::enqueue_tracked(args).await
}
#[cfg(test)]
mod tests;