Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
72 changes: 61 additions & 11 deletions src/conductor/providers/_pydantic_ai/compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ async def _estimate_context_tokens(
)


def _count_utf8_bytes(text: str) -> int:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RECOMMENDED

Both new helpers claim token units for values that are bytes or mixed, which is the proximate cause of the unit mixing.

_count_utf8_bytes is documented as "Return a one-byte-per-token safety bound for token-dense text" — it returns a byte count. Whether that bounds tokens is the caller's assumption, not this function's contract, and describing a primitive by its caller's role is the comment most likely to rot.

_estimate_conservative_context_tokens is worse: the name ends in _tokens and the return type is a bare int, but the value is neither tokens nor bytes. When a usage anchor is present, the harness returns provider tokens for the anchored prefix plus UTF-8 bytes for everything after — a mixed-unit quantity valid only as a one-sided upper bound. Nothing anywhere says "do not treat this as tokens," and three lines later it's assigned straight into before_estimate and shipped as tokens_before.

Also worth flagging: _estimate_context_tokens is async def while its new sibling is a plain def, though both wrap the same synchronous harness call. Given the project's "async/await for all provider operations" convention, the asymmetry reads as meaningful when it isn't.

Suggested fix: rename to _utf8_byte_length and _estimate_context_utf8_bytes, and state the unit plainly:

def _utf8_byte_length(text: str) -> int:
    """Return the UTF-8 byte length of ``text``.

    Used as a stand-in ``tokenizer``: no tokenizer emits more tokens than the
    text has bytes. The result is in BYTES, roughly 4x a real token count for
    English prose - never compare it with, or substitute it for, a token value.
    """

and for the estimator, document that with an anchor it returns anchor tokens + post-anchor bytes, so it's comparable only against the raw window and must not be reported as a token count. Under those names, before_estimate = max(before_estimate, byte_bound) reads immediately as the bug it is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e8490bf, with names that state the mechanism now that the byte count is gone entirely:

  • _density_text_token_bound(text) — the docstring states the result is in TOKENS, documents the three content classes (ordinary prose → chars/4, substantial non-ASCII share → chars, whitespace-poor ASCII → chars/2), and notes the residual multi-token-per-character emoji case as a known undercount rather than claiming a hard bound.
  • _estimate_context_tokens_density(messages, params) — the harness-anchored safety estimate; the docstring states it is comparable against the window and the target and must never be mixed into token telemetry.
  • _estimate_context_tokens_independent(messages) — the fallback, documented as sharing no code with the primary path.

