Skip to main content

lc_agents/
graph_approval.rs

1// lc-agents/src/graph_approval.rs
2//! #2 convergence: approval-as-graph-interrupt.
3//!
4//! When an agent runs as a graph, an approval-gated tool is a
5//! [`GraphNode`](lc_langgraph::GraphNode)
6//! that suspends via the runtime interrupt protocol instead of holding the
7//! approval signal inside the process:
8//!
9//! - **First pass** the node raises
10//!   [`GraphError::InterruptRequest`](lc_langgraph::GraphError::InterruptRequest) whose
11//!   payload is the approval context (tool + intended command). The graph
12//!   checkpointer is then the **single persistence** — the separate
13//!   [`crate::resume::ResumeStore`] is *not* used on this path.
14//! - A human / caller feeds an [`ApprovalDecision`] back through
15//!   [`lc_langgraph::compiled::CompiledGraph::resume_with_value`]; the same
16//!   node re-enters **with** the decision and applies it — `Allow` / `Modify`
17//!   run the tool, `Deny` skips it. Nothing is replayed.
18//!
19//! This collapses the two "resume" concepts (#1/#2) onto one set: the decision
20//! *is* the graph resume value, and the graph checkpoint is the recovery
21//! mechanism.
22
23use async_trait::async_trait;
24use lc_langgraph::{
25    AgentState, GraphError, GraphNode, NodeConfig, NodeResult, StateUpdate, INTERRUPT_RESUME_KEY,
26};
27use std::sync::Arc;
28
29use crate::approval::ApprovalDecision;
30
31/// An approval-gated tool, as a graph node.
32///
33/// The command to perform is read from `state.output` (the agent loop wrote the
34/// intended action there); the decision arrives via the resume-injected
35/// `INTERRUPT_RESUME_KEY`. `actions` runs the tool with a JSON string argument
36/// and returns the observation text — representing the side effect that must
37/// only happen once, on the approved pass.
38pub struct ApprovalGate {
39    name: String,
40    actions: Arc<dyn Fn(&str) -> String + Send + Sync>,
41}
42
43/// Rebuilds a state carrying `out` as its output (the bridge only needs the
44/// input/output channels; the richer agent channels stay empty here).
45fn carry_output(state: &AgentState, out: String) -> AgentState {
46    let mut next = AgentState::new(state.input.clone());
47    next.set_output(out);
48    next
49}
50
51impl ApprovalGate {
52    /// Create a graph approval node for tool `name`.
53    pub fn new(
54        name: impl Into<String>,
55        actions: impl Fn(&str) -> String + Send + Sync + 'static,
56    ) -> Self {
57        Self {
58            name: name.into(),
59            actions: Arc::new(actions),
60        }
61    }
62}
63
64#[async_trait]
65impl GraphNode<AgentState> for ApprovalGate {
66    fn name(&self) -> &str {
67        &self.name
68    }
69
70    async fn execute(
71        &self,
72        state: &AgentState,
73        config: Option<NodeConfig>,
74    ) -> NodeResult<AgentState> {
75        let resume: Option<ApprovalDecision> = config
76            .and_then(|c| c.metadata.get(INTERRUPT_RESUME_KEY).cloned())
77            .map(|v| {
78                serde_json::from_value(v).map_err(|e| {
79                    GraphError::ExecutionError(format!(
80                        "approval resume value is not an ApprovalDecision: {e}"
81                    ))
82                })
83            })
84            .transpose()?;
85
86        // The intended tool call (the loop staged it before entering this node).
87        let command = state.output.clone().unwrap_or_default();
88
89        match resume {
90            // First pass: suspend, hand the approval context to the outside world.
91            None => Err(GraphError::InterruptRequest {
92                payload: serde_json::json!({
93                    "kind": "tool_approval",
94                    "tool": self.name,
95                    "command": command,
96                }),
97            }),
98
99            Some(ApprovalDecision::Allow) => {
100                let obs = (self.actions)(&command);
101                Ok(StateUpdate::full(carry_output(state, format!("ok:{obs}"))))
102            }
103
104            Some(ApprovalDecision::Deny { reason }) => {
105                // Tool skipped entirely — no side effect.
106                Ok(StateUpdate::full(carry_output(
107                    state,
108                    format!("denied:{reason}"),
109                )))
110            }
111
112            Some(ApprovalDecision::Modify { note, .. }) => {
113                // This bridge keeps the staged command (argument rewrites belong
114                // to the caller's `actions` wiring); the note is recorded.
115                let obs = (self.actions)(&command);
116                Ok(StateUpdate::full(carry_output(
117                    state,
118                    format!("modified({note}):{obs}"),
119                )))
120            }
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use lc_langgraph::{FileCheckpointer, GraphBuilder, ThreadSafeMemoryCheckpointer, END, START};
129    use std::sync::atomic::{AtomicUsize, Ordering};
130
131    /// Linear graph: START -> approval -> END, with a side-effect counter on
132    /// the tool execution.
133    fn charge_graph(
134        counter: Arc<AtomicUsize>,
135        name: &str,
136    ) -> lc_langgraph::compiled::CompiledGraph<AgentState> {
137        let c = counter.clone();
138        GraphBuilder::<AgentState>::new()
139            .add_node(ApprovalGate::new(name, move |args| {
140                c.fetch_add(1, Ordering::SeqCst);
141                format!("charged {args}")
142            }))
143            .add_edge(START, name)
144            .add_edge(name, END)
145            .compile()
146            .unwrap()
147            .with_recursion_limit(10)
148    }
149
150    /// A state purely carrying the staged command to approve.
151    fn staged(output: &str) -> AgentState {
152        let mut s = AgentState::new("x");
153        s.set_output(output);
154        s
155    }
156
157    /// #2: the approval gate's first pass is a graph interrupt carrying the
158    /// approval context; a decision resumes the SAME node through the graph and
159    /// the tool runs exactly once (no separate ResumeStore).
160    #[tokio::test]
161    async fn modify_resumes_node_and_runs_tool_once() {
162        let counter = Arc::new(AtomicUsize::new(0));
163        let compiled = charge_graph(counter.clone(), "charge")
164            .with_checkpointer(ThreadSafeMemoryCheckpointer::<AgentState>::new());
165
166        let err = compiled.invoke(staged("credit_card 99")).await.unwrap_err();
167        let (node, payload) = match err {
168            GraphError::DynamicInterrupt { node, payload } => (node, payload),
169            other => panic!("expected DynamicInterrupt, got {other:?}"),
170        };
171        assert_eq!(node, "charge");
172        assert_eq!(payload["kind"], "tool_approval");
173        assert_eq!(payload["command"], "credit_card 99");
174        // Tool must NOT have run on the interrupt pass.
175        assert_eq!(counter.load(Ordering::SeqCst), 0);
176
177        let d = ApprovalDecision::Modify {
178            arguments: serde_json::json!({"amount": 99}),
179            note: "approved by ops".to_string(),
180        };
181        let inv = compiled
182            .resume_with_value("charge", serde_json::to_value(d).unwrap())
183            .await
184            .unwrap();
185        let out = inv.final_state.output.as_deref().unwrap();
186        assert!(out.starts_with("modified(approved by ops):charged"));
187        assert_eq!(
188            counter.load(Ordering::SeqCst),
189            1,
190            "tool must run exactly once"
191        );
192    }
193
194    /// #2: a Deny decision resumes without executing the side-effecting tool.
195    #[tokio::test]
196    async fn deny_resumes_without_running_tool() {
197        let counter = Arc::new(AtomicUsize::new(0));
198        let compiled = charge_graph(counter.clone(), "charge")
199            .with_checkpointer(ThreadSafeMemoryCheckpointer::<AgentState>::new());
200
201        let err = compiled.invoke(staged("credit_card 99")).await.unwrap_err();
202        match err {
203            GraphError::DynamicInterrupt { node, .. } => assert_eq!(node, "charge"),
204            other => panic!("expected DynamicInterrupt, got {other:?}"),
205        }
206        assert_eq!(counter.load(Ordering::SeqCst), 0);
207
208        let d = ApprovalDecision::Deny {
209            reason: "too expensive".to_string(),
210        };
211        let inv = compiled
212            .resume_with_value("charge", serde_json::to_value(d).unwrap())
213            .await
214            .unwrap();
215        assert!(inv
216            .final_state
217            .output
218            .as_deref()
219            .unwrap()
220            .starts_with("denied:too expensive"));
221        assert_eq!(
222            counter.load(Ordering::SeqCst),
223            0,
224            "denied tool must not run"
225        );
226    }
227
228    /// #2: approval/suspend follows the graph file checkpoint alone (no
229    /// ResumeStore) — a brand-new process over the same dir resumes the
230    /// interrupted node with the decision.
231    #[tokio::test]
232    async fn approval_converges_on_graph_file_checkpoint() {
233        let dir = tempfile::tempdir().unwrap();
234        let dir_path = dir.path().to_path_buf();
235
236        let build = |path: std::path::PathBuf, counter: Arc<AtomicUsize>| {
237            charge_graph(counter, "charge").with_checkpointer(FileCheckpointer::new(path).unwrap())
238        };
239
240        let counter = Arc::new(AtomicUsize::new(0));
241
242        // Process A: suspend.
243        {
244            let compiled = build(dir_path.clone(), counter.clone());
245            let err = compiled.invoke(staged("credit_card 99")).await.unwrap_err();
246            match err {
247                GraphError::DynamicInterrupt { node, .. } => assert_eq!(node, "charge"),
248                other => panic!("expected DynamicInterrupt, got {other:?}"),
249            }
250        } // graph dropped (process A gone)
251
252        // Process B: brand-new graph over the same checkpoint dir, resume Allow.
253        assert_eq!(counter.load(Ordering::SeqCst), 0);
254        let compiled2 = build(dir_path, counter.clone());
255        let d = ApprovalDecision::Allow;
256        let inv = compiled2
257            .resume_with_value("charge", serde_json::to_value(d).unwrap())
258            .await
259            .unwrap();
260        let out = inv.final_state.output.as_deref().unwrap();
261        assert!(out.starts_with("ok:charged credit_card 99"), "got {out}");
262        // Allow → the tool ran exactly once, on the resumed process.
263        assert_eq!(counter.load(Ordering::SeqCst), 1);
264    }
265
266    /// #2: two distinct approval-gated tools on one graph both route through
267    /// the graph interrupt/resume — sequential multi-tool approval converges on
268    /// one persistence set.
269    #[tokio::test]
270    async fn sequential_multi_tool_approval_on_graph() {
271        let first = Arc::new(AtomicUsize::new(0));
272        let second = Arc::new(AtomicUsize::new(0));
273        let f1 = first.clone();
274        let f2 = second.clone();
275
276        let compiled = GraphBuilder::<AgentState>::new()
277            .add_node(ApprovalGate::new("send_email", move |args| {
278                f1.fetch_add(1, Ordering::SeqCst);
279                format!("emailed {args}")
280            }))
281            .add_node(ApprovalGate::new("remote_exec", move |args| {
282                f2.fetch_add(1, Ordering::SeqCst);
283                format!("ran {args}")
284            }))
285            .add_edge(START, "send_email")
286            .add_edge("send_email", "remote_exec")
287            .add_edge("remote_exec", END)
288            .compile()
289            .unwrap()
290            .with_recursion_limit(10)
291            .with_checkpointer(ThreadSafeMemoryCheckpointer::<AgentState>::new());
292
293        // First tool interrupts.
294        let err = compiled.invoke(staged("notify admin")).await.unwrap_err();
295        match err {
296            GraphError::DynamicInterrupt { node, .. } => assert_eq!(node, "send_email"),
297            other => panic!("expected DynamicInterrupt, got {other:?}"),
298        }
299        assert_eq!(first.load(Ordering::SeqCst), 0, "nothing ran yet");
300
301        // Resume send_email with Allow -> it runs, then the graph continues and
302        // the NEXT approval gate suspends. Resume cascades tool->tool.
303        let err = compiled
304            .resume_with_value(
305                "send_email",
306                serde_json::to_value(ApprovalDecision::Allow).unwrap(),
307            )
308            .await
309            .unwrap_err();
310        match err {
311            GraphError::DynamicInterrupt { node, .. } => assert_eq!(node, "remote_exec"),
312            other => panic!("expected DynamicInterrupt, got {other:?}"),
313        }
314        assert_eq!(first.load(Ordering::SeqCst), 1, "approved tool ran once");
315
316        // Resume remote_exec with Deny -> skipped, graph reaches END.
317        let d = ApprovalDecision::Deny {
318            reason: "no ssh".to_string(),
319        };
320        let inv = compiled
321            .resume_with_value("remote_exec", serde_json::to_value(d).unwrap())
322            .await
323            .unwrap();
324        assert!(inv
325            .final_state
326            .output
327            .as_deref()
328            .unwrap()
329            .starts_with("denied:no ssh"));
330        assert_eq!(first.load(Ordering::SeqCst), 1, "approved tool ran once");
331        assert_eq!(second.load(Ordering::SeqCst), 0, "denied tool never ran");
332    }
333}