phi_core/tools/revert.rs
1//! Model-invocable tool for Composition I "braking": tree-structured revert
2//! between turns. See `docs/concepts/concept-brake.md` §5.
3//
4// ARCHITECTURE: RevertTool — model-directed branch abandonment via deferred execution
5//
6// Mirrors the deferred-apply pattern from `PrunTool` (see `tools/prun.rs`): the
7// tool's `execute()` validates input and enqueues a `RevertRequest` on a shared
8// `Arc<Mutex<Vec<_>>>`; the agent loop drains the queue between turns and applies
9// each request via `apply_revert` (Phase 3). The tool itself does NOT mutate
10// `AgentContext`.
11//
12// Why deferred (same reasoning as `PrunTool`):
13// 1. Ownership — tools see `&self`; the active-node-id and message tree are
14// owned by the agent loop. Threading `&mut AgentContext` through
15// `ToolContext` would defeat parallel tool execution.
16// 2. Timing — mid-turn mutation while the LLM stream is open would corrupt
17// content_index counters in `StreamEvent` deltas. Between-turn application
18// is the only safe window.
19// 3. Auditing — every drained `RevertRequest` produces an `AgentEvent::RevertApplied`
20// (Phase 3) which the session recorder auto-persists; the abandoned span
21// lives forever in the forensic `messages` log and is only off-trunk.
22//
23// Opt-in guarantee: `RevertTool` is registered exclusively by
24// `BasicAgent::with_revert_tool()`. The LLM never sees the tool unless the
25// builder explicitly enables it; the apply_revert drain is gated on
26// `AgentLoopConfig.revert_pending.is_some()`, which is set only by the same
27// builder method. There is no other registration path.
28
29use crate::types::{
30 AgentTool, Content, NodeId, RevertCategory, ToolContext, ToolError, ToolResult,
31};
32use std::sync::{Arc, Mutex};
33
34/// A pending revert request the LLM submitted via `revert_to_state`.
35///
36/// Lifecycle:
37/// 1. [`RevertTool::execute`] pushes one of these onto the shared queue.
38/// 2. The agent loop drains the queue between turns and calls `apply_revert`
39/// on each (Phase 3).
40/// 3. `apply_revert` validates the target, moves `AgentContext.active_node_id`,
41/// attaches a [`NodeTag`](crate::types::NodeTag) carrying `summary`, and emits
42/// `AgentEvent::RevertApplied` with the structured outcome.
43#[derive(Debug, Clone)]
44pub struct RevertRequest {
45 /// Which of the four categories the agent chose — drives the resulting
46 /// [`TagKind`](crate::types::TagKind) and the kind-aware render policy.
47 pub category: RevertCategory,
48 /// The [`NodeId`] the agent wants to revert to. The abandoned span is
49 /// everything strictly after this node on the current trunk.
50 pub target: NodeId,
51 /// Agent-supplied one-line summary that becomes the
52 /// [`NodeTag::text`](crate::types::NodeTag::text) attached to the target
53 /// node. `None` is structurally valid — `apply_revert` attaches an empty
54 /// tag — and reserved for a future fallback generator.
55 pub summary: Option<String>,
56}
57
58/// Structured metadata persisted in the `details` field of the synthetic
59/// `revert_to_state` `ToolResult`, and (mirroring `PrunRecord`) carried into
60/// the `AgentEvent::RevertApplied` payload by `apply_revert`.
61///
62/// Source-of-truth for revert observability: a session replay can reconstruct
63/// exactly which branch was abandoned, what category the agent assigned, and
64/// what summary it wrote.
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
66pub struct RevertRecord {
67 /// Category the agent assigned at call time.
68 pub category: RevertCategory,
69 /// Target node — the new active-node-id after the revert.
70 pub target: NodeId,
71 /// The `node_id`s of every message that fell off-trunk as a result of the
72 /// revert. Populated by `apply_revert` (Phase 3); empty at enqueue time.
73 pub abandoned_node_ids: Vec<NodeId>,
74 /// Echo of `RevertRequest.summary`.
75 pub summary: Option<String>,
76}
77
78/// Model-invocable tool that enqueues a revert request between turns.
79///
80/// Construction is gated by [`BasicAgent::with_revert_tool`](crate::agents::BasicAgent::with_revert_tool);
81/// the tool struct itself is `pub` so that custom agents (e.g. embedded
82/// downstream wrappers) can wire it manually if they share the same
83/// `Arc<Mutex<Vec<RevertRequest>>>` with [`AgentLoopConfig::revert_pending`](crate::agent_loop::AgentLoopConfig).
84pub struct RevertTool {
85 /// Shared queue read by the agent-loop drain. The Arc + Mutex pattern is
86 /// identical to `PrunTool::pending`.
87 pending: Arc<Mutex<Vec<RevertRequest>>>,
88}
89
90impl RevertTool {
91 /// Bind a new `RevertTool` to a shared pending queue.
92 pub fn new(pending: Arc<Mutex<Vec<RevertRequest>>>) -> Self {
93 Self { pending }
94 }
95}
96
97#[async_trait::async_trait]
98impl AgentTool for RevertTool {
99 fn name(&self) -> &str {
100 "revert_to_state"
101 }
102
103 fn label(&self) -> &str {
104 "Revert to State"
105 }
106
107 fn description(&self) -> &str {
108 "Abandon the current branch and return the conversation trunk to an earlier node. Use when a branch failed (failure), an exploration is finished (tangent), a sub-task is sealed (completion), or the trunk is long enough that a checkpoint helps (step-summary). Supply a one-line `summary` distilling what to remember; it is attached as an annotation on the target node so the next turn sees the lesson without the abandoned chatter. Abandoned messages stay in the forensic session log; only the active conversation context is rebuilt."
109 }
110
111 fn parameters_schema(&self) -> serde_json::Value {
112 serde_json::json!({
113 "type": "object",
114 "properties": {
115 "category": {
116 "type": "string",
117 "enum": ["failure", "tangent", "completion", "step-summary"],
118 "description": "failure = dead-end branch to learn from; tangent = finished exploration to fold back; completion = sealed sub-task outcome; step-summary = checkpoint a long ongoing trunk."
119 },
120 "step": {
121 "type": "string",
122 "description": "Node identifier to revert to. Accepts the inline render (e.g. \"n10\") or a bare integer (\"10\")."
123 },
124 "summary": {
125 "type": "string",
126 "description": "Optional one-line summary attached as an annotation on the target node — what to remember about the abandoned branch."
127 }
128 },
129 "required": ["category", "step"]
130 })
131 }
132
133 /*
134 DESIGN: execute() enqueues; apply_revert (Phase 3) mutates.
135
136 Three responsibilities:
137 1. Parse + validate `category` (must be one of the four kebab-case values).
138 2. Parse + validate `step` (lenient — `NodeId::parse` accepts both `"n12"`
139 and `"12"`).
140 3. Optionally lift `summary` (any non-string value is treated as absent).
141
142 On success: push a `RevertRequest` and return a synthetic ack so the LLM
143 sees the call was accepted. The real work — moving the active pointer,
144 attaching the NodeTag, emitting the event, rejecting unsafe targets —
145 happens in `apply_revert`.
146
147 `_ctx` is unused: there is no I/O, no cancellation budget to honour, no
148 streaming output. Same shape as `PrunTool::execute`.
149 */
150 async fn execute(
151 &self,
152 params: serde_json::Value,
153 _ctx: ToolContext,
154 ) -> Result<ToolResult, ToolError> {
155 let category_raw = params
156 .get("category")
157 .and_then(|v| v.as_str())
158 .ok_or_else(|| ToolError::InvalidArgs("category is required".to_string()))?;
159 let category: RevertCategory = serde_json::from_value(serde_json::Value::String(
160 category_raw.to_string(),
161 ))
162 .map_err(|_| {
163 ToolError::InvalidArgs(format!(
164 "category must be one of failure | tangent | completion | step-summary; got {:?}",
165 category_raw
166 ))
167 })?;
168
169 let step_raw = params
170 .get("step")
171 .and_then(|v| v.as_str())
172 .ok_or_else(|| ToolError::InvalidArgs("step is required".to_string()))?;
173 let target = NodeId::parse(step_raw).ok_or_else(|| {
174 ToolError::InvalidArgs(format!(
175 "step must be a node identifier like \"n12\" or \"12\"; got {:?}",
176 step_raw
177 ))
178 })?;
179
180 let summary = params
181 .get("summary")
182 .and_then(|v| v.as_str())
183 .map(|s| s.to_string());
184
185 self.pending.lock().unwrap().push(RevertRequest {
186 category,
187 target,
188 summary: summary.clone(),
189 });
190
191 let ack_text = match summary.as_deref() {
192 Some(s) => format!(
193 "Revert request recorded: category={:?}, target={}, summary={:?}. The trunk will be rebuilt from this node before the next turn.",
194 category, target, s
195 ),
196 None => format!(
197 "Revert request recorded: category={:?}, target={}. The trunk will be rebuilt from this node before the next turn.",
198 category, target
199 ),
200 };
201 Ok(ToolResult {
202 content: vec![Content::Text { text: ack_text }],
203 details: serde_json::Value::Null,
204 child_loop_id: None,
205 })
206 }
207}