1use 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
31pub struct ApprovalGate {
39 name: String,
40 actions: Arc<dyn Fn(&str) -> String + Send + Sync>,
41}
42
43fn 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 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 let command = state.output.clone().unwrap_or_default();
88
89 match resume {
90 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 Ok(StateUpdate::full(carry_output(
107 state,
108 format!("denied:{reason}"),
109 )))
110 }
111
112 Some(ApprovalDecision::Modify { note, .. }) => {
113 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 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 fn staged(output: &str) -> AgentState {
152 let mut s = AgentState::new("x");
153 s.set_output(output);
154 s
155 }
156
157 #[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 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 #[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 #[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 {
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 } 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 assert_eq!(counter.load(Ordering::SeqCst), 1);
264 }
265
266 #[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 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 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 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}