The async/sync asymmetry you flagged is removed as well: all three estimators are uniformly async (the primary's docstring notes the underlying harness call is synchronous and the signature is convention).

"""Return a one-byte-per-token safety bound for token-dense text."""
return len(text.encode("utf-8"))


def _estimate_conservative_context_tokens(
messages: list[ModelMessage],
model_request_parameters: ModelRequestParameters | None,
) -> int:
"""Estimate context with a one-byte-per-token safety bound."""
from pydantic_ai_harness.compaction import estimate_context_tokens

return estimate_context_tokens(
messages,
tokenizer=_count_utf8_bytes,
model_request_parameters=model_request_parameters,
)


def _estimate_after_compaction_tokens(
before_messages: list[ModelMessage],
after_messages: list[ModelMessage],
Expand Down Expand Up @@ -254,28 +273,59 @@ async def before_model_request(
if self._disabled:
return request_context

# Zone (a): gate measurement. A broken estimate says nothing about
# the compaction path, so this warns and skips compaction for this
# request only — no errored event, no disable latch.
before_messages = list(request_context.messages)
conservative_estimate: int | None = None
try:
before_messages = list(request_context.messages)
before_estimate = await _estimate_context_tokens(
before_messages,
request_context.model_request_parameters,
)
except Exception: # noqa: BLE001 - estimation must never fail the run
logger.warning(
"Compaction gate measurement failed for agent %r; "
"skipping compaction for this request.",
"Compaction gate measurement failed for agent %r; using the conservative fallback.",
self._config.agent_name,
exc_info=True,
)
try:
conservative_estimate = _estimate_conservative_context_tokens(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RECOMMENDED

The conservative estimator isn't independent of the primary, so the fallback is unreachable for realistic failures.

_estimate_conservative_context_tokens and _estimate_context_tokens differ in exactly one argument: tokenizer. Both funnel into the same pydantic_ai_harness.compaction.estimate_context_tokens with the same messages and the same model_request_parameters, so they share the whole text-collection path — part extraction, the usage-anchor lookup, instruction text, tool-schema serialization. Every realistic failure of the primary (a part whose str(...) raises, a malformed usage anchor, an unserialisable parameters_json_schema) is tokenizer-independent and takes the fallback down with it. The only failure surface unique to the fallback is _count_utf8_bytes, which can't raise for a str.

I verified this by patching the shared harness function the way a real estimator bug would fail: both warnings fire, compaction is skipped, no events, no latch — byte-for-byte the pre-PR outcome, after fifteen extra lines and a second full traversal.

Both new tests hide this by patching conductor.providers._pydantic_ai.compaction._estimate_context_tokens, Conductor's own one-line wrapper — the single layer the two estimators do not share. That constructs a failure mode that can't occur in production and asserts a compaction cycle for it.

Suggested fix: either drop the fallback branch and restore the simple skip, or make it genuinely independent — walk request_context.messages directly and sum len(str(part).encode()) inside a per-part try, so one bad part degrades rather than zeroing the estimate. If the branch stays, add a test that patches pydantic_ai_harness.compaction.estimate_context_tokens (the shared layer) and asserts the skip path, so the shared-failure reality is pinned rather than papered over.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — the old pair differed only in the tokenizer argument, so both funneled through the same harness text-collection path and any realistic failure (a part whose str(...) raises, a malformed anchor, an unserialisable schema) took the fallback down with the primary. Patching Conductor's own one-line wrapper in the tests was constructing a failure mode that can't occur in production, as you said.

The fallback is now genuinely independent in e8490bf: _estimate_context_tokens_independent walks the message list directly with attribute-level access (latest usage anchor found via getattr, per-part extraction inside a per-part try so one bad part degrades the estimate instead of zeroing it) and never imports the harness estimator. Your pinning test exists as test_shared_harness_failure_still_compacts_via_independent_fallback: it patches pydantic_ai_harness.compaction.estimate_context_tokens — the shared layer itself — and asserts the run still compacts via the fallback.

before_messages,
request_context.model_request_parameters,
)
except Exception: # noqa: BLE001 - estimation must never fail the run

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RECOMMENDED

Both new error branches are entirely uncovered, and the only surviving documented behaviour is untested.

Branch coverage on this module is 96%, missing exactly lines 294-301 and 309-310 — the two error paths this PR introduces.

Lines 294-301 (both estimators failed → return unchanged, no event, no latch) is now the only path that still behaves the way the class docstring describes, and the only remaining safety net against an exception escaping into the model request path. Lines 309-315 (primary succeeded, conservative raised) silently disarms the window guard for that request; if it were wrong — say return request_context instead of falling through — compaction would stop happening on the primary trigger whenever the safety estimator hiccups, and every existing test would still pass.

Separately, neither degradation is visible outside stderr. Conductor installs no logging handlers in src/, so these warnings reach logging.lastResort as unattributed stderr — absent from the JSONL log, the dashboard, and replay, and under --web-bg written to a file nobody was told to read. When compaction then runs on the primary alone, the emitted agent_compaction_complete is indistinguishable from a fully guarded one. Contrast the tier-failure path, which names degraded_tiers in the payload precisely so consumers don't read a degraded outcome as success. The engine has a precedent for the remedy in pricing_hook_silent.

Suggested fix: add both tests (patch _estimate_conservative_context_tokens with side_effect=RuntimeError(...) for the 309 branch, and patch both estimators for the 294 branch), asserting the emitted events and capability._disabled is False in each. For visibility, mirror the existing degraded_tiers convention: add degraded_estimators: list[str] to the complete payload, populated with "safety_bound" when the conservative estimate was lost, and emit a dedicated agent_compaction_skipped event with {"reason": "estimate_unavailable"} from the double-failure branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both branches are now covered in e8490bf, and the visibility gap is closed the way you suggested:

  • test_double_estimator_failure_emits_skipped_event_without_latch — both estimators raise; the context is returned unchanged, agent_compaction_skipped fires with {"reason": "estimate_unavailable"}, and _disabled stays False.
  • test_density_failure_compacts_on_primary_and_reports_degradation — the density measurement raises while the primary succeeds; compaction proceeds on the primary alone, the complete event carries degraded_estimators: ["density"], and the latch stays off.

degraded_estimators follows the existing degraded_tiers convention on the complete payload (always present, empty when nothing was lost; "primary" when the fallback drove the request, "density" when the window guard was disarmed), and the console subscriber renders both it and the new skipped event, so a degraded or skipped gate is visible outside stderr.

logger.warning(
"Compaction fallback measurement failed for agent %r; "
"skipping compaction for this request.",
self._config.agent_name,
exc_info=True,
)
return request_context
before_estimate = conservative_estimate
else:
try:
conservative_estimate = _estimate_conservative_context_tokens(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RECOMMENDED

The duplicated conservative-estimate handler makes control flow do the work of a value.

The try/except/else shape forces the same nested try: conservative_estimate = _estimate_conservative_context_tokens(...) except Exception: warn to be written twice, in the except arm and again in the else arm. The two copies differ only in the log suffix and in whether before_estimate gets seeded. A future edit to one arm will silently miss the other.

One side effect worth flagging: the second copy raises its warning from inside an active exception handler, so its exc_info traceback carries a spurious "During handling of the above exception, another exception occurred" chain that'll mislead whoever reads it.

One caution on the neighbouring cleanup — the apparently redundant conservative_estimate is not None at line 327 is load-bearing, not dead. ty doesn't propagate narrowing through a boolean stored in a local, so deleting the conjunct while keeping the may_exceed_window local breaks make typecheck with Argument to function max is incorrect: Expected int, found int | None. Restructure rather than simply deleting it.

Suggested fix: let both estimates be int | None and resolve once, which removes a nesting level and one copy of the handler while preserving both operator-facing suffixes:

primary_estimate: int | None = None
try:
    primary_estimate = await _estimate_context_tokens(...)
except Exception:  # noqa: BLE001 - estimation must never fail the run
    logger.warning("Compaction gate measurement failed for agent %r; "
                   "using the conservative fallback.", ..., exc_info=True)

conservative_estimate: int | None = None
try:
    conservative_estimate = _estimate_conservative_context_tokens(...)
except Exception:  # noqa: BLE001 - estimation must never fail the run
    outcome = ("skipping compaction for this request." if primary_estimate is None
               else "using the primary estimate.")
    logger.warning("Compaction safety measurement failed for agent %r; %s",
                   self._config.agent_name, outcome, exc_info=True)

if primary_estimate is not None:
    before_estimate = primary_estimate
elif conservative_estimate is not None:
    before_estimate = conservative_estimate
else:
    return request_context

Then fold the window condition directly into the if so the narrowing is real and the not/not dance disappears (see the telemetry finding for the combined form).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Restructured in e8490bf along the lines you sketched: both estimates are resolved as int | None with a single handler per arm, before_estimate is picked once, and the window condition is folded directly into the gate so the narrowing is real (the ty point about the load-bearing is not None is moot under the new shape — make typecheck is clean).

One deliberate deviation from the sketch: the two arms no longer call the same function. The else arm uses the shared harness-backed density estimator (accurate anchoring), while the except arm uses the new independent walker, because the fallback's whole purpose is to survive a failure in the shared path — see the thread above about fallback independence. A side effect of the restructure is exactly the one you predicted: the else-arm warning no longer fires from inside an active exception handler, so the spurious "During handling of the above exception" chaining is gone on the common path.

before_messages,
request_context.model_request_parameters,
)
except Exception: # noqa: BLE001 - primary estimate remains usable
logger.warning(
"Compaction safety measurement failed for agent %r; "
"using the primary estimate.",
self._config.agent_name,
exc_info=True,
)

# The primary estimate drives the reserve-based trigger. The byte bound
# only guards the hard window, avoiding premature compaction of ordinary
# text while catching suffixes the four-characters heuristic undercounts.
exceeds_trigger = before_estimate > self._config.trigger_tokens
may_exceed_window = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

BLOCKING

The window guard is a no-op whenever the primary estimate is at or below target_tokens.

When may_exceed_window fires, the wrapper delegates to self._inner.before_model_request(...), the harness's TieredCompaction. That capability re-gates on its own estimate, computed with tokenizer=None — the same four-characters-per-token heuristic the primary estimator uses — and returns the context unchanged once that estimate is already <= target_tokens. The byte bound never reaches the component that decides when to stop.

So the new branch only ever compacts in the narrow band target_tokens < primary <= trigger_tokens. Outside it, the guard fires, nothing gets dropped, and the request goes out at full size — while the wrapper still emits agent_compaction_start and a success-shaped agent_compaction_complete. That's worse than not guarding at all: an operator whose run dies on context_length_exceeded will see a completed compaction right beforehand and rule the estimator out as the cause.

This is anti-correlated with the bug being fixed. Content the heuristic undercounts is exactly what pushes the primary estimate down into the no-op region. I verified this against the real build_tiered_compaction stack (window 200,000 / trigger 96,000 / target 86,000): a 205,110-byte token-dense history — the exact shape this PR targets — gives primary 51,277 and conservative 205,110. The guard fires, and 40 messages come back as 40 messages, with both lifecycle events emitted anyway.

Suggested fix: the bound has to reach the escalation loop, not just the outer gate. Either build a second orchestrator over the same tier wrappers with the byte tokenizer and delegate to it when the window bound is what fired:

tiered_bytes = TieredCompaction(
    tiers=[clear_tier, summarize_tier, slide_tier],
    target_tokens=config.target_tokens,
    tokenizer=_count_utf8_bytes,  # baseline and reclaim in the same unit
)

or bypass TieredCompaction.before_model_request on that path and drive the tier chain directly with a target derived from the conservative measure, so the inner re-gate can't veto it. At minimum, don't report success for a no-op: after delegation, compare list(result.messages) against before_messages, and when the window guard fired but nothing changed, log at error and emit a degraded — not success-shaped — agent_compaction_complete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and reproduced before changing anything: under the production-resolved plan (resolve_compaction_plan(window=200_000, output_limit=64_000, tool_buffer=15_000) → trigger 121,000 / target 110,000) the motivating scenario returns 31 of 31 messages while both lifecycle events fire — the inner TieredCompaction re-gates on the same heuristic that under-counted the content and declines.

Fixed in e8490bf by taking your second option: on the guard path the wrapper now drives the tier chain directly (_drive_tiers_under_window_guard) instead of delegating to TieredCompaction.before_model_request. It escalates through the same tier wrappers, re-measuring with the density-calibrated estimate after each tier and stopping only once that estimate fits target_tokens, so the inner strategy's heuristic gate can no longer veto the safety compaction. The tiers receive the request's model context (mirroring the harness's context_for_request, so the summarizer resolves the same model it would on the normal path).

Your "at minimum" is covered as well: when the guard fired but the history is unchanged, the wrapper logs at error, and the complete event carries still_over_window: true instead of reading as success.

conservative_estimate is not None
and conservative_estimate >= self._config.window_tokens

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

BLOCKING

Byte bound fires at roughly a quarter of the window on ordinary text, producing a per-request no-op loop and a spurious user-facing warning.

A UTF-8 byte count runs about 4x a real token count for English prose, and the guard fires at conservative_estimate >= window_tokens. With no usage anchor, the conservative value is a pure byte count of the whole history, so the guard trips at roughly window/4 real tokens — far below trigger_tokens. The inline comment three lines above claims this avoids "premature compaction of ordinary text"; the arithmetic says the opposite.

Measured against the real stack with the project's own resolver (resolve_compaction_plan(window=200_000, output_limit=64_000, tool_buffer=40_000) → trigger 96,000, target 86,000), 216,000 characters of plain English prose (~54,000 real tokens, 27% of the window, 56% of the trigger) trips the guard. On a 128k window it trips at ~32,000 tokens against a 79,616 trigger.

Since 54,000 is below target_tokens, the inner strategy declines (see the companion finding), so this becomes a no-op that repeats on every model request for the rest of the run, each time emitting agent_compaction_start plus a complete event carrying tokens_before=216000 against a 200,000 window, tokens_saved=0, and still_over_trigger=True. cli/run.py renders that last flag as a user-visible WARNING: context compacted ... history remains above the trigger. A healthy run at a quarter of its context budget ends up producing a warning per request, forever.

The anchored path is milder but still reachable: the byte overcount applies to the post-anchor suffix, so the guard fires below the trigger once that suffix reaches about a third of the reserve. Worth noting: the PR's own reported evidence (primary ~106k, bytes >200k) reproduces exactly from plain ASCII prose with a ~74.5k anchor — it isn't by itself evidence of token density.

Suggested fix: a raw byte count overshoots real tokens by ~4x for English, while the heuristic's undercount on dense content is only ~1.3-2x — the correction is bigger than the error it's correcting for. Replace the absolute byte-vs-window comparison with something calibrated: a real tokenizer for the post-anchor suffix, or a density signal (bytes-per-character, non-ASCII fraction) that only escalates the estimate for suffixes that are actually dense. Whatever the mechanism, add the negative test that's missing today, because it fails against the PR as written:

@pytest.mark.asyncio
async def test_ordinary_prose_below_trigger_is_not_compacted(self) -> None:
    events: list[tuple[str, dict[str, Any]]] = []
    cfg = _make_config(  # window_tokens defaults to 200_000
        trigger_tokens=96_000, target_tokens=86_000,
        event_callback=lambda t, d: events.append((t, d)),
    )
    capability = build_tiered_compaction(cfg)
    prose = "The quick brown fox jumps over the lazy dog and then writes a "
    messages = [
        ModelRequest(parts=[UserPromptPart(content=(prose * 70)[:4_000])])
        for _ in range(50)
    ]  # 200_000 bytes ~= 50_000 real tokens
    request_context = _request_context_with_messages(messages)

    result = await capability.before_model_request(_make_run_context(), request_context)

    assert len(result.messages) == len(messages)
    assert not events

And rewrite the comment at lines 317-319 so it states the real trade-off instead of ruling out the failure mode it actually introduces.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — measured on the previous revision: 50 ordinary prose messages of 4,000 characters each gives primary 50,000 and conservative 200,000, so the guard tripped at 25% of the window on plain English, exactly as you calculated. The point about the PR's own evidence (primary ~106k / bytes >200k) reproducing from plain ASCII with a ~74.5k anchor was also correct.

The raw byte count is gone in e8490bf. The guard now uses a density-calibrated bound (_density_text_token_bound): it keeps the ~4-characters-per-token heuristic for ordinary prose, counts text with a substantial non-ASCII share at ~1 token per character, and whitespace-poor ASCII blobs at ~2 characters per token. It therefore escalates only for content that is measurably dense — base64's density comes from character distribution, which the whitespace check approximates, while CJK/non-Latin scripts are caught by the non-ASCII share. Both density signals are measured on a bounded leading sample so the per-request cost stays flat.

Your negative test is added nearly verbatim as test_ordinary_prose_below_trigger_is_not_compacted — it fails against the previous revision — plus direct unit tests for the bound (CJK → chars, base64-like → chars/2, prose → chars/4). The comment above the gate was rewritten to state the real trade-off instead of ruling out the failure mode it introduced.

)
if not exceeds_trigger and not may_exceed_window:
return request_context

# Gate on the token estimate, not the message count: one large
# prompt can exceed the trigger with nothing to drop.
if before_estimate <= self._config.trigger_tokens:
return request_context
if conservative_estimate is not None and may_exceed_window:
before_estimate = max(before_estimate, conservative_estimate)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

BLOCKING

before_estimate is overwritten with a byte count and reported as tokens, inverting still_over_trigger on successful compactions.

before_estimate = max(before_estimate, conservative_estimate) silently changes the variable's unit. conservative_estimate isn't tokens — it's provider tokens for the anchored prefix plus UTF-8 bytes for everything after. That value then flows into _on_before(estimate=...) as tokens_before, into _estimate_after_compaction_tokens(...), into _on_after(...) as tokens_after/tokens_saved, and into still_over_trigger.

_estimate_after_compaction_tokens subtracts a reclaim measured with estimate_token_count(..., None) — the four-characters-per-token heuristic, in token units — from a byte-scale baseline. Its own docstring justifies that subtraction as mirroring what TieredCompaction._escalate acted on; that justification is void once the baseline is in a different unit from what the loop acted on.

Measured on the PR's own new test scenario, where compaction genuinely succeeds and drops 10 of 31 messages:

field emitted actual
tokens_before 200,519 106,004
tokens_after 191,065 22,055
tokens_saved 9,454 ~83,900
still_over_trigger True false — 22,055 is far under the 121,000 trigger

Because conservative >= window_tokens is the precondition for reaching this line, window_tokens > trigger_tokens by construction, and a char-scale reclaim can't close a 4x unit gap — still_over_trigger will read True for essentially every byte-guarded compaction. cli/run.py and the dashboard store both branch on that flag to print a warning, so the PR turns every successful guard-triggered compaction into a false alarm and understates the reclaim by roughly 9x. tokens_before also renders as >100% of the context window in the console percentage.

Raising before_estimate buys nothing functionally either — it's never passed to the inner strategy, which re-measures internally. It's purely a telemetry baseline, and a wrong one.

Suggested fix: drop the promotion and keep before_estimate on the token scale for all telemetry:

window_guard_tripped = (
    conservative_estimate is not None
    and conservative_estimate >= self._config.window_tokens
)
if not window_guard_tripped and before_estimate <= self._config.trigger_tokens:
    return request_context

If the safety bound is useful for diagnostics, surface it as its own explicitly-named field on the start event (byte_bound, plus a trigger_reason="window_guard" | "trigger") rather than overloading tokens_before. If a byte-scale after-value is genuinely wanted, produce it with the same tokenizer that produced the before-value — _estimate_conservative_context_tokens(after_messages, ...) — never by subtracting a char-heuristic reclaim.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — on the PR's own scenario the emitted payload read tokens_before=200,519, tokens_saved=9,454, still_over_trigger=true against a real after-value of ~22,000 tokens, and you're right that the promotion bought nothing functionally since the inner strategy re-measures anyway.

The promotion is removed in e8490bf. before_estimate stays on the primary token scale (or the fallback estimate when the primary is lost) and is never mixed with the safety measurement: the density-calibrated value is a gate input only, reported on the start event as its own field — density_tokens — alongside trigger_reason ("trigger" / "window_guard"), which is your suggested shape with a name that matches the new mechanism. On the same scenario the complete event now reads tokens_before=106,026, tokens_after=96,564, still_over_trigger=false.

The genuine failure mode you identified — a guard compaction that cannot get back under the window — is now reported honestly via still_over_window on the complete event instead of leaking through a unit-mismatched still_over_trigger.


self._on_before(estimate=before_estimate, messages_before=len(before_messages))
for tier in self._tier_wrappers:
Expand Down
65 changes: 61 additions & 4 deletions tests/test_providers/test_pydantic_ai_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,60 @@ async def test_gate_measures_once_via_wrapper_estimate(self) -> None:
assert len(result.messages) == 1


class TestKnownWindowSafety:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RECOMMENDED

No non-ASCII coverage: replacing the UTF-8 encode with a character count passes the whole suite.

The stated justification for a byte bound is content the four-chars-per-token heuristic undercounts — CJK, emoji, non-Latin scripts, base64. None of it is tested. Mutating _count_utf8_bytes from len(text.encode("utf-8")) to len(text) passes all 30 tests, because every payload in the file is pure ASCII, where the two are identical.

The fixture in the new test compounds this: f"turn-{index}-" + "!" * 4_193 is a run of ASCII exclamation marks. Repeated single-byte punctuation is among the least token-dense content there is — BPE merges long ! runs into very few tokens — so its real token count sits far below the heuristic, the opposite of the case the test name describes. The test demonstrates the ordinary-text false-positive path and asserts it as correct behaviour.

The >= boundary at line 323 is also unpinned: mutating it to > passes every test.

Suggested fix: add a direct unit test for the encoder and a boundary test for the comparison:

def test_conservative_estimator_counts_utf8_bytes_not_characters(self) -> None:
    messages = [ModelRequest(parts=[UserPromptPart(content="日本語" * 1_000)])]
    assert _estimate_conservative_context_tokens(
        messages, ModelRequestParameters()
    ) == 9_000

and rebuild the token-dense fixture from content that's genuinely dense relative to the heuristic — CJK text or base64 — rather than repeated punctuation, so the scenario matches the failure the PR describes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both gaps are closed in e8490bf:

  • _density_text_token_bound("日本語" * 1_000) == 3_000 pins byte-vs-character behavior at the unit level — mutating the implementation toward a plain character count now fails this test, as does the suite-wide CJK coverage in TestKnownWindowSafety, whose fixtures are rebuilt from CJK text ("日本" * 2_098 per turn) instead of repeated ! runs. You're right that the old fixture was among the least token-dense content possible and was demonstrating the false-positive path.
  • The >= boundary is pinned by test_window_guard_fires_at_exact_window_boundary: a density estimate exactly equal to the window trips the guard (asserting trigger_reason == "window_guard" and density_tokens == 200_000), and a one-character-short control produces no events.

"""Requirement: known context windows are hard pre-request boundaries."""

@pytest.mark.asyncio
async def test_token_dense_suffix_compacts_before_known_window_overflow(self) -> None:
# Requirement: provider usage plus token-dense suffix growth must compact before
# the next request can exceed the known context window.
capability = build_tiered_compaction(
_make_config(trigger_tokens=121_000, target_tokens=100_000)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

BLOCKING

The regression test hard-codes a target_tokens the resolver never produces; under the real formula the motivating scenario doesn't compact.

target_tokens isn't a free parameter in production. compaction_window.target_tokens() computes max(1, min(int(window * 0.55), trigger - 1)), so for window_tokens=200_000 (the _make_config default) and trigger_tokens=121_000 the resolver produces 110,000, not the 100,000 hard-coded here. Confirmed against resolve_compaction_plan(window=200_000, output_limit=64_000, tool_buffer=15_000)trigger=121000, target=110000.

That 10,000-token difference is decisive, because the inner TieredCompaction gates on target_tokens. I ran this test's exact message history against both configurations:

PR test config:      trigger=121000 target=100000 | 31 -> 21   COMPACTED
Production config:   trigger=121000 target=110000 | 31 -> 31   *** NO-OP ***

The test passes only because it picks a target just below the scenario's primary estimate of 106,004. Under the configuration Conductor actually ships, the reported bug isn't fixed — the guard fires, the inner strategy declines, and the oversized request goes out unchanged behind a success-shaped agent_compaction_complete. The one test that pins the new behaviour certifies a parameterisation the product never produces.

The same hard-coded pair shows up again at line 373 for the second new test.

Suggested fix: derive the test configuration from the real resolver so a regression test can't pass against an unreachable config:

plan = resolve_compaction_plan(
    window=200_000, output_limit=64_000, tool_buffer=15_000
)
capability = build_tiered_compaction(
    _make_config(
        trigger_tokens=plan.trigger_tokens,
        target_tokens=plan.target_tokens,
    )
)

Then re-run the scenario. It'll fail — that's the correct signal — and resolve it as part of the delegation fix above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — target_tokens() resolves 110,000 for window 200,000 / trigger 121,000, and with the hard-coded 100,000 removed, the old test's scenario no-ops (31 → 31), which I reproduced before the fix.

The regression test now derives its configuration exactly as you suggested, from resolve_compaction_plan(window=200_000, output_limit=64_000, tool_buffer=15_000), so it cannot pass against a parameterisation the product never produces. It failed against the previous revision for precisely the reason you identified, and passes against the rework (e8490bf) because the guard path drives the tier chain against the density-calibrated measurement directly and no longer depends on the inner strategy's target gate. The same change is applied to the second test in the class, which previously hard-coded the same pair.

)
messages: list[Any] = [
ModelResponse(
parts=[TextPart(content="compaction complete")],
usage=RequestUsage(input_tokens=74_499, output_tokens=0),
)
]
for index in range(30):
messages.append(
ModelRequest(parts=[UserPromptPart(content=f"turn-{index}-" + "!" * 4_193)])
)
request_context = _request_context_with_messages(messages)

result = await capability.before_model_request(_make_run_context(), request_context)

assert len(result.messages) < len(messages)
assert len(result.messages) == 21

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RECOMMENDED

assert len(result.messages) == 21 pins an unrelated harness constant instead of the window property.

The class states its requirement as "known context windows are hard pre-request boundaries," but == 21 proves nothing about a window. It's keep_messages=20 from SlidingWindowCompaction in build_tiered_compaction, plus one. Change that tier parameter and this test breaks with an opaque failure while the safety behaviour is perfectly intact; conversely, it passes on real regressions.

It also hides which tier actually ran. The summarizing tier raises UserError: model must either be set on the agent... because SummarizingCompaction(..., model=None), degrades to the sliding-window fallback, and sets degraded_tiers == ["summarizing"] on every run — never asserted. So the test silently exercises only the deterministic fallback rather than the escalation its name implies.

The actual safety post-condition is never checked at all. The compacted result measures conservative = 88,220, comfortably under the 200,000 window — that's the assertion the test should be making.

Suggested fix:

assert len(result.messages) < len(messages)
assert (
    _estimate_conservative_context_tokens(
        list(result.messages), request_context.model_request_parameters
    )
    < cfg.window_tokens
), "compaction must bring the byte bound back under the known window"

Also assert degraded_tiers == ["summarizing"], or wire a stub summarizer model, so the test is honest about which tier it exercises. Consider asserting primary <= trigger < conservative up front so the test documents that it's isolating the byte bound — the current margin is 519 tokens out of 200,519 (0.26%), so any change to the harness's overhead accounting would silently flip its meaning without failing it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All three points addressed in e8490bf:

  • == 21 is gone. The test asserts len(result.messages) < len(messages) and then re-measures the result with the density estimator, requiring it to come back under cfg.window_tokens — the actual safety post-condition, essentially your suggested snippet adapted to the new name.
  • degraded_tiers == ["summarizing"] is now asserted, so the test is honest about exercising the deterministic fallback rather than the escalation its name implies.
  • The precondition is pinned up front: primary_before <= plan.trigger_tokens < density_before and density_before >= cfg.window_tokens, so the test documents that it isolates the window guard, and a change in harness overhead accounting that would flip its meaning now fails it loudly instead of silently.


@pytest.mark.asyncio
async def test_estimator_failure_uses_safe_fallback_before_large_request(self) -> None:
# Requirement: a failed primary estimate must not bypass compaction when an
# independent conservative estimate shows the request can exceed the known window.
inner = AsyncMock()
compacted = _request_context_with_messages(
[ModelRequest(parts=[UserPromptPart(content="compacted")])]
)
inner.before_model_request = AsyncMock(return_value=compacted)
capability = _FailOpenCompactionWrapper(
inner,
config=_make_config(trigger_tokens=121_000, target_tokens=100_000),
)
request_context = _request_context_with_messages(
[ModelRequest(parts=[UserPromptPart(content="!" * 200_288)])]
)

with patch(
"conductor.providers._pydantic_ai.compaction._estimate_context_tokens",
new=AsyncMock(side_effect=RuntimeError("estimator exploded")),
):
result = await capability.before_model_request(_make_run_context(), request_context)

inner.before_model_request.assert_called_once()
assert result is compacted


class TestTierFallback:
"""Requirement: a failing non-final tier still yields to the final tier."""

Expand Down Expand Up @@ -920,9 +974,9 @@ def callback(event_type: str, data: dict[str, Any]) -> None:
assert any(e[0] == "agent_compaction_complete" for e in events)

@pytest.mark.asyncio
async def test_gate_measurement_failure_skips_without_latch_or_event(self) -> None:
# A failing gate estimate warns, returns the
# original context, emits no event, and does not engage the latch.
async def test_gate_measurement_failure_uses_fallback_without_latch(self) -> None:
# Requirement: a failed primary estimate uses the conservative fallback
# without disabling compaction for later requests.
events: list[tuple[str, dict[str, Any]]] = []

def callback(event_type: str, data: dict[str, Any]) -> None:
Expand All @@ -942,7 +996,10 @@ def callback(event_type: str, data: dict[str, Any]) -> None:

assert result is request_context
assert capability._disabled is False # type: ignore[attr-defined]
assert not events
assert [event_type for event_type, _ in events] == [
"agent_compaction_start",
"agent_compaction_complete",
]

@pytest.mark.asyncio
async def test_telemetry_failure_keeps_compacted_result_without_latch(self) -> None:
Expand Down