1use std::collections::HashMap;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10use chrono::{SecondsFormat, Utc};
11use rmcp::{
12 handler::server::{router::tool::ToolRouter, wrapper::Parameters},
13 tool, tool_handler, tool_router, ErrorData, Json, ServerHandler,
14};
15use serde_json::Value;
16
17use std::path::PathBuf;
18
19use crate::evidence::{digest, validate_witness_capture, WitnessCapture, WitnessError};
20use crate::run_manager::{initial_state_for_input, RunBudgets, RunManager};
21use crate::spec::{
22 ensure_size, parse_and_validate, validate_max_graphs, GraphSpec, DEFAULT_MAX_GRAPHS,
23 MAX_INPUT_BYTES,
24};
25use crate::store::{
26 ApprovalError, ApprovalRecord, CheckpointError, CheckpointRecord, GraphDeleteResult,
27 PersistentStore,
28};
29use crate::templates;
30use crate::tools::*;
31
32fn internal_error(message: impl Into<std::borrow::Cow<'static, str>>) -> ErrorData {
33 ErrorData::internal_error(message, None)
34}
35
36fn invalid_params(message: impl Into<std::borrow::Cow<'static, str>>) -> ErrorData {
37 ErrorData::invalid_params(message, None)
38}
39
40fn structured_output(value: Value) -> Json<StructuredOutput> {
41 Json(StructuredOutput {
42 ok: true,
43 status: None,
44 data: Some(value),
45 error: None,
46 error_code: None,
47 graph_id: None,
48 graph_version: None,
49 run_id: None,
50 })
51}
52
53fn error_output(message: impl Into<String>, code: impl Into<String>) -> Json<StructuredOutput> {
54 Json(StructuredOutput {
55 ok: false,
56 status: None,
57 data: None,
58 error: Some(message.into()),
59 error_code: Some(code.into()),
60 graph_id: None,
61 graph_version: None,
62 run_id: None,
63 })
64}
65
66fn structured_from_value(value: Value) -> Result<Json<StructuredOutput>, ErrorData> {
67 serde_json::from_value::<StructuredOutput>(value)
68 .map(Json)
69 .map_err(|e| internal_error(format!("cached idempotency decode: {e}")))
70}
71
72fn canonical_request_value(value: &Value) -> Value {
73 match value {
74 Value::String(raw) => serde_json::from_str(raw).unwrap_or_else(|_| value.clone()),
75 _ => value.clone(),
76 }
77}
78
79fn check_idempotency(
80 store: Option<&PersistentStore>,
81 key: Option<&str>,
82 request_digest: &str,
83) -> Result<Option<Json<StructuredOutput>>, ErrorData> {
84 let Some((store, key)) = store.zip(key) else {
85 return Ok(None);
86 };
87 let Some((stored_digest, cached)) = store.check_idempotency(key).map_err(internal_error)?
88 else {
89 return Ok(None);
90 };
91 if stored_digest.as_deref() == Some(request_digest) {
92 return structured_from_value(cached).map(Some);
93 }
94 Ok(Some(error_output(
95 "idempotency key is already bound to different request material",
96 "IDEMPOTENCY_CONFLICT",
97 )))
98}
99
100fn persist_idempotency(
101 store: &PersistentStore,
102 key: &str,
103 request_digest: &str,
104 output: &Json<StructuredOutput>,
105) -> Result<Option<Json<StructuredOutput>>, ErrorData> {
106 let result_json =
107 serde_json::to_string(&output.0).map_err(|e| internal_error(e.to_string()))?;
108 if store
109 .save_idempotency(key, request_digest, &result_json)
110 .map_err(internal_error)?
111 {
112 return Ok(None);
113 }
114 check_idempotency(Some(store), Some(key), request_digest)
117}
118
119fn output_with_meta(
120 data: Value,
121 graph_id: Option<&str>,
122 graph_version: Option<&str>,
123 run_id: Option<&str>,
124) -> Json<StructuredOutput> {
125 Json(StructuredOutput {
126 ok: true,
127 status: None,
128 data: Some(data),
129 error: None,
130 error_code: None,
131 graph_id: graph_id.map(String::from),
132 graph_version: graph_version.map(String::from),
133 run_id: run_id.map(String::from),
134 })
135}
136
137fn checkpoint_error_output(error: CheckpointError) -> Json<StructuredOutput> {
138 error_output(error.message(), error.code())
139}
140
141fn approval_error_output(error: ApprovalError) -> Json<StructuredOutput> {
142 error_output(error.message(), error.code())
143}
144
145fn approval_value(record: &ApprovalRecord) -> Value {
146 serde_json::json!({
147 "approval_id": record.approval_id,
148 "checkpoint_id": record.checkpoint_id,
149 "run_id": record.run_id,
150 "graph_id": record.graph_id,
151 "graph_version": record.graph_version,
152 "checkpoint_digest": record.checkpoint_digest,
153 "audience": record.audience,
154 "prompt_digest": record.prompt_digest,
155 "allowed_decisions": record.allowed_decisions,
156 "approval_digest": record.approval_digest,
157 "status": record.status,
158 "decision": record.decision,
159 "decided_by": record.decided_by,
160 "decided_at": record.decided_at,
161 "expires_at": record.expires_at,
162 "created_at": record.created_at,
163 })
164}
165
166fn checkpoint_value(record: &CheckpointRecord) -> Value {
167 serde_json::json!({
168 "checkpoint_id": record.checkpoint_id,
169 "run_id": record.run_id,
170 "graph_id": record.graph_id,
171 "graph_version": record.graph_version,
172 "next_node_cursor": record.next_node_cursor,
173 "state": record.state,
174 "state_digest": record.state_digest,
175 "budgets": record.budgets,
176 "budget_counters": record.budget_counters,
177 "dependency_summary": record.dependency_summary,
178 "dependency_digest": record.dependency_digest,
179 "terminal_cursor": record.terminal_cursor,
180 "event_cursor": record.event_cursor,
181 "checkpoint_digest": record.checkpoint_digest,
182 "created_at": record.created_at,
183 "consumed_at": record.consumed_at,
184 "status": if record.consumed_at.is_some() { "consumed" } else { "available" },
185 "resume_capability": "deterministic_local_resume",
186 })
187}
188
189#[derive(Clone)]
190struct RegisteredGraph {
191 spec: GraphSpec,
192 normalized: Value,
193 version: String,
194 warnings: Vec<String>,
195}
196
197pub struct AgentGraphServer {
198 tool_router: ToolRouter<Self>,
199 base_url: String,
200 default_model: String,
201 api_key: Option<String>,
205 graphs: Mutex<HashMap<String, RegisteredGraph>>,
206 runs: Mutex<RunManager>,
207 store: Option<PersistentStore>,
208 max_graphs: usize,
209}
210
211impl AgentGraphServer {
212 fn graph_requires_witness_store(spec: &GraphSpec) -> bool {
213 spec.nodes.iter().any(|node| node.evidence_required)
214 }
215
216 fn witness_error_output(error: WitnessError) -> Json<StructuredOutput> {
217 error_output(error.message, error.code)
218 }
219
220 pub fn new(
221 base_url: String,
222 default_model: String,
223 data_dir: Option<PathBuf>,
224 integrity_key_path: Option<PathBuf>,
225 ) -> Result<Self, String> {
226 Self::new_with_max_graphs(
227 base_url,
228 default_model,
229 data_dir,
230 integrity_key_path,
231 DEFAULT_MAX_GRAPHS,
232 )
233 }
234
235 pub fn new_with_max_graphs(
236 base_url: String,
237 default_model: String,
238 data_dir: Option<PathBuf>,
239 integrity_key_path: Option<PathBuf>,
240 max_graphs: usize,
241 ) -> Result<Self, String> {
242 Self::new_with_max_graphs_and_key(
243 base_url,
244 default_model,
245 data_dir,
246 integrity_key_path,
247 max_graphs,
248 None,
249 )
250 }
251
252 pub fn new_with_max_graphs_and_key(
253 base_url: String,
254 default_model: String,
255 data_dir: Option<PathBuf>,
256 integrity_key_path: Option<PathBuf>,
257 max_graphs: usize,
258 api_key: Option<String>,
259 ) -> Result<Self, String> {
260 let max_graphs = validate_max_graphs(max_graphs)?;
261 let store = match data_dir {
262 Some(ref dir) => Some(PersistentStore::open_with_integrity_key(
263 dir,
264 integrity_key_path.as_deref(),
265 )?),
266 None => None,
267 };
268 if let Some(ref store) = store {
269 store.recover_incomplete_executions()?;
270 }
271
272 let runs = RunManager::default().with_api_key(api_key.clone());
273 let server = Self {
274 base_url,
275 default_model,
276 api_key,
277 graphs: Mutex::new(HashMap::new()),
278 runs: Mutex::new(runs),
279 store,
280 max_graphs,
281 tool_router: Self::tool_router(),
282 };
283
284 if let Some(ref store) = server.store {
286 if let Ok(graphs) = store.list_graphs() {
287 for (name, hash, _created) in graphs {
288 if let Ok(Some((spec_json, _))) = store.load_graph(&name) {
289 if let Ok(spec) = serde_json::from_str::<GraphSpec>(&spec_json) {
290 let normalized = serde_json::to_value(&spec).unwrap_or_default();
291 server.graphs.lock().unwrap().insert(
292 name,
293 RegisteredGraph {
294 spec,
295 normalized,
296 version: hash,
297 warnings: Vec::new(),
298 },
299 );
300 }
301 }
302 }
303 }
304 }
305
306 Ok(server)
307 }
308
309 fn safe_provider_label(&self) -> String {
310 let url = &self.base_url;
311 let without_fragment = url.split(['?', '#']).next().unwrap_or(url);
312 if let Some((scheme, rest)) = without_fragment.split_once("://") {
313 let authority_and_path = rest.rsplit_once('@').map(|(_, safe)| safe).unwrap_or(rest);
314 format!("{scheme}://{authority_and_path}")
315 } else {
316 "server-configured".into()
317 }
318 }
319
320 fn persist_terminal(
321 store: Option<PersistentStore>,
322 record: crate::run_manager::RunRecord,
323 ) -> Result<(), String> {
324 let Some(store) = store else {
325 return Ok(());
326 };
327 let final_state = serde_json::to_string(&record.final_state)
328 .map_err(|e| format!("serialize terminal state error: {e}"))?;
329 let events = record
332 .events
333 .iter()
334 .map(|entry| {
335 let seq = entry.get("cursor").and_then(Value::as_u64).unwrap_or(0);
336 let event = entry.get("event").cloned().unwrap_or_else(|| {
337 serde_json::json!({"receipt": "terminal event persisted with reduced fidelity"})
338 });
339 let event_type = event
340 .as_object()
341 .and_then(|object| object.keys().next().cloned())
342 .unwrap_or_else(|| "run_event".into());
343 Ok((seq, event_type, event.to_string()))
344 })
345 .collect::<Result<Vec<_>, String>>()?;
346 let mut durable_receipt = record.receipt.clone();
347 if let Some(object) = durable_receipt.as_object_mut() {
348 object.insert(
349 "persistence_status".into(),
350 Value::String("durable_terminal".into()),
351 );
352 }
353 let receipt = serde_json::to_string(&durable_receipt)
354 .map_err(|e| format!("serialize terminal receipt error: {e}"))?;
355 let durable_bundle = crate::evidence::bundle(
356 &record.run_id,
357 &record.graph_version,
358 &record.input,
359 &record.state,
360 &durable_receipt,
361 );
362 let bundle = serde_json::to_string(&durable_bundle)
363 .map_err(|e| format!("serialize terminal bundle error: {e}"))?;
364 store.persist_terminal_projection(
365 &record.run_id,
366 &record.status,
367 &final_state,
368 record.steps.len(),
369 &events,
370 &receipt,
371 &bundle,
372 )?;
373 Ok(())
374 }
375
376 fn persist_terminal_and_mark(
377 runs: crate::run_manager::RunManager,
378 store: Option<PersistentStore>,
379 record: crate::run_manager::RunRecord,
380 ) {
381 if store.is_none() {
382 runs.mark_persistence(&record.run_id, "volatile_no_store", None);
383 return;
384 }
385 match Self::persist_terminal(store, record.clone()) {
386 Ok(()) => runs.mark_persistence(&record.run_id, "durable_terminal", None),
387 Err(error) => {
388 tracing::error!(%error, "terminal run persistence failed; run remains volatile");
389 runs.mark_persistence(&record.run_id, "volatile_persistence_failed", Some(error));
390 }
391 }
392 }
393
394 fn stored_run(&self, run_id: &str) -> Result<Option<Value>, ErrorData> {
395 let Some(store) = &self.store else {
396 return Ok(None);
397 };
398 let Some(mut record) = store.load_execution(run_id).map_err(internal_error)? else {
399 return Ok(None);
400 };
401 if let Some(receipt) = store
402 .load_terminal_receipt(run_id)
403 .map_err(internal_error)?
404 .and_then(|value| value.get("receipt").cloned())
405 {
406 if let Some(object) = record.as_object_mut() {
407 for key in ["budgets", "budget_counters", "budget_exhausted"] {
408 if let Some(value) = receipt.get(key) {
409 object.insert(key.into(), value.clone());
410 }
411 }
412 object.insert("receipt".into(), receipt);
413 }
414 }
415 Ok(Some(record))
416 }
417
418 fn resolve_graph(
419 &self,
420 graph_id: &str,
421 requested_version: Option<&str>,
422 ) -> Result<RegisteredGraph, ErrorData> {
423 let current = self
424 .graphs
425 .lock()
426 .map_err(|e| internal_error(e.to_string()))?
427 .get(graph_id)
428 .cloned()
429 .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
430 let Some(requested_version) = requested_version else {
431 return Ok(current);
432 };
433 if requested_version == current.version {
434 return Ok(current);
435 }
436 let store = self.store.as_ref().ok_or_else(|| {
437 invalid_params("historical graph versions require SQLite persistence")
438 })?;
439 let serialized = store
440 .load_graph_version(graph_id, requested_version)
441 .map_err(internal_error)?
442 .ok_or_else(|| invalid_params("requested graph version was not found"))?;
443 let normalized: Value = serde_json::from_str(&serialized)
444 .map_err(|e| internal_error(format!("stored graph version JSON error: {e}")))?;
445 let spec = parse_and_validate(&normalized)
446 .map_err(|e| internal_error(format!("stored graph version validation error: {e}")))?;
447 let canonical = serde_json::to_value(&spec).map_err(|e| internal_error(e.to_string()))?;
448 let actual_version = digest(&canonical);
449 if actual_version != requested_version {
450 return Err(internal_error(
451 "stored graph version digest does not match its normalized specification",
452 ));
453 }
454 Ok(RegisteredGraph {
455 warnings: spec.warnings(),
456 spec,
457 normalized: canonical,
458 version: actual_version,
459 })
460 }
461
462 fn mermaid(spec: &GraphSpec) -> String {
463 let mut s = String::from("graph TD\n");
464 for edge in &spec.edges {
465 s.push_str(&format!(" {} --> {}\n", edge.from, edge.to));
466 }
467 s
468 }
469
470 fn delete_registered_graph(&self, graph_id: &str) -> Result<Json<StructuredOutput>, ErrorData> {
471 let exists = self
472 .graphs
473 .lock()
474 .map_err(|e| internal_error(e.to_string()))?
475 .contains_key(graph_id);
476 if !exists {
477 return Ok(error_output(
478 format!("graph '{graph_id}' not found"),
479 "GRAPH_NOT_FOUND",
480 ));
481 }
482
483 if let Some(store) = &self.store {
484 match store.delete_graph(graph_id).map_err(internal_error)? {
485 GraphDeleteResult::Deleted => {}
486 GraphDeleteResult::Referenced => {
487 return Ok(error_output(
488 format!("graph '{graph_id}' is referenced by a durable execution"),
489 "GRAPH_REFERENCED",
490 ));
491 }
492 GraphDeleteResult::ReferencedBySubgraph => {
493 return Ok(error_output(
494 format!(
495 "graph '{graph_id}' is referenced by another graph's subgraph node"
496 ),
497 "GRAPH_SUBGRAPH_REFERENCED",
498 ));
499 }
500 GraphDeleteResult::RetentionApprovalRequired => {
501 return Ok(error_output(
502 format!("graph '{graph_id}' requires delete_candidate then delete_approved retention states"),
503 "RETENTION_APPROVAL_REQUIRED",
504 ));
505 }
506 GraphDeleteResult::NotFound => {
507 return Ok(error_output(
508 format!(
509 "graph '{graph_id}' is present in memory but missing from durable storage"
510 ),
511 "GRAPH_PERSISTENCE_MISMATCH",
512 ));
513 }
514 }
515 }
516
517 self.graphs
518 .lock()
519 .map_err(|e| internal_error(e.to_string()))?
520 .remove(graph_id);
521 Ok(output_with_meta(
522 serde_json::json!({"status": "deleted"}),
523 Some(graph_id),
524 None,
525 None,
526 ))
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::run_manager::RunManager;
534 use crate::store::PersistentStore;
535
536 fn configure_test_integrity_key() {
537 let path = std::env::temp_dir().join("agent-graph-mcp-unit-integrity.key");
538 std::fs::write(&path, [0x5au8; 32]).expect("test integrity key");
539 std::env::set_var("AGENT_GRAPH_INTEGRITY_KEY_PATH", path);
540 }
541
542 #[test]
543 fn terminal_projection_failure_rolls_back_sqlite_and_marks_run_volatile() {
544 configure_test_integrity_key();
545 let temp = tempfile::tempdir().expect("temp graph database");
546 let store = PersistentStore::open(temp.path()).expect("store");
547 let spec: GraphSpec = serde_json::from_value(serde_json::json!({
548 "name":"fault-injection",
549 "entry":"x",
550 "nodes":[{"id":"x","type":"passthrough"}],
551 "edges":[{"from":"x","to":"END"}]
552 }))
553 .expect("graph spec");
554 let spec_json = serde_json::to_string(&spec).expect("spec JSON");
555 store
556 .save_graph("fault-injection", &spec_json, "version", false)
557 .expect("graph");
558
559 let runs = RunManager::default();
560 let run_id = runs
561 .allocate("fault-injection", "version", serde_json::json!({"x":1}))
562 .expect("run");
563 store
564 .save_execution(
565 &run_id,
566 "fault-injection",
567 "version",
568 "running",
569 "{\"x\":1}",
570 )
571 .expect("execution");
572 runs.execute(
573 &run_id,
574 spec,
575 "http://localhost".into(),
576 "test-model".into(),
577 )
578 .expect("execution completes");
579
580 store.fail_terminal_projection_after_events();
581 AgentGraphServer::persist_terminal_and_mark(
582 runs.clone(),
583 Some(store.clone()),
584 runs.get(&run_id).expect("terminal record"),
585 );
586
587 let public = runs.get(&run_id).expect("volatile record").public();
588 assert_eq!(public["persistence_status"], "volatile_persistence_failed");
589 assert_eq!(public["storage_class"], "volatile");
590
591 let reopened = PersistentStore::open(temp.path()).expect("fresh store");
592 assert_eq!(
593 reopened.load_execution(&run_id).unwrap().unwrap()["status"],
594 "running"
595 );
596 assert!(reopened.load_events(&run_id, 0, 100).unwrap().is_none());
597 assert!(reopened.load_terminal_receipt(&run_id).unwrap().is_none());
598 }
599
600 #[test]
601 fn capacity_is_reserved_before_direct_or_approved_checkpoint_consumption() {
602 configure_test_integrity_key();
603 let temp = tempfile::tempdir().expect("checkpoint database");
604 let server = AgentGraphServer::new(
605 "http://localhost".into(),
606 "test-model".into(),
607 Some(temp.path().to_owned()),
608 None,
609 )
610 .expect("server");
611 server
612 .graph_create(Parameters(GraphCreateParams {
613 spec: Some(serde_json::json!({
614 "name":"capacity-resume", "entry":"first",
615 "nodes":[
616 {"id":"first","type":"passthrough"},
617 {"id":"second","type":"state_transform","config":{"operations":[{"op":"set","path":"done","value":true}]}}
618 ],
619 "edges":[{"from":"first","to":"second"},{"from":"second","to":"END"}]
620 })),
621 action: None,
622 graph_id: None,
623 idempotency_key: None,
624 template: None,
625 overwrite: None,
626 }))
627 .expect("create graph");
628 let checkpoint = |server: &AgentGraphServer| {
629 server
630 .graph_run_start(Parameters(RunStartParams {
631 graph_id: "capacity-resume".into(),
632 input: None,
633 graph_version: None,
634 thread_id: None,
635 idempotency_key: None,
636 budgets: None,
637 checkpoint: Some(true),
638 }))
639 .expect("checkpoint start")
640 .0
641 .data
642 .unwrap()["checkpoint_id"]
643 .as_str()
644 .unwrap()
645 .to_owned()
646 };
647 let direct_checkpoint = checkpoint(&server);
648 {
649 let runs = server.runs.lock().expect("runs");
650 for index in 0..8 {
651 let run_id = runs
652 .allocate("capacity", "v1", serde_json::json!({"index":index}))
653 .expect("slot record");
654 runs.admit_async(&run_id).expect("slot admission");
655 }
656 }
657 let direct = server
658 .graph_run_resume(Parameters(RunResumeParams {
659 checkpoint_id: Some(direct_checkpoint.clone()),
660 run_id: None,
661 }))
662 .expect("resume response");
663 assert_eq!(direct.0.error_code.as_deref(), Some("RUN_CAPACITY"));
664 let store = server.store.as_ref().expect("store");
665 assert!(store
666 .load_resume_checkpoint(Some(&direct_checkpoint), None)
667 .expect("checkpoint")
668 .expect("record")
669 .consumed_at
670 .is_none());
671
672 let approval_checkpoint = checkpoint(&server);
673 let approval = server
674 .graph_approval_request(Parameters(ApprovalRequestParams {
675 checkpoint_id: approval_checkpoint.clone(),
676 audience: "operator".into(),
677 prompt: "approve after capacity is available".into(),
678 allowed_decisions: vec!["approve".into()],
679 expiration: (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
680 }))
681 .expect("approval request");
682 let approval_id = approval.0.data.unwrap()["approval_id"]
683 .as_str()
684 .unwrap()
685 .to_owned();
686 let decided = server
687 .graph_approval_decide(Parameters(ApprovalDecideParams {
688 approval_id: approval_id.clone(),
689 decision: "approve".into(),
690 claimed_actor_label: "operator".into(),
691 }))
692 .expect("approval response");
693 assert_eq!(
694 decided.0.error_code.as_deref(),
695 Some("AUTHENTICATED_OPERATOR_REQUIRED")
696 );
697 assert_eq!(
698 store
699 .get_checkpoint_approval(&approval_id)
700 .expect("approval")
701 .expect("approval row")
702 .status,
703 "pending"
704 );
705 assert!(store
706 .load_resume_checkpoint(Some(&approval_checkpoint), None)
707 .expect("checkpoint")
708 .expect("record")
709 .consumed_at
710 .is_none());
711 }
712}
713
714#[tool_router]
715impl AgentGraphServer {
716 #[tool(
719 description = "Create, validate, or delete a graph-orchestrated workflow from a JSON spec. Supports template instantiation and idempotency keys."
720 )]
721 fn graph_create(
722 &self,
723 Parameters(GraphCreateParams {
724 spec,
725 action,
726 graph_id,
727 idempotency_key,
728 template,
729 overwrite,
730 }): Parameters<GraphCreateParams>,
731 ) -> Result<Json<StructuredOutput>, ErrorData> {
732 let action = action.as_deref().unwrap_or("create");
733
734 let request_digest = digest(&serde_json::json!({
735 "operation": "graph_create",
736 "action": action,
737 "spec": spec.as_ref().map(canonical_request_value).unwrap_or(Value::Null),
738 "template": template.as_ref().map(canonical_request_value).unwrap_or(Value::Null),
739 "graph_id": graph_id,
740 "overwrite": overwrite.unwrap_or(false),
741 }));
742 if action != "delete" {
743 if let Some(cached) = check_idempotency(
744 self.store.as_ref(),
745 idempotency_key.as_deref(),
746 &request_digest,
747 )? {
748 return Ok(cached);
749 }
750 }
751
752 if action == "delete" {
757 return Ok(error_output(
758 "graph lifecycle deletion requires the authenticated operator service",
759 "AUTHENTICATED_OPERATOR_REQUIRED",
760 ));
761 }
762
763 if action != "create" && action != "validate" {
764 return Ok(error_output(
765 format!("unsupported graph_create action '{action}'"),
766 "INVALID_ACTION",
767 ));
768 }
769
770 let raw = if let Some(ref tpl) = template {
772 let tpl_val = if let Value::String(s) = tpl {
773 serde_json::from_str(s).unwrap_or_else(|_| tpl.clone())
774 } else {
775 tpl.clone()
776 };
777 let tpl_id = tpl_val
778 .get("id")
779 .and_then(Value::as_str)
780 .ok_or_else(|| invalid_params("template.id required"))?;
781 let tpl_name = tpl_val
782 .get("name")
783 .and_then(Value::as_str)
784 .or_else(|| graph_id.as_deref())
785 .unwrap_or(tpl_id);
786 templates::instantiate(tpl_id, tpl_name)
787 .map_err(|e| internal_error(format!("template error: {e}")))?
788 } else {
789 let spec = spec
790 .clone()
791 .ok_or_else(|| invalid_params("missing spec for create/validate"))?;
792 if let Value::String(s) = spec {
793 serde_json::from_str(&s)
794 .map_err(|e| invalid_params(format!("spec string parse error: {e}")))?
795 } else {
796 spec
797 }
798 };
799
800 let original_version = raw
801 .get("spec_version")
802 .and_then(Value::as_str)
803 .unwrap_or("1")
804 .to_owned();
805 let warnings_preview = serde_json::from_value::<GraphSpec>(raw.clone())
806 .ok()
807 .map(|s| s.warnings())
808 .unwrap_or_default();
809 let spec_parsed =
810 parse_and_validate(&raw).map_err(|e| invalid_params(format!("invalid spec: {e}")))?;
811 if let Some(node) = spec_parsed
812 .nodes
813 .iter()
814 .find(|node| crate::spec::GraphSpec::executable_node_type(&node.node_type).is_err())
815 {
816 return Ok(error_output(
817 format!("node '{}' declares an unsupported executable type", node.id),
818 "UNSUPPORTED_NODE_TYPE",
819 ));
820 }
821 let normalized =
822 serde_json::to_value(&spec_parsed).map_err(|e| internal_error(e.to_string()))?;
823 let version = digest(&normalized);
824 let warnings = if original_version == "1" {
825 warnings_preview
826 } else {
827 spec_parsed.warnings()
828 };
829
830 if action == "validate" {
831 let output = output_with_meta(
832 serde_json::json!({
833 "graph_id": spec_parsed.name,
834 "graph_version": version,
835 "digest": version,
836 "normalized_spec_version": "2",
837 "warnings": warnings,
838 "storage_class": "volatile",
839 "status": "valid"
840 }),
841 Some(&spec_parsed.name),
842 Some(&version),
843 None,
844 );
845 if let Some(ref store) = self.store {
846 if let Some(idem) = idempotency_key {
847 if let Some(cached) =
848 persist_idempotency(store, &idem, &request_digest, &output)?
849 {
850 return Ok(cached);
851 }
852 }
853 }
854 return Ok(output);
855 }
856
857 if Self::graph_requires_witness_store(&spec_parsed) && self.store.is_none() {
858 return Ok(error_output(
859 "evidence-required graphs require SQLite witness persistence",
860 "WITNESS_STORE_REQUIRED",
861 ));
862 }
863
864 let mut graphs = self
866 .graphs
867 .lock()
868 .map_err(|e| internal_error(e.to_string()))?;
869 let name = spec_parsed.name.clone();
870 let overwrite = overwrite.unwrap_or(false);
871 if !overwrite && !graphs.contains_key(&name) && graphs.len() >= self.max_graphs {
872 return Ok(error_output(
873 format!("graph capacity ({}) exhausted — {} graphs registered", self.max_graphs, graphs.len()),
874 "CAPACITY_EXHAUSTED",
875 ));
876 }
877
878 let id = name.clone();
879 if let Some(ref store) = self.store {
880 let spec_str = serde_json::to_string(&normalized).unwrap_or_default();
881 if let Err(error) = store.save_graph(&id, &spec_str, &version, overwrite) {
882 return Ok(error_output(error, "GRAPH_VERSION_CONFLICT"));
883 }
884 }
885 graphs.insert(
886 id.clone(),
887 RegisteredGraph {
888 spec: spec_parsed,
889 normalized: normalized.clone(),
890 version: version.clone(),
891 warnings: warnings.clone(),
892 },
893 );
894 drop(graphs);
895
896 let output = output_with_meta(
897 serde_json::json!({
898 "graph_id": id,
899 "graph_version": version,
900 "digest": version,
901 "normalized_spec_version": "2",
902 "warnings": warnings,
903 "storage_class": "volatile",
904 "status": "created"
905 }),
906 Some(&id),
907 Some(&version),
908 None,
909 );
910
911 if let Some(ref store) = self.store {
912 if let Some(idem) = idempotency_key {
913 if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
914 return Ok(cached);
915 }
916 }
917 }
918 Ok(output)
919 }
920
921 #[tool(
924 description = "Execute a registered graph. Sync mode blocks until completion; async mode returns immediately with a run_id."
925 )]
926 fn graph_execute(
927 &self,
928 Parameters(GraphExecuteParams {
929 graph_id,
930 input,
931 graph_version,
932 thread_id,
933 mode,
934 idempotency_key,
935 }): Parameters<GraphExecuteParams>,
936 ) -> Result<Json<StructuredOutput>, ErrorData> {
937 let input = input.unwrap_or(Value::Null);
938 ensure_size(&input, MAX_INPUT_BYTES, "execution input").map_err(|e| invalid_params(e))?;
939 let sync = mode.as_deref().is_none_or(|m| m == "sync");
940 if sync {
941 tracing::warn!(
942 "graph_execute sync mode is deprecated — use graph_run_start + graph_run_get"
943 );
944 }
945 if let Some(store) = self.store.as_ref() {
946 if !store
947 .graph_execution_allowed(&graph_id)
948 .map_err(internal_error)?
949 {
950 return Ok(error_output(
951 format!("graph '{graph_id}' is archived or pending deletion"),
952 "GRAPH_RETIRED",
953 ));
954 }
955 }
956
957 let graph = self.resolve_graph(&graph_id, graph_version.as_deref())?;
958
959 if Self::graph_requires_witness_store(&graph.spec) && self.store.is_none() {
960 return Ok(error_output(
961 "evidence-required graphs require SQLite witness persistence",
962 "WITNESS_STORE_REQUIRED",
963 ));
964 }
965
966 let request_digest = digest(&serde_json::json!({
967 "operation": "graph_execute",
968 "graph_id": graph_id,
969 "graph_spec": graph.normalized,
970 "graph_version": graph.version,
971 "input": input,
972 "mode": mode.clone().unwrap_or_else(|| "sync".into()),
973 "thread_id": thread_id,
974 }));
975
976 let runs = self
977 .runs
978 .lock()
979 .map_err(|e| internal_error(e.to_string()))?;
980 if let Some(idem) = idempotency_key.as_deref() {
981 if let Some(cached) =
982 check_idempotency(self.store.as_ref(), Some(idem), &request_digest)?
983 {
984 return Ok(cached);
985 }
986 }
987
988 let run_id = runs
989 .allocate(&graph_id, &graph.version, input.clone())
990 .map_err(|e| internal_error(e))?;
991
992 if let Err(e) = runs.admit_async(&run_id) {
993 runs.remove(&run_id);
994 return Ok(error_output(e, "RUN_CAPACITY"));
995 }
996 if let Some(ref store) = self.store {
997 let _ = store.save_execution(
998 &run_id,
999 &graph_id,
1000 &graph.version,
1001 "running",
1002 &input.to_string(),
1003 );
1004 }
1005
1006 let is_async = mode.as_deref() == Some("async");
1007 if is_async {
1008 let terminal_store = self.store.clone();
1009 let completion_runs = runs.clone();
1010 runs.start_with_completion_with_store(
1011 run_id.clone(),
1012 graph.spec,
1013 self.base_url.clone(),
1014 self.default_model.clone(),
1015 self.store.clone(),
1016 move |record| {
1017 Self::persist_terminal_and_mark(completion_runs, terminal_store, record)
1018 },
1019 );
1020 let output = output_with_meta(
1021 serde_json::json!({
1022 "run_id": run_id,
1023 "status": "accepted",
1024 "thread_id": thread_id,
1025 "storage_class": "volatile",
1026 "cancellation": "provider_future_best_effort_drop; underlying_request_may_continue"
1027 }),
1028 Some(&graph_id),
1029 Some(&graph.version),
1030 Some(&run_id),
1031 );
1032 if let Some(ref store) = self.store {
1033 if let Some(idem) = idempotency_key {
1034 if let Some(cached) =
1035 persist_idempotency(store, &idem, &request_digest, &output)?
1036 {
1037 return Ok(cached);
1038 }
1039 }
1040 }
1041 return Ok(output);
1042 }
1043
1044 let terminal_store = self.store.clone();
1045 let completion_runs = runs.clone();
1046 runs.start_with_completion_with_store(
1047 run_id.clone(),
1048 graph.spec,
1049 self.base_url.clone(),
1050 self.default_model.clone(),
1051 self.store.clone(),
1052 move |record| Self::persist_terminal_and_mark(completion_runs, terminal_store, record),
1053 );
1054
1055 let deadline = Instant::now() + Duration::from_millis(300_000);
1056 let output = loop {
1057 let r = runs
1058 .get(&run_id)
1059 .ok_or_else(|| internal_error(format!("run '{run_id}' not found")))?;
1060 if matches!(r.status.as_str(), "completed" | "failed" | "cancelled") {
1061 break output_with_meta(
1062 r.public(),
1063 Some(&graph_id),
1064 Some(&graph.version),
1065 Some(&run_id),
1066 );
1067 }
1068 if Instant::now() >= deadline {
1069 let cancellation = runs.cancel(&run_id).unwrap_or_else(
1070 |_| serde_json::json!({"run_id": run_id, "status": "cancellation_requested"}),
1071 );
1072 break output_with_meta(
1073 serde_json::json!({
1074 "run_id": run_id,
1075 "status": r.status,
1076 "timed_out": true,
1077 "completion_unknown": true,
1078 "cancellation": "requested",
1079 "cancellation_result": cancellation,
1080 }),
1081 Some(&graph_id),
1082 Some(&graph.version),
1083 Some(&run_id),
1084 );
1085 }
1086 std::thread::sleep(Duration::from_millis(100));
1087 };
1088
1089 if let Some(ref store) = self.store {
1090 let status = output
1091 .0
1092 .data
1093 .as_ref()
1094 .and_then(|data| data.get("status").and_then(Value::as_str))
1095 .unwrap_or("failed");
1096 let final_state = output
1097 .0
1098 .data
1099 .as_ref()
1100 .and_then(|data| data.get("final_state").cloned())
1101 .map(|v| serde_json::to_string(&v).unwrap_or_default());
1102 let _ = store.save_execution(
1103 &run_id,
1104 &graph_id,
1105 &graph.version,
1106 status,
1107 &input.to_string(),
1108 );
1109 let _ =
1110 store.update_execution_status(&run_id, status, final_state.as_deref(), None, None);
1111 }
1112
1113 if let Some(ref store) = self.store {
1114 if let Some(idem) = idempotency_key {
1115 if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
1116 return Ok(cached);
1117 }
1118 }
1119 }
1120 Ok(output)
1121 }
1122
1123 #[tool(
1126 description = "Persist caller-supplied UTF-8 source content as a local witness receipt. The locator is metadata only; this tool never fetches or verifies it."
1127 )]
1128 fn graph_source_witness_capture(
1129 &self,
1130 Parameters(WitnessCaptureParams {
1131 locator,
1132 content,
1133 media_type,
1134 authority_class,
1135 retrieved_at,
1136 }): Parameters<WitnessCaptureParams>,
1137 ) -> Result<Json<StructuredOutput>, ErrorData> {
1138 let capture = WitnessCapture {
1139 locator,
1140 content,
1141 media_type,
1142 authority_class,
1143 retrieved_at: retrieved_at
1144 .unwrap_or_else(|| Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true)),
1145 };
1146 if let Err(error) = validate_witness_capture(capture.clone()) {
1147 return Ok(Self::witness_error_output(error));
1148 }
1149 let Some(store) = self.store.as_ref() else {
1150 return Ok(error_output(
1151 "SQLite persistence is required for source witness capture",
1152 "WITNESS_STORE_REQUIRED",
1153 ));
1154 };
1155 match store.capture_witness(capture) {
1156 Ok(record) => Ok(structured_output(serde_json::json!({
1157 "witness_id": record.witness_id,
1158 "digest": record.digest,
1159 "locator_digest": digest(&Value::String(record.locator)),
1160 "media_type": record.media_type,
1161 "authority_class": record.authority_class,
1162 "retrieved_at": record.retrieved_at,
1163 "content_bytes": record.content.len(),
1164 "storage_class": "sqlite_source_witness"
1165 }))),
1166 Err(error) => Ok(Self::witness_error_output(error)),
1167 }
1168 }
1169
1170 #[tool(
1171 description = "Read one exact local source witness ID, verifying its HMAC-SHA256 authentication tag before returning metadata and captured content."
1172 )]
1173 fn graph_source_witness_get(
1174 &self,
1175 Parameters(WitnessGetParams { witness_id }): Parameters<WitnessGetParams>,
1176 ) -> Result<Json<StructuredOutput>, ErrorData> {
1177 let Some(store) = self.store.as_ref() else {
1178 return Ok(error_output(
1179 "SQLite persistence is required for source witness reads",
1180 "WITNESS_STORE_REQUIRED",
1181 ));
1182 };
1183 match store.get_witness(&witness_id) {
1184 Ok(Some(record)) => {
1185 let locator_digest = digest(&Value::String(record.locator.clone()));
1186 Ok(structured_output(serde_json::json!({
1187 "witness_id": record.witness_id,
1188 "digest": record.digest,
1189 "locator": record.locator,
1190 "locator_digest": locator_digest,
1191 "content": record.content,
1192 "media_type": record.media_type,
1193 "authority_class": record.authority_class,
1194 "retrieved_at": record.retrieved_at,
1195 "storage_class": "sqlite_source_witness"
1196 })))
1197 }
1198 Ok(None) => Ok(error_output(
1199 "source witness was not found",
1200 "WITNESS_NOT_FOUND",
1201 )),
1202 Err(error) => Ok(Self::witness_error_output(error)),
1203 }
1204 }
1205
1206 #[tool(
1209 description = "Query server state, graph details, run status, events, receipts, or templates."
1210 )]
1211 fn graph_status(
1212 &self,
1213 Parameters(GraphStatusParams {
1214 resource,
1215 graph_id,
1216 run_id,
1217 cursor,
1218 limit,
1219 }): Parameters<GraphStatusParams>,
1220 ) -> Result<Json<StructuredOutput>, ErrorData> {
1221 let resource = resource.as_deref();
1222
1223 if resource.is_none() || resource == Some("server") {
1225 let graphs = self
1226 .graphs
1227 .lock()
1228 .map_err(|e| internal_error(e.to_string()))?;
1229 let graph_names: Vec<&String> = graphs.keys().collect();
1230 let runs = self
1231 .runs
1232 .lock()
1233 .map_err(|e| internal_error(e.to_string()))?;
1234 let run_ids = runs.list();
1235 let durable_integrity = self
1236 .store
1237 .as_ref()
1238 .is_some_and(PersistentStore::has_integrity_key);
1239
1240 return Ok(structured_output(serde_json::json!({
1241 "graphs": graph_names,
1242 "graph_count": graphs.len(),
1243 "execution_count": run_ids.len(),
1244 "retained_execution_count": run_ids.len(),
1245 "total_execution_count": run_ids.len(),
1246 "base_url": self.safe_provider_label(),
1247 "default_model": self.default_model,
1248 "storage_class": if self.store.is_none() {
1249 "process_local"
1250 } else if durable_integrity {
1251 "persisted_integrity_verified"
1252 } else {
1253 "persisted_unverified"
1254 },
1255 "capabilities": {
1256 "runtime": "agent_graph",
1257 "async_start": true,
1258 "api_key_configured": self.api_key.is_some(),
1259 "cancellation": "provider_future_best_effort_drop; underlying_request_may_continue",
1260 "durable_resume": if durable_integrity {
1261 Value::String("deterministic_local_resume_only".into())
1262 } else {
1263 Value::Bool(false)
1264 },
1265 "terminal_persistence": if durable_integrity { "sqlite_projection_only" } else { "disabled_without_integrity_key" },
1266 "checkpointing": if durable_integrity { "deterministic_local_pre_execution" } else { "unavailable" },
1267 "events": if self.store.is_some() { "terminal_persisted_projection_with_sqlite_fallback" } else { "volatile_in_memory_only" },
1268 "event_replay": "not_replayable_execution",
1269 "restart_recovery": "interrupted_non_resumable",
1270 "budgets": {
1271 "max_wall_clock_ms": "enforced",
1272 "max_nodes": "enforced_at_engine_superstep_boundary",
1273 "max_llm_calls": "enforced_before_provider_invocation"
1274 },
1275 "state_write_conflicts": "rejected_without_explicit_reducer",
1276 "evidence": "witness_bound_local_capture_only; locators_not_fetched; source_authority_not_verified",
1277 "evidence_authority": "caller_supplied_unverified_or_local_primary_capture",
1278 "hitl": if durable_integrity { "checkpoint_bound_durable_approval_only" } else { "unavailable" },
1279 "replay": "integrity_only"
1280 },
1281 "limits": {"graphs": self.max_graphs},
1282 "capacity_state": if graphs.len() <= self.max_graphs {
1283 "within_limit"
1284 } else {
1285 "over_limit_legacy"
1286 }
1287 })));
1288 }
1289
1290 match resource.unwrap() {
1291 "templates" => Ok(structured_output(templates::list())),
1292
1293 "graph" => {
1294 let id = graph_id
1295 .as_deref()
1296 .ok_or_else(|| invalid_params("missing graph_id"))?;
1297 let graphs = self
1298 .graphs
1299 .lock()
1300 .map_err(|e| internal_error(e.to_string()))?;
1301 let g = graphs
1302 .get(id)
1303 .ok_or_else(|| invalid_params(format!("graph '{id}' not found")))?;
1304 Ok(output_with_meta(
1305 serde_json::json!({
1306 "graph_id": id,
1307 "graph_version": g.version,
1308 "normalized_spec": g.normalized,
1309 "mermaid": Self::mermaid(&g.spec),
1310 "warnings": g.warnings,
1311 "storage_class": "volatile"
1312 }),
1313 Some(id),
1314 Some(&g.version),
1315 None,
1316 ))
1317 }
1318
1319 "run" => {
1320 let runs = self
1321 .runs
1322 .lock()
1323 .map_err(|e| internal_error(e.to_string()))?;
1324 if run_id.is_none() {
1325 return Ok(structured_output(serde_json::json!({
1327 "runs": runs.list()
1328 })));
1329 }
1330 let id = run_id.as_deref().unwrap();
1331 let r = runs
1332 .get(id)
1333 .ok_or_else(|| invalid_params(format!("run '{id}' not found")))?;
1334 Ok(structured_output(r.public()))
1335 }
1336
1337 "events" => {
1338 let id = run_id
1339 .as_deref()
1340 .ok_or_else(|| invalid_params("missing run_id for events"))?;
1341 let runs = self
1342 .runs
1343 .lock()
1344 .map_err(|e| internal_error(e.to_string()))?;
1345 let cursor_val = cursor.unwrap_or(0);
1346 let limit_val = limit.unwrap_or(100) as usize;
1347 let result = runs
1348 .events(self.store.as_ref(), id, cursor_val, limit_val)
1349 .map_err(|e| invalid_params(e))?;
1350 Ok(output_with_meta(result, None, None, Some(id)))
1351 }
1352
1353 "receipt" => {
1354 let id = run_id
1355 .as_deref()
1356 .ok_or_else(|| invalid_params("missing run_id for receipt"))?;
1357 let runs = self
1358 .runs
1359 .lock()
1360 .map_err(|e| internal_error(e.to_string()))?;
1361 let r = runs
1362 .get(id)
1363 .ok_or_else(|| invalid_params(format!("run '{id}' not found")))?;
1364 Ok(output_with_meta(r.receipt.clone(), None, None, Some(id)))
1365 }
1366
1367 "bundle" => {
1368 let id = run_id
1369 .as_deref()
1370 .ok_or_else(|| invalid_params("missing run_id for bundle"))?;
1371 let runs = self
1372 .runs
1373 .lock()
1374 .map_err(|e| internal_error(e.to_string()))?;
1375 let r = runs
1376 .get(id)
1377 .ok_or_else(|| invalid_params(format!("run '{id}' not found")))?;
1378 Ok(output_with_meta(r.bundle.clone(), None, None, Some(id)))
1379 }
1380
1381 _ => Ok(error_output(
1382 format!("unknown status resource '{}'", resource.unwrap_or("")),
1383 "INVALID_RESOURCE",
1384 )),
1385 }
1386 }
1387
1388 #[tool(
1391 description = "List all registered graphs with metadata (name, node count, edge count, version)."
1392 )]
1393 fn graph_list(
1394 &self,
1395 Parameters(GraphListParams { query, limit }): Parameters<GraphListParams>,
1396 ) -> Result<Json<StructuredOutput>, ErrorData> {
1397 let graphs = self
1398 .graphs
1399 .lock()
1400 .map_err(|e| internal_error(e.to_string()))?;
1401
1402 let mut entries: Vec<Value> = graphs
1403 .iter()
1404 .filter(|(name, _)| {
1405 let visible = self
1406 .store
1407 .as_ref()
1408 .map(|store| store.graph_is_tombstoned(name).map(|v| !v).unwrap_or(false))
1409 .unwrap_or(true);
1410 query
1411 .as_ref()
1412 .map(|q| name.contains(q.as_str()))
1413 .unwrap_or(true)
1414 && visible
1415 })
1416 .take(limit.unwrap_or(50) as usize)
1417 .map(|(name, g)| {
1418 let version_history = self
1419 .store
1420 .as_ref()
1421 .and_then(|store| store.list_graph_versions(name).ok())
1422 .unwrap_or_else(|| vec![g.version.clone()]);
1423 serde_json::json!({
1424 "name": name,
1425 "version": g.version,
1426 "current_version": g.version,
1427 "version_history": version_history,
1428 "historical_specs": self.store.is_some(),
1429 "node_count": g.spec.nodes.len(),
1430 "edge_count": g.spec.edges.len(),
1431 "entry": g.spec.entry,
1432 "warnings": g.warnings,
1433 })
1434 })
1435 .collect();
1436
1437 entries.sort_by(|a, b| {
1438 a.get("name")
1439 .and_then(Value::as_str)
1440 .cmp(&b.get("name").and_then(Value::as_str))
1441 });
1442
1443 Ok(structured_output(serde_json::json!({
1444 "graphs": entries,
1445 "count": entries.len(),
1446 })))
1447 }
1448
1449 #[tool(
1450 description = "Read a durable graph retention inventory with execution, version, and inbound-subgraph reference counts. This tool never changes graph state."
1451 )]
1452 fn graph_retention_review(
1453 &self,
1454 Parameters(GraphRetentionReviewParams {
1455 graph_id,
1456 state,
1457 limit,
1458 }): Parameters<GraphRetentionReviewParams>,
1459 ) -> Result<Json<StructuredOutput>, ErrorData> {
1460 let Some(store) = self.store.as_ref() else {
1461 return Ok(error_output(
1462 "SQLite persistence is required for graph retention review",
1463 "RETENTION_STORE_REQUIRED",
1464 ));
1465 };
1466 let reports = store
1467 .graph_retention_review(
1468 graph_id.as_deref(),
1469 state.as_deref(),
1470 limit.unwrap_or(100).clamp(1, 256) as usize,
1471 )
1472 .map_err(internal_error)?;
1473 let graphs: Vec<Value> = reports
1474 .into_iter()
1475 .map(|report| {
1476 serde_json::json!({
1477 "graph_id": report.graph_id,
1478 "state": report.state,
1479 "reason": report.reason,
1480 "actor": report.actor,
1481 "review_after": report.review_after,
1482 "created_at": report.created_at,
1483 "updated_at": report.updated_at,
1484 "version_count": report.version_count,
1485 "execution_count": report.execution_count,
1486 "last_execution_at": report.last_execution_at,
1487 "state_digest": report.state_digest,
1488 "tombstoned": report.tombstoned,
1489 "inbound_subgraph_refs": report.inbound_subgraph_refs,
1490 "deletion_eligible": report.deletion_eligible,
1491 })
1492 })
1493 .collect();
1494 Ok(structured_output(serde_json::json!({
1495 "graphs": graphs,
1496 "count": graphs.len(),
1497 "storage_class": "sqlite_graph_retention",
1498 })))
1499 }
1500
1501 #[tool(
1502 description = "Set an explicit durable graph lifecycle state. delete_approved requires a prior delete_candidate state and no execution or inbound-subgraph references."
1503 )]
1504 fn graph_retention_set(
1505 &self,
1506 Parameters(GraphRetentionSetParams {
1507 graph_id,
1508 state,
1509 reason,
1510 actor,
1511 review_after,
1512 }): Parameters<GraphRetentionSetParams>,
1513 ) -> Result<Json<StructuredOutput>, ErrorData> {
1514 let _ = (graph_id, state, reason, actor, review_after);
1515 Ok(error_output(
1516 "graph lifecycle updates require the authenticated operator service",
1517 "AUTHENTICATED_OPERATOR_REQUIRED",
1518 ))
1519 }
1520
1521 #[allow(dead_code)]
1524 fn graph_delete(
1525 &self,
1526 Parameters(GraphDeleteParams { graph_id }): Parameters<GraphDeleteParams>,
1527 ) -> Result<Json<StructuredOutput>, ErrorData> {
1528 self.delete_registered_graph(&graph_id)
1529 }
1530
1531 #[tool(
1534 description = "Get a graph's full topology: nodes, edges, Mermaid diagram, and topology hash."
1535 )]
1536 fn graph_inspect(
1537 &self,
1538 Parameters(GraphInspectParams { graph_id }): Parameters<GraphInspectParams>,
1539 ) -> Result<Json<StructuredOutput>, ErrorData> {
1540 let graphs = self
1541 .graphs
1542 .lock()
1543 .map_err(|e| internal_error(e.to_string()))?;
1544 let g = graphs
1545 .get(&graph_id)
1546 .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
1547
1548 let nodes: Vec<Value> = g
1549 .spec
1550 .nodes
1551 .iter()
1552 .map(|n| {
1553 serde_json::json!({
1554 "id": n.id,
1555 "type": n.node_type,
1556 "config": n.config,
1557 })
1558 })
1559 .collect();
1560
1561 let edges: Vec<Value> = g
1562 .spec
1563 .edges
1564 .iter()
1565 .map(|e| {
1566 serde_json::json!({
1567 "from": e.from,
1568 "to": e.to,
1569 })
1570 })
1571 .collect();
1572
1573 Ok(output_with_meta(
1574 serde_json::json!({
1575 "name": graph_id,
1576 "version": g.version,
1577 "current_version": g.version,
1578 "version_history": self.store.as_ref().and_then(|store| store.list_graph_versions(&graph_id).ok()).unwrap_or_else(|| vec![g.version.clone()]),
1579 "historical_specs": self.store.is_some(),
1580 "entry": g.spec.entry,
1581 "max_iterations": g.spec.max_iterations,
1582 "max_parallelism": g.spec.max_parallelism,
1583 "nodes": nodes,
1584 "node_count": nodes.len(),
1585 "edges": edges,
1586 "edge_count": edges.len(),
1587 "mermaid": Self::mermaid(&g.spec),
1588 "topology_hash": g.version,
1589 "reducers": g.spec.reducers,
1590 "warnings": g.warnings,
1591 }),
1592 Some(&graph_id),
1593 Some(&g.version),
1594 None,
1595 ))
1596 }
1597
1598 fn validate_resume_checkpoint(
1601 &self,
1602 store: &PersistentStore,
1603 checkpoint: &CheckpointRecord,
1604 ) -> Result<
1605 (
1606 crate::store::ExecutionContract,
1607 RegisteredGraph,
1608 Option<RunBudgets>,
1609 ),
1610 (String, String),
1611 > {
1612 let contract = store
1613 .load_execution_contract(&checkpoint.run_id)
1614 .map_err(|error| (error, "CHECKPOINT_PERSISTENCE_FAILURE".into()))?
1615 .ok_or_else(|| {
1616 (
1617 "checkpoint execution contract was not found".into(),
1618 "CHECKPOINT_INTEGRITY_FAILURE".into(),
1619 )
1620 })?;
1621 if contract.graph_id != checkpoint.graph_id
1622 || contract.graph_version != checkpoint.graph_version
1623 || checkpoint.terminal_cursor != 0
1624 || checkpoint.event_cursor != 0
1625 {
1626 return Err((
1627 "checkpoint integrity validation failed".into(),
1628 "CHECKPOINT_INTEGRITY_FAILURE".into(),
1629 ));
1630 }
1631 let graph = self
1632 .resolve_graph(&checkpoint.graph_id, Some(&checkpoint.graph_version))
1633 .map_err(|_| {
1634 (
1635 "checkpoint graph version is unavailable".into(),
1636 "CHECKPOINT_INTEGRITY_FAILURE".into(),
1637 )
1638 })?;
1639 let eligibility = graph.spec.resume_eligibility().map_err(|_| {
1640 (
1641 "checkpoint graph is no longer in the deterministic local resume subset".into(),
1642 "RESUME_INELIGIBLE".into(),
1643 )
1644 })?;
1645 if graph.version != checkpoint.graph_version
1646 || checkpoint.next_node_cursor != eligibility.next_node_cursor
1647 || checkpoint.dependency_summary != eligibility.dependency_summary
1648 || checkpoint.dependency_digest != digest(&eligibility.dependency_summary)
1649 || checkpoint.state != initial_state_for_input(&contract.input)
1650 || checkpoint.budgets != contract.budgets
1651 || checkpoint.budget_counters
1652 != serde_json::json!({"nodes":0,"llm_calls":0,"wall_clock_ms":0})
1653 {
1654 return Err((
1655 "checkpoint integrity validation failed".into(),
1656 "CHECKPOINT_INTEGRITY_FAILURE".into(),
1657 ));
1658 }
1659 let budgets = RunBudgets::parse(Some(&checkpoint.budgets)).map_err(|_| {
1660 (
1661 "checkpoint budgets failed validation".into(),
1662 "CHECKPOINT_INTEGRITY_FAILURE".into(),
1663 )
1664 })?;
1665 Ok((contract, graph, budgets))
1666 }
1667
1668 fn launch_resumed(
1669 &self,
1670 checkpoint: CheckpointRecord,
1671 contract: crate::store::ExecutionContract,
1672 graph: RegisteredGraph,
1673 budgets: Option<RunBudgets>,
1674 approval: Option<Value>,
1675 ) -> Result<Json<StructuredOutput>, ErrorData> {
1676 let runs = self
1677 .runs
1678 .lock()
1679 .map_err(|e| internal_error(e.to_string()))?;
1680 if runs.get(&checkpoint.run_id).is_some() {
1681 let _ = runs.remove(&checkpoint.run_id);
1682 }
1683 let run_id = match runs.allocate_resumed(
1684 &checkpoint.run_id,
1685 &checkpoint.graph_id,
1686 &checkpoint.graph_version,
1687 contract.input,
1688 checkpoint.state.clone(),
1689 budgets,
1690 &checkpoint.checkpoint_id,
1691 &checkpoint.checkpoint_digest,
1692 approval.clone(),
1693 ) {
1694 Ok(run_id) => run_id,
1695 Err(error) => {
1696 runs.release_async_slot();
1697 return Ok(error_output(error, "RUN_CAPACITY"));
1698 }
1699 };
1700 if let Err(error) = runs.admit_reserved_async(&run_id) {
1701 runs.remove(&run_id);
1702 runs.release_async_slot();
1703 return Ok(error_output(error, "RUN_CAPACITY"));
1704 }
1705 self.store
1706 .as_ref()
1707 .expect("resumed launch requires SQLite")
1708 .update_execution_status(&run_id, "running", None, None, None)
1709 .map_err(internal_error)?;
1710 let terminal_store = self.store.clone();
1711 let completion_runs = runs.clone();
1712 runs.start_resumed_with_completion(
1713 run_id.clone(),
1714 graph.spec,
1715 self.base_url.clone(),
1716 self.default_model.clone(),
1717 self.store.clone(),
1718 move |record| Self::persist_terminal_and_mark(completion_runs, terminal_store, record),
1719 );
1720 Ok(output_with_meta(
1721 serde_json::json!({
1722 "run_id": run_id,
1723 "status": "running",
1724 "checkpoint": checkpoint_value(&checkpoint),
1725 "resume_capability": "deterministic_local_resume",
1726 "approval": approval,
1727 }),
1728 Some(&checkpoint.graph_id),
1729 Some(&checkpoint.graph_version),
1730 Some(&run_id),
1731 ))
1732 }
1733
1734 #[tool(
1735 description = "Create a durable approval request bound to one unconsumed deterministic-local checkpoint."
1736 )]
1737 fn graph_approval_request(
1738 &self,
1739 Parameters(ApprovalRequestParams {
1740 checkpoint_id,
1741 audience,
1742 prompt,
1743 allowed_decisions,
1744 expiration,
1745 }): Parameters<ApprovalRequestParams>,
1746 ) -> Result<Json<StructuredOutput>, ErrorData> {
1747 let Some(store) = self.store.as_ref() else {
1748 return Ok(error_output(
1749 "SQLite persistence is required for durable approvals",
1750 "APPROVAL_STORE_REQUIRED",
1751 ));
1752 };
1753 if audience.trim().is_empty() || audience.len() > 256 {
1754 return Ok(error_output(
1755 "audience must be non-empty and at most 256 bytes",
1756 "INVALID_PARAMS",
1757 ));
1758 }
1759 if allowed_decisions.is_empty()
1760 || allowed_decisions
1761 .iter()
1762 .any(|decision| !matches!(decision.as_str(), "approve" | "reject"))
1763 {
1764 return Ok(error_output(
1765 "allowed_decisions must be a non-empty subset of approve and reject",
1766 "INVALID_PARAMS",
1767 ));
1768 }
1769 if chrono::DateTime::parse_from_rfc3339(&expiration).is_err() {
1770 return Ok(error_output("expiration must be RFC3339", "INVALID_PARAMS"));
1771 }
1772 if prompt.len() > 16 * 1024 {
1773 return Ok(error_output(
1774 "prompt exceeds the bounded approval prompt size",
1775 "INVALID_PARAMS",
1776 ));
1777 }
1778 let checkpoint = match store.load_resume_checkpoint(Some(&checkpoint_id), None) {
1779 Ok(Some(checkpoint)) => checkpoint,
1780 Ok(None) => return Ok(checkpoint_error_output(CheckpointError::NotFound)),
1781 Err(error) => return Ok(checkpoint_error_output(error)),
1782 };
1783 if checkpoint.consumed_at.is_some() {
1784 return Ok(checkpoint_error_output(CheckpointError::Consumed));
1785 }
1786 if let Err((message, code)) = self.validate_resume_checkpoint(store, &checkpoint) {
1787 return Ok(error_output(message, code));
1788 }
1789 let prompt_digest = digest(&Value::String(prompt));
1790 let approval = match store.create_checkpoint_approval(
1791 &checkpoint.checkpoint_id,
1792 &checkpoint.graph_id,
1793 &checkpoint.graph_version,
1794 &checkpoint.next_node_cursor,
1795 &checkpoint.state,
1796 &checkpoint.budgets,
1797 &checkpoint.budget_counters,
1798 &checkpoint.dependency_summary,
1799 &audience,
1800 &prompt_digest,
1801 &allowed_decisions,
1802 &expiration,
1803 ) {
1804 Ok(approval) => approval,
1805 Err(error) => return Ok(approval_error_output(error)),
1806 };
1807 Ok(output_with_meta(
1808 approval_value(&approval),
1809 Some(&approval.graph_id),
1810 Some(&approval.graph_version),
1811 Some(&approval.run_id),
1812 ))
1813 }
1814
1815 #[tool(
1816 description = "Read durable checkpoint-bound approval metadata from SQLite without raw prompt or checkpoint state."
1817 )]
1818 fn graph_approval_list(
1819 &self,
1820 Parameters(ApprovalListParams {
1821 run_id,
1822 status,
1823 limit,
1824 }): Parameters<ApprovalListParams>,
1825 ) -> Result<Json<StructuredOutput>, ErrorData> {
1826 let Some(store) = self.store.as_ref() else {
1827 return Ok(error_output(
1828 "SQLite persistence is required for durable approvals",
1829 "APPROVAL_STORE_REQUIRED",
1830 ));
1831 };
1832 let approvals = store
1833 .list_checkpoint_approvals(
1834 run_id.as_deref(),
1835 status.as_deref(),
1836 limit.unwrap_or(50) as usize,
1837 )
1838 .map_err(|error| internal_error(error.message()))?;
1839 Ok(structured_output(serde_json::json!({
1840 "approvals": approvals.iter().map(approval_value).collect::<Vec<_>>(),
1841 "count": approvals.len(),
1842 "storage_class": "sqlite_durable_approval_metadata",
1843 })))
1844 }
1845
1846 #[tool(
1847 description = "Read one durable checkpoint-bound approval's metadata from SQLite without raw prompt or checkpoint state."
1848 )]
1849 fn graph_approval_get(
1850 &self,
1851 Parameters(ApprovalGetParams { approval_id }): Parameters<ApprovalGetParams>,
1852 ) -> Result<Json<StructuredOutput>, ErrorData> {
1853 let Some(store) = self.store.as_ref() else {
1854 return Ok(error_output(
1855 "SQLite persistence is required for durable approvals",
1856 "APPROVAL_STORE_REQUIRED",
1857 ));
1858 };
1859 match store
1860 .get_checkpoint_approval(&approval_id)
1861 .map_err(|error| internal_error(error.message()))?
1862 {
1863 Some(approval) => Ok(output_with_meta(
1864 approval_value(&approval),
1865 Some(&approval.graph_id),
1866 Some(&approval.graph_version),
1867 Some(&approval.run_id),
1868 )),
1869 None => Ok(approval_error_output(ApprovalError::NotFound)),
1870 }
1871 }
1872
1873 #[allow(dead_code)]
1874 fn graph_approval_decide(
1875 &self,
1876 Parameters(ApprovalDecideParams {
1877 approval_id: _,
1878 decision: _,
1879 claimed_actor_label: _,
1880 }): Parameters<ApprovalDecideParams>,
1881 ) -> Result<Json<StructuredOutput>, ErrorData> {
1882 return Ok(error_output(
1883 "approval decisions require authenticated operator transport",
1884 "AUTHENTICATED_OPERATOR_REQUIRED",
1885 ));
1886 }
1887
1888 #[tool(
1891 description = "Start an async graph run. Returns run_id immediately; use graph_run_wait to block on completion. Optional budgets accept only positive integer max_wall_clock_ms, max_nodes, or max_llm_calls fields; max_llm_calls is enforced before each provider invocation."
1892 )]
1893 fn graph_run_start(
1894 &self,
1895 Parameters(RunStartParams {
1896 graph_id,
1897 input,
1898 graph_version,
1899 thread_id,
1900 idempotency_key,
1901 budgets,
1902 checkpoint,
1903 }): Parameters<RunStartParams>,
1904 ) -> Result<Json<StructuredOutput>, ErrorData> {
1905 let requested_budgets = match RunBudgets::parse(budgets.as_ref()) {
1906 Ok(budgets) => budgets,
1907 Err(error) => return Ok(error_output(error, "INVALID_BUDGETS")),
1908 };
1909 let input = input.unwrap_or(Value::Null);
1910 let checkpoint_requested = checkpoint.unwrap_or(false);
1911 ensure_size(&input, MAX_INPUT_BYTES, "execution input").map_err(|e| invalid_params(e))?;
1912
1913 let RegisteredGraph {
1914 spec,
1915 normalized,
1916 version,
1917 ..
1918 } = self.resolve_graph(&graph_id, graph_version.as_deref())?;
1919
1920 if Self::graph_requires_witness_store(&spec) && self.store.is_none() {
1921 return Ok(error_output(
1922 "evidence-required graphs require SQLite witness persistence",
1923 "WITNESS_STORE_REQUIRED",
1924 ));
1925 }
1926
1927 let eligibility = if checkpoint_requested {
1928 match spec.resume_eligibility() {
1929 Ok(eligibility) => Some(eligibility),
1930 Err(reason) => return Ok(error_output(reason, "RESUME_INELIGIBLE")),
1931 }
1932 } else {
1933 None
1934 };
1935
1936 let request_digest = digest(&serde_json::json!({
1937 "operation": "graph_run_start",
1938 "graph_id": graph_id,
1939 "graph_spec": normalized,
1940 "graph_version": version,
1941 "input": input,
1942 "thread_id": thread_id,
1943 "budgets": requested_budgets
1944 .as_ref()
1945 .map(RunBudgets::requested_value)
1946 .unwrap_or(Value::Null),
1947 "checkpoint": checkpoint_requested,
1948 }));
1949
1950 let runs = self
1951 .runs
1952 .lock()
1953 .map_err(|e| internal_error(e.to_string()))?;
1954 if let Some(idem) = idempotency_key.as_deref() {
1955 if let Some(cached) =
1956 check_idempotency(self.store.as_ref(), Some(idem), &request_digest)?
1957 {
1958 return Ok(cached);
1959 }
1960 }
1961
1962 if checkpoint_requested {
1963 let Some(store) = self.store.as_ref() else {
1964 return Ok(error_output(
1965 "SQLite persistence is required for deterministic checkpoints",
1966 "CHECKPOINT_STORE_REQUIRED",
1967 ));
1968 };
1969 let eligibility = eligibility.expect("checkpoint eligibility");
1970 let state = initial_state_for_input(&input);
1971 let budgets_value = requested_budgets
1972 .as_ref()
1973 .map(RunBudgets::requested_value)
1974 .unwrap_or(Value::Null);
1975 let counters = serde_json::json!({"nodes":0,"llm_calls":0,"wall_clock_ms":0});
1976 let run_id = runs
1977 .allocate_with_budgets(
1978 &graph_id,
1979 &version,
1980 input.clone(),
1981 requested_budgets.clone(),
1982 )
1983 .map_err(|e| internal_error(e))?;
1984 if let Err(error) = store.save_execution_with_budgets(
1985 &run_id,
1986 &graph_id,
1987 &version,
1988 "checkpointed",
1989 &input.to_string(),
1990 Some(&budgets_value.to_string()),
1991 ) {
1992 runs.remove(&run_id);
1993 return Ok(error_output(error, "CHECKPOINT_PERSISTENCE_FAILURE"));
1994 }
1995 let checkpoint_record = match store.create_resume_checkpoint(
1996 &run_id,
1997 &graph_id,
1998 &version,
1999 &eligibility.next_node_cursor,
2000 &state,
2001 &budgets_value,
2002 &counters,
2003 &eligibility.dependency_summary,
2004 0,
2005 0,
2006 ) {
2007 Ok(record) => record,
2008 Err(error) => {
2009 let _ = store.update_execution_status(&run_id, "failed", None, None, None);
2010 runs.remove(&run_id);
2011 return Ok(checkpoint_error_output(error));
2012 }
2013 };
2014 runs.mark_checkpointed(
2015 &run_id,
2016 &checkpoint_record.checkpoint_id,
2017 &checkpoint_record.checkpoint_digest,
2018 )
2019 .map_err(internal_error)?;
2020 let output = output_with_meta(
2021 serde_json::json!({
2022 "run_id": run_id,
2023 "status": "checkpointed",
2024 "thread_id": thread_id,
2025 "checkpoint_id": checkpoint_record.checkpoint_id,
2026 "checkpoint_digest": checkpoint_record.checkpoint_digest,
2027 "checkpoint": checkpoint_value(&checkpoint_record),
2028 "resume_capability": "deterministic_local_resume",
2029 }),
2030 Some(&graph_id),
2031 Some(&version),
2032 Some(&run_id),
2033 );
2034 if let Some(idem) = idempotency_key {
2035 if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
2036 return Ok(cached);
2037 }
2038 }
2039 return Ok(output);
2040 }
2041
2042 let run_id = runs
2043 .allocate_with_budgets(&graph_id, &version, input.clone(), requested_budgets)
2044 .map_err(|e| internal_error(e))?;
2045 if let Err(e) = runs.admit_async(&run_id) {
2046 runs.remove(&run_id);
2047 return Ok(error_output(e, "RUN_CAPACITY"));
2048 }
2049
2050 if let Some(ref store) = self.store {
2051 let _ =
2052 store.save_execution(&run_id, &graph_id, &version, "running", &input.to_string());
2053 }
2054
2055 let terminal_store = self.store.clone();
2056 let completion_runs = runs.clone();
2057 runs.start_with_completion_with_store(
2058 run_id.clone(),
2059 spec,
2060 self.base_url.clone(),
2061 self.default_model.clone(),
2062 self.store.clone(),
2063 move |record| Self::persist_terminal_and_mark(completion_runs, terminal_store, record),
2064 );
2065
2066 let output = output_with_meta(
2067 serde_json::json!({
2068 "run_id": run_id,
2069 "status": "running",
2070 "thread_id": thread_id,
2071 }),
2072 Some(&graph_id),
2073 Some(&version),
2074 Some(&run_id),
2075 );
2076 if let Some(ref store) = self.store {
2077 if let Some(idem) = idempotency_key {
2078 if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
2079 return Ok(cached);
2080 }
2081 }
2082 }
2083
2084 Ok(output)
2085 }
2086
2087 #[tool(
2088 description = "Read one durable deterministic-local checkpoint, including its integrity-bound state and resume metadata."
2089 )]
2090 fn graph_run_checkpoint(
2091 &self,
2092 Parameters(RunCheckpointParams {
2093 run_id,
2094 checkpoint_id,
2095 }): Parameters<RunCheckpointParams>,
2096 ) -> Result<Json<StructuredOutput>, ErrorData> {
2097 let Some(store) = self.store.as_ref() else {
2098 return Ok(error_output(
2099 "SQLite persistence is required for checkpoint reads",
2100 "CHECKPOINT_STORE_REQUIRED",
2101 ));
2102 };
2103 if run_id.is_none() && checkpoint_id.is_none() {
2104 return Ok(error_output(
2105 "run_id or checkpoint_id is required for checkpoint reads",
2106 "INVALID_PARAMS",
2107 ));
2108 }
2109 match store.load_resume_checkpoint(checkpoint_id.as_deref(), run_id.as_deref()) {
2110 Ok(Some(record))
2111 if run_id
2112 .as_deref()
2113 .is_none_or(|run_id| record.run_id == run_id) =>
2114 {
2115 Ok(output_with_meta(
2116 checkpoint_value(&record),
2117 Some(&record.graph_id),
2118 Some(&record.graph_version),
2119 Some(&record.run_id),
2120 ))
2121 }
2122 Ok(Some(_)) => Ok(checkpoint_error_output(CheckpointError::Integrity)),
2123 Ok(None) => Ok(checkpoint_error_output(CheckpointError::NotFound)),
2124 Err(error) => Ok(checkpoint_error_output(error)),
2125 }
2126 }
2127
2128 #[tool(
2129 description = "Consume one deterministic-local checkpoint atomically and resume its pinned run exactly once."
2130 )]
2131 fn graph_run_resume(
2132 &self,
2133 Parameters(RunResumeParams {
2134 checkpoint_id,
2135 run_id,
2136 }): Parameters<RunResumeParams>,
2137 ) -> Result<Json<StructuredOutput>, ErrorData> {
2138 let Some(store) = self.store.as_ref() else {
2139 return Ok(error_output(
2140 "SQLite persistence is required for deterministic resume",
2141 "CHECKPOINT_STORE_REQUIRED",
2142 ));
2143 };
2144 if checkpoint_id.is_none() && run_id.is_none() {
2145 return Ok(error_output(
2146 "checkpoint_id or run_id is required for resume",
2147 "INVALID_PARAMS",
2148 ));
2149 }
2150 let checkpoint =
2151 match store.load_resume_checkpoint(checkpoint_id.as_deref(), run_id.as_deref()) {
2152 Ok(Some(record)) => record,
2153 Ok(None) => return Ok(checkpoint_error_output(CheckpointError::NotFound)),
2154 Err(error) => return Ok(checkpoint_error_output(error)),
2155 };
2156 if store
2157 .checkpoint_approval_status(&checkpoint.checkpoint_id)
2158 .map_err(|error| internal_error(error.message()))?
2159 .as_deref()
2160 == Some("pending")
2161 {
2162 return Ok(error_output(
2163 "checkpoint resume is pending its durable approval decision",
2164 "APPROVAL_PENDING",
2165 ));
2166 }
2167 if checkpoint.consumed_at.is_some() {
2168 return Ok(checkpoint_error_output(CheckpointError::Consumed));
2169 }
2170 if run_id
2171 .as_deref()
2172 .is_some_and(|run_id| run_id != checkpoint.run_id)
2173 {
2174 return Ok(checkpoint_error_output(CheckpointError::Integrity));
2175 }
2176 let Some(contract) = store
2177 .load_execution_contract(&checkpoint.run_id)
2178 .map_err(internal_error)?
2179 else {
2180 return Ok(checkpoint_error_output(CheckpointError::Integrity));
2181 };
2182 if contract.graph_id != checkpoint.graph_id
2183 || contract.graph_version != checkpoint.graph_version
2184 || checkpoint.terminal_cursor != 0
2185 || checkpoint.event_cursor != 0
2186 {
2187 return Ok(checkpoint_error_output(CheckpointError::Integrity));
2188 }
2189 let graph = match self.resolve_graph(&checkpoint.graph_id, Some(&checkpoint.graph_version))
2190 {
2191 Ok(graph) => graph,
2192 Err(_) => return Ok(checkpoint_error_output(CheckpointError::Integrity)),
2193 };
2194 if graph.version != checkpoint.graph_version {
2195 return Ok(checkpoint_error_output(CheckpointError::Integrity));
2196 }
2197 let eligibility = match graph.spec.resume_eligibility() {
2198 Ok(eligibility) => eligibility,
2199 Err(_) => {
2200 return Ok(error_output(
2201 "checkpoint graph is no longer in the deterministic local resume subset",
2202 "RESUME_INELIGIBLE",
2203 ));
2204 }
2205 };
2206 if checkpoint.next_node_cursor != eligibility.next_node_cursor
2207 || checkpoint.dependency_summary != eligibility.dependency_summary
2208 || checkpoint.dependency_digest != digest(&eligibility.dependency_summary)
2209 || checkpoint.state != initial_state_for_input(&contract.input)
2210 || checkpoint.budgets != contract.budgets
2211 || checkpoint.budget_counters
2212 != serde_json::json!({"nodes":0,"llm_calls":0,"wall_clock_ms":0})
2213 {
2214 return Ok(checkpoint_error_output(CheckpointError::Integrity));
2215 }
2216 let budgets = match RunBudgets::parse(Some(&checkpoint.budgets)) {
2217 Ok(budgets) => budgets,
2218 Err(_) => return Ok(checkpoint_error_output(CheckpointError::Integrity)),
2219 };
2220 let reserved_runs = self
2221 .runs
2222 .lock()
2223 .map_err(|e| internal_error(e.to_string()))?;
2224 if let Err(error) = reserved_runs.reserve_async_slot() {
2225 return Ok(error_output(error, "RUN_CAPACITY"));
2226 }
2227 drop(reserved_runs);
2228 let consumed = match store.consume_resume_checkpoint(&checkpoint.checkpoint_id) {
2229 Ok(record) => record,
2230 Err(error) => {
2231 if let Ok(runs) = self.runs.lock() {
2232 runs.release_async_slot();
2233 }
2234 return Ok(checkpoint_error_output(error));
2235 }
2236 };
2237 self.launch_resumed(consumed, contract, graph, budgets, None)
2238 }
2239
2240 #[tool(description = "Wait for an async run to complete, with optional timeout.")]
2241 fn graph_run_wait(
2242 &self,
2243 Parameters(RunWaitParams { run_id, timeout_ms }): Parameters<RunWaitParams>,
2244 ) -> Result<Json<StructuredOutput>, ErrorData> {
2245 let timeout = Duration::from_millis(timeout_ms.unwrap_or(300_000));
2246 let deadline = Instant::now() + timeout;
2247 loop {
2248 let r = {
2249 let runs = self
2250 .runs
2251 .lock()
2252 .map_err(|e| internal_error(e.to_string()))?;
2253 runs.get(&run_id)
2254 .ok_or_else(|| invalid_params(format!("run '{run_id}' not found")))?
2255 };
2256 if matches!(r.status.as_str(), "completed" | "failed" | "cancelled") {
2257 let persist = Self::persist_terminal(self.store.clone(), r.clone());
2258 if let Ok(runs) = self.runs.lock() {
2259 if self.store.is_none() {
2260 runs.mark_persistence(&run_id, "volatile_no_store", None);
2261 } else {
2262 match persist {
2263 Ok(()) => runs.mark_persistence(&run_id, "durable_terminal", None),
2264 Err(error) => runs.mark_persistence(
2265 &run_id,
2266 "volatile_persistence_failed",
2267 Some(error),
2268 ),
2269 }
2270 }
2271 }
2272 let public = self
2273 .runs
2274 .lock()
2275 .ok()
2276 .and_then(|runs| runs.get(&run_id).map(|record| record.public()))
2277 .unwrap_or_else(|| r.public());
2278 return Ok(output_with_meta(public, None, None, Some(&run_id)));
2279 }
2280 if Instant::now() >= deadline {
2281 return Ok(output_with_meta(
2282 serde_json::json!({
2283 "run_id": run_id,
2284 "status": r.status,
2285 "timed_out": true,
2286 }),
2287 None,
2288 None,
2289 Some(&run_id),
2290 ));
2291 }
2292 std::thread::sleep(Duration::from_millis(100));
2293 }
2294 }
2295
2296 #[tool(description = "Cancel a running execution.")]
2297 fn graph_run_cancel(
2298 &self,
2299 Parameters(RunCancelParams { run_id, reason: _ }): Parameters<RunCancelParams>,
2300 ) -> Result<Json<StructuredOutput>, ErrorData> {
2301 let runs = self
2302 .runs
2303 .lock()
2304 .map_err(|e| internal_error(e.to_string()))?;
2305 match runs.cancel(&run_id) {
2306 Ok(_) => {}
2307 Err(error) if error == "RUN_NOT_CANCELLABLE" => {
2308 return Ok(error_output(
2309 "terminal or checkpointed runs cannot be cancelled",
2310 "RUN_NOT_CANCELLABLE",
2311 ));
2312 }
2313 Err(error) if error == "run not found" => {
2314 drop(runs);
2315 if self
2316 .store
2317 .as_ref()
2318 .and_then(|store| store.load_execution(&run_id).ok().flatten())
2319 .is_some_and(|stored| {
2320 matches!(
2321 stored.get("status").and_then(Value::as_str),
2322 Some("completed" | "failed" | "cancelled" | "checkpointed")
2323 )
2324 })
2325 {
2326 return Ok(error_output(
2327 "terminal or checkpointed runs cannot be cancelled",
2328 "RUN_NOT_CANCELLABLE",
2329 ));
2330 }
2331 return Err(invalid_params(error));
2332 }
2333 Err(error) => return Err(invalid_params(error)),
2334 }
2335 Ok(output_with_meta(
2336 serde_json::json!({
2337 "run_id": run_id,
2338 "status": "cancellation_requested",
2339 "cancellation_effect": "best_effort_drop_provider_future",
2340 "provider_request_may_still_be_in_flight": true,
2341 "effective_at": "provider_completion_or_cancellation_observation"
2342 }),
2343 None,
2344 None,
2345 Some(&run_id),
2346 ))
2347 }
2348
2349 #[tool(description = "Get current run status, budget usage, and pending approvals.")]
2350 fn graph_run_get(
2351 &self,
2352 Parameters(RunGetParams { run_id }): Parameters<RunGetParams>,
2353 ) -> Result<Json<StructuredOutput>, ErrorData> {
2354 let runs = self
2355 .runs
2356 .lock()
2357 .map_err(|e| internal_error(e.to_string()))?;
2358 if let Some(r) = runs.get(&run_id) {
2359 return Ok(output_with_meta(r.public(), None, None, Some(&run_id)));
2360 }
2361 drop(runs);
2362 if let Some(record) = self.stored_run(&run_id)? {
2363 return Ok(output_with_meta(record, None, None, Some(&run_id)));
2364 }
2365 Err(invalid_params(format!("run '{run_id}' not found")))
2366 }
2367
2368 #[tool(
2369 description = "Read the in-memory state projection from a live run; use graph_run_checkpoint for a durable checkpoint state."
2370 )]
2371 fn graph_run_state(
2372 &self,
2373 Parameters(RunStateParams {
2374 run_id,
2375 checkpoint_id: _,
2376 json_pointer,
2377 }): Parameters<RunStateParams>,
2378 ) -> Result<Json<StructuredOutput>, ErrorData> {
2379 let runs = self
2380 .runs
2381 .lock()
2382 .map_err(|e| internal_error(e.to_string()))?;
2383 let r = runs
2384 .get(&run_id)
2385 .ok_or_else(|| invalid_params(format!("run '{run_id}' not found")))?;
2386 let state = if let Some(pointer) = json_pointer.as_deref() {
2387 if pointer.is_empty() {
2388 r.state.clone()
2389 } else {
2390 r.state.pointer(pointer).cloned().unwrap_or(Value::Null)
2391 }
2392 } else {
2393 r.state.clone()
2394 };
2395 Ok(output_with_meta(
2396 serde_json::json!({
2397 "state": state,
2398 "run_id": run_id,
2399 "status": r.status,
2400 }),
2401 None,
2402 None,
2403 Some(&run_id),
2404 ))
2405 }
2406
2407 #[tool(
2408 description = "Read bounded events. With SQLite, terminal emitted events remain available as a persisted projection after restart; this is not replayable execution or resume support."
2409 )]
2410 fn graph_run_events(
2411 &self,
2412 Parameters(RunEventsParams {
2413 run_id,
2414 cursor,
2415 limit,
2416 }): Parameters<RunEventsParams>,
2417 ) -> Result<Json<StructuredOutput>, ErrorData> {
2418 let runs = self
2419 .runs
2420 .lock()
2421 .map_err(|e| internal_error(e.to_string()))?;
2422 let result = runs
2423 .events(
2424 self.store.as_ref(),
2425 &run_id,
2426 cursor.unwrap_or(0),
2427 limit.unwrap_or(100) as usize,
2428 )
2429 .map_err(|e| invalid_params(e))?;
2430 Ok(output_with_meta(result, None, None, Some(&run_id)))
2431 }
2432
2433 #[tool(description = "Fetch the canonical execution receipt for a run.")]
2434 fn graph_run_receipt(
2435 &self,
2436 Parameters(RunReceiptParams { run_id }): Parameters<RunReceiptParams>,
2437 ) -> Result<Json<StructuredOutput>, ErrorData> {
2438 if let Some(r) = self
2439 .runs
2440 .lock()
2441 .map_err(|e| internal_error(e.to_string()))?
2442 .get(&run_id)
2443 {
2444 return Ok(output_with_meta(
2449 serde_json::json!({
2450 "receipt": r.receipt.clone(),
2451 "receipt_digest": Value::Null,
2452 "storage_class": "volatile_live",
2453 "replay_capability": r
2454 .receipt
2455 .get("replay_capability")
2456 .and_then(Value::as_str)
2457 .unwrap_or("integrity_only"),
2458 }),
2459 None,
2460 None,
2461 Some(&run_id),
2462 ));
2463 }
2464 if let Some(store) = &self.store {
2465 match store.load_terminal_receipt(&run_id) {
2466 Ok(Some(receipt)) => {
2467 return Ok(output_with_meta(receipt, None, None, Some(&run_id)));
2468 }
2469 Ok(None) => {}
2470 Err(error) if error == "RECEIPT_INTEGRITY_FAILURE" => {
2471 return Ok(error_output(
2472 "terminal receipt integrity validation failed",
2473 "RECEIPT_INTEGRITY_FAILURE",
2474 ));
2475 }
2476 Err(error) if error == "INTEGRITY_KEY_REQUIRED" => {
2477 return Ok(error_output(
2478 "an external integrity key is required for terminal receipt reads",
2479 "INTEGRITY_KEY_REQUIRED",
2480 ));
2481 }
2482 Err(error) => return Err(internal_error(error)),
2483 }
2484 }
2485 Ok(error_output(
2486 format!("run '{run_id}' not found"),
2487 "RUN_NOT_FOUND",
2488 ))
2489 }
2490
2491 #[tool(description = "Preflight a graph against policy before execution.")]
2494 fn graph_policy_check(
2495 &self,
2496 Parameters(PolicyCheckParams { graph_id, input: _ }): Parameters<PolicyCheckParams>,
2497 ) -> Result<Json<StructuredOutput>, ErrorData> {
2498 let graphs = self
2499 .graphs
2500 .lock()
2501 .map_err(|e| internal_error(e.to_string()))?;
2502 let g = graphs
2503 .get(&graph_id)
2504 .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
2505
2506 let node_count = g.spec.nodes.len();
2507 let edge_count = g.spec.edges.len();
2508 let issues: Vec<String> = Vec::new();
2509
2510 Ok(structured_output(serde_json::json!({
2511 "graph_id": graph_id,
2512 "passed": issues.is_empty(),
2513 "issues": issues,
2514 "stats": {
2515 "node_count": node_count,
2516 "edge_count": edge_count,
2517 "max_iterations": g.spec.max_iterations,
2518 "max_parallelism": g.spec.max_parallelism,
2519 },
2520 "capabilities": {
2521 "models": [self.default_model.clone()],
2522 "tools": [],
2523 }
2524 })))
2525 }
2526
2527 #[tool(description = "Render a graph as Mermaid diagram or JSON topology.")]
2528 fn graph_render(
2529 &self,
2530 Parameters(RenderParams { graph_id, format }): Parameters<RenderParams>,
2531 ) -> Result<Json<StructuredOutput>, ErrorData> {
2532 let graphs = self
2533 .graphs
2534 .lock()
2535 .map_err(|e| internal_error(e.to_string()))?;
2536 let g = graphs
2537 .get(&graph_id)
2538 .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
2539 let fmt = format.as_deref().unwrap_or("mermaid");
2540
2541 match fmt {
2542 "json" => Ok(output_with_meta(
2543 serde_json::json!({
2544 "name": graph_id,
2545 "nodes": g.spec.nodes.iter().map(|n| serde_json::json!({
2546 "id": n.id, "type": n.node_type
2547 })).collect::<Vec<_>>(),
2548 "edges": g.spec.edges.iter().map(|e| serde_json::json!({
2549 "from": e.from, "to": e.to
2550 })).collect::<Vec<_>>(),
2551 }),
2552 Some(&graph_id),
2553 Some(&g.version),
2554 None,
2555 )),
2556 _ => Ok(output_with_meta(
2557 serde_json::json!({
2558 "mermaid": Self::mermaid(&g.spec),
2559 "name": graph_id,
2560 }),
2561 Some(&graph_id),
2562 Some(&g.version),
2563 None,
2564 )),
2565 }
2566 }
2567
2568 #[tool(description = "List available built-in graph templates.")]
2571 fn graph_template_list(
2572 &self,
2573 Parameters(TemplateListParams { query: _ }): Parameters<TemplateListParams>,
2574 ) -> Result<Json<StructuredOutput>, ErrorData> {
2575 Ok(structured_output(templates::list()))
2576 }
2577
2578 #[tool(
2579 description = "Instantiate a template into a graph spec that can be passed to graph_create."
2580 )]
2581 fn graph_template_instantiate(
2582 &self,
2583 Parameters(TemplateInstantiateParams { template_id, name }): Parameters<
2584 TemplateInstantiateParams,
2585 >,
2586 ) -> Result<Json<StructuredOutput>, ErrorData> {
2587 match templates::instantiate(&template_id, &name) {
2588 Ok(spec) => Ok(structured_output(serde_json::json!({
2589 "template_id": template_id,
2590 "name": name,
2591 "spec": spec,
2592 }))),
2593 Err(e) => Ok(error_output(e, "GRAPH_INVALID")),
2594 }
2595 }
2596 #[tool(description = "Read-only list of template promotion candidates.")]
2597 fn graph_template_candidates(
2598 &self,
2599 Parameters(TemplateCandidatesParams { state: _ }): Parameters<TemplateCandidatesParams>,
2600 ) -> Result<Json<StructuredOutput>, ErrorData> {
2601 Ok(structured_output(serde_json::json!({ "candidates": [] })))
2602 }
2603
2604 #[tool(description = "Read-only list of recorded outcomes for a template.")]
2605 fn graph_template_outcomes(
2606 &self,
2607 Parameters(TemplateOutcomesParams { template_id }): Parameters<TemplateOutcomesParams>,
2608 ) -> Result<Json<StructuredOutput>, ErrorData> {
2609 Ok(structured_output(serde_json::json!({
2610 "template_id": template_id,
2611 "outcomes": [],
2612 })))
2613 }
2614}
2615
2616#[tool_handler(
2617 router = self.tool_router,
2618 name = "agent-graph-mcp",
2619 version = "0.2.0",
2620 instructions = "Graph orchestration for bounded multi-step LLM workflows with parallel fan-out, conditional routing, state transforms, joins, cooperative cancellation, and optional enforced max_wall_clock_ms/max_nodes/max_llm_calls run budgets (max_llm_calls reserves each provider attempt before invocation; failed or timed-out attempts still count). Parallel unordered state writes require an explicit reducer. Cancellation can drop the local provider future on request, best effort; an underlying provider request may continue. Optional SQLite stores terminal projections plus explicit pre-execution checkpoints. Durable checkpoints, approvals, terminal receipts, and source witnesses require an external key file named by AGENT_GRAPH_INTEGRITY_KEY_PATH; without it their operations fail closed with INTEGRITY_KEY_REQUIRED. Deterministic local resume is limited to linear passthrough/state_transform chains and is never generic replay; uncheckpointed or ineligible runs remain interrupted_non_resumable after restart. SQLite-backed approvals can decide only an immutable deterministic-local checkpoint and resume that checkpoint; HumanApproval nodes and arbitrary external actions remain unsupported. Source witnesses are caller-supplied local captures: locators are never fetched, HMAC-authenticated witness integrity and bounded evidence spans are checked against SQLite, and source authority is not independently verified. Receipts provide integrity_only except a successfully resumed deterministic-local path, which reports deterministic_local_resume. Define graphs with graph_create, execute with graph_execute or graph_run_start, checkpoint with checkpoint:true, inspect with graph_run_get/wait/cancel/state/events/receipt/checkpoint, request or decide checkpoint approvals with graph_approval_request/decide, and resume with graph_run_resume."
2621)]
2622impl ServerHandler for AgentGraphServer {}