Skip to main content

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}