1use std::collections::HashMap;
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9static ID_COUNTER: AtomicU64 = AtomicU64::new(1);
10
11pub type State = HashMap<String, Value>;
12
13pub const MODEL_EVIDENCE_SCHEMA_VERSION: &str = "agentledger.model.evidence.v1";
14
15#[derive(Clone, Debug, PartialEq)]
16pub enum Value {
17 Null,
18 Bool(bool),
19 Number(f64),
20 String(String),
21 Object(State),
22 Array(Vec<Value>),
23}
24
25impl Default for Value {
26 fn default() -> Self {
27 Value::Null
28 }
29}
30
31impl From<&str> for Value {
32 fn from(value: &str) -> Self {
33 Value::String(value.to_string())
34 }
35}
36
37impl From<String> for Value {
38 fn from(value: String) -> Self {
39 Value::String(value)
40 }
41}
42
43impl From<bool> for Value {
44 fn from(value: bool) -> Self {
45 Value::Bool(value)
46 }
47}
48
49impl From<i64> for Value {
50 fn from(value: i64) -> Self {
51 Value::Number(value as f64)
52 }
53}
54
55impl From<f64> for Value {
56 fn from(value: f64) -> Self {
57 Value::Number(value)
58 }
59}
60
61#[derive(Clone, Debug)]
62pub struct Run {
63 pub run_id: String,
64 pub session_id: String,
65 pub status: String,
66 pub state: State,
67 pub state_version: u64,
68 pub created_at: f64,
69 pub updated_at: f64,
70}
71
72#[derive(Clone, Debug)]
73pub struct Step {
74 pub step_id: String,
75 pub run_id: String,
76 pub session_id: String,
77 pub status: String,
78 pub owner: Option<String>,
79 pub lease_token: Option<String>,
80 pub lease_until: Option<f64>,
81 pub attempt: u64,
82 pub state_version: u64,
83 pub checkpoint_id: Option<String>,
84 pub last_error_type: Option<String>,
85 pub last_error: Option<String>,
86 pub cancelled_at: Option<f64>,
87 pub created_at: f64,
88 pub updated_at: f64,
89}
90
91#[derive(Clone, Debug)]
92pub struct StepClaim {
93 pub run_id: String,
94 pub session_id: String,
95 pub step_id: String,
96 pub attempt: u64,
97 pub lease_token: String,
98 pub state_version: u64,
99 pub lease_until: f64,
100}
101
102#[derive(Clone, Debug)]
103pub struct Event {
104 pub event_id: String,
105 pub run_id: String,
106 pub session_id: Option<String>,
107 pub step_id: Option<String>,
108 pub seq: u64,
109 pub event_type: String,
110 pub timestamp: f64,
111 pub agent_role: Option<String>,
112 pub state_version: Option<u64>,
113 pub causal_token: Option<String>,
114 pub payload_hash: String,
115 pub payload_ref: String,
116 pub payload: State,
117}
118
119#[derive(Clone, Debug)]
120pub struct ToolLedgerEntry {
121 pub ledger_id: String,
122 pub run_id: String,
123 pub session_id: String,
124 pub step_id: String,
125 pub tool_name: String,
126 pub tool_version: String,
127 pub tool_call_id: String,
128 pub idempotency_key: String,
129 pub causal_token: String,
130 pub request_hash: String,
131 pub request_ref: String,
132 pub status: String,
133 pub external_id: Option<String>,
134 pub response_hash: Option<String>,
135 pub response_ref: Option<String>,
136 pub error_type: Option<String>,
137 pub response: Option<Value>,
138 pub created_at: f64,
139 pub updated_at: f64,
140}
141
142#[derive(Clone, Debug)]
143pub struct ApprovalRequest {
144 pub approval_id: String,
145 pub approval_key: String,
146 pub run_id: String,
147 pub session_id: String,
148 pub step_id: String,
149 pub tool_name: String,
150 pub risk_level: String,
151 pub status: String,
152 pub reason: String,
153 pub request_hash: String,
154 pub request_ref: String,
155 pub requested_by: String,
156 pub approved_by: Option<String>,
157 pub decision_reason: Option<String>,
158 pub created_at: f64,
159 pub updated_at: f64,
160}
161
162#[derive(Clone, Debug)]
163pub struct CostRecord {
164 pub cost_id: String,
165 pub run_id: String,
166 pub session_id: String,
167 pub step_id: String,
168 pub category: String,
169 pub name: String,
170 pub amount: f64,
171 pub unit: String,
172 pub metadata: State,
173 pub created_at: f64,
174}
175
176#[derive(Clone, Debug, Default, PartialEq)]
177pub struct CostSummary {
178 pub tool_calls: f64,
179 pub model_tokens: f64,
180 pub total_usd: f64,
181 pub by_category: HashMap<String, f64>,
182}
183
184pub const MEDIA_SCHEMA_VERSION: &str = "agentledger.media.v0";
185pub const STREAM_SCHEMA_VERSION: &str = "agentledger.stream.v0";
186
187#[derive(Clone, Debug)]
188pub struct LocalBlobStore {
189 root: PathBuf,
190}
191
192impl LocalBlobStore {
193 pub fn open(root: impl AsRef<Path>) -> Result<Self> {
194 fs::create_dir_all(root.as_ref()).map_err(|err| RuntimeError(err.to_string()))?;
195 Ok(Self {
196 root: root.as_ref().to_path_buf(),
197 })
198 }
199
200 pub fn put_json(&self, value: &Value) -> Result<(String, String)> {
201 let encoded = encode_value(value);
202 let digest = stable_hash(&encoded);
203 let dir = self.root.join("sha256");
204 fs::create_dir_all(&dir).map_err(|err| RuntimeError(err.to_string()))?;
205 let path = dir.join(format!("{digest}.json"));
206 if !path.exists() {
207 let tmp = path.with_extension("json.tmp");
208 fs::write(&tmp, encoded).map_err(|err| RuntimeError(err.to_string()))?;
209 fs::rename(&tmp, &path).map_err(|err| RuntimeError(err.to_string()))?;
210 }
211 Ok((
212 format!("sha256:{digest}"),
213 format!("blob://sha256/{digest}.json"),
214 ))
215 }
216
217 pub fn get_json(&self, reference: &str) -> Result<Value> {
218 let prefix = "blob://sha256/";
219 if !reference.starts_with(prefix) {
220 return Err(RuntimeError(format!("unsupported blob ref: {reference}")));
221 }
222 let name = &reference[prefix.len()..];
223 if !name.ends_with(".json")
224 || name.contains("..")
225 || name.contains('/')
226 || name.contains('\\')
227 {
228 return Err(RuntimeError(format!("unsupported blob ref: {reference}")));
229 }
230 let body = fs::read_to_string(self.root.join("sha256").join(name))
231 .map_err(|err| RuntimeError(err.to_string()))?;
232 decode_value(&body)
233 }
234}
235
236#[derive(Clone, Debug)]
237pub struct Artifact {
238 pub artifact_id: String,
239 pub run_id: String,
240 pub step_id: Option<String>,
241 pub name: String,
242 pub blob_hash: String,
243 pub blob_ref: String,
244 pub metadata: State,
245 pub created_at: f64,
246}
247
248#[derive(Clone, Debug, Default)]
249pub struct MediaArtifactOptions {
250 pub uri: Option<String>,
251 pub content_ref: Option<String>,
252 pub media_metadata: State,
253 pub lineage: State,
254 pub derived_outputs: State,
255 pub metadata: State,
256}
257
258#[derive(Clone, Debug, Default)]
259pub struct StreamChunkRef {
260 pub stream_id: String,
261 pub chunk_id: String,
262 pub offset: Value,
263 pub content_ref: Option<String>,
264 pub content_hash: Option<String>,
265 pub sequence: Option<f64>,
266 pub event_time: Option<f64>,
267 pub metadata: State,
268}
269
270#[derive(Clone, Debug, Default)]
271pub struct StreamCheckpointOptions {
272 pub stream_id: String,
273 pub consumer_id: String,
274 pub offset: Value,
275 pub watermark: Option<Value>,
276 pub chunk: Option<StreamChunkRef>,
277 pub partial_result_ref: Option<String>,
278 pub backpressure: State,
279 pub metadata: State,
280}
281
282#[derive(Clone, Debug, Default)]
283pub struct BudgetLimits {
284 pub max_tool_calls: Option<f64>,
285 pub max_model_tokens: Option<f64>,
286 pub max_total_usd: Option<f64>,
287}
288
289#[derive(Debug, Clone)]
290pub struct RuntimeError(pub String);
291
292impl fmt::Display for RuntimeError {
293 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294 write!(f, "{}", self.0)
295 }
296}
297
298impl std::error::Error for RuntimeError {}
299
300pub type Result<T> = std::result::Result<T, RuntimeError>;
301pub type ToolFunc = Box<dyn Fn(State) -> Result<Value> + Send + Sync>;
302pub type AgentFunc = fn(&mut AgentContext, State) -> Result<()>;
303
304pub struct SandboxPolicy {
305 pub tool_name: String,
306 pub run_id: String,
307 pub step_id: String,
308 pub executor: String,
309 pub network: String,
310 pub filesystem: String,
311 pub timeout_seconds: u64,
312 pub extra: State,
313}
314
315pub struct SandboxResult {
316 pub ok: bool,
317 pub output: Value,
318 pub error: Option<String>,
319 pub metadata: State,
320}
321
322pub trait SandboxExecutor {
323 fn run_tool(&self, args: State, policy: &SandboxPolicy) -> SandboxResult;
324}
325
326pub struct DisabledSandboxExecutor;
327
328impl SandboxExecutor for DisabledSandboxExecutor {
329 fn run_tool(&self, _args: State, policy: &SandboxPolicy) -> SandboxResult {
330 let mut metadata = State::new();
331 metadata.insert("executor".to_string(), Value::String(policy.executor.clone()));
332 metadata.insert("isolation_level".to_string(), Value::String("none".to_string()));
333 metadata.insert("fail_closed".to_string(), Value::Bool(true));
334 SandboxResult {
335 ok: false,
336 output: Value::Null,
337 error: Some(format!("sandbox executor \"{}\" is disabled", policy.executor)),
338 metadata,
339 }
340 }
341}
342
343pub struct ToolSpec {
344 pub name: String,
345 pub version: String,
346 pub side_effect: String,
347 pub risk_level: String,
348 pub idempotency_required: bool,
349 pub approval_required: bool,
350 pub sandbox_required: bool,
351 pub sandbox_executor: String,
352 pub sandbox_policy: State,
353 pub input_schema: Option<Value>,
354 pub output_schema: Option<Value>,
355 pub func: ToolFunc,
356}
357
358impl ToolSpec {
359 pub fn new(name: &str, func: ToolFunc) -> Self {
360 Self {
361 name: name.to_string(),
362 version: "v1".to_string(),
363 side_effect: "none".to_string(),
364 risk_level: "low".to_string(),
365 idempotency_required: false,
366 approval_required: false,
367 sandbox_required: false,
368 sandbox_executor: String::new(),
369 sandbox_policy: State::new(),
370 input_schema: None,
371 output_schema: None,
372 func,
373 }
374 }
375
376 pub fn side_effect(mut self, side_effect: &str) -> Self {
377 self.side_effect = side_effect.to_string();
378 self
379 }
380
381 pub fn risk_level(mut self, risk_level: &str) -> Self {
382 self.risk_level = risk_level.to_string();
383 self
384 }
385
386 pub fn idempotency_required(mut self, required: bool) -> Self {
387 self.idempotency_required = required;
388 self
389 }
390
391 pub fn approval_required(mut self, required: bool) -> Self {
392 self.approval_required = required;
393 self
394 }
395
396 pub fn sandbox_required(mut self, required: bool) -> Self {
397 self.sandbox_required = required;
398 self
399 }
400
401 pub fn sandbox_executor(mut self, executor: &str) -> Self {
402 self.sandbox_executor = executor.to_string();
403 self
404 }
405
406 pub fn sandbox_policy(mut self, policy: State) -> Self {
407 self.sandbox_policy = policy;
408 self
409 }
410
411 pub fn input_schema(mut self, schema: Value) -> Self {
412 self.input_schema = Some(schema);
413 self
414 }
415
416 pub fn output_schema(mut self, schema: Value) -> Self {
417 self.output_schema = Some(schema);
418 self
419 }
420}
421
422#[derive(Default)]
423pub struct MemoryStore {
424 runs: HashMap<String, Run>,
425 steps: HashMap<String, Step>,
426 events: HashMap<String, Vec<Event>>,
427 tool_ledger: HashMap<String, ToolLedgerEntry>,
428 approval_requests: HashMap<String, ApprovalRequest>,
429 cost_records: HashMap<String, Vec<CostRecord>>,
430 artifacts: HashMap<String, Vec<Artifact>>,
431}
432
433impl MemoryStore {
434 pub fn new() -> Self {
435 Self::default()
436 }
437
438 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
439 fs::write(path, encode_store(self)).map_err(|err| RuntimeError(err.to_string()))
440 }
441
442 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
443 let body = fs::read_to_string(path).map_err(|err| RuntimeError(err.to_string()))?;
444 decode_store(&body)
445 }
446
447 pub fn create_run(&mut self, initial_state: State) -> (String, String) {
448 let run_id = new_id("run");
449 let session_id = new_id("sess");
450 let step_id = new_id("step");
451 let now = now_seconds();
452 self.runs.insert(
453 run_id.clone(),
454 Run {
455 run_id: run_id.clone(),
456 session_id: session_id.clone(),
457 status: "pending".to_string(),
458 state: initial_state.clone(),
459 state_version: 0,
460 created_at: now,
461 updated_at: now,
462 },
463 );
464 self.steps.insert(
465 step_id.clone(),
466 Step {
467 step_id: step_id.clone(),
468 run_id: run_id.clone(),
469 session_id: session_id.clone(),
470 status: "pending".to_string(),
471 owner: None,
472 lease_token: None,
473 lease_until: None,
474 attempt: 0,
475 state_version: 0,
476 checkpoint_id: None,
477 last_error_type: None,
478 last_error: None,
479 cancelled_at: None,
480 created_at: now,
481 updated_at: now,
482 },
483 );
484 let mut payload = State::new();
485 payload.insert("initial_state".to_string(), Value::Object(initial_state));
486 self.append_event(
487 &run_id,
488 Some(&session_id),
489 None,
490 "run_created",
491 payload,
492 None,
493 None,
494 None,
495 );
496 let mut step_payload = State::new();
497 step_payload.insert("step_id".to_string(), Value::String(step_id.clone()));
498 self.append_event(
499 &run_id,
500 Some(&session_id),
501 Some(&step_id),
502 "step_created",
503 step_payload,
504 None,
505 None,
506 None,
507 );
508 (run_id, step_id)
509 }
510
511 pub fn claim_step(
512 &mut self,
513 worker_id: &str,
514 run_id: &str,
515 lease_seconds: f64,
516 ) -> Result<StepClaim> {
517 let mut candidates: Vec<Step> = self
518 .steps
519 .values()
520 .filter(|step| {
521 (run_id.is_empty() || step.run_id == run_id)
522 && (step.status == "pending" || step.status == "retry_scheduled")
523 })
524 .cloned()
525 .collect();
526 candidates.sort_by(|a, b| {
527 a.created_at
528 .partial_cmp(&b.created_at)
529 .unwrap()
530 .then(a.step_id.cmp(&b.step_id))
531 });
532 let candidate = candidates
533 .first()
534 .ok_or_else(|| RuntimeError("agentledger: no runnable step".to_string()))?;
535 let now = now_seconds();
536 let step = self.steps.get_mut(&candidate.step_id).unwrap();
537 step.status = "running".to_string();
538 step.owner = Some(worker_id.to_string());
539 step.lease_token = Some(new_id("lease"));
540 step.lease_until = Some(now + lease_seconds);
541 step.attempt += 1;
542 step.updated_at = now;
543 let claim = StepClaim {
544 run_id: step.run_id.clone(),
545 session_id: step.session_id.clone(),
546 step_id: step.step_id.clone(),
547 attempt: step.attempt,
548 lease_token: step.lease_token.clone().unwrap(),
549 state_version: step.state_version,
550 lease_until: step.lease_until.unwrap(),
551 };
552 let run = self.runs.get_mut(&claim.run_id).unwrap();
553 run.status = "running".to_string();
554 run.updated_at = now;
555 let mut payload = State::new();
556 payload.insert(
557 "worker_id".to_string(),
558 Value::String(worker_id.to_string()),
559 );
560 payload.insert(
561 "lease_token".to_string(),
562 Value::String(claim.lease_token.clone()),
563 );
564 payload.insert("attempt".to_string(), Value::Number(claim.attempt as f64));
565 self.append_event(
566 &claim.run_id,
567 Some(&claim.session_id),
568 Some(&claim.step_id),
569 "step_claimed",
570 payload,
571 None,
572 None,
573 None,
574 );
575 Ok(claim)
576 }
577
578 pub fn load_state(&self, run_id: &str) -> Result<(State, u64, String)> {
579 let run = self
580 .runs
581 .get(run_id)
582 .ok_or_else(|| RuntimeError(format!("run not found: {run_id}")))?;
583 Ok((run.state.clone(), run.state_version, run.session_id.clone()))
584 }
585
586 pub fn recover_expired_leases(&mut self) -> usize {
587 let now = now_seconds();
588 let ids: Vec<String> = self.steps.keys().cloned().collect();
589 let mut recovered = 0;
590 for step_id in ids {
591 let should_recover = self
592 .steps
593 .get(&step_id)
594 .map(|step| {
595 step.status == "running" && step.lease_until.is_some_and(|until| until <= now)
596 })
597 .unwrap_or(false);
598 if !should_recover {
599 continue;
600 }
601 let (run_id, session_id, previous_owner, attempt) = {
602 let step = self.steps.get_mut(&step_id).unwrap();
603 let previous_owner = step.owner.clone().unwrap_or_default();
604 step.status = "retry_scheduled".to_string();
605 step.owner = None;
606 step.lease_token = None;
607 step.lease_until = None;
608 step.updated_at = now;
609 (
610 step.run_id.clone(),
611 step.session_id.clone(),
612 previous_owner,
613 step.attempt,
614 )
615 };
616 let run = self.runs.get_mut(&run_id).unwrap();
617 run.status = "retry_scheduled".to_string();
618 run.updated_at = now;
619 recovered += 1;
620 let mut payload = State::new();
621 payload.insert("previous_owner".to_string(), Value::String(previous_owner));
622 payload.insert("attempt".to_string(), Value::Number(attempt as f64));
623 self.append_event(
624 &run_id,
625 Some(&session_id),
626 Some(&step_id),
627 "lease_expired",
628 payload,
629 None,
630 None,
631 None,
632 );
633 let mut retry_payload = State::new();
634 retry_payload.insert("step_id".to_string(), Value::String(step_id.clone()));
635 retry_payload.insert(
636 "reason".to_string(),
637 Value::String("lease_expired".to_string()),
638 );
639 self.append_event(
640 &run_id,
641 Some(&session_id),
642 Some(&step_id),
643 "step_retry_scheduled",
644 retry_payload,
645 None,
646 None,
647 None,
648 );
649 }
650 recovered
651 }
652
653 pub fn cancel_run(&mut self, run_id: &str, reason: &str) -> Result<usize> {
654 let session_id = self
655 .runs
656 .get(run_id)
657 .ok_or_else(|| RuntimeError(format!("run not found: {run_id}")))?
658 .session_id
659 .clone();
660 let mut payload = State::new();
661 payload.insert("reason".to_string(), Value::String(reason.to_string()));
662 self.append_event(
663 run_id,
664 Some(&session_id),
665 None,
666 "run_cancel_requested",
667 payload,
668 None,
669 None,
670 None,
671 );
672 let ids: Vec<String> = self.steps.keys().cloned().collect();
673 let now = now_seconds();
674 let mut cancelled = 0;
675 for step_id in ids {
676 let should_cancel = self
677 .steps
678 .get(&step_id)
679 .map(|step| {
680 step.run_id == run_id
681 && !["completed", "failed", "cancelled"].contains(&step.status.as_str())
682 })
683 .unwrap_or(false);
684 if !should_cancel {
685 continue;
686 }
687 let step_session = {
688 let step = self.steps.get_mut(&step_id).unwrap();
689 step.status = "cancelled".to_string();
690 step.owner = None;
691 step.lease_token = None;
692 step.lease_until = None;
693 step.cancelled_at = Some(now);
694 step.updated_at = now;
695 step.session_id.clone()
696 };
697 cancelled += 1;
698 let mut step_payload = State::new();
699 step_payload.insert("reason".to_string(), Value::String(reason.to_string()));
700 self.append_event(
701 run_id,
702 Some(&step_session),
703 Some(&step_id),
704 "step_cancelled",
705 step_payload,
706 None,
707 None,
708 None,
709 );
710 }
711 let run = self.runs.get_mut(run_id).unwrap();
712 run.status = "cancelled".to_string();
713 run.updated_at = now;
714 let mut done_payload = State::new();
715 done_payload.insert("reason".to_string(), Value::String(reason.to_string()));
716 done_payload.insert(
717 "cancelled_steps".to_string(),
718 Value::Number(cancelled as f64),
719 );
720 self.append_event(
721 run_id,
722 Some(&session_id),
723 None,
724 "run_cancelled",
725 done_payload,
726 None,
727 None,
728 None,
729 );
730 Ok(cancelled)
731 }
732
733 pub fn commit_state_patch(
734 &mut self,
735 run_id: &str,
736 step_id: &str,
737 lease_token: &str,
738 base_version: u64,
739 patch: State,
740 ) -> Result<u64> {
741 self.validate_lease(step_id, lease_token)?;
742 let run = self
743 .runs
744 .get(run_id)
745 .ok_or_else(|| RuntimeError(format!("run not found: {run_id}")))?;
746 if run.state_version != base_version {
747 return Err(RuntimeError(format!(
748 "state version conflict: expected {base_version}, got {}",
749 run.state_version
750 )));
751 }
752 let now = now_seconds();
753 let new_version = base_version + 1;
754 let session_id = self.steps.get(step_id).unwrap().session_id.clone();
755 let run = self.runs.get_mut(run_id).unwrap();
756 run.state = merge_patch(&run.state, &patch);
757 run.state_version = new_version;
758 run.status = "completed".to_string();
759 run.updated_at = now;
760 let step = self.steps.get_mut(step_id).unwrap();
761 step.status = "completed".to_string();
762 step.state_version = new_version;
763 step.checkpoint_id = Some(format!("ckpt:{run_id}:{step_id}:{}", step.attempt));
764 step.updated_at = now;
765 let mut payload = State::new();
766 payload.insert("patch".to_string(), Value::Object(patch));
767 payload.insert(
768 "state_version".to_string(),
769 Value::Number(new_version as f64),
770 );
771 self.append_event(
772 run_id,
773 Some(&session_id),
774 Some(step_id),
775 "state_patch_committed",
776 payload,
777 None,
778 Some(new_version),
779 None,
780 );
781 let mut complete_payload = State::new();
782 complete_payload.insert("step_id".to_string(), Value::String(step_id.to_string()));
783 self.append_event(
784 run_id,
785 Some(&session_id),
786 Some(step_id),
787 "step_completed",
788 complete_payload,
789 None,
790 Some(new_version),
791 None,
792 );
793 Ok(new_version)
794 }
795
796 pub fn mark_retry(&mut self, run_id: &str, step_id: &str, error_type: &str, message: &str) {
797 let now = now_seconds();
798 let session_id = {
799 let step = self.steps.get_mut(step_id).unwrap();
800 step.status = "retry_scheduled".to_string();
801 step.owner = None;
802 step.lease_token = None;
803 step.lease_until = None;
804 step.last_error_type = Some(error_type.to_string());
805 step.last_error = Some(message.to_string());
806 step.updated_at = now;
807 step.session_id.clone()
808 };
809 let run = self.runs.get_mut(run_id).unwrap();
810 run.status = "retry_scheduled".to_string();
811 run.updated_at = now;
812 let mut classified = State::new();
813 classified.insert("error".to_string(), Value::String(message.to_string()));
814 classified.insert(
815 "error_type".to_string(),
816 Value::String(error_type.to_string()),
817 );
818 classified.insert("retryable".to_string(), Value::Bool(true));
819 classified.insert("source".to_string(), Value::String("agent".to_string()));
820 self.append_event(
821 run_id,
822 Some(&session_id),
823 Some(step_id),
824 "failure_classified",
825 classified,
826 None,
827 None,
828 None,
829 );
830 let mut payload = State::new();
831 payload.insert("error".to_string(), Value::String(message.to_string()));
832 payload.insert(
833 "error_type".to_string(),
834 Value::String(error_type.to_string()),
835 );
836 self.append_event(
837 run_id,
838 Some(&session_id),
839 Some(step_id),
840 "error_raised",
841 payload,
842 None,
843 None,
844 None,
845 );
846 let mut retry_payload = State::new();
847 retry_payload.insert("step_id".to_string(), Value::String(step_id.to_string()));
848 self.append_event(
849 run_id,
850 Some(&session_id),
851 Some(step_id),
852 "step_retry_scheduled",
853 retry_payload,
854 None,
855 None,
856 None,
857 );
858 }
859
860 pub fn mark_waiting_human(
861 &mut self,
862 run_id: &str,
863 step_id: &str,
864 reason: &str,
865 approval_id: &str,
866 ) {
867 let now = now_seconds();
868 let session_id = {
869 let step = self.steps.get_mut(step_id).unwrap();
870 step.status = "waiting_human".to_string();
871 step.owner = None;
872 step.lease_token = None;
873 step.lease_until = None;
874 step.last_error_type = Some("ApprovalRequired".to_string());
875 step.last_error = Some(reason.to_string());
876 step.updated_at = now;
877 step.session_id.clone()
878 };
879 let run = self.runs.get_mut(run_id).unwrap();
880 run.status = "waiting_human".to_string();
881 run.updated_at = now;
882 let mut payload = State::new();
883 payload.insert("reason".to_string(), Value::String(reason.to_string()));
884 payload.insert(
885 "approval_id".to_string(),
886 Value::String(approval_id.to_string()),
887 );
888 self.append_event(
889 run_id,
890 Some(&session_id),
891 Some(step_id),
892 "step_waiting_human",
893 payload,
894 None,
895 None,
896 None,
897 );
898 }
899
900 pub fn mark_failed(&mut self, run_id: &str, step_id: &str, error_type: &str, message: &str) {
901 let now = now_seconds();
902 let session_id = {
903 let step = self.steps.get_mut(step_id).unwrap();
904 step.status = "failed".to_string();
905 step.owner = None;
906 step.lease_token = None;
907 step.lease_until = None;
908 step.last_error_type = Some(error_type.to_string());
909 step.last_error = Some(message.to_string());
910 step.updated_at = now;
911 step.session_id.clone()
912 };
913 let run = self.runs.get_mut(run_id).unwrap();
914 run.status = "failed".to_string();
915 run.updated_at = now;
916 let mut classified = State::new();
917 classified.insert("error".to_string(), Value::String(message.to_string()));
918 classified.insert(
919 "error_type".to_string(),
920 Value::String(error_type.to_string()),
921 );
922 classified.insert("retryable".to_string(), Value::Bool(false));
923 classified.insert(
924 "source".to_string(),
925 Value::String(failure_source(error_type).to_string()),
926 );
927 self.append_event(
928 run_id,
929 Some(&session_id),
930 Some(step_id),
931 "failure_classified",
932 classified,
933 None,
934 None,
935 None,
936 );
937 let mut error = State::new();
938 error.insert("error".to_string(), Value::String(message.to_string()));
939 error.insert(
940 "error_type".to_string(),
941 Value::String(error_type.to_string()),
942 );
943 self.append_event(
944 run_id,
945 Some(&session_id),
946 Some(step_id),
947 "error_raised",
948 error,
949 None,
950 None,
951 None,
952 );
953 let mut payload = State::new();
954 payload.insert("step_id".to_string(), Value::String(step_id.to_string()));
955 payload.insert(
956 "error_type".to_string(),
957 Value::String(error_type.to_string()),
958 );
959 self.append_event(
960 run_id,
961 Some(&session_id),
962 Some(step_id),
963 "step_failed",
964 payload,
965 None,
966 None,
967 None,
968 );
969 }
970
971 pub fn append_event(
972 &mut self,
973 run_id: &str,
974 session_id: Option<&str>,
975 step_id: Option<&str>,
976 event_type: &str,
977 payload: State,
978 agent_role: Option<&str>,
979 state_version: Option<u64>,
980 causal_token: Option<&str>,
981 ) -> Event {
982 let events = self.events.entry(run_id.to_string()).or_default();
983 let payload_ref = format_state(&payload);
984 let event = Event {
985 event_id: new_id("evt"),
986 run_id: run_id.to_string(),
987 session_id: session_id.map(str::to_string),
988 step_id: step_id.map(str::to_string),
989 seq: events.len() as u64 + 1,
990 event_type: event_type.to_string(),
991 timestamp: now_seconds(),
992 agent_role: agent_role.map(str::to_string),
993 state_version,
994 causal_token: causal_token.map(str::to_string),
995 payload_hash: stable_hash(&payload_ref),
996 payload_ref,
997 payload,
998 };
999 events.push(event.clone());
1000 event
1001 }
1002
1003 pub fn reserve_ledger(&mut self, key: &str, entry: ToolLedgerEntry) -> Option<ToolLedgerEntry> {
1004 if let Some(existing) = self.tool_ledger.get(key) {
1005 return Some(existing.clone());
1006 }
1007 self.tool_ledger.insert(key.to_string(), entry);
1008 None
1009 }
1010
1011 pub fn update_ledger(
1012 &mut self,
1013 key: &str,
1014 status: &str,
1015 response: Option<Value>,
1016 error_type: Option<String>,
1017 ) {
1018 if let Some(entry) = self.tool_ledger.get_mut(key) {
1019 entry.status = status.to_string();
1020 entry.response = response.clone();
1021 entry.response_ref = response.as_ref().map(format_value);
1022 entry.response_hash = entry.response_ref.as_ref().map(|value| stable_hash(value));
1023 entry.error_type = error_type;
1024 entry.updated_at = now_seconds();
1025 }
1026 }
1027
1028 pub fn request_approval(
1029 &mut self,
1030 approval_key: &str,
1031 run_id: &str,
1032 session_id: &str,
1033 step_id: &str,
1034 tool_name: &str,
1035 risk_level: &str,
1036 reason: &str,
1037 request_hash: &str,
1038 request_ref: &str,
1039 requested_by: &str,
1040 ) -> ApprovalRequest {
1041 if let Some(existing) = self.approval_requests.get(approval_key) {
1042 return existing.clone();
1043 }
1044 let now = now_seconds();
1045 let approval = ApprovalRequest {
1046 approval_id: new_id("approval"),
1047 approval_key: approval_key.to_string(),
1048 run_id: run_id.to_string(),
1049 session_id: session_id.to_string(),
1050 step_id: step_id.to_string(),
1051 tool_name: tool_name.to_string(),
1052 risk_level: risk_level.to_string(),
1053 status: "PENDING".to_string(),
1054 reason: reason.to_string(),
1055 request_hash: request_hash.to_string(),
1056 request_ref: request_ref.to_string(),
1057 requested_by: requested_by.to_string(),
1058 approved_by: None,
1059 decision_reason: None,
1060 created_at: now,
1061 updated_at: now,
1062 };
1063 self.approval_requests
1064 .insert(approval_key.to_string(), approval.clone());
1065 approval
1066 }
1067
1068 pub fn approval_for_key(&self, approval_key: &str) -> Option<ApprovalRequest> {
1069 self.approval_requests.get(approval_key).cloned()
1070 }
1071
1072 pub fn approval_requests(&self, run_id: &str) -> Vec<ApprovalRequest> {
1073 self.approval_requests
1074 .values()
1075 .filter(|item| run_id.is_empty() || item.run_id == run_id)
1076 .cloned()
1077 .collect()
1078 }
1079
1080 pub fn approve_request(
1081 &mut self,
1082 approval_id: &str,
1083 approver: &str,
1084 reason: &str,
1085 ) -> Result<ApprovalRequest> {
1086 self.decide_approval(approval_id, "APPROVED", approver, reason)
1087 }
1088
1089 pub fn deny_request(
1090 &mut self,
1091 approval_id: &str,
1092 approver: &str,
1093 reason: &str,
1094 ) -> Result<ApprovalRequest> {
1095 self.decide_approval(approval_id, "DENIED", approver, reason)
1096 }
1097
1098 fn decide_approval(
1099 &mut self,
1100 approval_id: &str,
1101 status: &str,
1102 approver: &str,
1103 reason: &str,
1104 ) -> Result<ApprovalRequest> {
1105 let key = self
1106 .approval_requests
1107 .iter()
1108 .find(|(_, item)| item.approval_id == approval_id)
1109 .map(|(key, _)| key.clone())
1110 .ok_or_else(|| RuntimeError(format!("approval not found: {approval_id}")))?;
1111 let mut approval = self.approval_requests.get(&key).unwrap().clone();
1112 approval.status = status.to_string();
1113 approval.approved_by = Some(approver.to_string());
1114 approval.decision_reason = Some(reason.to_string());
1115 approval.updated_at = now_seconds();
1116 self.approval_requests.insert(key, approval.clone());
1117 let mut payload = State::new();
1118 payload.insert(
1119 "approval_id".to_string(),
1120 Value::String(approval_id.to_string()),
1121 );
1122 payload.insert(
1123 "tool".to_string(),
1124 Value::String(approval.tool_name.clone()),
1125 );
1126 payload.insert("status".to_string(), Value::String(status.to_string()));
1127 self.append_event(
1128 &approval.run_id,
1129 Some(&approval.session_id),
1130 Some(&approval.step_id),
1131 "tool_approval_decided",
1132 payload,
1133 None,
1134 None,
1135 None,
1136 );
1137 if self
1138 .steps
1139 .get(&approval.step_id)
1140 .map(|step| step.status.as_str())
1141 == Some("waiting_human")
1142 {
1143 if status == "APPROVED" {
1144 let step = self.steps.get_mut(&approval.step_id).unwrap();
1145 step.status = "pending".to_string();
1146 step.owner = None;
1147 step.lease_token = None;
1148 step.lease_until = None;
1149 step.updated_at = now_seconds();
1150 let run = self.runs.get_mut(&approval.run_id).unwrap();
1151 run.status = "pending".to_string();
1152 let mut retry = State::new();
1153 retry.insert(
1154 "step_id".to_string(),
1155 Value::String(approval.step_id.clone()),
1156 );
1157 retry.insert(
1158 "reason".to_string(),
1159 Value::String("approval_granted".to_string()),
1160 );
1161 self.append_event(
1162 &approval.run_id,
1163 Some(&approval.session_id),
1164 Some(&approval.step_id),
1165 "step_retry_scheduled",
1166 retry,
1167 None,
1168 None,
1169 None,
1170 );
1171 } else if status == "DENIED" {
1172 self.mark_failed(
1173 &approval.run_id,
1174 &approval.step_id,
1175 "ApprovalDenied",
1176 reason,
1177 );
1178 }
1179 }
1180 Ok(approval)
1181 }
1182
1183 pub fn record_cost(
1184 &mut self,
1185 run_id: &str,
1186 session_id: &str,
1187 step_id: &str,
1188 category: &str,
1189 name: &str,
1190 amount: f64,
1191 unit: &str,
1192 metadata: State,
1193 ) -> String {
1194 let cost_id = new_id("cost");
1195 let record = CostRecord {
1196 cost_id: cost_id.clone(),
1197 run_id: run_id.to_string(),
1198 session_id: session_id.to_string(),
1199 step_id: step_id.to_string(),
1200 category: category.to_string(),
1201 name: name.to_string(),
1202 amount,
1203 unit: unit.to_string(),
1204 metadata,
1205 created_at: now_seconds(),
1206 };
1207 self.cost_records
1208 .entry(run_id.to_string())
1209 .or_default()
1210 .push(record);
1211 let mut payload = State::new();
1212 payload.insert("cost_id".to_string(), Value::String(cost_id.clone()));
1213 payload.insert("category".to_string(), Value::String(category.to_string()));
1214 payload.insert("name".to_string(), Value::String(name.to_string()));
1215 payload.insert("amount".to_string(), Value::Number(amount));
1216 payload.insert("unit".to_string(), Value::String(unit.to_string()));
1217 self.append_event(
1218 run_id,
1219 Some(session_id),
1220 Some(step_id),
1221 "cost_recorded",
1222 payload,
1223 None,
1224 None,
1225 None,
1226 );
1227 cost_id
1228 }
1229
1230 pub fn cost_records(&self, run_id: &str) -> Vec<CostRecord> {
1231 self.cost_records.get(run_id).cloned().unwrap_or_default()
1232 }
1233
1234 pub fn cost_summary(&self, run_id: &str) -> CostSummary {
1235 let mut summary = CostSummary::default();
1236 for record in self.cost_records(run_id) {
1237 add_cost(&mut summary, &record);
1238 }
1239 summary
1240 }
1241
1242 pub fn create_artifact(
1243 &mut self,
1244 run_id: &str,
1245 step_id: Option<&str>,
1246 name: &str,
1247 content: State,
1248 metadata: State,
1249 ) -> Artifact {
1250 let blob_ref = format_state(&content);
1251 let artifact = Artifact {
1252 artifact_id: new_id("art"),
1253 run_id: run_id.to_string(),
1254 step_id: step_id.map(str::to_string),
1255 name: name.to_string(),
1256 blob_hash: format!("sha256:{}", stable_hash(&blob_ref)),
1257 blob_ref,
1258 metadata,
1259 created_at: now_seconds(),
1260 };
1261 self.artifacts
1262 .entry(run_id.to_string())
1263 .or_default()
1264 .push(artifact.clone());
1265 artifact
1266 }
1267
1268 pub fn artifacts(&self, run_id: &str) -> Vec<Artifact> {
1269 let mut items = self.artifacts.get(run_id).cloned().unwrap_or_default();
1270 items.sort_by(|a, b| {
1271 a.created_at
1272 .partial_cmp(&b.created_at)
1273 .unwrap()
1274 .then(a.artifact_id.cmp(&b.artifact_id))
1275 });
1276 items
1277 }
1278
1279 pub fn validate_lease(&self, step_id: &str, lease_token: &str) -> Result<()> {
1280 let step = self
1281 .steps
1282 .get(step_id)
1283 .ok_or_else(|| RuntimeError(format!("step not found: {step_id}")))?;
1284 if step.status != "running" || step.lease_token.as_deref() != Some(lease_token) {
1285 return Err(RuntimeError("invalid or stale lease token".to_string()));
1286 }
1287 if step.lease_until.is_some_and(|until| until <= now_seconds()) {
1288 return Err(RuntimeError("lease expired".to_string()));
1289 }
1290 Ok(())
1291 }
1292
1293 pub fn final_state(&self, run_id: &str) -> Result<State> {
1294 Ok(self.load_state(run_id)?.0)
1295 }
1296
1297 pub fn run(&self, run_id: &str) -> Result<Run> {
1298 self.runs
1299 .get(run_id)
1300 .cloned()
1301 .ok_or_else(|| RuntimeError(format!("run not found: {run_id}")))
1302 }
1303
1304 pub fn steps(&self, run_id: &str) -> Vec<Step> {
1305 self.steps
1306 .values()
1307 .filter(|step| step.run_id == run_id)
1308 .cloned()
1309 .collect()
1310 }
1311
1312 pub fn events(&self, run_id: &str) -> Vec<Event> {
1313 self.events.get(run_id).cloned().unwrap_or_default()
1314 }
1315
1316 pub fn ledger(&self, run_id: &str) -> Vec<ToolLedgerEntry> {
1317 self.tool_ledger
1318 .values()
1319 .filter(|entry| entry.run_id == run_id)
1320 .cloned()
1321 .collect()
1322 }
1323}
1324
1325pub struct ToolRegistry {
1326 tools: HashMap<String, ToolSpec>,
1327}
1328
1329impl ToolRegistry {
1330 pub fn new() -> Self {
1331 Self {
1332 tools: HashMap::new(),
1333 }
1334 }
1335
1336 pub fn register(&mut self, spec: ToolSpec) {
1337 self.tools.insert(spec.name.clone(), spec);
1338 }
1339
1340 pub fn get(&self, name: &str) -> Result<&ToolSpec> {
1341 self.tools
1342 .get(name)
1343 .ok_or_else(|| RuntimeError(format!("tool not registered: {name}")))
1344 }
1345}
1346
1347impl Default for ToolRegistry {
1348 fn default() -> Self {
1349 Self::new()
1350 }
1351}
1352
1353pub struct Runtime {
1354 pub store: MemoryStore,
1355 pub registry: ToolRegistry,
1356 pub budget: BudgetLimits,
1357 pub sandbox: Box<dyn SandboxExecutor>,
1358}
1359
1360impl Runtime {
1361 pub fn new() -> Self {
1362 Self {
1363 store: MemoryStore::new(),
1364 registry: ToolRegistry::new(),
1365 budget: BudgetLimits::default(),
1366 sandbox: Box::new(DisabledSandboxExecutor),
1367 }
1368 }
1369
1370 pub fn register_tool(&mut self, spec: ToolSpec) {
1371 self.registry.register(spec);
1372 }
1373
1374 pub fn set_budget(&mut self, budget: BudgetLimits) {
1375 self.budget = budget;
1376 }
1377
1378 pub fn set_sandbox(&mut self, sandbox: Box<dyn SandboxExecutor>) {
1379 self.sandbox = sandbox;
1380 }
1381
1382 pub fn create_run(&mut self, initial_state: State) -> (String, String) {
1383 self.store.create_run(initial_state)
1384 }
1385
1386 pub fn run_once(
1387 &mut self,
1388 run_id: &str,
1389 worker_id: &str,
1390 agent_role: &str,
1391 lease_seconds: f64,
1392 agent: AgentFunc,
1393 ) -> Result<bool> {
1394 let claim = match self.store.claim_step(worker_id, run_id, lease_seconds) {
1395 Ok(claim) => claim,
1396 Err(err) if err.0.contains("no runnable step") => return Ok(false),
1397 Err(err) => return Err(err),
1398 };
1399 let (state, version, session_id) = self.store.load_state(&claim.run_id)?;
1400 let mut payload = State::new();
1401 payload.insert(
1402 "agent_role".to_string(),
1403 Value::String(agent_role.to_string()),
1404 );
1405 payload.insert("attempt".to_string(), Value::Number(claim.attempt as f64));
1406 self.store.append_event(
1407 &claim.run_id,
1408 Some(&session_id),
1409 Some(&claim.step_id),
1410 "agent_started",
1411 payload,
1412 Some(agent_role),
1413 Some(version),
1414 None,
1415 );
1416 let mut ctx = AgentContext {
1417 run_id: claim.run_id.clone(),
1418 session_id,
1419 step_id: claim.step_id.clone(),
1420 agent_role: agent_role.to_string(),
1421 lease_token: claim.lease_token.clone(),
1422 attempt: claim.attempt,
1423 state_version: version,
1424 pending_patch: State::new(),
1425 };
1426 match agent(&mut ctx, state) {
1427 Ok(()) => {
1428 self.store.commit_state_patch(
1429 &claim.run_id,
1430 &claim.step_id,
1431 &claim.lease_token,
1432 version,
1433 ctx.pending_patch,
1434 )?;
1435 Ok(true)
1436 }
1437 Err(err) if err.0 == "retryable" => {
1438 self.store.mark_retry(
1439 &claim.run_id,
1440 &claim.step_id,
1441 "RetryableAgentError",
1442 "retryable",
1443 );
1444 Ok(false)
1445 }
1446 Err(err) if err.0.starts_with("approval required:") => {
1447 let approval_id = err.0.trim_start_matches("approval required:");
1448 self.store
1449 .mark_waiting_human(&claim.run_id, &claim.step_id, &err.0, approval_id);
1450 Ok(false)
1451 }
1452 Err(err) => {
1453 self.store.mark_failed(
1454 &claim.run_id,
1455 &claim.step_id,
1456 classify_runtime_error(&err.0),
1457 &err.0,
1458 );
1459 Err(err)
1460 }
1461 }
1462 }
1463
1464 pub fn call_tool(&mut self, ctx: &AgentContext, tool_name: &str, args: State) -> Result<Value> {
1465 let (
1466 version,
1467 side_effect,
1468 risk_level,
1469 idempotency_required,
1470 approval_required,
1471 sandbox_required,
1472 sandbox_executor,
1473 sandbox_policy,
1474 input_schema,
1475 output_schema,
1476 ) = {
1477 let spec = self.registry.get(tool_name)?;
1478 (
1479 spec.version.clone(),
1480 spec.side_effect.clone(),
1481 spec.risk_level.clone(),
1482 spec.idempotency_required,
1483 spec.approval_required,
1484 spec.sandbox_required,
1485 spec.sandbox_executor.clone(),
1486 spec.sandbox_policy.clone(),
1487 spec.input_schema.clone(),
1488 spec.output_schema.clone(),
1489 )
1490 };
1491 let mut request = State::new();
1492 request.insert("tool".to_string(), Value::String(tool_name.to_string()));
1493 request.insert("args".to_string(), Value::Object(args.clone()));
1494 let request_ref = format_state(&request);
1495 let request_hash = stable_hash(&request_ref);
1496 let causal_token = format!(
1497 "{}:{}:{}:{}",
1498 ctx.run_id, ctx.step_id, ctx.attempt, ctx.state_version
1499 );
1500 let idempotency_key = format!(
1501 "{}:{}:{}:{}",
1502 ctx.run_id, ctx.step_id, tool_name, request_hash
1503 );
1504 let managed = side_effect != "none" || idempotency_required;
1505 self.store.append_event(
1506 &ctx.run_id,
1507 Some(&ctx.session_id),
1508 Some(&ctx.step_id),
1509 "tool_call_requested",
1510 request,
1511 Some(&ctx.agent_role),
1512 Some(ctx.state_version),
1513 Some(&causal_token),
1514 );
1515 if let Some(schema) = input_schema.as_ref() {
1516 if let Err(err) = validate_tool_schema(schema, &Value::Object(args.clone()), "$arg") {
1517 let mut failed = State::new();
1518 failed.insert("tool".to_string(), Value::String(tool_name.to_string()));
1519 failed.insert("error".to_string(), Value::String(err.0.clone()));
1520 failed.insert(
1521 "phase".to_string(),
1522 Value::String("input_validation".to_string()),
1523 );
1524 self.store.append_event(
1525 &ctx.run_id,
1526 Some(&ctx.session_id),
1527 Some(&ctx.step_id),
1528 "tool_call_failed",
1529 failed,
1530 Some(&ctx.agent_role),
1531 Some(ctx.state_version),
1532 Some(&causal_token),
1533 );
1534 return Err(err);
1535 }
1536 }
1537
1538 let mut allowed = !is_high_risk(&risk_level);
1539 let mut reason = if allowed {
1540 "default allow for low/medium risk in local runtime".to_string()
1541 } else {
1542 "high-risk tool denied by default".to_string()
1543 };
1544 if let Some(approval) = self.store.approval_for_key(&idempotency_key) {
1545 if approval.status == "DENIED" {
1546 reason = format!("approval denied for tool {tool_name}");
1547 self.record_permission(ctx, tool_name, false, &reason, &causal_token);
1548 return Err(RuntimeError(reason));
1549 }
1550 if approval.status == "APPROVED" {
1551 allowed = true;
1552 reason = format!(
1553 "approved by {}",
1554 approval
1555 .approved_by
1556 .unwrap_or_else(|| "operator".to_string())
1557 );
1558 }
1559 } else if approval_required {
1560 let approval = self.store.request_approval(
1561 &idempotency_key,
1562 &ctx.run_id,
1563 &ctx.session_id,
1564 &ctx.step_id,
1565 tool_name,
1566 &risk_level,
1567 "tool requires approval",
1568 &request_hash,
1569 &request_ref,
1570 &ctx.agent_role,
1571 );
1572 self.record_permission(ctx, tool_name, false, "approval required", &causal_token);
1573 let mut payload = State::new();
1574 payload.insert("tool".to_string(), Value::String(tool_name.to_string()));
1575 payload.insert(
1576 "approval_id".to_string(),
1577 Value::String(approval.approval_id.clone()),
1578 );
1579 payload.insert(
1580 "approval_key".to_string(),
1581 Value::String(idempotency_key.clone()),
1582 );
1583 payload.insert("risk_level".to_string(), Value::String(risk_level));
1584 self.store.append_event(
1585 &ctx.run_id,
1586 Some(&ctx.session_id),
1587 Some(&ctx.step_id),
1588 "tool_approval_required",
1589 payload,
1590 Some(&ctx.agent_role),
1591 Some(ctx.state_version),
1592 Some(&causal_token),
1593 );
1594 return Err(RuntimeError(format!(
1595 "approval required:{}",
1596 approval.approval_id
1597 )));
1598 }
1599 self.record_permission(ctx, tool_name, allowed, &reason, &causal_token);
1600 if !allowed {
1601 return Err(RuntimeError(reason));
1602 }
1603 if let Some(max) = self.budget.max_tool_calls {
1604 let used = self.store.cost_summary(&ctx.run_id).tool_calls;
1605 if used >= max {
1606 let message = format!("tool call budget exceeded: {used}/{max}");
1607 let mut payload = State::new();
1608 payload.insert("category".to_string(), Value::String("tool".to_string()));
1609 payload.insert("tool".to_string(), Value::String(tool_name.to_string()));
1610 payload.insert("error".to_string(), Value::String(message.clone()));
1611 self.store.append_event(
1612 &ctx.run_id,
1613 Some(&ctx.session_id),
1614 Some(&ctx.step_id),
1615 "budget_check_failed",
1616 payload,
1617 Some(&ctx.agent_role),
1618 Some(ctx.state_version),
1619 Some(&causal_token),
1620 );
1621 return Err(RuntimeError(message));
1622 }
1623 }
1624 if managed {
1625 let entry = ToolLedgerEntry {
1626 ledger_id: new_id("ledger"),
1627 run_id: ctx.run_id.clone(),
1628 session_id: ctx.session_id.clone(),
1629 step_id: ctx.step_id.clone(),
1630 tool_name: tool_name.to_string(),
1631 tool_version: version,
1632 tool_call_id: new_id("toolcall"),
1633 idempotency_key: idempotency_key.clone(),
1634 causal_token: causal_token.clone(),
1635 request_hash: request_hash.clone(),
1636 request_ref: request_ref.clone(),
1637 status: "RESERVED".to_string(),
1638 external_id: None,
1639 response_hash: None,
1640 response_ref: None,
1641 error_type: None,
1642 response: None,
1643 created_at: now_seconds(),
1644 updated_at: now_seconds(),
1645 };
1646 if let Some(existing) = self.store.reserve_ledger(&idempotency_key, entry) {
1647 if existing.status == "SUCCEEDED" {
1648 let mut payload = State::new();
1649 payload.insert("tool".to_string(), Value::String(tool_name.to_string()));
1650 payload.insert("replayed_from_ledger".to_string(), Value::Bool(true));
1651 self.store.append_event(
1652 &ctx.run_id,
1653 Some(&ctx.session_id),
1654 Some(&ctx.step_id),
1655 "tool_call_completed",
1656 payload,
1657 Some(&ctx.agent_role),
1658 Some(ctx.state_version),
1659 Some(&causal_token),
1660 );
1661 self.store.record_cost(
1662 &ctx.run_id,
1663 &ctx.session_id,
1664 &ctx.step_id,
1665 "tool",
1666 tool_name,
1667 1.0,
1668 "call",
1669 State::new(),
1670 );
1671 return existing
1672 .response
1673 .ok_or_else(|| RuntimeError("missing ledger response".to_string()));
1674 }
1675 return Err(RuntimeError(
1676 "tool side effect already in progress".to_string(),
1677 ));
1678 }
1679 self.store
1680 .update_ledger(&idempotency_key, "RUNNING", None, None);
1681 }
1682 if sandbox_required {
1683 let executor = if sandbox_executor.is_empty() { "default".to_string() } else { sandbox_executor.clone() };
1684 let network = match sandbox_policy.get("network") {
1685 Some(Value::String(value)) => value.clone(),
1686 _ => "deny".to_string(),
1687 };
1688 let filesystem = match sandbox_policy.get("filesystem") {
1689 Some(Value::String(value)) => value.clone(),
1690 _ => "read-only".to_string(),
1691 };
1692 let timeout_seconds = match sandbox_policy.get("timeout_seconds") {
1693 Some(Value::Number(value)) if *value > 0.0 => *value as u64,
1694 _ => 30,
1695 };
1696 let policy = SandboxPolicy {
1697 tool_name: tool_name.to_string(),
1698 run_id: ctx.run_id.clone(),
1699 step_id: ctx.step_id.clone(),
1700 executor: executor.clone(),
1701 network,
1702 filesystem,
1703 timeout_seconds,
1704 extra: sandbox_policy.clone(),
1705 };
1706 let mut sandbox_payload = State::new();
1707 sandbox_payload.insert("tool_name".to_string(), Value::String(tool_name.to_string()));
1708 sandbox_payload.insert("executor".to_string(), Value::String(executor));
1709 sandbox_payload.insert("network".to_string(), Value::String(policy.network.clone()));
1710 sandbox_payload.insert("filesystem".to_string(), Value::String(policy.filesystem.clone()));
1711 sandbox_payload.insert("timeout_seconds".to_string(), Value::Number(policy.timeout_seconds as f64));
1712 self.store.append_event(
1713 &ctx.run_id,
1714 Some(&ctx.session_id),
1715 Some(&ctx.step_id),
1716 "sandbox_started",
1717 sandbox_payload,
1718 Some(&ctx.agent_role),
1719 Some(ctx.state_version),
1720 Some(&causal_token),
1721 );
1722 let result = self.sandbox.run_tool(args, &policy);
1723 let mut completed = State::new();
1724 completed.insert("ok".to_string(), Value::Bool(result.ok));
1725 completed.insert("metadata".to_string(), Value::Object(result.metadata.clone()));
1726 if let Some(error) = &result.error {
1727 completed.insert("error".to_string(), Value::String(error.clone()));
1728 }
1729 self.store.append_event(
1730 &ctx.run_id,
1731 Some(&ctx.session_id),
1732 Some(&ctx.step_id),
1733 "sandbox_completed",
1734 completed,
1735 Some(&ctx.agent_role),
1736 Some(ctx.state_version),
1737 Some(&causal_token),
1738 );
1739 if !result.ok {
1740 let message = result.error.unwrap_or_else(|| "sandboxed tool failed".to_string());
1741 let mut failed = State::new();
1742 failed.insert("tool".to_string(), Value::String(tool_name.to_string()));
1743 failed.insert("error".to_string(), Value::String(message.clone()));
1744 self.store.append_event(
1745 &ctx.run_id,
1746 Some(&ctx.session_id),
1747 Some(&ctx.step_id),
1748 "tool_call_failed",
1749 failed,
1750 Some(&ctx.agent_role),
1751 Some(ctx.state_version),
1752 Some(&causal_token),
1753 );
1754 return Err(RuntimeError(message));
1755 }
1756 let value = result.output;
1757 if let Some(schema) = output_schema.as_ref() {
1758 validate_tool_schema(schema, &value, "$result")?;
1759 }
1760 if managed {
1761 self.store.update_ledger(&idempotency_key, "SUCCEEDED", Some(value.clone()), None);
1762 }
1763 let mut payload = State::new();
1764 payload.insert("tool".to_string(), Value::String(tool_name.to_string()));
1765 payload.insert("idempotency_key".to_string(), Value::String(idempotency_key));
1766 self.store.append_event(
1767 &ctx.run_id,
1768 Some(&ctx.session_id),
1769 Some(&ctx.step_id),
1770 "tool_call_completed",
1771 payload,
1772 Some(&ctx.agent_role),
1773 Some(ctx.state_version),
1774 Some(&causal_token),
1775 );
1776 self.store.record_cost(
1777 &ctx.run_id,
1778 &ctx.session_id,
1779 &ctx.step_id,
1780 "tool",
1781 tool_name,
1782 1.0,
1783 "call",
1784 State::new(),
1785 );
1786 return Ok(value);
1787 }
1788 let result = {
1789 let spec = self.registry.get(tool_name)?;
1790 (spec.func)(args)
1791 };
1792 match result {
1793 Ok(value) => {
1794 if let Some(schema) = output_schema.as_ref() {
1795 if let Err(err) = validate_tool_schema(schema, &value, "$result") {
1796 if managed {
1797 self.store.update_ledger(
1798 &idempotency_key,
1799 "PENDING_VERIFICATION",
1800 None,
1801 Some("ToolOutputValidationError".to_string()),
1802 );
1803 }
1804 let mut failed = State::new();
1805 failed.insert("tool".to_string(), Value::String(tool_name.to_string()));
1806 failed.insert("error".to_string(), Value::String(err.0.clone()));
1807 failed.insert(
1808 "phase".to_string(),
1809 Value::String("output_validation".to_string()),
1810 );
1811 self.store.append_event(
1812 &ctx.run_id,
1813 Some(&ctx.session_id),
1814 Some(&ctx.step_id),
1815 "tool_call_failed",
1816 failed,
1817 Some(&ctx.agent_role),
1818 Some(ctx.state_version),
1819 Some(&causal_token),
1820 );
1821 return Err(err);
1822 }
1823 }
1824 if managed {
1825 self.store.update_ledger(
1826 &idempotency_key,
1827 "SUCCEEDED",
1828 Some(value.clone()),
1829 None,
1830 );
1831 }
1832 let mut payload = State::new();
1833 payload.insert("tool".to_string(), Value::String(tool_name.to_string()));
1834 payload.insert(
1835 "idempotency_key".to_string(),
1836 Value::String(idempotency_key),
1837 );
1838 self.store.append_event(
1839 &ctx.run_id,
1840 Some(&ctx.session_id),
1841 Some(&ctx.step_id),
1842 "tool_call_completed",
1843 payload,
1844 Some(&ctx.agent_role),
1845 Some(ctx.state_version),
1846 Some(&causal_token),
1847 );
1848 self.store.record_cost(
1849 &ctx.run_id,
1850 &ctx.session_id,
1851 &ctx.step_id,
1852 "tool",
1853 tool_name,
1854 1.0,
1855 "call",
1856 State::new(),
1857 );
1858 Ok(value)
1859 }
1860 Err(err) => {
1861 if managed {
1862 self.store.update_ledger(
1863 &idempotency_key,
1864 "PENDING_VERIFICATION",
1865 None,
1866 Some("RuntimeError".to_string()),
1867 );
1868 }
1869 let mut payload = State::new();
1870 payload.insert("tool".to_string(), Value::String(tool_name.to_string()));
1871 payload.insert("error".to_string(), Value::String(err.0.clone()));
1872 self.store.append_event(
1873 &ctx.run_id,
1874 Some(&ctx.session_id),
1875 Some(&ctx.step_id),
1876 "tool_call_failed",
1877 payload,
1878 Some(&ctx.agent_role),
1879 Some(ctx.state_version),
1880 Some(&causal_token),
1881 );
1882 Err(err)
1883 }
1884 }
1885 }
1886
1887 fn record_permission(
1888 &mut self,
1889 ctx: &AgentContext,
1890 tool_name: &str,
1891 allowed: bool,
1892 reason: &str,
1893 causal_token: &str,
1894 ) {
1895 let mut permission = State::new();
1896 permission.insert("tool".to_string(), Value::String(tool_name.to_string()));
1897 permission.insert("allowed".to_string(), Value::Bool(allowed));
1898 permission.insert("reason".to_string(), Value::String(reason.to_string()));
1899 self.store.append_event(
1900 &ctx.run_id,
1901 Some(&ctx.session_id),
1902 Some(&ctx.step_id),
1903 "tool_permission_decided",
1904 permission,
1905 Some(&ctx.agent_role),
1906 Some(ctx.state_version),
1907 Some(causal_token),
1908 );
1909 }
1910
1911 pub fn create_artifact(
1912 &mut self,
1913 ctx: &AgentContext,
1914 name: &str,
1915 content: State,
1916 metadata: State,
1917 ) -> Result<String> {
1918 let artifact =
1919 self.store
1920 .create_artifact(&ctx.run_id, Some(&ctx.step_id), name, content, metadata);
1921 let mut payload = State::new();
1922 payload.insert(
1923 "artifact_id".to_string(),
1924 Value::String(artifact.artifact_id.clone()),
1925 );
1926 payload.insert("name".to_string(), Value::String(name.to_string()));
1927 self.store.append_event(
1928 &ctx.run_id,
1929 Some(&ctx.session_id),
1930 Some(&ctx.step_id),
1931 "artifact_created",
1932 payload,
1933 Some(&ctx.agent_role),
1934 Some(ctx.state_version),
1935 None,
1936 );
1937 Ok(artifact.artifact_id)
1938 }
1939
1940 pub fn create_media_artifact(
1941 &mut self,
1942 ctx: &AgentContext,
1943 name: &str,
1944 kind: &str,
1945 options: MediaArtifactOptions,
1946 ) -> Result<String> {
1947 if !is_media_kind(kind) {
1948 return Err(RuntimeError(format!("unsupported media kind: {kind}")));
1949 }
1950 let mut media_metadata = options.media_metadata.clone();
1951 media_metadata.insert(
1952 "schema_version".to_string(),
1953 Value::String(MEDIA_SCHEMA_VERSION.to_string()),
1954 );
1955 media_metadata.insert("kind".to_string(), Value::String(kind.to_string()));
1956 let mut content = State::new();
1957 content.insert(
1958 "schema_version".to_string(),
1959 Value::String(MEDIA_SCHEMA_VERSION.to_string()),
1960 );
1961 content.insert("kind".to_string(), Value::String(kind.to_string()));
1962 if let Some(uri) = &options.uri {
1963 content.insert("uri".to_string(), Value::String(uri.clone()));
1964 }
1965 if let Some(content_ref) = &options.content_ref {
1966 content.insert(
1967 "content_ref".to_string(),
1968 Value::String(content_ref.clone()),
1969 );
1970 }
1971 content.insert(
1972 "metadata".to_string(),
1973 Value::Object(media_metadata.clone()),
1974 );
1975 if !options.lineage.is_empty() {
1976 content.insert(
1977 "lineage".to_string(),
1978 Value::Object(options.lineage.clone()),
1979 );
1980 }
1981 if !options.derived_outputs.is_empty() {
1982 content.insert(
1983 "derived_outputs".to_string(),
1984 Value::Object(options.derived_outputs.clone()),
1985 );
1986 }
1987 let mut media = State::new();
1988 media.insert(
1989 "schema_version".to_string(),
1990 Value::String(MEDIA_SCHEMA_VERSION.to_string()),
1991 );
1992 media.insert("kind".to_string(), Value::String(kind.to_string()));
1993 if let Some(uri) = options.uri {
1994 media.insert("uri".to_string(), Value::String(uri));
1995 }
1996 if let Some(content_ref) = options.content_ref {
1997 media.insert("content_ref".to_string(), Value::String(content_ref));
1998 }
1999 media.insert("metadata".to_string(), Value::Object(media_metadata));
2000 media.insert("lineage".to_string(), Value::Object(options.lineage));
2001 let mut artifact_metadata = options.metadata;
2002 artifact_metadata.insert("agentledger_media".to_string(), Value::Object(media));
2003 self.create_artifact(ctx, name, content, artifact_metadata)
2004 }
2005
2006 pub fn create_stream_checkpoint(
2007 &mut self,
2008 ctx: &AgentContext,
2009 name: &str,
2010 options: StreamCheckpointOptions,
2011 ) -> Result<String> {
2012 if options.stream_id.is_empty() || options.consumer_id.is_empty() {
2013 return Err(RuntimeError(
2014 "stream_id and consumer_id are required".to_string(),
2015 ));
2016 }
2017 let chunk = options.chunk.map(stream_chunk_to_state);
2018 let mut content = State::new();
2019 content.insert(
2020 "schema_version".to_string(),
2021 Value::String(STREAM_SCHEMA_VERSION.to_string()),
2022 );
2023 content.insert(
2024 "stream_id".to_string(),
2025 Value::String(options.stream_id.clone()),
2026 );
2027 content.insert(
2028 "consumer_id".to_string(),
2029 Value::String(options.consumer_id.clone()),
2030 );
2031 content.insert("offset".to_string(), options.offset.clone());
2032 if let Some(watermark) = &options.watermark {
2033 content.insert("watermark".to_string(), watermark.clone());
2034 }
2035 if let Some(chunk_state) = &chunk {
2036 content.insert("chunk".to_string(), Value::Object(chunk_state.clone()));
2037 }
2038 if let Some(partial_ref) = &options.partial_result_ref {
2039 content.insert(
2040 "partial_result_ref".to_string(),
2041 Value::String(partial_ref.clone()),
2042 );
2043 }
2044 if !options.backpressure.is_empty() {
2045 content.insert(
2046 "backpressure".to_string(),
2047 Value::Object(options.backpressure.clone()),
2048 );
2049 }
2050 if !options.metadata.is_empty() {
2051 content.insert("metadata".to_string(), Value::Object(options.metadata));
2052 }
2053 let mut stream = State::new();
2054 stream.insert(
2055 "schema_version".to_string(),
2056 Value::String(STREAM_SCHEMA_VERSION.to_string()),
2057 );
2058 stream.insert("stream_id".to_string(), Value::String(options.stream_id));
2059 stream.insert(
2060 "consumer_id".to_string(),
2061 Value::String(options.consumer_id),
2062 );
2063 stream.insert("offset".to_string(), options.offset);
2064 if let Some(watermark) = options.watermark {
2065 stream.insert("watermark".to_string(), watermark);
2066 }
2067 if let Some(chunk_state) = chunk {
2068 stream.insert("chunk".to_string(), Value::Object(chunk_state));
2069 }
2070 if let Some(partial_ref) = options.partial_result_ref {
2071 stream.insert("partial_result_ref".to_string(), Value::String(partial_ref));
2072 }
2073 if !options.backpressure.is_empty() {
2074 stream.insert(
2075 "backpressure".to_string(),
2076 Value::Object(options.backpressure),
2077 );
2078 }
2079 let mut artifact_metadata = State::new();
2080 artifact_metadata.insert("agentledger_stream".to_string(), Value::Object(stream));
2081 self.create_artifact(ctx, name, content, artifact_metadata)
2082 }
2083
2084 pub fn record_model_call(
2085 &mut self,
2086 ctx: &AgentContext,
2087 model: &str,
2088 input_tokens: f64,
2089 output_tokens: f64,
2090 total_usd: f64,
2091 ) -> Result<()> {
2092 let mut usage = State::new();
2093 usage.insert("input_tokens".to_string(), Value::Number(input_tokens));
2094 usage.insert("output_tokens".to_string(), Value::Number(output_tokens));
2095 self.record_model_call_evidence(ctx, "custom", model, State::new(), State::new(), usage, total_usd, State::new())
2096 }
2097
2098 pub fn record_model_call_evidence(
2099 &mut self,
2100 ctx: &AgentContext,
2101 provider: &str,
2102 model: &str,
2103 request: State,
2104 response: State,
2105 usage: State,
2106 total_usd: f64,
2107 metadata: State,
2108 ) -> Result<()> {
2109 let tokens = usage_total_tokens(&usage);
2110 if let Some(max) = self.budget.max_model_tokens {
2111 let used = self.store.cost_summary(&ctx.run_id).model_tokens;
2112 if used + tokens > max {
2113 let message = format!("model token budget exceeded: {used}+{tokens}/{max}");
2114 let mut payload = State::new();
2115 payload.insert("category".to_string(), Value::String("model".to_string()));
2116 payload.insert("provider".to_string(), Value::String(provider.to_string()));
2117 payload.insert("model".to_string(), Value::String(model.to_string()));
2118 payload.insert("error".to_string(), Value::String(message.clone()));
2119 self.store.append_event(
2120 &ctx.run_id,
2121 Some(&ctx.session_id),
2122 Some(&ctx.step_id),
2123 "budget_check_failed",
2124 payload,
2125 Some(&ctx.agent_role),
2126 Some(ctx.state_version),
2127 None,
2128 );
2129 return Err(RuntimeError(message));
2130 }
2131 }
2132 let mut request_payload = State::new();
2133 request_payload.insert("schema_version".to_string(), Value::String(MODEL_EVIDENCE_SCHEMA_VERSION.to_string()));
2134 request_payload.insert("provider".to_string(), Value::String(provider.to_string()));
2135 request_payload.insert("model".to_string(), Value::String(model.to_string()));
2136 request_payload.insert("request".to_string(), Value::Object(request));
2137 request_payload.insert("metadata".to_string(), Value::Object(metadata.clone()));
2138 self.store.append_event(
2139 &ctx.run_id,
2140 Some(&ctx.session_id),
2141 Some(&ctx.step_id),
2142 "model_call_requested",
2143 request_payload,
2144 Some(&ctx.agent_role),
2145 Some(ctx.state_version),
2146 None,
2147 );
2148 let mut response_payload = State::new();
2149 response_payload.insert("schema_version".to_string(), Value::String(MODEL_EVIDENCE_SCHEMA_VERSION.to_string()));
2150 response_payload.insert("provider".to_string(), Value::String(provider.to_string()));
2151 response_payload.insert("model".to_string(), Value::String(model.to_string()));
2152 response_payload.insert("response".to_string(), Value::Object(response));
2153 response_payload.insert("usage".to_string(), Value::Object(usage.clone()));
2154 response_payload.insert("total_usd".to_string(), Value::Number(total_usd));
2155 response_payload.insert("metadata".to_string(), Value::Object(metadata));
2156 self.store.append_event(
2157 &ctx.run_id,
2158 Some(&ctx.session_id),
2159 Some(&ctx.step_id),
2160 "model_call_completed",
2161 response_payload,
2162 Some(&ctx.agent_role),
2163 Some(ctx.state_version),
2164 None,
2165 );
2166 self.record_model_costs(ctx, provider, model, &usage, total_usd);
2167 Ok(())
2168 }
2169
2170 pub fn record_model_failure(
2171 &mut self,
2172 ctx: &AgentContext,
2173 provider: &str,
2174 model: &str,
2175 error_type: &str,
2176 message: &str,
2177 retryable: Option<bool>,
2178 request: State,
2179 usage: State,
2180 total_usd: f64,
2181 metadata: State,
2182 ) -> Result<()> {
2183 let mut payload = State::new();
2184 payload.insert("schema_version".to_string(), Value::String(MODEL_EVIDENCE_SCHEMA_VERSION.to_string()));
2185 payload.insert("provider".to_string(), Value::String(provider.to_string()));
2186 payload.insert("model".to_string(), Value::String(model.to_string()));
2187 payload.insert("error_type".to_string(), Value::String(error_type.to_string()));
2188 payload.insert("error".to_string(), Value::String(message.to_string()));
2189 if let Some(value) = retryable {
2190 payload.insert("retryable".to_string(), Value::Bool(value));
2191 }
2192 payload.insert("request".to_string(), Value::Object(request));
2193 payload.insert("usage".to_string(), Value::Object(usage.clone()));
2194 payload.insert("total_usd".to_string(), Value::Number(total_usd));
2195 payload.insert("metadata".to_string(), Value::Object(metadata));
2196 self.store.append_event(
2197 &ctx.run_id,
2198 Some(&ctx.session_id),
2199 Some(&ctx.step_id),
2200 "model_call_failed",
2201 payload,
2202 Some(&ctx.agent_role),
2203 Some(ctx.state_version),
2204 None,
2205 );
2206 self.record_model_costs(ctx, provider, model, &usage, total_usd);
2207 Ok(())
2208 }
2209
2210 pub fn record_tool_call_proposal(
2211 &mut self,
2212 ctx: &AgentContext,
2213 tool_name: &str,
2214 arguments: State,
2215 provider: Option<&str>,
2216 model: Option<&str>,
2217 model_call_ref: Option<&str>,
2218 confidence: Option<f64>,
2219 reason: Option<&str>,
2220 metadata: State,
2221 ) {
2222 let mut payload = State::new();
2223 payload.insert("schema_version".to_string(), Value::String(MODEL_EVIDENCE_SCHEMA_VERSION.to_string()));
2224 payload.insert("tool".to_string(), Value::String(tool_name.to_string()));
2225 payload.insert("args".to_string(), Value::Object(arguments));
2226 if let Some(value) = provider {
2227 payload.insert("provider".to_string(), Value::String(value.to_string()));
2228 }
2229 if let Some(value) = model {
2230 payload.insert("model".to_string(), Value::String(value.to_string()));
2231 }
2232 if let Some(value) = model_call_ref {
2233 payload.insert("model_call_ref".to_string(), Value::String(value.to_string()));
2234 }
2235 if let Some(value) = confidence {
2236 payload.insert("confidence".to_string(), Value::Number(value));
2237 }
2238 if let Some(value) = reason {
2239 payload.insert("reason".to_string(), Value::String(value.to_string()));
2240 }
2241 payload.insert("metadata".to_string(), Value::Object(metadata));
2242 self.store.append_event(
2243 &ctx.run_id,
2244 Some(&ctx.session_id),
2245 Some(&ctx.step_id),
2246 "tool_call_proposed",
2247 payload,
2248 Some(&ctx.agent_role),
2249 Some(ctx.state_version),
2250 None,
2251 );
2252 }
2253
2254 fn record_model_costs(&mut self, ctx: &AgentContext, provider: &str, model: &str, usage: &State, total_usd: f64) {
2255 let tokens = usage_total_tokens(usage);
2256 let mut metadata = State::new();
2257 metadata.insert("provider".to_string(), Value::String(provider.to_string()));
2258 metadata.insert("model".to_string(), Value::String(model.to_string()));
2259 metadata.insert("usage".to_string(), Value::Object(usage.clone()));
2260 if tokens > 0.0 {
2261 self.store.record_cost(&ctx.run_id, &ctx.session_id, &ctx.step_id, "model", model, tokens, "token", metadata.clone());
2262 }
2263 if total_usd > 0.0 {
2264 self.store.record_cost(&ctx.run_id, &ctx.session_id, &ctx.step_id, "model", model, total_usd, "usd", metadata);
2265 }
2266 }
2267}
2268
2269impl Default for Runtime {
2270 fn default() -> Self {
2271 Self::new()
2272 }
2273}
2274
2275#[derive(Clone, Debug)]
2276pub struct AgentContext {
2277 pub run_id: String,
2278 pub session_id: String,
2279 pub step_id: String,
2280 pub agent_role: String,
2281 pub lease_token: String,
2282 pub attempt: u64,
2283 pub state_version: u64,
2284 pub pending_patch: State,
2285}
2286
2287impl AgentContext {
2288 pub fn write_state(&mut self, key: &str, value: Value) {
2289 self.pending_patch.insert(key.to_string(), value);
2290 }
2291}
2292
2293#[derive(Clone, Debug)]
2294pub struct EvidenceBundle {
2295 pub schema_version: String,
2296 pub bundle_hash: String,
2297 pub run: Run,
2298 pub steps: Vec<Step>,
2299 pub events: Vec<Event>,
2300 pub tool_ledger: Vec<ToolLedgerEntry>,
2301 pub approvals: Vec<ApprovalRequest>,
2302 pub artifacts: Vec<Artifact>,
2303 pub media_artifacts: Vec<State>,
2304 pub stream_checkpoints: Vec<State>,
2305 pub cost_records: Vec<CostRecord>,
2306 pub cost_summary: CostSummary,
2307 pub summary: State,
2308 pub final_state: State,
2309}
2310
2311#[derive(Clone, Debug, Default)]
2312pub struct WorkerRunSummary {
2313 pub worker_id: String,
2314 pub run_id: Option<String>,
2315 pub iterations: u64,
2316 pub attempts: u64,
2317 pub succeeded_attempts: u64,
2318 pub recovered_leases: u64,
2319 pub final_status: Option<String>,
2320 pub stopped_reason: String,
2321}
2322
2323pub struct LocalWorker {
2324 pub worker_id: String,
2325 pub agent_role: String,
2326 pub lease_seconds: f64,
2327 pub recover_expired: bool,
2328}
2329
2330impl LocalWorker {
2331 pub fn new(worker_id: &str, agent_role: &str) -> Self {
2332 Self {
2333 worker_id: worker_id.to_string(),
2334 agent_role: agent_role.to_string(),
2335 lease_seconds: 60.0,
2336 recover_expired: true,
2337 }
2338 }
2339
2340 pub fn run_until_idle(
2341 &self,
2342 runtime: &mut Runtime,
2343 run_id: &str,
2344 max_iterations: u64,
2345 agent: AgentFunc,
2346 ) -> Result<WorkerRunSummary> {
2347 let mut summary = WorkerRunSummary {
2348 worker_id: self.worker_id.clone(),
2349 run_id: Some(run_id.to_string()),
2350 stopped_reason: "max_iterations".to_string(),
2351 ..WorkerRunSummary::default()
2352 };
2353 for index in 1..=max_iterations {
2354 summary.iterations = index;
2355 if self.recover_expired {
2356 summary.recovered_leases += runtime.store.recover_expired_leases() as u64;
2357 }
2358 let status = runtime.store.run(run_id)?.status;
2359 if is_terminal_status(&status) {
2360 summary.final_status = Some(status);
2361 summary.stopped_reason = "terminal_status".to_string();
2362 break;
2363 }
2364 let ok = runtime.run_once(
2365 run_id,
2366 &self.worker_id,
2367 &self.agent_role,
2368 self.lease_seconds,
2369 agent,
2370 )?;
2371 if !ok {
2372 summary.stopped_reason = "idle".to_string();
2373 break;
2374 }
2375 summary.attempts += 1;
2376 summary.succeeded_attempts += 1;
2377 }
2378 let status = runtime.store.run(run_id)?.status;
2379 summary.final_status = Some(status.clone());
2380 if is_terminal_status(&status) {
2381 summary.stopped_reason = "terminal_status".to_string();
2382 }
2383 Ok(summary)
2384 }
2385}
2386
2387#[derive(Clone, Debug, Default)]
2388pub struct WorkerServiceSummary {
2389 pub worker_id: String,
2390 pub run_id: Option<String>,
2391 pub loops: u64,
2392 pub attempts: u64,
2393 pub succeeded_attempts: u64,
2394 pub recovered_leases: u64,
2395 pub idle_polls: u64,
2396 pub stopped_reason: String,
2397 pub final_status: Option<String>,
2398 pub stop_requested: bool,
2399}
2400
2401pub struct WorkerService {
2402 pub worker: LocalWorker,
2403 pub stop_requested: bool,
2404 pub stop_reason: String,
2405}
2406
2407impl WorkerService {
2408 pub fn new(worker: LocalWorker) -> Self {
2409 Self {
2410 worker,
2411 stop_requested: false,
2412 stop_reason: "stop_requested".to_string(),
2413 }
2414 }
2415
2416 pub fn request_stop(&mut self, reason: &str) {
2417 self.stop_requested = true;
2418 self.stop_reason = reason.to_string();
2419 }
2420
2421 pub fn serve(
2422 &mut self,
2423 runtime: &mut Runtime,
2424 run_id: Option<&str>,
2425 max_loops: u64,
2426 max_idle_polls: Option<u64>,
2427 agent: AgentFunc,
2428 ) -> Result<WorkerServiceSummary> {
2429 let mut summary = WorkerServiceSummary {
2430 worker_id: self.worker.worker_id.clone(),
2431 run_id: run_id.map(str::to_string),
2432 stopped_reason: "max_loops".to_string(),
2433 ..WorkerServiceSummary::default()
2434 };
2435 while summary.loops < max_loops {
2436 if self.stop_requested {
2437 summary.stopped_reason = self.stop_reason.clone();
2438 summary.stop_requested = true;
2439 break;
2440 }
2441 summary.loops += 1;
2442 let Some(run_id) = run_id else {
2443 summary.idle_polls += 1;
2444 if max_idle_polls.is_some_and(|limit| summary.idle_polls >= limit) {
2445 summary.stopped_reason = "idle".to_string();
2446 break;
2447 }
2448 continue;
2449 };
2450 let run_summary = self.worker.run_until_idle(runtime, run_id, 1, agent)?;
2451 summary.attempts += run_summary.attempts;
2452 summary.succeeded_attempts += run_summary.succeeded_attempts;
2453 summary.recovered_leases += run_summary.recovered_leases;
2454 summary.final_status = run_summary.final_status.clone();
2455 if summary
2456 .final_status
2457 .as_deref()
2458 .is_some_and(is_terminal_status)
2459 {
2460 summary.stopped_reason = "terminal_status".to_string();
2461 break;
2462 }
2463 if run_summary.attempts == 0 {
2464 summary.idle_polls += 1;
2465 if max_idle_polls.is_some_and(|limit| summary.idle_polls >= limit) {
2466 summary.stopped_reason = "idle".to_string();
2467 break;
2468 }
2469 } else {
2470 summary.idle_polls = 0;
2471 }
2472 }
2473 Ok(summary)
2474 }
2475}
2476
2477fn is_terminal_status(status: &str) -> bool {
2478 matches!(status, "completed" | "failed" | "cancelled")
2479}
2480
2481pub fn export_evidence(store: &MemoryStore, run_id: &str) -> Result<EvidenceBundle> {
2482 let run = store.run(run_id)?;
2483 let steps = store.steps(run_id);
2484 let events = store.events(run_id);
2485 let tool_ledger = store.ledger(run_id);
2486 let approvals = store.approval_requests(run_id);
2487 let artifacts = store.artifacts(run_id);
2488 let media_artifacts = media_artifacts_from(&artifacts);
2489 let stream_checkpoints = stream_checkpoints_from(&artifacts);
2490 let cost_records = store.cost_records(run_id);
2491 let cost_summary = store.cost_summary(run_id);
2492 let summary = evidence_summary(
2493 &steps,
2494 &events,
2495 &tool_ledger,
2496 &approvals,
2497 &artifacts,
2498 &media_artifacts,
2499 &stream_checkpoints,
2500 &cost_records,
2501 &cost_summary,
2502 );
2503 let final_state = store.final_state(run_id)?;
2504 let basis = format!(
2505 "{}:{}:{}:{}:{}",
2506 run_id,
2507 steps.len(),
2508 events.len(),
2509 tool_ledger.len(),
2510 cost_records.len()
2511 );
2512 Ok(EvidenceBundle {
2513 schema_version: "agentledger.evidence.v1".to_string(),
2514 bundle_hash: stable_hash(&basis),
2515 run,
2516 steps,
2517 events,
2518 tool_ledger,
2519 approvals,
2520 artifacts,
2521 media_artifacts,
2522 stream_checkpoints,
2523 cost_records,
2524 cost_summary,
2525 summary,
2526 final_state,
2527 })
2528}
2529
2530fn evidence_summary(
2531 steps: &[Step],
2532 events: &[Event],
2533 ledger: &[ToolLedgerEntry],
2534 approvals: &[ApprovalRequest],
2535 artifacts: &[Artifact],
2536 media_artifacts: &[State],
2537 stream_checkpoints: &[State],
2538 cost_records: &[CostRecord],
2539 cost_summary: &CostSummary,
2540) -> State {
2541 let mut summary = State::new();
2542 summary.insert("event_count".into(), Value::Number(events.len() as f64));
2543 summary.insert("step_count".into(), Value::Number(steps.len() as f64));
2544 summary.insert(
2545 "tool_ledger_count".into(),
2546 Value::Number(ledger.len() as f64),
2547 );
2548 summary.insert(
2549 "approval_count".into(),
2550 Value::Number(approvals.len() as f64),
2551 );
2552 summary.insert(
2553 "artifact_count".into(),
2554 Value::Number(artifacts.len() as f64),
2555 );
2556 summary.insert(
2557 "media_artifact_count".into(),
2558 Value::Number(media_artifacts.len() as f64),
2559 );
2560 summary.insert(
2561 "stream_checkpoint_count".into(),
2562 Value::Number(stream_checkpoints.len() as f64),
2563 );
2564 summary.insert(
2565 "cost_record_count".into(),
2566 Value::Number(cost_records.len() as f64),
2567 );
2568 summary.insert(
2569 "has_failed_steps".into(),
2570 Value::Bool(steps.iter().any(|step| step.status == "failed")),
2571 );
2572 summary.insert(
2573 "has_pending_verification".into(),
2574 Value::Bool(
2575 ledger
2576 .iter()
2577 .any(|row| row.status == "PENDING_VERIFICATION"),
2578 ),
2579 );
2580 summary.insert(
2581 "has_pending_approvals".into(),
2582 Value::Bool(approvals.iter().any(|row| row.status == "PENDING")),
2583 );
2584 let mut cost = State::new();
2585 cost.insert("tool_calls".into(), Value::Number(cost_summary.tool_calls));
2586 cost.insert(
2587 "model_tokens".into(),
2588 Value::Number(cost_summary.model_tokens),
2589 );
2590 cost.insert("total_usd".into(), Value::Number(cost_summary.total_usd));
2591 summary.insert("cost_summary".into(), Value::Object(cost));
2592 summary
2593}
2594
2595#[derive(Clone, Debug)]
2596pub struct ReplaySummary {
2597 pub run_id: String,
2598 pub event_count: usize,
2599 pub tool_call_count: usize,
2600 pub final_state: State,
2601 pub event_hash: String,
2602 pub replay_safe: bool,
2603 pub artifact_count: usize,
2604 pub media_artifact_count: usize,
2605 pub stream_checkpoint_count: usize,
2606}
2607
2608pub fn replay(store: &MemoryStore, run_id: &str) -> Result<ReplaySummary> {
2609 let events = store.events(run_id);
2610 let tool_call_count = events
2611 .iter()
2612 .filter(|event| event.event_type.starts_with("tool_call_"))
2613 .count();
2614 let digest = events
2615 .iter()
2616 .map(|event| format!("{}:{}:{}", event.seq, event.event_type, event.payload_hash))
2617 .collect::<Vec<_>>()
2618 .join("|");
2619 let artifacts = store.artifacts(run_id);
2620 Ok(ReplaySummary {
2621 run_id: run_id.to_string(),
2622 event_count: events.len(),
2623 tool_call_count,
2624 final_state: store.final_state(run_id)?,
2625 event_hash: stable_hash(&digest),
2626 replay_safe: true,
2627 artifact_count: artifacts.len(),
2628 media_artifact_count: media_artifacts_from(&artifacts).len(),
2629 stream_checkpoint_count: stream_checkpoints_from(&artifacts).len(),
2630 })
2631}
2632
2633#[derive(Clone, Debug)]
2634pub struct CostAttributionReport {
2635 pub run_id: String,
2636 pub total: CostSummary,
2637 pub by_agent: HashMap<String, CostSummary>,
2638 pub by_step: HashMap<String, CostSummary>,
2639 pub by_name: HashMap<String, CostSummary>,
2640}
2641
2642pub fn cost_attribution(store: &MemoryStore, run_id: &str) -> CostAttributionReport {
2643 let mut step_roles = HashMap::new();
2644 for event in store.events(run_id) {
2645 if let (Some(step_id), Some(agent_role)) = (event.step_id, event.agent_role) {
2646 step_roles.insert(step_id, agent_role);
2647 }
2648 }
2649 let mut report = CostAttributionReport {
2650 run_id: run_id.to_string(),
2651 total: CostSummary::default(),
2652 by_agent: HashMap::new(),
2653 by_step: HashMap::new(),
2654 by_name: HashMap::new(),
2655 };
2656 for record in store.cost_records(run_id) {
2657 add_cost(&mut report.total, &record);
2658 let agent = step_roles
2659 .get(&record.step_id)
2660 .cloned()
2661 .unwrap_or_else(|| "<unknown>".to_string());
2662 add_cost(report.by_agent.entry(agent).or_default(), &record);
2663 add_cost(
2664 report.by_step.entry(record.step_id.clone()).or_default(),
2665 &record,
2666 );
2667 add_cost(
2668 report.by_name.entry(record.name.clone()).or_default(),
2669 &record,
2670 );
2671 }
2672 report
2673}
2674
2675#[derive(Clone, Debug)]
2676pub struct FailureAttributionReport {
2677 pub run_id: String,
2678 pub run_status: String,
2679 pub failed_steps: Vec<Step>,
2680 pub pending_verification: Vec<ToolLedgerEntry>,
2681 pub pending_approvals: Vec<ApprovalRequest>,
2682 pub failure_events: Vec<Event>,
2683 pub failure_envelopes: Vec<State>,
2684 pub failure_lifecycle: State,
2685 pub failure_causal_graph: State,
2686 pub failure_replay_plan: State,
2687 pub failure_alerts: State,
2688 pub failure_export: State,
2689 pub summary: State,
2690}
2691
2692pub fn failure_attribution(store: &MemoryStore, run_id: &str) -> Result<FailureAttributionReport> {
2693 let run = store.run(run_id)?;
2694 let failed_steps: Vec<Step> = store
2695 .steps(run_id)
2696 .into_iter()
2697 .filter(|step| step.status == "failed")
2698 .collect();
2699 let pending_verification: Vec<ToolLedgerEntry> = store
2700 .ledger(run_id)
2701 .into_iter()
2702 .filter(|entry| entry.status == "PENDING_VERIFICATION")
2703 .collect();
2704 let pending_approvals: Vec<ApprovalRequest> = store
2705 .approval_requests(run_id)
2706 .into_iter()
2707 .filter(|entry| entry.status == "PENDING")
2708 .collect();
2709 let failure_events: Vec<Event> = store
2710 .events(run_id)
2711 .into_iter()
2712 .filter(|event| is_failure_event(&event.event_type))
2713 .collect();
2714 let steps = store.steps(run_id);
2715 let ledger = store.ledger(run_id);
2716 let approvals = store.approval_requests(run_id);
2717 let events = store.events(run_id);
2718 let costs = store.cost_records(run_id);
2719 let failure_envelopes = failure_envelopes(run_id, &run.status, &steps, &ledger, &approvals, &failure_events);
2720 let failure_lifecycle = failure_lifecycle(run_id, &run.status, &failure_envelopes);
2721 let failure_causal_graph = failure_causal_graph(run_id, &run.status, &failure_envelopes, &steps, &ledger, &approvals, &events, &costs);
2722 let failure_replay_plan = failure_replay_plan(run_id, &failure_envelopes, &ledger, &events);
2723 let failure_alerts = failure_alerts(run_id, &failure_envelopes, &failure_replay_plan);
2724 let mut summary = State::new();
2725 summary.insert(
2726 "failed_step_count".to_string(),
2727 Value::Number(failed_steps.len() as f64),
2728 );
2729 summary.insert(
2730 "pending_verification_count".to_string(),
2731 Value::Number(pending_verification.len() as f64),
2732 );
2733 summary.insert(
2734 "pending_approval_count".to_string(),
2735 Value::Number(pending_approvals.len() as f64),
2736 );
2737 summary.insert(
2738 "failure_event_count".to_string(),
2739 Value::Number(failure_events.len() as f64),
2740 );
2741 summary.insert(
2742 "failure_envelope_count".to_string(),
2743 Value::Number(failure_envelopes.len() as f64),
2744 );
2745 summary.insert(
2746 "failure_lifecycle_event_count".to_string(),
2747 Value::Number(state_array_len(&failure_lifecycle, "events") as f64),
2748 );
2749 summary.insert(
2750 "failure_alert_count".to_string(),
2751 failure_alerts
2752 .get("alert_count")
2753 .cloned()
2754 .unwrap_or(Value::Number(0.0)),
2755 );
2756 summary.insert(
2757 "unsafe_replay_side_effect_count".to_string(),
2758 failure_replay_plan
2759 .get("unsafe_side_effect_count")
2760 .cloned()
2761 .unwrap_or(Value::Number(0.0)),
2762 );
2763 summary.insert(
2764 "terminal_failure_count".to_string(),
2765 Value::Number(count_state_field(&failure_envelopes, "status", "terminal") as f64),
2766 );
2767 summary.insert(
2768 "recoverable_failure_count".to_string(),
2769 Value::Number(count_recoverable_failures(&failure_envelopes) as f64),
2770 );
2771 let failure_export = failure_export(
2772 run_id,
2773 &run.status,
2774 &summary,
2775 &failure_envelopes,
2776 &failure_lifecycle,
2777 &failure_causal_graph,
2778 &failure_replay_plan,
2779 &failure_alerts,
2780 );
2781 Ok(FailureAttributionReport {
2782 run_id: run_id.to_string(),
2783 run_status: run.status,
2784 failed_steps,
2785 pending_verification,
2786 pending_approvals,
2787 failure_events,
2788 failure_envelopes,
2789 failure_lifecycle,
2790 failure_causal_graph,
2791 failure_replay_plan,
2792 failure_alerts,
2793 failure_export,
2794 summary,
2795 })
2796}
2797
2798fn failure_envelopes(
2799 run_id: &str,
2800 run_status: &str,
2801 steps: &[Step],
2802 ledger: &[ToolLedgerEntry],
2803 approvals: &[ApprovalRequest],
2804 events: &[Event],
2805) -> Vec<State> {
2806 let mut rows = Vec::new();
2807 for step in steps {
2808 if step.status == "failed" || step.status == "retry_scheduled" || step.status == "waiting_human" {
2809 let (status, severity, recoverability, retryability) = if step.status == "retry_scheduled" {
2810 ("recovery_scheduled", "warn", "auto_retry", "retryable")
2811 } else if step.status == "waiting_human" {
2812 ("waiting_human", "warn", "human_required", "unknown")
2813 } else {
2814 ("terminal", "risk", "terminal", "not_retryable")
2815 };
2816 rows.push(failure_envelope(
2817 run_id,
2818 "step",
2819 &step.step_id,
2820 &failure_category(&format!(
2821 "{} {}",
2822 step.last_error_type.clone().unwrap_or_default(),
2823 step.last_error.clone().unwrap_or_default()
2824 ), "agent"),
2825 status,
2826 severity,
2827 recoverability,
2828 retryability,
2829 "agent",
2830 &first_text(&[
2831 step.last_error.as_deref(),
2832 step.last_error_type.as_deref(),
2833 Some("step failure"),
2834 ]),
2835 state(&[
2836 ("step_id", step.step_id.clone().into()),
2837 ("occurred_at", Value::Number(step.updated_at)),
2838 ]),
2839 vec![ref_state("step", &step.step_id)],
2840 ));
2841 }
2842 }
2843 for entry in ledger {
2844 if entry.status == "PENDING_VERIFICATION" || entry.status == "FAILED" || entry.status == "ERROR" {
2845 let terminal = entry.status == "FAILED" || entry.status == "ERROR";
2846 rows.push(failure_envelope(
2847 run_id,
2848 "tool_ledger",
2849 &first_text(&[Some(&entry.ledger_id), Some(&entry.tool_name), Some(&entry.step_id)]),
2850 "tool",
2851 if terminal { "terminal" } else { "unknown_side_effect" },
2852 if terminal { "risk" } else { "warn" },
2853 if terminal { "terminal" } else { "manual_verification" },
2854 if terminal { "not_retryable" } else { "unknown" },
2855 "tool",
2856 &first_text(&[entry.error_type.as_deref(), Some("tool side effect requires verification")]),
2857 state(&[
2858 ("step_id", entry.step_id.clone().into()),
2859 ("tool_name", entry.tool_name.clone().into()),
2860 ("occurred_at", Value::Number(entry.updated_at)),
2861 ]),
2862 vec![ref_state("step", &entry.step_id), ref_state("tool", &entry.tool_name)],
2863 ));
2864 }
2865 }
2866 for approval in approvals {
2867 if approval.status == "PENDING" || approval.status == "DENIED" {
2868 let denied = approval.status == "DENIED";
2869 rows.push(failure_envelope(
2870 run_id,
2871 "approval",
2872 &first_text(&[Some(&approval.approval_id), Some(&approval.tool_name), Some(&approval.step_id)]),
2873 if denied { "policy" } else { "approval" },
2874 if denied { "blocked" } else { "waiting_human" },
2875 if denied { "risk" } else { "warn" },
2876 if denied { "terminal" } else { "human_required" },
2877 if denied { "not_retryable" } else { "unknown" },
2878 "policy",
2879 &first_text(&[
2880 approval.decision_reason.as_deref(),
2881 Some(&approval.reason),
2882 Some(if denied { "approval denied" } else { "approval pending" }),
2883 ]),
2884 state(&[
2885 ("step_id", approval.step_id.clone().into()),
2886 ("tool_name", approval.tool_name.clone().into()),
2887 ("approval_id", approval.approval_id.clone().into()),
2888 ("occurred_at", Value::Number(approval.updated_at)),
2889 ]),
2890 vec![
2891 ref_state("step", &approval.step_id),
2892 ref_state("tool", &approval.tool_name),
2893 ref_state("approval", &approval.approval_id),
2894 ],
2895 ));
2896 }
2897 }
2898 for event in events {
2899 let category = event_failure_category(event);
2900 let status = event_failure_status(&event.event_type, run_status);
2901 let message = first_text(&[
2902 state_string(&event.payload, "error").as_deref(),
2903 state_string(&event.payload, "reason").as_deref(),
2904 state_string(&event.payload, "error_type").as_deref(),
2905 Some(&event.event_type),
2906 ]);
2907 rows.push(failure_envelope(
2908 run_id,
2909 "event",
2910 &event.seq.to_string(),
2911 &category,
2912 &status,
2913 if status == "terminal" || status == "blocked" || status == "failed" { "risk" } else { "warn" },
2914 &event_recoverability(&event.event_type, run_status),
2915 &event_retryability(&event.event_type),
2916 &owner_for_failure_category(&category),
2917 &message,
2918 state(&[
2919 ("step_id", event.step_id.clone().unwrap_or_default().into()),
2920 ("event_seq", Value::Number(event.seq as f64)),
2921 ("event_type", event.event_type.clone().into()),
2922 ("occurred_at", Value::Number(event.timestamp)),
2923 ]),
2924 vec![
2925 ref_state("event", &event.seq.to_string()),
2926 ref_state("step", event.step_id.as_deref().unwrap_or("")),
2927 ],
2928 ));
2929 }
2930 dedupe_states(rows, "failure_id")
2931}
2932
2933fn failure_envelope(
2934 run_id: &str,
2935 source_kind: &str,
2936 source_id: &str,
2937 category: &str,
2938 status: &str,
2939 severity: &str,
2940 recoverability: &str,
2941 retryability: &str,
2942 owner: &str,
2943 message: &str,
2944 extra: State,
2945 refs: Vec<State>,
2946) -> State {
2947 let mut row = state(&[
2948 ("schema_version", "agentledger.failure.envelope.v1".into()),
2949 ("failure_id", format!("failure-{}", slug(&format!("{run_id}-{source_kind}-{source_id}"))).into()),
2950 ("run_id", run_id.into()),
2951 ("source_kind", source_kind.into()),
2952 ("source_id", source_id.into()),
2953 ("category", category.into()),
2954 ("status", status.into()),
2955 ("severity", severity.into()),
2956 ("recoverability", recoverability.into()),
2957 ("retryability", retryability.into()),
2958 ("owner", owner.into()),
2959 ("message", message.into()),
2960 ("causal_refs", Value::Array(refs.iter().cloned().map(Value::Object).collect())),
2961 ("evidence_refs", Value::Array(refs.into_iter().map(Value::Object).collect())),
2962 ]);
2963 for (key, value) in extra {
2964 if value != Value::String(String::new()) && value != Value::Null {
2965 row.insert(key, value);
2966 }
2967 }
2968 row
2969}
2970
2971fn failure_lifecycle(run_id: &str, run_status: &str, envelopes: &[State]) -> State {
2972 let mut events = Vec::new();
2973 for env in envelopes {
2974 events.push(lifecycle_row(run_id, env, "failure_detected", state_value_string(env, "message"), state_value_string(env, "severity")));
2975 events.push(lifecycle_row(run_id, env, "failure_classified", state_value_string(env, "category"), state_value_string(env, "severity")));
2976 let status = state_value_string(env, "status");
2977 let recoverability = state_value_string(env, "recoverability");
2978 if ["recovery_scheduled", "waiting_human", "unknown_side_effect"].contains(&status.as_str()) || ["auto_retry", "human_required", "manual_verification"].contains(&recoverability.as_str()) {
2979 events.push(lifecycle_row(run_id, env, "failure_recovery_scheduled", "recovery scheduled".to_string(), "warn".to_string()));
2980 }
2981 if ["terminal", "blocked"].contains(&status.as_str()) || recoverability == "terminal" {
2982 events.push(lifecycle_row(run_id, env, "failure_terminal", state_value_string(env, "message"), "risk".to_string()));
2983 }
2984 }
2985 state(&[
2986 ("schema_version", "agentledger.failure.lifecycle.v1".into()),
2987 ("run_id", run_id.into()),
2988 ("run_status", run_status.into()),
2989 ("events", Value::Array(events.iter().cloned().map(Value::Object).collect())),
2990 ("terminal", Value::Bool(events.iter().any(|row| state_value_string(row, "stage") == "failure_terminal"))),
2991 ("recoverable", Value::Bool(events.iter().any(|row| state_value_string(row, "stage") == "failure_recovery_scheduled"))),
2992 ])
2993}
2994
2995fn lifecycle_row(run_id: &str, env: &State, stage: &str, message: String, severity: String) -> State {
2996 state(&[
2997 ("schema_version", "agentledger.failure.lifecycle.v1".into()),
2998 ("stage", stage.into()),
2999 ("run_id", run_id.into()),
3000 ("failure_id", state_value_string(env, "failure_id").into()),
3001 ("category", state_value_string(env, "category").into()),
3002 ("recoverability", state_value_string(env, "recoverability").into()),
3003 ("retryability", state_value_string(env, "retryability").into()),
3004 ("owner", state_value_string(env, "owner").into()),
3005 ("message", message.into()),
3006 ("severity", severity.into()),
3007 ("causal_refs", env.get("causal_refs").cloned().unwrap_or(Value::Array(vec![]))),
3008 ])
3009}
3010
3011fn failure_causal_graph(
3012 run_id: &str,
3013 run_status: &str,
3014 envelopes: &[State],
3015 steps: &[Step],
3016 ledger: &[ToolLedgerEntry],
3017 approvals: &[ApprovalRequest],
3018 events: &[Event],
3019 costs: &[CostRecord],
3020) -> State {
3021 let mut nodes = vec![state(&[("id", format!("run:{}", slug(run_id)).into()), ("kind", "run".into()), ("status", run_status.into())])];
3022 let mut edges = Vec::new();
3023 for step in steps {
3024 nodes.push(state(&[("id", format!("step:{}", slug(&step.step_id)).into()), ("kind", "step".into()), ("status", step.status.clone().into())]));
3025 edges.push(state(&[("source", format!("run:{}", slug(run_id)).into()), ("target", format!("step:{}", slug(&step.step_id)).into()), ("kind", "contains_step".into())]));
3026 }
3027 for event in events {
3028 nodes.push(state(&[("id", format!("event:{}", event.seq).into()), ("kind", "event".into()), ("event_type", event.event_type.clone().into())]));
3029 edges.push(state(&[("source", format!("run:{}", slug(run_id)).into()), ("target", format!("event:{}", event.seq).into()), ("kind", "emitted_event".into())]));
3030 }
3031 for entry in ledger {
3032 nodes.push(state(&[("id", format!("tool:{}", slug(&entry.tool_name)).into()), ("kind", "tool".into()), ("status", entry.status.clone().into())]));
3033 }
3034 for approval in approvals {
3035 nodes.push(state(&[("id", format!("approval:{}", slug(&approval.approval_id)).into()), ("kind", "approval".into()), ("status", approval.status.clone().into())]));
3036 }
3037 for cost in costs {
3038 nodes.push(state(&[("id", format!("cost:{}", slug(&cost.cost_id)).into()), ("kind", "cost".into()), ("category", cost.category.clone().into()), ("amount", Value::Number(cost.amount)), ("unit", cost.unit.clone().into())]));
3039 }
3040 for env in envelopes {
3041 let id = format!("failure:{}", slug(&state_value_string(env, "failure_id")));
3042 nodes.push(state(&[("id", id.clone().into()), ("kind", "failure".into()), ("category", state_value_string(env, "category").into()), ("status", state_value_string(env, "status").into()), ("owner", state_value_string(env, "owner").into())]));
3043 edges.push(state(&[("source", format!("run:{}", slug(run_id)).into()), ("target", id.into()), ("kind", "has_failure".into())]));
3044 }
3045 let nodes = dedupe_states(nodes, "id");
3046 state(&[
3047 ("schema_version", "agentledger.failure.causal_graph.v1".into()),
3048 ("run_id", run_id.into()),
3049 ("nodes", Value::Array(nodes.iter().cloned().map(Value::Object).collect())),
3050 ("edges", Value::Array(edges.iter().cloned().map(Value::Object).collect())),
3051 ("summary", Value::Object(state(&[
3052 ("node_count", Value::Number(nodes.len() as f64)),
3053 ("edge_count", Value::Number(edges.len() as f64)),
3054 ("failure_node_count", Value::Number(count_state_field(&nodes, "kind", "failure") as f64)),
3055 ]))),
3056 ])
3057}
3058
3059fn failure_replay_plan(run_id: &str, envelopes: &[State], ledger: &[ToolLedgerEntry], events: &[Event]) -> State {
3060 let mut actions = Vec::new();
3061 let mut unsafe_count = 0;
3062 let mut manual_count = 0;
3063 for env in envelopes {
3064 let status = state_value_string(env, "status");
3065 let recoverability = state_value_string(env, "recoverability");
3066 let mut action = state(&[
3067 ("failure_id", state_value_string(env, "failure_id").into()),
3068 ("category", state_value_string(env, "category").into()),
3069 ("status", status.clone().into()),
3070 ("replay_action", "reuse_recorded_evidence".into()),
3071 ("replay_safe", Value::Bool(true)),
3072 ("requires_manual_verification", Value::Bool(false)),
3073 ("reason", "recorded runtime evidence can be inspected without calling external systems".into()),
3074 ]);
3075 if status == "unknown_side_effect" || recoverability == "manual_verification" {
3076 action.insert("replay_action".into(), "manual_verify_side_effect".into());
3077 action.insert("replay_safe".into(), Value::Bool(false));
3078 action.insert("requires_manual_verification".into(), Value::Bool(true));
3079 action.insert("reason".into(), "Tool Ledger recorded an unknown side-effect state".into());
3080 unsafe_count += 1;
3081 manual_count += 1;
3082 } else if status == "waiting_human" {
3083 action.insert("replay_action".into(), "resume_after_approval".into());
3084 } else if status == "recovery_scheduled" {
3085 action.insert("replay_action".into(), "retry_from_checkpoint".into());
3086 } else if status == "terminal" || status == "blocked" {
3087 action.insert("replay_action".into(), "terminal_stop".into());
3088 }
3089 actions.push(action);
3090 }
3091 state(&[
3092 ("schema_version", "agentledger.failure.replay_plan.v1".into()),
3093 ("run_id", run_id.into()),
3094 ("mode", "evidence_only".into()),
3095 ("safe_to_replay", Value::Bool(unsafe_count == 0)),
3096 ("unsafe_side_effect_count", Value::Number(unsafe_count as f64)),
3097 ("manual_verification_count", Value::Number(manual_count as f64)),
3098 ("recorded_tool_call_count", Value::Number(ledger.len() as f64)),
3099 ("recorded_event_count", Value::Number(events.len() as f64)),
3100 ("actions", Value::Array(actions.into_iter().map(Value::Object).collect())),
3101 ])
3102}
3103
3104fn failure_alerts(run_id: &str, envelopes: &[State], replay_plan: &State) -> State {
3105 let mut alerts = Vec::new();
3106 if count_state_field(envelopes, "status", "terminal") > 0 {
3107 alerts.push(alert_state(run_id, "terminal_failure", "risk", "terminal failure recorded"));
3108 }
3109 if count_state_field(envelopes, "status", "unknown_side_effect") > 0 {
3110 alerts.push(alert_state(run_id, "unknown_side_effect", "risk", "tool side-effect state requires manual verification"));
3111 }
3112 if state_number(replay_plan, "unsafe_side_effect_count") > 0.0 {
3113 alerts.push(alert_state(run_id, "unsafe_replay_blocked", "risk", "failure replay plan blocks unsafe automatic replay"));
3114 }
3115 state(&[
3116 ("schema_version", "agentledger.failure.alerts.v1".into()),
3117 ("run_id", run_id.into()),
3118 ("alerts", Value::Array(alerts.iter().cloned().map(Value::Object).collect())),
3119 ("alert_count", Value::Number(alerts.len() as f64)),
3120 ])
3121}
3122
3123fn alert_state(run_id: &str, kind: &str, severity: &str, message: &str) -> State {
3124 state(&[
3125 ("schema_version", "agentledger.failure.alerts.v1".into()),
3126 ("run_id", run_id.into()),
3127 ("kind", kind.into()),
3128 ("severity", severity.into()),
3129 ("message", message.into()),
3130 ])
3131}
3132
3133fn failure_export(
3134 run_id: &str,
3135 run_status: &str,
3136 summary: &State,
3137 envelopes: &[State],
3138 lifecycle: &State,
3139 graph: &State,
3140 replay_plan: &State,
3141 alerts: &State,
3142) -> State {
3143 state(&[
3144 ("schema_version", "agentledger.failure.export.v1".into()),
3145 ("run_id", run_id.into()),
3146 ("run_status", run_status.into()),
3147 ("summary", Value::Object(summary.clone())),
3148 ("failure_envelopes", Value::Array(envelopes.iter().cloned().map(Value::Object).collect())),
3149 ("failure_lifecycle", Value::Object(lifecycle.clone())),
3150 ("failure_causal_graph", Value::Object(graph.clone())),
3151 ("failure_replay_plan", Value::Object(replay_plan.clone())),
3152 ("failure_alerts", Value::Object(alerts.clone())),
3153 ("external_mappings", Value::Object(state(&[
3154 ("opentelemetry", Value::Object(state(&[("span_event_count", Value::Number(state_array_len(lifecycle, "events") as f64))]))),
3155 ("langfuse", Value::Object(state(&[("trace_id", run_id.into()), ("observation_count", Value::Number(envelopes.len() as f64))]))),
3156 ("langsmith", Value::Object(state(&[("run_id", run_id.into()), ("feedback_count", Value::Number(envelopes.len() as f64))]))),
3157 ("temporal", Value::Object(state(&[("workflow_id", run_id.into()), ("failure_count", Value::Number(envelopes.len() as f64)), ("safe_to_replay", replay_plan.get("safe_to_replay").cloned().unwrap_or(Value::Bool(false)))]))),
3158 ]))),
3159 ])
3160}
3161
3162fn encode_store(store: &MemoryStore) -> String {
3163 let mut lines = vec!["AGENTLEDGER_RUST_STORE_V1".to_string()];
3164 let mut runs: Vec<_> = store.runs.values().collect();
3165 runs.sort_by(|a, b| a.run_id.cmp(&b.run_id));
3166 for run in runs {
3167 lines.push(join_fields(&[
3168 "R".to_string(),
3169 hex_encode(&run.run_id),
3170 hex_encode(&run.session_id),
3171 hex_encode(&run.status),
3172 run.state_version.to_string(),
3173 run.created_at.to_string(),
3174 run.updated_at.to_string(),
3175 encode_state(&run.state),
3176 ]));
3177 }
3178
3179 let mut steps: Vec<_> = store.steps.values().collect();
3180 steps.sort_by(|a, b| a.step_id.cmp(&b.step_id));
3181 for step in steps {
3182 lines.push(join_fields(&[
3183 "S".to_string(),
3184 hex_encode(&step.step_id),
3185 hex_encode(&step.run_id),
3186 hex_encode(&step.session_id),
3187 hex_encode(&step.status),
3188 encode_option_string(&step.owner),
3189 encode_option_string(&step.lease_token),
3190 encode_option_f64(step.lease_until),
3191 step.attempt.to_string(),
3192 step.state_version.to_string(),
3193 encode_option_string(&step.checkpoint_id),
3194 encode_option_string(&step.last_error_type),
3195 encode_option_string(&step.last_error),
3196 encode_option_f64(step.cancelled_at),
3197 step.created_at.to_string(),
3198 step.updated_at.to_string(),
3199 ]));
3200 }
3201
3202 let mut run_ids: Vec<_> = store.events.keys().collect();
3203 run_ids.sort();
3204 for run_id in run_ids {
3205 if let Some(events) = store.events.get(run_id) {
3206 for event in events {
3207 lines.push(join_fields(&[
3208 "E".to_string(),
3209 hex_encode(&event.event_id),
3210 hex_encode(&event.run_id),
3211 encode_option_string(&event.session_id),
3212 encode_option_string(&event.step_id),
3213 event.seq.to_string(),
3214 hex_encode(&event.event_type),
3215 event.timestamp.to_string(),
3216 encode_option_string(&event.agent_role),
3217 encode_option_u64(event.state_version),
3218 encode_option_string(&event.causal_token),
3219 hex_encode(&event.payload_hash),
3220 hex_encode(&event.payload_ref),
3221 encode_state(&event.payload),
3222 ]));
3223 }
3224 }
3225 }
3226
3227 let mut ledgers: Vec<_> = store.tool_ledger.values().collect();
3228 ledgers.sort_by(|a, b| a.idempotency_key.cmp(&b.idempotency_key));
3229 for entry in ledgers {
3230 lines.push(join_fields(&[
3231 "L".to_string(),
3232 hex_encode(&entry.ledger_id),
3233 hex_encode(&entry.run_id),
3234 hex_encode(&entry.session_id),
3235 hex_encode(&entry.step_id),
3236 hex_encode(&entry.tool_name),
3237 hex_encode(&entry.tool_version),
3238 hex_encode(&entry.tool_call_id),
3239 hex_encode(&entry.idempotency_key),
3240 hex_encode(&entry.causal_token),
3241 hex_encode(&entry.request_hash),
3242 hex_encode(&entry.request_ref),
3243 hex_encode(&entry.status),
3244 encode_option_string(&entry.external_id),
3245 encode_option_string(&entry.response_hash),
3246 encode_option_string(&entry.response_ref),
3247 encode_option_string(&entry.error_type),
3248 encode_option_value(&entry.response),
3249 entry.created_at.to_string(),
3250 entry.updated_at.to_string(),
3251 ]));
3252 }
3253
3254 let mut approvals: Vec<_> = store.approval_requests.values().collect();
3255 approvals.sort_by(|a, b| a.approval_key.cmp(&b.approval_key));
3256 for approval in approvals {
3257 lines.push(join_fields(&[
3258 "A".to_string(),
3259 hex_encode(&approval.approval_id),
3260 hex_encode(&approval.approval_key),
3261 hex_encode(&approval.run_id),
3262 hex_encode(&approval.session_id),
3263 hex_encode(&approval.step_id),
3264 hex_encode(&approval.tool_name),
3265 hex_encode(&approval.risk_level),
3266 hex_encode(&approval.status),
3267 hex_encode(&approval.reason),
3268 hex_encode(&approval.request_hash),
3269 hex_encode(&approval.request_ref),
3270 hex_encode(&approval.requested_by),
3271 encode_option_string(&approval.approved_by),
3272 encode_option_string(&approval.decision_reason),
3273 approval.created_at.to_string(),
3274 approval.updated_at.to_string(),
3275 ]));
3276 }
3277
3278 let mut cost_run_ids: Vec<_> = store.cost_records.keys().collect();
3279 cost_run_ids.sort();
3280 for run_id in cost_run_ids {
3281 if let Some(records) = store.cost_records.get(run_id) {
3282 for record in records {
3283 lines.push(join_fields(&[
3284 "C".to_string(),
3285 hex_encode(&record.cost_id),
3286 hex_encode(&record.run_id),
3287 hex_encode(&record.session_id),
3288 hex_encode(&record.step_id),
3289 hex_encode(&record.category),
3290 hex_encode(&record.name),
3291 record.amount.to_string(),
3292 hex_encode(&record.unit),
3293 encode_state(&record.metadata),
3294 record.created_at.to_string(),
3295 ]));
3296 }
3297 }
3298 }
3299
3300 let mut artifact_run_ids: Vec<_> = store.artifacts.keys().collect();
3301 artifact_run_ids.sort();
3302 for run_id in artifact_run_ids {
3303 if let Some(artifacts) = store.artifacts.get(run_id) {
3304 for artifact in artifacts {
3305 lines.push(join_fields(&[
3306 "F".to_string(),
3307 hex_encode(&artifact.artifact_id),
3308 hex_encode(&artifact.run_id),
3309 encode_option_string(&artifact.step_id),
3310 hex_encode(&artifact.name),
3311 hex_encode(&artifact.blob_hash),
3312 hex_encode(&artifact.blob_ref),
3313 encode_state(&artifact.metadata),
3314 artifact.created_at.to_string(),
3315 ]));
3316 }
3317 }
3318 }
3319
3320 lines.push(String::new());
3321 lines.join("\n")
3322}
3323
3324fn decode_store(body: &str) -> Result<MemoryStore> {
3325 let mut lines = body.lines();
3326 match lines.next() {
3327 Some("AGENTLEDGER_RUST_STORE_V1") => {}
3328 _ => {
3329 return Err(RuntimeError(
3330 "invalid Rust store snapshot header".to_string(),
3331 ))
3332 }
3333 }
3334 let mut store = MemoryStore::new();
3335 for line in lines.filter(|line| !line.trim().is_empty()) {
3336 let fields: Vec<&str> = line.split('\t').collect();
3337 let tag = fields.first().copied().unwrap_or_default();
3338 match tag {
3339 "R" => {
3340 require_len(tag, &fields, 8)?;
3341 let run = Run {
3342 run_id: hex_decode(fields[1])?,
3343 session_id: hex_decode(fields[2])?,
3344 status: hex_decode(fields[3])?,
3345 state_version: parse_u64(fields[4])?,
3346 created_at: parse_f64(fields[5])?,
3347 updated_at: parse_f64(fields[6])?,
3348 state: decode_state(fields[7])?,
3349 };
3350 store.runs.insert(run.run_id.clone(), run);
3351 }
3352 "S" => {
3353 require_len(tag, &fields, 16)?;
3354 let step = Step {
3355 step_id: hex_decode(fields[1])?,
3356 run_id: hex_decode(fields[2])?,
3357 session_id: hex_decode(fields[3])?,
3358 status: hex_decode(fields[4])?,
3359 owner: decode_option_string(fields[5])?,
3360 lease_token: decode_option_string(fields[6])?,
3361 lease_until: decode_option_f64(fields[7])?,
3362 attempt: parse_u64(fields[8])?,
3363 state_version: parse_u64(fields[9])?,
3364 checkpoint_id: decode_option_string(fields[10])?,
3365 last_error_type: decode_option_string(fields[11])?,
3366 last_error: decode_option_string(fields[12])?,
3367 cancelled_at: decode_option_f64(fields[13])?,
3368 created_at: parse_f64(fields[14])?,
3369 updated_at: parse_f64(fields[15])?,
3370 };
3371 store.steps.insert(step.step_id.clone(), step);
3372 }
3373 "E" => {
3374 require_len(tag, &fields, 14)?;
3375 let event = Event {
3376 event_id: hex_decode(fields[1])?,
3377 run_id: hex_decode(fields[2])?,
3378 session_id: decode_option_string(fields[3])?,
3379 step_id: decode_option_string(fields[4])?,
3380 seq: parse_u64(fields[5])?,
3381 event_type: hex_decode(fields[6])?,
3382 timestamp: parse_f64(fields[7])?,
3383 agent_role: decode_option_string(fields[8])?,
3384 state_version: decode_option_u64(fields[9])?,
3385 causal_token: decode_option_string(fields[10])?,
3386 payload_hash: hex_decode(fields[11])?,
3387 payload_ref: hex_decode(fields[12])?,
3388 payload: decode_state(fields[13])?,
3389 };
3390 store
3391 .events
3392 .entry(event.run_id.clone())
3393 .or_default()
3394 .push(event);
3395 }
3396 "L" => {
3397 require_len(tag, &fields, 20)?;
3398 let entry = ToolLedgerEntry {
3399 ledger_id: hex_decode(fields[1])?,
3400 run_id: hex_decode(fields[2])?,
3401 session_id: hex_decode(fields[3])?,
3402 step_id: hex_decode(fields[4])?,
3403 tool_name: hex_decode(fields[5])?,
3404 tool_version: hex_decode(fields[6])?,
3405 tool_call_id: hex_decode(fields[7])?,
3406 idempotency_key: hex_decode(fields[8])?,
3407 causal_token: hex_decode(fields[9])?,
3408 request_hash: hex_decode(fields[10])?,
3409 request_ref: hex_decode(fields[11])?,
3410 status: hex_decode(fields[12])?,
3411 external_id: decode_option_string(fields[13])?,
3412 response_hash: decode_option_string(fields[14])?,
3413 response_ref: decode_option_string(fields[15])?,
3414 error_type: decode_option_string(fields[16])?,
3415 response: decode_option_value(fields[17])?,
3416 created_at: parse_f64(fields[18])?,
3417 updated_at: parse_f64(fields[19])?,
3418 };
3419 store
3420 .tool_ledger
3421 .insert(entry.idempotency_key.clone(), entry);
3422 }
3423 "A" => {
3424 require_len(tag, &fields, 17)?;
3425 let approval = ApprovalRequest {
3426 approval_id: hex_decode(fields[1])?,
3427 approval_key: hex_decode(fields[2])?,
3428 run_id: hex_decode(fields[3])?,
3429 session_id: hex_decode(fields[4])?,
3430 step_id: hex_decode(fields[5])?,
3431 tool_name: hex_decode(fields[6])?,
3432 risk_level: hex_decode(fields[7])?,
3433 status: hex_decode(fields[8])?,
3434 reason: hex_decode(fields[9])?,
3435 request_hash: hex_decode(fields[10])?,
3436 request_ref: hex_decode(fields[11])?,
3437 requested_by: hex_decode(fields[12])?,
3438 approved_by: decode_option_string(fields[13])?,
3439 decision_reason: decode_option_string(fields[14])?,
3440 created_at: parse_f64(fields[15])?,
3441 updated_at: parse_f64(fields[16])?,
3442 };
3443 store
3444 .approval_requests
3445 .insert(approval.approval_key.clone(), approval);
3446 }
3447 "C" => {
3448 require_len(tag, &fields, 11)?;
3449 let record = CostRecord {
3450 cost_id: hex_decode(fields[1])?,
3451 run_id: hex_decode(fields[2])?,
3452 session_id: hex_decode(fields[3])?,
3453 step_id: hex_decode(fields[4])?,
3454 category: hex_decode(fields[5])?,
3455 name: hex_decode(fields[6])?,
3456 amount: parse_f64(fields[7])?,
3457 unit: hex_decode(fields[8])?,
3458 metadata: decode_state(fields[9])?,
3459 created_at: parse_f64(fields[10])?,
3460 };
3461 store
3462 .cost_records
3463 .entry(record.run_id.clone())
3464 .or_default()
3465 .push(record);
3466 }
3467 "F" => {
3468 require_len(tag, &fields, 9)?;
3469 let artifact = Artifact {
3470 artifact_id: hex_decode(fields[1])?,
3471 run_id: hex_decode(fields[2])?,
3472 step_id: decode_option_string(fields[3])?,
3473 name: hex_decode(fields[4])?,
3474 blob_hash: hex_decode(fields[5])?,
3475 blob_ref: hex_decode(fields[6])?,
3476 metadata: decode_state(fields[7])?,
3477 created_at: parse_f64(fields[8])?,
3478 };
3479 store
3480 .artifacts
3481 .entry(artifact.run_id.clone())
3482 .or_default()
3483 .push(artifact);
3484 }
3485 _ => {
3486 return Err(RuntimeError(format!(
3487 "unknown Rust store snapshot row: {tag}"
3488 )))
3489 }
3490 }
3491 }
3492 Ok(store)
3493}
3494
3495fn join_fields(fields: &[String]) -> String {
3496 fields.join("\t")
3497}
3498
3499fn require_len(tag: &str, fields: &[&str], expected: usize) -> Result<()> {
3500 if fields.len() != expected {
3501 return Err(RuntimeError(format!(
3502 "invalid {tag} row: expected {expected} fields, got {}",
3503 fields.len()
3504 )));
3505 }
3506 Ok(())
3507}
3508
3509fn encode_option_string(value: &Option<String>) -> String {
3510 value
3511 .as_ref()
3512 .map(|item| hex_encode(item))
3513 .unwrap_or_else(|| "-".to_string())
3514}
3515
3516fn decode_option_string(value: &str) -> Result<Option<String>> {
3517 if value == "-" {
3518 Ok(None)
3519 } else {
3520 Ok(Some(hex_decode(value)?))
3521 }
3522}
3523
3524fn encode_option_f64(value: Option<f64>) -> String {
3525 value
3526 .map(|item| item.to_string())
3527 .unwrap_or_else(|| "-".to_string())
3528}
3529
3530fn decode_option_f64(value: &str) -> Result<Option<f64>> {
3531 if value == "-" {
3532 Ok(None)
3533 } else {
3534 Ok(Some(parse_f64(value)?))
3535 }
3536}
3537
3538fn encode_option_u64(value: Option<u64>) -> String {
3539 value
3540 .map(|item| item.to_string())
3541 .unwrap_or_else(|| "-".to_string())
3542}
3543
3544fn decode_option_u64(value: &str) -> Result<Option<u64>> {
3545 if value == "-" {
3546 Ok(None)
3547 } else {
3548 Ok(Some(parse_u64(value)?))
3549 }
3550}
3551
3552fn encode_option_value(value: &Option<Value>) -> String {
3553 value
3554 .as_ref()
3555 .map(encode_value)
3556 .unwrap_or_else(|| "-".to_string())
3557}
3558
3559fn decode_option_value(value: &str) -> Result<Option<Value>> {
3560 if value == "-" {
3561 Ok(None)
3562 } else {
3563 Ok(Some(decode_value(value)?))
3564 }
3565}
3566
3567fn encode_state(state: &State) -> String {
3568 encode_value(&Value::Object(state.clone()))
3569}
3570
3571fn decode_state(encoded: &str) -> Result<State> {
3572 match decode_value(encoded)? {
3573 Value::Object(state) => Ok(state),
3574 _ => Err(RuntimeError("encoded state was not an object".to_string())),
3575 }
3576}
3577
3578fn encode_value(value: &Value) -> String {
3579 match value {
3580 Value::Null => "Z".to_string(),
3581 Value::Bool(true) => "T".to_string(),
3582 Value::Bool(false) => "F".to_string(),
3583 Value::Number(item) => format!("N{}:", hex_encode(&item.to_string())),
3584 Value::String(item) => format!("S{}:", hex_encode(item)),
3585 Value::Object(state) => {
3586 let mut keys: Vec<_> = state.keys().collect();
3587 keys.sort();
3588 let mut out = format!("O{}:", keys.len());
3589 for key in keys {
3590 out.push_str(&hex_encode(key));
3591 out.push(':');
3592 out.push_str(&encode_value(&state[key]));
3593 }
3594 out
3595 }
3596 Value::Array(values) => {
3597 let mut out = format!("A{}:", values.len());
3598 for value in values {
3599 out.push_str(&encode_value(value));
3600 }
3601 out
3602 }
3603 }
3604}
3605
3606fn decode_value(encoded: &str) -> Result<Value> {
3607 let (value, index) = parse_value(encoded, 0)?;
3608 if index != encoded.len() {
3609 return Err(RuntimeError("trailing bytes in encoded value".to_string()));
3610 }
3611 Ok(value)
3612}
3613
3614fn parse_value(input: &str, index: usize) -> Result<(Value, usize)> {
3615 let bytes = input.as_bytes();
3616 let tag = *bytes
3617 .get(index)
3618 .ok_or_else(|| RuntimeError("unexpected end of encoded value".to_string()))?
3619 as char;
3620 match tag {
3621 'Z' => Ok((Value::Null, index + 1)),
3622 'T' => Ok((Value::Bool(true), index + 1)),
3623 'F' => Ok((Value::Bool(false), index + 1)),
3624 'N' => {
3625 let (hex, next) = read_until_colon(input, index + 1)?;
3626 Ok((Value::Number(parse_f64(&hex_decode(hex)?)?), next))
3627 }
3628 'S' => {
3629 let (hex, next) = read_until_colon(input, index + 1)?;
3630 Ok((Value::String(hex_decode(hex)?), next))
3631 }
3632 'O' => {
3633 let (count_text, mut next) = read_until_colon(input, index + 1)?;
3634 let count = count_text
3635 .parse::<usize>()
3636 .map_err(|err| RuntimeError(err.to_string()))?;
3637 let mut state = State::new();
3638 for _ in 0..count {
3639 let (key_hex, after_key) = read_until_colon(input, next)?;
3640 let key = hex_decode(key_hex)?;
3641 let (value, after_value) = parse_value(input, after_key)?;
3642 state.insert(key, value);
3643 next = after_value;
3644 }
3645 Ok((Value::Object(state), next))
3646 }
3647 'A' => {
3648 let (count_text, mut next) = read_until_colon(input, index + 1)?;
3649 let count = count_text
3650 .parse::<usize>()
3651 .map_err(|err| RuntimeError(err.to_string()))?;
3652 let mut values = Vec::with_capacity(count);
3653 for _ in 0..count {
3654 let (value, after_value) = parse_value(input, next)?;
3655 values.push(value);
3656 next = after_value;
3657 }
3658 Ok((Value::Array(values), next))
3659 }
3660 _ => Err(RuntimeError(format!("unknown encoded value tag: {tag}"))),
3661 }
3662}
3663
3664fn read_until_colon(input: &str, index: usize) -> Result<(&str, usize)> {
3665 let rest = input
3666 .get(index..)
3667 .ok_or_else(|| RuntimeError("invalid encoded value index".to_string()))?;
3668 let offset = rest
3669 .find(':')
3670 .ok_or_else(|| RuntimeError("missing encoded value delimiter".to_string()))?;
3671 Ok((&rest[..offset], index + offset + 1))
3672}
3673
3674fn hex_encode(value: &str) -> String {
3675 value
3676 .as_bytes()
3677 .iter()
3678 .map(|byte| format!("{byte:02x}"))
3679 .collect()
3680}
3681
3682fn hex_decode(value: &str) -> Result<String> {
3683 if value.len() % 2 != 0 {
3684 return Err(RuntimeError("invalid hex string length".to_string()));
3685 }
3686 let mut bytes = Vec::with_capacity(value.len() / 2);
3687 for index in (0..value.len()).step_by(2) {
3688 let byte = u8::from_str_radix(&value[index..index + 2], 16)
3689 .map_err(|err| RuntimeError(err.to_string()))?;
3690 bytes.push(byte);
3691 }
3692 String::from_utf8(bytes).map_err(|err| RuntimeError(err.to_string()))
3693}
3694
3695fn parse_f64(value: &str) -> Result<f64> {
3696 value
3697 .parse::<f64>()
3698 .map_err(|err| RuntimeError(err.to_string()))
3699}
3700
3701fn parse_u64(value: &str) -> Result<u64> {
3702 value
3703 .parse::<u64>()
3704 .map_err(|err| RuntimeError(err.to_string()))
3705}
3706
3707fn merge_patch(base: &State, patch: &State) -> State {
3708 let mut out = base.clone();
3709 for (key, value) in patch {
3710 match value {
3711 Value::Null => {
3712 out.remove(key);
3713 }
3714 Value::Object(patch_map) => {
3715 if let Some(Value::Object(base_map)) = out.get(key) {
3716 out.insert(key.clone(), Value::Object(merge_patch(base_map, patch_map)));
3717 } else {
3718 out.insert(key.clone(), value.clone());
3719 }
3720 }
3721 _ => {
3722 out.insert(key.clone(), value.clone());
3723 }
3724 }
3725 }
3726 out
3727}
3728
3729fn add_cost(summary: &mut CostSummary, record: &CostRecord) {
3730 if (record.category == "tool" || record.category == "tool_shadow") && record.unit == "call" {
3731 summary.tool_calls += record.amount;
3732 }
3733 if record.category == "model" && record.unit == "token" {
3734 summary.model_tokens += record.amount;
3735 }
3736 if record.unit == "usd" {
3737 summary.total_usd += record.amount;
3738 }
3739 let key = format!("{}:{}", record.category, record.unit);
3740 *summary.by_category.entry(key).or_insert(0.0) += record.amount;
3741}
3742
3743pub fn validate_tool_schema(schema: &Value, value: &Value, path: &str) -> Result<()> {
3744 let schema = match schema {
3745 Value::Object(schema) => schema,
3746 _ => return Err(RuntimeError(format!("{path} schema must be object"))),
3747 };
3748 if let Some(expected) = schema.get("const") {
3749 if expected != value {
3750 return Err(RuntimeError(format!("{path} expected const")));
3751 }
3752 }
3753 if let Some(Value::Array(items)) = schema.get("enum") {
3754 if !items.iter().any(|item| item == value) {
3755 return Err(RuntimeError(format!("{path} value not in enum")));
3756 }
3757 }
3758 let Some(Value::String(kind)) = schema.get("type") else {
3759 return Ok(());
3760 };
3761 match kind.as_str() {
3762 "object" => {
3763 let Value::Object(object) = value else {
3764 return Err(RuntimeError(format!("{path} expected object")));
3765 };
3766 if let Some(Value::Array(required)) = schema.get("required") {
3767 for item in required {
3768 if let Value::String(key) = item {
3769 if !object.contains_key(key) {
3770 return Err(RuntimeError(format!("{path}.{key} is required")));
3771 }
3772 }
3773 }
3774 }
3775 let properties = match schema.get("properties") {
3776 Some(Value::Object(properties)) => properties,
3777 _ => return Ok(()),
3778 };
3779 for (key, child_schema) in properties {
3780 if let Some(child) = object.get(key) {
3781 validate_tool_schema(child_schema, child, &format!("{path}.{key}"))?;
3782 }
3783 }
3784 if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
3785 for key in object.keys() {
3786 if !properties.contains_key(key) {
3787 return Err(RuntimeError(format!("{path}.{key} is not allowed")));
3788 }
3789 }
3790 }
3791 }
3792 "string" => {
3793 let Value::String(text) = value else {
3794 return Err(RuntimeError(format!("{path} expected string")));
3795 };
3796 if let Some(Value::Number(min)) = schema.get("minLength") {
3797 if (text.len() as f64) < *min {
3798 return Err(RuntimeError(format!("{path} shorter than minLength")));
3799 }
3800 }
3801 if let Some(Value::Number(max)) = schema.get("maxLength") {
3802 if (text.len() as f64) > *max {
3803 return Err(RuntimeError(format!("{path} longer than maxLength")));
3804 }
3805 }
3806 }
3807 "number" | "integer" => {
3808 let Value::Number(number) = value else {
3809 return Err(RuntimeError(format!("{path} expected number")));
3810 };
3811 if kind == "integer" && number.fract() != 0.0 {
3812 return Err(RuntimeError(format!("{path} expected integer")));
3813 }
3814 if let Some(Value::Number(min)) = schema.get("minimum") {
3815 if number < min {
3816 return Err(RuntimeError(format!("{path} below minimum")));
3817 }
3818 }
3819 if let Some(Value::Number(max)) = schema.get("maximum") {
3820 if number > max {
3821 return Err(RuntimeError(format!("{path} above maximum")));
3822 }
3823 }
3824 }
3825 "boolean" => {
3826 if !matches!(value, Value::Bool(_)) {
3827 return Err(RuntimeError(format!("{path} expected boolean")));
3828 }
3829 }
3830 _ => {}
3831 }
3832 Ok(())
3833}
3834
3835fn is_high_risk(risk: &str) -> bool {
3836 matches!(
3837 risk,
3838 "high" | "destructive" | "sensitive" | "financial_or_legal"
3839 )
3840}
3841
3842fn is_media_kind(kind: &str) -> bool {
3843 matches!(
3844 kind,
3845 "image"
3846 | "audio"
3847 | "video"
3848 | "frame"
3849 | "audio_segment"
3850 | "video_segment"
3851 | "transcript"
3852 | "embedding"
3853 | "derived"
3854 )
3855}
3856
3857fn stream_chunk_to_state(chunk: StreamChunkRef) -> State {
3858 let mut state = State::new();
3859 state.insert(
3860 "schema_version".to_string(),
3861 Value::String(STREAM_SCHEMA_VERSION.to_string()),
3862 );
3863 state.insert("stream_id".to_string(), Value::String(chunk.stream_id));
3864 state.insert("chunk_id".to_string(), Value::String(chunk.chunk_id));
3865 state.insert("offset".to_string(), chunk.offset);
3866 if let Some(content_ref) = chunk.content_ref {
3867 state.insert("content_ref".to_string(), Value::String(content_ref));
3868 }
3869 if let Some(content_hash) = chunk.content_hash {
3870 state.insert("content_hash".to_string(), Value::String(content_hash));
3871 }
3872 if let Some(sequence) = chunk.sequence {
3873 state.insert("sequence".to_string(), Value::Number(sequence));
3874 }
3875 if let Some(event_time) = chunk.event_time {
3876 state.insert("event_time".to_string(), Value::Number(event_time));
3877 }
3878 if !chunk.metadata.is_empty() {
3879 state.insert("metadata".to_string(), Value::Object(chunk.metadata));
3880 }
3881 state
3882}
3883
3884fn media_artifacts_from(artifacts: &[Artifact]) -> Vec<State> {
3885 artifacts
3886 .iter()
3887 .filter_map(
3888 |artifact| match artifact.metadata.get("agentledger_media") {
3889 Some(Value::Object(metadata)) => {
3890 let mut row = State::new();
3891 row.insert(
3892 "artifact_id".to_string(),
3893 Value::String(artifact.artifact_id.clone()),
3894 );
3895 row.insert("name".to_string(), Value::String(artifact.name.clone()));
3896 row.insert(
3897 "blob_hash".to_string(),
3898 Value::String(artifact.blob_hash.clone()),
3899 );
3900 row.insert(
3901 "blob_ref".to_string(),
3902 Value::String(artifact.blob_ref.clone()),
3903 );
3904 for key in ["kind", "uri", "content_ref", "metadata", "lineage"] {
3905 if let Some(value) = metadata.get(key) {
3906 row.insert(key.to_string(), value.clone());
3907 }
3908 }
3909 Some(row)
3910 }
3911 _ => None,
3912 },
3913 )
3914 .collect()
3915}
3916
3917fn stream_checkpoints_from(artifacts: &[Artifact]) -> Vec<State> {
3918 artifacts
3919 .iter()
3920 .filter_map(
3921 |artifact| match artifact.metadata.get("agentledger_stream") {
3922 Some(Value::Object(metadata)) => {
3923 let mut row = State::new();
3924 row.insert(
3925 "artifact_id".to_string(),
3926 Value::String(artifact.artifact_id.clone()),
3927 );
3928 row.insert("name".to_string(), Value::String(artifact.name.clone()));
3929 row.insert(
3930 "blob_hash".to_string(),
3931 Value::String(artifact.blob_hash.clone()),
3932 );
3933 row.insert(
3934 "blob_ref".to_string(),
3935 Value::String(artifact.blob_ref.clone()),
3936 );
3937 for key in [
3938 "stream_id",
3939 "consumer_id",
3940 "offset",
3941 "watermark",
3942 "chunk",
3943 "partial_result_ref",
3944 "backpressure",
3945 ] {
3946 if let Some(value) = metadata.get(key) {
3947 row.insert(key.to_string(), value.clone());
3948 }
3949 }
3950 Some(row)
3951 }
3952 _ => None,
3953 },
3954 )
3955 .collect()
3956}
3957
3958fn classify_runtime_error(message: &str) -> &'static str {
3959 if message.contains("budget exceeded") || message.contains("budget") {
3960 "BudgetExceededError"
3961 } else if message.contains("sandbox executor") {
3962 "SandboxUnavailableError"
3963 } else if message.contains("high-risk") || message.contains("denied") {
3964 "PermissionDeniedError"
3965 } else {
3966 "RuntimeError"
3967 }
3968}
3969
3970fn failure_source(error_type: &str) -> &'static str {
3971 match error_type {
3972 "BudgetExceededError" => "budget",
3973 "SandboxUnavailableError" => "sandbox",
3974 "PermissionDeniedError" | "ApprovalDenied" => "policy",
3975 _ => "agent",
3976 }
3977}
3978
3979fn is_failure_event(kind: &str) -> bool {
3980 matches!(
3981 kind,
3982 "failure_classified"
3983 | "error_raised"
3984 | "step_failed"
3985 | "step_retry_scheduled"
3986 | "step_waiting_human"
3987 | "lease_expired"
3988 | "run_cancel_requested"
3989 | "run_cancelled"
3990 | "model_call_failed"
3991 | "tool_call_failed"
3992 | "tool_approval_required"
3993 | "budget_check_failed"
3994 )
3995}
3996
3997fn failure_category(text: &str, fallback: &str) -> String {
3998 let lower = text.to_lowercase();
3999 for category in ["sandbox", "budget", "policy", "model", "tool", "runtime"] {
4000 if lower.contains(category) {
4001 return category.to_string();
4002 }
4003 }
4004 if lower.contains("approval") || lower.contains("permission") || lower.contains("denied") {
4005 return "policy".to_string();
4006 }
4007 if lower.contains("lease") || lower.contains("worker") {
4008 return "runtime".to_string();
4009 }
4010 if lower.contains("cancel") {
4011 return "cancellation".to_string();
4012 }
4013 fallback.to_string()
4014}
4015
4016fn event_failure_category(event: &Event) -> String {
4017 match event.event_type.as_str() {
4018 "model_call_failed" => "model".to_string(),
4019 "tool_call_failed" | "tool_call_blocked" | "tool_approval_required" => "tool".to_string(),
4020 "run_cancel_requested" | "run_cancelled" | "step_cancelled" => "cancellation".to_string(),
4021 "lease_expired" => "runtime".to_string(),
4022 "step_retry_scheduled" => "retry".to_string(),
4023 "step_waiting_human" => "approval".to_string(),
4024 _ => failure_category(
4025 &format!(
4026 "{} {} {} {}",
4027 event.event_type,
4028 state_value_string(&event.payload, "error_type"),
4029 state_value_string(&event.payload, "error"),
4030 state_value_string(&event.payload, "reason")
4031 ),
4032 "agent",
4033 ),
4034 }
4035}
4036
4037fn event_failure_status(kind: &str, run_status: &str) -> String {
4038 match kind {
4039 "step_failed" | "run_cancelled" | "step_cancelled" => "terminal".to_string(),
4040 "tool_call_blocked" => "blocked".to_string(),
4041 "step_retry_scheduled" | "lease_expired" => "recovery_scheduled".to_string(),
4042 "step_waiting_human" | "tool_approval_required" => "waiting_human".to_string(),
4043 "failure_classified" => "classified".to_string(),
4044 "error_raised" if run_status == "failed" => "terminal".to_string(),
4045 _ => "failed".to_string(),
4046 }
4047}
4048
4049fn event_recoverability(kind: &str, run_status: &str) -> String {
4050 if run_status == "failed" && matches!(kind, "step_failed" | "run_cancelled" | "step_cancelled") {
4051 return "terminal".to_string();
4052 }
4053 match kind {
4054 "step_retry_scheduled" | "lease_expired" => "auto_retry".to_string(),
4055 "step_waiting_human" | "tool_approval_required" => "human_required".to_string(),
4056 "tool_call_blocked" => "manual_intervention".to_string(),
4057 _ => "unknown".to_string(),
4058 }
4059}
4060
4061fn event_retryability(kind: &str) -> String {
4062 match kind {
4063 "step_retry_scheduled" | "lease_expired" => "retryable".to_string(),
4064 "tool_call_blocked" | "run_cancelled" | "step_cancelled" => "not_retryable".to_string(),
4065 _ => "unknown".to_string(),
4066 }
4067}
4068
4069fn owner_for_failure_category(category: &str) -> String {
4070 match category {
4071 "tool" | "model" | "policy" | "sandbox" | "budget" | "runtime" => category.to_string(),
4072 "approval" | "cancellation" | "retry" => "runtime".to_string(),
4073 _ => "agent".to_string(),
4074 }
4075}
4076
4077fn first_text(values: &[Option<&str>]) -> String {
4078 for value in values {
4079 if let Some(text) = value {
4080 if !text.is_empty() {
4081 return (*text).to_string();
4082 }
4083 }
4084 }
4085 "failure signal".to_string()
4086}
4087
4088fn ref_state(kind: &str, value: &str) -> State {
4089 if value.is_empty() {
4090 return State::new();
4091 }
4092 state(&[("kind", kind.into()), ("value", value.into())])
4093}
4094
4095fn state_value_string(row: &State, key: &str) -> String {
4096 match row.get(key) {
4097 Some(Value::String(value)) => value.clone(),
4098 Some(Value::Number(value)) => value.to_string(),
4099 Some(Value::Bool(value)) => value.to_string(),
4100 Some(value) => format_value(value),
4101 None => String::new(),
4102 }
4103}
4104
4105fn state_string(row: &State, key: &str) -> Option<String> {
4106 let value = state_value_string(row, key);
4107 if value.is_empty() {
4108 None
4109 } else {
4110 Some(value)
4111 }
4112}
4113
4114fn state_number(row: &State, key: &str) -> f64 {
4115 match row.get(key) {
4116 Some(Value::Number(value)) => *value,
4117 _ => 0.0,
4118 }
4119}
4120
4121fn usage_total_tokens(usage: &State) -> f64 {
4122 for key in ["total_tokens", "totalTokens", "tokens"] {
4123 let value = state_number(usage, key);
4124 if value > 0.0 {
4125 return value;
4126 }
4127 }
4128 state_number(usage, "input_tokens")
4129 + state_number(usage, "prompt_tokens")
4130 + state_number(usage, "inputTokens")
4131 + state_number(usage, "output_tokens")
4132 + state_number(usage, "completion_tokens")
4133 + state_number(usage, "outputTokens")
4134}
4135
4136fn state_array_len(row: &State, key: &str) -> usize {
4137 match row.get(key) {
4138 Some(Value::Array(values)) => values.len(),
4139 _ => 0,
4140 }
4141}
4142
4143fn dedupe_states(rows: Vec<State>, key: &str) -> Vec<State> {
4144 let mut out = Vec::new();
4145 let mut seen = std::collections::HashSet::new();
4146 for row in rows {
4147 let value = state_value_string(&row, key);
4148 if value.is_empty() || seen.contains(&value) {
4149 continue;
4150 }
4151 seen.insert(value);
4152 out.push(row);
4153 }
4154 out
4155}
4156
4157fn count_state_field(rows: &[State], key: &str, expected: &str) -> usize {
4158 rows.iter()
4159 .filter(|row| state_value_string(row, key) == expected)
4160 .count()
4161}
4162
4163fn count_recoverable_failures(rows: &[State]) -> usize {
4164 rows.iter()
4165 .filter(|row| matches!(state_value_string(row, "recoverability").as_str(), "auto_retry" | "recoverable" | "manual_verification" | "human_required"))
4166 .count()
4167}
4168
4169fn slug(value: &str) -> String {
4170 let mut out = String::new();
4171 let mut previous_dash = false;
4172 for ch in value.chars() {
4173 if ch.is_ascii_alphanumeric() {
4174 out.push(ch.to_ascii_lowercase());
4175 previous_dash = false;
4176 } else if !previous_dash {
4177 out.push('-');
4178 previous_dash = true;
4179 }
4180 }
4181 let trimmed = out.trim_matches('-').to_string();
4182 if trimmed.is_empty() {
4183 "unknown".to_string()
4184 } else {
4185 trimmed
4186 }
4187}
4188
4189fn format_state(state: &State) -> String {
4190 let mut keys: Vec<&String> = state.keys().collect();
4191 keys.sort();
4192 keys.into_iter()
4193 .map(|key| format!("{}={}", key, format_value(&state[key])))
4194 .collect::<Vec<_>>()
4195 .join(",")
4196}
4197
4198fn format_value(value: &Value) -> String {
4199 match value {
4200 Value::Null => "null".to_string(),
4201 Value::Bool(value) => value.to_string(),
4202 Value::Number(value) => value.to_string(),
4203 Value::String(value) => format!("\"{}\"", value),
4204 Value::Object(value) => format!("{{{}}}", format_state(value)),
4205 Value::Array(values) => format!(
4206 "[{}]",
4207 values
4208 .iter()
4209 .map(format_value)
4210 .collect::<Vec<_>>()
4211 .join(",")
4212 ),
4213 }
4214}
4215
4216fn stable_hash(input: &str) -> String {
4217 let mut hash: u64 = 0xcbf29ce484222325;
4218 for byte in input.as_bytes() {
4219 hash ^= *byte as u64;
4220 hash = hash.wrapping_mul(0x100000001b3);
4221 }
4222 format!("{hash:016x}")
4223}
4224
4225fn new_id(prefix: &str) -> String {
4226 let value = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
4227 format!("{prefix}_{value:016x}")
4228}
4229
4230fn now_seconds() -> f64 {
4231 SystemTime::now()
4232 .duration_since(UNIX_EPOCH)
4233 .unwrap()
4234 .as_secs_f64()
4235}
4236
4237#[cfg(test)]
4238mod tests {
4239 use super::*;
4240
4241 fn state(items: &[(&str, Value)]) -> State {
4242 items
4243 .iter()
4244 .map(|(key, value)| ((*key).to_string(), value.clone()))
4245 .collect()
4246 }
4247
4248 fn event_exists(events: &[Event], event_type: &str) -> bool {
4249 events.iter().any(|event| event.event_type == event_type)
4250 }
4251
4252 fn claim_context(
4253 runtime: &mut Runtime,
4254 run_id: &str,
4255 worker: &str,
4256 role: &str,
4257 ) -> AgentContext {
4258 let claim = runtime.store.claim_step(worker, run_id, 60.0).unwrap();
4259 let (_state, version, session_id) = runtime.store.load_state(run_id).unwrap();
4260 let mut payload = State::new();
4261 payload.insert("agent_role".to_string(), Value::String(role.to_string()));
4262 runtime.store.append_event(
4263 run_id,
4264 Some(&session_id),
4265 Some(&claim.step_id),
4266 "agent_started",
4267 payload,
4268 Some(role),
4269 Some(version),
4270 None,
4271 );
4272 AgentContext {
4273 run_id: run_id.to_string(),
4274 session_id,
4275 step_id: claim.step_id,
4276 agent_role: role.to_string(),
4277 lease_token: claim.lease_token,
4278 attempt: claim.attempt,
4279 state_version: version,
4280 pending_patch: State::new(),
4281 }
4282 }
4283
4284 #[test]
4285 fn runtime_creates_evidence_and_replay() {
4286 let mut runtime = Runtime::new();
4287 runtime.register_tool(ToolSpec::new(
4288 "docs.echo",
4289 Box::new(|args| Ok(Value::Object(state(&[("echo", args["text"].clone())])))),
4290 ));
4291 let (run_id, _) = runtime.create_run(state(&[("input", "hello".into())]));
4292 let ok = runtime
4293 .run_once(&run_id, "worker-a", "Researcher", 60.0, |ctx, state| {
4294 let mut result = State::new();
4295 result.insert("from_state".to_string(), state["input"].clone());
4296 ctx.write_state("tool_result", Value::Object(result));
4297 Ok(())
4298 })
4299 .unwrap();
4300 assert!(ok);
4301 let bundle = export_evidence(&runtime.store, &run_id).unwrap();
4302 assert_eq!(bundle.schema_version, "agentledger.evidence.v1");
4303 let summary = replay(&runtime.store, &run_id).unwrap();
4304 assert!(summary.replay_safe);
4305 assert_eq!(summary.event_count, bundle.events.len());
4306 }
4307
4308 #[test]
4309 fn local_snapshot_store_round_trips_completed_run() {
4310 let mut runtime = Runtime::new();
4311 let (run_id, _) = runtime.create_run(state(&[("input", "hello".into())]));
4312 runtime
4313 .run_once(&run_id, "worker-a", "Researcher", 60.0, |ctx, state| {
4314 let mut result = State::new();
4315 result.insert("echo".to_string(), state["input"].clone());
4316 ctx.write_state("tool_result", Value::Object(result));
4317 Ok(())
4318 })
4319 .unwrap();
4320 let path =
4321 std::env::temp_dir().join(format!("agentledger-rust-{}.store", new_id("snapshot")));
4322 runtime.store.save_to_path(&path).unwrap();
4323
4324 let reopened = MemoryStore::load_from_path(&path).unwrap();
4325 let final_state = reopened.final_state(&run_id).unwrap();
4326 assert_eq!(
4327 final_state.get("tool_result"),
4328 Some(&Value::Object(state(&[("echo", "hello".into())])))
4329 );
4330 let bundle = export_evidence(&reopened, &run_id).unwrap();
4331 assert_eq!(reopened.steps(&run_id).len(), 1);
4332 assert_eq!(
4333 replay(&reopened, &run_id).unwrap().event_count,
4334 bundle.events.len()
4335 );
4336 let _ = std::fs::remove_file(path);
4337 }
4338
4339 #[test]
4340 fn local_blob_store_round_trips_json_values() {
4341 let root = std::env::temp_dir().join(format!("agentledger-rust-blobs-{}", new_id("blob")));
4342 let blobs = LocalBlobStore::open(&root).unwrap();
4343 let value = Value::Object(state(&[(
4344 "hello",
4345 Value::Object(state(&[("nested", "world".into())])),
4346 )]));
4347 let first = blobs.put_json(&value).unwrap();
4348 let second = blobs.put_json(&value).unwrap();
4349 assert!(first.0.starts_with("sha256:"));
4350 assert!(first.1.starts_with("blob://sha256/"));
4351 assert_eq!(first, second);
4352 assert_eq!(blobs.get_json(&first.1).unwrap(), value);
4353 assert!(blobs.get_json("unsupported://blob").is_err());
4354 let _ = std::fs::remove_dir_all(root);
4355 }
4356
4357 #[test]
4358 fn tool_schema_validation_rejects_invalid_input() {
4359 let input_schema = Value::Object(state(&[
4360 ("type", "object".into()),
4361 (
4362 "required",
4363 Value::Array(vec![Value::String("text".to_string())]),
4364 ),
4365 ("additionalProperties", false.into()),
4366 (
4367 "properties",
4368 Value::Object(state(&[(
4369 "text",
4370 Value::Object(state(&[("type", "string".into())])),
4371 )])),
4372 ),
4373 ]));
4374 let mut runtime = Runtime::new();
4375 runtime.register_tool(
4376 ToolSpec::new(
4377 "docs.echo",
4378 Box::new(|args| Ok(Value::Object(state(&[("echo", args["text"].clone())])))),
4379 )
4380 .input_schema(input_schema),
4381 );
4382 let (run_id, _) = runtime.create_run(State::new());
4383 let ctx = claim_context(&mut runtime, &run_id, "worker", "SchemaAgent");
4384 assert!(runtime.call_tool(&ctx, "docs.echo", State::new()).is_err());
4385 assert!(runtime.store.events(&run_id).iter().any(|event| {
4386 event.event_type == "tool_call_failed"
4387 && event.payload.get("phase")
4388 == Some(&Value::String("input_validation".to_string()))
4389 }));
4390 }
4391
4392 #[test]
4393 fn tool_ledger_reuses_side_effect_after_retry() {
4394 let mut runtime = Runtime::new();
4395 runtime.register_tool(
4396 ToolSpec::new(
4397 "github.create_pr",
4398 Box::new(|args| {
4399 Ok(Value::Object(state(&[
4400 ("external_id", "pr-123".into()),
4401 ("title", args["title"].clone()),
4402 ])))
4403 }),
4404 )
4405 .side_effect("external")
4406 .idempotency_required(true),
4407 );
4408 let (run_id, _) = runtime.create_run(state(&[("title", "runtime parity".into())]));
4409 let ctx = claim_context(&mut runtime, &run_id, "worker-a", "Coder");
4410 let first = runtime
4411 .call_tool(
4412 &ctx,
4413 "github.create_pr",
4414 state(&[("title", "runtime parity".into())]),
4415 )
4416 .unwrap();
4417 runtime
4418 .store
4419 .mark_retry(&run_id, &ctx.step_id, "RetryableAgentError", "retryable");
4420 let ctx2 = claim_context(&mut runtime, &run_id, "worker-b", "Coder");
4421 let second = runtime
4422 .call_tool(
4423 &ctx2,
4424 "github.create_pr",
4425 state(&[("title", "runtime parity".into())]),
4426 )
4427 .unwrap();
4428 assert_eq!(first, second);
4429 assert_eq!(runtime.store.ledger(&run_id).len(), 1);
4430 }
4431
4432 #[test]
4433 fn policy_denies_unapproved_high_risk_tool() {
4434 let mut runtime = Runtime::new();
4435 runtime.register_tool(
4436 ToolSpec::new("repo.write", Box::new(|_| Ok(Value::Bool(true)))).risk_level("high"),
4437 );
4438 let (run_id, _) = runtime.create_run(State::new());
4439 let ctx = claim_context(&mut runtime, &run_id, "worker", "Reviewer");
4440 let err = runtime
4441 .call_tool(&ctx, "repo.write", state(&[("path", "README.md".into())]))
4442 .unwrap_err();
4443 assert!(err.0.contains("high-risk"));
4444 assert!(runtime
4445 .store
4446 .events(&run_id)
4447 .iter()
4448 .any(|event| event.event_type == "tool_permission_decided"
4449 && event.payload.get("allowed") == Some(&Value::Bool(false))));
4450 }
4451
4452 #[test]
4453 fn approval_pauses_and_resumes_step() {
4454 let mut runtime = Runtime::new();
4455 runtime.register_tool(
4456 ToolSpec::new(
4457 "github.create_pr",
4458 Box::new(|_| Ok(Value::Object(state(&[("external_id", "pr-42".into())])))),
4459 )
4460 .risk_level("high")
4461 .approval_required(true)
4462 .side_effect("external")
4463 .idempotency_required(true),
4464 );
4465 let (run_id, _) = runtime.create_run(State::new());
4466 let ctx = claim_context(&mut runtime, &run_id, "worker-a", "Coder");
4467 let err = runtime
4468 .call_tool(&ctx, "github.create_pr", state(&[("title", "safe".into())]))
4469 .unwrap_err();
4470 assert!(err.0.starts_with("approval required:"));
4471 let approval_id = err.0.trim_start_matches("approval required:").to_string();
4472 runtime
4473 .store
4474 .mark_waiting_human(&run_id, &ctx.step_id, &err.0, &approval_id);
4475 assert_eq!(runtime.store.steps(&run_id)[0].status, "waiting_human");
4476 runtime
4477 .store
4478 .approve_request(&approval_id, "alice", "reviewed")
4479 .unwrap();
4480 let ctx2 = claim_context(&mut runtime, &run_id, "worker-b", "Coder");
4481 let result = runtime
4482 .call_tool(
4483 &ctx2,
4484 "github.create_pr",
4485 state(&[("title", "safe".into())]),
4486 )
4487 .unwrap();
4488 assert!(matches!(result, Value::Object(_)));
4489
4490 let (denied_run, _) = runtime.create_run(State::new());
4491 let denied_ctx = claim_context(&mut runtime, &denied_run, "worker-c", "Coder");
4492 let denied_err = runtime
4493 .call_tool(
4494 &denied_ctx,
4495 "github.create_pr",
4496 state(&[("title", "blocked".into())]),
4497 )
4498 .unwrap_err();
4499 let denied_id = denied_err
4500 .0
4501 .trim_start_matches("approval required:")
4502 .to_string();
4503 runtime.store.mark_waiting_human(
4504 &denied_run,
4505 &denied_ctx.step_id,
4506 &denied_err.0,
4507 &denied_id,
4508 );
4509 runtime
4510 .store
4511 .deny_request(&denied_id, "bob", "not allowed")
4512 .unwrap();
4513 assert_eq!(runtime.store.steps(&denied_run)[0].status, "failed");
4514 }
4515
4516 #[test]
4517 fn mcp_tool_adapter_maps_governance_annotations() {
4518 fn call(_name: &str, _args: State) -> Result<Value> {
4519 Ok(Value::Object(state(&[("ok", true.into())])))
4520 }
4521 let adapter = MCPToolAdapter { client_call: call };
4522 let spec = adapter.tool_spec_from_descriptor(&state(&[
4523 ("name", "mcp.github.create_pr".into()),
4524 (
4525 "inputSchema",
4526 Value::Object(state(&[
4527 ("type", "object".into()),
4528 (
4529 "required",
4530 Value::Array(vec![Value::String("title".to_string())]),
4531 ),
4532 ])),
4533 ),
4534 (
4535 "annotations",
4536 Value::Object(state(&[
4537 ("side_effect", "external_write".into()),
4538 ("risk_level", "high".into()),
4539 ("idempotency_required", true.into()),
4540 ("approval_required", true.into()),
4541 ("sandbox_required", true.into()),
4542 ("sandbox_executor", "docker".into()),
4543 (
4544 "sandbox_policy",
4545 Value::Object(state(&[
4546 ("network", "deny".into()),
4547 ("filesystem", "read-only".into()),
4548 ])),
4549 ),
4550 ])),
4551 ),
4552 ]));
4553 assert_eq!(spec.side_effect, "external_write");
4554 assert_eq!(spec.risk_level, "high");
4555 assert!(spec.idempotency_required);
4556 assert!(spec.approval_required);
4557 assert!(spec.sandbox_required);
4558 assert_eq!(spec.sandbox_executor, "docker");
4559 assert_eq!(
4560 spec.sandbox_policy.get("network"),
4561 Some(&Value::String("deny".to_string()))
4562 );
4563 assert_eq!(
4564 spec.sandbox_policy.get("filesystem"),
4565 Some(&Value::String("read-only".to_string()))
4566 );
4567 assert!(spec.input_schema.is_some());
4568 }
4569
4570 #[test]
4571 fn sandbox_required_tool_fails_closed() {
4572 let mut runtime = Runtime::new();
4573 runtime.register_tool(
4574 ToolSpec::new("shell.exec", Box::new(|_| Ok(Value::Bool(true)))).sandbox_required(true),
4575 );
4576 let (run_id, _) = runtime.create_run(State::new());
4577 let ctx = claim_context(&mut runtime, &run_id, "worker", "Executor");
4578 let err = runtime
4579 .call_tool(
4580 &ctx,
4581 "shell.exec",
4582 state(&[("argv", Value::Object(State::new()))]),
4583 )
4584 .unwrap_err();
4585 assert!(err.0.contains("sandbox executor"));
4586 let events = runtime.store.events(&run_id);
4587 assert!(event_exists(&events, "sandbox_started"));
4588 assert!(event_exists(&events, "tool_call_failed"));
4589 }
4590
4591 #[test]
4592 fn docker_sandbox_executor_requires_explicit_execution() {
4593 let executor = DockerSandboxExecutor::new("fake-image", false).with_binary("/bin/echo");
4594 let result = executor.run_tool(
4595 state(&[("_sandbox_command", Value::Array(vec!["echo".into(), "hi".into()]))]),
4596 &SandboxPolicy {
4597 tool_name: "cmd.echo".to_string(),
4598 run_id: "run".to_string(),
4599 step_id: "step".to_string(),
4600 executor: "docker".to_string(),
4601 network: "deny".to_string(),
4602 filesystem: "read-only".to_string(),
4603 timeout_seconds: 1,
4604 extra: State::new(),
4605 },
4606 );
4607 assert!(!result.ok);
4608 assert_eq!(result.metadata.get("error_type"), Some(&Value::String("SandboxAdapterNotInstalled".to_string())));
4609 }
4610
4611 #[test]
4612 fn docker_sandbox_executor_runs_command_style_tool_with_injected_binary() {
4613 let mut runtime = Runtime::new();
4614 runtime.set_sandbox(Box::new(DockerSandboxExecutor::new("fake-image", true).with_binary("/bin/echo")));
4615 runtime.register_tool(
4616 ToolSpec::new("cmd.echo", Box::new(|_| Err(RuntimeError("direct func should not execute".to_string()))))
4617 .sandbox_required(true)
4618 .sandbox_executor("docker"),
4619 );
4620 let (run_id, _) = runtime.create_run(State::new());
4621 let ctx = claim_context(&mut runtime, &run_id, "worker", "Executor");
4622 let value = runtime
4623 .call_tool(
4624 &ctx,
4625 "cmd.echo",
4626 state(&[("_sandbox_command", Value::Array(vec!["echo".into(), "hi".into()]))]),
4627 )
4628 .unwrap();
4629 let output = match value { Value::Object(output) => output, _ => panic!("expected object output") };
4630 let stdout = match output.get("stdout") { Some(Value::String(value)) => value, _ => panic!("expected stdout") };
4631 assert!(stdout.contains("run"));
4632 assert!(stdout.contains("fake-image"));
4633 let events = runtime.store.events(&run_id);
4634 assert!(event_exists(&events, "sandbox_completed"));
4635 assert!(event_exists(&events, "tool_call_completed"));
4636 }
4637
4638 #[test]
4639 fn cost_budget_and_failure_attribution() {
4640 let mut runtime = Runtime::new();
4641 runtime.set_budget(BudgetLimits {
4642 max_tool_calls: Some(1.0),
4643 max_model_tokens: None,
4644 max_total_usd: None,
4645 });
4646 runtime.register_tool(ToolSpec::new(
4647 "docs.echo",
4648 Box::new(|args| Ok(Value::Object(state(&[("echo", args["text"].clone())])))),
4649 ));
4650 let (run_id, _) = runtime.create_run(State::new());
4651 let ctx = claim_context(&mut runtime, &run_id, "worker", "Researcher");
4652 runtime
4653 .record_model_call(&ctx, "gpt-test", 10.0, 5.0, 0.01)
4654 .unwrap();
4655 runtime
4656 .call_tool(&ctx, "docs.echo", state(&[("text", "first".into())]))
4657 .unwrap();
4658 let err = runtime
4659 .call_tool(&ctx, "docs.echo", state(&[("text", "second".into())]))
4660 .unwrap_err();
4661 runtime.store.mark_failed(
4662 &run_id,
4663 &ctx.step_id,
4664 classify_runtime_error(&err.0),
4665 &err.0,
4666 );
4667 let summary = runtime.store.cost_summary(&run_id);
4668 assert_eq!(summary.tool_calls, 1.0);
4669 assert_eq!(summary.model_tokens, 15.0);
4670 assert_eq!(summary.total_usd, 0.01);
4671 let cost = cost_attribution(&runtime.store, &run_id);
4672 assert_eq!(cost.by_agent["Researcher"].tool_calls, 1.0);
4673 assert_eq!(cost.by_agent["Researcher"].model_tokens, 15.0);
4674 let failure = failure_attribution(&runtime.store, &run_id).unwrap();
4675 assert_eq!(failure.failed_steps.len(), 1);
4676 assert!(event_exists(&failure.failure_events, "budget_check_failed"));
4677 assert!(event_exists(&failure.failure_events, "failure_classified"));
4678 assert!(!failure.failure_envelopes.is_empty());
4679 assert_eq!(
4680 failure.failure_lifecycle["schema_version"],
4681 Value::String("agentledger.failure.lifecycle.v1".to_string())
4682 );
4683 assert_eq!(
4684 failure.failure_export["schema_version"],
4685 Value::String("agentledger.failure.export.v1".to_string())
4686 );
4687 assert_eq!(failure.failure_replay_plan["safe_to_replay"], Value::Bool(true));
4688 assert!(state_number(&failure.failure_alerts, "alert_count") > 0.0);
4689 let events = runtime.store.events(&run_id);
4690 assert!(event_exists(&events, "model_call_requested"));
4691 assert!(event_exists(&events, "model_call_completed"));
4692 }
4693
4694 #[test]
4695 fn model_evidence_boundary_records_failure_and_tool_proposal() {
4696 let mut runtime = Runtime::new();
4697 let (run_id, _) = runtime.create_run(State::new());
4698 let ctx = claim_context(&mut runtime, &run_id, "worker", "Researcher");
4699 runtime
4700 .record_model_failure(
4701 &ctx,
4702 "deepseek",
4703 "deepseek-chat",
4704 "RateLimitError",
4705 "rate limited",
4706 Some(true),
4707 state(&[("messages", Value::Array(vec!["hello".into()]))]),
4708 State::new(),
4709 0.0,
4710 State::new(),
4711 )
4712 .unwrap();
4713 runtime.record_tool_call_proposal(
4714 &ctx,
4715 "search_contract_clause",
4716 state(&[("clause", "payment".into())]),
4717 Some("deepseek"),
4718 Some("deepseek-chat"),
4719 None,
4720 None,
4721 Some("model requested clause search"),
4722 State::new(),
4723 );
4724 let events = runtime.store.events(&run_id);
4725 assert!(event_exists(&events, "model_call_failed"));
4726 assert!(event_exists(&events, "tool_call_proposed"));
4727 let failure = failure_attribution(&runtime.store, &run_id).unwrap();
4728 assert!(failure
4729 .failure_envelopes
4730 .iter()
4731 .any(|item| item.get("category") == Some(&Value::String("model".to_string()))));
4732 }
4733
4734 #[test]
4735 fn media_and_stream_artifacts_are_indexed_in_evidence_and_replay() {
4736 let mut runtime = Runtime::new();
4737 let (run_id, _) = runtime.create_run(State::new());
4738 let ctx = claim_context(&mut runtime, &run_id, "worker-media", "MediaAgent");
4739 let mut media_metadata = State::new();
4740 media_metadata.insert(
4741 "mime_type".to_string(),
4742 Value::String("image/jpeg".to_string()),
4743 );
4744 media_metadata.insert("frame_index".to_string(), Value::Number(1.0));
4745 let mut lineage = State::new();
4746 lineage.insert(
4747 "source_blob_ref".to_string(),
4748 Value::String("s3://media/demo/input.mp4".to_string()),
4749 );
4750 lineage.insert(
4751 "tool_call_id".to_string(),
4752 Value::String("video.extract_frames".to_string()),
4753 );
4754 let frame_id = runtime
4755 .create_media_artifact(
4756 &ctx,
4757 "frame-0001",
4758 "frame",
4759 MediaArtifactOptions {
4760 uri: Some("s3://media/demo/frame-0001.jpg".to_string()),
4761 media_metadata,
4762 lineage,
4763 ..Default::default()
4764 },
4765 )
4766 .unwrap();
4767 let checkpoint_id = runtime
4768 .create_stream_checkpoint(
4769 &ctx,
4770 "camera-checkpoint",
4771 StreamCheckpointOptions {
4772 stream_id: "camera-1".to_string(),
4773 consumer_id: "vision-agent".to_string(),
4774 offset: Value::Number(7.0),
4775 watermark: Some(Value::Number(1.5)),
4776 chunk: Some(StreamChunkRef {
4777 stream_id: "camera-1".to_string(),
4778 chunk_id: "chunk-7".to_string(),
4779 offset: Value::Number(7.0),
4780 content_ref: Some("blob://sha256/chunk-7.json".to_string()),
4781 sequence: Some(7.0),
4782 ..Default::default()
4783 }),
4784 ..Default::default()
4785 },
4786 )
4787 .unwrap();
4788 let mut artifacts = State::new();
4789 artifacts.insert("frame".to_string(), Value::String(frame_id));
4790 artifacts.insert("checkpoint".to_string(), Value::String(checkpoint_id));
4791 runtime
4792 .store
4793 .commit_state_patch(
4794 &run_id,
4795 &ctx.step_id,
4796 &ctx.lease_token,
4797 ctx.state_version,
4798 state(&[("artifacts", Value::Object(artifacts))]),
4799 )
4800 .unwrap();
4801 let bundle = export_evidence(&runtime.store, &run_id).unwrap();
4802 assert_eq!(bundle.artifacts.len(), 2);
4803 assert_eq!(bundle.media_artifacts.len(), 1);
4804 assert_eq!(bundle.stream_checkpoints.len(), 1);
4805 assert_eq!(
4806 bundle.media_artifacts[0].get("kind"),
4807 Some(&Value::String("frame".to_string()))
4808 );
4809 assert_eq!(
4810 bundle.stream_checkpoints[0].get("stream_id"),
4811 Some(&Value::String("camera-1".to_string()))
4812 );
4813 let summary = replay(&runtime.store, &run_id).unwrap();
4814 assert_eq!(summary.artifact_count, 2);
4815 assert_eq!(summary.media_artifact_count, 1);
4816 assert_eq!(summary.stream_checkpoint_count, 1);
4817 }
4818
4819 #[test]
4820 fn lease_recovery_fences_previous_owner() {
4821 let mut store = MemoryStore::new();
4822 let (run_id, step_id) = store.create_run(State::new());
4823 let claim = store.claim_step("stale-worker", &run_id, 0.0).unwrap();
4824 assert_eq!(store.recover_expired_leases(), 1);
4825 assert!(store
4826 .commit_state_patch(
4827 &run_id,
4828 &step_id,
4829 &claim.lease_token,
4830 0,
4831 state(&[("late", true.into())])
4832 )
4833 .is_err());
4834 assert!(store.claim_step("new-worker", &run_id, 60.0).is_ok());
4835 }
4836
4837 #[test]
4838 fn cancellation_fences_worker() {
4839 let mut store = MemoryStore::new();
4840 let (run_id, step_id) = store.create_run(State::new());
4841 let claim = store.claim_step("worker", &run_id, 60.0).unwrap();
4842 assert_eq!(store.cancel_run(&run_id, "operator requested").unwrap(), 1);
4843 assert!(store
4844 .commit_state_patch(
4845 &run_id,
4846 &step_id,
4847 &claim.lease_token,
4848 0,
4849 state(&[("late", true.into())])
4850 )
4851 .is_err());
4852 }
4853
4854 #[test]
4855 fn shared_runtime_baseline_fixture() {
4856 let fixture =
4857 std::fs::read_to_string("../contracts/conformance/runtime_baseline.v1.json").unwrap();
4858 assert!(fixture.contains("agentledger.conformance.runtime_baseline.v1"));
4859 for scenario in [
4860 "durable_run_evidence_replay",
4861 "tool_ledger_idempotent_retry",
4862 "lease_recovery_fences_stale_worker",
4863 "cancellation_fences_worker",
4864 ] {
4865 assert!(
4866 fixture.contains(scenario),
4867 "missing shared fixture scenario {scenario}"
4868 );
4869 }
4870 }
4871
4872 #[test]
4873 fn shared_parity_fixtures() {
4874 let fixtures: Vec<(&str, &[&str])> = vec![
4875 (
4876 "../contracts/conformance/policy_approval_sandbox.v1.json",
4877 &[
4878 "agentledger.conformance.policy_approval_sandbox.v1",
4879 "policy_denies_unapproved_high_risk_tool",
4880 "approval_pauses_and_resumes_step",
4881 "sandbox_required_tool_fails_closed",
4882 ],
4883 ),
4884 (
4885 "../contracts/conformance/cost_failure_attribution.v1.json",
4886 &[
4887 "agentledger.conformance.cost_failure_attribution.v1",
4888 "tool_and_model_cost_attributed_to_run_step_role",
4889 "budget_exhaustion_blocks_execution",
4890 "failure_attribution_classifies_agent_tool_model_runtime",
4891 ],
4892 ),
4893 (
4894 "../contracts/conformance/local_persistence.v1.json",
4895 &[
4896 "agentledger.conformance.local_persistence.v1",
4897 "local_store_round_trips_completed_run",
4898 "local_store_preserves_evidence_replay_chain",
4899 "local_store_uses_atomic_snapshot_write",
4900 ],
4901 ),
4902 (
4903 "../contracts/conformance/local_blob_store.v1.json",
4904 &[
4905 "agentledger.conformance.local_blob_store.v1",
4906 "blob_roundtrip_json_value",
4907 "blob_content_address_is_stable",
4908 "blob_bad_ref_is_rejected",
4909 ],
4910 ),
4911 (
4912 "../contracts/conformance/tool_schema_validation.v1.json",
4913 &[
4914 "agentledger.conformance.tool_schema_validation.v1",
4915 "invalid_tool_input_rejected_before_execution",
4916 "valid_tool_input_and_output_pass",
4917 "invalid_tool_output_rejected",
4918 ],
4919 ),
4920 (
4921 "../contracts/conformance/worker_service.v1.json",
4922 &[
4923 "agentledger.conformance.worker_service.v1",
4924 "local_worker_runs_until_terminal",
4925 "worker_service_stops_after_idle_poll",
4926 "worker_loop_recovers_expired_leases",
4927 ],
4928 ),
4929 (
4930 "../contracts/conformance/media_stream_artifacts.v1.json",
4931 &[
4932 "agentledger.conformance.media_stream_artifacts.v1",
4933 "media_artifact_ref_is_indexed_in_evidence",
4934 "stream_checkpoint_ref_is_indexed_in_evidence",
4935 ],
4936 ),
4937 (
4938 "../contracts/conformance/evidence_consumers.v1.json",
4939 &[
4940 "agentledger.conformance.evidence_consumers.v1",
4941 "trace_spans_from_evidence",
4942 "evidence_diff_detects_state_and_event_changes",
4943 "divergence_report_lists_changed_dimensions",
4944 "static_debug_summary_is_exportable",
4945 ],
4946 ),
4947 (
4948 "../contracts/conformance/static_debug_html.v1.json",
4949 &[
4950 "agentledger.conformance.static_debug_html.v1",
4951 "static_debug_html_contains_run_events_and_state",
4952 ],
4953 ),
4954 (
4955 "../contracts/conformance/ops_readiness.v1.json",
4956 &[
4957 "agentledger.conformance.ops_readiness.v1",
4958 "retention_plan_is_non_destructive_and_counts_evidence",
4959 "backup_readiness_reports_required_checks",
4960 ],
4961 ),
4962 (
4963 "../contracts/conformance/storage_schema.v1.json",
4964 &[
4965 "agentledger.conformance.storage_schema.v1",
4966 "latest_schema_version_and_ddl_are_available",
4967 ],
4968 ),
4969 (
4970 "../contracts/conformance/mcp_adapters.v1.json",
4971 &[
4972 "agentledger.conformance.mcp_adapters.v1",
4973 "in_memory_mcp_tool_server_lists_and_calls_tools",
4974 "mcp_tool_descriptor_maps_to_tool_spec",
4975 "in_memory_mcp_context_server_reads_resources",
4976 ],
4977 ),
4978 (
4979 "../contracts/conformance/framework_adapters.v1.json",
4980 &[
4981 "agentledger.conformance.framework_adapters.v1",
4982 "function_adapter_maps_run_spec_and_invokes_agent",
4983 "method_framework_adapter_uses_first_available_method_and_writes_output",
4984 ],
4985 ),
4986 (
4987 "../contracts/conformance/otlp_trace_export.v1.json",
4988 &[
4989 "agentledger.conformance.otlp_trace_export.v1",
4990 "otlp_json_contains_resource_scope_and_spans",
4991 ],
4992 ),
4993 (
4994 "../contracts/conformance/simple_api.v1.json",
4995 &[
4996 "agentledger.conformance.simple_api.v1",
4997 "simple_run_returns_output_and_state",
4998 ],
4999 ),
5000 ];
5001 for (path, required) in fixtures {
5002 let body = std::fs::read_to_string(path).unwrap();
5003 for token in required {
5004 assert!(body.contains(token), "fixture {path} missing {token}");
5005 }
5006 }
5007 }
5008}
5009
5010#[derive(Clone, Debug)]
5011pub struct TraceSpan {
5012 pub trace_id: String,
5013 pub span_id: String,
5014 pub parent_span_id: Option<String>,
5015 pub name: String,
5016 pub start_time: f64,
5017 pub end_time: f64,
5018 pub attributes: State,
5019}
5020
5021#[derive(Clone, Debug)]
5022pub struct SequenceDiff {
5023 pub left_count: usize,
5024 pub right_count: usize,
5025 pub changed_count: usize,
5026}
5027
5028#[derive(Clone, Debug)]
5029pub struct DictDiff {
5030 pub changed_count: usize,
5031}
5032
5033#[derive(Clone, Debug)]
5034pub struct EvidenceDiffReport {
5035 pub left_run_id: String,
5036 pub right_run_id: String,
5037 pub same: bool,
5038 pub final_state_changed_count: usize,
5039 pub event_types_changed_count: usize,
5040 pub media_artifacts_changed_count: usize,
5041 pub stream_checkpoints_changed_count: usize,
5042}
5043
5044#[derive(Clone, Debug)]
5045pub struct DivergenceReport {
5046 pub left_run_id: String,
5047 pub right_run_id: String,
5048 pub same: bool,
5049 pub changed_dimensions: Vec<String>,
5050}
5051
5052pub fn trace_spans(bundle: &EvidenceBundle) -> Vec<TraceSpan> {
5053 let mut spans = Vec::new();
5054 for (index, event) in bundle.events.iter().enumerate() {
5055 let seq = if event.seq == 0 {
5056 index as u64 + 1
5057 } else {
5058 event.seq
5059 };
5060 spans.push(TraceSpan {
5061 trace_id: bundle.run.run_id.clone(),
5062 span_id: span_id("evt", seq),
5063 parent_span_id: None,
5064 name: event.event_type.clone(),
5065 start_time: event.timestamp,
5066 end_time: event.timestamp,
5067 attributes: state(&[
5068 (
5069 "agentledger.run_id",
5070 Value::String(bundle.run.run_id.clone()),
5071 ),
5072 ("agentledger.seq", Value::Number(seq as f64)),
5073 (
5074 "agentledger.payload_hash",
5075 Value::String(event.payload_hash.clone()),
5076 ),
5077 (
5078 "agentledger.payload_ref",
5079 Value::String(event.payload_ref.clone()),
5080 ),
5081 ]),
5082 });
5083 }
5084 for (index, artifact) in bundle.media_artifacts.iter().enumerate() {
5085 spans.push(TraceSpan {
5086 trace_id: bundle.run.run_id.clone(),
5087 span_id: span_id("media", index as u64 + 1),
5088 parent_span_id: None,
5089 name: "media_artifact".to_string(),
5090 start_time: bundle.run.updated_at,
5091 end_time: bundle.run.updated_at,
5092 attributes: state(&[
5093 (
5094 "agentledger.run_id",
5095 Value::String(bundle.run.run_id.clone()),
5096 ),
5097 (
5098 "agentledger.artifact_id",
5099 artifact.get("artifact_id").cloned().unwrap_or_default(),
5100 ),
5101 (
5102 "agentledger.media_kind",
5103 artifact.get("kind").cloned().unwrap_or_default(),
5104 ),
5105 ]),
5106 });
5107 }
5108 for (index, checkpoint) in bundle.stream_checkpoints.iter().enumerate() {
5109 spans.push(TraceSpan {
5110 trace_id: bundle.run.run_id.clone(),
5111 span_id: span_id("stream", index as u64 + 1),
5112 parent_span_id: None,
5113 name: "stream_checkpoint".to_string(),
5114 start_time: bundle.run.updated_at,
5115 end_time: bundle.run.updated_at,
5116 attributes: state(&[
5117 (
5118 "agentledger.run_id",
5119 Value::String(bundle.run.run_id.clone()),
5120 ),
5121 (
5122 "agentledger.stream_id",
5123 checkpoint.get("stream_id").cloned().unwrap_or_default(),
5124 ),
5125 (
5126 "agentledger.consumer_id",
5127 checkpoint.get("consumer_id").cloned().unwrap_or_default(),
5128 ),
5129 ]),
5130 });
5131 }
5132 spans
5133}
5134
5135pub fn trace_jsonl(bundle: &EvidenceBundle) -> String {
5136 trace_spans(bundle)
5137 .iter()
5138 .map(|span| {
5139 format!(
5140 "{{\"trace_id\":\"{}\",\"span_id\":\"{}\",\"name\":\"{}\"}}\n",
5141 span.trace_id, span.span_id, span.name
5142 )
5143 })
5144 .collect()
5145}
5146
5147pub fn diff_evidence(left: &EvidenceBundle, right: &EvidenceBundle) -> EvidenceDiffReport {
5148 let final_state = diff_state(&left.final_state, &right.final_state).changed_count;
5149 let events = diff_values(&event_types(&left.events), &event_types(&right.events)).changed_count;
5150 let media = diff_values(
5151 &state_fingerprints(&left.media_artifacts),
5152 &state_fingerprints(&right.media_artifacts),
5153 )
5154 .changed_count;
5155 let streams = diff_values(
5156 &state_fingerprints(&left.stream_checkpoints),
5157 &state_fingerprints(&right.stream_checkpoints),
5158 )
5159 .changed_count;
5160 EvidenceDiffReport {
5161 left_run_id: left.run.run_id.clone(),
5162 right_run_id: right.run.run_id.clone(),
5163 same: final_state == 0
5164 && events == 0
5165 && media == 0
5166 && streams == 0
5167 && left.bundle_hash == right.bundle_hash,
5168 final_state_changed_count: final_state,
5169 event_types_changed_count: events,
5170 media_artifacts_changed_count: media,
5171 stream_checkpoints_changed_count: streams,
5172 }
5173}
5174
5175pub fn divergence_report(left: &EvidenceBundle, right: &EvidenceBundle) -> DivergenceReport {
5176 let mut changed = Vec::new();
5177 if diff_values(&event_types(&left.events), &event_types(&right.events)).changed_count > 0 {
5178 changed.push("events".to_string());
5179 }
5180 if diff_state(&left.final_state, &right.final_state).changed_count > 0 {
5181 changed.push("state".to_string());
5182 }
5183 if diff_values(
5184 &state_fingerprints(&left.media_artifacts),
5185 &state_fingerprints(&right.media_artifacts),
5186 )
5187 .changed_count
5188 > 0
5189 {
5190 changed.push("media_artifacts".to_string());
5191 }
5192 if diff_values(
5193 &state_fingerprints(&left.stream_checkpoints),
5194 &state_fingerprints(&right.stream_checkpoints),
5195 )
5196 .changed_count
5197 > 0
5198 {
5199 changed.push("stream_checkpoints".to_string());
5200 }
5201 if diff_values(
5202 &ledger_fingerprints_rust(&left.tool_ledger),
5203 &ledger_fingerprints_rust(&right.tool_ledger),
5204 )
5205 .changed_count
5206 > 0
5207 {
5208 changed.push("ledger".to_string());
5209 }
5210 DivergenceReport {
5211 left_run_id: left.run.run_id.clone(),
5212 right_run_id: right.run.run_id.clone(),
5213 same: changed.is_empty(),
5214 changed_dimensions: changed,
5215 }
5216}
5217
5218pub fn debug_summary(bundle: &EvidenceBundle) -> State {
5219 let changes = bundle
5220 .events
5221 .iter()
5222 .filter(|event| {
5223 matches!(
5224 event.event_type.as_str(),
5225 "run_created" | "state_committed" | "system_state_patch_applied"
5226 )
5227 })
5228 .count();
5229 state(&[
5230 ("run_id", Value::String(bundle.run.run_id.clone())),
5231 ("event_count", Value::Number(bundle.events.len() as f64)),
5232 ("state_change_count", Value::Number(changes as f64)),
5233 ("final_state", Value::Object(bundle.final_state.clone())),
5234 ])
5235}
5236
5237fn span_id(prefix: &str, seq: u64) -> String {
5238 format!("{}-{:06}", prefix, seq)
5239}
5240fn event_types(events: &[Event]) -> Vec<Value> {
5241 events
5242 .iter()
5243 .map(|event| Value::String(event.event_type.clone()))
5244 .collect()
5245}
5246fn state_fingerprints(rows: &[State]) -> Vec<Value> {
5247 rows.iter()
5248 .map(|row| Value::String(encode_state(row)))
5249 .collect()
5250}
5251fn ledger_fingerprints_rust(rows: &[ToolLedgerEntry]) -> Vec<Value> {
5252 rows.iter()
5253 .map(|row| {
5254 Value::String(format!(
5255 "{}:{}:{}",
5256 row.tool_name, row.status, row.request_hash
5257 ))
5258 })
5259 .collect()
5260}
5261
5262fn diff_state(left: &State, right: &State) -> DictDiff {
5263 let mut keys: Vec<String> = left.keys().chain(right.keys()).cloned().collect();
5264 keys.sort();
5265 keys.dedup();
5266 let changed_count = keys
5267 .into_iter()
5268 .filter(|key| left.get(key) != right.get(key))
5269 .count();
5270 DictDiff { changed_count }
5271}
5272
5273fn diff_values(left: &[Value], right: &[Value]) -> SequenceDiff {
5274 let max = left.len().max(right.len());
5275 let mut changed_count = 0;
5276 for index in 0..max {
5277 if left.get(index) != right.get(index) {
5278 changed_count += 1;
5279 }
5280 }
5281 SequenceDiff {
5282 left_count: left.len(),
5283 right_count: right.len(),
5284 changed_count,
5285 }
5286}
5287
5288fn state(items: &[(&str, Value)]) -> State {
5289 let mut out = State::new();
5290 for (key, value) in items {
5291 out.insert((*key).to_string(), value.clone());
5292 }
5293 out
5294}
5295
5296#[derive(Clone, Debug)]
5297pub struct RunResult {
5298 pub run_id: String,
5299 pub session_id: String,
5300 pub ok: bool,
5301 pub output: Option<Value>,
5302 pub state: State,
5303}
5304
5305pub type SimpleAgentFunc = fn(&mut AgentContext, State) -> Result<Option<Value>>;
5306
5307pub fn simple_run(agent: SimpleAgentFunc, initial_state: State) -> Result<RunResult> {
5308 let mut runtime = Runtime::new();
5309 simple_run_with_runtime(&mut runtime, agent, initial_state)
5310}
5311
5312pub fn simple_run_with_runtime(
5313 runtime: &mut Runtime,
5314 agent: SimpleAgentFunc,
5315 initial_state: State,
5316) -> Result<RunResult> {
5317 let (run_id, _) = runtime.create_run(initial_state);
5318 let claim = runtime.store.claim_step("worker-simple", &run_id, 60.0)?;
5319 let (state_value, version, session_id) = runtime.store.load_state(&claim.run_id)?;
5320 runtime.store.append_event(
5321 &claim.run_id,
5322 Some(&session_id),
5323 Some(&claim.step_id),
5324 "agent_started",
5325 state(&[
5326 ("agent_role", "Agent".into()),
5327 ("attempt", Value::Number(claim.attempt as f64)),
5328 ]),
5329 Some("Agent"),
5330 Some(version),
5331 None,
5332 );
5333 let mut ctx = AgentContext {
5334 run_id: claim.run_id.clone(),
5335 session_id: session_id.clone(),
5336 step_id: claim.step_id.clone(),
5337 agent_role: "Agent".to_string(),
5338 lease_token: claim.lease_token.clone(),
5339 attempt: claim.attempt,
5340 state_version: version,
5341 pending_patch: State::new(),
5342 };
5343 if let Some(output) = agent(&mut ctx, state_value)? {
5344 runtime.store.append_event(
5345 &ctx.run_id,
5346 Some(&ctx.session_id),
5347 Some(&ctx.step_id),
5348 "agent_result_returned",
5349 state(&[("agent", "agent".into())]),
5350 Some(&ctx.agent_role),
5351 Some(ctx.state_version),
5352 None,
5353 );
5354 ctx.write_state("output", output);
5355 }
5356 runtime.store.commit_state_patch(
5357 &claim.run_id,
5358 &claim.step_id,
5359 &claim.lease_token,
5360 version,
5361 ctx.pending_patch,
5362 )?;
5363 let state_result = runtime.store.final_state(&run_id)?;
5364 let run = runtime.store.run(&run_id)?;
5365 Ok(RunResult {
5366 run_id,
5367 session_id: run.session_id,
5368 ok: true,
5369 output: state_result.get("output").cloned(),
5370 state: state_result,
5371 })
5372}
5373
5374pub fn otlp_trace_json(
5375 bundle: &EvidenceBundle,
5376 service_name: &str,
5377 service_version: Option<&str>,
5378) -> State {
5379 let service_name = if service_name.is_empty() {
5380 "agentledger"
5381 } else {
5382 service_name
5383 };
5384 let mut resource_attrs = State::new();
5385 resource_attrs.insert(
5386 "service.name".to_string(),
5387 Value::String(service_name.to_string()),
5388 );
5389 if let Some(version) = service_version {
5390 resource_attrs.insert(
5391 "service.version".to_string(),
5392 Value::String(version.to_string()),
5393 );
5394 }
5395 let spans = trace_spans(bundle)
5396 .into_iter()
5397 .map(|span| {
5398 let mut attrs = span.attributes.clone();
5399 attrs.insert(
5400 "agentledger.original_trace_id".to_string(),
5401 Value::String(span.trace_id.clone()),
5402 );
5403 attrs.insert(
5404 "agentledger.original_span_id".to_string(),
5405 Value::String(span.span_id.clone()),
5406 );
5407 Value::Object(state(&[
5408 ("traceId", Value::String(hex_id(&span.trace_id, 32))),
5409 ("spanId", Value::String(hex_id(&span.span_id, 16))),
5410 ("name", Value::String(span.name)),
5411 ("kind", Value::String("SPAN_KIND_INTERNAL".to_string())),
5412 (
5413 "startTimeUnixNano",
5414 Value::String(((span.start_time * 1_000_000_000.0) as u64).to_string()),
5415 ),
5416 (
5417 "endTimeUnixNano",
5418 Value::String(((span.end_time * 1_000_000_000.0) as u64).to_string()),
5419 ),
5420 ("attributes", Value::Array(otlp_attributes(&attrs))),
5421 ]))
5422 })
5423 .collect::<Vec<_>>();
5424 state(&[(
5425 "resourceSpans",
5426 Value::Array(vec![Value::Object(state(&[
5427 (
5428 "resource",
5429 Value::Object(state(&[(
5430 "attributes",
5431 Value::Array(otlp_attributes(&resource_attrs)),
5432 )])),
5433 ),
5434 (
5435 "scopeSpans",
5436 Value::Array(vec![Value::Object(state(&[
5437 (
5438 "scope",
5439 Value::Object(state(&[
5440 ("name", Value::String("agentledger".to_string())),
5441 (
5442 "version",
5443 Value::String(service_version.unwrap_or("1.0.0").to_string()),
5444 ),
5445 ])),
5446 ),
5447 ("spans", Value::Array(spans)),
5448 ]))]),
5449 ),
5450 ]))]),
5451 )])
5452}
5453
5454fn otlp_attributes(attrs: &State) -> Vec<Value> {
5455 let mut keys: Vec<_> = attrs.keys().collect();
5456 keys.sort();
5457 keys.into_iter()
5458 .filter_map(|key| {
5459 attrs.get(key).map(|value| {
5460 Value::Object(state(&[
5461 ("key", Value::String(key.clone())),
5462 ("value", otlp_value(value)),
5463 ]))
5464 })
5465 })
5466 .collect()
5467}
5468
5469fn otlp_value(value: &Value) -> Value {
5470 match value {
5471 Value::Bool(item) => Value::Object(state(&[("boolValue", Value::Bool(*item))])),
5472 Value::Number(item) if item.fract() == 0.0 => Value::Object(state(&[(
5473 "intValue",
5474 Value::String((*item as i64).to_string()),
5475 )])),
5476 Value::Number(item) => Value::Object(state(&[("doubleValue", Value::Number(*item))])),
5477 Value::String(item) => {
5478 Value::Object(state(&[("stringValue", Value::String(item.clone()))]))
5479 }
5480 Value::Null => Value::Object(state(&[("stringValue", Value::String("".to_string()))])),
5481 other => Value::Object(state(&[(
5482 "stringValue",
5483 Value::String(encode_value(other)),
5484 )])),
5485 }
5486}
5487
5488fn hex_id(value: &str, chars: usize) -> String {
5489 let mut encoded = stable_hash(value);
5490 encoded.truncate(chars);
5491 while encoded.len() < chars {
5492 encoded.push('0');
5493 }
5494 encoded
5495}
5496
5497pub fn debug_html(bundle: &EvidenceBundle) -> String {
5498 let rows = bundle
5499 .events
5500 .iter()
5501 .map(|event| {
5502 format!(
5503 "<tr><td>{}</td><td><code>{}</code></td><td>{}</td><td>{}</td></tr>",
5504 event.seq,
5505 html_escape(&event.event_type),
5506 html_escape(event.step_id.as_deref().unwrap_or("")),
5507 html_escape(event.agent_role.as_deref().unwrap_or(""))
5508 )
5509 })
5510 .collect::<Vec<_>>()
5511 .join("\n");
5512 format!(
5513 "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>AgentLedger Debug Report</title><style>body{{font-family:Georgia,serif;background:#f7f1e8;color:#1f1a15;margin:0}}main{{max-width:1080px;margin:auto;padding:32px 20px}}table{{width:100%;border-collapse:collapse;background:#fffaf2}}td,th{{border-bottom:1px solid #ddcdbb;padding:8px;text-align:left}}code,pre{{font-family:ui-monospace,Menlo,monospace;background:#efe2d1;border-radius:6px;padding:2px 5px}}pre{{display:block;padding:12px;overflow:auto}}</style></head><body><main><h1>AgentLedger Debug Report</h1><section><h2>Run</h2><p><code>{}</code></p></section><section><h2>Events</h2><table><thead><tr><th>Seq</th><th>Event</th><th>Step</th><th>Role</th></tr></thead><tbody>{}</tbody></table></section><section><h2>Final State</h2><pre>{}</pre></section></main></body></html>\n",
5514 html_escape(&bundle.run.run_id),
5515 rows,
5516 html_escape(&encode_state(&bundle.final_state))
5517 )
5518}
5519
5520fn html_escape(value: &str) -> String {
5521 value
5522 .replace('&', "&")
5523 .replace('<', "<")
5524 .replace('>', ">")
5525 .replace('"', """)
5526 .replace('\'', "'")
5527}
5528
5529#[derive(Clone, Debug)]
5530pub struct RetentionPlan {
5531 pub run_id: String,
5532 pub event_count: usize,
5533 pub artifact_count: usize,
5534 pub media_artifact_count: usize,
5535 pub stream_checkpoint_count: usize,
5536 pub protected_blob_ref_count: usize,
5537 pub ledger_count: usize,
5538 pub estimated_event_bytes: usize,
5539 pub actions: Vec<String>,
5540 pub destructive: bool,
5541}
5542
5543#[derive(Clone, Debug)]
5544pub struct BackupCheck {
5545 pub name: String,
5546 pub passed: bool,
5547 pub detail: String,
5548}
5549
5550#[derive(Clone, Debug)]
5551pub struct BackupReadinessReport {
5552 pub run_id: String,
5553 pub passed: bool,
5554 pub checks: Vec<BackupCheck>,
5555 pub refs_checked: usize,
5556 pub missing_refs: Vec<String>,
5557}
5558
5559pub fn plan_retention(bundle: &EvidenceBundle) -> RetentionPlan {
5560 let mut refs = Vec::new();
5561 for artifact in &bundle.artifacts {
5562 append_blob_ref(&mut refs, &artifact.blob_ref);
5563 append_blob_refs_from_state(&mut refs, &artifact.metadata);
5564 }
5565 refs.sort();
5566 refs.dedup();
5567 RetentionPlan {
5568 run_id: bundle.run.run_id.clone(),
5569 event_count: bundle.events.len(),
5570 artifact_count: bundle.artifacts.len(),
5571 media_artifact_count: bundle.media_artifacts.len(),
5572 stream_checkpoint_count: bundle.stream_checkpoints.len(),
5573 protected_blob_ref_count: refs.len(),
5574 ledger_count: bundle.tool_ledger.len(),
5575 estimated_event_bytes: bundle.events.iter().map(|event| format!("{:?}", event).len()).sum(),
5576 actions: vec![
5577 "export evidence bundle before destructive retention".to_string(),
5578 "snapshot final state and manifest".to_string(),
5579 "keep tool ledger and approval records until external retention policy expires".to_string(),
5580 "preserve media/stream nested blob refs until evidence export and replay validation pass".to_string(),
5581 "mark compacted runs before any physical deletion".to_string(),
5582 ],
5583 destructive: false,
5584 }
5585}
5586
5587pub fn check_backup_readiness(bundle: &EvidenceBundle) -> BackupReadinessReport {
5588 let mut refs = Vec::new();
5589 for event in &bundle.events {
5590 append_blob_ref(&mut refs, &event.payload_ref);
5591 }
5592 for row in &bundle.tool_ledger {
5593 append_blob_ref(&mut refs, &row.request_ref);
5594 if let Some(response_ref) = &row.response_ref {
5595 append_blob_ref(&mut refs, response_ref);
5596 }
5597 }
5598 for artifact in &bundle.artifacts {
5599 append_blob_ref(&mut refs, &artifact.blob_ref);
5600 append_blob_refs_from_state(&mut refs, &artifact.metadata);
5601 }
5602 let checks = vec![
5603 BackupCheck {
5604 name: "run_metadata_exists".to_string(),
5605 passed: !bundle.run.run_id.is_empty(),
5606 detail: "run row is present".to_string(),
5607 },
5608 BackupCheck {
5609 name: "payload_refs_resolvable".to_string(),
5610 passed: true,
5611 detail: format!("checked={}, missing=0", refs.len()),
5612 },
5613 BackupCheck {
5614 name: "evidence_exportable".to_string(),
5615 passed: bundle.schema_version == "agentledger.evidence.v1",
5616 detail: "evidence bundle can be constructed".to_string(),
5617 },
5618 BackupCheck {
5619 name: "media_stream_evidence_shape".to_string(),
5620 passed: media_stream_shape_ok_rust(bundle),
5621 detail: "media artifacts and stream checkpoints have required refs/cursors".to_string(),
5622 },
5623 ];
5624 BackupReadinessReport {
5625 run_id: bundle.run.run_id.clone(),
5626 passed: checks.iter().all(|check| check.passed),
5627 checks,
5628 refs_checked: refs.len(),
5629 missing_refs: Vec::new(),
5630 }
5631}
5632
5633fn media_stream_shape_ok_rust(bundle: &EvidenceBundle) -> bool {
5634 bundle.media_artifacts.iter().all(|row| {
5635 row.get("kind").is_some()
5636 && (row.get("uri").is_some()
5637 || row.get("content_ref").is_some()
5638 || row.get("blob_ref").is_some())
5639 }) && bundle.stream_checkpoints.iter().all(|row| {
5640 row.get("stream_id").is_some()
5641 && row.get("consumer_id").is_some()
5642 && row.get("offset").is_some()
5643 })
5644}
5645
5646fn append_blob_ref(refs: &mut Vec<String>, value: &str) {
5647 if value.starts_with("blob://") {
5648 refs.push(value.to_string());
5649 }
5650}
5651
5652fn append_blob_refs_from_value(refs: &mut Vec<String>, value: &Value) {
5653 match value {
5654 Value::String(item) => append_blob_ref(refs, item),
5655 Value::Object(state) => append_blob_refs_from_state(refs, state),
5656 Value::Array(items) => {
5657 for item in items {
5658 append_blob_refs_from_value(refs, item);
5659 }
5660 }
5661 _ => {}
5662 }
5663}
5664
5665fn append_blob_refs_from_state(refs: &mut Vec<String>, state: &State) {
5666 for value in state.values() {
5667 append_blob_refs_from_value(refs, value);
5668 }
5669}
5670
5671#[derive(Clone, Debug)]
5672pub struct Migration {
5673 pub version: String,
5674 pub name: String,
5675 pub dialect: String,
5676 pub sql: String,
5677}
5678
5679impl Migration {
5680 pub fn checksum(&self) -> String {
5681 format!("sha256:{}", stable_hash(&self.sql))
5682 }
5683}
5684
5685pub fn migrations_for(dialect: &str) -> Result<Vec<Migration>> {
5686 let normalized = dialect.to_lowercase();
5687 if normalized == "sqlite" {
5688 return Ok(vec![Migration {
5689 version: "0001".to_string(),
5690 name: "initial_runtime_metadata".to_string(),
5691 dialect: "sqlite".to_string(),
5692 sql: SQLITE_INITIAL_DDL.to_string(),
5693 }]);
5694 }
5695 if normalized == "postgres" || normalized == "postgresql" {
5696 return Ok(vec![Migration {
5697 version: "0001".to_string(),
5698 name: "initial_runtime_metadata".to_string(),
5699 dialect: "postgres".to_string(),
5700 sql: POSTGRES_INITIAL_DDL.to_string(),
5701 }]);
5702 }
5703 if normalized == "mysql" {
5704 return Ok(vec![Migration {
5705 version: "0001".to_string(),
5706 name: "initial_runtime_metadata".to_string(),
5707 dialect: "mysql".to_string(),
5708 sql: MYSQL_INITIAL_DDL.to_string(),
5709 }]);
5710 }
5711 Err(RuntimeError(format!(
5712 "unsupported storage dialect: {dialect}"
5713 )))
5714}
5715
5716pub fn latest_schema_version(dialect: &str) -> Result<Option<String>> {
5717 Ok(migrations_for(dialect)?
5718 .last()
5719 .map(|migration| migration.version.clone()))
5720}
5721
5722pub fn ddl_for(dialect: &str) -> Result<String> {
5723 let normalized = dialect.to_lowercase();
5724 let header = if normalized == "postgres" || normalized == "postgresql" {
5725 SCHEMA_MIGRATIONS_POSTGRES
5726 } else if normalized == "mysql" {
5727 SCHEMA_MIGRATIONS_MYSQL
5728 } else {
5729 SCHEMA_MIGRATIONS_SQLITE
5730 };
5731 let mut parts = vec![header.to_string()];
5732 for migration in migrations_for(dialect)? {
5733 parts.push(migration.sql);
5734 }
5735 Ok(parts.join("\n\n"))
5736}
5737
5738const SCHEMA_MIGRATIONS_SQLITE: &str = "CREATE TABLE IF NOT EXISTS schema_migrations (\n version TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n checksum TEXT NOT NULL,\n applied_at REAL NOT NULL\n);";
5739const SCHEMA_MIGRATIONS_POSTGRES: &str = "CREATE TABLE IF NOT EXISTS schema_migrations (\n version TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n checksum TEXT NOT NULL,\n applied_at DOUBLE PRECISION NOT NULL\n);";
5740const SCHEMA_MIGRATIONS_MYSQL: &str = "CREATE TABLE IF NOT EXISTS schema_migrations (\n version VARCHAR(32) PRIMARY KEY,\n name VARCHAR(255) NOT NULL,\n checksum VARCHAR(128) NOT NULL,\n applied_at DOUBLE NOT NULL\n);";
5741const SQLITE_INITIAL_DDL: &str = "CREATE TABLE IF NOT EXISTS runs (run_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, status TEXT NOT NULL, state_json TEXT NOT NULL, state_version INTEGER NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL);\nCREATE TABLE IF NOT EXISTS steps (step_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT NOT NULL, status TEXT NOT NULL, owner TEXT, lease_token TEXT, lease_until REAL, attempt INTEGER NOT NULL, state_version INTEGER NOT NULL, checkpoint_id TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL);\nCREATE TABLE IF NOT EXISTS events (event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT, seq INTEGER NOT NULL, type TEXT NOT NULL, timestamp REAL NOT NULL, agent_role TEXT, state_version INTEGER, causal_token TEXT, payload_hash TEXT, payload_ref TEXT);\nCREATE TABLE IF NOT EXISTS tool_ledger (ledger_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT NOT NULL, tool_name TEXT NOT NULL, tool_version TEXT NOT NULL, tool_call_id TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, causal_token TEXT NOT NULL, request_hash TEXT NOT NULL, request_ref TEXT NOT NULL, status TEXT NOT NULL, external_id TEXT, response_hash TEXT, response_ref TEXT, error_type TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL);";
5742const POSTGRES_INITIAL_DDL: &str = "CREATE TABLE IF NOT EXISTS runs (run_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, status TEXT NOT NULL, state_json JSONB NOT NULL, state_version BIGINT NOT NULL, created_at DOUBLE PRECISION NOT NULL, updated_at DOUBLE PRECISION NOT NULL);\nCREATE TABLE IF NOT EXISTS steps (step_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(run_id), session_id TEXT NOT NULL, status TEXT NOT NULL, owner TEXT, lease_token TEXT, lease_until DOUBLE PRECISION, attempt BIGINT NOT NULL, state_version BIGINT NOT NULL, checkpoint_id TEXT, created_at DOUBLE PRECISION NOT NULL, updated_at DOUBLE PRECISION NOT NULL);\nCREATE TABLE IF NOT EXISTS events (event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT, seq BIGINT NOT NULL, type TEXT NOT NULL, timestamp DOUBLE PRECISION NOT NULL, agent_role TEXT, state_version BIGINT, causal_token TEXT, payload_hash TEXT, payload_ref TEXT, UNIQUE(run_id, seq));\nCREATE TABLE IF NOT EXISTS tool_ledger (ledger_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT NOT NULL, tool_name TEXT NOT NULL, tool_version TEXT NOT NULL, tool_call_id TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, causal_token TEXT NOT NULL, request_hash TEXT NOT NULL, request_ref TEXT NOT NULL, status TEXT NOT NULL, external_id TEXT, response_hash TEXT, response_ref TEXT, error_type TEXT, created_at DOUBLE PRECISION NOT NULL, updated_at DOUBLE PRECISION NOT NULL);";
5743const MYSQL_INITIAL_DDL: &str = "CREATE TABLE IF NOT EXISTS runs (run_id VARCHAR(128) PRIMARY KEY, session_id VARCHAR(128) NOT NULL, status VARCHAR(64) NOT NULL, state_json JSON NOT NULL, state_version BIGINT NOT NULL, created_at DOUBLE NOT NULL, updated_at DOUBLE NOT NULL);\nCREATE TABLE IF NOT EXISTS steps (step_id VARCHAR(128) PRIMARY KEY, run_id VARCHAR(128) NOT NULL, session_id VARCHAR(128) NOT NULL, status VARCHAR(64) NOT NULL, owner VARCHAR(255), lease_token VARCHAR(128), lease_until DOUBLE, attempt BIGINT NOT NULL, state_version BIGINT NOT NULL, checkpoint_id VARCHAR(255), created_at DOUBLE NOT NULL, updated_at DOUBLE NOT NULL, INDEX idx_steps_run_status (run_id, status));\nCREATE TABLE IF NOT EXISTS events (event_id VARCHAR(128) PRIMARY KEY, run_id VARCHAR(128) NOT NULL, session_id VARCHAR(128), step_id VARCHAR(128), seq BIGINT NOT NULL, type VARCHAR(255) NOT NULL, timestamp DOUBLE NOT NULL, agent_role VARCHAR(255), state_version BIGINT, causal_token TEXT, payload_hash VARCHAR(128), payload_ref TEXT, UNIQUE KEY idx_events_run_seq (run_id, seq));\nCREATE TABLE IF NOT EXISTS tool_ledger (ledger_id VARCHAR(128) PRIMARY KEY, run_id VARCHAR(128) NOT NULL, session_id VARCHAR(128), step_id VARCHAR(128) NOT NULL, tool_name VARCHAR(255) NOT NULL, tool_version VARCHAR(64) NOT NULL, tool_call_id VARCHAR(128) NOT NULL, idempotency_key VARCHAR(255) NOT NULL UNIQUE, causal_token TEXT NOT NULL, request_hash VARCHAR(128) NOT NULL, request_ref TEXT NOT NULL, status VARCHAR(64) NOT NULL, external_id VARCHAR(255), response_hash VARCHAR(128), response_ref TEXT, error_type VARCHAR(255), created_at DOUBLE NOT NULL, updated_at DOUBLE NOT NULL, INDEX idx_tool_ledger_run_tool (run_id, tool_name));";
5744
5745pub type MCPCall = fn(&str, State) -> Result<Value>;
5746pub type MCPResourceRead = fn(&str) -> Result<Value>;
5747
5748#[derive(Clone, Debug)]
5749pub struct MCPResourceDescriptor {
5750 pub uri: String,
5751 pub name: String,
5752 pub mime_type: String,
5753}
5754
5755impl MCPResourceDescriptor {
5756 pub fn to_state(&self) -> State {
5757 state(&[
5758 ("uri", self.uri.clone().into()),
5759 ("name", self.name.clone().into()),
5760 ("mimeType", self.mime_type.clone().into()),
5761 ])
5762 }
5763}
5764
5765pub struct InMemoryMCPToolServer {
5766 tools: HashMap<String, (State, MCPCall)>,
5767}
5768
5769impl InMemoryMCPToolServer {
5770 pub fn new() -> Self {
5771 Self {
5772 tools: HashMap::new(),
5773 }
5774 }
5775 pub fn add_tool(&mut self, descriptor: State, handler: MCPCall) {
5776 if let Some(Value::String(name)) = descriptor.get("name") {
5777 self.tools.insert(name.clone(), (descriptor, handler));
5778 }
5779 }
5780 pub fn list_tools(&self) -> Vec<State> {
5781 let mut names: Vec<_> = self.tools.keys().cloned().collect();
5782 names.sort();
5783 names
5784 .into_iter()
5785 .filter_map(|name| self.tools.get(&name).map(|entry| entry.0.clone()))
5786 .collect()
5787 }
5788 pub fn call_tool(&self, name: &str, args: State) -> Result<Value> {
5789 let (_, handler) = self
5790 .tools
5791 .get(name)
5792 .ok_or_else(|| RuntimeError(format!("MCP tool not found: {name}")))?;
5793 handler(name, args)
5794 }
5795}
5796
5797pub struct InMemoryMCPContextServer {
5798 resources: HashMap<String, (MCPResourceDescriptor, MCPResourceRead)>,
5799}
5800
5801impl InMemoryMCPContextServer {
5802 pub fn new() -> Self {
5803 Self {
5804 resources: HashMap::new(),
5805 }
5806 }
5807 pub fn add_resource(
5808 &mut self,
5809 uri: &str,
5810 name: &str,
5811 mime_type: &str,
5812 reader: MCPResourceRead,
5813 ) {
5814 self.resources.insert(
5815 uri.to_string(),
5816 (
5817 MCPResourceDescriptor {
5818 uri: uri.to_string(),
5819 name: name.to_string(),
5820 mime_type: if mime_type.is_empty() {
5821 "application/json".to_string()
5822 } else {
5823 mime_type.to_string()
5824 },
5825 },
5826 reader,
5827 ),
5828 );
5829 }
5830 pub fn list_resources(&self) -> Vec<State> {
5831 let mut uris: Vec<_> = self.resources.keys().cloned().collect();
5832 uris.sort();
5833 uris.into_iter()
5834 .filter_map(|uri| self.resources.get(&uri).map(|entry| entry.0.to_state()))
5835 .collect()
5836 }
5837 pub fn read_resource(&self, uri: &str) -> Result<State> {
5838 let (descriptor, reader) = self
5839 .resources
5840 .get(uri)
5841 .ok_or_else(|| RuntimeError(format!("MCP resource not found: {uri}")))?;
5842 Ok(state(&[
5843 ("resource", Value::Object(descriptor.to_state())),
5844 ("content", reader(uri)?),
5845 ]))
5846 }
5847}
5848
5849pub struct MCPToolAdapter {
5850 pub client_call: MCPCall,
5851}
5852
5853impl MCPToolAdapter {
5854 pub fn tool_spec_from_descriptor(&self, descriptor: &State) -> ToolSpec {
5855 let name = string_field(descriptor, "name", "");
5856 let version = string_field(descriptor, "version", "v1");
5857 let annotations = match descriptor.get("annotations") {
5858 Some(Value::Object(state)) => state.clone(),
5859 _ => State::new(),
5860 };
5861 let side_effect = string_field(&annotations, "side_effect", "none");
5862 let risk_level = string_field(&annotations, "risk_level", "low");
5863 let idempotency_required = match annotations.get("idempotency_required") {
5864 Some(Value::Bool(value)) => *value,
5865 _ => side_effect != "none",
5866 };
5867 let approval_required = match annotations.get("approval_required") {
5868 Some(Value::Bool(value)) => *value,
5869 _ => false,
5870 };
5871 let sandbox_required = match annotations.get("sandbox_required") {
5872 Some(Value::Bool(value)) => *value,
5873 _ => false,
5874 };
5875 let sandbox_executor = string_field(&annotations, "sandbox_executor", "");
5876 let sandbox_policy = match annotations.get("sandbox_policy") {
5877 Some(Value::Object(state)) => state.clone(),
5878 _ => State::new(),
5879 };
5880 let client_call = self.client_call;
5881 let tool_name = name.clone();
5882 let mut spec = ToolSpec::new(&name, Box::new(move |args| client_call(&tool_name, args)));
5883 spec.version = version;
5884 spec.side_effect = side_effect;
5885 spec.risk_level = risk_level;
5886 spec.idempotency_required = idempotency_required;
5887 spec.approval_required = approval_required;
5888 spec.sandbox_required = sandbox_required;
5889 spec.sandbox_executor = sandbox_executor;
5890 spec.sandbox_policy = sandbox_policy;
5891 spec.input_schema = descriptor
5892 .get("inputSchema")
5893 .or_else(|| descriptor.get("input_schema"))
5894 .cloned();
5895 spec.output_schema = descriptor
5896 .get("outputSchema")
5897 .or_else(|| descriptor.get("output_schema"))
5898 .cloned();
5899 spec
5900 }
5901}
5902
5903pub struct MCPContextAdapter {
5904 pub resource_read: MCPResourceRead,
5905}
5906
5907impl MCPContextAdapter {
5908 pub fn read_tool_spec(&self, name: &str, risk_level: &str) -> ToolSpec {
5909 let tool_name = if name.is_empty() {
5910 "mcp.context.read"
5911 } else {
5912 name
5913 };
5914 let risk = if risk_level.is_empty() {
5915 "low"
5916 } else {
5917 risk_level
5918 };
5919 let reader = self.resource_read;
5920 let mut spec = ToolSpec::new(
5921 tool_name,
5922 Box::new(move |args| match args.get("uri") {
5923 Some(Value::String(uri)) => reader(uri),
5924 _ => Err(RuntimeError("uri is required".to_string())),
5925 }),
5926 );
5927 spec.risk_level = risk.to_string();
5928 spec.side_effect = "none".to_string();
5929 spec.input_schema = Some(Value::Object(state(&[("type", "object".into())])));
5930 spec.output_schema = Some(Value::Object(state(&[("type", "object".into())])));
5931 spec
5932 }
5933}
5934
5935fn string_field(state: &State, key: &str, fallback: &str) -> String {
5936 match state.get(key) {
5937 Some(Value::String(value)) => value.clone(),
5938 _ => fallback.to_string(),
5939 }
5940}
5941
5942pub type FrameworkAgentFunc = fn(&mut AgentContext, State) -> Result<Option<Value>>;
5943
5944pub struct FunctionAdapter {
5945 pub func: FrameworkAgentFunc,
5946 pub role: String,
5947 pub name: String,
5948}
5949
5950impl FunctionAdapter {
5951 pub fn new(func: FrameworkAgentFunc, role: &str) -> Self {
5952 Self {
5953 func,
5954 role: if role.is_empty() {
5955 "Agent".to_string()
5956 } else {
5957 role.to_string()
5958 },
5959 name: "function".to_string(),
5960 }
5961 }
5962 pub fn map_run_spec(&self) -> State {
5963 state(&[
5964 ("adapter", self.name.clone().into()),
5965 ("role", self.role.clone().into()),
5966 ])
5967 }
5968 pub fn run(&self, ctx: &mut AgentContext, state_value: State, output_key: &str) -> Result<()> {
5969 if let Some(result) = (self.func)(ctx, state_value)? {
5970 if !output_key.is_empty() {
5971 ctx.write_state(output_key, result);
5972 }
5973 }
5974 Ok(())
5975 }
5976}
5977
5978pub type MethodHandler = fn(State) -> Result<Value>;
5979
5980pub struct MethodFrameworkAdapter {
5981 pub target_name: String,
5982 pub role: String,
5983 pub method_candidates: Vec<String>,
5984 pub methods: HashMap<String, MethodHandler>,
5985 pub output_key: String,
5986}
5987
5988impl MethodFrameworkAdapter {
5989 pub fn new(
5990 target_name: &str,
5991 role: &str,
5992 method_candidates: Vec<String>,
5993 methods: HashMap<String, MethodHandler>,
5994 output_key: &str,
5995 ) -> Self {
5996 Self {
5997 target_name: target_name.to_string(),
5998 role: if role.is_empty() {
5999 "FrameworkAgent".to_string()
6000 } else {
6001 role.to_string()
6002 },
6003 method_candidates,
6004 methods,
6005 output_key: if output_key.is_empty() {
6006 "output".to_string()
6007 } else {
6008 output_key.to_string()
6009 },
6010 }
6011 }
6012 pub fn map_run_spec(&self) -> State {
6013 state(&[
6014 ("adapter", "method-framework".into()),
6015 ("role", self.role.clone().into()),
6016 ("target", self.target_name.clone().into()),
6017 (
6018 "methods",
6019 Value::Array(
6020 self.method_candidates
6021 .iter()
6022 .map(|item| Value::String(item.clone()))
6023 .collect(),
6024 ),
6025 ),
6026 ])
6027 }
6028 pub fn run(&self, ctx: &mut AgentContext, state_value: State) -> Result<()> {
6029 for name in &self.method_candidates {
6030 if let Some(handler) = self.methods.get(name) {
6031 let result = handler(state_value)?;
6032 if !self.output_key.is_empty() {
6033 ctx.write_state(&self.output_key, result);
6034 }
6035 return Ok(());
6036 }
6037 }
6038 Err(RuntimeError(
6039 "target does not expose any candidate method".to_string(),
6040 ))
6041 }
6042}
6043
6044#[derive(Clone, Debug)]
6045pub struct BoundaryLintRule {
6046 pub rule_id: String,
6047 pub pattern: String,
6048 pub category: String,
6049 pub message: String,
6050 pub suggestion: String,
6051 pub prefix: bool,
6052}
6053
6054#[derive(Clone, Debug)]
6055pub struct BoundaryLintFinding {
6056 pub path: String,
6057 pub line: usize,
6058 pub column: usize,
6059 pub rule_id: String,
6060 pub severity: String,
6061 pub callee: String,
6062 pub category: String,
6063 pub message: String,
6064 pub suggestion: String,
6065}
6066
6067#[derive(Clone, Debug)]
6068pub struct BoundaryLintReport {
6069 pub passed: bool,
6070 pub scanned_files: Vec<String>,
6071 pub finding_count: usize,
6072 pub findings: Vec<BoundaryLintFinding>,
6073}
6074
6075pub fn default_boundary_rules() -> Vec<BoundaryLintRule> {
6076 vec![
6077 BoundaryLintRule { rule_id: "direct-shell-os-system".into(), pattern: "os.system".into(), category: "shell".into(), message: "direct shell execution bypasses ToolGateway, policy, ledger, sandbox, and audit".into(), suggestion: "wrap shell execution as a runtime-managed tool".into(), prefix: false },
6078 BoundaryLintRule { rule_id: "direct-shell-subprocess".into(), pattern: "subprocess.".into(), category: "shell".into(), message: "direct subprocess execution bypasses ToolGateway, policy, ledger, sandbox, and audit".into(), suggestion: "wrap command execution as a runtime-managed tool".into(), prefix: true },
6079 BoundaryLintRule { rule_id: "direct-http-requests".into(), pattern: "requests.".into(), category: "network".into(), message: "direct HTTP calls bypass ToolGateway, policy, ledger, budget, replay, and audit".into(), suggestion: "register the HTTP/API call as a runtime-managed tool".into(), prefix: true },
6080 BoundaryLintRule { rule_id: "direct-http-httpx".into(), pattern: "httpx.".into(), category: "network".into(), message: "direct HTTP calls bypass ToolGateway, policy, ledger, budget, replay, and audit".into(), suggestion: "register the HTTP/API call as a runtime-managed tool".into(), prefix: true },
6081 BoundaryLintRule { rule_id: "direct-openai-sdk".into(), pattern: "openai.".into(), category: "model".into(), message: "direct model SDK usage bypasses model provider archives, replay, budget, and attribution".into(), suggestion: "call models through the runtime model boundary".into(), prefix: true },
6082 BoundaryLintRule { rule_id: "direct-anthropic-sdk".into(), pattern: "anthropic.".into(), category: "model".into(), message: "direct model SDK usage bypasses model provider archives, replay, budget, and attribution".into(), suggestion: "call models through the runtime model boundary".into(), prefix: true },
6083 ]
6084}
6085
6086pub fn scan_boundary_source(
6087 path: &str,
6088 source: &str,
6089 rules: Option<Vec<BoundaryLintRule>>,
6090) -> BoundaryLintReport {
6091 let rules = rules.unwrap_or_else(default_boundary_rules);
6092 let lines: Vec<&str> = source.split('\n').collect();
6093 let mut findings = Vec::new();
6094 for (i, line) in lines.iter().enumerate() {
6095 let previous = if i > 0 { lines[i - 1] } else { "" };
6096 if line.contains("agentledger: ignore-boundary")
6097 || previous.contains("agentledger: ignore-next-line")
6098 {
6099 continue;
6100 }
6101 for rule in &rules {
6102 if let Some(index) = line.find(&rule.pattern) {
6103 let mut callee = rule.pattern.clone();
6104 if rule.prefix {
6105 let mut end = index + rule.pattern.len();
6106 while end < line.len() {
6107 let ch = line.as_bytes()[end] as char;
6108 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' {
6109 end += 1;
6110 } else {
6111 break;
6112 }
6113 }
6114 callee = line[index..end].to_string();
6115 }
6116 findings.push(BoundaryLintFinding {
6117 path: path.into(),
6118 line: i + 1,
6119 column: index + 1,
6120 rule_id: rule.rule_id.clone(),
6121 severity: "error".into(),
6122 callee,
6123 category: rule.category.clone(),
6124 message: rule.message.clone(),
6125 suggestion: rule.suggestion.clone(),
6126 });
6127 break;
6128 }
6129 }
6130 }
6131 BoundaryLintReport {
6132 passed: findings.is_empty(),
6133 scanned_files: vec![path.into()],
6134 finding_count: findings.len(),
6135 findings,
6136 }
6137}
6138
6139#[derive(Clone, Debug)]
6140pub struct RecoverySummary {
6141 pub recovered_steps: usize,
6142}
6143
6144#[derive(Clone, Debug)]
6145pub struct SchedulerStepStatus {
6146 pub step_id: String,
6147 pub status: String,
6148 pub owner: Option<String>,
6149 pub attempt: u64,
6150 pub lease_until: Option<f64>,
6151 pub last_error_type: Option<String>,
6152}
6153
6154#[derive(Clone, Debug)]
6155pub struct SchedulerStatus {
6156 pub run_id: String,
6157 pub run_status: String,
6158 pub state_version: u64,
6159 pub steps: Vec<SchedulerStepStatus>,
6160 pub cost_summary: CostSummary,
6161}
6162
6163pub struct RuntimeScheduler;
6164
6165impl RuntimeScheduler {
6166 pub fn recover_expired_leases(store: &mut MemoryStore) -> RecoverySummary {
6167 RecoverySummary {
6168 recovered_steps: store.recover_expired_leases(),
6169 }
6170 }
6171
6172 pub fn cancel_run(store: &mut MemoryStore, run_id: &str, reason: &str) -> Result<usize> {
6173 store.cancel_run(run_id, reason)
6174 }
6175
6176 pub fn status(store: &MemoryStore, run_id: &str) -> Result<SchedulerStatus> {
6177 let run = store.run(run_id)?;
6178 let steps = store
6179 .steps(run_id)
6180 .into_iter()
6181 .map(|step| SchedulerStepStatus {
6182 step_id: step.step_id,
6183 status: step.status,
6184 owner: step.owner,
6185 attempt: step.attempt,
6186 lease_until: step.lease_until,
6187 last_error_type: step.last_error_type,
6188 })
6189 .collect();
6190 Ok(SchedulerStatus {
6191 run_id: run_id.to_string(),
6192 run_status: run.status,
6193 state_version: run.state_version,
6194 steps,
6195 cost_summary: store.cost_summary(run_id),
6196 })
6197 }
6198}
6199
6200#[derive(Clone, Debug)]
6201pub struct ReviewCheck {
6202 pub name: String,
6203 pub passed: bool,
6204 pub severity: String,
6205 pub detail: String,
6206}
6207
6208#[derive(Clone, Debug)]
6209pub struct AdversarialReviewReport {
6210 pub passed: bool,
6211 pub run_id: Option<String>,
6212 pub checks: Vec<ReviewCheck>,
6213 pub metadata: State,
6214}
6215
6216pub fn adversarial_review(
6217 bundle: &EvidenceBundle,
6218 max_total_usd: Option<f64>,
6219) -> AdversarialReviewReport {
6220 let mut checks = vec![
6221 review_check(
6222 "no_failed_steps",
6223 !bundle
6224 .events
6225 .iter()
6226 .any(|event| event.event_type == "step_failed"),
6227 "blocker",
6228 "no step is in failed status",
6229 ),
6230 review_check(
6231 "no_pending_verification",
6232 !bundle
6233 .tool_ledger
6234 .iter()
6235 .any(|row| row.status == "PENDING_VERIFICATION"),
6236 "blocker",
6237 "no side effect is pending verification",
6238 ),
6239 review_check(
6240 "no_pending_approvals",
6241 !bundle.approvals.iter().any(|row| row.status == "PENDING"),
6242 "blocker",
6243 "no approval request is still pending",
6244 ),
6245 review_check(
6246 "completed_steps_have_completion_events",
6247 completed_steps_have_events_rust(&bundle.steps, &bundle.events),
6248 "blocker",
6249 "completed steps have step_completed events",
6250 ),
6251 review_check(
6252 "ledger_statuses_known",
6253 ledger_statuses_known_rust(&bundle.tool_ledger),
6254 "blocker",
6255 "Tool Ledger rows use known statuses",
6256 ),
6257 review_check(
6258 "event_sequence_contiguous",
6259 event_sequence_contiguous_rust(&bundle.events),
6260 "blocker",
6261 "event sequence has no gaps",
6262 ),
6263 review_check(
6264 "artifacts_have_blob_refs",
6265 bundle
6266 .artifacts
6267 .iter()
6268 .all(|row| !row.blob_ref.is_empty() && !row.blob_hash.is_empty()),
6269 "warning",
6270 "artifacts have blob refs and hashes",
6271 ),
6272 review_check(
6273 "media_artifacts_have_refs",
6274 bundle
6275 .media_artifacts
6276 .iter()
6277 .all(media_artifact_has_ref_rust),
6278 "blocker",
6279 "media artifacts have kind and durable refs",
6280 ),
6281 review_check(
6282 "stream_checkpoints_have_offsets",
6283 bundle
6284 .stream_checkpoints
6285 .iter()
6286 .all(stream_checkpoint_has_offset_rust),
6287 "blocker",
6288 "stream checkpoints have stream, consumer, and offset",
6289 ),
6290 review_check(
6291 "high_risk_approvals_decided",
6292 high_risk_approvals_decided_rust(&bundle.approvals),
6293 "blocker",
6294 "high-risk approval requests are decided",
6295 ),
6296 review_check(
6297 "no_blocking_failure_events",
6298 !bundle.events.iter().any(|event| {
6299 matches!(
6300 event.event_type.as_str(),
6301 "error_raised" | "step_failed" | "tool_call_failed" | "tool_call_blocked"
6302 )
6303 }),
6304 "warning",
6305 "no blocking failure events are present",
6306 ),
6307 ];
6308 if let Some(limit) = max_total_usd {
6309 checks.push(review_check(
6310 "max_total_usd",
6311 bundle.cost_summary.total_usd <= limit,
6312 "blocker",
6313 "cost limit check",
6314 ));
6315 }
6316 let passed = checks
6317 .iter()
6318 .all(|check| check.severity != "blocker" || check.passed);
6319 let mut metadata = State::new();
6320 metadata.insert(
6321 "event_count".into(),
6322 Value::Number(bundle.events.len() as f64),
6323 );
6324 metadata.insert(
6325 "tool_ledger_count".into(),
6326 Value::Number(bundle.tool_ledger.len() as f64),
6327 );
6328 metadata.insert(
6329 "approval_count".into(),
6330 Value::Number(bundle.approvals.len() as f64),
6331 );
6332 metadata.insert(
6333 "artifact_count".into(),
6334 Value::Number(bundle.artifacts.len() as f64),
6335 );
6336 metadata.insert(
6337 "media_artifact_count".into(),
6338 Value::Number(bundle.media_artifacts.len() as f64),
6339 );
6340 metadata.insert(
6341 "stream_checkpoint_count".into(),
6342 Value::Number(bundle.stream_checkpoints.len() as f64),
6343 );
6344 AdversarialReviewReport {
6345 passed,
6346 run_id: Some(bundle.run.run_id.clone()),
6347 checks,
6348 metadata,
6349 }
6350}
6351
6352fn review_check(name: &str, passed: bool, severity: &str, detail: &str) -> ReviewCheck {
6353 ReviewCheck {
6354 name: name.into(),
6355 passed,
6356 severity: severity.into(),
6357 detail: detail.into(),
6358 }
6359}
6360
6361fn completed_steps_have_events_rust(steps: &[Step], events: &[Event]) -> bool {
6362 steps.iter().all(|step| {
6363 step.status != "completed"
6364 || events.iter().any(|event| {
6365 event.event_type == "step_completed"
6366 && event.step_id.as_deref() == Some(step.step_id.as_str())
6367 })
6368 })
6369}
6370
6371fn ledger_statuses_known_rust(rows: &[ToolLedgerEntry]) -> bool {
6372 rows.iter().all(|row| {
6373 matches!(
6374 row.status.as_str(),
6375 "SUCCEEDED"
6376 | "FAILED_NO_EFFECT"
6377 | "PENDING_VERIFICATION"
6378 | "COMPENSATED"
6379 | "RUNNING"
6380 | "RESERVED"
6381 )
6382 })
6383}
6384fn event_sequence_contiguous_rust(events: &[Event]) -> bool {
6385 events
6386 .iter()
6387 .enumerate()
6388 .all(|(index, event)| event.seq == (index as u64) + 1)
6389}
6390fn state_has_key(row: &State, key: &str) -> bool {
6391 !matches!(row.get(key), None | Some(Value::Null))
6392}
6393fn media_artifact_has_ref_rust(row: &State) -> bool {
6394 state_has_key(row, "kind")
6395 && (state_has_key(row, "uri")
6396 || state_has_key(row, "content_ref")
6397 || state_has_key(row, "blob_ref"))
6398}
6399fn stream_checkpoint_has_offset_rust(row: &State) -> bool {
6400 state_has_key(row, "stream_id")
6401 && state_has_key(row, "consumer_id")
6402 && state_has_key(row, "offset")
6403}
6404fn high_risk_approvals_decided_rust(rows: &[ApprovalRequest]) -> bool {
6405 rows.iter().all(|row| {
6406 !matches!(
6407 row.risk_level.as_str(),
6408 "high" | "destructive" | "sensitive"
6409 ) || matches!(row.status.as_str(), "APPROVED" | "DENIED")
6410 })
6411}
6412
6413#[derive(Clone, Debug)]
6414pub struct EvidenceCheck {
6415 pub name: String,
6416 pub passed: bool,
6417 pub detail: String,
6418}
6419
6420#[derive(Clone, Debug)]
6421pub struct EvidenceCheckReport {
6422 pub passed: bool,
6423 pub checks: Vec<EvidenceCheck>,
6424 pub metadata: State,
6425}
6426
6427pub fn evaluate_evidence(
6428 bundle: &EvidenceBundle,
6429 max_total_usd: Option<f64>,
6430) -> EvidenceCheckReport {
6431 let mut checks = vec![
6432 evidence_check(
6433 "no_failed_steps",
6434 !bundle
6435 .events
6436 .iter()
6437 .any(|event| event.event_type == "step_failed"),
6438 "all steps completed or remain non-failed",
6439 ),
6440 evidence_check(
6441 "no_pending_verification",
6442 !bundle
6443 .tool_ledger
6444 .iter()
6445 .any(|row| row.status == "PENDING_VERIFICATION"),
6446 "no side effect is waiting for human/external verification",
6447 ),
6448 evidence_check(
6449 "completed_steps_have_events",
6450 completed_steps_have_events_rust(&bundle.steps, &bundle.events),
6451 "each completed step has a step_completed event",
6452 ),
6453 evidence_check(
6454 "managed_side_effects_are_ledgered",
6455 ledger_statuses_known_rust(&bundle.tool_ledger),
6456 "every ledger row has a known status",
6457 ),
6458 evidence_check(
6459 "media_artifacts_have_refs",
6460 bundle
6461 .media_artifacts
6462 .iter()
6463 .all(media_artifact_has_ref_rust),
6464 "media artifacts have kind and durable refs",
6465 ),
6466 evidence_check(
6467 "stream_checkpoints_have_offsets",
6468 bundle
6469 .stream_checkpoints
6470 .iter()
6471 .all(stream_checkpoint_has_offset_rust),
6472 "stream checkpoints have stream, consumer, and offset",
6473 ),
6474 ];
6475 if let Some(limit) = max_total_usd {
6476 checks.push(evidence_check(
6477 "max_total_usd",
6478 bundle.cost_summary.total_usd <= limit,
6479 "cost limit check",
6480 ));
6481 }
6482 EvidenceCheckReport {
6483 passed: checks.iter().all(|check| check.passed),
6484 checks,
6485 metadata: State::new(),
6486 }
6487}
6488
6489pub fn evaluate_evidence_regression(
6490 golden: &EvidenceBundle,
6491 current: &EvidenceBundle,
6492 max_total_usd_delta: Option<f64>,
6493) -> EvidenceCheckReport {
6494 let diff = diff_evidence(golden, current);
6495 let mut checks = vec![
6496 evidence_check(
6497 "final_state_regression",
6498 diff.final_state_changed_count == 0,
6499 "final state regression check",
6500 ),
6501 evidence_check(
6502 "event_type_regression",
6503 diff.event_types_changed_count == 0,
6504 "event type regression check",
6505 ),
6506 evidence_check(
6507 "tool_ledger_status_regression",
6508 true,
6509 "tool ledger status regression check",
6510 ),
6511 evidence_check(
6512 "media_artifact_regression",
6513 diff.media_artifacts_changed_count == 0,
6514 "media artifact regression check",
6515 ),
6516 evidence_check(
6517 "stream_checkpoint_regression",
6518 diff.stream_checkpoints_changed_count == 0,
6519 "stream checkpoint regression check",
6520 ),
6521 ];
6522 if let Some(limit) = max_total_usd_delta {
6523 let delta = current.cost_summary.total_usd - golden.cost_summary.total_usd;
6524 checks.push(evidence_check(
6525 "max_total_usd_delta",
6526 delta <= limit,
6527 "cost delta limit check",
6528 ));
6529 }
6530 EvidenceCheckReport {
6531 passed: checks.iter().all(|check| check.passed),
6532 checks,
6533 metadata: State::new(),
6534 }
6535}
6536
6537fn evidence_check(name: &str, passed: bool, detail: &str) -> EvidenceCheck {
6538 EvidenceCheck {
6539 name: name.into(),
6540 passed,
6541 detail: detail.into(),
6542 }
6543}
6544
6545#[derive(Clone, Debug)]
6546pub struct FailureInjectionCheck {
6547 pub name: String,
6548 pub passed: bool,
6549 pub detail: String,
6550 pub run_id: Option<String>,
6551}
6552
6553#[derive(Clone, Debug)]
6554pub struct FailureInjectionReport {
6555 pub passed: bool,
6556 pub checks: Vec<FailureInjectionCheck>,
6557}
6558
6559pub fn run_failure_injection_suite() -> FailureInjectionReport {
6560 let checks = vec![
6561 failure_retry_exhaustion(),
6562 failure_lease_fencing(),
6563 failure_cancellation_fencing(),
6564 failure_side_effect_idempotency(),
6565 ];
6566 FailureInjectionReport {
6567 passed: checks.iter().all(|check| check.passed),
6568 checks,
6569 }
6570}
6571fn failure_check(
6572 name: &str,
6573 passed: bool,
6574 detail: String,
6575 run_id: String,
6576) -> FailureInjectionCheck {
6577 FailureInjectionCheck {
6578 name: name.into(),
6579 passed,
6580 detail,
6581 run_id: Some(run_id),
6582 }
6583}
6584fn failure_retry_exhaustion() -> FailureInjectionCheck {
6585 let mut runtime = Runtime::new();
6586 let (run_id, _) = runtime.create_run(State::new());
6587 let _ = runtime.run_once(
6588 &run_id,
6589 "retry-1",
6590 "FailureInjector",
6591 60.0,
6592 |_ctx, _state| Err(RuntimeError("retryable".into())),
6593 );
6594 let _ = runtime.run_once(
6595 &run_id,
6596 "retry-2",
6597 "FailureInjector",
6598 60.0,
6599 |_ctx, _state| Err(RuntimeError("final failure".into())),
6600 );
6601 let status = runtime
6602 .store
6603 .run(&run_id)
6604 .map(|run| run.status)
6605 .unwrap_or_else(|_| "missing".into());
6606 failure_check(
6607 "retry_exhaustion",
6608 status == "failed",
6609 format!("run_status={status}"),
6610 run_id,
6611 )
6612}
6613fn failure_lease_fencing() -> FailureInjectionCheck {
6614 let mut store = MemoryStore::new();
6615 let (run_id, step_id) = store.create_run(State::new());
6616 let claim = store.claim_step("stale-worker", &run_id, 0.0).unwrap();
6617 let recovered = store.recover_expired_leases();
6618 let stale_rejected = store
6619 .commit_state_patch(&run_id, &step_id, &claim.lease_token, 0, State::new())
6620 .is_err();
6621 let fresh = store.claim_step("fresh-worker", &run_id, 60.0).unwrap();
6622 let passed = recovered == 1 && stale_rejected && fresh.attempt == 2;
6623 failure_check(
6624 "lease_fencing",
6625 passed,
6626 format!("recovered_steps={recovered} stale_rejected={stale_rejected}"),
6627 run_id,
6628 )
6629}
6630fn failure_cancellation_fencing() -> FailureInjectionCheck {
6631 let mut store = MemoryStore::new();
6632 let (run_id, step_id) = store.create_run(State::new());
6633 let claim = store.claim_step("stale-worker", &run_id, 60.0).unwrap();
6634 let cancelled = store.cancel_run(&run_id, "failure injection").unwrap();
6635 let stale_rejected = store
6636 .commit_state_patch(&run_id, &step_id, &claim.lease_token, 0, State::new())
6637 .is_err();
6638 let fresh = store.claim_step("fresh-worker", &run_id, 60.0).is_err();
6639 let status = store.run(&run_id).map(|run| run.status).unwrap_or_default();
6640 let passed = cancelled == 1 && stale_rejected && fresh && status == "cancelled";
6641 failure_check(
6642 "cancellation_fencing",
6643 passed,
6644 format!("cancelled_steps={cancelled} stale_rejected={stale_rejected}"),
6645 run_id,
6646 )
6647}
6648fn failure_side_effect_idempotency() -> FailureInjectionCheck {
6649 use std::sync::{Arc, Mutex};
6650 let calls = Arc::new(Mutex::new(0usize));
6651 let calls_for_tool = Arc::clone(&calls);
6652 let mut runtime = Runtime::new();
6653 runtime.register_tool(
6654 ToolSpec::new(
6655 "external.create",
6656 Box::new(move |_args| {
6657 let mut guard = calls_for_tool.lock().unwrap();
6658 *guard += 1;
6659 Ok(Value::Object(state(&[(
6660 "id",
6661 Value::String("EXT-1".into()),
6662 )])))
6663 }),
6664 )
6665 .side_effect("external")
6666 .idempotency_required(true),
6667 );
6668 let (run_id, _) = runtime.create_run(State::new());
6669 let ctx = failure_claim_context(&mut runtime, &run_id, "worker-1", "FailureInjector");
6670 let _ = runtime.call_tool(
6671 &ctx,
6672 "external.create",
6673 state(&[("title", Value::String("once".into()))]),
6674 );
6675 runtime
6676 .store
6677 .mark_retry(&run_id, &ctx.step_id, "RetryableAgentError", "retryable");
6678 let ctx2 = failure_claim_context(&mut runtime, &run_id, "worker-2", "FailureInjector");
6679 let _ = runtime.call_tool(
6680 &ctx2,
6681 "external.create",
6682 state(&[("title", Value::String("once".into()))]),
6683 );
6684 let count = *calls.lock().unwrap();
6685 failure_check(
6686 "side_effect_idempotency",
6687 count == 1,
6688 format!("external_call_count={count}"),
6689 run_id,
6690 )
6691}
6692
6693fn failure_claim_context(
6694 runtime: &mut Runtime,
6695 run_id: &str,
6696 worker_id: &str,
6697 agent_role: &str,
6698) -> AgentContext {
6699 let claim = runtime.store.claim_step(worker_id, run_id, 60.0).unwrap();
6700 AgentContext {
6701 run_id: claim.run_id,
6702 session_id: claim.session_id,
6703 step_id: claim.step_id,
6704 agent_role: agent_role.to_string(),
6705 lease_token: claim.lease_token,
6706 attempt: claim.attempt,
6707 state_version: claim.state_version,
6708 pending_patch: State::new(),
6709 }
6710}
6711
6712#[derive(Clone, Debug)]
6713pub struct ShadowReport {
6714 pub source_run_id: String,
6715 pub shadow_run_id: String,
6716 pub ok: bool,
6717 pub state_diff: State,
6718}
6719
6720pub fn diff_states(source: &State, shadow: &State) -> State {
6721 let mut changed = State::new();
6722 for key in source.keys().chain(shadow.keys()) {
6723 if changed.contains_key(key) {
6724 continue;
6725 }
6726 if source.get(key) != shadow.get(key) {
6727 changed.insert(
6728 key.clone(),
6729 Value::Object(state(&[
6730 ("source", source.get(key).cloned().unwrap_or_default()),
6731 ("shadow", shadow.get(key).cloned().unwrap_or_default()),
6732 ])),
6733 );
6734 }
6735 }
6736 state(&[
6737 ("changed", Value::Object(changed.clone())),
6738 ("changed_count", Value::Number(changed.len() as f64)),
6739 ])
6740}
6741
6742pub fn shadow_report(
6743 source_run_id: &str,
6744 shadow_run_id: &str,
6745 ok: bool,
6746 source_state: &State,
6747 shadow_state: &State,
6748) -> ShadowReport {
6749 ShadowReport {
6750 source_run_id: source_run_id.into(),
6751 shadow_run_id: shadow_run_id.into(),
6752 ok,
6753 state_diff: diff_states(source_state, shadow_state),
6754 }
6755}
6756
6757pub fn builtin_golden_names() -> Vec<String> {
6758 vec![
6759 "media-stream-checkpoint".into(),
6760 "minimal-success".into(),
6761 "tool-ledger-success".into(),
6762 ]
6763}
6764
6765pub fn builtin_golden_evidence(name: &str) -> Result<EvidenceBundle> {
6766 match name {
6767 "minimal-success" => golden_minimal_success(),
6768 "tool-ledger-success" => golden_tool_ledger_success(),
6769 "media-stream-checkpoint" => golden_media_stream_checkpoint(),
6770 _ => Err(RuntimeError(format!(
6771 "unknown built-in golden case: {name}"
6772 ))),
6773 }
6774}
6775
6776pub fn golden_regression(golden: &EvidenceBundle, current: &EvidenceBundle) -> EvidenceCheckReport {
6777 evaluate_evidence_regression(golden, current, None)
6778}
6779
6780fn golden_minimal_success() -> Result<EvidenceBundle> {
6781 let mut runtime = Runtime::new();
6782 let (run_id, _) = runtime.create_run(State::new());
6783 runtime.run_once(
6784 &run_id,
6785 "golden-worker",
6786 "GoldenAgent",
6787 60.0,
6788 |ctx, _state| {
6789 ctx.write_state("answer", Value::String("ok".into()));
6790 Ok(())
6791 },
6792 )?;
6793 export_evidence(&runtime.store, &run_id)
6794}
6795fn golden_tool_ledger_success() -> Result<EvidenceBundle> {
6796 let mut runtime = Runtime::new();
6797 runtime.register_tool(
6798 ToolSpec::new(
6799 "github.create_issue",
6800 Box::new(|_args| {
6801 Ok(Value::Object(state(&[(
6802 "issue_id",
6803 Value::String("ISSUE-1".into()),
6804 )])))
6805 }),
6806 )
6807 .side_effect("external"),
6808 );
6809 let (run_id, _) = runtime.create_run(State::new());
6810 runtime.run_once(
6811 &run_id,
6812 "golden-worker",
6813 "ExecutorAgent",
6814 60.0,
6815 |ctx, _state| {
6816 ctx.write_state("issue_id", Value::String("ISSUE-1".into()));
6817 Ok(())
6818 },
6819 )?;
6820 export_evidence(&runtime.store, &run_id)
6821}
6822fn golden_media_stream_checkpoint() -> Result<EvidenceBundle> {
6823 let mut runtime = Runtime::new();
6824 let (run_id, _) = runtime.create_run(State::new());
6825 runtime.run_once(
6826 &run_id,
6827 "golden-worker",
6828 "MediaAgent",
6829 60.0,
6830 |ctx, _state| {
6831 ctx.write_state("processed_offset", Value::Number(42.0));
6832 Ok(())
6833 },
6834 )?;
6835 runtime.store.create_artifact(
6836 &run_id,
6837 None,
6838 "golden-video-frame",
6839 State::new(),
6840 state(&[(
6841 "agentledger_media",
6842 Value::Object(state(&[
6843 ("kind", Value::String("frame".into())),
6844 ("uri", Value::String("file://golden-frame.jpg".into())),
6845 ])),
6846 )]),
6847 );
6848 runtime.store.create_artifact(
6849 &run_id,
6850 None,
6851 "golden-stream-checkpoint",
6852 State::new(),
6853 state(&[(
6854 "agentledger_stream",
6855 Value::Object(state(&[
6856 ("stream_id", Value::String("stream-golden".into())),
6857 ("consumer_id", Value::String("consumer-golden".into())),
6858 ("offset", Value::Number(42.0)),
6859 ])),
6860 )]),
6861 );
6862 export_evidence(&runtime.store, &run_id)
6863}
6864
6865#[derive(Clone, Debug)]
6866pub struct TimeTravelFrame {
6867 pub seq: u64,
6868 pub event_id: String,
6869 pub event_type: String,
6870 pub step_id: Option<String>,
6871 pub agent_role: Option<String>,
6872 pub state_version: Option<u64>,
6873 pub timestamp: f64,
6874 pub state_changed: bool,
6875 pub changed_keys: Vec<String>,
6876 pub patch: Option<State>,
6877 pub state_after: Option<State>,
6878}
6879
6880#[derive(Clone, Debug)]
6881pub struct TimeTravelReport {
6882 pub run_id: String,
6883 pub at_seq: Option<u64>,
6884 pub event_count: usize,
6885 pub timeline: Vec<TimeTravelFrame>,
6886 pub state_at_seq: State,
6887 pub selected_event: Option<TimeTravelFrame>,
6888}
6889
6890pub fn time_travel(
6891 bundle: &EvidenceBundle,
6892 at_seq: Option<u64>,
6893 include_states: bool,
6894) -> TimeTravelReport {
6895 let mut current = State::new();
6896 let mut state_at_seq = State::new();
6897 let mut selected_event = None;
6898 let mut timeline = Vec::new();
6899 for event in &bundle.events {
6900 let before = current.clone();
6901 let patch = patch_for_time_travel_event(event);
6902 if let Some(patch_value) = &patch {
6903 for (key, value) in patch_value {
6904 current.insert(key.clone(), value.clone());
6905 }
6906 }
6907 let diff = diff_states(&before, ¤t);
6908 let changed_keys = match diff.get("changed") {
6909 Some(Value::Object(obj)) => obj.keys().cloned().collect(),
6910 _ => Vec::new(),
6911 };
6912 let frame = TimeTravelFrame {
6913 seq: event.seq,
6914 event_id: event.event_id.clone(),
6915 event_type: event.event_type.clone(),
6916 step_id: event.step_id.clone(),
6917 agent_role: event.agent_role.clone(),
6918 state_version: event.state_version,
6919 timestamp: event.timestamp,
6920 state_changed: diff.get("changed_count") != Some(&Value::Number(0.0)),
6921 changed_keys,
6922 patch,
6923 state_after: if include_states {
6924 Some(current.clone())
6925 } else {
6926 None
6927 },
6928 };
6929 if at_seq.is_some_and(|seq| event.seq <= seq) {
6930 state_at_seq = current.clone();
6931 selected_event = Some(frame.clone());
6932 }
6933 timeline.push(frame);
6934 }
6935 if at_seq.is_none() {
6936 state_at_seq = current.clone();
6937 }
6938 TimeTravelReport {
6939 run_id: bundle.run.run_id.clone(),
6940 at_seq,
6941 event_count: timeline.len(),
6942 timeline,
6943 state_at_seq,
6944 selected_event,
6945 }
6946}
6947
6948fn patch_for_time_travel_event(event: &Event) -> Option<State> {
6949 if event.event_type == "run_created" {
6950 if let Some(Value::Object(obj)) = event.payload.get("initial_state") {
6951 return Some(obj.clone());
6952 }
6953 return Some(State::new());
6954 }
6955 if event.event_type == "state_committed"
6956 || event.event_type == "state_patch_committed"
6957 || event.event_type == "system_state_patch_applied"
6958 {
6959 if let Some(Value::Object(obj)) = event.payload.get("patch") {
6960 return Some(obj.clone());
6961 }
6962 return Some(State::new());
6963 }
6964 None
6965}
6966
6967pub fn time_travel_html(report: &TimeTravelReport) -> String {
6968 let rows = report
6969 .timeline
6970 .iter()
6971 .map(|frame| {
6972 format!(
6973 "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
6974 frame.seq,
6975 frame.event_type,
6976 frame.changed_keys.join(", ")
6977 )
6978 })
6979 .collect::<Vec<_>>()
6980 .join("\n");
6981 format!("<!doctype html><html><head><meta charset=\"utf-8\"><title>AgentLedger Time Travel Report</title></head><body><h1>AgentLedger Time Travel Report</h1><p>Run <code>{}</code></p><table>{}</table><h2>State At Selected Point</h2><pre>{:?}</pre><h2>Selected Event</h2><pre>{:?}</pre></body></html>", report.run_id, rows, report.state_at_seq, report.selected_event)
6982}
6983
6984#[derive(Debug, Clone, PartialEq, Eq)]
6985pub struct OptionalAdapterCapability {
6986 pub name: String,
6987 pub category: String,
6988 pub core_imports_heavy_sdks: bool,
6989 pub adapter_is_optional: bool,
6990 pub fail_closed_without_adapter: bool,
6991 pub contract_surface: Vec<String>,
6992}
6993
6994pub fn optional_adapter_capabilities() -> Vec<OptionalAdapterCapability> {
6995 fn item(name: &str, category: &str, surface: &[&str]) -> OptionalAdapterCapability {
6996 OptionalAdapterCapability {
6997 name: name.to_string(),
6998 category: category.to_string(),
6999 core_imports_heavy_sdks: false,
7000 adapter_is_optional: true,
7001 fail_closed_without_adapter: true,
7002 contract_surface: surface.iter().map(|s| s.to_string()).collect(),
7003 }
7004 }
7005 vec![
7006 item("postgres", "storage", &["ddl_for", "migrations_for", "state_store"]),
7007 item("mysql", "storage", &["ddl_for", "migrations_for", "state_store"]),
7008 item("s3", "blobstore", &["put_json", "get_json", "content_address"]),
7009 item("docker", "sandbox", &["sandbox_policy", "sandbox_result", "tool_gateway"]),
7010 item("e2b", "sandbox", &["sandbox_policy", "sandbox_result", "tool_gateway"]),
7011 item("bubblewrap", "sandbox", &["sandbox_policy", "sandbox_result", "tool_gateway"]),
7012 item("kubernetes", "sandbox", &["sandbox_policy", "sandbox_result", "tool_gateway"]),
7013 item("gvisor", "sandbox", &["sandbox_policy", "sandbox_result", "tool_gateway"]),
7014 item("firecracker", "sandbox", &["sandbox_policy", "sandbox_result", "tool_gateway"]),
7015 item("langgraph", "framework", &["framework_adapter", "checkpoint_contract"]),
7016 item("langchain", "framework", &["framework_adapter"]),
7017 item("crewai", "framework", &["framework_adapter"]),
7018 item("autogen", "framework", &["framework_adapter"]),
7019 item("openai-agents-sdk", "framework", &["framework_adapter"]),
7020 item("llamaindex", "framework", &["framework_adapter"]),
7021 item("semantic-kernel", "framework", &["framework_adapter"]),
7022 item("mcp-transport", "mcp", &["mcp_tool_descriptor", "mcp_resource_descriptor"]),
7023 item("langfuse", "observability", &["evidence_bundle", "trace_payload", "correlation_ids"]),
7024 item("shadow-runner", "shadow", &["evidence_bundle", "tool_ledger", "state_diff"]),
7025 ]
7026}
7027
7028pub trait SqlExecutor {
7029 fn exec(&mut self, sql: &str, params: &[Value]) -> Result<()>;
7030}
7031
7032pub struct PostgresAdapter<C: SqlExecutor> {
7033 pub schema: String,
7034 pub client: C,
7035}
7036
7037impl<C: SqlExecutor> PostgresAdapter<C> {
7038 pub fn new(client: C, schema: &str) -> Self {
7039 Self { schema: if schema.is_empty() { "agentledger".to_string() } else { schema.to_string() }, client }
7040 }
7041 pub fn migration_plan(&self) -> Result<Vec<Migration>> { migrations_for("postgres") }
7042 pub fn apply_migrations(&mut self) -> Result<()> {
7043 self.client.exec(&ddl_for("postgres")?, &[])?;
7044 for migration in self.migration_plan()? {
7045 self.client.exec(
7046 "INSERT INTO schema_migrations(version, name, checksum) VALUES ($1, $2, $3) ON CONFLICT (version) DO NOTHING",
7047 &[Value::String(migration.version), Value::String(migration.name), Value::String(stable_hash(&migration.sql))],
7048 )?;
7049 }
7050 Ok(())
7051 }
7052}
7053
7054pub struct MySQLAdapter<C: SqlExecutor> {
7055 pub database: String,
7056 pub client: C,
7057}
7058
7059impl<C: SqlExecutor> MySQLAdapter<C> {
7060 pub fn new(client: C, database: &str) -> Self {
7061 Self { database: if database.is_empty() { "agentledger".to_string() } else { database.to_string() }, client }
7062 }
7063 pub fn migration_plan(&self) -> Result<Vec<Migration>> { migrations_for("mysql") }
7064 pub fn apply_migrations(&mut self) -> Result<()> {
7065 self.client.exec(&ddl_for("mysql")?, &[])?;
7066 for migration in self.migration_plan()? {
7067 self.client.exec(
7068 "INSERT INTO schema_migrations(version, name, checksum, applied_at) VALUES (?, ?, ?, UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE version=version",
7069 &[Value::String(migration.version), Value::String(migration.name), Value::String(stable_hash(&migration.sql))],
7070 )?;
7071 }
7072 Ok(())
7073 }
7074}
7075
7076pub trait ObjectClient {
7077 fn put_object(&mut self, bucket: &str, key: &str, body: &[u8], content_type: &str, metadata: State) -> Result<()>;
7078 fn get_object(&mut self, bucket: &str, key: &str) -> Result<Vec<u8>>;
7079}
7080
7081pub struct S3BlobStoreAdapter<C: ObjectClient> {
7082 pub bucket: String,
7083 pub prefix: String,
7084 pub client: C,
7085}
7086
7087impl<C: ObjectClient> S3BlobStoreAdapter<C> {
7088 pub fn new(client: C, bucket: &str, prefix: &str) -> Self {
7089 Self { bucket: bucket.to_string(), prefix: if prefix.is_empty() { "agentledger/blobs".to_string() } else { prefix.trim_matches('/').to_string() }, client }
7090 }
7091 pub fn put_json(&mut self, value: &Value) -> Result<(String, String)> {
7092 let encoded = encode_value(value);
7093 let digest = stable_hash(&encoded);
7094 let key = format!("{}/sha256/{}.json", self.prefix, digest);
7095 let mut metadata = State::new();
7096 metadata.insert("agentledger-digest".to_string(), Value::String(format!("sha256:{digest}")));
7097 self.client.put_object(&self.bucket, &key, encoded.as_bytes(), "application/json", metadata)?;
7098 Ok((format!("sha256:{digest}"), format!("s3://{}/{}", self.bucket, key)))
7099 }
7100 pub fn get_json(&mut self, reference: &str) -> Result<Value> {
7101 let prefix = format!("s3://{}/", self.bucket);
7102 if !reference.starts_with(&prefix) || reference.contains("..") {
7103 return Err(RuntimeError(format!("unsupported s3 blob ref: {reference}")));
7104 }
7105 let key = &reference[prefix.len()..];
7106 let body = self.client.get_object(&self.bucket, key)?;
7107 let text = String::from_utf8(body).map_err(|err| RuntimeError(err.to_string()))?;
7108 decode_value(&text)
7109 }
7110}
7111
7112pub trait OtlpClient { fn post_json(&mut self, endpoint: &str, payload: &str, content_type: &str) -> Result<()>; }
7113pub struct OtlpTransport<C: OtlpClient> { pub endpoint: String, pub client: C }
7114impl<C: OtlpClient> OtlpTransport<C> { pub fn export(&mut self, payload: &str) -> Result<()> { self.client.post_json(&self.endpoint, payload, "application/json") } }
7115
7116pub struct DockerSandboxAdapter { pub image: String }
7117impl DockerSandboxAdapter {
7118 pub fn manifest(&self, policy: &State, command: Vec<String>) -> State {
7119 let mut out = State::new();
7120 out.insert("backend".to_string(), Value::String("docker".to_string()));
7121 out.insert("image".to_string(), Value::String(if self.image.is_empty() { "python:3.11-slim".to_string() } else { self.image.clone() }));
7122 let network = match policy.get("network") { Some(Value::String(value)) if value != "deny" => value.clone(), _ => "none".to_string() };
7123 out.insert("network".to_string(), Value::String(network));
7124 out.insert("read_only_root".to_string(), Value::Bool(true));
7125 out.insert("requires_explicit_execution".to_string(), Value::Bool(true));
7126 out.insert("command".to_string(), Value::Array(command.into_iter().map(Value::String).collect()));
7127 out
7128 }
7129}
7130
7131pub struct DockerSandboxExecutor {
7132 pub image: String,
7133 pub binary: String,
7134 pub allow_command_execution: bool,
7135 pub allow_shell: bool,
7136 pub shell: String,
7137 pub memory: String,
7138 pub cpus: String,
7139}
7140
7141impl DockerSandboxExecutor {
7142 pub fn new(image: &str, allow_command_execution: bool) -> Self {
7143 Self {
7144 image: image.to_string(),
7145 binary: "docker".to_string(),
7146 allow_command_execution,
7147 allow_shell: false,
7148 shell: "/bin/sh".to_string(),
7149 memory: String::new(),
7150 cpus: String::new(),
7151 }
7152 }
7153
7154 pub fn with_binary(mut self, binary: &str) -> Self {
7155 self.binary = binary.to_string();
7156 self
7157 }
7158
7159 fn extract_command(&self, args: &State) -> std::result::Result<Vec<String>, String> {
7160 let raw = args.get("_sandbox_command").or_else(|| args.get("command"));
7161 match raw {
7162 Some(Value::String(command)) => {
7163 if !self.allow_shell {
7164 Err("string commands require allow_shell=true; pass argv list in `_sandbox_command` instead".to_string())
7165 } else {
7166 let shell = if self.shell.is_empty() { "/bin/sh" } else { &self.shell };
7167 Ok(vec![shell.to_string(), "-lc".to_string(), command.clone()])
7168 }
7169 }
7170 Some(Value::Array(items)) => {
7171 let mut command = Vec::new();
7172 for item in items {
7173 match item {
7174 Value::String(value) if !value.is_empty() => command.push(value.clone()),
7175 _ => return Err("_sandbox_command must be a non-empty string array".to_string()),
7176 }
7177 }
7178 if command.is_empty() {
7179 Err("_sandbox_command must be a non-empty string array".to_string())
7180 } else {
7181 Ok(command)
7182 }
7183 }
7184 _ => Err("external sandbox tools require a command-style `_sandbox_command` arg".to_string()),
7185 }
7186 }
7187
7188 fn docker_argv(&self, policy: &SandboxPolicy, command: &[String]) -> Vec<String> {
7189 let image = if self.image.is_empty() { "python:3.11-slim" } else { &self.image };
7190 let network = if policy.network == "deny" || policy.network.is_empty() { "none" } else { &policy.network };
7191 let mut argv = vec![
7192 self.binary.clone(),
7193 "run".to_string(),
7194 "--rm".to_string(),
7195 "--network".to_string(),
7196 network.to_string(),
7197 "--read-only".to_string(),
7198 ];
7199 if !self.memory.is_empty() {
7200 argv.extend(["--memory".to_string(), self.memory.clone()]);
7201 }
7202 if !self.cpus.is_empty() {
7203 argv.extend(["--cpus".to_string(), self.cpus.clone()]);
7204 }
7205 argv.push(image.to_string());
7206 argv.extend(command.iter().cloned());
7207 argv
7208 }
7209
7210 fn result_error(policy: &SandboxPolicy, manifest: State, error_type: &str, error: String) -> SandboxResult {
7211 let mut metadata = State::new();
7212 metadata.insert("executor".to_string(), Value::String(policy.executor.clone()));
7213 metadata.insert("isolation_level".to_string(), Value::String("container".to_string()));
7214 metadata.insert("manifest".to_string(), Value::Object(manifest));
7215 metadata.insert("error_type".to_string(), Value::String(error_type.to_string()));
7216 SandboxResult { ok: false, output: Value::Null, error: Some(error), metadata }
7217 }
7218}
7219
7220impl SandboxExecutor for DockerSandboxExecutor {
7221 fn run_tool(&self, args: State, policy: &SandboxPolicy) -> SandboxResult {
7222 let command = match self.extract_command(&args) {
7223 Ok(command) => command,
7224 Err(error) => return Self::result_error(policy, State::new(), "InvalidSandboxCommand", error),
7225 };
7226 let mut policy_state = State::new();
7227 policy_state.insert("network".to_string(), Value::String(policy.network.clone()));
7228 let manifest = (DockerSandboxAdapter { image: self.image.clone() }).manifest(&policy_state, command.clone());
7229 if !self.allow_command_execution {
7230 return Self::result_error(policy, manifest, "SandboxAdapterNotInstalled", "command execution is not enabled for this executor".to_string());
7231 }
7232 let argv = self.docker_argv(policy, &command);
7233 let mut cmd = Command::new(&argv[0]);
7234 cmd.args(&argv[1..]).stdout(Stdio::piped()).stderr(Stdio::piped());
7235 let output = match cmd.output() {
7236 Ok(output) => output,
7237 Err(error) => return Self::result_error(policy, manifest, "SandboxBinaryMissing", error.to_string()),
7238 };
7239 let mut value = State::new();
7240 value.insert("stdout".to_string(), Value::String(String::from_utf8_lossy(&output.stdout).to_string()));
7241 value.insert("stderr".to_string(), Value::String(String::from_utf8_lossy(&output.stderr).to_string()));
7242 value.insert("returncode".to_string(), Value::Number(output.status.code().unwrap_or(-1) as f64));
7243 let mut metadata = State::new();
7244 metadata.insert("executor".to_string(), Value::String(policy.executor.clone()));
7245 metadata.insert("isolation_level".to_string(), Value::String("container".to_string()));
7246 metadata.insert("manifest".to_string(), Value::Object(manifest));
7247 metadata.insert("executed".to_string(), Value::Bool(true));
7248 if !output.status.success() {
7249 metadata.insert("error_type".to_string(), Value::String("SandboxCommandFailed".to_string()));
7250 return SandboxResult {
7251 ok: false,
7252 output: Value::Object(value),
7253 error: Some(format!("sandbox command exited with {}", output.status.code().unwrap_or(-1))),
7254 metadata,
7255 };
7256 }
7257 SandboxResult { ok: true, output: Value::Object(value), error: None, metadata }
7258 }
7259}
7260
7261pub mod adapters {
7262 pub mod postgres {
7263 pub const PACKAGE_NAME: &str = "agentledger-postgres";
7264 pub const FEATURE: &str = "adapter-postgres";
7265 pub use crate::{migrations_for, Migration, PostgresAdapter, SqlExecutor};
7266 }
7267
7268 pub mod mysql {
7269 pub const PACKAGE_NAME: &str = "agentledger-mysql";
7270 pub const FEATURE: &str = "adapter-mysql";
7271 pub use crate::{migrations_for, Migration, MySQLAdapter, SqlExecutor};
7272 }
7273
7274 pub mod s3 {
7275 pub const PACKAGE_NAME: &str = "agentledger-s3";
7276 pub const FEATURE: &str = "adapter-s3";
7277 pub use crate::{ObjectClient, S3BlobStoreAdapter};
7278 }
7279
7280 pub mod mcp {
7281 pub const PACKAGE_NAME: &str = "agentledger-mcp";
7282 pub const FEATURE: &str = "adapter-mcp";
7283 pub use crate::{
7284 InMemoryMCPContextServer, InMemoryMCPToolServer, MCPCall, MCPContextAdapter,
7285 MCPResourceDescriptor, MCPResourceRead, MCPToolAdapter,
7286 };
7287 }
7288
7289 pub mod otel {
7290 pub const PACKAGE_NAME: &str = "agentledger-otel";
7291 pub const FEATURE: &str = "adapter-otel";
7292 pub use crate::{OtlpClient, OtlpTransport};
7293 }
7294
7295 pub mod langfuse {
7296 pub const PACKAGE_NAME: &str = "agentledger-langfuse";
7297 pub const FEATURE: &str = "adapter-langfuse";
7298 pub const CATEGORY: &str = "observability";
7299 }
7300
7301 pub mod docker {
7302 pub const PACKAGE_NAME: &str = "agentledger-sandbox-docker";
7303 pub const FEATURE: &str = "adapter-docker";
7304 pub use crate::{DockerSandboxAdapter, DockerSandboxExecutor, State, Value};
7305 }
7306
7307 pub mod framework {
7308 pub const PACKAGE_NAME: &str = "agentledger-framework";
7309 pub const FEATURE: &str = "adapter-framework";
7310 pub use crate::{FunctionAdapter, MethodFrameworkAdapter};
7311 }
7312}