1use kernel::{
27 AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
28 ToolName, ToolOutcome,
29};
30use serde_json::{Map, Value};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Succession {
35 pub reason: String,
36}
37
38#[derive(Debug, Default)]
42pub struct SuccessionDesk {
43 asked: Option<Succession>,
44}
45
46impl SuccessionDesk {
47 #[must_use]
48 pub fn new() -> SuccessionDesk {
49 SuccessionDesk::default()
50 }
51
52 pub fn ask(&mut self, succession: Succession) {
53 self.asked = Some(succession);
54 }
55
56 #[must_use]
57 pub fn asked(&self) -> Option<&Succession> {
58 self.asked.as_ref()
59 }
60
61 pub fn take(&mut self) -> Option<Succession> {
64 self.asked.take()
65 }
66}
67
68pub struct SucceedTool {
70 desk: std::sync::Arc<std::sync::Mutex<SuccessionDesk>>,
71 meta: ToolMeta,
72}
73
74impl SucceedTool {
75 pub fn new(
79 desk: std::sync::Arc<std::sync::Mutex<SuccessionDesk>>,
80 ) -> Result<SucceedTool, AxError> {
81 let mut reason = Map::new();
82 reason.insert("type".to_owned(), Value::String("string".to_owned()));
83 reason.insert(
84 "description".to_owned(),
85 Value::String("one line: why this run hands over now".to_owned()),
86 );
87 let mut properties = Map::new();
88 properties.insert("reason".to_owned(), Value::Object(reason));
89 let mut params = Map::new();
90 params.insert("type".to_owned(), Value::String("object".to_owned()));
91 params.insert("properties".to_owned(), Value::Object(properties));
92 params.insert(
93 "required".to_owned(),
94 Value::Array(vec![Value::String("reason".to_owned())]),
95 );
96 Ok(SucceedTool {
97 desk,
98 meta: ToolMeta {
99 name: ToolName::parse("succeed")?,
100 disclosure: "Replace yourself with a successor at this address when the \
101 window fills: same tools, same depth, without asking the User. Write \
102 Handoff.md in your room first; the successor starts from it and \
103 from your transcript."
104 .to_owned(),
105 params: Payload::new(params)?,
106 effect: Effect::Read,
107 cost_tier: CostTier::Heavy,
108 timeout: None,
109 render: RenderIntent::Generic,
110 temporal: Temporal::Timeless,
111 },
112 })
113 }
114}
115
116impl Tool for SucceedTool {
117 fn meta(&self) -> &ToolMeta {
118 &self.meta
119 }
120
121 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
122 if call.name != self.meta.name {
123 return Err(AxError::failure(
124 AxCode::InvalidArgs,
125 "ask for a successor",
126 format!("call routed to the wrong tool: {}", call.name.as_str()),
127 )
128 .with_recovery(format!(
129 "call `{}`, the name this tool answers to",
130 self.meta.name.as_str()
131 )));
132 }
133 let reason = call
134 .args
135 .as_map()
136 .get("reason")
137 .and_then(Value::as_str)
138 .ok_or_else(|| {
139 AxError::failure(
140 AxCode::InvalidArgs,
141 "ask for a successor",
142 "missing string argument `reason`",
143 )
144 .with_recovery("say in one line why this run hands over now")
145 })?;
146 let mut desk = self.desk.lock().map_err(|_| {
147 AxError::failure(
148 AxCode::StorageFatal,
149 "ask for a successor",
150 "the desk was left locked by a thread that died",
151 )
152 .with_recovery(
153 "end this run and resume it: the succession desk cannot be reached \
154 again inside a process where a thread died holding it",
155 )
156 })?;
157 desk.ask(Succession {
158 reason: reason.to_owned(),
159 });
160 let mut out = Map::new();
161 out.insert(
162 "starts".to_owned(),
163 Value::String("when this run freezes, at this address".to_owned()),
164 );
165 out.insert(
166 "before_then".to_owned(),
167 Value::String(
168 "write Handoff.md in your room, then end your turn without calling a tool"
169 .to_owned(),
170 ),
171 );
172 Ok(ToolOutcome {
173 result: Payload::new(out)?,
174 attachments: Vec::new(),
175 })
176 }
177}
178
179#[cfg(test)]
180#[allow(
181 clippy::unwrap_used,
182 clippy::expect_used,
183 clippy::panic,
184 clippy::indexing_slicing,
185 reason = "test code"
186)]
187mod tests {
188 use super::*;
189
190 fn call(args: Value) -> ToolCall {
191 ToolCall {
192 id: "tu_1".to_owned(),
193 name: ToolName::parse("succeed").unwrap(),
194 args: Payload::new(args.as_object().cloned().unwrap()).unwrap(),
195 }
196 }
197
198 #[test]
199 fn asking_lands_on_the_desk_and_the_last_reason_wins() {
200 let desk = std::sync::Arc::new(std::sync::Mutex::new(SuccessionDesk::new()));
201 let tool = SucceedTool::new(std::sync::Arc::clone(&desk)).unwrap();
202 tool.invoke(&call(serde_json::json!({ "reason": "first" })))
203 .unwrap();
204 tool.invoke(&call(serde_json::json!({ "reason": "second" })))
205 .unwrap();
206 assert_eq!(desk.lock().unwrap().asked().unwrap().reason, "second");
207 let taken = desk.lock().unwrap().take().unwrap();
208 assert_eq!(taken.reason, "second");
209 assert!(
210 desk.lock().unwrap().asked().is_none(),
211 "taken once, empty after"
212 );
213 }
214
215 #[test]
216 fn a_missing_reason_is_refused_and_nothing_lands() {
217 let desk = std::sync::Arc::new(std::sync::Mutex::new(SuccessionDesk::new()));
218 let tool = SucceedTool::new(std::sync::Arc::clone(&desk)).unwrap();
219 let err = tool.invoke(&call(serde_json::json!({}))).unwrap_err();
220 assert_eq!(err.code(), &AxCode::InvalidArgs);
221 assert!(desk.lock().unwrap().asked().is_none());
222 }
223
224 #[test]
225 fn succession_asks_no_person_and_adds_no_depth() {
226 let desk = std::sync::Arc::new(std::sync::Mutex::new(SuccessionDesk::new()));
227 let tool = SucceedTool::new(desk).unwrap();
228 assert_eq!(
229 tool.meta().effect,
230 Effect::Read,
231 "not a spawn: no depth, no gate"
232 );
233 }
234}