Skip to main content

agentd/agentloop/
stop.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Terminal statuses — the stop-condition disjunction.
3//!
4//! [`TerminalStatus`] is the single authority for *why* a run ended: a run
5//! stops for exactly one of these reasons, and [`crate::exit`] maps each to an
6//! exit code. "partial" is **not** a status — it is a property of the result
7//! body, so a run can `complete` while still carrying a partial answer; see
8//! [`Outcome`]. The two fatal-infra aborts (intelligence unreachable, a
9//! required MCP server down) are *aborts* rather than variants here: they never
10//! reach a terminal status and short-circuit to exit codes 4 / 6 directly.
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum TerminalStatus {
17    /// The model emitted a final answer. Success is judged against the tool
18    /// results the run actually produced, never against the model's own claim
19    /// that it succeeded.
20    Completed,
21    /// The agent concluded the task cannot/should not be done (semantic).
22    Refused,
23    /// Hit the per-run step cap.
24    ExhaustedSteps,
25    /// Hit the token budget (per-node or tree ceiling).
26    ExhaustedTokens,
27    /// Hit the loop's own wall-clock deadline.
28    Deadline,
29    /// Output content-hash unchanged for N turns (default 3) — spinning.
30    Stalled,
31    /// A single tool repeated past the per-tool cap K (default 3).
32    LoopDetected,
33    /// Cancelled by the supervisor (drain, parent cancel, route teardown).
34    Cancelled,
35    /// The subagent process crashed / was killed before a final.
36    Crashed,
37}
38
39impl TerminalStatus {
40    pub fn as_str(self) -> &'static str {
41        use TerminalStatus::*;
42        match self {
43            Completed => "completed",
44            Refused => "refused",
45            ExhaustedSteps => "exhausted_steps",
46            ExhaustedTokens => "exhausted_tokens",
47            Deadline => "deadline",
48            Stalled => "stalled",
49            LoopDetected => "loop_detected",
50            Cancelled => "cancelled",
51            Crashed => "crashed",
52        }
53    }
54
55    /// Did the run reach a clean, intended conclusion?
56    pub fn is_success(self) -> bool {
57        matches!(self, TerminalStatus::Completed)
58    }
59
60    /// Was the run cut short by a budget bound (steps/tokens/deadline)?
61    pub fn is_budget(self) -> bool {
62        matches!(
63            self,
64            TerminalStatus::ExhaustedSteps
65                | TerminalStatus::ExhaustedTokens
66                | TerminalStatus::Deadline
67        )
68    }
69}
70
71/// A future wake-up an agent requested for itself via the `schedule` self-tool.
72/// The reactive daemon arms it relative to now and re-invokes the agent with
73/// `instruction` when it fires — the agent setting its own next tick. Honoured
74/// only under a long-lived daemon: a one-shot run exits before any "later"
75/// could arrive, so the request is carried on the outcome but never armed.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ScheduleRequest {
78    /// Delay from the run's completion before the wake fires.
79    pub after_ms: u64,
80    /// The instruction the woken reaction runs.
81    pub instruction: String,
82}
83
84/// A resource (un)subscription an agent requested for itself via the
85/// `subscribe`/`unsubscribe`/`await_resource` self-tools. The reactive daemon
86/// applies it to its live subscriptions + router after the run, so an agent can
87/// widen or narrow what wakes it. Honoured only under a daemon. Not `Eq`,
88/// because a `condition` may carry a JSON number.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct SubscriptionRequest {
91    pub uri: String,
92    pub action: SubscriptionAction,
93    /// Optional content predicate (raw self-tool args, e.g.
94    /// `{"pointer":"/status","op":"eq","value":"ready"}`) for a conditional
95    /// `await_resource` subscribe — the route fires only when the resource content
96    /// satisfies it. Validated at tool-call time, then re-parsed into a
97    /// content-predicate condition when the daemon arms the route. `None` means
98    /// fire on any update (plain `subscribe`). Skipped in the wire form when
99    /// absent, so an `unsubscribe` / plain `subscribe` carries no extra key.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub condition: Option<serde_json::Value>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum SubscriptionAction {
107    Subscribe,
108    Unsubscribe,
109}
110
111/// A finished run: its terminal status, whether the result body is partial, the
112/// distilled result value, and any self-requested future wake-ups / resource
113/// (un)subscriptions. This is what a run hands back to its caller — a parent
114/// supervisor reads the same shape for a child it spawned.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Outcome {
117    pub status: TerminalStatus,
118    /// True when the agent produced *some* usable output but did not fully
119    /// satisfy the objective (drives exit code 3 in one-shot mode).
120    pub partial: bool,
121    pub result: serde_json::Value,
122    /// Future wake-ups the agent scheduled for itself. Empty unless the model
123    /// called `schedule`; acted on only by a daemon supervisor.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub scheduled: Vec<ScheduleRequest>,
126    /// Resource (un)subscriptions the agent requested for itself. Empty unless
127    /// the model called `subscribe`/`unsubscribe`; daemon-applied.
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub subscriptions: Vec<SubscriptionRequest>,
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn status_serializes_snake_case() {
138        let s = serde_json::to_string(&TerminalStatus::ExhaustedSteps).unwrap();
139        assert_eq!(s, "\"exhausted_steps\"");
140    }
141
142    #[test]
143    fn budget_classification() {
144        assert!(TerminalStatus::Deadline.is_budget());
145        assert!(!TerminalStatus::Completed.is_budget());
146        assert!(TerminalStatus::Completed.is_success());
147    }
148}