Skip to main content

agentd/agentloop/
stop.rs

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