Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions apps/signalboxd/src/process_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6956,6 +6956,7 @@ where
session,
requested_through_position,
automatic_for_turn: None,
automatic_content_byte_target: None,
defaults_version: defaults.version(),
selection,
target,
Expand Down Expand Up @@ -7254,11 +7255,18 @@ pub(crate) async fn compact_automatically(
.resolve(FrozenModelSelection::Direct(selection))
.map_err(|_| AutomaticContextCompactionError::Configuration)?
.target();
let input_includes_cache_tokens = model_configuration
let route = model_configuration
.resolve_direct_model(selection)
.ok_or(AutomaticContextCompactionError::Configuration)?
.adapter()
.reports_cache_inclusive_input();
.ok_or(AutomaticContextCompactionError::Configuration)?;
let input_includes_cache_tokens = route.adapter().reports_cache_inclusive_input();
let runtime_models = model_configuration.runtime_model_catalog();
let definition = runtime_models
.resolve(target)
.ok_or(AutomaticContextCompactionError::Configuration)?;
let automatic_content_byte_target = u64::from(definition.context_window_tokens())
.checked_sub(u64::from(definition.max_output_tokens()))
.and_then(NonZeroU64::new)
Comment on lines +7266 to +7268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the rendered compaction request, not raw content

When a frontier contains many small entries—or content requiring JSON escaping—this target does not actually bound the model input: bounded_safe_boundary counts only stored payload bytes, while load_context_compaction_range serializes every selected entry with IDs, type metadata, field names, delimiters, and escaping, and the request also adds the nonempty compaction system prompt. The selected prefix can therefore render far beyond context_window_tokens - max_output_tokens, causing the provider to reject the sole automatic compaction attempt and leaving the oversized queued turn unrecoverable. Compute the boundary from the rendered/token-counted request or reserve all serialization and prompt overhead.

AGENTS.md reference: AGENTS.md:L116-L120

Useful? React with 👍 / 👎.

.ok_or(AutomaticContextCompactionError::Configuration)?;
let credential_reference = model_calls
.resolve_session_credential_reference(session, target)
.await
Expand All @@ -7270,6 +7278,7 @@ pub(crate) async fn compact_automatically(
session,
requested_through_position: None,
automatic_for_turn: Some(turn),
automatic_content_byte_target: Some(automatic_content_byte_target),
defaults_version: defaults.version(),
selection,
target,
Expand Down
3 changes: 3 additions & 0 deletions apps/signalboxd/tests/process_protocol_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2029,6 +2029,7 @@ fn direct_compaction_request(
session: SessionId::from_uuid(session_id.into_uuid()),
requested_through_position,
automatic_for_turn: None,
automatic_content_byte_target: None,
defaults_version: SessionConfigurationDefaultsVersion::first(),
selection: DirectModelSelection::from_uuid(Uuid::from_u128(1)),
target: ResolvedProviderTarget::naming(ProviderModelIdentity::from_uuid(Uuid::from_u128(
Expand Down Expand Up @@ -7714,6 +7715,7 @@ async fn s01_s03_inv005_inv014_inv015_explicit_compaction_survives_restart_and_p
session: SessionId::from_uuid(session_id.into_uuid()),
requested_through_position: None,
automatic_for_turn: None,
automatic_content_byte_target: None,
defaults_version: SessionConfigurationDefaultsVersion::first(),
selection: DirectModelSelection::from_uuid(Uuid::from_u128(1)),
target: ResolvedProviderTarget::naming(ProviderModelIdentity::from_uuid(
Expand Down Expand Up @@ -7761,6 +7763,7 @@ async fn s01_s03_inv005_inv014_inv015_explicit_compaction_survives_restart_and_p
session: SessionId::from_uuid(session_id.into_uuid()),
requested_through_position: None,
automatic_for_turn: None,
automatic_content_byte_target: None,
defaults_version: SessionConfigurationDefaultsVersion::first(),
selection: DirectModelSelection::from_uuid(Uuid::from_u128(1)),
target: ResolvedProviderTarget::naming(ProviderModelIdentity::from_uuid(
Expand Down
68 changes: 59 additions & 9 deletions crates/persistence/src/context_compaction.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Durable explicit context-compaction command and call lifecycle.

use std::{collections::BTreeMap, error::Error, fmt};
use std::{collections::BTreeMap, error::Error, fmt, num::NonZeroU64};

use rust_decimal::Decimal;
use signalbox_application::{ClassifyOperatorFailure, OperatorFailureClass};
Expand Down Expand Up @@ -34,6 +34,8 @@ pub struct PrepareContextCompactionRequest {
pub requested_through_position: Option<u64>,
/// Queued turn whose context guard owns this automatic attempt.
pub automatic_for_turn: Option<TurnId>,
/// Model-derived content-byte target for an automatic summary prefix.
pub automatic_content_byte_target: Option<NonZeroU64>,
/// Current defaults epoch observed before entering the transaction.
pub defaults_version: SessionConfigurationDefaultsVersion,
/// Current direct model selection after freezing any alias.
Expand Down Expand Up @@ -777,6 +779,11 @@ async fn prepare_in_transaction(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
request: &PrepareContextCompactionRequest,
) -> Result<(bool, PrepareContextCompactionOutcome), ContextCompactionRepositoryError> {
if request.automatic_for_turn.is_some() != request.automatic_content_byte_target.is_some()
|| request.automatic_for_turn.is_some() && request.requested_through_position.is_some()
{
return Ok((false, PrepareContextCompactionOutcome::InvalidBoundary));
}
match lookup_command_on_connection(
transaction,
request.command,
Expand Down Expand Up @@ -1010,8 +1017,10 @@ async fn prepare_in_transaction(
Some(position) => visible
.iter()
.position(|member| member.position == position),
None if request.automatic_for_turn.is_some() => bounded_safe_boundary(&visible),
None => latest_safe_boundary(&visible),
None => match request.automatic_content_byte_target {
Some(target) => bounded_safe_boundary(&visible, target.get()),
None => latest_safe_boundary(&visible),
},
};
let Some(through_index) = through_index else {
return Ok((false, PrepareContextCompactionOutcome::InvalidBoundary));
Expand Down Expand Up @@ -1395,11 +1404,14 @@ fn latest_safe_boundary(members: &[ProjectedFrontierMember]) -> Option<usize> {
latest
}

fn bounded_safe_boundary(members: &[ProjectedFrontierMember]) -> Option<usize> {
fn bounded_safe_boundary(
members: &[ProjectedFrontierMember],
content_byte_target: u64,
) -> Option<usize> {
let total_weight = members.iter().fold(0_u64, |total, member| {
total.saturating_add(member.content_bytes.max(1))
});
let midpoint_weight = total_weight.div_ceil(2);
let target_weight = total_weight.div_ceil(2).min(content_byte_target);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ensure one compaction can make the successor fit

When a session history is sufficiently larger than twice the model's input capacity, capping the summarized prefix at content_byte_target leaves a suffix that exceeds the same capacity even if the summary call itself fits and returns a minimal summary. The counted activation path then renders the successor, detects that it is still oversized, and returns ContextStillExceeded because this turn has already used its single automatic compaction; the queued turn is permanently stalled. The boundary strategy must either guarantee that the retained suffix plus summary can fit or support multiple bounded chunks before consuming the turn's sole attempt.

AGENTS.md reference: AGENTS.md:L252-L257

Useful? React with 👍 / 👎.

let mut through_weight = 0_u64;
let mut open_requests = 0_usize;
for (index, member) in members.iter().enumerate() {
Expand All @@ -1411,7 +1423,7 @@ fn bounded_safe_boundary(members: &[ProjectedFrontierMember]) -> Option<usize> {
}
_ => {}
}
if through_weight >= midpoint_weight && open_requests == 0 {
if through_weight >= target_weight && open_requests == 0 {
return Some(index);
}
}
Expand Down Expand Up @@ -1662,7 +1674,7 @@ mod tests {
ordinary(4, entry(0x7024)),
];

assert_eq!(bounded_safe_boundary(&visible), Some(1));
assert_eq!(bounded_safe_boundary(&visible, u64::MAX), Some(1));
}

#[test]
Expand Down Expand Up @@ -1700,7 +1712,7 @@ mod tests {
ordinary(6, entry(0x7036)),
];

assert_eq!(bounded_safe_boundary(&visible), Some(3));
assert_eq!(bounded_safe_boundary(&visible, u64::MAX), Some(3));
}

#[test]
Expand All @@ -1712,6 +1724,44 @@ mod tests {
weighted_ordinary(4, entry(0x7044), 100),
];

assert_eq!(bounded_safe_boundary(&visible), Some(2));
assert_eq!(bounded_safe_boundary(&visible, u64::MAX), Some(2));
}

#[test]
fn automatic_boundary_stops_at_the_model_derived_content_budget() {
let visible = vec![
weighted_ordinary(1, entry(0x7051), 100),
weighted_ordinary(2, entry(0x7052), 100),
weighted_ordinary(3, entry(0x7053), 100),
weighted_ordinary(4, entry(0x7054), 100),
weighted_ordinary(5, entry(0x7055), 100),
weighted_ordinary(6, entry(0x7056), 100),
];

assert_eq!(bounded_safe_boundary(&visible, 200), Some(1));
}

#[test]
fn automatic_boundary_closes_a_tool_exchange_crossing_the_model_budget() {
let visible = vec![
weighted_ordinary(1, entry(0x7061), 100),
ProjectedFrontierMember {
position: 2,
reference: entry(0x7062),
payload_kind: "assistant_tool_use".to_owned(),
content_bytes: 100,
summary_range: None,
},
ProjectedFrontierMember {
position: 3,
reference: entry(0x7063),
payload_kind: "tool_execution_result".to_owned(),
content_bytes: 100,
summary_range: None,
},
weighted_ordinary(4, entry(0x7064), 100),
];

assert_eq!(bounded_safe_boundary(&visible, 150), Some(2));
}
}
1 change: 1 addition & 0 deletions crates/persistence/tests/goal_postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4320,6 +4320,7 @@ async fn s18_inv015_inv032_logically_terminal_child_admits_compaction() -> Resul
session: session(bound_child),
requested_through_position: None,
automatic_for_turn: None,
automatic_content_byte_target: None,
defaults_version: SessionConfigurationDefaultsVersion::first(),
selection: DirectModelSelection::from_uuid(Uuid::from_u128(0xfa22)),
target: ResolvedProviderTarget::naming(ProviderModelIdentity::from_uuid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ async fn context_compaction_usage_is_available_to_pre_activation_compaction()
session: fixture.session,
requested_through_position: None,
automatic_for_turn: None,
automatic_content_byte_target: None,
defaults_version: SessionConfigurationDefaultsVersion::first(),
selection: DirectModelSelection::from_uuid(Uuid::from_u128(seed + 5)),
target,
Expand Down
22 changes: 12 additions & 10 deletions docs/spec/model-call-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ successor frontier is verified against this PR
(`agent/daemon-live-headroom-disjoint-suffix`). Dedicated-compaction usage as
the next queued-turn baseline is verified against this PR
(`agent/daemon-live-compaction-source-headroom`). Automatic compaction's
content-weighted boundary is verified against this PR
(`agent/daemon-live-compaction-byte-boundary`). Codex advisory output
model-bounded content-weighted boundary is verified against this PR
(`agent/daemon-live-model-bounded-compaction`). Codex advisory output
reservation behavior is re-verified against this PR
(`agent/daemon-live-codex-output-reservation`).

Expand Down Expand Up @@ -407,14 +407,16 @@ automatic preparation path retries transient database failures while loading its
selected transcript range, retaining the live `Prepared` call as provably unsent
rather than consuming that queued turn's sole automatic attempt. It weights each
model-visible entry by its durable content bytes, with unit weight for an empty
entry, and selects the first safe boundary at or beyond half the total weight.
An open tool exchange extends the prefix through its first safe closing
boundary. This keeps a few large entries from remaining indefinitely in a
count-light tail while still preventing the summary request from repeating the
complete oversized input unless one indivisible entry or tool exchange itself
spans the midpoint. An integrity failure still terminalizes the unsent call.
After a successful provider result, the daemon retains the summary and its usage
in memory until the exact completion is durably applied or replayed.
entry, and selects the first safe boundary at or beyond the smaller of half the
total weight and the selected model's context window after its configured output
reservation. An open tool exchange extends the prefix through its first safe
closing boundary. This keeps a few large entries from remaining indefinitely in
a count-light tail without making the summary request repeat a complete
oversized input merely because the visible history kept growing; an indivisible
entry or tool exchange can still cross the derived target. An integrity failure
still terminalizes the unsent call. After a successful provider result, the
daemon retains the summary and its usage in memory until the exact completion is
durably applied or replayed.

The explicit `compact_session` request names a session and an optional semantic
transcript position. Absence selects the latest safe terminal or pre-call
Expand Down
Loading