context pressure carries usage and ceiling as named fields
BackendEvent::ContextPressure was (f32, usize) whose second element was the context limit. A positional tuple crossing a module boundary made every consumer guess: the TUI guessed limit and was right, the HTTP layer named it tokens and was wrong. So every non-TUI surface rendered the ceiling as the usage — a constant 250000 that looked like a measurement. Nothing failed and nothing logged. Name the fields, and carry tokens_used explicitly rather than leaving it to be reconstructed as pressure x limit. bifrost_pressure already computed the token count and discarded it.
This commit is contained in:
parent
5cc7a90f44
commit
f266136510
5 changed files with 101 additions and 16 deletions
|
|
@ -378,7 +378,11 @@ pub enum StreamEvent {
|
||||||
#[serde(rename = "compaction_warning")]
|
#[serde(rename = "compaction_warning")]
|
||||||
CompactionWarning { pressure: f32, tier: u8 },
|
CompactionWarning { pressure: f32, tier: u8 },
|
||||||
#[serde(rename = "context_pressure")]
|
#[serde(rename = "context_pressure")]
|
||||||
ContextPressure { pressure: f32, tokens: usize },
|
ContextPressure {
|
||||||
|
pressure: f32,
|
||||||
|
tokens_used: usize,
|
||||||
|
context_limit: usize,
|
||||||
|
},
|
||||||
#[serde(rename = "inference_strain")]
|
#[serde(rename = "inference_strain")]
|
||||||
InferenceStrain {
|
InferenceStrain {
|
||||||
attempt: u32,
|
attempt: u32,
|
||||||
|
|
@ -482,7 +486,15 @@ impl From<crate::backend::BackendEvent> for StreamEvent {
|
||||||
pressure,
|
pressure,
|
||||||
},
|
},
|
||||||
BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier },
|
BE::CompactionWarning { pressure, tier } => Self::CompactionWarning { pressure, tier },
|
||||||
BE::ContextPressure(pressure, tokens) => Self::ContextPressure { pressure, tokens },
|
BE::ContextPressure {
|
||||||
|
pressure,
|
||||||
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
} => Self::ContextPressure {
|
||||||
|
pressure,
|
||||||
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
},
|
||||||
BE::InferenceStrain {
|
BE::InferenceStrain {
|
||||||
attempt,
|
attempt,
|
||||||
status,
|
status,
|
||||||
|
|
@ -578,9 +590,15 @@ impl From<StreamEvent> for crate::backend::BackendEvent {
|
||||||
StreamEvent::CompactionWarning { pressure, tier } => {
|
StreamEvent::CompactionWarning { pressure, tier } => {
|
||||||
BE::CompactionWarning { pressure, tier }
|
BE::CompactionWarning { pressure, tier }
|
||||||
}
|
}
|
||||||
StreamEvent::ContextPressure { pressure, tokens } => {
|
StreamEvent::ContextPressure {
|
||||||
BE::ContextPressure(pressure, tokens)
|
pressure,
|
||||||
}
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
} => BE::ContextPressure {
|
||||||
|
pressure,
|
||||||
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
},
|
||||||
StreamEvent::InferenceStrain {
|
StreamEvent::InferenceStrain {
|
||||||
attempt,
|
attempt,
|
||||||
status,
|
status,
|
||||||
|
|
@ -797,4 +815,43 @@ mod tests {
|
||||||
assert!(!typed.content.has_images());
|
assert!(!typed.content.has_images());
|
||||||
assert!(carrying.content.has_images());
|
assert!(carrying.content.has_images());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_pressure_keeps_usage_and_ceiling_distinct_on_the_wire() {
|
||||||
|
// Regression, 2026-08-12. `BackendEvent::ContextPressure` was a
|
||||||
|
// positional `(f32, usize)` whose second element was the context
|
||||||
|
// *limit*. This layer named that element `tokens`, so every HTTP
|
||||||
|
// surface rendered the ceiling as the usage — a constant 250000
|
||||||
|
// that looked exactly like a measurement. Assert the two numbers
|
||||||
|
// are carried separately and cannot be confused again.
|
||||||
|
let ev = StreamEvent::ContextPressure {
|
||||||
|
pressure: 0.37,
|
||||||
|
tokens_used: 92_500,
|
||||||
|
context_limit: 250_000,
|
||||||
|
};
|
||||||
|
let wire: serde_json::Value = serde_json::to_value(&ev).unwrap();
|
||||||
|
assert_eq!(wire["tokens_used"], 92_500);
|
||||||
|
assert_eq!(wire["context_limit"], 250_000);
|
||||||
|
// The old, ambiguous name must not reappear.
|
||||||
|
assert!(
|
||||||
|
wire.get("tokens").is_none(),
|
||||||
|
"`tokens` was the ambiguous name that caused the bug; \
|
||||||
|
a surface reading it must fail loudly, not read a ceiling"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it must survive the trip back into the backend enum.
|
||||||
|
let back: crate::backend::BackendEvent = ev.into();
|
||||||
|
match back {
|
||||||
|
crate::backend::BackendEvent::ContextPressure {
|
||||||
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(tokens_used, 92_500);
|
||||||
|
assert_eq!(context_limit, 250_000);
|
||||||
|
assert_ne!(tokens_used, context_limit);
|
||||||
|
}
|
||||||
|
other => panic!("expected ContextPressure, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,19 @@ pub enum BackendEvent {
|
||||||
/// Continuous context pressure update (sub-threshold).
|
/// Continuous context pressure update (sub-threshold).
|
||||||
/// Fires every round so the TUI ctx counter reflects live state
|
/// Fires every round so the TUI ctx counter reflects live state
|
||||||
/// rather than only updating when a warning crosses a threshold.
|
/// rather than only updating when a warning crosses a threshold.
|
||||||
ContextPressure(f32, usize),
|
///
|
||||||
|
/// Named fields, deliberately. This was `(f32, usize)` until 2026-08-12,
|
||||||
|
/// and a positional tuple crossing a module boundary made every consumer
|
||||||
|
/// guess what the second element meant. The TUI guessed `limit` (right);
|
||||||
|
/// the HTTP layer guessed `tokens` (wrong), so every non-TUI surface
|
||||||
|
/// rendered the context *ceiling* as if it were the usage — a constant
|
||||||
|
/// 250000 that looked like a measurement. Nothing failed and nothing
|
||||||
|
/// logged. Carry the names.
|
||||||
|
ContextPressure {
|
||||||
|
pressure: f32,
|
||||||
|
tokens_used: usize,
|
||||||
|
context_limit: usize,
|
||||||
|
},
|
||||||
/// Inference strain — the voice is hoarse, providers are slow.
|
/// Inference strain — the voice is hoarse, providers are slow.
|
||||||
/// Correlates to health over time.
|
/// Correlates to health over time.
|
||||||
InferenceStrain {
|
InferenceStrain {
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,13 @@ fn bifrost_pressure(
|
||||||
counter: &TokenCounter,
|
counter: &TokenCounter,
|
||||||
messages: &[BifrostMessage],
|
messages: &[BifrostMessage],
|
||||||
context_limit: usize,
|
context_limit: usize,
|
||||||
) -> f32 {
|
) -> (usize, f32) {
|
||||||
let tokens: usize = messages
|
let tokens: usize = messages
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| counter.count(&m.content.as_text()))
|
.map(|m| counter.count(&m.content.as_text()))
|
||||||
.sum();
|
.sum();
|
||||||
let limit = context_limit.max(1);
|
let limit = context_limit.max(1);
|
||||||
(tokens as f32 / limit as f32).min(1.0)
|
(tokens, (tokens as f32 / limit as f32).min(1.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: bump adaptive delay when we hit a 429. No decay — once bumped,
|
/// Helper: bump adaptive delay when we hit a 429. No decay — once bumped,
|
||||||
|
|
@ -364,7 +364,7 @@ pub(crate) async fn run_turn(
|
||||||
last_pulse = Instant::now();
|
last_pulse = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
let pressure = bifrost_pressure(&counter, &messages, context_limit);
|
let (tokens_used, pressure) = bifrost_pressure(&counter, &messages, context_limit);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
turn_round = tool_round,
|
turn_round = tool_round,
|
||||||
agent = %agent_id,
|
agent = %agent_id,
|
||||||
|
|
@ -375,7 +375,11 @@ pub(crate) async fn run_turn(
|
||||||
);
|
);
|
||||||
let max_tokens = pressure_to_max_tokens(pressure, output_limit);
|
let max_tokens = pressure_to_max_tokens(pressure, output_limit);
|
||||||
let _ = tx
|
let _ = tx
|
||||||
.send(Ok(BackendEvent::ContextPressure(pressure, context_limit)))
|
.send(Ok(BackendEvent::ContextPressure {
|
||||||
|
pressure,
|
||||||
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
}))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if max_rounds == 0 {
|
if max_rounds == 0 {
|
||||||
|
|
|
||||||
|
|
@ -397,8 +397,12 @@ impl App {
|
||||||
BackendEvent::CompactionWarning { pressure, tier } => {
|
BackendEvent::CompactionWarning { pressure, tier } => {
|
||||||
self.dispatch(TuiEvent::CompactionWarning { pressure, tier });
|
self.dispatch(TuiEvent::CompactionWarning { pressure, tier });
|
||||||
}
|
}
|
||||||
BackendEvent::ContextPressure(p, limit) => {
|
BackendEvent::ContextPressure {
|
||||||
self.dispatch(TuiEvent::PressureChanged(p, limit));
|
pressure,
|
||||||
|
context_limit,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.dispatch(TuiEvent::PressureChanged(pressure, context_limit));
|
||||||
}
|
}
|
||||||
BackendEvent::InferenceStrain {
|
BackendEvent::InferenceStrain {
|
||||||
attempt, status, ..
|
attempt, status, ..
|
||||||
|
|
|
||||||
|
|
@ -296,11 +296,19 @@ impl ChatState {
|
||||||
self.pending_consciousness
|
self.pending_consciousness
|
||||||
.push(BackendEvent::CompactionWarning { pressure, tier });
|
.push(BackendEvent::CompactionWarning { pressure, tier });
|
||||||
}
|
}
|
||||||
BackendEvent::ContextPressure(p, limit) => {
|
BackendEvent::ContextPressure {
|
||||||
self.pressure = p;
|
pressure,
|
||||||
self.context_limit = Some(limit);
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
} => {
|
||||||
|
self.pressure = pressure;
|
||||||
|
self.context_limit = Some(context_limit);
|
||||||
self.pending_consciousness
|
self.pending_consciousness
|
||||||
.push(BackendEvent::ContextPressure(p, limit));
|
.push(BackendEvent::ContextPressure {
|
||||||
|
pressure,
|
||||||
|
tokens_used,
|
||||||
|
context_limit,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
BackendEvent::InferenceStrain {
|
BackendEvent::InferenceStrain {
|
||||||
attempt,
|
attempt,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue