1use std::future::Future;
21use std::pin::Pin;
22use std::sync::Arc;
23use std::time::Duration;
24
25use autumn_web::task::{Schedule, TaskCoordination, TaskInfo};
26use autumn_web::{AppState, AutumnResult};
27use futures::future::BoxFuture;
28use serde::{Deserialize, Serialize};
29
30use crate::agent::{AgentOutcome, AgentRuntime};
31use crate::error::{AgentError, ErrorKind};
32use crate::ids::{RunId, SessionId};
33use crate::jobs::{AgentRunArgs, RunOrigin, enqueue_agent_run_in, execute_run};
34use crate::memory::MemoryScope;
35use crate::policy::{Strictest, ToolPolicy, ToolRules};
36use crate::tools::{Tool, ToolContext, ToolEffect};
37
38pub const HEARTBEAT_OK: &str = "HEARTBEAT_OK";
40
41pub const SILENT_ACK_MAX_CHARS: usize = 300;
44
45pub const DEFAULT_HEARTBEAT_PROMPT: &str = "This is a scheduled heartbeat check-in. Review your memory, your standing instructions, and anything your tools can show you. If something needs the user's attention, report it in a few sentences. If nothing needs attention, reply with exactly HEARTBEAT_OK.";
47
48#[must_use]
52pub fn is_silent(text: &str) -> bool {
53 let text = text.trim();
54 let rest = text
55 .strip_prefix(HEARTBEAT_OK)
56 .or_else(|| text.strip_suffix(HEARTBEAT_OK));
57 rest.is_some_and(|rest| rest.trim().chars().count() <= SILENT_ACK_MAX_CHARS)
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum ReportSource {
64 Heartbeat,
66 Followup,
68 Job,
70 Resume,
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub struct Report {
77 pub source: ReportSource,
79 pub run_id: RunId,
81 pub session_id: Option<SessionId>,
84 pub text: String,
86 pub outcome: AgentOutcome,
89}
90
91pub trait Delivery: Send + Sync + std::fmt::Debug {
93 fn deliver<'a>(&'a self, report: &'a Report) -> BoxFuture<'a, Result<(), AgentError>>;
95}
96
97#[derive(Debug, Clone, Copy, Default)]
99pub struct LogDelivery;
100
101impl Delivery for LogDelivery {
102 fn deliver<'a>(&'a self, report: &'a Report) -> BoxFuture<'a, Result<(), AgentError>> {
103 tracing::info!(
104 source = ?report.source,
105 run_id = %report.run_id,
106 session = report.session_id.as_ref().map(SessionId::as_str),
107 text = %report.text,
108 "agent report"
109 );
110 Box::pin(std::future::ready(Ok(())))
111 }
112}
113
114#[must_use]
119pub fn report_text(outcome: &AgentOutcome) -> Option<String> {
120 match outcome {
121 AgentOutcome::Completed { text, .. } if !is_silent(text) => Some(text.clone()),
122 AgentOutcome::AwaitingApproval { state } => {
123 let lines: Vec<String> = state
124 .awaiting()
125 .map(|pending| format!("- {} {}", pending.call.name, pending.call.arguments))
126 .collect();
127 Some(format!(
128 "The agent wants to run these calls and needs approval:\n{}",
129 lines.join("\n")
130 ))
131 }
132 _ => None,
133 }
134}
135
136pub(crate) async fn deliver_outcome(
139 delivery: &dyn Delivery,
140 source: ReportSource,
141 run_id: &RunId,
142 session_id: Option<&SessionId>,
143 outcome: &AgentOutcome,
144) {
145 let Some(text) = report_text(outcome) else {
146 tracing::debug!(run_id = %run_id, ?source, "background run had nothing to report");
147 return;
148 };
149 let report = Report {
150 source,
151 run_id: run_id.clone(),
152 session_id: session_id.cloned(),
153 text,
154 outcome: outcome.clone(),
155 };
156 if let Err(err) = delivery.deliver(&report).await {
157 tracing::error!(run_id = %run_id, error = %err, "cannot deliver the agent report");
158 }
159}
160
161pub type Precheck = Arc<dyn Fn(AppState) -> BoxFuture<'static, bool> + Send + Sync>;
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum HeartbeatSchedule {
167 Every(Duration),
169 Cron {
171 expression: String,
173 timezone: Option<String>,
175 },
176}
177
178#[derive(Clone)]
191pub struct Heartbeat {
192 schedule: HeartbeatSchedule,
193 prompt: String,
194 session: Option<SessionId>,
195 memory_scope: Option<MemoryScope>,
196 read_only: bool,
197 coordination: TaskCoordination,
198 precheck: Option<Precheck>,
199}
200
201impl std::fmt::Debug for Heartbeat {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.debug_struct("Heartbeat")
204 .field("schedule", &self.schedule)
205 .field("session", &self.session)
206 .field("memory_scope", &self.memory_scope)
207 .field("read_only", &self.read_only)
208 .field("coordination", &self.coordination)
209 .field("precheck", &self.precheck.is_some())
210 .finish_non_exhaustive()
211 }
212}
213
214impl Heartbeat {
215 pub const TASK_NAME: &'static str = "agent_heartbeat";
217
218 fn with_schedule(schedule: HeartbeatSchedule) -> Self {
219 Self {
220 schedule,
221 prompt: DEFAULT_HEARTBEAT_PROMPT.to_owned(),
222 session: None,
223 memory_scope: None,
224 read_only: true,
225 coordination: TaskCoordination::Fleet,
226 precheck: None,
227 }
228 }
229
230 #[must_use]
232 pub fn every(interval: Duration) -> Self {
233 Self::with_schedule(HeartbeatSchedule::Every(interval))
234 }
235
236 #[must_use]
238 pub fn cron(expression: impl Into<String>) -> Self {
239 Self::with_schedule(HeartbeatSchedule::Cron {
240 expression: expression.into(),
241 timezone: None,
242 })
243 }
244
245 #[must_use]
247 pub fn timezone(mut self, timezone: impl Into<String>) -> Self {
248 if let HeartbeatSchedule::Cron { timezone: tz, .. } = &mut self.schedule {
249 *tz = Some(timezone.into());
250 }
251 self
252 }
253
254 #[must_use]
257 pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
258 self.prompt = prompt.into();
259 self
260 }
261
262 #[must_use]
264 pub fn session(mut self, session: impl Into<SessionId>) -> Self {
265 self.session = Some(session.into());
266 self
267 }
268
269 #[must_use]
271 pub fn memory_scope(mut self, scope: MemoryScope) -> Self {
272 self.memory_scope = Some(scope);
273 self
274 }
275
276 #[must_use]
279 pub const fn allow_actions(mut self) -> Self {
280 self.read_only = false;
281 self
282 }
283
284 #[must_use]
286 pub const fn per_replica(mut self) -> Self {
287 self.coordination = TaskCoordination::PerReplica;
288 self
289 }
290
291 #[must_use]
293 pub fn precheck<F, Fut>(mut self, check: F) -> Self
294 where
295 F: Fn(AppState) -> Fut + Send + Sync + 'static,
296 Fut: Future<Output = bool> + Send + 'static,
297 {
298 self.precheck = Some(Arc::new(move |state| Box::pin(check(state))));
299 self
300 }
301
302 #[must_use]
304 pub const fn schedule(&self) -> &HeartbeatSchedule {
305 &self.schedule
306 }
307
308 pub fn validate(&self) -> Result<(), AgentError> {
315 match &self.schedule {
316 HeartbeatSchedule::Every(interval) if *interval < Duration::from_secs(1) => {
317 return Err(AgentError::new(
318 ErrorKind::Config,
319 "heartbeat interval must be at least one second",
320 ));
321 }
322 HeartbeatSchedule::Cron { expression, .. } if expression.trim().is_empty() => {
323 return Err(AgentError::new(
324 ErrorKind::Config,
325 "heartbeat cron expression must not be blank",
326 ));
327 }
328 _ => {}
329 }
330 if self.prompt.trim().is_empty() {
331 return Err(AgentError::new(
332 ErrorKind::Config,
333 "heartbeat prompt must not be blank",
334 ));
335 }
336 Ok(())
337 }
338
339 pub(crate) fn task_info(&self) -> TaskInfo {
341 let schedule = match &self.schedule {
342 HeartbeatSchedule::Every(interval) => Schedule::FixedDelay(*interval),
343 HeartbeatSchedule::Cron {
344 expression,
345 timezone,
346 } => Schedule::Cron {
347 expression: expression.clone(),
348 timezone: timezone.clone(),
349 },
350 };
351 TaskInfo {
352 name: Self::TASK_NAME.to_owned(),
353 schedule,
354 coordination: self.coordination,
355 handler: heartbeat_tick,
356 }
357 }
358
359 pub async fn tick(
366 &self,
367 runtime: &AgentRuntime,
368 state: &AppState,
369 ) -> Result<Option<AgentOutcome>, AgentError> {
370 if let Some(check) = &self.precheck
371 && !check(state.clone()).await
372 {
373 tracing::debug!("heartbeat precheck found nothing to do; skipping the tick");
374 return Ok(None);
375 }
376 let args = AgentRunArgs::new(self.prompt.clone())
377 .session(self.session.clone())
378 .memory_scope(self.memory_scope.clone())
379 .deliver(true)
380 .origin(RunOrigin::Heartbeat);
381 let policy: Option<Arc<dyn ToolPolicy>> = self.read_only.then(|| {
382 Arc::new(Strictest::new(vec![
383 runtime.policy(),
384 Arc::new(ToolRules::read_only()),
385 ])) as Arc<dyn ToolPolicy>
386 });
387 execute_run(runtime, args, policy).await.map(Some)
388 }
389}
390
391#[derive(Debug, Clone)]
393pub(crate) struct HeartbeatSettings(pub(crate) Heartbeat);
394
395fn heartbeat_tick(state: AppState) -> Pin<Box<dyn Future<Output = AutumnResult<()>> + Send>> {
397 Box::pin(async move {
398 let (Some(runtime), Some(settings)) = (
399 state.extension::<AgentRuntime>(),
400 state.extension::<HeartbeatSettings>(),
401 ) else {
402 tracing::warn!("agent heartbeat fired before the agent plugin started");
403 return Ok(());
404 };
405 settings
406 .0
407 .tick(&runtime, &state)
408 .await
409 .map(|_| ())
410 .map_err(|err| {
411 tracing::error!(error = %err, "agent heartbeat failed");
412 err.into_autumn_error()
413 })
414 })
415}
416
417#[derive(Debug, Clone)]
423pub struct FollowupTool {
424 max_delay: Duration,
425 memory_scope: Option<MemoryScope>,
426 depth: u32,
427}
428
429impl FollowupTool {
430 pub const MAX_PROMPT_CHARS: usize = 2_000;
432
433 pub const MAX_CHAIN: u32 = 10;
436
437 #[must_use]
439 pub const fn new(max_delay: Duration) -> Self {
440 Self {
441 max_delay,
442 memory_scope: None,
443 depth: 0,
444 }
445 }
446
447 #[must_use]
449 pub const fn depth(mut self, depth: u32) -> Self {
450 self.depth = depth;
451 self
452 }
453
454 #[must_use]
456 pub fn memory_scope(mut self, scope: MemoryScope) -> Self {
457 self.memory_scope = Some(scope);
458 self
459 }
460
461 fn plan(
463 &self,
464 input: &serde_json::Value,
465 ctx: &ToolContext,
466 ) -> Result<(AgentRunArgs, Duration), AgentError> {
467 let depth = self.depth.saturating_add(1);
468 if depth > Self::MAX_CHAIN {
469 return Err(AgentError::new(
470 ErrorKind::Tool,
471 format!(
472 "this run is already {} follow-ups deep; finish now and report instead of scheduling another",
473 self.depth
474 ),
475 ));
476 }
477 let max_minutes = self.max_delay.as_secs() / 60;
478 let minutes = input
479 .get("delay_minutes")
480 .and_then(serde_json::Value::as_u64)
481 .filter(|minutes| (1..=max_minutes).contains(minutes))
482 .ok_or_else(|| {
483 AgentError::new(
484 ErrorKind::Tool,
485 format!("delay_minutes must be a whole number from 1 to {max_minutes}"),
486 )
487 })?;
488 let prompt = input
489 .get("prompt")
490 .and_then(serde_json::Value::as_str)
491 .map(str::trim)
492 .filter(|prompt| !prompt.is_empty())
493 .ok_or_else(|| {
494 AgentError::new(ErrorKind::Tool, "give a `prompt` for the follow-up run")
495 })?;
496 if prompt.chars().count() > Self::MAX_PROMPT_CHARS {
497 return Err(AgentError::new(
498 ErrorKind::Tool,
499 format!(
500 "the follow-up prompt must be at most {} characters",
501 Self::MAX_PROMPT_CHARS
502 ),
503 ));
504 }
505 let mut args = AgentRunArgs::new(prompt)
506 .session(ctx.session_id.clone())
507 .memory_scope(self.memory_scope.clone())
508 .deliver(true)
509 .origin(RunOrigin::Followup);
510 args.followup_depth = depth;
511 Ok((args, Duration::from_secs(minutes.saturating_mul(60))))
512 }
513}
514
515impl Tool for FollowupTool {
516 fn name(&self) -> &'static str {
517 "schedule_followup"
518 }
519
520 fn description(&self) -> &'static str {
521 "Wake yourself up later: after delay_minutes, a new run starts with `prompt` in this conversation, and its answer is sent to the user. Use it to check back on something that is not ready yet."
522 }
523
524 fn input_schema(&self) -> serde_json::Value {
525 serde_json::json!({
526 "type": "object",
527 "properties": {
528 "delay_minutes": {
529 "type": "integer",
530 "minimum": 1,
531 "maximum": self.max_delay.as_secs() / 60,
532 },
533 "prompt": {
534 "type": "string",
535 "description": "What your future self should do, with every detail it needs.",
536 }
537 },
538 "required": ["delay_minutes", "prompt"]
539 })
540 }
541
542 fn effect(&self) -> ToolEffect {
543 ToolEffect::Internal
544 }
545
546 fn execute<'a>(
547 &'a self,
548 input: serde_json::Value,
549 ctx: &'a ToolContext,
550 ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, AgentError>> + Send + 'a>> {
551 Box::pin(async move {
552 let (args, delay) = self.plan(&input, ctx)?;
553 let run_id = args.run_id.clone();
554 enqueue_agent_run_in(args, delay).await.map_err(|err| {
555 AgentError::new(
556 ErrorKind::Tool,
557 format!("cannot schedule the follow-up: {err}"),
558 )
559 })?;
560 Ok(serde_json::json!({
561 "scheduled": true,
562 "in_minutes": delay.as_secs() / 60,
563 "run_id": run_id,
564 }))
565 })
566 }
567}
568
569#[cfg(test)]
570mod tests;