Skip to main content

orchestral_runtime/
generic_agent_checkpoint.rs

1//! Provider-private write-ahead journal for the in-process Generic Agent.
2//!
3//! This journal is deliberately separate from the Host Agent journal. The
4//! Host journal proves public protocol state; this journal proves whether the
5//! Generic Agent may safely reconstruct private model-loop work after restart.
6
7use 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/// Immutable Generic Agent identity bound before any model attempt begins.
54#[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    /// Binds the model contract, system prompt, Host Tool authority and
61    /// schemas, Skill catalog, and loop limits that determine reconstructed
62    /// execution.
63    pub config_digest: Digest,
64}
65
66/// One model Tool call reconstructed from the canonical streaming protocol.
67/// Raw argument bytes are retained so recovery can apply the same JSON/schema
68/// validation without asking the model to repeat the call.
69#[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/// Durable aggregate produced only after a valid terminal Model stream event
82/// has been observed. It is Provider-private execution state, not a public
83/// Agent event and not a claim that any Tool effect has occurred.
84#[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    /// Provider-owned message state, separate from visible text and Tool data.
91    #[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/// Durable provenance for the exact Session Context projected before one
100/// model attempt. The request digest proves bytes; this trace explains which
101/// Journal ranges and Host limits produced those bytes.
102#[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/// Private facts required to distinguish a stable loop boundary from an
283/// uncertain model attempt. `ModelAttemptStarted` is written before polling
284/// `ModelBackend::start`.
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
287#[non_exhaustive]
288// This is a versioned wire contract. Boxing individual fields would change the
289// public Rust construction API without changing the serialized representation.
290#[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    /// A definite capacity rejection, before any model usage or content.
316    /// This closes the attempt without executing tools and durably reserves
317    /// a smaller input or output budget, including after restart.
318    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    /// A retry within an open logical attempt, before any non-usage event was
328    /// observed. Recovery still treats an open attempt as interrupted; this
329    /// fact does not authorize replaying a model request after process loss.
330    ModelRetryScheduled {
331        round: u64,
332        request_id: ModelRequestId,
333        retry_number: u32,
334        delay_ms: u64,
335        error: orchestral_core::model_protocol::ModelError,
336        /// Last reported usage snapshot of the failed attempt. Missing fields
337        /// remain unknown; this does not certify the request's final cost.
338        #[serde(default, skip_serializing_if = "Option::is_none")]
339        observed_usage: Option<ModelUsage>,
340    },
341    /// Written before entering the Workflow DAG executor. Once present, a
342    /// missing durable Workflow output is intentionally outcome-unknown and
343    /// must never be reconstructed by rerunning the DAG.
344    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    /// Provider drafts are persisted before they enter the live Provider
357    /// stream. A delivery batch may contain OutputCommitted followed by the
358    /// single terminal DeliveryCommitted event.
359    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    /// Seals one validated private-WAL draft at the store-assigned sequence.
586    /// Concrete checkpoint-store plugins use this after enforcing their
587    /// compare-and-append boundary.
588    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    /// Derived from the last completed request, never cumulative/output usage.
709    pub observed_prefix: Option<ObservedPrefixAnchor>,
710    pub context_recovery: Option<GenericContextRecovery>,
711}
712
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct GenericContextRecovery {
715    /// Consecutive rejected attempts. Zero means generation succeeded; an
716    /// input ceiling remains learned only when input reduction was required.
717    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    /// An output-only rejection does not establish a smaller input capacity.
725    /// Keep its original input fixed during retry; after success, allow new
726    /// observations to use the space released by the smaller output budget.
727    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    /// The ceiling is one token below the rejected input; rounding its half
732    /// upward recovers half the original rejected input without overflowing.
733    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
1142/// Synchronous metadata WAL used on the Generic Agent's existing commit
1143/// critical sections. Implementations must keep records small and must not do
1144/// heavyweight parsing, embedding, or remote work in this call path.
1145pub 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        // Omitting the new optional field preserves the serialized payload and
1480        // digest of checkpoints written before usage-only retries existed.
1481        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}