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}