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