1use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9use std::sync::RwLock;
10
11use crate::session_context::observed_prefix::{ContextPlanningTrace, ObservedPrefixAnchor};
12use orchestral_core::agent_protocol::wire::{
13 AgentAdmission, AgentCommandEnvelope, AgentEvent, AgentEventDraft, AgentEventId,
14 AgentExecutionRef, AgentStartRequest, CommandId, Digest, ProviderCommandOutcome, RunId,
15};
16use orchestral_core::agent_session::SessionSourceRange;
17use orchestral_core::model_protocol::{
18 ModelContent, ModelFinishReason, ModelRequestId, ModelToolCallId, ModelUsage,
19};
20use orchestral_core::tool_protocol::ApprovalCapability;
21use serde::{Deserialize, Serialize};
22
23macro_rules! string_id {
24 ($name:ident) => {
25 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26 #[serde(transparent)]
27 pub struct $name(String);
28
29 impl $name {
30 pub fn new(value: impl Into<String>) -> Self {
31 Self(value.into())
32 }
33
34 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37
38 pub fn is_empty(&self) -> bool {
39 self.0.trim().is_empty()
40 }
41 }
42
43 impl fmt::Display for $name {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 formatter.write_str(&self.0)
46 }
47 }
48 };
49}
50
51string_id!(GenericCheckpointEventId);
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct GenericAgentRunRegistration {
57 pub request: AgentStartRequest,
58 pub execution: AgentExecutionRef,
59 pub admission: AgentAdmission,
60 pub config_digest: Digest,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct GenericObservedToolCall {
72 pub call_id: ModelToolCallId,
73 pub name: String,
74 #[serde(default)]
75 pub arguments: String,
76 #[serde(default)]
77 pub extensions: BTreeMap<String, serde_json::Value>,
78 pub ended: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct GenericModelObservation {
87 pub finish_reason: ModelFinishReason,
88 #[serde(default)]
89 pub response: String,
90 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92 pub continuation: BTreeMap<String, serde_json::Value>,
93 #[serde(default)]
94 pub usage: Option<ModelUsage>,
95 #[serde(default)]
96 pub tool_calls: Vec<GenericObservedToolCall>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct GenericModelContextTrace {
105 pub through_session_seq: u64,
106 pub included_ranges: Vec<SessionSourceRange>,
107 pub deferred_ranges: Vec<SessionSourceRange>,
108 pub config_digest: Digest,
109 pub history_limit: usize,
110 pub used_input_tokens: u64,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub context_estimate: Option<orchestral_core::model_protocol::ModelContextEstimate>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub planning: Option<ContextPlanningTrace>,
115 pub input_budget_tokens: u64,
116}
117
118impl GenericModelContextTrace {
119 pub(crate) fn validate(&self) -> Result<(), GenericCheckpointError> {
120 if self.planning.as_ref().is_some_and(|planning| {
121 !planning.input.validate()
122 || planning.input.raw_estimate_tokens > self.used_input_tokens
123 || self.context_estimate.is_none()
124 || planning
125 .anchor
126 .as_ref()
127 .is_some_and(|anchor| !anchor.validate())
128 }) {
129 return Err(GenericCheckpointError::InvalidData(
130 "invalid context planning provenance".to_owned(),
131 ));
132 }
133 if let Some(estimate) = &self.context_estimate {
134 if estimate.accounting
135 != orchestral_core::model_protocol::ModelTokenAccounting::Estimated
136 || estimate.tokens > self.used_input_tokens
137 {
138 return Err(GenericCheckpointError::InvalidData(
139 "model Context planning estimate must be marked estimated and within its input bound".to_owned(),
140 ));
141 }
142 }
143 if !self.config_digest.is_sha256()
144 || self.history_limit == 0
145 || self.input_budget_tokens == 0
146 || self
147 .context_estimate
148 .as_ref()
149 .map_or(self.used_input_tokens, |estimate| estimate.tokens)
150 > self.input_budget_tokens
151 {
152 return Err(GenericCheckpointError::InvalidData(
153 "model Context trace requires a config digest and valid Host limits".to_owned(),
154 ));
155 }
156 let ranges = self
157 .included_ranges
158 .iter()
159 .chain(self.deferred_ranges.iter())
160 .collect::<Vec<_>>();
161 for range in &ranges {
162 range.validate().map_err(invalid_data)?;
163 if range.last_session_seq > self.through_session_seq {
164 return Err(GenericCheckpointError::InvalidData(
165 "model Context range exceeds its Session Journal cursor".to_owned(),
166 ));
167 }
168 }
169 for (index, left) in ranges.iter().enumerate() {
170 if ranges.iter().skip(index + 1).any(|right| {
171 left.first_session_seq <= right.last_session_seq
172 && right.first_session_seq <= left.last_session_seq
173 }) {
174 return Err(GenericCheckpointError::InvalidData(
175 "model Context trace ranges must not overlap".to_owned(),
176 ));
177 }
178 }
179 Ok(())
180 }
181
182 pub(crate) fn observed_prefix(
183 &self,
184 run_id: &RunId,
185 request_id: &ModelRequestId,
186 observation: &GenericModelObservation,
187 max_output_tokens: Option<u64>,
188 ) -> Option<ObservedPrefixAnchor> {
189 let planning = self.planning.as_ref()?;
190 let input_tokens = observation.usage.as_ref()?.input_tokens?;
191 if input_tokens == 0
192 || input_tokens > self.used_input_tokens
193 || observation
194 .usage
195 .as_ref()
196 .and_then(|usage| usage.output_tokens)
197 .zip(max_output_tokens)
198 .is_some_and(|(used, cap)| used > cap)
199 || !matches!(
200 observation.finish_reason,
201 ModelFinishReason::Stop | ModelFinishReason::ToolCalls
202 )
203 || (observation.response.is_empty() && observation.tool_calls.is_empty())
204 || observation.tool_calls.iter().any(|call| {
205 !call.ended || serde_json::from_str::<serde_json::Value>(&call.arguments).is_err()
206 })
207 {
208 return None;
209 }
210 Some(ObservedPrefixAnchor {
211 run_id: run_id.clone(),
212 config_digest: self.config_digest.clone(),
213 source_request_id: request_id.clone(),
214 input: planning.input.clone(),
215 observed_input_tokens: input_tokens,
216 })
217 }
218}
219
220impl GenericModelObservation {
221 pub(crate) fn assistant_content(&self) -> Vec<ModelContent> {
222 let mut content = Vec::new();
223 if !self.response.is_empty() {
224 content.push(ModelContent::Text {
225 text: self.response.clone(),
226 });
227 }
228 content.extend(self.continuation.iter().map(|(namespace, value)| {
229 ModelContent::Continuation {
230 namespace: namespace.clone(),
231 value: value.clone(),
232 }
233 }));
234 content
235 }
236
237 fn validate(&self) -> Result<(), GenericCheckpointError> {
238 for content in self.assistant_content() {
239 content.validate().map_err(invalid_data)?;
240 }
241 let mut call_ids = BTreeSet::new();
242 if self.tool_calls.iter().any(|call| {
243 call.call_id.is_empty()
244 || call.name.trim().is_empty()
245 || !call_ids.insert(call.call_id.clone())
246 }) {
247 return Err(GenericCheckpointError::InvalidData(
248 "model observation Tool calls require unique identities and names".to_owned(),
249 ));
250 }
251 Ok(())
252 }
253}
254
255impl GenericAgentRunRegistration {
256 pub fn run_id(&self) -> &RunId {
257 &self.execution.run_id
258 }
259
260 pub fn validate(&self) -> Result<(), GenericCheckpointError> {
261 self.request
262 .run
263 .validate_integrity()
264 .map_err(invalid_data)?;
265 self.execution.validate_integrity().map_err(invalid_data)?;
266 self.admission.validate_integrity().map_err(invalid_data)?;
267 if !self.config_digest.is_sha256()
268 || self.execution.run_id != self.request.run.spec.run_id
269 || self.execution.session_id != self.request.run.spec.session_id
270 || self.execution.spec_digest != self.request.run.spec_digest
271 || self.execution.binding_ref != self.request.provider_binding
272 || self.execution.descriptor_digest != self.request.expected_descriptor_digest
273 {
274 return Err(GenericCheckpointError::InvalidData(
275 "Generic Agent registration identities or config digest do not agree".to_owned(),
276 ));
277 }
278 Ok(())
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
287#[non_exhaustive]
288#[allow(clippy::large_enum_variant)]
291pub enum GenericCheckpointEvent {
292 LoopBoundaryCommitted {
293 next_model_round: u64,
294 #[serde(default)]
295 usage: ModelUsage,
296 tool_call_count: u64,
297 #[serde(default)]
298 last_response: String,
299 #[serde(default)]
300 supporting_event_ids: Vec<AgentEventId>,
301 },
302 ModelAttemptStarted {
303 round: u64,
304 request_id: ModelRequestId,
305 request_digest: Digest,
306 #[serde(default)]
307 max_output_tokens: Option<u64>,
308 context: GenericModelContextTrace,
309 },
310 ModelAttemptObserved {
311 round: u64,
312 request_id: ModelRequestId,
313 observation: GenericModelObservation,
314 },
315 ModelContextRejected {
319 round: u64,
320 request_id: ModelRequestId,
321 retry_number: u32,
322 input_budget_tokens: u64,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 output_budget_tokens: Option<u64>,
325 error: orchestral_core::model_protocol::ModelError,
326 },
327 ModelRetryScheduled {
331 round: u64,
332 request_id: ModelRequestId,
333 retry_number: u32,
334 delay_ms: u64,
335 error: orchestral_core::model_protocol::ModelError,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
339 observed_usage: Option<ModelUsage>,
340 },
341 WorkflowAttemptStarted {
345 round: u64,
346 request_id: ModelRequestId,
347 call_id: ModelToolCallId,
348 arguments_digest: Digest,
349 },
350 CommandCommitted {
351 command: AgentCommandEnvelope,
352 outcome: ProviderCommandOutcome,
353 #[serde(default)]
354 approval_capability: Option<ApprovalCapability>,
355 },
356 ProviderEventsCommitted { events: Vec<AgentEventDraft> },
360}
361
362impl GenericCheckpointEvent {
363 fn validate(&self, run_id: &RunId) -> Result<(), GenericCheckpointError> {
364 match self {
365 Self::ModelContextRejected {
366 round,
367 request_id,
368 retry_number,
369 input_budget_tokens,
370 output_budget_tokens,
371 error,
372 } => {
373 if *round == 0
374 || round.checked_add(1).is_none()
375 || request_id.is_empty()
376 || *retry_number == 0
377 || *input_budget_tokens == 0
378 || *output_budget_tokens == Some(0)
379 || error.code
380 != orchestral_core::model_protocol::ModelErrorCode::ContextLengthExceeded
381 {
382 return Err(GenericCheckpointError::InvalidData(
383 "context recovery requires a definite capacity rejection and positive budget".to_owned(),
384 ));
385 }
386 }
387 Self::ModelRetryScheduled {
388 round,
389 request_id,
390 retry_number,
391 delay_ms,
392 error,
393 ..
394 } => {
395 if *round == 0
396 || request_id.is_empty()
397 || *retry_number == 0
398 || *delay_ms == 0
399 || !error.retryable
400 || !matches!(
401 error.code,
402 orchestral_core::model_protocol::ModelErrorCode::RateLimited
403 | orchestral_core::model_protocol::ModelErrorCode::Unavailable
404 )
405 {
406 return Err(GenericCheckpointError::InvalidData(
407 "model retry requires an attempt identity, delay, and transient error"
408 .to_owned(),
409 ));
410 }
411 }
412 Self::LoopBoundaryCommitted {
413 next_model_round,
414 supporting_event_ids,
415 ..
416 } => {
417 if *next_model_round == 0
418 || supporting_event_ids.iter().any(AgentEventId::is_empty)
419 || supporting_event_ids.iter().collect::<BTreeSet<_>>().len()
420 != supporting_event_ids.len()
421 {
422 return Err(GenericCheckpointError::InvalidData(
423 "loop boundary requires a positive round and unique event references"
424 .to_owned(),
425 ));
426 }
427 }
428 Self::ModelAttemptStarted {
429 round,
430 request_id,
431 request_digest,
432 max_output_tokens,
433 context,
434 } => {
435 if *round == 0
436 || request_id.is_empty()
437 || !request_digest.is_sha256()
438 || *max_output_tokens == Some(0)
439 {
440 return Err(GenericCheckpointError::InvalidData(
441 "model attempt requires a round, request identity, and digest".to_owned(),
442 ));
443 }
444 context.validate()?;
445 }
446 Self::ModelAttemptObserved {
447 round,
448 request_id,
449 observation,
450 } => {
451 if *round == 0 || request_id.is_empty() {
452 return Err(GenericCheckpointError::InvalidData(
453 "model observation requires a round and request identity".to_owned(),
454 ));
455 }
456 observation.validate()?;
457 }
458 Self::WorkflowAttemptStarted {
459 round,
460 request_id,
461 call_id,
462 arguments_digest,
463 } => {
464 if *round == 0
465 || request_id.is_empty()
466 || call_id.is_empty()
467 || !arguments_digest.is_sha256()
468 {
469 return Err(GenericCheckpointError::InvalidData(
470 "workflow attempt requires model, call, and argument identities".to_owned(),
471 ));
472 }
473 }
474 Self::CommandCommitted {
475 command,
476 outcome,
477 approval_capability,
478 } => {
479 command.verify_digest().map_err(invalid_data)?;
480 outcome.validate_shape().map_err(invalid_data)?;
481 if command.run_id != *run_id {
482 return Err(GenericCheckpointError::InvalidData(
483 "checkpoint command crossed a Run boundary".to_owned(),
484 ));
485 }
486 let accepted_allow = matches!(
487 (&command.payload, outcome),
488 (
489 orchestral_core::agent_protocol::wire::AgentCommand::ResolveRequest {
490 response: orchestral_core::agent_protocol::wire::RequestResolution::Approval {
491 decision: orchestral_core::agent_protocol::wire::ApprovalDecision::Allow,
492 ..
493 }
494 },
495 ProviderCommandOutcome::Accepted
496 )
497 );
498 if accepted_allow != approval_capability.is_some()
499 || approval_capability.as_ref().is_some_and(|capability| {
500 capability.claims.binding.run_id != *run_id
501 || !capability.authenticator.is_sha256()
502 })
503 {
504 return Err(GenericCheckpointError::InvalidData(
505 "checkpoint approval capability does not match its accepted command"
506 .to_owned(),
507 ));
508 }
509 }
510 Self::ProviderEventsCommitted { events } => {
511 if events.is_empty() {
512 return Err(GenericCheckpointError::InvalidData(
513 "Provider event checkpoint batch must not be empty".to_owned(),
514 ));
515 }
516 let mut event_ids = BTreeSet::new();
517 let mut terminal_seen = false;
518 for (index, event) in events.iter().enumerate() {
519 event.validate_integrity().map_err(invalid_data)?;
520 if event.run_id != *run_id || !event_ids.insert(&event.event_id) {
521 return Err(GenericCheckpointError::InvalidData(
522 "Provider checkpoint events must be unique and Run-bound".to_owned(),
523 ));
524 }
525 let terminal = is_terminal_event(&event.payload);
526 if terminal_seen || (terminal && index + 1 != events.len()) {
527 return Err(GenericCheckpointError::InvalidData(
528 "terminal Provider event must be the final event in its batch"
529 .to_owned(),
530 ));
531 }
532 terminal_seen = terminal;
533 }
534 }
535 }
536 Ok(())
537 }
538}
539
540#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
541#[serde(deny_unknown_fields)]
542pub struct GenericCheckpointDraft {
543 pub event_id: GenericCheckpointEventId,
544 pub run_id: RunId,
545 pub payload: GenericCheckpointEvent,
546}
547
548impl GenericCheckpointDraft {
549 pub fn validate(&self) -> Result<(), GenericCheckpointError> {
550 if self.event_id.is_empty() || self.run_id.is_empty() {
551 return Err(GenericCheckpointError::InvalidData(
552 "Generic checkpoint identities must not be empty".to_owned(),
553 ));
554 }
555 self.payload.validate(&self.run_id)
556 }
557
558 pub fn digest(&self) -> Result<Digest, GenericCheckpointError> {
559 self.validate()?;
560 canonical_digest(self)
561 }
562}
563
564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
565#[serde(deny_unknown_fields)]
566pub struct GenericCheckpointRecord {
567 pub checkpoint_seq: u64,
568 pub draft_digest: Digest,
569 pub event_digest: Digest,
570 pub event_id: GenericCheckpointEventId,
571 pub run_id: RunId,
572 pub payload: GenericCheckpointEvent,
573}
574
575#[derive(Serialize)]
576struct GenericCheckpointRecordDigestView<'a> {
577 checkpoint_seq: u64,
578 draft_digest: &'a Digest,
579 event_id: &'a GenericCheckpointEventId,
580 run_id: &'a RunId,
581 payload: &'a GenericCheckpointEvent,
582}
583
584impl GenericCheckpointRecord {
585 pub fn seal(
589 draft: GenericCheckpointDraft,
590 checkpoint_seq: u64,
591 ) -> Result<Self, GenericCheckpointError> {
592 draft.validate()?;
593 if checkpoint_seq == 0 {
594 return Err(GenericCheckpointError::InvalidData(
595 "checkpoint sequence must be positive".to_owned(),
596 ));
597 }
598 let draft_digest = draft.digest()?;
599 let mut record = Self {
600 checkpoint_seq,
601 draft_digest,
602 event_digest: Digest::sha256([]),
603 event_id: draft.event_id,
604 run_id: draft.run_id,
605 payload: draft.payload,
606 };
607 record.event_digest = record.computed_event_digest()?;
608 Ok(record)
609 }
610
611 pub fn validate(&self) -> Result<(), GenericCheckpointError> {
612 if self.checkpoint_seq == 0
613 || self.event_id.is_empty()
614 || self.run_id.is_empty()
615 || !self.draft_digest.is_sha256()
616 || self.computed_event_digest()? != self.event_digest
617 {
618 return Err(GenericCheckpointError::InvalidData(
619 "Generic checkpoint record identity or digest is invalid".to_owned(),
620 ));
621 }
622 self.payload.validate(&self.run_id)
623 }
624
625 fn computed_event_digest(&self) -> Result<Digest, GenericCheckpointError> {
626 canonical_digest(&GenericCheckpointRecordDigestView {
627 checkpoint_seq: self.checkpoint_seq,
628 draft_digest: &self.draft_digest,
629 event_id: &self.event_id,
630 run_id: &self.run_id,
631 payload: &self.payload,
632 })
633 }
634}
635
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
637#[serde(deny_unknown_fields)]
638pub struct StoredGenericAgentRun {
639 pub registration: GenericAgentRunRegistration,
640 pub records: Vec<GenericCheckpointRecord>,
641}
642
643impl StoredGenericAgentRun {
644 pub fn validate(&self) -> Result<GenericAgentCheckpointProjection, GenericCheckpointError> {
645 self.registration.validate()?;
646 replay_generic_agent_checkpoint(self)
647 }
648
649 pub fn last_checkpoint_seq(&self) -> u64 {
650 self.records
651 .last()
652 .map(|record| record.checkpoint_seq)
653 .unwrap_or(0)
654 }
655}
656
657#[derive(Debug, Clone, PartialEq)]
658pub struct GenericLoopBoundary {
659 pub next_model_round: u64,
660 pub usage: ModelUsage,
661 pub tool_call_count: u64,
662 pub last_response: String,
663 pub supporting_event_ids: Vec<AgentEventId>,
664}
665
666#[derive(Debug, Clone, PartialEq)]
667#[non_exhaustive]
668pub enum GenericCheckpointPhase {
669 Prepared,
670 Stable(GenericLoopBoundary),
671 ModelAttemptOpen {
672 boundary: GenericLoopBoundary,
673 round: u64,
674 request_id: ModelRequestId,
675 request_digest: Digest,
676 },
677 ModelAttemptObserved {
678 boundary: GenericLoopBoundary,
679 round: u64,
680 request_id: ModelRequestId,
681 request_digest: Digest,
682 observation: GenericModelObservation,
683 },
684 WorkflowAttemptOpen {
685 boundary: GenericLoopBoundary,
686 round: u64,
687 request_id: ModelRequestId,
688 request_digest: Digest,
689 observation: GenericModelObservation,
690 call_id: ModelToolCallId,
691 arguments_digest: Digest,
692 },
693 Terminal,
694}
695
696impl GenericCheckpointPhase {
697 pub fn is_stable(&self) -> bool {
698 matches!(self, Self::Stable(_))
699 }
700}
701
702#[derive(Debug, Clone, PartialEq)]
703pub struct GenericAgentCheckpointProjection {
704 pub phase: GenericCheckpointPhase,
705 pub provider_events: Vec<AgentEventDraft>,
706 pub commands: BTreeMap<CommandId, CommandCheckpoint>,
707 pub last_checkpoint_seq: u64,
708 pub observed_prefix: Option<ObservedPrefixAnchor>,
710 pub context_recovery: Option<GenericContextRecovery>,
711}
712
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct GenericContextRecovery {
715 pub retry_number: u32,
718 pub input_budget_tokens: u64,
719 pub output_budget_tokens: Option<u64>,
720 pub compact_input: bool,
721}
722
723impl GenericContextRecovery {
724 pub(crate) fn input_capacity_tokens(&self) -> Option<u64> {
728 (self.retry_number > 0 || self.compact_input).then_some(self.input_budget_tokens)
729 }
730
731 pub(crate) fn compaction_target_tokens(&self) -> Option<u64> {
734 (self.retry_number > 0 && self.compact_input).then(|| self.input_budget_tokens.div_ceil(2))
735 }
736
737 pub(crate) fn generation_observed(&mut self) {
738 self.retry_number = 0;
739 }
740}
741
742#[derive(Debug, Clone, PartialEq)]
743pub struct CommandCheckpoint {
744 pub command: AgentCommandEnvelope,
745 pub outcome: ProviderCommandOutcome,
746 pub approval_capability: Option<ApprovalCapability>,
747}
748
749pub fn replay_generic_agent_checkpoint(
750 run: &StoredGenericAgentRun,
751) -> Result<GenericAgentCheckpointProjection, GenericCheckpointError> {
752 run.registration.validate()?;
753 let run_id = run.registration.run_id();
754 let mut phase = GenericCheckpointPhase::Prepared;
755 let mut provider_events = Vec::new();
756 let mut provider_event_digests = BTreeMap::<AgentEventId, Digest>::new();
757 let mut commands = BTreeMap::<CommandId, CommandCheckpoint>::new();
758 let mut checkpoint_ids = BTreeMap::<GenericCheckpointEventId, Digest>::new();
759 let mut last_retry_number = 0_u32;
760 let mut observed_prefix = None;
761 let mut context_recovery: Option<GenericContextRecovery> = None;
762 let mut started_context: Option<(GenericModelContextTrace, Option<u64>)> = None;
763
764 for (index, record) in run.records.iter().enumerate() {
765 record.validate()?;
766 let expected_seq = index as u64 + 1;
767 if record.run_id != *run_id || record.checkpoint_seq != expected_seq {
768 return Err(GenericCheckpointError::InvalidData(format!(
769 "Generic checkpoint sequence mismatch at {expected_seq}"
770 )));
771 }
772 if let Some(existing) =
773 checkpoint_ids.insert(record.event_id.clone(), record.draft_digest.clone())
774 {
775 return Err(if existing == record.draft_digest {
776 GenericCheckpointError::InvalidData(
777 "stored Generic checkpoint contains a duplicate record".to_owned(),
778 )
779 } else {
780 GenericCheckpointError::EventConflict(record.event_id.clone())
781 });
782 }
783 if matches!(phase, GenericCheckpointPhase::Terminal) {
784 return Err(GenericCheckpointError::InvalidData(
785 "Generic checkpoint contains facts after terminal".to_owned(),
786 ));
787 }
788
789 match &record.payload {
790 GenericCheckpointEvent::ModelContextRejected {
791 round,
792 request_id,
793 retry_number,
794 input_budget_tokens,
795 output_budget_tokens,
796 ..
797 } => {
798 let GenericCheckpointPhase::ModelAttemptOpen {
799 boundary,
800 round: open_round,
801 request_id: open_request_id,
802 ..
803 } = &phase
804 else {
805 return Err(GenericCheckpointError::InvalidData(
806 "context rejection must close an open model attempt".to_owned(),
807 ));
808 };
809 let (trace, rejected_output) = &started_context.as_ref().ok_or_else(|| {
810 GenericCheckpointError::InvalidData("missing rejected context trace".to_owned())
811 })?;
812 let planned_input = trace
813 .context_estimate
814 .as_ref()
815 .map_or(trace.used_input_tokens, |estimate| estimate.tokens);
816 if round != open_round
817 || request_id != open_request_id
818 || context_recovery
819 .as_ref()
820 .map_or(Some(1), |prior| prior.retry_number.checked_add(1))
821 != Some(*retry_number)
822 || *input_budget_tokens > trace.input_budget_tokens
823 || output_budget_tokens
824 .is_some_and(|output| rejected_output.is_none_or(|prior| output > prior))
825 || !(*input_budget_tokens < planned_input
826 || output_budget_tokens.is_some_and(|output| {
827 rejected_output.is_some_and(|prior| output < prior)
828 }))
829 {
830 return Err(GenericCheckpointError::InvalidData(
831 "context recovery must advance its rejection count and reduce the rejected input or output budget".to_owned(),
832 ));
833 }
834 let mut next = boundary.clone();
835 next.next_model_round = round.checked_add(1).ok_or_else(|| {
836 GenericCheckpointError::InvalidData(
837 "context recovery round overflow".to_owned(),
838 )
839 })?;
840 phase = GenericCheckpointPhase::Stable(next);
841 context_recovery = Some(GenericContextRecovery {
842 retry_number: *retry_number,
843 input_budget_tokens: *input_budget_tokens,
844 output_budget_tokens: *output_budget_tokens,
845 compact_input: *input_budget_tokens < planned_input
846 || context_recovery
847 .as_ref()
848 .is_some_and(|prior| prior.compact_input),
849 });
850 }
851 GenericCheckpointEvent::ModelRetryScheduled {
852 round,
853 request_id,
854 retry_number,
855 observed_usage,
856 ..
857 } => {
858 if !matches!(&phase, GenericCheckpointPhase::ModelAttemptOpen {
859 round: open_round, request_id: open_request_id, ..
860 } if round == open_round && request_id == open_request_id)
861 || last_retry_number.checked_add(1) != Some(*retry_number)
862 {
863 return Err(GenericCheckpointError::InvalidData(
864 "model retry must advance the retry sequence of its open attempt"
865 .to_owned(),
866 ));
867 }
868 last_retry_number = *retry_number;
869 if let (Some(usage), GenericCheckpointPhase::ModelAttemptOpen { boundary, .. }) =
870 (observed_usage, &mut phase)
871 {
872 for (total, observed) in [
873 (&mut boundary.usage.input_tokens, usage.input_tokens),
874 (&mut boundary.usage.output_tokens, usage.output_tokens),
875 ] {
876 if let Some(observed) = observed {
877 *total = Some(total.unwrap_or(0).saturating_add(observed));
878 }
879 }
880 }
881 }
882 GenericCheckpointEvent::LoopBoundaryCommitted {
883 next_model_round,
884 usage,
885 tool_call_count,
886 last_response,
887 supporting_event_ids,
888 } => {
889 match &phase {
890 GenericCheckpointPhase::Prepared if *next_model_round == 1 => {}
891 GenericCheckpointPhase::ModelAttemptOpen { round, .. }
892 | GenericCheckpointPhase::ModelAttemptObserved { round, .. }
893 | GenericCheckpointPhase::WorkflowAttemptOpen { round, .. }
894 if *next_model_round > *round => {}
895 _ => {
896 return Err(GenericCheckpointError::InvalidData(
897 "loop boundary does not close the current checkpoint phase".to_owned(),
898 ))
899 }
900 }
901 phase = GenericCheckpointPhase::Stable(GenericLoopBoundary {
902 next_model_round: *next_model_round,
903 usage: usage.clone(),
904 tool_call_count: *tool_call_count,
905 last_response: last_response.clone(),
906 supporting_event_ids: supporting_event_ids.clone(),
907 });
908 }
909 GenericCheckpointEvent::ModelAttemptStarted {
910 round,
911 request_id,
912 request_digest,
913 max_output_tokens,
914 context,
915 } => {
916 let GenericCheckpointPhase::Stable(boundary) = &phase else {
917 return Err(GenericCheckpointError::InvalidData(
918 "model attempt did not begin at a stable loop boundary".to_owned(),
919 ));
920 };
921 if *round != boundary.next_model_round {
922 return Err(GenericCheckpointError::InvalidData(
923 "model attempt round does not match the stable boundary".to_owned(),
924 ));
925 }
926 if context_recovery
927 .as_ref()
928 .and_then(GenericContextRecovery::input_capacity_tokens)
929 .is_some_and(|ceiling| context.input_budget_tokens > ceiling)
930 {
931 return Err(GenericCheckpointError::InvalidData(
932 "model retry exceeded its durable recovery input budget".to_owned(),
933 ));
934 }
935 if context.planning.as_ref().is_some_and(|planning| {
936 context.config_digest != run.registration.config_digest
937 || planning
938 .anchor
939 .as_ref()
940 .is_some_and(|anchor| Some(anchor) != observed_prefix.as_ref())
941 }) {
942 return Err(GenericCheckpointError::InvalidData(
943 "context anchor does not match an earlier completed request in this Run"
944 .to_owned(),
945 ));
946 }
947 started_context = Some((context.clone(), *max_output_tokens));
948 phase = GenericCheckpointPhase::ModelAttemptOpen {
949 boundary: boundary.clone(),
950 round: *round,
951 request_id: request_id.clone(),
952 request_digest: request_digest.clone(),
953 };
954 last_retry_number = 0;
955 }
956 GenericCheckpointEvent::ModelAttemptObserved {
957 round,
958 request_id,
959 observation,
960 } => {
961 let GenericCheckpointPhase::ModelAttemptOpen {
962 boundary,
963 round: open_round,
964 request_id: open_request_id,
965 request_digest,
966 } = &phase
967 else {
968 return Err(GenericCheckpointError::InvalidData(
969 "model observation did not close an open attempt".to_owned(),
970 ));
971 };
972 if round != open_round || request_id != open_request_id {
973 return Err(GenericCheckpointError::InvalidData(
974 "model observation identity does not match its open attempt".to_owned(),
975 ));
976 }
977 observed_prefix = started_context.as_ref().and_then(|(context, cap)| {
978 context.observed_prefix(run_id, request_id, observation, *cap)
979 });
980 if let Some(recovery) = &mut context_recovery {
981 recovery.generation_observed();
982 }
983 phase = GenericCheckpointPhase::ModelAttemptObserved {
984 boundary: boundary.clone(),
985 round: *round,
986 request_id: request_id.clone(),
987 request_digest: request_digest.clone(),
988 observation: observation.clone(),
989 };
990 }
991 GenericCheckpointEvent::WorkflowAttemptStarted {
992 round,
993 request_id,
994 call_id,
995 arguments_digest,
996 } => {
997 let GenericCheckpointPhase::ModelAttemptObserved {
998 boundary,
999 round: observed_round,
1000 request_id: observed_request_id,
1001 request_digest,
1002 observation,
1003 } = &phase
1004 else {
1005 return Err(GenericCheckpointError::InvalidData(
1006 "workflow attempt did not begin from an observed model call".to_owned(),
1007 ));
1008 };
1009 let matching_call = observation.tool_calls.iter().find(|call| {
1010 call.call_id == *call_id
1011 && call.name == "orchestral_workflow"
1012 && call.ended
1013 && Digest::sha256(call.arguments.as_bytes()) == *arguments_digest
1014 });
1015 if round != observed_round
1016 || request_id != observed_request_id
1017 || matching_call.is_none()
1018 {
1019 return Err(GenericCheckpointError::InvalidData(
1020 "workflow attempt identity does not match its observed model call"
1021 .to_owned(),
1022 ));
1023 }
1024 phase = GenericCheckpointPhase::WorkflowAttemptOpen {
1025 boundary: boundary.clone(),
1026 round: *round,
1027 request_id: request_id.clone(),
1028 request_digest: request_digest.clone(),
1029 observation: observation.clone(),
1030 call_id: call_id.clone(),
1031 arguments_digest: arguments_digest.clone(),
1032 };
1033 }
1034 GenericCheckpointEvent::CommandCommitted {
1035 command,
1036 outcome,
1037 approval_capability,
1038 } => {
1039 if let Some(existing) = commands.get(&command.command_id) {
1040 if existing.command != *command
1041 || existing.outcome != *outcome
1042 || existing.approval_capability != *approval_capability
1043 {
1044 return Err(GenericCheckpointError::InvalidData(
1045 "command identity was reused with different checkpoint content"
1046 .to_owned(),
1047 ));
1048 }
1049 return Err(GenericCheckpointError::InvalidData(
1050 "stored Generic checkpoint contains a duplicate command".to_owned(),
1051 ));
1052 }
1053 commands.insert(
1054 command.command_id.clone(),
1055 CommandCheckpoint {
1056 command: command.clone(),
1057 outcome: outcome.clone(),
1058 approval_capability: approval_capability.clone(),
1059 },
1060 );
1061 }
1062 GenericCheckpointEvent::ProviderEventsCommitted { events } => {
1063 for event in events {
1064 let digest = event.computed_digest().map_err(invalid_data)?;
1065 if let Some(existing) = provider_event_digests.get(&event.event_id) {
1066 if existing != &digest {
1067 return Err(GenericCheckpointError::InvalidData(
1068 "Provider event identity was reused with different content"
1069 .to_owned(),
1070 ));
1071 }
1072 return Err(GenericCheckpointError::InvalidData(
1073 "stored Generic checkpoint contains a duplicate Provider event"
1074 .to_owned(),
1075 ));
1076 }
1077 provider_event_digests.insert(event.event_id.clone(), digest);
1078 provider_events.push(event.clone());
1079 if is_terminal_event(&event.payload) {
1080 phase = GenericCheckpointPhase::Terminal;
1081 }
1082 }
1083 }
1084 }
1085 }
1086
1087 Ok(GenericAgentCheckpointProjection {
1088 phase,
1089 provider_events,
1090 commands,
1091 last_checkpoint_seq: run.last_checkpoint_seq(),
1092 observed_prefix,
1093 context_recovery,
1094 })
1095}
1096
1097fn is_terminal_event(event: &AgentEvent) -> bool {
1098 matches!(
1099 event,
1100 AgentEvent::DeliveryCommitted { .. }
1101 | AgentEvent::RunIncomplete { .. }
1102 | AgentEvent::RunFailed { .. }
1103 | AgentEvent::RunCancelled { .. }
1104 )
1105}
1106
1107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1108pub enum CreateGenericRunOutcome {
1109 Created,
1110 ExactExisting,
1111}
1112
1113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1114pub enum AppendGenericCheckpointOutcome {
1115 Appended,
1116 ExactDuplicate,
1117}
1118
1119#[derive(Debug, thiserror::Error)]
1120#[non_exhaustive]
1121pub enum GenericCheckpointError {
1122 #[error("Generic Agent checkpoint storage is unavailable: {0}")]
1123 Unavailable(String),
1124 #[error("Generic Agent checkpoint Run does not exist: {0}")]
1125 RunNotFound(RunId),
1126 #[error("Generic Agent checkpoint Run conflicts with durable state: {0}")]
1127 RunConflict(RunId),
1128 #[error(
1129 "Generic Agent checkpoint sequence conflict for {run_id}: expected previous {expected_previous}, durable previous {actual_previous}"
1130 )]
1131 SequenceConflict {
1132 run_id: RunId,
1133 expected_previous: u64,
1134 actual_previous: u64,
1135 },
1136 #[error("Generic Agent checkpoint event identity conflict: {0}")]
1137 EventConflict(GenericCheckpointEventId),
1138 #[error("Generic Agent checkpoint data is invalid: {0}")]
1139 InvalidData(String),
1140}
1141
1142pub trait GenericAgentCheckpointStore: Send + Sync {
1146 fn load_run(
1147 &self,
1148 run_id: &RunId,
1149 ) -> Result<Option<StoredGenericAgentRun>, GenericCheckpointError>;
1150
1151 fn create_run(
1152 &self,
1153 registration: GenericAgentRunRegistration,
1154 ) -> Result<CreateGenericRunOutcome, GenericCheckpointError>;
1155
1156 fn append(
1157 &self,
1158 run_id: &RunId,
1159 expected_previous: u64,
1160 draft: GenericCheckpointDraft,
1161 ) -> Result<AppendGenericCheckpointOutcome, GenericCheckpointError>;
1162}
1163
1164#[derive(Default)]
1165pub struct InMemoryGenericAgentCheckpointStore {
1166 runs: RwLock<BTreeMap<RunId, StoredGenericAgentRun>>,
1167}
1168
1169impl GenericAgentCheckpointStore for InMemoryGenericAgentCheckpointStore {
1170 fn load_run(
1171 &self,
1172 run_id: &RunId,
1173 ) -> Result<Option<StoredGenericAgentRun>, GenericCheckpointError> {
1174 let run = self
1175 .runs
1176 .read()
1177 .map_err(|_| GenericCheckpointError::Unavailable("reader lock poisoned".to_owned()))?
1178 .get(run_id)
1179 .cloned();
1180 if let Some(run) = &run {
1181 run.validate()?;
1182 }
1183 Ok(run)
1184 }
1185
1186 fn create_run(
1187 &self,
1188 registration: GenericAgentRunRegistration,
1189 ) -> Result<CreateGenericRunOutcome, GenericCheckpointError> {
1190 registration.validate()?;
1191 let run_id = registration.run_id().clone();
1192 let mut runs = self
1193 .runs
1194 .write()
1195 .map_err(|_| GenericCheckpointError::Unavailable("writer lock poisoned".to_owned()))?;
1196 if let Some(existing) = runs.get(&run_id) {
1197 return if existing.registration == registration {
1198 Ok(CreateGenericRunOutcome::ExactExisting)
1199 } else {
1200 Err(GenericCheckpointError::RunConflict(run_id))
1201 };
1202 }
1203 runs.insert(
1204 run_id,
1205 StoredGenericAgentRun {
1206 registration,
1207 records: Vec::new(),
1208 },
1209 );
1210 Ok(CreateGenericRunOutcome::Created)
1211 }
1212
1213 fn append(
1214 &self,
1215 run_id: &RunId,
1216 expected_previous: u64,
1217 draft: GenericCheckpointDraft,
1218 ) -> Result<AppendGenericCheckpointOutcome, GenericCheckpointError> {
1219 draft.validate()?;
1220 if draft.run_id != *run_id {
1221 return Err(GenericCheckpointError::InvalidData(
1222 "checkpoint append crossed a Run boundary".to_owned(),
1223 ));
1224 }
1225 let draft_digest = draft.digest()?;
1226 let mut runs = self
1227 .runs
1228 .write()
1229 .map_err(|_| GenericCheckpointError::Unavailable("writer lock poisoned".to_owned()))?;
1230 let run = runs
1231 .get_mut(run_id)
1232 .ok_or_else(|| GenericCheckpointError::RunNotFound(run_id.clone()))?;
1233 if let Some(existing) = run
1234 .records
1235 .iter()
1236 .find(|record| record.event_id == draft.event_id)
1237 {
1238 return if existing.draft_digest == draft_digest {
1239 Ok(AppendGenericCheckpointOutcome::ExactDuplicate)
1240 } else {
1241 Err(GenericCheckpointError::EventConflict(draft.event_id))
1242 };
1243 }
1244 let actual_previous = run.last_checkpoint_seq();
1245 if actual_previous != expected_previous {
1246 return Err(GenericCheckpointError::SequenceConflict {
1247 run_id: run_id.clone(),
1248 expected_previous,
1249 actual_previous,
1250 });
1251 }
1252 let record = GenericCheckpointRecord::seal(draft, actual_previous + 1)?;
1253 let mut candidate = run.clone();
1254 candidate.records.push(record.clone());
1255 candidate.validate()?;
1256 run.records.push(record);
1257 Ok(AppendGenericCheckpointOutcome::Appended)
1258 }
1259}
1260
1261fn canonical_digest(value: &impl Serialize) -> Result<Digest, GenericCheckpointError> {
1262 serde_jcs::to_vec(value)
1263 .map(Digest::sha256)
1264 .map_err(|error| GenericCheckpointError::InvalidData(error.to_string()))
1265}
1266
1267fn invalid_data(error: impl fmt::Display) -> GenericCheckpointError {
1268 GenericCheckpointError::InvalidData(error.to_string())
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273 use orchestral_core::agent_protocol::wire::{
1274 AgentDescriptor, AgentDescriptorEnvelope, AgentId, AgentProviderId, AgentRunEnvelope,
1275 AgentSessionId, Content, ProviderBindingRef,
1276 };
1277 use orchestral_core::agent_protocol::AGENT_PROTOCOL_V1;
1278
1279 use super::*;
1280
1281 fn registration() -> GenericAgentRunRegistration {
1282 let descriptor = AgentDescriptorEnvelope::seal(AgentDescriptor {
1283 provider_id: AgentProviderId::new("test/generic"),
1284 agent_id: AgentId::new("generic-v1"),
1285 supported_protocol_versions: vec![AGENT_PROTOCOL_V1],
1286 accepted_content_types: BTreeSet::from(["text/plain".to_owned()]),
1287 capabilities: Default::default(),
1288 extensions: Default::default(),
1289 })
1290 .unwrap();
1291 let run = AgentRunEnvelope::new(
1292 AGENT_PROTOCOL_V1,
1293 AgentSessionId::new("session-1"),
1294 RunId::new("run-1"),
1295 vec![Content::text("hello")],
1296 )
1297 .unwrap();
1298 let request =
1299 AgentStartRequest::new(run, ProviderBindingRef::new("binding-1"), &descriptor).unwrap();
1300 GenericAgentRunRegistration {
1301 execution: AgentExecutionRef::for_start(&request, &descriptor).unwrap(),
1302 request,
1303 admission: AgentAdmission::default(),
1304 config_digest: Digest::sha256("config-v1"),
1305 }
1306 }
1307
1308 fn boundary(run_id: &RunId, next_model_round: u64) -> GenericCheckpointDraft {
1309 GenericCheckpointDraft {
1310 event_id: GenericCheckpointEventId::new(format!("boundary-{next_model_round}")),
1311 run_id: run_id.clone(),
1312 payload: GenericCheckpointEvent::LoopBoundaryCommitted {
1313 next_model_round,
1314 usage: ModelUsage::default(),
1315 tool_call_count: 0,
1316 last_response: String::new(),
1317 supporting_event_ids: Vec::new(),
1318 },
1319 }
1320 }
1321
1322 fn context_trace() -> GenericModelContextTrace {
1323 GenericModelContextTrace {
1324 through_session_seq: 1,
1325 included_ranges: vec![SessionSourceRange {
1326 first_session_seq: 1,
1327 last_session_seq: 1,
1328 }],
1329 deferred_ranges: Vec::new(),
1330 config_digest: Digest::sha256("config-v1"),
1331 history_limit: 128,
1332 used_input_tokens: 10,
1333 context_estimate: None,
1334 planning: None,
1335 input_budget_tokens: 100,
1336 }
1337 }
1338
1339 #[test]
1340 fn model_context_trace_rejects_overlapping_or_over_budget_provenance() {
1341 let mut trace = context_trace();
1342 trace.deferred_ranges = trace.included_ranges.clone();
1343 assert!(trace.validate().is_err());
1344
1345 let mut trace = context_trace();
1346 trace.used_input_tokens = trace.input_budget_tokens + 1;
1347 assert!(trace.validate().is_err());
1348 }
1349
1350 #[test]
1351 fn model_context_trace_keeps_legacy_bounds_and_explicit_estimates_distinct() {
1352 use orchestral_core::model_protocol::{ModelContextEstimate, ModelTokenAccounting};
1353 let legacy = context_trace();
1354 let serialized = serde_json::to_value(&legacy).unwrap();
1355 assert!(serialized.get("context_estimate").is_none());
1356 assert!(serialized.get("planning").is_none());
1357 let restored: GenericModelContextTrace =
1358 serde_json::from_value(serialized.clone()).unwrap();
1359 assert_eq!(
1360 serde_jcs::to_vec(&restored).unwrap(),
1361 serde_jcs::to_vec(&serialized).unwrap()
1362 );
1363 restored.validate().unwrap();
1364 assert_eq!(restored, legacy);
1365
1366 let mut trace = legacy;
1367 trace.used_input_tokens = 900;
1368 trace.context_estimate = Some(ModelContextEstimate {
1369 tokens: 80,
1370 accounting: ModelTokenAccounting::Estimated,
1371 });
1372 trace.validate().unwrap();
1373 let restored: GenericModelContextTrace =
1374 serde_json::from_slice(&serde_json::to_vec(&trace).unwrap()).unwrap();
1375 assert_eq!(restored, trace);
1376 for (tokens, accounting) in [
1377 (101, ModelTokenAccounting::Estimated),
1378 (901, ModelTokenAccounting::Estimated),
1379 (80, ModelTokenAccounting::Exact),
1380 (80, ModelTokenAccounting::ConservativeUpperBound),
1381 ] {
1382 trace.context_estimate = Some(ModelContextEstimate { tokens, accounting });
1383 assert!(trace.validate().is_err());
1384 }
1385 trace.context_estimate = None;
1386 assert!(trace.validate().is_err());
1387 }
1388
1389 #[test]
1390 fn observed_prefix_requires_positive_complete_in_bound_input_usage() {
1391 use crate::session_context::observed_prefix::ContextInputSignature;
1392 use orchestral_core::model_protocol::{ModelContextEstimate, ModelTokenAccounting};
1393 let mut trace = context_trace();
1394 trace.context_estimate = Some(ModelContextEstimate {
1395 tokens: 8,
1396 accounting: ModelTokenAccounting::Estimated,
1397 });
1398 trace.planning = Some(ContextPlanningTrace {
1399 input: ContextInputSignature {
1400 messages_len: 2,
1401 messages_digest: Digest::sha256("messages"),
1402 tools_digest: Digest::sha256("tools"),
1403 raw_estimate_tokens: 8,
1404 },
1405 anchor: None,
1406 });
1407 let observation = GenericModelObservation {
1408 finish_reason: ModelFinishReason::Stop,
1409 response: "complete".to_owned(),
1410 continuation: Default::default(),
1411 tool_calls: Vec::new(),
1412 usage: Some(ModelUsage {
1413 input_tokens: Some(5),
1414 output_tokens: Some(2),
1415 }),
1416 };
1417 let derive = |observation: &GenericModelObservation| {
1418 trace.observed_prefix(
1419 &RunId::new("run"),
1420 &ModelRequestId::new("request"),
1421 observation,
1422 Some(4),
1423 )
1424 };
1425 assert_eq!(derive(&observation).unwrap().observed_input_tokens, 5);
1426 for reason in [
1427 ModelFinishReason::Length,
1428 ModelFinishReason::Cancelled,
1429 ModelFinishReason::ContentFilter,
1430 ModelFinishReason::Other,
1431 ] {
1432 let mut rejected = observation.clone();
1433 rejected.finish_reason = reason;
1434 assert!(derive(&rejected).is_none());
1435 }
1436 for input in [None, Some(0), Some(11)] {
1437 let mut rejected = observation.clone();
1438 rejected.usage.as_mut().unwrap().input_tokens = input;
1439 assert!(derive(&rejected).is_none());
1440 }
1441 let mut rejected = observation.clone();
1442 rejected.usage = None;
1443 assert!(derive(&rejected).is_none());
1444 let mut rejected = observation.clone();
1445 rejected.usage.as_mut().unwrap().output_tokens = Some(5);
1446 assert!(derive(&rejected).is_none());
1447 let mut rejected = observation;
1448 rejected.tool_calls.push(GenericObservedToolCall {
1449 call_id: ModelToolCallId::new("half-call"),
1450 name: "inspect".to_owned(),
1451 arguments: "{}".to_owned(),
1452 extensions: Default::default(),
1453 ended: false,
1454 });
1455 assert!(derive(&rejected).is_none());
1456 }
1457
1458 #[test]
1459 fn retry_checkpoints_must_match_an_open_attempt_and_advance_in_order() {
1460 use orchestral_core::model_protocol::{ModelError, ModelErrorCode};
1461 let store = InMemoryGenericAgentCheckpointStore::default();
1462 let registration = registration();
1463 let run_id = registration.run_id().clone();
1464 store.create_run(registration).unwrap();
1465 store.append(&run_id, 0, boundary(&run_id, 1)).unwrap();
1466 let retry = |number, request_id: &str| GenericCheckpointDraft {
1467 event_id: GenericCheckpointEventId::new(format!("retry-{number}-{request_id}")),
1468 run_id: run_id.clone(),
1469 payload: GenericCheckpointEvent::ModelRetryScheduled {
1470 round: 1,
1471 request_id: ModelRequestId::new(request_id),
1472 retry_number: number,
1473 delay_ms: 1,
1474 error: ModelError::new(ModelErrorCode::Unavailable, "temporary")
1475 .with_retryable(true),
1476 observed_usage: None,
1477 },
1478 };
1479 let old_payload = serde_json::json!({
1482 "type": "model_retry_scheduled",
1483 "round": 1,
1484 "request_id": "model-1",
1485 "retry_number": 1,
1486 "delay_ms": 1,
1487 "error": {
1488 "code": "unavailable", "message": "temporary",
1489 "retryable": true, "details": null,
1490 },
1491 });
1492 let decoded: GenericCheckpointEvent = serde_json::from_value(old_payload.clone()).unwrap();
1493 assert_eq!(serde_json::to_value(&decoded).unwrap(), old_payload);
1494 assert_eq!(decoded, retry(1, "model-1").payload);
1495 assert!(store.append(&run_id, 1, retry(1, "model-1")).is_err());
1496 store
1497 .append(
1498 &run_id,
1499 1,
1500 GenericCheckpointDraft {
1501 event_id: GenericCheckpointEventId::new("attempt-1"),
1502 run_id: run_id.clone(),
1503 payload: GenericCheckpointEvent::ModelAttemptStarted {
1504 round: 1,
1505 request_id: ModelRequestId::new("model-1"),
1506 request_digest: Digest::sha256("request"),
1507 max_output_tokens: None,
1508 context: context_trace(),
1509 },
1510 },
1511 )
1512 .unwrap();
1513 assert!(store.append(&run_id, 2, retry(2, "model-1")).is_err());
1514 assert!(store
1515 .append(&run_id, 2, retry(1, "different-model"))
1516 .is_err());
1517 store.append(&run_id, 2, retry(1, "model-1")).unwrap();
1518 assert!(matches!(
1519 store
1520 .load_run(&run_id)
1521 .unwrap()
1522 .unwrap()
1523 .validate()
1524 .unwrap()
1525 .phase,
1526 GenericCheckpointPhase::ModelAttemptOpen { .. }
1527 ));
1528 store
1529 .append(
1530 &run_id,
1531 3,
1532 GenericCheckpointDraft {
1533 event_id: GenericCheckpointEventId::new("observed-1"),
1534 run_id: run_id.clone(),
1535 payload: GenericCheckpointEvent::ModelAttemptObserved {
1536 round: 1,
1537 request_id: ModelRequestId::new("model-1"),
1538 observation: GenericModelObservation {
1539 finish_reason: ModelFinishReason::Stop,
1540 response: "done".to_owned(),
1541 continuation: BTreeMap::new(),
1542 usage: None,
1543 tool_calls: vec![],
1544 },
1545 },
1546 },
1547 )
1548 .unwrap();
1549 assert!(store.append(&run_id, 4, retry(2, "model-1")).is_err());
1550 }
1551
1552 #[test]
1553 fn stable_open_and_observed_model_boundaries_are_distinguishable_after_replay() {
1554 let store = InMemoryGenericAgentCheckpointStore::default();
1555 let registration = registration();
1556 let run_id = registration.run_id().clone();
1557 store.create_run(registration).unwrap();
1558 store.append(&run_id, 0, boundary(&run_id, 1)).unwrap();
1559 let stable = store.load_run(&run_id).unwrap().unwrap();
1560 assert!(stable.validate().unwrap().phase.is_stable());
1561
1562 store
1563 .append(
1564 &run_id,
1565 1,
1566 GenericCheckpointDraft {
1567 event_id: GenericCheckpointEventId::new("attempt-1"),
1568 run_id: run_id.clone(),
1569 payload: GenericCheckpointEvent::ModelAttemptStarted {
1570 round: 1,
1571 request_id: ModelRequestId::new("model-run-1-1"),
1572 request_digest: Digest::sha256("request-1"),
1573 max_output_tokens: None,
1574 context: context_trace(),
1575 },
1576 },
1577 )
1578 .unwrap();
1579 let uncertain = store.load_run(&run_id).unwrap().unwrap();
1580 assert!(matches!(
1581 uncertain.validate().unwrap().phase,
1582 GenericCheckpointPhase::ModelAttemptOpen { .. }
1583 ));
1584
1585 store
1586 .append(
1587 &run_id,
1588 2,
1589 GenericCheckpointDraft {
1590 event_id: GenericCheckpointEventId::new("observed-1"),
1591 run_id: run_id.clone(),
1592 payload: GenericCheckpointEvent::ModelAttemptObserved {
1593 round: 1,
1594 request_id: ModelRequestId::new("model-run-1-1"),
1595 observation: GenericModelObservation {
1596 finish_reason: ModelFinishReason::ToolCalls,
1597 response: "calling a Tool".to_owned(),
1598 continuation: BTreeMap::from([(
1599 "fixture/native".to_owned(),
1600 serde_json::json!({"opaque": "Ω\n"}),
1601 )]),
1602 usage: Some(ModelUsage {
1603 input_tokens: Some(10),
1604 output_tokens: Some(5),
1605 }),
1606 tool_calls: vec![GenericObservedToolCall {
1607 call_id: ModelToolCallId::new("call-1"),
1608 name: "echo".to_owned(),
1609 arguments: r#"{"value":"hello"}"#.to_owned(),
1610 extensions: Default::default(),
1611 ended: true,
1612 }],
1613 },
1614 },
1615 },
1616 )
1617 .unwrap();
1618 let observed = store.load_run(&run_id).unwrap().unwrap();
1619 let persisted = serde_json::to_vec(&observed).unwrap();
1620 let restored: StoredGenericAgentRun = serde_json::from_slice(&persisted).unwrap();
1621 let GenericCheckpointPhase::ModelAttemptObserved { observation, .. } =
1622 restored.validate().unwrap().phase
1623 else {
1624 panic!("restored terminal model observation");
1625 };
1626 assert_eq!(
1627 observation.continuation["fixture/native"],
1628 serde_json::json!({"opaque": "Ω\n"})
1629 );
1630 assert!(
1631 matches!(&observation.assistant_content()[1], ModelContent::Continuation { namespace, .. } if namespace == "fixture/native")
1632 );
1633 assert!(matches!(
1634 observed.validate().unwrap().phase,
1635 GenericCheckpointPhase::ModelAttemptObserved {
1636 round: 1,
1637 observation: GenericModelObservation { ref tool_calls, .. },
1638 ..
1639 } if tool_calls.len() == 1
1640 ));
1641
1642 store.append(&run_id, 3, boundary(&run_id, 2)).unwrap();
1643 assert!(matches!(
1644 store
1645 .load_run(&run_id)
1646 .unwrap()
1647 .unwrap()
1648 .validate()
1649 .unwrap()
1650 .phase,
1651 GenericCheckpointPhase::Stable(GenericLoopBoundary {
1652 next_model_round: 2,
1653 ..
1654 })
1655 ));
1656 }
1657
1658 #[test]
1659 fn invalid_transition_and_event_equivocation_never_advance_the_wal() {
1660 let store = InMemoryGenericAgentCheckpointStore::default();
1661 let registration = registration();
1662 let run_id = registration.run_id().clone();
1663 store.create_run(registration).unwrap();
1664 assert!(store.append(&run_id, 0, boundary(&run_id, 2)).is_err());
1665 assert_eq!(
1666 store
1667 .load_run(&run_id)
1668 .unwrap()
1669 .unwrap()
1670 .last_checkpoint_seq(),
1671 0
1672 );
1673
1674 let original = boundary(&run_id, 1);
1675 store.append(&run_id, 0, original.clone()).unwrap();
1676 assert_eq!(
1677 store.append(&run_id, 1, original).unwrap(),
1678 AppendGenericCheckpointOutcome::ExactDuplicate
1679 );
1680 let mut conflict = boundary(&run_id, 2);
1681 conflict.event_id = GenericCheckpointEventId::new("boundary-1");
1682 assert!(matches!(
1683 store.append(&run_id, 1, conflict),
1684 Err(GenericCheckpointError::EventConflict(_))
1685 ));
1686 assert_eq!(
1687 store
1688 .load_run(&run_id)
1689 .unwrap()
1690 .unwrap()
1691 .last_checkpoint_seq(),
1692 1
1693 );
1694 }
1695}