Skip to main content

orchestral_runtime/
session_context.rs

1//! Replay-derived model context for the Generic Agent.
2
3mod live_source;
4pub mod observed_prefix;
5mod placement;
6mod pressure;
7mod summary_continuation;
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use orchestral_core::agent_protocol::wire::{AgentSessionId, Digest, RunId};
14use orchestral_core::agent_session::{
15    session_range_digest, validate_session_trace, AgentSessionError, AgentSessionEvent,
16    AgentSessionEventDraft, AgentSessionEventId, AgentSessionJournalStore, AgentSessionRecord,
17    SessionSourceRange,
18};
19use orchestral_core::model_protocol::{
20    ModelContent, ModelContextEstimate, ModelError, ModelMessage, ModelRole, ModelTokenAccounting,
21    ModelTokenMeter, ModelTokenMeterDescriptor, ModelToolDefinition,
22};
23use orchestral_core::skill_protocol::SkillId;
24use serde::{Deserialize, Serialize};
25
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum SessionContextError {
29    #[error(transparent)]
30    Journal(#[from] AgentSessionError),
31    #[error("invalid Session Context request: {0}")]
32    InvalidRequest(String),
33    #[error("pinned Session Context exceeds the model input budget: used {used}, budget {budget}")]
34    ContextOverflow { used: u64, budget: u64 },
35    #[error("Session compaction failed: {0}")]
36    Compaction(String),
37}
38
39pub struct JsonSizeTokenMeter {
40    bytes_per_token: u64,
41}
42
43impl JsonSizeTokenMeter {
44    pub fn new(bytes_per_token: u64) -> Result<Self, SessionContextError> {
45        if bytes_per_token != 1 {
46            return Err(SessionContextError::InvalidRequest(
47                "the conservative JSON fallback requires exactly one byte per token".to_owned(),
48            ));
49        }
50        Ok(Self { bytes_per_token })
51    }
52}
53
54impl Default for JsonSizeTokenMeter {
55    fn default() -> Self {
56        // One UTF-8/JSON byte per token is intentionally pessimistic. This
57        // fallback is safe for unknown families; supported production
58        // adapters replace it with their provider-wire upper bound.
59        Self { bytes_per_token: 1 }
60    }
61}
62
63impl ModelTokenMeter for JsonSizeTokenMeter {
64    fn meter_descriptor(&self) -> ModelTokenMeterDescriptor {
65        ModelTokenMeterDescriptor {
66            strategy: "canonical-json-size".to_owned(),
67            version: "1".to_owned(),
68            accounting: ModelTokenAccounting::ConservativeUpperBound,
69            config_digest: Digest::sha256(format!(
70                "canonical-json-size/v1\0bytes_per_token={}",
71                self.bytes_per_token
72            )),
73        }
74    }
75
76    fn count_request_input(
77        &self,
78        messages: &[ModelMessage],
79        tools: &[ModelToolDefinition],
80    ) -> Result<u64, ModelError> {
81        let bytes = serde_jcs::to_vec(&(messages, tools)).map_err(|error| {
82            ModelError::invalid_request(format!(
83                "could not serialize model context for metering: {error}"
84            ))
85        })?;
86        Ok((bytes.len() as u64).div_ceil(self.bytes_per_token))
87    }
88}
89
90pub struct SessionContextRequest {
91    pub session_id: AgentSessionId,
92    pub current_run_id: RunId,
93    /// Optional append-only Journal cursor used to reproduce a prior model
94    /// request exactly during recovery. `None` projects the current head.
95    pub through_session_seq: Option<u64>,
96    pub system_message: Option<ModelMessage>,
97    pub tools: Vec<ModelToolDefinition>,
98    /// Maximum number of non-current Run history groups eligible for this
99    /// request. Current Run task/safety state and its loaded Skills remain pinned;
100    /// complete older Tool exchanges may be replaced by a durable active-Run
101    /// summary under Context pressure.
102    pub history_limit: usize,
103    pub max_context_tokens: u64,
104    pub reserved_output_tokens: u64,
105    pub config_digest: Digest,
106    /// Exact Skill packages visible to this immutable Run binding.
107    pub allowed_skill_digests: BTreeMap<SkillId, Digest>,
108}
109
110pub struct SessionContextProjection {
111    pub messages: Vec<ModelMessage>,
112    pub included_ranges: Vec<SessionSourceRange>,
113    pub deferred_ranges: Vec<SessionSourceRange>,
114    pub used_input_tokens: u64,
115    /// Optional soft planning count. `used_input_tokens` remains the hard
116    /// input bound used for dispatch reservations and observed-usage checks.
117    pub context_estimate: Option<ModelContextEstimate>,
118    /// Optional provenance for soft planning; absent for certified accounting.
119    pub planning: Option<observed_prefix::ContextPlanningTrace>,
120    pub input_budget_tokens: u64,
121    pub through_session_seq: u64,
122    pub config_digest: Digest,
123}
124
125/// Select context with either a certified bound or an explicitly soft estimate.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum ContextTokenPolicy {
128    UpperBound,
129    Planning,
130}
131
132pub struct AgentSessionContextEngine {
133    journal: Arc<dyn AgentSessionJournalStore>,
134    token_meter: Arc<dyn ModelTokenMeter>,
135}
136
137impl AgentSessionContextEngine {
138    pub fn new(
139        journal: Arc<dyn AgentSessionJournalStore>,
140        token_meter: Arc<dyn ModelTokenMeter>,
141    ) -> Self {
142        Self {
143            journal,
144            token_meter,
145        }
146    }
147
148    pub async fn project(
149        &self,
150        request: SessionContextRequest,
151    ) -> Result<SessionContextProjection, SessionContextError> {
152        self.project_with_policy(request, ContextTokenPolicy::UpperBound)
153            .await
154    }
155
156    pub async fn project_with_policy(
157        &self,
158        request: SessionContextRequest,
159        policy: ContextTokenPolicy,
160    ) -> Result<SessionContextProjection, SessionContextError> {
161        validate_context_request(&request)?;
162        let records = self.journal.load_session(&request.session_id).await?;
163        validate_session_trace(&request.session_id, &records)?;
164        let records = match request.through_session_seq {
165            Some(through) if through > records.len() as u64 => {
166                return Err(SessionContextError::InvalidRequest(format!(
167                    "Session Context cursor {through} is past Journal head {}",
168                    records.len()
169                )))
170            }
171            Some(through) => &records[..through as usize],
172            None => records.as_slice(),
173        };
174        let mut groups = replay_groups(
175            records,
176            &request.current_run_id,
177            &request.allowed_skill_digests,
178        )?;
179        let input_budget = request
180            .max_context_tokens
181            .saturating_sub(request.reserved_output_tokens);
182        let mut selected = groups
183            .values()
184            .filter(|group| group.pinned)
185            .map(|group| group.key)
186            .collect::<BTreeSet<_>>();
187        let pinned_messages = assemble_messages(&request.system_message, &groups, &selected);
188        let pinned_tokens = self.context_input_tokens(&pinned_messages, &request.tools, policy)?;
189        if pinned_tokens > input_budget {
190            return Err(SessionContextError::ContextOverflow {
191                used: pinned_tokens,
192                budget: input_budget,
193            });
194        }
195
196        // On a follow-up turn, prefer the latest original user request over
197        // incidental older Tool output, even if a historical summary shadows
198        // it. Never promote historical text to system authority or exceed the
199        // configured history/token budget. Large originals remain recallable.
200        let mut retained_anchor = None;
201        if request.history_limit > 0 {
202            if let Some(record) = records
203                .iter()
204                .rev()
205                .find(|record| {
206                    record.run_id != request.current_run_id
207                        && matches!(record.payload, AgentSessionEvent::RunInputCommitted { .. })
208                })
209                .filter(|record| !groups.contains_key(&record.session_seq))
210            {
211                let AgentSessionEvent::RunInputCommitted { message } = &record.payload else {
212                    unreachable!()
213                };
214                let mut candidate_groups = groups.clone();
215                candidate_groups
216                    .entry(record.session_seq)
217                    .or_insert_with(|| MessageGroup {
218                        key: record.session_seq,
219                        producer_seq: record.session_seq,
220                        source_ranges: vec![single_range(record.session_seq)],
221                        logical_source: single_range(record.session_seq),
222                        messages: vec![message.clone()],
223                        pinned: false,
224                        active_compactable: false,
225                    });
226                let mut candidate = selected.clone();
227                candidate.insert(record.session_seq);
228                if self.context_input_tokens(
229                    &assemble_messages(&request.system_message, &candidate_groups, &candidate),
230                    &request.tools,
231                    policy,
232                )? <= input_budget
233                {
234                    groups = candidate_groups;
235                    selected = candidate;
236                    retained_anchor = Some(record.session_seq);
237                }
238            }
239        }
240
241        for group in groups
242            .values()
243            .rev()
244            .filter(|group| !group.pinned && Some(group.key) != retained_anchor)
245            .take(
246                request
247                    .history_limit
248                    .saturating_sub(usize::from(retained_anchor.is_some())),
249            )
250        {
251            let mut candidate = selected.clone();
252            candidate.insert(group.key);
253            let messages = assemble_messages(&request.system_message, &groups, &candidate);
254            if self.context_input_tokens(&messages, &request.tools, policy)? <= input_budget {
255                selected = candidate;
256            }
257        }
258        let messages = assemble_messages(&request.system_message, &groups, &selected);
259        let used_input_tokens = self.count_request_input(&messages, &request.tools)?;
260        let context_estimate = if policy == ContextTokenPolicy::Planning {
261            let estimate = self
262                .token_meter
263                .estimate_context_input(&messages, &request.tools)
264                .map_err(|error| SessionContextError::InvalidRequest(error.to_string()))?;
265            if estimate.tokens > input_budget
266                || estimate.tokens > used_input_tokens
267                || (estimate.accounting != ModelTokenAccounting::Estimated
268                    && estimate.tokens != used_input_tokens)
269            {
270                return Err(SessionContextError::InvalidRequest(
271                    "context estimate is inconsistent with the input bound or selected budget"
272                        .to_owned(),
273                ));
274            }
275            // Preserve the durable trace of adapters that retain certified
276            // accounting, including checkpoints written before estimates existed.
277            (estimate.accounting == ModelTokenAccounting::Estimated).then_some(estimate)
278        } else {
279            None
280        };
281        let mut included_ranges = Vec::new();
282        let mut deferred_ranges = Vec::new();
283        for group in groups.values() {
284            let destination = if selected.contains(&group.key) {
285                &mut included_ranges
286            } else {
287                &mut deferred_ranges
288            };
289            // Provenance is a partition of source records. An original User
290            // anchor restored from a summary must not also appear in that
291            // summary's included/deferred range in the durable model trace.
292            for source in &group.source_ranges {
293                if let Some(anchor) = retained_anchor
294                    .filter(|anchor| group.key != *anchor && source.contains(*anchor))
295                {
296                    if source.first_session_seq < anchor {
297                        destination.push(SessionSourceRange {
298                            first_session_seq: source.first_session_seq,
299                            last_session_seq: anchor - 1,
300                        });
301                    }
302                    if source.last_session_seq > anchor {
303                        destination.push(SessionSourceRange {
304                            first_session_seq: anchor + 1,
305                            last_session_seq: source.last_session_seq,
306                        });
307                    }
308                } else {
309                    destination.push(source.clone());
310                }
311            }
312        }
313        Ok(SessionContextProjection {
314            messages,
315            included_ranges,
316            deferred_ranges,
317            used_input_tokens,
318            context_estimate,
319            planning: None,
320            input_budget_tokens: input_budget,
321            through_session_seq: records.last().map(|record| record.session_seq).unwrap_or(0),
322            config_digest: request.config_digest,
323        })
324    }
325
326    fn context_input_tokens(
327        &self,
328        messages: &[ModelMessage],
329        tools: &[ModelToolDefinition],
330        policy: ContextTokenPolicy,
331    ) -> Result<u64, SessionContextError> {
332        if policy == ContextTokenPolicy::UpperBound {
333            return self.count_request_input(messages, tools);
334        }
335        self.token_meter
336            .estimate_context_input(messages, tools)
337            .map(|estimate| estimate.tokens)
338            .map_err(|error| SessionContextError::InvalidRequest(error.to_string()))
339    }
340
341    fn count_request_input(
342        &self,
343        messages: &[ModelMessage],
344        tools: &[ModelToolDefinition],
345    ) -> Result<u64, SessionContextError> {
346        self.token_meter
347            .count_request_input(messages, tools)
348            .map_err(|error| {
349                SessionContextError::InvalidRequest(format!(
350                    "model token meter rejected Context input: {error}"
351                ))
352            })
353    }
354}
355
356#[derive(Clone)]
357struct MessageGroup {
358    key: u64,
359    /// Journal record that currently materializes this group. Nested
360    /// compaction shadows producer records, not the older records summarized
361    /// by those producers.
362    producer_seq: u64,
363    /// Live producer records consumed by this group. A compaction range can
364    /// contain shadowed records whose live replacement survives elsewhere;
365    /// those gaps must not be claimed as this group's provenance.
366    source_ranges: Vec<SessionSourceRange>,
367    /// Extent of the original history represented by this group, including
368    /// transitive summary sources. Producer order remains the durable identity
369    /// and history-selection order; this range controls new summary placement.
370    logical_source: SessionSourceRange,
371    messages: Vec<ModelMessage>,
372    pinned: bool,
373    /// The group is a complete Tool exchange (or an existing summary of such
374    /// exchanges) and may participate in active-Run pressure compaction.
375    active_compactable: bool,
376}
377
378fn replay_groups(
379    records: &[AgentSessionRecord],
380    current_run_id: &RunId,
381    allowed_skill_digests: &BTreeMap<SkillId, Digest>,
382) -> Result<BTreeMap<u64, MessageGroup>, SessionContextError> {
383    let mut groups = BTreeMap::new();
384    let mut loaded_skills = BTreeMap::new();
385    for record in records {
386        match &record.payload {
387            AgentSessionEvent::RunInputCommitted { message } => {
388                groups.insert(
389                    record.session_seq,
390                    MessageGroup {
391                        key: record.session_seq,
392                        producer_seq: record.session_seq,
393                        source_ranges: vec![single_range(record.session_seq)],
394                        logical_source: single_range(record.session_seq),
395                        messages: vec![message.clone()],
396                        pinned: record.run_id == *current_run_id,
397                        active_compactable: false,
398                    },
399                );
400            }
401            AgentSessionEvent::ToolExchangeCommitted {
402                assistant,
403                tool,
404                retained_artifacts,
405                ..
406            } => {
407                groups.insert(
408                    record.session_seq,
409                    MessageGroup {
410                        key: record.session_seq,
411                        producer_seq: record.session_seq,
412                        source_ranges: vec![single_range(record.session_seq)],
413                        logical_source: single_range(record.session_seq),
414                        messages: vec![assistant.clone(), tool.clone()],
415                        pinned: record.run_id == *current_run_id || !retained_artifacts.is_empty(),
416                        active_compactable: retained_artifacts.is_empty(),
417                    },
418                );
419            }
420            AgentSessionEvent::EffectUncertaintyCommitted {
421                effect_call_id,
422                model_call_id,
423                tool_name,
424                message,
425            } => {
426                groups.insert(
427                    record.session_seq,
428                    MessageGroup {
429                        key: record.session_seq,
430                        producer_seq: record.session_seq,
431                        source_ranges: vec![single_range(record.session_seq)],
432                        logical_source: single_range(record.session_seq),
433                        messages: vec![effect_uncertainty_message(
434                            effect_call_id,
435                            model_call_id,
436                            tool_name,
437                            message,
438                        )],
439                        pinned: true,
440                        active_compactable: false,
441                    },
442                );
443            }
444            AgentSessionEvent::RunOutputCommitted { message, .. } => {
445                groups.insert(
446                    record.session_seq,
447                    MessageGroup {
448                        key: record.session_seq,
449                        producer_seq: record.session_seq,
450                        source_ranges: vec![single_range(record.session_seq)],
451                        logical_source: single_range(record.session_seq),
452                        messages: vec![message.clone()],
453                        pinned: record.run_id == *current_run_id,
454                        active_compactable: false,
455                    },
456                );
457            }
458            AgentSessionEvent::SkillLoaded { load } => {
459                // A Skill load is working context for the Run that requested
460                // it, not durable instructions for every future task in the
461                // Session.
462                if record.run_id != *current_run_id {
463                    continue;
464                }
465                let descriptor = &load.package.descriptor;
466                if allowed_skill_digests.get(&descriptor.skill_id) != Some(&descriptor.digest) {
467                    continue;
468                }
469                if let Some(previous) =
470                    loaded_skills.insert(descriptor.skill_id.clone(), descriptor.digest.clone())
471                {
472                    if previous != descriptor.digest {
473                        return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
474                            format!(
475                                "Skill '{}' changed digest inside one Run without an explicit replacement protocol",
476                                descriptor.skill_id
477                            ),
478                        )));
479                    }
480                    // Loading the same immutable instructions is idempotent and must
481                    // not duplicate full instructions in model context.
482                    continue;
483                }
484                groups.insert(
485                    record.session_seq,
486                    MessageGroup {
487                        key: record.session_seq,
488                        producer_seq: record.session_seq,
489                        source_ranges: vec![single_range(record.session_seq)],
490                        logical_source: single_range(record.session_seq),
491                        messages: vec![skill_load_message(load)],
492                        // Current-Run instructions stay pinned for recovery
493                        // and cannot be evicted by ordinary history selection.
494                        pinned: true,
495                        active_compactable: false,
496                    },
497                );
498            }
499            AgentSessionEvent::CompactionCommitted {
500                source,
501                source_digest,
502                summary,
503                ..
504            } => {
505                let observed = session_range_digest(records, source)?;
506                if observed != *source_digest {
507                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
508                        format!(
509                            "compaction source digest mismatch at session_seq {}",
510                            record.session_seq
511                        ),
512                    )));
513                }
514                if records.iter().any(|candidate| {
515                    source.contains(candidate.session_seq)
516                        && matches!(candidate.payload, AgentSessionEvent::SkillLoaded { .. })
517                }) {
518                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
519                        "compaction cannot shadow durable loaded Skill state".to_owned(),
520                    )));
521                }
522                let source_groups = groups
523                    .values()
524                    .filter(|group| source.contains(group.producer_seq))
525                    .collect::<Vec<_>>();
526                let source_record_count = records
527                    .iter()
528                    .filter(|candidate| source.contains(candidate.session_seq))
529                    .count();
530                if source_groups.len() != source_record_count {
531                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
532                        "compaction source contains an already-shadowed or unprojected record"
533                            .to_owned(),
534                    )));
535                }
536                if source_groups.iter().any(|group| group.pinned) {
537                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
538                        "compaction attempted to shadow the current Run".to_owned(),
539                    )));
540                }
541                if summary.role == ModelRole::Assistant
542                    && !placement::can_replace_source(&groups, records, source)
543                {
544                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
545                        "summary placement crosses a surviving Context group".to_owned(),
546                    )));
547                }
548                let summary_group = placement::summary_group(
549                    &groups,
550                    source,
551                    record.session_seq,
552                    summary,
553                    false,
554                    false,
555                )
556                .map_err(|error| {
557                    SessionContextError::Journal(AgentSessionError::Corrupt(error.to_string()))
558                })?;
559                groups.retain(|_, group| !source.contains(group.producer_seq));
560                groups.insert(record.session_seq, summary_group);
561            }
562            AgentSessionEvent::ActiveRunCompactionCommitted {
563                source,
564                source_digest,
565                summary,
566                ..
567            } => {
568                let observed = session_range_digest(records, source)?;
569                if observed != *source_digest {
570                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
571                        format!(
572                            "active-Run compaction source digest mismatch at session_seq {}",
573                            record.session_seq
574                        ),
575                    )));
576                }
577                if !live_source::valid_active_source(&groups, records, source, &record.run_id) {
578                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
579                        "active-Run compaction may shadow only live, complete Tool exchanges from one Run"
580                            .to_owned(),
581                    )));
582                }
583                if (summary.role == ModelRole::Assistant
584                    || live_source::has_shadowed_records(&groups, source))
585                    && !placement::can_replace_source(&groups, records, source)
586                {
587                    return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
588                        "summary placement crosses a surviving Context group".to_owned(),
589                    )));
590                }
591                let summary_group = placement::summary_group(
592                    &groups,
593                    source,
594                    record.session_seq,
595                    summary,
596                    record.run_id == *current_run_id,
597                    true,
598                )
599                .map_err(|error| {
600                    SessionContextError::Journal(AgentSessionError::Corrupt(error.to_string()))
601                })?;
602                groups.retain(|_, group| !source.contains(group.producer_seq));
603                groups.insert(record.session_seq, summary_group);
604            }
605            _ => {
606                return Err(SessionContextError::Journal(AgentSessionError::Corrupt(
607                    "unsupported Session event cannot be projected safely".to_owned(),
608                )))
609            }
610        }
611    }
612    Ok(groups)
613}
614
615pub(crate) fn skill_load_message(
616    load: &orchestral_core::skill_protocol::SkillLoad,
617) -> ModelMessage {
618    let descriptor = &load.package.descriptor;
619    let version = descriptor.version.as_deref().unwrap_or("unversioned");
620    let resource_base = skill_resource_base(&descriptor.source)
621        .map(|base| {
622            format!(
623                "\nresource_base: {base}\nRelative paths in this Skill's instructions are relative to resource_base."
624            )
625        })
626        .unwrap_or_default();
627    ModelMessage::text(
628        ModelRole::System,
629        format!(
630            "Loaded Skill (immutable instruction context)\nname: {}\nskill_id: {}\nsource: {:?}:{}{}\nversion: {}\ndigest: {}\n\nInstructions:\n{}",
631            descriptor.name,
632            descriptor.skill_id,
633            descriptor.source.kind,
634            descriptor.source.locator,
635            resource_base,
636            version,
637            descriptor.digest,
638            load.package.instructions
639        ),
640    )
641}
642
643pub(crate) fn skill_resource_base(
644    source: &orchestral_core::skill_protocol::SkillSource,
645) -> Option<String> {
646    let locator = std::path::Path::new(&source.locator);
647    if !locator.is_absolute() {
648        return None;
649    }
650    locator
651        .parent()
652        .map(|parent| parent.to_string_lossy().into_owned())
653}
654
655fn effect_uncertainty_message(
656    effect_call_id: &orchestral_core::tool_protocol::ToolCallId,
657    model_call_id: &orchestral_core::model_protocol::ModelToolCallId,
658    tool_name: &str,
659    message: &str,
660) -> ModelMessage {
661    ModelMessage::text(
662        ModelRole::System,
663        format!(
664            "HOST SAFETY FACT: unresolved Tool effect; never retry automatically.\neffect_call_id: {}\nmodel_call_id: {}\ntool: {}\nobservation: {}\nresolution: explicit Host reconciliation is required",
665            effect_call_id, model_call_id, tool_name, message
666        ),
667    )
668}
669
670fn assemble_messages(
671    system_message: &Option<ModelMessage>,
672    groups: &BTreeMap<u64, MessageGroup>,
673    selected: &BTreeSet<u64>,
674) -> Vec<ModelMessage> {
675    let mut messages = Vec::new();
676    if let Some(system) = system_message {
677        messages.push(system.clone());
678    }
679    for group in groups.values() {
680        if selected.contains(&group.key)
681            && group
682                .messages
683                .iter()
684                .all(|message| message.role == ModelRole::System)
685        {
686            messages.extend(group.messages.clone());
687        }
688    }
689    let mut conversation = groups.values().collect::<Vec<_>>();
690    conversation.sort_by_key(|group| (group.logical_source.first_session_seq, group.producer_seq));
691    for group in conversation {
692        if selected.contains(&group.key)
693            && !group
694                .messages
695                .iter()
696                .all(|message| message.role == ModelRole::System)
697        {
698            messages.extend(group.messages.clone());
699        }
700    }
701    messages
702}
703
704fn validate_context_request(request: &SessionContextRequest) -> Result<(), SessionContextError> {
705    if request.session_id.is_empty()
706        || request.current_run_id.is_empty()
707        || request.history_limit == 0
708        || request.max_context_tokens == 0
709        || request.reserved_output_tokens >= request.max_context_tokens
710        || !request.config_digest.is_sha256()
711    {
712        return Err(SessionContextError::InvalidRequest(
713            "Session/context identities, digest, and token budget are invalid".to_owned(),
714        ));
715    }
716    if let Some(system) = &request.system_message {
717        system.validate().map_err(|error| {
718            SessionContextError::InvalidRequest(format!("invalid system message: {error}"))
719        })?;
720        if system.role != ModelRole::System {
721            return Err(SessionContextError::InvalidRequest(
722                "configured system message must have the System role".to_owned(),
723            ));
724        }
725    }
726    for tool in &request.tools {
727        tool.validate().map_err(|error| {
728            SessionContextError::InvalidRequest(format!("invalid Tool schema: {error}"))
729        })?;
730    }
731    Ok(())
732}
733
734fn single_range(sequence: u64) -> SessionSourceRange {
735    SessionSourceRange {
736        first_session_seq: sequence,
737        last_session_seq: sequence,
738    }
739}
740
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742#[serde(deny_unknown_fields)]
743pub struct SessionCompactionPolicy {
744    pub minimum_source_records: usize,
745    pub keep_recent_records: usize,
746}
747
748impl Default for SessionCompactionPolicy {
749    fn default() -> Self {
750        Self {
751            minimum_source_records: 8,
752            keep_recent_records: 8,
753        }
754    }
755}
756
757impl SessionCompactionPolicy {
758    pub fn validate(&self) -> Result<(), SessionContextError> {
759        if self.minimum_source_records == 0
760            || self.keep_recent_records == 0
761            || self
762                .minimum_source_records
763                .checked_add(self.keep_recent_records)
764                .is_none()
765        {
766            return Err(SessionContextError::InvalidRequest(
767                "Session compaction limits must be positive and bounded".to_owned(),
768            ));
769        }
770        Ok(())
771    }
772
773    pub fn digest(&self) -> Result<Digest, SessionContextError> {
774        self.validate()?;
775        serde_jcs::to_vec(self)
776            .map(Digest::sha256)
777            .map_err(|error| {
778                SessionContextError::InvalidRequest(format!(
779                    "could not digest Session compaction policy: {error}"
780                ))
781            })
782    }
783}
784
785pub fn select_compaction_source(
786    records: &[AgentSessionRecord],
787    current_run_id: &RunId,
788    policy: &SessionCompactionPolicy,
789) -> Option<SessionSourceRange> {
790    policy.validate().ok()?;
791    let minimum_records = policy
792        .minimum_source_records
793        .checked_add(policy.keep_recent_records)?;
794    if records.len() < minimum_records {
795        return None;
796    }
797    if let Some(previous_compaction) = records.iter().rev().find(|record| {
798        matches!(
799            record.payload,
800            AgentSessionEvent::CompactionCommitted { .. }
801                | AgentSessionEvent::ActiveRunCompactionCommitted { .. }
802        )
803    }) {
804        let records_since_compaction = records
805            .len()
806            .saturating_sub(previous_compaction.session_seq as usize);
807        if records_since_compaction < minimum_records {
808            return None;
809        }
810    }
811    let source_len = records.len().saturating_sub(policy.keep_recent_records);
812    let shadowed = records
813        .iter()
814        .filter_map(|record| match &record.payload {
815            AgentSessionEvent::CompactionCommitted { source, .. }
816            | AgentSessionEvent::ActiveRunCompactionCommitted { source, .. } => Some(source),
817            _ => None,
818        })
819        .collect::<Vec<_>>();
820    let mut segment_start = None;
821    let mut segment_end = 0;
822    for record in &records[..source_len] {
823        let is_shadowed = shadowed
824            .iter()
825            .any(|source| source.contains(record.session_seq));
826        let is_barrier = record.run_id == *current_run_id
827            || match &record.payload {
828                AgentSessionEvent::SkillLoaded { .. }
829                | AgentSessionEvent::EffectUncertaintyCommitted { .. } => true,
830                AgentSessionEvent::ToolExchangeCommitted {
831                    retained_artifacts, ..
832                } => !retained_artifacts.is_empty(),
833                _ => false,
834            };
835        if is_shadowed || is_barrier {
836            if let Some(start) = segment_start {
837                if segment_end - start + 1 >= policy.minimum_source_records as u64 {
838                    return Some(SessionSourceRange {
839                        first_session_seq: start,
840                        last_session_seq: segment_end,
841                    });
842                }
843            }
844            segment_start = None;
845            continue;
846        }
847        segment_start.get_or_insert(record.session_seq);
848        segment_end = record.session_seq;
849    }
850    segment_start.and_then(|start| {
851        if segment_end - start + 1 >= policy.minimum_source_records as u64 {
852            return Some(SessionSourceRange {
853                first_session_seq: start,
854                last_session_seq: segment_end,
855            });
856        }
857        None
858    })
859}
860
861/// Selects a contiguous, currently materialized prefix of complete Tool
862/// exchanges from the active Run while retaining the most recent exchanges
863/// verbatim. Existing active summaries are eligible producers so repeated
864/// pressure compaction collapses them together with later exchanges instead
865/// of accumulating summaries without bound.
866pub fn select_active_run_compaction_source(
867    records: &[AgentSessionRecord],
868    current_run_id: &RunId,
869    policy: &SessionCompactionPolicy,
870) -> Option<SessionSourceRange> {
871    policy.validate().ok()?;
872
873    let mut live = BTreeSet::new();
874    for record in records {
875        match &record.payload {
876            AgentSessionEvent::ToolExchangeCommitted {
877                retained_artifacts, ..
878            } if record.run_id == *current_run_id && retained_artifacts.is_empty() => {
879                live.insert(record.session_seq);
880            }
881            AgentSessionEvent::ActiveRunCompactionCommitted { source, .. } => {
882                live.retain(|sequence| !source.contains(*sequence));
883                if record.run_id == *current_run_id {
884                    live.insert(record.session_seq);
885                }
886            }
887            AgentSessionEvent::CompactionCommitted { source, .. } => {
888                live.retain(|sequence| !source.contains(*sequence));
889            }
890            _ => {}
891        }
892    }
893
894    if let Some(summary_seq) = records.iter().rev().find_map(|record| {
895        (live.contains(&record.session_seq)
896            && record.run_id == *current_run_id
897            && matches!(
898                record.payload,
899                AgentSessionEvent::ActiveRunCompactionCommitted { .. }
900            ))
901        .then_some(record.session_seq)
902    }) {
903        let summary_index = (summary_seq - 1) as usize;
904        let mut first_index = summary_index;
905        while first_index > 0 && live.contains(&records[first_index - 1].session_seq) {
906            first_index -= 1;
907        }
908        let mut last_index = summary_index;
909        while last_index + 1 < records.len() && live.contains(&records[last_index + 1].session_seq)
910        {
911            last_index += 1;
912        }
913        // Keep the newest post-summary exchange verbatim when possible. The
914        // rest of the live segment, including the old summary producer, is
915        // collapsed into one new summary so summaries cannot accumulate.
916        let source_last_index = if last_index > summary_index
917            && matches!(
918                records[last_index].payload,
919                AgentSessionEvent::ToolExchangeCommitted { .. }
920            ) {
921            last_index - 1
922        } else {
923            last_index
924        };
925        if source_last_index >= first_index
926            && records[first_index..=source_last_index]
927                .iter()
928                .any(|record| {
929                    matches!(
930                        record.payload,
931                        AgentSessionEvent::ToolExchangeCommitted { .. }
932                    )
933                })
934        {
935            return Some(SessionSourceRange {
936                first_session_seq: records[first_index].session_seq,
937                last_session_seq: records[source_last_index].session_seq,
938            });
939        }
940    }
941
942    let live_exchange_count = records
943        .iter()
944        .filter(|record| {
945            live.contains(&record.session_seq)
946                && matches!(
947                    record.payload,
948                    AgentSessionEvent::ToolExchangeCommitted { .. }
949                )
950        })
951        .count();
952    // The normal retention target is a quality preference, not a reason to
953    // fail a Run that is already over budget. When pressure arrives before
954    // that target is reached, preserve the newest exchange and compact the
955    // rest. A single oversized exchange is itself eligible for compaction.
956    let retain_count = if live_exchange_count > policy.keep_recent_records {
957        policy.keep_recent_records
958    } else if live_exchange_count > 1 {
959        1
960    } else {
961        0
962    };
963    let retained_recent = records
964        .iter()
965        .rev()
966        .filter(|record| {
967            live.contains(&record.session_seq)
968                && matches!(
969                    record.payload,
970                    AgentSessionEvent::ToolExchangeCommitted { .. }
971                )
972        })
973        .take(retain_count)
974        .map(|record| record.session_seq)
975        .collect::<BTreeSet<_>>();
976    let candidates = live
977        .difference(&retained_recent)
978        .copied()
979        .collect::<BTreeSet<_>>();
980
981    let mut start = None;
982    let mut end = 0;
983    let mut contains_exchange = false;
984    for record in records {
985        if !candidates.contains(&record.session_seq) {
986            if let Some(first) = start {
987                if contains_exchange {
988                    return Some(SessionSourceRange {
989                        first_session_seq: first,
990                        last_session_seq: end,
991                    });
992                }
993            }
994            start = None;
995            contains_exchange = false;
996            continue;
997        }
998        start.get_or_insert(record.session_seq);
999        end = record.session_seq;
1000        contains_exchange |= matches!(
1001            record.payload,
1002            AgentSessionEvent::ToolExchangeCommitted { .. }
1003        );
1004    }
1005    let contiguous = start.and_then(|first| {
1006        contains_exchange.then_some(SessionSourceRange {
1007            first_session_seq: first,
1008            last_session_seq: end,
1009        })
1010    });
1011    contiguous.or_else(|| {
1012        // Minimum summaries may be separated only by superseded records.
1013        // Fold their live producers even when no original exchange remains
1014        // individually materialized. Their immutable originals are expanded
1015        // by commit/replay using the same source range and digest.
1016        let groups = replay_groups(records, current_run_id, &BTreeMap::new()).ok()?;
1017        live_source::compactable_segments(&groups, records, current_run_id)
1018            .into_iter()
1019            .find(|source| {
1020                groups
1021                    .range(source.first_session_seq..=source.last_session_seq)
1022                    .count()
1023                    > 1
1024            })
1025    })
1026}
1027
1028#[derive(Debug, Clone, PartialEq)]
1029pub struct SessionCompactionGroup {
1030    pub source: SessionSourceRange,
1031    pub messages: Vec<ModelMessage>,
1032}
1033
1034pub struct SessionCompactionInput {
1035    pub session_id: AgentSessionId,
1036    pub source: SessionSourceRange,
1037    /// Original groups reached through `source`, expanding earlier compaction
1038    /// references. Their sequences can precede `source`, but never follow it.
1039    /// A Tool call/result exchange remains one atomic group. Summarizers must
1040    /// use these originals rather than repeatedly summarizing lossy summaries.
1041    pub groups: Vec<SessionCompactionGroup>,
1042    /// Required groups outside `source` are relevance hints and are never
1043    /// copied automatically into the summary.
1044    pub focus_messages: Vec<ModelMessage>,
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1048#[serde(deny_unknown_fields)]
1049pub struct SessionSummarizerDescriptor {
1050    pub strategy: String,
1051    pub model: Option<String>,
1052    pub version: String,
1053    pub config_digest: Digest,
1054}
1055
1056impl SessionSummarizerDescriptor {
1057    pub fn validate(&self) -> Result<(), SessionContextError> {
1058        if self.strategy.trim().is_empty()
1059            || self.version.trim().is_empty()
1060            || self
1061                .model
1062                .as_ref()
1063                .is_some_and(|model| model.trim().is_empty())
1064            || !self.config_digest.is_sha256()
1065        {
1066            return Err(SessionContextError::InvalidRequest(
1067                "Session summarizer descriptor is invalid".to_owned(),
1068            ));
1069        }
1070        Ok(())
1071    }
1072}
1073
1074/// Provider-neutral fallback compaction strategy. It does not invent facts or
1075/// call a model: whole replay groups are ranked by overlap with required
1076/// Context, then copied into a bounded, explicitly untrusted transcript.
1077pub struct DeterministicExtractiveSessionSummarizer {
1078    max_summary_chars: usize,
1079    descriptor: SessionSummarizerDescriptor,
1080}
1081
1082impl DeterministicExtractiveSessionSummarizer {
1083    pub fn new(max_summary_chars: usize) -> Result<Self, SessionContextError> {
1084        if max_summary_chars < 256 {
1085            return Err(SessionContextError::InvalidRequest(
1086                "deterministic Session summaries require at least 256 characters".to_owned(),
1087            ));
1088        }
1089        let config = serde_json::json!({
1090            "contract": "deterministic-extractive-session-summary/v7",
1091            "max_summary_chars": max_summary_chars,
1092        });
1093        let bytes = serde_jcs::to_vec(&config).map_err(|error| {
1094            SessionContextError::InvalidRequest(format!(
1095                "could not digest deterministic Session summarizer config: {error}"
1096            ))
1097        })?;
1098        Ok(Self {
1099            max_summary_chars,
1100            descriptor: SessionSummarizerDescriptor {
1101                strategy: "deterministic-extractive".to_owned(),
1102                model: None,
1103                version: "7".to_owned(),
1104                config_digest: Digest::sha256(bytes),
1105            },
1106        })
1107    }
1108
1109    pub fn max_summary_chars(&self) -> usize {
1110        self.max_summary_chars
1111    }
1112}
1113
1114struct ExtractiveCandidate {
1115    index: usize,
1116    rendered: String,
1117    terms: BTreeSet<String>,
1118    score: u64,
1119    failed: bool,
1120    latest_tool_exchange: bool,
1121    tool_observation: Option<(String, String)>,
1122    continuation_observation: Option<String>,
1123}
1124
1125#[async_trait]
1126impl AgentSessionSummarizer for DeterministicExtractiveSessionSummarizer {
1127    fn descriptor(&self) -> SessionSummarizerDescriptor {
1128        self.descriptor.clone()
1129    }
1130
1131    async fn summarize_with_char_budget(
1132        &self,
1133        input: SessionCompactionInput,
1134        max_chars: usize,
1135    ) -> Result<ModelMessage, SessionContextError> {
1136        Self::new(self.max_summary_chars.min(max_chars))?
1137            .summarize(input)
1138            .await
1139    }
1140
1141    async fn summarize(
1142        &self,
1143        input: SessionCompactionInput,
1144    ) -> Result<ModelMessage, SessionContextError> {
1145        if input.groups.is_empty()
1146            || input.groups.iter().any(|group| {
1147                group.messages.is_empty()
1148                    || group.source.validate().is_err()
1149                    || group.source.last_session_seq > input.source.last_session_seq
1150            })
1151        {
1152            return Err(SessionContextError::Compaction(
1153                "extractive summary requires non-empty source groups inside its source range"
1154                    .to_owned(),
1155            ));
1156        }
1157
1158        let focus = input
1159            .focus_messages
1160            .iter()
1161            .map(render_compaction_message)
1162            .collect::<Result<Vec<_>, _>>()?
1163            .join("\n");
1164        let focus_terms = extract_summary_terms(&focus);
1165        let latest_tool_exchange = input.groups.iter().rposition(|group| {
1166            group.messages.iter().any(|message| {
1167                message
1168                    .content
1169                    .iter()
1170                    .any(|content| matches!(content, ModelContent::ToolResult { .. }))
1171            })
1172        });
1173        // Bound individual excerpts before selection. Otherwise a long result
1174        // can never fit and a completed check disappears behind short reads.
1175        let excerpt_limit = (self.max_summary_chars / 4).max(512);
1176        let mut candidates = input
1177            .groups
1178            .iter()
1179            .enumerate()
1180            .map(|(index, group)| {
1181                let rendered = render_compaction_group(group)?;
1182                Ok(ExtractiveCandidate {
1183                    index,
1184                    terms: extract_summary_terms(&rendered),
1185                    rendered: excerpt_summary_chars(&rendered, excerpt_limit),
1186                    score: 0,
1187                    latest_tool_exchange: Some(index) == latest_tool_exchange,
1188                    tool_observation: compact_tool_observation(group)?,
1189                    continuation_observation: summary_continuation::artifact_page_observation(
1190                        group,
1191                    ),
1192                    failed: group
1193                        .messages
1194                        .iter()
1195                        .flat_map(|message| &message.content)
1196                        .any(|content| {
1197                            matches!(content, ModelContent::ToolResult { is_error: true, .. })
1198                        }),
1199                })
1200            })
1201            .collect::<Result<Vec<_>, SessionContextError>>()?;
1202        let header = if self.max_summary_chars <= 512 {
1203            format!(
1204                "UNTRUSTED earlier transcript; not policy/verification.\nshadowed_session_seq={}..{}",
1205                input.source.first_session_seq, input.source.last_session_seq
1206            )
1207        } else {
1208            format!(
1209            "UNTRUSTED earlier transcript, not system policy. Non-contiguous excerpts are not complete replacement text; recall original session_seq records. Tool success does not prove task verification.\nshadowed_session_seq={}..{}",
1210            input.source.first_session_seq, input.source.last_session_seq
1211        )
1212        };
1213        let mut remaining = self
1214            .max_summary_chars
1215            .saturating_sub(header.chars().count());
1216        // Relevance scores alone can keep old failures and source listings
1217        // while losing current observations. Reserve space for recent Tool
1218        // results independently of vocabulary, retaining the latest occurrence
1219        // of identical calls. This is an observation ledger, not a task verdict.
1220        let mut observation_budget = if remaining >= 512 {
1221            remaining / 2
1222        } else {
1223            remaining
1224        };
1225        let mut observations = Vec::new();
1226        let mut seen_calls = BTreeSet::new();
1227        for candidate in candidates.iter().rev() {
1228            let Some((key, observation)) = &candidate.tool_observation else {
1229                continue;
1230            };
1231            if !seen_calls.insert(key) {
1232                continue;
1233            }
1234            // A small summary must preserve a usable continuation instead of
1235            // fragmenting the reference or cursor among transcript excerpts.
1236            let observation = if let Some(continuation) = &candidate.continuation_observation {
1237                continuation
1238            } else {
1239                observation
1240            };
1241            let chars = observation.chars().count() + 2;
1242            if chars <= observation_budget {
1243                observations.push(observation.clone());
1244                observation_budget -= chars;
1245                remaining -= chars;
1246            }
1247        }
1248        observations.reverse();
1249        let mut document_frequency = BTreeMap::<String, usize>::new();
1250        for candidate in &candidates {
1251            for term in &candidate.terms {
1252                *document_frequency.entry(term.clone()).or_default() += 1;
1253            }
1254        }
1255        let candidate_count = candidates.len();
1256        for candidate in &mut candidates {
1257            candidate.score = candidate
1258                .terms
1259                .intersection(&focus_terms)
1260                .filter_map(|term| {
1261                    let frequency = *document_frequency.get(term)?;
1262                    (frequency < candidate_count).then(|| {
1263                        let length = term.chars().count().min(32) as u64;
1264                        length
1265                            .saturating_mul(length)
1266                            .saturating_mul((candidate_count + 1 - frequency) as u64)
1267                    })
1268                })
1269                .sum();
1270        }
1271        let has_relevant = candidates.iter().any(|candidate| candidate.score > 0);
1272        if has_relevant {
1273            candidates.retain(|candidate| {
1274                candidate.score > 0 || candidate.failed || candidate.latest_tool_exchange
1275            });
1276        }
1277        candidates.sort_by(|left, right| {
1278            right
1279                .latest_tool_exchange
1280                .cmp(&left.latest_tool_exchange)
1281                .then_with(|| right.failed.cmp(&left.failed))
1282                .then_with(|| right.score.cmp(&left.score))
1283                .then_with(|| right.index.cmp(&left.index))
1284        });
1285
1286        let mut selected = Vec::<(usize, String)>::new();
1287        for candidate in &candidates {
1288            let separator_chars = 2;
1289            if remaining <= separator_chars {
1290                break;
1291            }
1292            let rendered_chars = candidate.rendered.chars().count();
1293            if rendered_chars + separator_chars <= remaining {
1294                selected.push((candidate.index, candidate.rendered.clone()));
1295                remaining -= rendered_chars + separator_chars;
1296            } else {
1297                let available = remaining.saturating_sub(separator_chars);
1298                if available >= 128 {
1299                    selected.push((
1300                        candidate.index,
1301                        excerpt_summary_chars(&candidate.rendered, available),
1302                    ));
1303                    remaining -= available + separator_chars;
1304                }
1305            }
1306        }
1307        if selected.is_empty() {
1308            if let Some(candidate) = candidates.first() {
1309                let available = remaining.saturating_sub(2);
1310                if available > 0 {
1311                    selected.push((
1312                        candidate.index,
1313                        excerpt_summary_chars(&candidate.rendered, available),
1314                    ));
1315                }
1316            }
1317        }
1318        selected.sort_by_key(|(index, _)| *index);
1319        let mut summary = header;
1320        for observation in observations {
1321            summary.push_str("\n\n");
1322            summary.push_str(&observation);
1323        }
1324        for (_, rendered) in selected {
1325            summary.push_str("\n\n");
1326            summary.push_str(&rendered);
1327        }
1328        debug_assert!(summary.chars().count() <= self.max_summary_chars);
1329        Ok(ModelMessage::text(ModelRole::Assistant, summary))
1330    }
1331}
1332
1333fn compact_tool_observation(
1334    group: &SessionCompactionGroup,
1335) -> Result<Option<(String, String)>, SessionContextError> {
1336    let mut calls = Vec::new();
1337    let mut results = Vec::new();
1338    for message in &group.messages {
1339        for content in &message.content {
1340            match content {
1341                ModelContent::ToolCall {
1342                    name, arguments, ..
1343                } => calls.push((name, canonical_summary_json(arguments)?)),
1344                ModelContent::ToolResult {
1345                    result, is_error, ..
1346                } => results.push((result, is_error)),
1347                _ => {}
1348            }
1349        }
1350    }
1351    if results.is_empty() {
1352        return Ok(None);
1353    }
1354    let key = if calls.is_empty() {
1355        format!("source:{}", group.source.first_session_seq)
1356    } else {
1357        canonical_summary_json(&serde_json::json!(calls))?
1358    };
1359    let mut observation = format!(
1360        "[recent tool observation session_seq={}..{}; latest identical call]",
1361        group.source.first_session_seq, group.source.last_session_seq
1362    );
1363    for (name, arguments) in calls {
1364        observation.push_str(&format!(
1365            "\n{name} {}",
1366            excerpt_summary_chars(&arguments, 128)
1367        ));
1368    }
1369    for (result, is_error) in results {
1370        observation.push_str(&format!(
1371            "\nrecorded_tool_outcome status={}",
1372            if *is_error { "failed" } else { "succeeded" }
1373        ));
1374        if let Some(fields) = result.as_object() {
1375            let scalars = fields
1376                .iter()
1377                .filter(|(_, value)| value.is_null() || value.is_boolean() || value.is_number())
1378                .map(|(key, value)| (key.clone(), value.clone()))
1379                .collect::<serde_json::Map<_, _>>();
1380            observation.push_str(" scalar_fields=");
1381            observation.push_str(&excerpt_summary_chars(
1382                &canonical_summary_json(&serde_json::Value::Object(scalars))?,
1383                160,
1384            ));
1385            let mut seen_text = BTreeSet::new();
1386            for (key, text) in literal_result_fields(fields) {
1387                if !text.is_empty() && seen_text.insert(text) {
1388                    observation.push_str(&format!(
1389                        "\n{key} text_excerpt={}",
1390                        excerpt_summary_chars(text, 256)
1391                    ));
1392                }
1393            }
1394        } else {
1395            observation.push_str(" result_excerpt=");
1396            observation.push_str(&excerpt_summary_chars(
1397                &render_compaction_result(result)?,
1398                256,
1399            ));
1400        }
1401    }
1402    Ok(Some((key, excerpt_summary_chars(&observation, 512))))
1403}
1404
1405fn render_compaction_group(group: &SessionCompactionGroup) -> Result<String, SessionContextError> {
1406    let mut rendered = format!(
1407        "[historical group session_seq={}..{}]",
1408        group.source.first_session_seq, group.source.last_session_seq
1409    );
1410    // Put typed outcomes ahead of potentially huge arguments and results so
1411    // a bounded excerpt cannot make a failed call look like completed work.
1412    for message in &group.messages {
1413        for content in &message.content {
1414            if let ModelContent::ToolResult {
1415                call_id,
1416                is_error,
1417                result,
1418            } = content
1419            {
1420                rendered.push_str(&format!(
1421                    "\nrecorded_tool_outcome call_id={} status={}",
1422                    call_id,
1423                    if *is_error { "failed" } else { "succeeded" }
1424                ));
1425                // Keep typed scalar result fields (including numeric exit
1426                // status) ahead of large payloads. String fields are rendered
1427                // literally below, not duplicated as escaped JSON. Do not
1428                // interpret arbitrary result fields as task verification.
1429                if let Some(fields) = result.as_object() {
1430                    let scalars = fields
1431                        .iter()
1432                        .filter(|(_, value)| {
1433                            !value.is_string() && !value.is_array() && !value.is_object()
1434                        })
1435                        .map(|(key, value)| (key.clone(), value.clone()))
1436                        .collect::<serde_json::Map<_, _>>();
1437                    if !scalars.is_empty() {
1438                        rendered.push_str("\nrecorded_result_fields=");
1439                        rendered.push_str(&excerpt_summary_chars(
1440                            &canonical_summary_json(&serde_json::Value::Object(scalars))?,
1441                            512,
1442                        ));
1443                    }
1444                    // Preserve short error/source facts before large payloads
1445                    // as before, but do not turn their strings into JSON
1446                    // literals. Bound this ledger independently of the body.
1447                    let short_text = fields
1448                        .iter()
1449                        .filter(|(_, value)| {
1450                            value
1451                                .as_str()
1452                                .is_some_and(|text| text.chars().count() <= 128)
1453                        })
1454                        .map(|(key, value)| (key.clone(), value.clone()))
1455                        .collect::<serde_json::Map<_, _>>();
1456                    if !short_text.is_empty() {
1457                        rendered.push_str("\nrecorded_result_text=");
1458                        rendered.push_str(&excerpt_summary_chars(
1459                            &render_compaction_result(&serde_json::Value::Object(short_text))?,
1460                            512,
1461                        ));
1462                    }
1463                }
1464                if *is_error {
1465                    rendered.push_str("\nerror_result=");
1466                    rendered.push_str(&truncate_summary_chars(
1467                        &render_compaction_result(result)?,
1468                        256,
1469                    ));
1470                }
1471            }
1472        }
1473    }
1474    for message in &group.messages {
1475        for content in &message.content {
1476            if let ModelContent::ToolCall {
1477                call_id,
1478                name,
1479                arguments,
1480                ..
1481            } = content
1482            {
1483                rendered.push_str(&format!(
1484                    "\nrecorded_tool_call call_id={call_id} name={name} arguments={}",
1485                    excerpt_summary_chars(&canonical_summary_json(arguments)?, 256)
1486                ));
1487            }
1488        }
1489    }
1490    for message in &group.messages {
1491        rendered.push('\n');
1492        rendered.push_str(&render_compaction_message(message)?);
1493    }
1494    Ok(rendered)
1495}
1496
1497fn render_compaction_message(message: &ModelMessage) -> Result<String, SessionContextError> {
1498    let role = match message.role {
1499        ModelRole::System => "system",
1500        ModelRole::User => "user",
1501        ModelRole::Assistant => "assistant",
1502        ModelRole::Tool => "tool",
1503        _ => {
1504            return Err(SessionContextError::Compaction(
1505                "extractive summary does not support this future model role".to_owned(),
1506            ))
1507        }
1508    };
1509    let mut rendered = format!("{role}: ");
1510    // Provider continuation belongs to the original assistant exchange. It is
1511    // neither transcript text nor a fact that can be copied into a new System
1512    // summary. The source records and uncompressed recent messages keep it.
1513    for (index, content) in message
1514        .content
1515        .iter()
1516        .filter(|content| !matches!(content, ModelContent::Continuation { .. }))
1517        .enumerate()
1518    {
1519        if index > 0 {
1520            rendered.push_str(" | ");
1521        }
1522        match content {
1523            ModelContent::Text { text } => rendered.push_str(text),
1524            ModelContent::Json { value } => {
1525                rendered.push_str("json=");
1526                rendered.push_str(&canonical_summary_json(value)?);
1527            }
1528            ModelContent::Data { media_type, value } => {
1529                rendered.push_str("data[");
1530                rendered.push_str(media_type);
1531                rendered.push_str("]=");
1532                rendered.push_str(&canonical_summary_json(value)?);
1533            }
1534            ModelContent::ToolCall {
1535                call_id,
1536                name,
1537                arguments,
1538                ..
1539            } => {
1540                rendered.push_str("tool_call id=");
1541                rendered.push_str(call_id.as_str());
1542                rendered.push_str(" name=");
1543                rendered.push_str(name);
1544                rendered.push_str(" arguments=");
1545                rendered.push_str(&canonical_summary_json(arguments)?);
1546            }
1547            ModelContent::ToolResult {
1548                call_id,
1549                result,
1550                is_error,
1551            } => {
1552                rendered.push_str("tool_result id=");
1553                rendered.push_str(call_id.as_str());
1554                rendered.push_str(" error=");
1555                rendered.push_str(if *is_error { "true" } else { "false" });
1556                rendered.push_str(" result=");
1557                rendered.push_str(&render_compaction_result(result)?);
1558            }
1559            _ => {
1560                return Err(SessionContextError::Compaction(
1561                    "extractive summary does not support this future model content".to_owned(),
1562                ))
1563            }
1564        }
1565    }
1566    Ok(rendered)
1567}
1568
1569/// Render the original top-level string fields as text, not JSON string
1570/// literals. A summary becomes ordinary model text, so adapter ToolResult
1571/// codecs cannot undo JSON escaping introduced here. Non-string values keep
1572/// their canonical JSON representation; the journal itself is never changed.
1573fn render_compaction_result(value: &serde_json::Value) -> Result<String, SessionContextError> {
1574    match value {
1575        serde_json::Value::String(text) => Ok(format!("literal text excerpt:\n{text}")),
1576        serde_json::Value::Object(fields) => {
1577            let metadata = fields
1578                .iter()
1579                .filter(|(_, value)| !value.is_string())
1580                .map(|(key, value)| (key.clone(), value.clone()))
1581                .collect::<serde_json::Map<_, _>>();
1582            let mut rendered = canonical_summary_json(&serde_json::Value::Object(metadata))?;
1583            for (key, text) in literal_result_fields(fields) {
1584                rendered.push_str(&format!(
1585                    "\nfield {} literal text excerpt:\n{text}",
1586                    canonical_summary_json(&serde_json::Value::String(key.to_owned()))?,
1587                ));
1588            }
1589            Ok(rendered)
1590        }
1591        _ => canonical_summary_json(value),
1592    }
1593}
1594
1595fn literal_result_fields(
1596    fields: &serde_json::Map<String, serde_json::Value>,
1597) -> BTreeMap<&str, &str> {
1598    // The summary identity must not depend on serde_json's preserve_order
1599    // feature or on the insertion order of an otherwise identical result.
1600    fields
1601        .iter()
1602        .filter_map(|(key, value)| value.as_str().map(|text| (key.as_str(), text)))
1603        .collect()
1604}
1605
1606fn canonical_summary_json(value: &serde_json::Value) -> Result<String, SessionContextError> {
1607    let bytes = serde_jcs::to_vec(value).map_err(|error| {
1608        SessionContextError::Compaction(format!(
1609            "could not render canonical summary content: {error}"
1610        ))
1611    })?;
1612    String::from_utf8(bytes).map_err(|error| {
1613        SessionContextError::Compaction(format!("canonical summary was not UTF-8: {error}"))
1614    })
1615}
1616
1617fn extract_summary_terms(text: &str) -> BTreeSet<String> {
1618    let mut terms = BTreeSet::new();
1619    let mut current = String::new();
1620    let flush = |current: &mut String, terms: &mut BTreeSet<String>| {
1621        if current.chars().count() >= 2 {
1622            terms.insert(std::mem::take(current));
1623        } else {
1624            current.clear();
1625        }
1626    };
1627    for character in text.chars() {
1628        if character.is_alphanumeric() || matches!(character, '_' | '-') {
1629            current.extend(character.to_lowercase());
1630        } else {
1631            flush(&mut current, &mut terms);
1632        }
1633    }
1634    flush(&mut current, &mut terms);
1635    terms
1636}
1637
1638fn truncate_summary_chars(value: &str, limit: usize) -> String {
1639    if value.chars().count() <= limit {
1640        return value.to_owned();
1641    }
1642    if limit == 1 {
1643        return "…".to_owned();
1644    }
1645    value.chars().take(limit - 1).chain(['…']).collect()
1646}
1647
1648fn excerpt_summary_chars(value: &str, limit: usize) -> String {
1649    const OMITTED: &str = "\n[… excerpt omitted …]\n";
1650    let count = value.chars().count();
1651    if count <= limit {
1652        return value.to_owned();
1653    }
1654    let marker_chars = OMITTED.chars().count();
1655    if limit <= marker_chars {
1656        return truncate_summary_chars(value, limit);
1657    }
1658    let available = limit - marker_chars;
1659    let head = available.div_ceil(2);
1660    let tail = available - head;
1661    value
1662        .chars()
1663        .take(head)
1664        .chain(OMITTED.chars())
1665        .chain(value.chars().skip(count - tail))
1666        .collect()
1667}
1668
1669#[async_trait]
1670pub trait AgentSessionSummarizer: Send + Sync {
1671    fn descriptor(&self) -> SessionSummarizerDescriptor;
1672
1673    async fn summarize(
1674        &self,
1675        input: SessionCompactionInput,
1676    ) -> Result<ModelMessage, SessionContextError>;
1677
1678    /// A size hint for pressure compaction, including the summary's framing
1679    /// text. The runtime still meters the complete candidate model input and
1680    /// never commits a summary that increases its context use. Implementations
1681    /// that do not support adaptive summaries may return their normal result.
1682    async fn summarize_with_char_budget(
1683        &self,
1684        input: SessionCompactionInput,
1685        _max_chars: usize,
1686    ) -> Result<ModelMessage, SessionContextError> {
1687        self.summarize(input).await
1688    }
1689}
1690
1691/// Expand only durable backward references; original exchanges stay atomic
1692/// and are deduplicated in chronological order. No summary text is parsed.
1693fn original_compaction_groups(
1694    records: &[AgentSessionRecord],
1695    source: &SessionSourceRange,
1696) -> Result<Vec<SessionCompactionGroup>, SessionContextError> {
1697    let mut pending = (source.first_session_seq..=source.last_session_seq).collect::<Vec<_>>();
1698    let mut visited = BTreeSet::new();
1699    let mut originals = BTreeMap::new();
1700    while let Some(sequence) = pending.pop() {
1701        if !visited.insert(sequence) {
1702            continue;
1703        }
1704        let record = records.get((sequence - 1) as usize).ok_or_else(|| {
1705            SessionContextError::Compaction(
1706                "original source is outside the Session journal".to_owned(),
1707            )
1708        })?;
1709        let messages = match &record.payload {
1710            AgentSessionEvent::CompactionCommitted { source, .. }
1711            | AgentSessionEvent::ActiveRunCompactionCommitted { source, .. } => {
1712                if source.last_session_seq >= sequence {
1713                    return Err(SessionContextError::Compaction(
1714                        "compaction references must point backward".to_owned(),
1715                    ));
1716                }
1717                pending.extend(source.first_session_seq..=source.last_session_seq);
1718                continue;
1719            }
1720            AgentSessionEvent::RunInputCommitted { message }
1721            | AgentSessionEvent::RunOutputCommitted { message, .. } => vec![message.clone()],
1722            AgentSessionEvent::ToolExchangeCommitted {
1723                assistant, tool, ..
1724            } => vec![assistant.clone(), tool.clone()],
1725            _ => {
1726                return Err(SessionContextError::Compaction(
1727                    "original source crosses a protected Session fact".to_owned(),
1728                ))
1729            }
1730        };
1731        originals.insert(
1732            sequence,
1733            SessionCompactionGroup {
1734                source: single_range(sequence),
1735                messages,
1736            },
1737        );
1738    }
1739    Ok(originals.into_values().collect())
1740}
1741
1742pub struct AgentSessionCompactor {
1743    journal: Arc<dyn AgentSessionJournalStore>,
1744    summarizer: Arc<dyn AgentSessionSummarizer>,
1745    summarizer_descriptor: SessionSummarizerDescriptor,
1746    policy: SessionCompactionPolicy,
1747}
1748
1749impl AgentSessionCompactor {
1750    pub fn new(
1751        journal: Arc<dyn AgentSessionJournalStore>,
1752        summarizer: Arc<dyn AgentSessionSummarizer>,
1753        policy: SessionCompactionPolicy,
1754    ) -> Result<Self, SessionContextError> {
1755        policy.validate()?;
1756        let summarizer_descriptor = summarizer.descriptor();
1757        summarizer_descriptor.validate()?;
1758        Ok(Self {
1759            journal,
1760            summarizer,
1761            summarizer_descriptor,
1762            policy,
1763        })
1764    }
1765
1766    pub fn policy(&self) -> &SessionCompactionPolicy {
1767        &self.policy
1768    }
1769
1770    pub fn summarizer_descriptor(&self) -> &SessionSummarizerDescriptor {
1771        &self.summarizer_descriptor
1772    }
1773
1774    pub async fn compact_if_needed(
1775        &self,
1776        session_id: &AgentSessionId,
1777        current_run_id: &RunId,
1778    ) -> Result<Option<AgentSessionRecord>, SessionContextError> {
1779        let records = self.journal.load_session(session_id).await?;
1780        validate_session_trace(session_id, &records)?;
1781        let Some(source) = select_compaction_source(&records, current_run_id, &self.policy) else {
1782            return Ok(None);
1783        };
1784        let source_digest = session_range_digest(&records, &source)?;
1785        let policy_digest = self.policy.digest()?;
1786        let groups = replay_groups(&records, current_run_id, &BTreeMap::new())?;
1787        let source_groups = original_compaction_groups(&records, &source)?;
1788        let focus_messages = groups
1789            .values()
1790            .filter(|group| group.pinned && !source.contains(group.producer_seq))
1791            .flat_map(|group| group.messages.clone())
1792            .collect();
1793        let summary = self
1794            .summarizer
1795            .summarize(SessionCompactionInput {
1796                session_id: session_id.clone(),
1797                source: source.clone(),
1798                groups: source_groups,
1799                focus_messages,
1800            })
1801            .await?;
1802        summary.validate().map_err(|error| {
1803            SessionContextError::Compaction(format!("invalid summary message: {error}"))
1804        })?;
1805        if summary.role == ModelRole::Assistant
1806            && !placement::can_replace_source(&groups, &records, &source)
1807        {
1808            return Ok(None);
1809        }
1810        let event_id = AgentSessionEventId::new(format!(
1811            "compaction-{}-{}-{}",
1812            session_id.as_str(),
1813            source.first_session_seq,
1814            source.last_session_seq
1815        ));
1816        let appended = self
1817            .journal
1818            .append(AgentSessionEventDraft {
1819                event_id,
1820                session_id: session_id.clone(),
1821                run_id: current_run_id.clone(),
1822                payload: AgentSessionEvent::CompactionCommitted {
1823                    source,
1824                    source_digest,
1825                    policy_digest,
1826                    summary_config_digest: self.summarizer_descriptor.config_digest.clone(),
1827                    summary,
1828                    strategy: self.summarizer_descriptor.strategy.clone(),
1829                    model: self.summarizer_descriptor.model.clone(),
1830                    version: self.summarizer_descriptor.version.clone(),
1831                },
1832            })
1833            .await?;
1834        Ok(Some(appended.record))
1835    }
1836
1837    /// Compacts only already-committed, complete Tool exchanges from the
1838    /// current Run. Callers use this after a local Context preflight reports
1839    /// pressure and before starting the next model attempt.
1840    pub async fn compact_active_run_for_pressure(
1841        &self,
1842        session_id: &AgentSessionId,
1843        current_run_id: &RunId,
1844    ) -> Result<Option<AgentSessionRecord>, SessionContextError> {
1845        let records = self.journal.load_session(session_id).await?;
1846        validate_session_trace(session_id, &records)?;
1847        let Some(source) =
1848            select_active_run_compaction_source(&records, current_run_id, &self.policy)
1849        else {
1850            return Ok(None);
1851        };
1852        let groups = replay_groups(&records, current_run_id, &BTreeMap::new())?;
1853        if !live_source::valid_active_source(&groups, &records, &source, current_run_id) {
1854            return Err(SessionContextError::Compaction(
1855                "active-Run compaction source no longer matches live Context groups".to_owned(),
1856            ));
1857        }
1858        let source_groups = original_compaction_groups(&records, &source)?;
1859        let focus_messages = groups
1860            .values()
1861            .filter(|group| group.pinned && !source.contains(group.producer_seq))
1862            .flat_map(|group| group.messages.clone())
1863            .collect();
1864        let summary = self
1865            .summarizer
1866            .summarize(SessionCompactionInput {
1867                session_id: session_id.clone(),
1868                source: source.clone(),
1869                groups: source_groups,
1870                focus_messages,
1871            })
1872            .await?;
1873        self.commit_active_run_summary(&records, current_run_id, source, summary)
1874            .await
1875    }
1876
1877    async fn commit_active_run_summary(
1878        &self,
1879        records: &[AgentSessionRecord],
1880        current_run_id: &RunId,
1881        source: SessionSourceRange,
1882        summary: ModelMessage,
1883    ) -> Result<Option<AgentSessionRecord>, SessionContextError> {
1884        let session_id = &records[0].session_id;
1885        let groups = replay_groups(records, current_run_id, &BTreeMap::new())?;
1886        if !live_source::valid_active_source(&groups, records, &source, current_run_id) {
1887            return Err(SessionContextError::Compaction(
1888                "active-Run source must contain live Tool producers from one Run".to_owned(),
1889            ));
1890        }
1891        if (summary.role == ModelRole::Assistant
1892            || live_source::has_shadowed_records(&groups, &source))
1893            && !placement::can_replace_source(&groups, records, &source)
1894        {
1895            return Ok(None);
1896        }
1897        let source_digest = session_range_digest(records, &source)?;
1898        let policy_digest = self.policy.digest()?;
1899        summary.validate().map_err(|error| {
1900            SessionContextError::Compaction(format!("invalid active-Run summary: {error}"))
1901        })?;
1902        let event_id = AgentSessionEventId::new(format!(
1903            "active-run-compaction-{}-{}-{}-{}",
1904            session_id.as_str(),
1905            current_run_id.as_str(),
1906            source.first_session_seq,
1907            source.last_session_seq
1908        ));
1909        let appended = self
1910            .journal
1911            .append(AgentSessionEventDraft {
1912                event_id,
1913                session_id: session_id.clone(),
1914                run_id: current_run_id.clone(),
1915                payload: AgentSessionEvent::ActiveRunCompactionCommitted {
1916                    source,
1917                    source_digest,
1918                    policy_digest,
1919                    summary_config_digest: self.summarizer_descriptor.config_digest.clone(),
1920                    summary,
1921                    strategy: self.summarizer_descriptor.strategy.clone(),
1922                    model: self.summarizer_descriptor.model.clone(),
1923                    version: self.summarizer_descriptor.version.clone(),
1924                },
1925            })
1926            .await?;
1927        Ok(Some(appended.record))
1928    }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933    use super::*;
1934    use orchestral_core::agent_protocol::wire::{ArtifactRef, ArtifactRefWithDigest};
1935    use orchestral_core::agent_session::{
1936        AgentSessionEvent, AgentSessionEventDraft, InMemoryAgentSessionJournalStore,
1937    };
1938    use orchestral_core::model_protocol::{ModelContent, ModelRequestId, ModelToolCallId};
1939    use orchestral_core::tool_protocol::ToolCallId;
1940    use serde_json::json;
1941
1942    struct FixedSummarizer;
1943
1944    #[async_trait]
1945    impl AgentSessionSummarizer for FixedSummarizer {
1946        fn descriptor(&self) -> SessionSummarizerDescriptor {
1947            SessionSummarizerDescriptor {
1948                strategy: "fixed-test-summary".to_owned(),
1949                model: None,
1950                version: "1".to_owned(),
1951                config_digest: Digest::sha256("fixed-test-summary/v1"),
1952            }
1953        }
1954
1955        async fn summarize(
1956            &self,
1957            input: SessionCompactionInput,
1958        ) -> Result<ModelMessage, SessionContextError> {
1959            Ok(ModelMessage::text(
1960                ModelRole::System,
1961                format!(
1962                    "summary of {} messages",
1963                    input
1964                        .groups
1965                        .iter()
1966                        .map(|group| group.messages.len())
1967                        .sum::<usize>()
1968                ),
1969            ))
1970        }
1971    }
1972
1973    pub(super) async fn append_input(
1974        store: &Arc<InMemoryAgentSessionJournalStore>,
1975        sequence: u64,
1976        run_id: &str,
1977        text: String,
1978    ) {
1979        store
1980            .append(AgentSessionEventDraft {
1981                event_id: AgentSessionEventId::new(format!("input-{sequence}")),
1982                session_id: AgentSessionId::new("session-1"),
1983                run_id: RunId::new(run_id),
1984                payload: AgentSessionEvent::RunInputCommitted {
1985                    message: ModelMessage::text(ModelRole::User, text),
1986                },
1987            })
1988            .await
1989            .unwrap();
1990    }
1991
1992    async fn append_session_payload(
1993        store: &Arc<InMemoryAgentSessionJournalStore>,
1994        session_id: &AgentSessionId,
1995        run_id: &RunId,
1996        event_id: String,
1997        payload: AgentSessionEvent,
1998    ) {
1999        store
2000            .append(AgentSessionEventDraft {
2001                event_id: AgentSessionEventId::new(event_id),
2002                session_id: session_id.clone(),
2003                run_id: run_id.clone(),
2004                payload,
2005            })
2006            .await
2007            .unwrap();
2008    }
2009
2010    pub(super) async fn append_tool_exchange(
2011        store: &Arc<InMemoryAgentSessionJournalStore>,
2012        sequence: u64,
2013        run_id: &str,
2014        width: usize,
2015    ) {
2016        let call_id = ModelToolCallId::new(format!("active-call-{sequence}"));
2017        store
2018            .append(AgentSessionEventDraft {
2019                event_id: AgentSessionEventId::new(format!("active-exchange-{sequence}")),
2020                session_id: AgentSessionId::new("session-1"),
2021                run_id: RunId::new(run_id),
2022                payload: AgentSessionEvent::ToolExchangeCommitted {
2023                    request_id: ModelRequestId::new(format!("active-request-{sequence}")),
2024                    assistant: ModelMessage {
2025                        role: ModelRole::Assistant,
2026                        content: vec![ModelContent::ToolCall {
2027                            call_id: call_id.clone(),
2028                            name: "file_read".to_owned(),
2029                            arguments: json!({"path": format!("src/file-{sequence}.rs")}),
2030                            extensions: Default::default(),
2031                        }],
2032                    },
2033                    tool: ModelMessage {
2034                        role: ModelRole::Tool,
2035                        content: vec![ModelContent::ToolResult {
2036                            call_id,
2037                            result: json!({"text": "x".repeat(width)}),
2038                            is_error: false,
2039                        }],
2040                    },
2041                    retained_artifacts: Vec::new(),
2042                    usage: None,
2043                },
2044            })
2045            .await
2046            .unwrap();
2047    }
2048
2049    fn push_session_record(
2050        records: &mut Vec<AgentSessionRecord>,
2051        session_id: &AgentSessionId,
2052        run_id: &RunId,
2053        event_suffix: impl std::fmt::Display,
2054        payload: AgentSessionEvent,
2055    ) {
2056        let session_seq = records.len() as u64 + 1;
2057        records.push(
2058            AgentSessionRecord::seal(
2059                AgentSessionEventDraft {
2060                    event_id: AgentSessionEventId::new(format!(
2061                        "replay-{event_suffix}-{session_seq}"
2062                    )),
2063                    session_id: session_id.clone(),
2064                    run_id: run_id.clone(),
2065                    payload,
2066                },
2067                session_seq,
2068            )
2069            .unwrap(),
2070        );
2071    }
2072
2073    #[tokio::test]
2074    async fn newest_history_cannot_be_evicted_by_older_messages() {
2075        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2076        for index in 1..=12 {
2077            append_input(
2078                &store,
2079                index,
2080                if index == 12 { "current" } else { "old" },
2081                format!("message-{index}-{}", "x".repeat(60)),
2082            )
2083            .await;
2084        }
2085        let engine =
2086            AgentSessionContextEngine::new(store, Arc::new(JsonSizeTokenMeter::new(1).unwrap()));
2087        let request = || SessionContextRequest {
2088            session_id: AgentSessionId::new("session-1"),
2089            current_run_id: RunId::new("current"),
2090            through_session_seq: None,
2091            system_message: Some(ModelMessage::text(ModelRole::System, "system")),
2092            tools: Vec::new(),
2093            history_limit: 100,
2094            max_context_tokens: 600,
2095            reserved_output_tokens: 100,
2096            config_digest: Digest::sha256("config"),
2097            allowed_skill_digests: BTreeMap::new(),
2098        };
2099        let projection = engine.project(request()).await.unwrap();
2100        let rendered = serde_json::to_string(&projection.messages).unwrap();
2101        assert!(rendered.contains("message-12"));
2102        assert!(rendered.contains("message-11"));
2103        assert!(!rendered.contains("message-1-"));
2104        assert!(projection.used_input_tokens <= projection.input_budget_tokens);
2105        let planning = engine
2106            .project_with_policy(request(), ContextTokenPolicy::Planning)
2107            .await
2108            .unwrap();
2109        assert_eq!(planning.messages, projection.messages);
2110        assert_eq!(planning.used_input_tokens, projection.used_input_tokens);
2111        assert_eq!(planning.included_ranges, projection.included_ranges);
2112        assert!(planning.context_estimate.is_none());
2113    }
2114
2115    #[tokio::test]
2116    async fn tool_call_and_result_are_selected_as_one_atomic_group() {
2117        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2118        append_input(&store, 1, "old", "old".to_owned()).await;
2119        store
2120            .append(AgentSessionEventDraft {
2121                event_id: AgentSessionEventId::new("tool-pair"),
2122                session_id: AgentSessionId::new("session-1"),
2123                run_id: RunId::new("old"),
2124                payload: AgentSessionEvent::ToolExchangeCommitted {
2125                    request_id: ModelRequestId::new("request-1"),
2126                    assistant: ModelMessage {
2127                        role: ModelRole::Assistant,
2128                        content: vec![ModelContent::ToolCall {
2129                            call_id: ModelToolCallId::new("call-1"),
2130                            name: "echo".to_owned(),
2131                            arguments: json!({}),
2132                            extensions: Default::default(),
2133                        }],
2134                    },
2135                    tool: ModelMessage {
2136                        role: ModelRole::Tool,
2137                        content: vec![ModelContent::ToolResult {
2138                            call_id: ModelToolCallId::new("call-1"),
2139                            result: json!({ "large": "x".repeat(100) }),
2140                            is_error: false,
2141                        }],
2142                    },
2143                    retained_artifacts: Vec::new(),
2144                    usage: None,
2145                },
2146            })
2147            .await
2148            .unwrap();
2149        append_input(&store, 3, "current", "current".to_owned()).await;
2150        let engine =
2151            AgentSessionContextEngine::new(store, Arc::new(JsonSizeTokenMeter::new(1).unwrap()));
2152        let projection = engine
2153            .project(SessionContextRequest {
2154                session_id: AgentSessionId::new("session-1"),
2155                current_run_id: RunId::new("current"),
2156                through_session_seq: None,
2157                system_message: None,
2158                tools: Vec::new(),
2159                history_limit: 100,
2160                max_context_tokens: 180,
2161                reserved_output_tokens: 20,
2162                config_digest: Digest::sha256("config"),
2163                allowed_skill_digests: BTreeMap::new(),
2164            })
2165            .await
2166            .unwrap();
2167        let has_call = projection.messages.iter().any(|message| {
2168            message
2169                .content
2170                .iter()
2171                .any(|content| matches!(content, ModelContent::ToolCall { .. }))
2172        });
2173        let has_result = projection.messages.iter().any(|message| {
2174            message
2175                .content
2176                .iter()
2177                .any(|content| matches!(content, ModelContent::ToolResult { .. }))
2178        });
2179        assert_eq!(has_call, has_result);
2180
2181        let prior = engine
2182            .project(SessionContextRequest {
2183                session_id: AgentSessionId::new("session-1"),
2184                current_run_id: RunId::new("current"),
2185                through_session_seq: Some(1),
2186                system_message: None,
2187                tools: Vec::new(),
2188                history_limit: 100,
2189                max_context_tokens: 180,
2190                reserved_output_tokens: 20,
2191                config_digest: Digest::sha256("config"),
2192                allowed_skill_digests: BTreeMap::new(),
2193            })
2194            .await
2195            .unwrap();
2196        assert_eq!(prior.through_session_seq, 1);
2197        assert!(prior.messages.iter().all(|message| {
2198            message.content.iter().all(|content| {
2199                !matches!(
2200                    content,
2201                    ModelContent::ToolCall { .. } | ModelContent::ToolResult { .. }
2202                )
2203            })
2204        }));
2205    }
2206
2207    #[tokio::test]
2208    async fn compaction_is_traceable_ordered_and_does_not_repeat_without_new_history() {
2209        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2210        for index in 1..=6 {
2211            append_input(&store, index, "old", format!("old-{index}")).await;
2212        }
2213        append_input(&store, 7, "current", "current-input".to_owned()).await;
2214        let policy = SessionCompactionPolicy {
2215            minimum_source_records: 3,
2216            keep_recent_records: 2,
2217        };
2218        let policy_digest = policy.digest().unwrap();
2219        let compactor =
2220            AgentSessionCompactor::new(store.clone(), Arc::new(FixedSummarizer), policy).unwrap();
2221        let compacted = compactor
2222            .compact_if_needed(&AgentSessionId::new("session-1"), &RunId::new("current"))
2223            .await
2224            .unwrap()
2225            .expect("old prefix is compacted");
2226        let (source, source_digest, persisted_policy_digest, summary_config_digest) =
2227            match &compacted.payload {
2228                AgentSessionEvent::CompactionCommitted {
2229                    source,
2230                    source_digest,
2231                    policy_digest,
2232                    summary_config_digest,
2233                    ..
2234                } => (source, source_digest, policy_digest, summary_config_digest),
2235                _ => panic!("expected compaction record"),
2236            };
2237        assert_eq!(source.first_session_seq, 1);
2238        assert_eq!(source.last_session_seq, 5);
2239        let records = store
2240            .load_session(&AgentSessionId::new("session-1"))
2241            .await
2242            .unwrap();
2243        assert_eq!(
2244            session_range_digest(&records, source).unwrap(),
2245            *source_digest
2246        );
2247        assert_eq!(*persisted_policy_digest, policy_digest);
2248        assert_eq!(
2249            *summary_config_digest,
2250            Digest::sha256("fixed-test-summary/v1")
2251        );
2252        assert!(compactor
2253            .compact_if_needed(&AgentSessionId::new("session-1"), &RunId::new("current"),)
2254            .await
2255            .unwrap()
2256            .is_none());
2257
2258        let engine =
2259            AgentSessionContextEngine::new(store, Arc::new(JsonSizeTokenMeter::new(1).unwrap()));
2260        let projection = engine
2261            .project(SessionContextRequest {
2262                session_id: AgentSessionId::new("session-1"),
2263                current_run_id: RunId::new("current"),
2264                through_session_seq: None,
2265                system_message: None,
2266                tools: Vec::new(),
2267                history_limit: 100,
2268                max_context_tokens: 10_000,
2269                reserved_output_tokens: 100,
2270                config_digest: Digest::sha256("config"),
2271                allowed_skill_digests: BTreeMap::new(),
2272            })
2273            .await
2274            .unwrap();
2275        let rendered = projection
2276            .messages
2277            .iter()
2278            .map(|message| serde_json::to_string(message).unwrap())
2279            .collect::<Vec<_>>();
2280        assert!(rendered[0].contains("summary of 5 messages"));
2281        assert!(!rendered.join(" ").contains("old-1"));
2282        assert!(rendered.last().unwrap().contains("current-input"));
2283    }
2284
2285    #[tokio::test]
2286    async fn active_run_pressure_compaction_preserves_task_and_atomic_recent_exchanges() {
2287        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2288        append_input(&store, 1, "current", "inspect the repository".to_owned()).await;
2289        for sequence in 1..=5 {
2290            append_tool_exchange(&store, sequence, "current", 400).await;
2291        }
2292        let meter = Arc::new(JsonSizeTokenMeter::new(1).unwrap());
2293        let engine = AgentSessionContextEngine::new(store.clone(), meter);
2294        let request = || SessionContextRequest {
2295            session_id: AgentSessionId::new("session-1"),
2296            current_run_id: RunId::new("current"),
2297            through_session_seq: None,
2298            system_message: None,
2299            tools: Vec::new(),
2300            history_limit: 100,
2301            max_context_tokens: 1_800,
2302            reserved_output_tokens: 100,
2303            config_digest: Digest::sha256("config"),
2304            allowed_skill_digests: BTreeMap::new(),
2305        };
2306        assert!(matches!(
2307            engine.project(request()).await,
2308            Err(SessionContextError::ContextOverflow { .. })
2309        ));
2310
2311        let compactor = AgentSessionCompactor::new(
2312            store.clone(),
2313            Arc::new(FixedSummarizer),
2314            SessionCompactionPolicy {
2315                minimum_source_records: 3,
2316                keep_recent_records: 2,
2317            },
2318        )
2319        .unwrap();
2320        let compacted = compactor
2321            .compact_active_run_for_pressure(
2322                &AgentSessionId::new("session-1"),
2323                &RunId::new("current"),
2324            )
2325            .await
2326            .unwrap()
2327            .expect("older active exchanges should compact");
2328        assert!(matches!(
2329            compacted.payload,
2330            AgentSessionEvent::ActiveRunCompactionCommitted { .. }
2331        ));
2332
2333        let projection = engine.project(request()).await.unwrap();
2334        let rendered = serde_json::to_string(&projection.messages).unwrap();
2335        assert!(rendered.contains("inspect the repository"));
2336        assert!(rendered.contains("summary of 6 messages"));
2337        assert!(!rendered.contains("active-call-1"));
2338        assert!(rendered.contains("active-call-4"));
2339        assert!(rendered.contains("active-call-5"));
2340        let calls = projection
2341            .messages
2342            .iter()
2343            .flat_map(|message| &message.content)
2344            .filter(|content| matches!(content, ModelContent::ToolCall { .. }))
2345            .count();
2346        let results = projection
2347            .messages
2348            .iter()
2349            .flat_map(|message| &message.content)
2350            .filter(|content| matches!(content, ModelContent::ToolResult { .. }))
2351            .count();
2352        assert_eq!((calls, results), (2, 2));
2353
2354        let prior = engine
2355            .project(SessionContextRequest {
2356                through_session_seq: Some(6),
2357                max_context_tokens: 10_000,
2358                ..request()
2359            })
2360            .await
2361            .unwrap();
2362        let prior_rendered = serde_json::to_string(&prior.messages).unwrap();
2363        assert!(prior_rendered.contains("active-call-1"));
2364        assert!(!prior_rendered.contains("summary of 6 messages"));
2365    }
2366
2367    #[tokio::test]
2368    async fn repeated_active_run_compaction_merges_the_previous_summary() {
2369        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2370        append_input(&store, 1, "current", "continue the task".to_owned()).await;
2371        for sequence in 1..=5 {
2372            append_tool_exchange(&store, sequence, "current", 100).await;
2373        }
2374        let compactor = AgentSessionCompactor::new(
2375            store.clone(),
2376            Arc::new(FixedSummarizer),
2377            SessionCompactionPolicy {
2378                minimum_source_records: 3,
2379                keep_recent_records: 2,
2380            },
2381        )
2382        .unwrap();
2383        compactor
2384            .compact_active_run_for_pressure(
2385                &AgentSessionId::new("session-1"),
2386                &RunId::new("current"),
2387            )
2388            .await
2389            .unwrap()
2390            .unwrap();
2391        append_tool_exchange(&store, 6, "current", 100).await;
2392        append_tool_exchange(&store, 7, "current", 100).await;
2393        let second = compactor
2394            .compact_active_run_for_pressure(
2395                &AgentSessionId::new("session-1"),
2396                &RunId::new("current"),
2397            )
2398            .await
2399            .unwrap()
2400            .expect("the prior summary and newly old exchanges should merge");
2401        let AgentSessionEvent::ActiveRunCompactionCommitted { source, .. } = second.payload else {
2402            panic!("expected active-Run compaction");
2403        };
2404        assert_eq!(
2405            source,
2406            SessionSourceRange {
2407                first_session_seq: 5,
2408                last_session_seq: 8,
2409            }
2410        );
2411
2412        let records = store
2413            .load_session(&AgentSessionId::new("session-1"))
2414            .await
2415            .unwrap();
2416        let originals = original_compaction_groups(&records, &source).unwrap();
2417        assert_eq!(originals.len(), 6);
2418        assert_eq!(originals[0].source, single_range(2));
2419        assert!(originals
2420            .iter()
2421            .flat_map(|group| &group.messages)
2422            .all(|message| message.role != ModelRole::System));
2423        let groups = replay_groups(&records, &RunId::new("current"), &BTreeMap::new()).unwrap();
2424        let selected = groups.keys().copied().collect::<BTreeSet<_>>();
2425        let rendered =
2426            serde_json::to_string(&assemble_messages(&None, &groups, &selected)).unwrap();
2427        // The second summary sees six original exchanges, including those
2428        // omitted by the first summary, rather than a summary plus three calls.
2429        assert!(rendered.contains("summary of 12 messages"));
2430        assert!(!rendered.contains("active-call-6"));
2431        assert!(rendered.contains("active-call-7"));
2432        assert!(!rendered.contains("summary of 6 messages"));
2433    }
2434
2435    #[tokio::test]
2436    async fn pressure_compaction_preserves_literal_tool_text_and_replays_without_reencoding() {
2437        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2438        let session_id = AgentSessionId::new("session-1");
2439        let run_id = RunId::new("current");
2440        let source = "fn trim_line(text: &str) -> &str {\r\n\t// 保留 `\\n` and \"quotes\"\r\n\ttext.trim_end_matches('\\n')\r\n}\r\n";
2441        let result = json!({
2442            "content": source,
2443            "path": "src/lines.rs",
2444            "range": { "start": 1, "end": 4 },
2445            "truncated": false,
2446        });
2447        append_input(&store, 1, "current", "Inspect line handling".into()).await;
2448        append_session_payload(
2449            &store,
2450            &session_id,
2451            &run_id,
2452            "source-observation".into(),
2453            AgentSessionEvent::ToolExchangeCommitted {
2454                request_id: ModelRequestId::new("source-request"),
2455                assistant: ModelMessage {
2456                    role: ModelRole::Assistant,
2457                    content: vec![ModelContent::ToolCall {
2458                        call_id: ModelToolCallId::new("source-call"),
2459                        name: "inspect".into(),
2460                        arguments: json!({"path": "src/lines.rs"}),
2461                        extensions: Default::default(),
2462                    }],
2463                },
2464                tool: ModelMessage {
2465                    role: ModelRole::Tool,
2466                    content: vec![ModelContent::ToolResult {
2467                        call_id: ModelToolCallId::new("source-call"),
2468                        result: result.clone(),
2469                        is_error: false,
2470                    }],
2471                },
2472                retained_artifacts: Vec::new(),
2473                usage: None,
2474            },
2475        )
2476        .await;
2477        append_tool_exchange(&store, 2, "current", 10).await;
2478        let originals = store.load_session(&session_id).await.unwrap();
2479        let compactor = AgentSessionCompactor::new(
2480            store.clone(),
2481            Arc::new(DeterministicExtractiveSessionSummarizer::new(4096).unwrap()),
2482            SessionCompactionPolicy {
2483                minimum_source_records: 2,
2484                keep_recent_records: 1,
2485            },
2486        )
2487        .unwrap();
2488        compactor
2489            .compact_active_run_for_pressure(&session_id, &run_id)
2490            .await
2491            .unwrap()
2492            .unwrap();
2493        let records = store.load_session(&session_id).await.unwrap();
2494        assert_eq!(&records[..originals.len()], originals.as_slice());
2495        let AgentSessionEvent::ToolExchangeCommitted { tool, .. } = &records[1].payload else {
2496            panic!("original tool exchange");
2497        };
2498        assert!(
2499            matches!(&tool.content[0], ModelContent::ToolResult { result: saved, .. } if saved == &result)
2500        );
2501
2502        let engine = AgentSessionContextEngine::new(
2503            store.clone(),
2504            Arc::new(JsonSizeTokenMeter::new(1).unwrap()),
2505        );
2506        let request = || SessionContextRequest {
2507            session_id: session_id.clone(),
2508            current_run_id: run_id.clone(),
2509            through_session_seq: None,
2510            system_message: None,
2511            tools: Vec::new(),
2512            history_limit: 100,
2513            max_context_tokens: 20_000,
2514            reserved_output_tokens: 64,
2515            config_digest: Digest::sha256("literal-summary"),
2516            allowed_skill_digests: BTreeMap::new(),
2517        };
2518        let projected = engine.project(request()).await.unwrap();
2519        let summary = projected
2520            .messages
2521            .iter()
2522            .filter(|message| {
2523                message.role == ModelRole::Assistant
2524                    && message
2525                        .content
2526                        .iter()
2527                        .all(|content| matches!(content, ModelContent::Text { .. }))
2528            })
2529            .flat_map(|message| &message.content)
2530            .find_map(|content| match content {
2531                ModelContent::Text { text } => Some(text),
2532                _ => None,
2533            })
2534            .expect("projected literal summary");
2535        assert!(summary.contains(source));
2536        assert!(!summary.contains(&serde_json::to_string(source).unwrap()));
2537        assert!(summary.contains("\"truncated\":false"));
2538        assert!(summary.contains("\"range\":{\"end\":4,\"start\":1}"));
2539        let replayed = engine
2540            .project(SessionContextRequest {
2541                through_session_seq: Some(projected.through_session_seq),
2542                ..request()
2543            })
2544            .await
2545            .unwrap();
2546        assert_eq!(replayed.messages, projected.messages);
2547        assert_eq!(store.load_session(&session_id).await.unwrap(), records);
2548        assert!(matches!(
2549            engine
2550                .project(SessionContextRequest {
2551                    max_context_tokens: projected.used_input_tokens + 64 - 1,
2552                    ..request()
2553                })
2554                .await,
2555            Err(SessionContextError::ContextOverflow { .. })
2556        ));
2557    }
2558
2559    #[tokio::test]
2560    async fn extractive_literal_text_excerpts_mark_omitted_regions_and_keep_failure_metadata() {
2561        let head = "let start = \"\\n\";\n";
2562        let tail = "let end = '\\n';\n";
2563        let source = format!("{head}{}{tail}", "intermediate source line\n".repeat(300));
2564        let summarizer = DeterministicExtractiveSessionSummarizer::new(2048).unwrap();
2565        let input = |reverse: bool| {
2566            let mut fields = vec![
2567                ("content".into(), json!(source)),
2568                ("exit_code".into(), json!(2)),
2569                ("kind".into(), json!("invalid_text")),
2570                ("message".into(), json!("expected one '\\n' boundary")),
2571                ("path".into(), json!("src/lines.rs")),
2572                ("truncated".into(), json!(true)),
2573            ];
2574            if reverse {
2575                fields.reverse();
2576            }
2577            SessionCompactionInput {
2578                session_id: AgentSessionId::new("literal-excerpts"),
2579                source: single_range(1),
2580                focus_messages: Vec::new(),
2581                groups: vec![SessionCompactionGroup {
2582                    source: single_range(1),
2583                    messages: vec![ModelMessage {
2584                        role: ModelRole::Tool,
2585                        content: vec![ModelContent::ToolResult {
2586                            call_id: ModelToolCallId::new("source-error"),
2587                            result: serde_json::Value::Object(fields.into_iter().collect()),
2588                            is_error: true,
2589                        }],
2590                    }],
2591                }],
2592            }
2593        };
2594        let summary = summarizer.summarize(input(false)).await.unwrap();
2595        assert_eq!(summary, summarizer.summarize(input(true)).await.unwrap());
2596        let ModelContent::Text { text } = &summary.content[0] else {
2597            panic!("text summary");
2598        };
2599        let head_at = text.find(head).expect("literal source head");
2600        let tail_at = text[head_at..].find(tail).unwrap() + head_at;
2601        assert!(text[head_at..tail_at].contains("\n[… excerpt omitted …]\n"));
2602        assert!(!text.contains(&format!("{head}{tail}")));
2603        assert!(!text.contains(&source));
2604        assert!(text.contains("not complete replacement text"));
2605        assert!(text.contains("status=failed"));
2606        assert!(text.contains("invalid_text"));
2607        assert!(text.contains("expected one '\\n' boundary"));
2608        assert!(text.contains("src/lines.rs"));
2609        assert!(text.contains("\"exit_code\":2"));
2610        assert!(text.contains("\"truncated\":true"));
2611        assert!(text.chars().count() <= 2048);
2612    }
2613
2614    #[tokio::test]
2615    async fn compaction_omits_continuation_but_preserves_recent_and_durable_exchanges() {
2616        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2617        let session_id = AgentSessionId::new("session-1");
2618        let run_id = RunId::new("current");
2619        append_input(&store, 1, "current", "Inspect recorded observations".into()).await;
2620        for seq in 1..=5 {
2621            let call_id = ModelToolCallId::new(format!("inspect-{seq}"));
2622            append_session_payload(
2623                &store,
2624                &session_id,
2625                &run_id,
2626                format!("exchange-{seq}"),
2627                AgentSessionEvent::ToolExchangeCommitted {
2628                    request_id: ModelRequestId::new(format!("request-{seq}")),
2629                    assistant: ModelMessage {
2630                        role: ModelRole::Assistant,
2631                        content: vec![
2632                            ModelContent::Continuation {
2633                                namespace: "fixture/continuation".into(),
2634                                value: json!({"opaque": format!("private-step-{seq}")}),
2635                            },
2636                            ModelContent::Data {
2637                                media_type: "application/json".into(),
2638                                value: json!({"observation": "public-data"}),
2639                            },
2640                            ModelContent::ToolCall {
2641                                call_id: call_id.clone(),
2642                                name: "inspect".into(),
2643                                arguments: json!({"entry": seq}),
2644                                extensions: Default::default(),
2645                            },
2646                        ],
2647                    },
2648                    tool: ModelMessage {
2649                        role: ModelRole::Tool,
2650                        content: vec![ModelContent::ToolResult {
2651                            call_id,
2652                            result: json!({"observed": seq}),
2653                            is_error: false,
2654                        }],
2655                    },
2656                    retained_artifacts: Vec::new(),
2657                    usage: None,
2658                },
2659            )
2660            .await;
2661        }
2662        let originals = store.load_session(&session_id).await.unwrap();
2663        let engine = AgentSessionContextEngine::new(
2664            store.clone(),
2665            Arc::new(JsonSizeTokenMeter::new(1).unwrap()),
2666        );
2667        let request = || SessionContextRequest {
2668            session_id: session_id.clone(),
2669            current_run_id: run_id.clone(),
2670            through_session_seq: None,
2671            system_message: None,
2672            tools: Vec::new(),
2673            history_limit: 100,
2674            max_context_tokens: 20_000,
2675            reserved_output_tokens: 100,
2676            config_digest: Digest::sha256("continuation-compaction"),
2677            allowed_skill_digests: BTreeMap::new(),
2678        };
2679        let before = engine.project(request()).await.unwrap();
2680        let compactor = AgentSessionCompactor::new(
2681            store.clone(),
2682            Arc::new(DeterministicExtractiveSessionSummarizer::new(4096).unwrap()),
2683            SessionCompactionPolicy {
2684                minimum_source_records: 3,
2685                keep_recent_records: 2,
2686            },
2687        )
2688        .unwrap();
2689        compactor
2690            .compact_active_run_for_pressure(&session_id, &run_id)
2691            .await
2692            .unwrap()
2693            .expect("older exchanges compact");
2694        let after = engine.project(request()).await.unwrap();
2695        let summaries = after
2696            .messages
2697            .iter()
2698            .filter(|message| {
2699                message.role == ModelRole::Assistant
2700                    && message
2701                        .content
2702                        .iter()
2703                        .all(|content| matches!(content, ModelContent::Text { .. }))
2704            })
2705            .collect::<Vec<_>>();
2706        assert!(!summaries.is_empty());
2707        let summary = serde_json::to_string(&summaries).unwrap();
2708        assert!(!summary.contains("private-step-"));
2709        assert!(!summary.contains("fixture/continuation"));
2710        assert!(
2711            summary.contains("public-data"),
2712            "ordinary Data remains visible"
2713        );
2714        let continuations = |messages: &[ModelMessage]| {
2715            messages
2716                .iter()
2717                .flat_map(|message| &message.content)
2718                .filter_map(|content| match content {
2719                    ModelContent::Continuation { value, .. } => Some(value.clone()),
2720                    _ => None,
2721                })
2722                .collect::<Vec<_>>()
2723        };
2724        let original_continuations = continuations(&before.messages);
2725        assert_eq!(original_continuations.len(), 5);
2726        assert_eq!(continuations(&after.messages), original_continuations[3..]);
2727        let records = store.load_session(&session_id).await.unwrap();
2728        assert_eq!(records[..originals.len()], originals);
2729        let replay = engine
2730            .project(SessionContextRequest {
2731                through_session_seq: Some(originals.len() as u64),
2732                ..request()
2733            })
2734            .await
2735            .unwrap();
2736        assert_eq!(replay.messages, before.messages);
2737    }
2738
2739    #[tokio::test]
2740    async fn extractive_summary_is_bounded_deterministic_and_retains_referenced_facts() {
2741        const FACTS: usize = 200;
2742        const QUERIES: usize = 100;
2743        const REFERENCED_PER_QUERY: usize = 5;
2744        const MAX_SUMMARY_CHARS: usize = 2_048;
2745
2746        assert!(DeterministicExtractiveSessionSummarizer::new(255).is_err());
2747        let summarizer = DeterministicExtractiveSessionSummarizer::new(MAX_SUMMARY_CHARS).unwrap();
2748        let descriptor = summarizer.descriptor();
2749        descriptor.validate().unwrap();
2750        assert_eq!(descriptor, summarizer.descriptor());
2751        let groups = (0..FACTS)
2752            .map(|index| SessionCompactionGroup {
2753                source: single_range(index as u64 + 1),
2754                messages: vec![ModelMessage::text(
2755                    ModelRole::User,
2756                    format!(
2757                        "fact_{index:04}=value_{:08x}; ordinary durable conversation fact",
2758                        index.wrapping_mul(2_654_435_761)
2759                    ),
2760                )],
2761            })
2762            .collect::<Vec<_>>();
2763        let source = SessionSourceRange {
2764            first_session_seq: 1,
2765            last_session_seq: FACTS as u64,
2766        };
2767        let mut true_positives = 0usize;
2768        let mut false_positives = 0usize;
2769        let mut false_negatives = 0usize;
2770
2771        for query_index in 0..QUERIES {
2772            let expected = (0..REFERENCED_PER_QUERY)
2773                .map(|offset| (query_index * 17 + offset * 37) % FACTS)
2774                .collect::<BTreeSet<_>>();
2775            let focus = expected
2776                .iter()
2777                .map(|index| format!("fact_{index:04}"))
2778                .collect::<Vec<_>>()
2779                .join(", ");
2780            let summarize = || SessionCompactionInput {
2781                session_id: AgentSessionId::new(format!("summary-session-{query_index}")),
2782                source: source.clone(),
2783                groups: groups.clone(),
2784                focus_messages: vec![ModelMessage::text(
2785                    ModelRole::User,
2786                    format!("Use these earlier facts to answer: {focus}"),
2787                )],
2788            };
2789            let first = summarizer.summarize(summarize()).await.unwrap();
2790            let second = summarizer.summarize(summarize()).await.unwrap();
2791            assert_eq!(first, second);
2792            assert_eq!(first.role, ModelRole::Assistant);
2793            let ModelContent::Text { text } = &first.content[0] else {
2794                panic!("extractive summary must be one text block");
2795            };
2796            assert!(text.starts_with("UNTRUSTED earlier transcript"));
2797            assert!(text.chars().count() <= MAX_SUMMARY_CHARS);
2798            for fact_index in 0..FACTS {
2799                let retained = text.contains(&format!("fact_{fact_index:04}="));
2800                match (expected.contains(&fact_index), retained) {
2801                    (true, true) => true_positives += 1,
2802                    (false, true) => false_positives += 1,
2803                    (true, false) => false_negatives += 1,
2804                    (false, false) => {}
2805                }
2806            }
2807        }
2808
2809        let f1 = (2 * true_positives) as f64
2810            / (2 * true_positives + false_positives + false_negatives) as f64;
2811        assert!(f1 >= 0.98, "ordinary fact retention F1 was {f1:.4}");
2812        assert_eq!(false_positives, 0);
2813        assert_eq!(false_negatives, 0);
2814
2815        // Negative controls ensure this gate cannot be passed by an empty or
2816        // safety-only summary that retains none of the ordinary facts.
2817        for negative in ["", "system policy and security constraints retained"] {
2818            let retained = (0..REFERENCED_PER_QUERY)
2819                .filter(|index| negative.contains(&format!("fact_{index:04}=")))
2820                .count();
2821            let negative_f1 = if retained == 0 {
2822                0.0
2823            } else {
2824                2.0 * retained as f64 / (REFERENCED_PER_QUERY + retained) as f64
2825            };
2826            assert!(negative_f1 < 0.98);
2827        }
2828    }
2829
2830    #[tokio::test]
2831    async fn followup_prioritizes_the_latest_original_user_correction_after_compaction() {
2832        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
2833        append_input(&store, 1, "old", "Keep public names unchanged".into()).await;
2834        append_input(
2835            &store,
2836            2,
2837            "old",
2838            "Correction: preserve the existing output format too".into(),
2839        )
2840        .await;
2841        append_tool_exchange(&store, 1, "old", 100).await;
2842        append_input(&store, 3, "current", "Continue verification".into()).await;
2843        let compactor = AgentSessionCompactor::new(
2844            store.clone(),
2845            Arc::new(FixedSummarizer),
2846            SessionCompactionPolicy {
2847                minimum_source_records: 2,
2848                keep_recent_records: 1,
2849            },
2850        )
2851        .unwrap();
2852        compactor
2853            .compact_if_needed(&AgentSessionId::new("session-1"), &RunId::new("current"))
2854            .await
2855            .unwrap()
2856            .unwrap();
2857        let engine =
2858            AgentSessionContextEngine::new(store.clone(), Arc::new(JsonSizeTokenMeter::default()));
2859        let request = || SessionContextRequest {
2860            session_id: AgentSessionId::new("session-1"),
2861            current_run_id: RunId::new("current"),
2862            through_session_seq: None,
2863            system_message: None,
2864            tools: Vec::new(),
2865            history_limit: 1,
2866            max_context_tokens: 1024,
2867            reserved_output_tokens: 64,
2868            config_digest: Digest::sha256("anchor-test"),
2869            allowed_skill_digests: BTreeMap::new(),
2870        };
2871        let projected = engine.project(request()).await.unwrap();
2872        let text = serde_json::to_string(&projected.messages).unwrap();
2873        assert!(text.contains("Correction: preserve the existing output format too"));
2874        assert!(text.contains("Continue verification"));
2875        assert_eq!(
2876            projected
2877                .messages
2878                .iter()
2879                .filter(|m| m.role == ModelRole::User)
2880                .count(),
2881            2
2882        );
2883        assert!(projected
2884            .deferred_ranges
2885            .iter()
2886            .all(|range| !range.contains(2)));
2887        let replayed = engine
2888            .project(SessionContextRequest {
2889                through_session_seq: Some(projected.through_session_seq),
2890                ..request()
2891            })
2892            .await
2893            .unwrap();
2894        assert_eq!(replayed.messages, projected.messages);
2895    }
2896
2897    #[tokio::test]
2898    async fn bounded_summary_keeps_typed_failure_before_large_call_arguments() {
2899        let call_id = ModelToolCallId::new("verification-call");
2900        let summarizer = DeterministicExtractiveSessionSummarizer::new(640).unwrap();
2901        let summary = summarizer
2902            .summarize(SessionCompactionInput {
2903                session_id: AgentSessionId::new("s"),
2904                source: single_range(1),
2905                focus_messages: Vec::new(),
2906                groups: vec![SessionCompactionGroup {
2907                    source: single_range(1),
2908                    messages: vec![
2909                        ModelMessage {
2910                            role: ModelRole::Assistant,
2911                            content: vec![ModelContent::ToolCall {
2912                                call_id: call_id.clone(),
2913                                name: "check".into(),
2914                                arguments: json!({"data": "x".repeat(4000)}),
2915                                extensions: Default::default(),
2916                            }],
2917                        },
2918                        ModelMessage {
2919                            role: ModelRole::Tool,
2920                            content: vec![ModelContent::ToolResult {
2921                                call_id,
2922                                result: json!({"reason": "output contract mismatch"}),
2923                                is_error: true,
2924                            }],
2925                        },
2926                    ],
2927                }],
2928            })
2929            .await
2930            .unwrap();
2931        let ModelContent::Text { text } = &summary.content[0] else {
2932            panic!("text summary");
2933        };
2934        assert!(text.contains("status=failed"));
2935        assert!(text.contains("output contract mismatch"));
2936        assert!(text.contains("session_seq=1..1"));
2937        assert!(text.chars().count() <= 640);
2938    }
2939
2940    #[tokio::test]
2941    async fn extractive_summary_retains_large_completed_check_alongside_inspection() {
2942        let exchange = |seq, command: &str, result| SessionCompactionGroup {
2943            source: single_range(seq),
2944            messages: vec![
2945                ModelMessage {
2946                    role: ModelRole::Assistant,
2947                    content: vec![ModelContent::ToolCall {
2948                        call_id: ModelToolCallId::new(format!("call-{seq}")),
2949                        name: "run_process".into(),
2950                        arguments: json!({"command": command}),
2951                        extensions: Default::default(),
2952                    }],
2953                },
2954                ModelMessage {
2955                    role: ModelRole::Tool,
2956                    content: vec![ModelContent::ToolResult {
2957                        call_id: ModelToolCallId::new(format!("call-{seq}")),
2958                        result,
2959                        is_error: false,
2960                    }],
2961                },
2962            ],
2963        };
2964        let summarizer = DeterministicExtractiveSessionSummarizer::new(4096).unwrap();
2965        let input = || SessionCompactionInput {
2966            session_id: AgentSessionId::new("large-check"),
2967            source: SessionSourceRange {
2968                first_session_seq: 1,
2969                last_session_seq: 3,
2970            },
2971            focus_messages: vec![ModelMessage::text(
2972                ModelRole::User,
2973                "Inspect the parser, run its checks and deliver the requested output",
2974            )],
2975            groups: vec![
2976                exchange(1, "inspect parser", json!({"source": "parser code"})),
2977                exchange(
2978                    2,
2979                    "run checks",
2980                    json!({
2981                        "output": format!("{}\nCHECK COMPLETE: 243 passed", "checking parser\n".repeat(6000)),
2982                        "exit_code": 0,
2983                        "alive": false,
2984                    }),
2985                ),
2986                exchange(
2987                    3,
2988                    "inspect parser after checks",
2989                    json!({
2990                        "source": "parser implementation\n".repeat(6000),
2991                    }),
2992                ),
2993            ],
2994        };
2995        let summary = summarizer.summarize(input()).await.unwrap();
2996        assert_eq!(summary, summarizer.summarize(input()).await.unwrap());
2997        let ModelContent::Text { text } = &summary.content[0] else {
2998            panic!("text summary");
2999        };
3000        assert!(text.contains("run checks"));
3001        assert!(text.contains("\"exit_code\":0"));
3002        assert!(text.contains("CHECK COMPLETE: 243 passed"));
3003        assert!(text.contains("session_seq=2..2"));
3004        assert!(text.chars().count() <= 4096);
3005    }
3006
3007    #[tokio::test]
3008    async fn extractive_summary_keeps_current_process_state_among_relevant_old_failures() {
3009        let summarizer = DeterministicExtractiveSessionSummarizer::new(2048).unwrap();
3010        let mut groups = (1..=8)
3011            .map(|seq| SessionCompactionGroup {
3012                source: single_range(seq),
3013                messages: vec![ModelMessage {
3014                    role: ModelRole::Tool,
3015                    content: vec![ModelContent::ToolResult {
3016                        call_id: ModelToolCallId::new(format!("old-{seq}")),
3017                        result: json!({"reason": "earlier packaging failure".repeat(100)}),
3018                        is_error: true,
3019                    }],
3020                }],
3021            })
3022            .collect::<Vec<_>>();
3023        groups.push(SessionCompactionGroup {
3024            source: single_range(9),
3025            messages: vec![ModelMessage {
3026                role: ModelRole::Tool,
3027                content: vec![ModelContent::ToolResult {
3028                    call_id: ModelToolCallId::new("current-process"),
3029                    result: json!({"alive": true, "session_id": 71, "output": "进展\n".repeat(4000)}),
3030                    is_error: false,
3031                }],
3032            }],
3033        });
3034        let summary = summarizer
3035            .summarize(SessionCompactionInput {
3036                session_id: AgentSessionId::new("process-state"),
3037                source: SessionSourceRange {
3038                    first_session_seq: 1,
3039                    last_session_seq: 9,
3040                },
3041                groups,
3042                focus_messages: vec![ModelMessage::text(ModelRole::User, "Finish packaging")],
3043            })
3044            .await
3045            .unwrap();
3046        let ModelContent::Text { text } = &summary.content[0] else {
3047            panic!("text summary");
3048        };
3049        assert!(text.contains("session_seq=9..9"));
3050        assert!(text.contains("\"alive\":true"));
3051        assert!(text.contains("\"session_id\":71"));
3052        assert!(text.contains("status=failed"));
3053        assert!(text.contains("Tool success does not prove task verification"));
3054        assert!(text.chars().count() <= 2048);
3055    }
3056
3057    #[tokio::test]
3058    async fn recent_observations_survive_repeated_inspection_without_shared_focus_words() {
3059        let summarizer = DeterministicExtractiveSessionSummarizer::new(4096).unwrap();
3060        let mut groups = Vec::new();
3061        for seq in 1..=30 {
3062            let is_check = seq == 2;
3063            let (name, arguments, result) = if is_check {
3064                (
3065                    "execute",
3066                    json!({"command": "validate-release"}),
3067                    json!({
3068                        "exit_code": 0,
3069                        "output": format!("{}\nVALIDATION COMPLETE", "progress\n".repeat(3000)),
3070                    }),
3071                )
3072            } else {
3073                (
3074                    "inspect",
3075                    json!({"path": "parser"}),
3076                    json!({
3077                        "source": "parser implementation\n".repeat(3000),
3078                    }),
3079                )
3080            };
3081            let call_id = ModelToolCallId::new(format!("call-{seq}"));
3082            groups.push(SessionCompactionGroup {
3083                source: single_range(seq),
3084                messages: vec![
3085                    ModelMessage {
3086                        role: ModelRole::Assistant,
3087                        content: vec![ModelContent::ToolCall {
3088                            call_id: call_id.clone(),
3089                            name: name.into(),
3090                            arguments,
3091                            extensions: Default::default(),
3092                        }],
3093                    },
3094                    ModelMessage {
3095                        role: ModelRole::Tool,
3096                        content: vec![ModelContent::ToolResult {
3097                            call_id,
3098                            result,
3099                            is_error: false,
3100                        }],
3101                    },
3102                ],
3103            });
3104        }
3105        let summary = summarizer
3106            .summarize(SessionCompactionInput {
3107                session_id: AgentSessionId::new("repeated-inspection"),
3108                source: SessionSourceRange {
3109                    first_session_seq: 1,
3110                    last_session_seq: 30,
3111                },
3112                groups,
3113                focus_messages: vec![ModelMessage::text(ModelRole::User, "Inspect parser")],
3114            })
3115            .await
3116            .unwrap();
3117        let ModelContent::Text { text } = &summary.content[0] else {
3118            panic!("text summary");
3119        };
3120        assert!(text.contains("session_seq=2..2"));
3121        assert!(text.contains("validate-release"));
3122        assert!(text.contains("\"exit_code\":0"));
3123        assert!(text.contains("VALIDATION COMPLETE"));
3124        assert!(text.contains("session_seq=30..30"));
3125        assert!(text.chars().count() <= 4096);
3126    }
3127
3128    #[test]
3129    fn ten_thousand_persisted_session_traces_replay_to_online_message_projection() {
3130        const TRACES: usize = 10_000;
3131
3132        let policy_digest = SessionCompactionPolicy {
3133            minimum_source_records: 3,
3134            keep_recent_records: 2,
3135        }
3136        .digest()
3137        .unwrap();
3138        let summary_config_digest = Digest::sha256("session-replay-summary/v1");
3139        let mut compacted_traces = 0usize;
3140        let mut current_tool_traces = 0usize;
3141
3142        for case in 0..TRACES {
3143            let session_id = AgentSessionId::new(format!("replay-session-{case}"));
3144            let old_run_id = RunId::new(format!("replay-old-{case}"));
3145            let current_run_id = RunId::new(format!("replay-current-{case}"));
3146            let old_input = ModelMessage::text(ModelRole::User, format!("old-input-{case}"));
3147            let old_call_id = ModelToolCallId::new(format!("old-call-{case}"));
3148            let old_assistant = ModelMessage {
3149                role: ModelRole::Assistant,
3150                content: vec![ModelContent::ToolCall {
3151                    call_id: old_call_id.clone(),
3152                    name: "inspect".to_owned(),
3153                    arguments: json!({ "case": case }),
3154                    extensions: Default::default(),
3155                }],
3156            };
3157            let old_tool = ModelMessage {
3158                role: ModelRole::Tool,
3159                content: vec![ModelContent::ToolResult {
3160                    call_id: old_call_id,
3161                    result: json!({ "observed": case * 2 }),
3162                    is_error: false,
3163                }],
3164            };
3165            let old_output = ModelMessage::text(ModelRole::Assistant, format!("old-output-{case}"));
3166            let old_tail = ModelMessage::text(ModelRole::User, format!("old-tail-{case}"));
3167            let current_input =
3168                ModelMessage::text(ModelRole::User, format!("current-input-{case}"));
3169            let mut records = Vec::with_capacity(9);
3170            push_session_record(
3171                &mut records,
3172                &session_id,
3173                &old_run_id,
3174                case,
3175                AgentSessionEvent::RunInputCommitted {
3176                    message: old_input.clone(),
3177                },
3178            );
3179            push_session_record(
3180                &mut records,
3181                &session_id,
3182                &old_run_id,
3183                case,
3184                AgentSessionEvent::ToolExchangeCommitted {
3185                    request_id: ModelRequestId::new(format!("old-request-{case}")),
3186                    assistant: old_assistant.clone(),
3187                    tool: old_tool.clone(),
3188                    retained_artifacts: Vec::new(),
3189                    usage: None,
3190                },
3191            );
3192            push_session_record(
3193                &mut records,
3194                &session_id,
3195                &old_run_id,
3196                case,
3197                AgentSessionEvent::RunOutputCommitted {
3198                    request_id: ModelRequestId::new(format!("old-output-request-{case}")),
3199                    message: old_output.clone(),
3200                    usage: None,
3201                },
3202            );
3203            push_session_record(
3204                &mut records,
3205                &session_id,
3206                &old_run_id,
3207                case,
3208                AgentSessionEvent::RunInputCommitted {
3209                    message: old_tail.clone(),
3210                },
3211            );
3212            push_session_record(
3213                &mut records,
3214                &session_id,
3215                &current_run_id,
3216                case,
3217                AgentSessionEvent::RunInputCommitted {
3218                    message: current_input.clone(),
3219                },
3220            );
3221
3222            let current_exchange = (case % 2 == 0).then(|| {
3223                let call_id = ModelToolCallId::new(format!("current-call-{case}"));
3224                let retained_artifacts = if case % 4 == 0 {
3225                    vec![ArtifactRefWithDigest {
3226                        artifact_ref: ArtifactRef::new(format!("replay-artifact-{case}")),
3227                        digest: Digest::sha256(format!("replay-artifact-bytes-{case}")),
3228                    }]
3229                } else {
3230                    Vec::new()
3231                };
3232                let result = retained_artifacts.first().map_or_else(
3233                    || json!({ "current_result": case + 1 }),
3234                    |artifact| json!({"kind": "artifact", "artifact": artifact}),
3235                );
3236                (
3237                    ModelMessage {
3238                        role: ModelRole::Assistant,
3239                        content: vec![ModelContent::ToolCall {
3240                            call_id: call_id.clone(),
3241                            name: "lookup".to_owned(),
3242                            arguments: json!({ "current": case }),
3243                            extensions: Default::default(),
3244                        }],
3245                    },
3246                    ModelMessage {
3247                        role: ModelRole::Tool,
3248                        content: vec![ModelContent::ToolResult {
3249                            call_id,
3250                            result,
3251                            is_error: false,
3252                        }],
3253                    },
3254                    retained_artifacts,
3255                )
3256            });
3257            if let Some((assistant, tool, retained_artifacts)) = &current_exchange {
3258                push_session_record(
3259                    &mut records,
3260                    &session_id,
3261                    &current_run_id,
3262                    case,
3263                    AgentSessionEvent::ToolExchangeCommitted {
3264                        request_id: ModelRequestId::new(format!("current-request-{case}")),
3265                        assistant: assistant.clone(),
3266                        tool: tool.clone(),
3267                        retained_artifacts: retained_artifacts.clone(),
3268                        usage: None,
3269                    },
3270                );
3271                current_tool_traces += 1;
3272            }
3273
3274            let compacted = case % 3 != 0;
3275            let summary =
3276                ModelMessage::text(ModelRole::System, format!("durable-summary-for-{case}"));
3277            if compacted {
3278                let source = SessionSourceRange {
3279                    first_session_seq: 1,
3280                    last_session_seq: 3,
3281                };
3282                let source_digest = session_range_digest(&records, &source).unwrap();
3283                push_session_record(
3284                    &mut records,
3285                    &session_id,
3286                    &current_run_id,
3287                    case,
3288                    AgentSessionEvent::CompactionCommitted {
3289                        source,
3290                        source_digest,
3291                        policy_digest: policy_digest.clone(),
3292                        summary_config_digest: summary_config_digest.clone(),
3293                        summary: summary.clone(),
3294                        strategy: "session-replay-summary".to_owned(),
3295                        model: None,
3296                        version: "1".to_owned(),
3297                    },
3298                );
3299                compacted_traces += 1;
3300            }
3301            let uncertainty = (case % 5 == 0).then(|| {
3302                let effect_call_id = ToolCallId::new(format!("replay-effect-{case}"));
3303                let model_call_id = ModelToolCallId::new(format!("replay-model-call-{case}"));
3304                let tool_name = "replay-effect-tool".to_owned();
3305                let message = "effect acknowledgement missing".to_owned();
3306                push_session_record(
3307                    &mut records,
3308                    &session_id,
3309                    &old_run_id,
3310                    case,
3311                    AgentSessionEvent::EffectUncertaintyCommitted {
3312                        effect_call_id: effect_call_id.clone(),
3313                        model_call_id: model_call_id.clone(),
3314                        tool_name: tool_name.clone(),
3315                        message: message.clone(),
3316                    },
3317                );
3318                effect_uncertainty_message(&effect_call_id, &model_call_id, &tool_name, &message)
3319            });
3320            validate_session_trace(&session_id, &records).unwrap();
3321
3322            let persisted_bytes = serde_json::to_vec(&records).unwrap();
3323            let persisted: Vec<AgentSessionRecord> =
3324                serde_json::from_slice(&persisted_bytes).unwrap();
3325            validate_session_trace(&session_id, &persisted).unwrap();
3326            let groups = replay_groups(&persisted, &current_run_id, &BTreeMap::new()).unwrap();
3327            let selected = groups.keys().copied().collect::<BTreeSet<_>>();
3328            let replayed = assemble_messages(&None, &groups, &selected);
3329
3330            let mut online = if compacted {
3331                vec![summary, old_tail, current_input]
3332            } else {
3333                vec![
3334                    old_input,
3335                    old_assistant,
3336                    old_tool,
3337                    old_output,
3338                    old_tail,
3339                    current_input,
3340                ]
3341            };
3342            if let Some((assistant, tool, _)) = current_exchange {
3343                online.push(assistant);
3344                online.push(tool);
3345            }
3346            if let Some(uncertainty) = uncertainty {
3347                online.insert(usize::from(compacted), uncertainty);
3348            }
3349            assert_eq!(replayed, online);
3350            let replayed_calls = replayed
3351                .iter()
3352                .flat_map(|message| message.content.iter())
3353                .filter(|content| matches!(content, ModelContent::ToolCall { .. }))
3354                .count();
3355            let replayed_results = replayed
3356                .iter()
3357                .flat_map(|message| message.content.iter())
3358                .filter(|content| matches!(content, ModelContent::ToolResult { .. }))
3359                .count();
3360            assert_eq!(replayed_calls, replayed_results);
3361        }
3362
3363        assert_eq!(compacted_traces, 6_666);
3364        assert_eq!(current_tool_traces, 5_000);
3365    }
3366
3367    #[test]
3368    fn ten_thousand_journal_policy_combinations_select_one_deterministic_traceable_source() {
3369        const HISTORIES: usize = 100;
3370        const POLICIES_PER_HISTORY: usize = 100;
3371
3372        let mut seed = 0xC04D_AC71_0A11_CE55u64;
3373        let mut selected = 0usize;
3374        let mut deferred = 0usize;
3375
3376        for history_index in 0..HISTORIES {
3377            seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3378            let record_count = 16 + (seed % 33) as usize;
3379            seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3380            let current_sequence = 1 + (seed % record_count as u64);
3381            let session_id = AgentSessionId::new(format!("compaction-session-{history_index}"));
3382            let old_run_id = RunId::new(format!("compaction-old-{history_index}"));
3383            let current_run_id = RunId::new(format!("compaction-current-{history_index}"));
3384            let records = (1..=record_count as u64)
3385                .map(|session_seq| {
3386                    AgentSessionRecord::seal(
3387                        AgentSessionEventDraft {
3388                            event_id: AgentSessionEventId::new(format!(
3389                                "compaction-event-{history_index}-{session_seq}"
3390                            )),
3391                            session_id: session_id.clone(),
3392                            run_id: if session_seq == current_sequence {
3393                                current_run_id.clone()
3394                            } else {
3395                                old_run_id.clone()
3396                            },
3397                            payload: AgentSessionEvent::RunInputCommitted {
3398                                message: ModelMessage::text(
3399                                    ModelRole::User,
3400                                    format!("history-{history_index}-{session_seq}"),
3401                                ),
3402                            },
3403                        },
3404                        session_seq,
3405                    )
3406                    .unwrap()
3407                })
3408                .collect::<Vec<_>>();
3409            validate_session_trace(&session_id, &records).unwrap();
3410
3411            for _ in 0..POLICIES_PER_HISTORY {
3412                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3413                let minimum_source_records = 1 + (seed % 12) as usize;
3414                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3415                let keep_recent_records = 1 + (seed % 12) as usize;
3416                let policy = SessionCompactionPolicy {
3417                    minimum_source_records,
3418                    keep_recent_records,
3419                };
3420                let policy_digest = policy.digest().unwrap();
3421                assert_eq!(policy.digest().unwrap(), policy_digest);
3422                assert_ne!(
3423                    SessionCompactionPolicy {
3424                        minimum_source_records,
3425                        keep_recent_records: keep_recent_records + 1,
3426                    }
3427                    .digest()
3428                    .unwrap(),
3429                    policy_digest
3430                );
3431
3432                let first = select_compaction_source(&records, &current_run_id, &policy);
3433                let second = select_compaction_source(&records, &current_run_id, &policy);
3434                assert_eq!(first, second);
3435
3436                let source_len = record_count.saturating_sub(keep_recent_records);
3437                let expected = if record_count < minimum_source_records + keep_recent_records {
3438                    None
3439                } else if current_sequence as usize > source_len {
3440                    Some(SessionSourceRange {
3441                        first_session_seq: 1,
3442                        last_session_seq: source_len as u64,
3443                    })
3444                } else if current_sequence.saturating_sub(1) >= minimum_source_records as u64 {
3445                    Some(SessionSourceRange {
3446                        first_session_seq: 1,
3447                        last_session_seq: current_sequence - 1,
3448                    })
3449                } else if source_len as u64 - current_sequence >= minimum_source_records as u64 {
3450                    Some(SessionSourceRange {
3451                        first_session_seq: current_sequence + 1,
3452                        last_session_seq: source_len as u64,
3453                    })
3454                } else {
3455                    None
3456                };
3457                assert_eq!(first, expected);
3458
3459                if let Some(source) = first {
3460                    source.validate().unwrap();
3461                    let source_records = &records
3462                        [(source.first_session_seq - 1) as usize..source.last_session_seq as usize];
3463                    assert!(source_records.len() >= minimum_source_records);
3464                    assert!(source_records
3465                        .iter()
3466                        .all(|record| record.run_id != current_run_id));
3467                    assert!(source.last_session_seq as usize <= record_count - keep_recent_records);
3468                    assert!(session_range_digest(&records, &source).unwrap().is_sha256());
3469                    selected += 1;
3470                } else {
3471                    deferred += 1;
3472                }
3473            }
3474        }
3475
3476        assert_eq!(selected + deferred, HISTORIES * POLICIES_PER_HISTORY);
3477        assert!(selected > 0);
3478        assert!(deferred > 0);
3479    }
3480
3481    #[test]
3482    fn compaction_advances_around_artifact_and_uncertain_effect_barriers() {
3483        let session_id = AgentSessionId::new("barrier-session");
3484        let old_run_id = RunId::new("barrier-old-run");
3485        let current_run_id = RunId::new("barrier-current-run");
3486        let artifact = ArtifactRefWithDigest {
3487            artifact_ref: ArtifactRef::new("barrier-artifact"),
3488            digest: Digest::sha256("barrier-artifact-bytes"),
3489        };
3490        let artifact_call = ModelToolCallId::new("barrier-artifact-call");
3491        let artifact_exchange = AgentSessionEvent::ToolExchangeCommitted {
3492            request_id: ModelRequestId::new("barrier-artifact-request"),
3493            assistant: ModelMessage {
3494                role: ModelRole::Assistant,
3495                content: vec![ModelContent::ToolCall {
3496                    call_id: artifact_call.clone(),
3497                    name: "artifact_source".to_owned(),
3498                    arguments: json!({}),
3499                    extensions: Default::default(),
3500                }],
3501            },
3502            tool: ModelMessage {
3503                role: ModelRole::Tool,
3504                content: vec![ModelContent::ToolResult {
3505                    call_id: artifact_call,
3506                    result: json!({"artifact": artifact.clone()}),
3507                    is_error: false,
3508                }],
3509            },
3510            retained_artifacts: vec![artifact],
3511            usage: None,
3512        };
3513        let mut records = Vec::new();
3514        for sequence in 1..=12_u64 {
3515            let payload = match sequence {
3516                4 => artifact_exchange.clone(),
3517                8 => AgentSessionEvent::EffectUncertaintyCommitted {
3518                    effect_call_id: ToolCallId::new("barrier-effect"),
3519                    model_call_id: ModelToolCallId::new("barrier-model-call"),
3520                    tool_name: "dangerous_tool".to_owned(),
3521                    message: "effect may have happened".to_owned(),
3522                },
3523                _ => AgentSessionEvent::RunInputCommitted {
3524                    message: ModelMessage::text(
3525                        ModelRole::User,
3526                        format!("barrier-history-{sequence}"),
3527                    ),
3528                },
3529            };
3530            push_session_record(&mut records, &session_id, &old_run_id, sequence, payload);
3531        }
3532        let policy = SessionCompactionPolicy {
3533            minimum_source_records: 3,
3534            keep_recent_records: 2,
3535        };
3536        let first = select_compaction_source(&records, &current_run_id, &policy).unwrap();
3537        assert_eq!(
3538            first,
3539            SessionSourceRange {
3540                first_session_seq: 1,
3541                last_session_seq: 3,
3542            }
3543        );
3544        let source_digest = session_range_digest(&records, &first).unwrap();
3545        push_session_record(
3546            &mut records,
3547            &session_id,
3548            &old_run_id,
3549            "first-compaction",
3550            AgentSessionEvent::CompactionCommitted {
3551                source: first,
3552                source_digest,
3553                policy_digest: policy.digest().unwrap(),
3554                summary_config_digest: Digest::sha256("barrier-summary-config"),
3555                summary: ModelMessage::text(ModelRole::System, "first barrier summary"),
3556                strategy: "barrier-test".to_owned(),
3557                model: None,
3558                version: "1".to_owned(),
3559            },
3560        );
3561        for sequence in 14..=18_u64 {
3562            push_session_record(
3563                &mut records,
3564                &session_id,
3565                &old_run_id,
3566                sequence,
3567                AgentSessionEvent::RunInputCommitted {
3568                    message: ModelMessage::text(
3569                        ModelRole::User,
3570                        format!("post-compaction-{sequence}"),
3571                    ),
3572                },
3573            );
3574        }
3575        let second = select_compaction_source(&records, &current_run_id, &policy).unwrap();
3576        assert_eq!(
3577            second,
3578            SessionSourceRange {
3579                first_session_seq: 5,
3580                last_session_seq: 7,
3581            }
3582        );
3583    }
3584
3585    #[tokio::test]
3586    async fn ten_thousand_generated_long_histories_retain_every_fittable_safety_and_task_anchor() {
3587        const HISTORIES: usize = 100;
3588        const CONFIGS_PER_HISTORY: usize = 100;
3589
3590        let store = Arc::new(InMemoryAgentSessionJournalStore::default());
3591        let meter = Arc::new(JsonSizeTokenMeter::new(1).unwrap());
3592        let engine = AgentSessionContextEngine::new(store.clone(), meter.clone());
3593        let system = ModelMessage::text(ModelRole::System, "stable system policy");
3594        let tools = vec![ModelToolDefinition {
3595            name: "inspect".to_owned(),
3596            description: "Inspect one generated value".to_owned(),
3597            input_schema: json!({
3598                "type": "object",
3599                "required": ["value"],
3600                "properties": { "value": { "type": "string" } },
3601                "additionalProperties": false
3602            }),
3603        }];
3604        let mut seed = 0xA6E7_5E55_D15C_A11Du64;
3605        let mut successful = 0usize;
3606        let mut overflowed = 0usize;
3607
3608        for history_index in 0..HISTORIES {
3609            let session_id = AgentSessionId::new(format!("budget-session-{history_index}"));
3610            let old_run_id = RunId::new(format!("budget-old-{history_index}"));
3611            let current_run_id = RunId::new(format!("budget-current-{history_index}"));
3612            for sequence in 1..=12u64 {
3613                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3614                let width = 12 + (seed % 96) as usize;
3615                let payload = if sequence % 3 == 0 {
3616                    let call_id =
3617                        ModelToolCallId::new(format!("budget-call-{history_index}-{sequence}"));
3618                    AgentSessionEvent::ToolExchangeCommitted {
3619                        request_id: ModelRequestId::new(format!(
3620                            "budget-request-{history_index}-{sequence}"
3621                        )),
3622                        assistant: ModelMessage {
3623                            role: ModelRole::Assistant,
3624                            content: vec![ModelContent::ToolCall {
3625                                call_id: call_id.clone(),
3626                                name: "inspect".to_owned(),
3627                                arguments: json!({ "value": "x".repeat(width) }),
3628                                extensions: Default::default(),
3629                            }],
3630                        },
3631                        tool: ModelMessage {
3632                            role: ModelRole::Tool,
3633                            content: vec![ModelContent::ToolResult {
3634                                call_id,
3635                                result: json!({ "observed": "y".repeat(width / 2 + 1) }),
3636                                is_error: false,
3637                            }],
3638                        },
3639                        retained_artifacts: Vec::new(),
3640                        usage: None,
3641                    }
3642                } else {
3643                    AgentSessionEvent::RunInputCommitted {
3644                        message: ModelMessage::text(
3645                            ModelRole::User,
3646                            format!("history-{history_index}-{sequence}-{}", "z".repeat(width)),
3647                        ),
3648                    }
3649                };
3650                append_session_payload(
3651                    &store,
3652                    &session_id,
3653                    &old_run_id,
3654                    format!("budget-event-{history_index}-{sequence}"),
3655                    payload,
3656                )
3657                .await;
3658            }
3659            let artifact = ArtifactRefWithDigest {
3660                artifact_ref: ArtifactRef::new(format!("artifact-{history_index}")),
3661                digest: Digest::sha256(format!("artifact-bytes-{history_index}")),
3662            };
3663            let artifact_call_id = ModelToolCallId::new(format!("artifact-call-{history_index}"));
3664            append_session_payload(
3665                &store,
3666                &session_id,
3667                &old_run_id,
3668                format!("budget-artifact-{history_index}"),
3669                AgentSessionEvent::ToolExchangeCommitted {
3670                    request_id: ModelRequestId::new(format!("artifact-request-{history_index}")),
3671                    assistant: ModelMessage {
3672                        role: ModelRole::Assistant,
3673                        content: vec![ModelContent::ToolCall {
3674                            call_id: artifact_call_id.clone(),
3675                            name: "artifact_source".to_owned(),
3676                            arguments: json!({}),
3677                            extensions: Default::default(),
3678                        }],
3679                    },
3680                    tool: ModelMessage {
3681                        role: ModelRole::Tool,
3682                        content: vec![ModelContent::ToolResult {
3683                            call_id: artifact_call_id,
3684                            result: json!({
3685                                "kind": "artifact",
3686                                "artifact": artifact.clone(),
3687                                "summary": "bounded generated Artifact"
3688                            }),
3689                            is_error: false,
3690                        }],
3691                    },
3692                    retained_artifacts: vec![artifact.clone()],
3693                    usage: None,
3694                },
3695            )
3696            .await;
3697            let uncertain_effect_call = format!("uncertain-effect-{history_index}");
3698            let uncertain_model_call = format!("uncertain-model-call-{history_index}");
3699            append_session_payload(
3700                &store,
3701                &session_id,
3702                &old_run_id,
3703                format!("budget-uncertain-effect-{history_index}"),
3704                AgentSessionEvent::EffectUncertaintyCommitted {
3705                    effect_call_id: ToolCallId::new(&uncertain_effect_call),
3706                    model_call_id: ModelToolCallId::new(&uncertain_model_call),
3707                    tool_name: "generated_effect".to_owned(),
3708                    message: "dispatch acknowledgement was lost".to_owned(),
3709                },
3710            )
3711            .await;
3712            append_session_payload(
3713                &store,
3714                &session_id,
3715                &current_run_id,
3716                format!("budget-current-event-{history_index}"),
3717                AgentSessionEvent::RunInputCommitted {
3718                    message: ModelMessage::text(
3719                        ModelRole::User,
3720                        format!("current-task-{history_index}"),
3721                    ),
3722                },
3723            )
3724            .await;
3725            for (kind, tool_name) in [
3726                ("pending-input", "orchestral_request_input"),
3727                ("pending-approval", "approval_guarded_tool"),
3728            ] {
3729                let call_id = ModelToolCallId::new(format!("{kind}-call-{history_index}"));
3730                append_session_payload(
3731                    &store,
3732                    &session_id,
3733                    &current_run_id,
3734                    format!("budget-{kind}-{history_index}"),
3735                    AgentSessionEvent::ToolExchangeCommitted {
3736                        request_id: ModelRequestId::new(format!("{kind}-request-{history_index}")),
3737                        assistant: ModelMessage {
3738                            role: ModelRole::Assistant,
3739                            content: vec![ModelContent::ToolCall {
3740                                call_id: call_id.clone(),
3741                                name: tool_name.to_owned(),
3742                                arguments: json!({"marker": kind}),
3743                                extensions: Default::default(),
3744                            }],
3745                        },
3746                        tool: ModelMessage {
3747                            role: ModelRole::Tool,
3748                            content: vec![ModelContent::ToolResult {
3749                                call_id,
3750                                result: json!({"status": "resolved", "marker": kind}),
3751                                is_error: false,
3752                            }],
3753                        },
3754                        retained_artifacts: Vec::new(),
3755                        usage: None,
3756                    },
3757                )
3758                .await;
3759            }
3760
3761            let records = store.load_session(&session_id).await.unwrap();
3762            let groups = replay_groups(&records, &current_run_id, &BTreeMap::new()).unwrap();
3763            let pinned = groups
3764                .values()
3765                .filter(|group| group.pinned)
3766                .map(|group| group.key)
3767                .collect::<BTreeSet<_>>();
3768            let pinned_tokens = meter
3769                .count_request_input(
3770                    &assemble_messages(&Some(system.clone()), &groups, &pinned),
3771                    &tools,
3772                )
3773                .unwrap();
3774            let old_keys = groups
3775                .values()
3776                .rev()
3777                .filter(|group| !group.pinned)
3778                .map(|group| group.key)
3779                .collect::<Vec<_>>();
3780            let mut recent = pinned.clone();
3781            let mut recent_prefixes = Vec::new();
3782            for key in &old_keys {
3783                recent.insert(*key);
3784                recent_prefixes.push((
3785                    recent.clone(),
3786                    meter
3787                        .count_request_input(
3788                            &assemble_messages(&Some(system.clone()), &groups, &recent),
3789                            &tools,
3790                        )
3791                        .unwrap(),
3792                ));
3793            }
3794
3795            for config_index in 0..CONFIGS_PER_HISTORY {
3796                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3797                let max_context_tokens = 320 + seed % 4_681;
3798                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3799                let reserved_output_tokens = 1 + seed % (max_context_tokens - 1);
3800                seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
3801                let history_limit = 1 + (seed % 12) as usize;
3802                let input_budget = max_context_tokens - reserved_output_tokens;
3803                let config_digest =
3804                    Digest::sha256(format!("budget-config-{history_index}-{config_index}"));
3805                let projected = engine
3806                    .project(SessionContextRequest {
3807                        session_id: session_id.clone(),
3808                        current_run_id: current_run_id.clone(),
3809                        through_session_seq: None,
3810                        system_message: Some(system.clone()),
3811                        tools: tools.clone(),
3812                        history_limit,
3813                        max_context_tokens,
3814                        reserved_output_tokens,
3815                        config_digest: config_digest.clone(),
3816                        allowed_skill_digests: BTreeMap::new(),
3817                    })
3818                    .await;
3819
3820                if input_budget < pinned_tokens {
3821                    assert!(matches!(
3822                        projected,
3823                        Err(SessionContextError::ContextOverflow { used, budget })
3824                            if used == pinned_tokens && budget == input_budget
3825                    ));
3826                    overflowed += 1;
3827                    continue;
3828                }
3829
3830                let projection = projected.expect("fittable pinned context projects");
3831                let actual = meter
3832                    .count_request_input(&projection.messages, &tools)
3833                    .unwrap();
3834                assert_eq!(actual, projection.used_input_tokens);
3835                assert!(actual <= input_budget);
3836                assert_eq!(projection.input_budget_tokens, input_budget);
3837                assert_eq!(projection.config_digest, config_digest);
3838                assert_eq!(projection.through_session_seq, records.len() as u64);
3839                let selected = projection
3840                    .included_ranges
3841                    .iter()
3842                    .map(|range| {
3843                        assert_eq!(range.first_session_seq, range.last_session_seq);
3844                        range.first_session_seq
3845                    })
3846                    .collect::<BTreeSet<_>>();
3847                assert!(pinned.is_subset(&selected));
3848                let rendered = serde_json::to_string(&projection.messages).unwrap();
3849                assert!(rendered.contains("stable system policy"));
3850                assert!(rendered.contains(&format!("current-task-{history_index}")));
3851                assert!(rendered.contains(artifact.artifact_ref.as_str()));
3852                assert!(rendered.contains(&uncertain_effect_call));
3853                assert!(rendered.contains(&uncertain_model_call));
3854                assert!(rendered.contains(&format!("pending-input-call-{history_index}")));
3855                assert!(rendered.contains(&format!("pending-approval-call-{history_index}")));
3856                let eligible_history = old_keys
3857                    .iter()
3858                    .take(history_limit)
3859                    .copied()
3860                    .collect::<BTreeSet<_>>();
3861                assert!(selected
3862                    .difference(&pinned)
3863                    .all(|key| eligible_history.contains(key)));
3864                if let Some((required, _)) =
3865                    recent_prefixes.iter().rev().find(|(required, tokens)| {
3866                        required.len().saturating_sub(pinned.len()) <= history_limit
3867                            && *tokens <= input_budget
3868                    })
3869                {
3870                    assert!(required.is_subset(&selected));
3871                }
3872                let calls = projection
3873                    .messages
3874                    .iter()
3875                    .flat_map(|message| message.content.iter())
3876                    .filter_map(|content| match content {
3877                        ModelContent::ToolCall { call_id, .. } => Some(call_id.clone()),
3878                        _ => None,
3879                    })
3880                    .collect::<BTreeSet<_>>();
3881                let results = projection
3882                    .messages
3883                    .iter()
3884                    .flat_map(|message| message.content.iter())
3885                    .filter_map(|content| match content {
3886                        ModelContent::ToolResult { call_id, .. } => Some(call_id.clone()),
3887                        _ => None,
3888                    })
3889                    .collect::<BTreeSet<_>>();
3890                assert_eq!(calls, results);
3891                successful += 1;
3892            }
3893        }
3894
3895        assert_eq!(successful + overflowed, HISTORIES * CONFIGS_PER_HISTORY);
3896        assert!(successful > 0);
3897        assert!(overflowed > 0);
3898    }
3899}