Skip to main content

recursive/agent/
types.rs

1//! Cross-cutting agent types shared by the kernel, runtime, and tools.
2//!
3//! These types were extracted from `agent.rs` during Goal 219.  They have
4//! no dependency on the deprecated `Agent` / `StepEvent` types and are
5//! re-exported from `crate::agent::*` for backward compatibility.
6//!
7//! When Goal 219 Commit 2 deletes the deprecated `Agent` path, this
8//! module will be the sole owner of these four types.
9
10use serde::{Deserialize, Serialize};
11
12/// Decision returned by a permission hook to allow, deny, or transform a tool call.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum PermissionDecision {
16    /// Let the tool execute with the original arguments.
17    Allow,
18    /// Block execution and return the reason as a tool error to the model.
19    Deny(String),
20    /// Replace the arguments before execution.
21    Transform(serde_json::Value),
22}
23
24/// Controls how the agent executes tool calls.
25///
26/// Currently only `Immediate` is supported. Agent-driven planning is handled by
27/// the `enter_plan_mode` / `exit_plan_mode` tool pair (Plan Mode 2.0), which does
28/// not require a separate runtime mode flag.
29#[derive(Debug, Clone, PartialEq, Default)]
30pub enum PlanningMode {
31    /// Execute tool calls immediately.
32    #[default]
33    Immediate,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(tag = "kind", rename_all = "snake_case")]
38/// Why the agent's run terminated.
39///
40/// # Variants
41///
42/// - `NoMoreToolCalls`: Model produced a response without tool calls (natural completion).
43/// - `BudgetExceeded`: Ran out of steps (when `max_steps > 0`). Agent likely unfinished.
44/// - `ProviderStop(reason)`: LLM provider stopped unexpectedly. `reason` may be "length"
45///   (truncated by token limit), "stop"/"end_turn", or a provider-specific code.
46/// - `Stuck`: Agent got stuck calling the same tool repeatedly with the same arguments.
47///   `repeated_call` is the tool name, `repeats` is how many times before stopping.
48/// - `TranscriptLimit`: Transcript size hit `max_transcript_chars` hard limit before
49///   compaction could reduce it further. Agent cannot continue. `chars` is final size,
50///   `limit` is the configured maximum.
51#[non_exhaustive]
52pub enum FinishReason {
53    /// Model generated final response without requesting more tools.
54    NoMoreToolCalls,
55    /// Agent exceeded the maximum number of steps allowed.
56    BudgetExceeded,
57    /// LLM provider stopped with a specific reason or status code.
58    ProviderStop(String),
59    /// Agent detected repeated identical tool calls (stuck loop).
60    Stuck {
61        repeated_call: String,
62        repeats: usize,
63    },
64    /// Transcript size exceeded hard limit and cannot be reduced further.
65    TranscriptLimit { chars: usize, limit: usize },
66    /// Agent was cancelled by a shutdown signal (SIGINT/SIGTERM).
67    Cancelled,
68
69    /// The auto permission classifier reached its denial limit
70    /// (3 consecutive or 10 total denials). All subsequent tool
71    /// calls are blocked to prevent denial loops.
72    PermissionDenialLimit,
73
74    /// Wall-clock deadline exceeded (Goal 345). The agent's
75    /// `--wall-timeout` was reached before any other finish
76    /// condition.
77    WallClockExceeded { secs: u64 },
78}
79
80impl std::fmt::Display for FinishReason {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            FinishReason::NoMoreToolCalls => write!(f, "no_more_tool_calls"),
84            FinishReason::BudgetExceeded => write!(f, "budget_exceeded"),
85            FinishReason::ProviderStop(reason) => write!(f, "provider_stop:{reason}"),
86            FinishReason::Stuck {
87                repeated_call,
88                repeats,
89            } => write!(f, "stuck:{repeated_call}:{repeats}"),
90            FinishReason::TranscriptLimit { chars, limit } => {
91                write!(f, "transcript_limit:{chars}/{limit}")
92            }
93            FinishReason::Cancelled => write!(f, "cancelled"),
94            FinishReason::PermissionDenialLimit => write!(f, "permission_denial_limit"),
95            FinishReason::WallClockExceeded { secs } => {
96                write!(f, "wall_clock_exceeded:{secs}")
97            }
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn finish_reason_display_all_variants() {
108        // kills `replace <impl Display for FinishReason>::fmt with Ok(Default::default())`
109        // and individual match-arm replacements.
110        assert_eq!(
111            FinishReason::NoMoreToolCalls.to_string(),
112            "no_more_tool_calls"
113        );
114        assert_eq!(FinishReason::BudgetExceeded.to_string(), "budget_exceeded");
115        assert_eq!(
116            FinishReason::ProviderStop("length".into()).to_string(),
117            "provider_stop:length"
118        );
119        assert_eq!(
120            FinishReason::Stuck {
121                repeated_call: "Read".into(),
122                repeats: 3
123            }
124            .to_string(),
125            "stuck:Read:3"
126        );
127        assert_eq!(
128            FinishReason::TranscriptLimit {
129                chars: 10_000,
130                limit: 8_000
131            }
132            .to_string(),
133            "transcript_limit:10000/8000"
134        );
135        assert_eq!(FinishReason::Cancelled.to_string(), "cancelled");
136        assert_eq!(
137            FinishReason::PermissionDenialLimit.to_string(),
138            "permission_denial_limit"
139        );
140        assert_eq!(
141            FinishReason::WallClockExceeded { secs: 120 }.to_string(),
142            "wall_clock_exceeded:120"
143        );
144    }
145
146    #[test]
147    fn planning_mode_default_is_immediate() {
148        assert_eq!(PlanningMode::default(), PlanningMode::Immediate);
149    }
150
151    #[test]
152    fn finish_reason_struct_variants_serialize_with_kind_tag() {
153        // kills mutations swapping `kind` tag values for struct/unit variants
154        let json = serde_json::to_value(&FinishReason::NoMoreToolCalls).unwrap();
155        assert_eq!(json["kind"], "no_more_tool_calls");
156
157        let json = serde_json::to_value(&FinishReason::BudgetExceeded).unwrap();
158        assert_eq!(json["kind"], "budget_exceeded");
159
160        let json = serde_json::to_value(&FinishReason::Stuck {
161            repeated_call: "Bash".into(),
162            repeats: 5,
163        })
164        .unwrap();
165        assert_eq!(json["kind"], "stuck");
166        assert_eq!(json["repeated_call"], "Bash");
167        assert_eq!(json["repeats"], 5);
168
169        let json = serde_json::to_value(&FinishReason::TranscriptLimit {
170            chars: 1000,
171            limit: 500,
172        })
173        .unwrap();
174        assert_eq!(json["kind"], "transcript_limit");
175        assert_eq!(json["chars"], 1000);
176        assert_eq!(json["limit"], 500);
177
178        let json = serde_json::to_value(&FinishReason::Cancelled).unwrap();
179        assert_eq!(json["kind"], "cancelled");
180
181        let json = serde_json::to_value(&FinishReason::PermissionDenialLimit).unwrap();
182        assert_eq!(json["kind"], "permission_denial_limit");
183
184        let json = serde_json::to_value(&FinishReason::WallClockExceeded { secs: 300 }).unwrap();
185        assert_eq!(json["kind"], "wall_clock_exceeded");
186        assert_eq!(json["secs"], 300);
187    }
188
189    #[test]
190    fn finish_reason_deserializes_from_kind_tag() {
191        // kills mutations that swap field names on deserialization
192        let r: FinishReason = serde_json::from_str(r#"{"kind":"no_more_tool_calls"}"#).unwrap();
193        assert!(matches!(r, FinishReason::NoMoreToolCalls));
194
195        let r: FinishReason =
196            serde_json::from_str(r#"{"kind":"stuck","repeated_call":"Read","repeats":3}"#).unwrap();
197        match r {
198            FinishReason::Stuck {
199                repeated_call,
200                repeats,
201            } => {
202                assert_eq!(repeated_call, "Read");
203                assert_eq!(repeats, 3);
204            }
205            other => panic!("expected Stuck, got {other:?}"),
206        }
207
208        let r: FinishReason = serde_json::from_str(r#"{"kind":"cancelled"}"#).unwrap();
209        assert!(matches!(r, FinishReason::Cancelled));
210
211        let r: FinishReason =
212            serde_json::from_str(r#"{"kind":"wall_clock_exceeded","secs":99}"#).unwrap();
213        assert!(matches!(r, FinishReason::WallClockExceeded { secs: 99 }));
214    }
215
216    #[test]
217    fn permission_decision_serializes_deny_and_allow() {
218        // kills variant swap mutations in PermissionDecision serialization
219        let deny = serde_json::to_value(PermissionDecision::Deny("blocked".into())).unwrap();
220        // snake_case rename_all → key is "deny"
221        assert!(
222            deny.get("deny").is_some(),
223            "Deny must have 'deny' key, got {deny}"
224        );
225
226        let allow = serde_json::to_value(&PermissionDecision::Allow).unwrap();
227        assert_eq!(
228            allow,
229            serde_json::json!("allow"),
230            "Allow must serialize to string 'allow'"
231        );
232    }
233}