Skip to main content

runtime/tools/
succeed.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The face succession shows a model: ask to be replaced by a successor
7//! at the same address, the same depth, and therefore with the same
8//! tools — and the desk that holds the ask until the run freezes.
9//!
10//! **Succession is not delegation.** `delegate` adds one to the depth
11//! and hands part of the work to somebody else; `succeed` hands all of
12//! it to the next run of the same resident, which is why a successor
13//! may still delegate. No person is asked: nothing outside the run's
14//! own room changes, and no depth is added. What bounds a chain of
15//! successions is the hop counter its landing is checked against, in
16//! `crates/accounting/src/worker/driving/owing.rs`, whose ceilings are
17//! recorded in `crates/sprawling/Spec.lean` §8-46-12. `Halt` answers a different
18//! question: it shuts a scope to new work.
19//!
20//! **A request is not a run.** The tool answers with where the successor
21//! will start, not with a result, for the reason `delegate` gives: a run
22//! is built by the assembly layer, and a tool that drove one would be
23//! driving a run from inside another run's tool bench. The desk is read
24//! when the run concludes; a cancelled run is not succeeded.
25
26use kernel::{
27    AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
28    ToolName, ToolOutcome,
29};
30use serde_json::{Map, Value};
31
32/// What a run said when it asked to be replaced.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Succession {
35    pub reason: String,
36}
37
38/// Holds at most one ask. A second ask replaces the first, reason
39/// included: the run is replaced once whatever it says, and the last
40/// reason it gave is the one worth keeping.
41#[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    /// Takes the ask, leaving the desk empty. Called once when the run
62    /// concludes.
63    pub fn take(&mut self) -> Option<Succession> {
64        self.asked.take()
65    }
66}
67
68/// The tool itself.
69pub struct SucceedTool {
70    desk: std::sync::Arc<std::sync::Mutex<SuccessionDesk>>,
71    meta: ToolMeta,
72}
73
74impl SucceedTool {
75    /// # Errors
76    /// Propagates a malformed parameter schema, which is a build-time
77    /// defect rather than a runtime one.
78    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, no person asked. 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}