diff --git a/CHANGELOG.md b/CHANGELOG.md index 7708645..aaf2b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cached/reasoning subsets from input/output (#137, thanks @kevin-lozada-santos). Cache-write premiums and streaming usage remain outside this change. +### Changed + +- `context_compaction` derives compaction capability from `controls.compaction_supported` + (set by `wrap_complete`) instead of a `has_hook` config flag. The flag is removed from + governance config — compaction works automatically when using `wrap_complete` (the + documented integration path). A one-time warning is logged when the policy degrades to + telemetry (no hook available). + ## [0.3.0] - 2026-09-12 ### Added diff --git a/benchmarking/browseruse/configs.py b/benchmarking/browseruse/configs.py index ef67b23..306b528 100644 --- a/benchmarking/browseruse/configs.py +++ b/benchmarking/browseruse/configs.py @@ -43,7 +43,7 @@ def _steering_policies(*, limit_micros: int) -> dict[str, Any]: "tool_fix": {"registry": list(BROWSERUSE_ACTION_REGISTRY), "k": 2}, "tool_output_cap": {"cap_tokens": 8000}, "output_runaway": {"repeats": 12, "domination": 0.9, "max_retries": 2}, - "context_compaction": {"ctx_max": 100_000, "has_hook": True}, + "context_compaction": {"ctx_max": 100_000}, "cost_guard": {"budget": "run_llm_cap", "threshold": 0.8, "mode": "minimize"}, } diff --git a/benchmarking/common/configs.py b/benchmarking/common/configs.py index 7ae161c..2bcdae8 100644 --- a/benchmarking/common/configs.py +++ b/benchmarking/common/configs.py @@ -30,7 +30,7 @@ def tokenops_config(*, limit_micros: int) -> dict[str, Any]: # tool_fix registry is research-agent shaped; browser-use actions differ — skip for live bench "tool_output_cap": {"cap_tokens": 8000}, "output_runaway": {"repeats": 12, "domination": 0.9, "max_retries": 2}, - "context_compaction": {"ctx_max": 100_000, "has_hook": True}, + "context_compaction": {"ctx_max": 100_000}, "cost_guard": {"budget": "run_llm_cap", "threshold": 0.8, "mode": "minimize"}, }, } diff --git a/benchmarking/metagpt/configs.py b/benchmarking/metagpt/configs.py index 678a2e2..3abe4bb 100644 --- a/benchmarking/metagpt/configs.py +++ b/benchmarking/metagpt/configs.py @@ -26,7 +26,7 @@ def tokenops_config_steering(*, limit_micros: int, max_react_loop: int = 100) -> "tool_fix": {"registry": list(METAGPT_ACTION_REGISTRY), "k": 2}, "tool_output_cap": {"cap_tokens": 6000}, "output_runaway": {"repeats": 10, "domination": 0.88, "max_retries": 2}, - "context_compaction": {"ctx_max": 80_000, "has_hook": True}, + "context_compaction": {"ctx_max": 80_000}, "cost_guard": {"budget": "run_llm_cap", "threshold": 0.8, "mode": "minimize"}, }, } diff --git a/docs/policies/context_compaction.md b/docs/policies/context_compaction.md index f14f362..e140dae 100644 --- a/docs/policies/context_compaction.md +++ b/docs/policies/context_compaction.md @@ -1,6 +1,6 @@ # context_compaction — keep the prompt lean and cache-friendly -Companion to `halt.md`. Default; needs a hook. +Companion to `halt.md`. Default; capability derived from controls. Code: `tokenops-dev/src/tokenops/control/policies/context_compaction.py` Tests: `tokenops-dev/tests/test_context_compaction.py` @@ -15,6 +15,19 @@ discount), dedup tool outputs by hash, and summarize only filler while pinning t prompt, schema, constraints, and state. Without a prompt-assembly hook it degrades to telemetry. **Never HALTs.** +## Where the hook comes from + +Compaction capability is **derived at runtime** from `controls.compaction_supported`, +not declared in governance config. `wrap_complete` (`integration.py`) sets this flag to +`True` because it IS the prompt-assembly hook — the `_compact_messages` call at +`integration.py:252` rewrites the outgoing messages before dispatch. + +If you follow the [Tier-1 quickstart](../../README.md#-quickstart) (use +`wrap_complete`), compaction works out of the box. No `has_hook` config needed. + +Without `wrap_complete` (e.g. `RaiseControls`), the policy degrades to telemetry +(ALLOW) and a one-time warning is logged. + ## Detect (formula) ``` @@ -47,7 +60,7 @@ HALT: a bloated prompt or a lost cache discount is a cost issue, not a safety st | `est_input = ctx_max` | `WARN` → `MUTATE` | | `est_input` well below | `None` | | `est_input ≈ ctx_max/2`, rising across recent llm steps | trips early | -| `has_hook=False` | `ALLOW` (telemetry only) — never HALT | +| No `compaction_supported` on controls | `ALLOW` (telemetry only) — never HALT | ## Status diff --git a/examples/config/brief.yaml b/examples/config/brief.yaml index 81e8d50..3c1ba89 100644 --- a/examples/config/brief.yaml +++ b/examples/config/brief.yaml @@ -57,7 +57,6 @@ governance: mode: minimize context_compaction: ctx_max: 100000 - has_hook: false output_runaway: repeats: 4 max_retries: 2 diff --git a/examples/config/compose.yaml b/examples/config/compose.yaml index 305f724..434d909 100644 --- a/examples/config/compose.yaml +++ b/examples/config/compose.yaml @@ -50,7 +50,6 @@ governance: mode: minimize context_compaction: ctx_max: 100000 - has_hook: false output_runaway: repeats: 4 max_retries: 2 diff --git a/examples/config/default.yaml b/examples/config/default.yaml index efca565..091b6db 100644 --- a/examples/config/default.yaml +++ b/examples/config/default.yaml @@ -73,7 +73,6 @@ governance: mode: minimize context_compaction: ctx_max: 100000 - has_hook: false output_runaway: repeats: 4 max_retries: 2 diff --git a/examples/config/triad.compose.yaml b/examples/config/triad.compose.yaml index 2e9d044..c95cd35 100644 --- a/examples/config/triad.compose.yaml +++ b/examples/config/triad.compose.yaml @@ -70,7 +70,6 @@ governance: mode: minimize context_compaction: ctx_max: 100000 - has_hook: false output_runaway: repeats: 4 max_retries: 2 diff --git a/examples/config/triad.yaml b/examples/config/triad.yaml index be8797c..c1d1e42 100644 --- a/examples/config/triad.yaml +++ b/examples/config/triad.yaml @@ -71,7 +71,6 @@ governance: mode: minimize context_compaction: ctx_max: 100000 - has_hook: false output_runaway: repeats: 4 max_retries: 2 diff --git a/src/tokenops/config/default.yaml b/src/tokenops/config/default.yaml index 4787cd5..ab7d47c 100644 --- a/src/tokenops/config/default.yaml +++ b/src/tokenops/config/default.yaml @@ -31,7 +31,6 @@ governance: mode: minimize context_compaction: ctx_max: 100000 - has_hook: false output_runaway: repeats: 4 max_retries: 2 diff --git a/src/tokenops/control/config.py b/src/tokenops/control/config.py index 354654d..386b1df 100644 --- a/src/tokenops/control/config.py +++ b/src/tokenops/control/config.py @@ -20,7 +20,7 @@ tool_output_cap: { cap_tokens: 8000 } progress_guard: { window: 6, repeats: 3, max_corrections: 2 } cost_guard: { budget: run_llm_cap, threshold: 0.8, mode: minimize } - context_compaction: { ctx_max: 100000, has_hook: false } + context_compaction: { ctx_max: 100000 } output_runaway: { repeats: 4, max_retries: 2 } Fail closed: an unknown policy key, a missing budget reference, or a missing required @@ -104,7 +104,7 @@ def budget(self, ref: str) -> Budget: velocity_m=p.get("velocity_m", 5), ), "context_compaction": lambda p, c: context_compaction.build( - p["ctx_max"], window=p.get("window", 4), has_hook=p.get("has_hook", True) + p["ctx_max"], window=p.get("window", 4) ), "output_runaway": lambda p, c: output_runaway.build( n=p.get("n", 3), diff --git a/src/tokenops/control/context.py b/src/tokenops/control/context.py index 66680d2..3f7f8aa 100644 --- a/src/tokenops/control/context.py +++ b/src/tokenops/control/context.py @@ -36,6 +36,7 @@ class BoundRun: _registration: ContextVar[RunRegistration | None] = ContextVar("run_registration", default=None) _span: ContextVar[SpanContext | None] = ContextVar("run_span", default=None) _governance: ContextVar[GovernanceContext | None] = ContextVar("governance", default=None) +_current_controls: ContextVar[Any | None] = ContextVar("current_controls", default=None) @dataclass(frozen=True, kw_only=True) @@ -60,6 +61,21 @@ def clear_governance() -> None: _governance.set(None) +def current_controls() -> Any: + """Return the active ``AgentControls`` for the current governed call, or ``None``.""" + return _current_controls.get() + + +def set_current_controls(controls: Any) -> Any: + """Set the active controls context; returns a token for ``reset_current_controls``.""" + return _current_controls.set(controls) + + +def reset_current_controls(token: Any) -> None: + """Reset the controls context to its previous value.""" + _current_controls.reset(token) + + def current_registration() -> RunRegistration | None: return _registration.get() @@ -90,6 +106,7 @@ def clear() -> None: _registration.set(None) _span.set(None) _governance.set(None) + _current_controls.set(None) @contextmanager diff --git a/src/tokenops/control/engine.py b/src/tokenops/control/engine.py index d34d771..b00a1a4 100644 --- a/src/tokenops/control/engine.py +++ b/src/tokenops/control/engine.py @@ -118,8 +118,14 @@ class ApplyControls: **Directives are bounded.** ``carry`` dedups and stops at ``max_carry``. ``cost_guard`` fires *because* spend is high, so an unbounded pile of steer messages would add prompt tokens at exactly the wrong moment. + + ``compaction_supported`` advertises that the host can honour a MUTATE ``compact`` + action (set by ``wrap_complete`` which supplies the prompt-assembly hook). Policies + that need the hook read this instead of a config flag — capability is derived, not + declared. """ + compaction_supported: bool = False carry: list[str] = field(default_factory=list) event_log: list[Action] = field(default_factory=list) call: _ResolvedCall = field(default_factory=_ResolvedCall) @@ -321,13 +327,19 @@ def _refuse_if_halted(self, run_id: str) -> None: ) def _enforce(self, signals: Sequence[Signal]) -> None: - for sig in sorted(signals, key=lambda s: _SEVERITY_RANK[s.severity], reverse=True): - policy = self._policy_by_name.get(sig.detector) - if policy is None: - continue # a detector with no paired policy is observe-only telemetry - action = policy.decide(sig, self.ledger) - if action.kind is ActionKind.HALT and self.enforce: - # set the durable flag BEFORE applying, so the kill switch survives a - # swallowed raise. Idempotent — marking twice is harmless. - self.ledger.mark_halted(action.run_id, action.reason) - self.controls.apply(action) # may raise Halt and unwind the agent loop + from tokenops.control.context import reset_current_controls, set_current_controls + + tok = set_current_controls(self.controls) + try: + for sig in sorted(signals, key=lambda s: _SEVERITY_RANK[s.severity], reverse=True): + policy = self._policy_by_name.get(sig.detector) + if policy is None: + continue # a detector with no paired policy is observe-only telemetry + action = policy.decide(sig, self.ledger) + if action.kind is ActionKind.HALT and self.enforce: + # set the durable flag BEFORE applying, so the kill switch survives a + # swallowed raise. Idempotent — marking twice is harmless. + self.ledger.mark_halted(action.run_id, action.reason) + self.controls.apply(action) # may raise Halt and unwind the agent loop + finally: + reset_current_controls(tok) diff --git a/src/tokenops/control/integration.py b/src/tokenops/control/integration.py index be3ed9c..45bb1f4 100644 --- a/src/tokenops/control/integration.py +++ b/src/tokenops/control/integration.py @@ -229,6 +229,7 @@ def wrap_complete( seg = f"run:{attr.run_id}" boundary_id = f"{service or attr.agent}.chat" traced = wrap_llm(boundary_id, dispatch) + controls.compaction_supported = True # wrap_complete IS the prompt-assembly hook def governed(p: str, m: str, messages) -> object: from tokenops.control.crossing import reset_wrap_owns_precall, wrap_owns_precall diff --git a/src/tokenops/control/policies/context_compaction.py b/src/tokenops/control/policies/context_compaction.py index b4e155d..ee67dd7 100644 --- a/src/tokenops/control/policies/context_compaction.py +++ b/src/tokenops/control/policies/context_compaction.py @@ -1,4 +1,4 @@ -"""context_compaction — default; needs a prompt-assembly hook. +"""context_compaction — default; derive compaction capability from controls. LLD row: Detect: est_input ≥ ctx_max OR est_input rising over recent(run, W) (estimate from last @@ -10,10 +10,16 @@ Fires at pre_call (it shapes the *next* prompt). Without an assembly hook it can only observe — so it emits the signal for the dashboard and takes no action. It never HALTs. + +Capability is derived at runtime from ``controls.compaction_supported`` (set by +``wrap_complete`` which supplies the prompt-assembly hook), not from a governance config +flag. See ``docs/policies/context_compaction.md``. """ from __future__ import annotations +import logging + from tokenops.control.core import ( Action, ActionKind, @@ -25,6 +31,8 @@ Signal, ) +_log = logging.getLogger(__name__) + class ContextCompactionDetector(Detector): """WARN when the estimated input is at/over the context ceiling, or rising across the @@ -70,15 +78,31 @@ def pre_call(self, request: CallRequest, view: LedgerView) -> Signal | None: class ContextCompactionPolicy(Policy): """MUTATE the prompt if an assembly hook exists; otherwise telemetry-only (ALLOW). Never - HALT — losing the cache discount or a bloated prompt is not a reason to kill a run.""" + HALT — losing the cache discount or a bloated prompt is not a reason to kill a run. + + Compaction capability is read from ``controls.compaction_supported`` (advertised by + ``wrap_complete``), not from a config flag. + """ name = "context_compaction" - def __init__(self, has_hook: bool = True) -> None: - self.has_hook = has_hook + _telemetry_only_logged: bool = False def decide(self, signal: Signal, view: LedgerView) -> Action: - if not self.has_hook: + from tokenops.control.context import current_controls + + controls = current_controls() + compaction_supported = ( + getattr(controls, "compaction_supported", False) if controls else False + ) + if not compaction_supported: + if not ContextCompactionPolicy._telemetry_only_logged: + _log.warning( + "context_compaction: no compaction hook available — " + "policy will emit telemetry only (ALLOW). " + "Use wrap_complete for full compaction support." + ) + ContextCompactionPolicy._telemetry_only_logged = True return Action( kind=ActionKind.ALLOW, run_id=signal.run_id, @@ -94,5 +118,5 @@ def decide(self, signal: Signal, view: LedgerView) -> Action: ) -def build(ctx_max: int, *, window: int = 4, has_hook: bool = True) -> tuple[Detector, Policy]: - return ContextCompactionDetector(ctx_max, window=window), ContextCompactionPolicy(has_hook) +def build(ctx_max: int, *, window: int = 4) -> tuple[Detector, Policy]: + return ContextCompactionDetector(ctx_max, window=window), ContextCompactionPolicy() diff --git a/src/tokenops/ui/views/admin.py b/src/tokenops/ui/views/admin.py index 3fd1362..87def4f 100644 --- a/src/tokenops/ui/views/admin.py +++ b/src/tokenops/ui/views/admin.py @@ -33,7 +33,7 @@ "tool_output_cap": '{"cap_tokens": 8000}', "progress_guard": '{"window": 6, "repeats": 3, "max_corrections": 2}', "cost_guard": '{"threshold": 0.8, "mode": "minimize"}', - "context_compaction": '{"ctx_max": 100000, "has_hook": false}', + "context_compaction": '{"ctx_max": 100000}', "output_runaway": '{"repeats": 4, "max_retries": 2}', } diff --git a/tests/test_actuators.py b/tests/test_actuators.py index 1844559..b896c78 100644 --- a/tests/test_actuators.py +++ b/tests/test_actuators.py @@ -173,7 +173,7 @@ def test_deep_compaction_rewrites_outgoing_messages(): ledger = Ledger(price=toy_price) controls = ApplyControls() gov = Governor(ledger, controls) - gov.register(*context_compaction.build(ctx_max=10, has_hook=True)) # tiny ctx → always trips + gov.register(*context_compaction.build(ctx_max=10)) # tiny ctx → always trips attr = _attr("r3") ledger.open_run("r3") diff --git a/tests/test_context_compaction.py b/tests/test_context_compaction.py index 7b56193..aad030d 100644 --- a/tests/test_context_compaction.py +++ b/tests/test_context_compaction.py @@ -4,6 +4,8 @@ from conftest import FakeView, make_attr, make_step from tokenops.control import ActionKind, CallRequest, Usage +from tokenops.control.context import reset_current_controls, set_current_controls +from tokenops.control.engine import ApplyControls from tokenops.control.policies import context_compaction @@ -13,11 +15,23 @@ def _req(est): ) +def _with_compaction_supported(): + """Set up a controls context where compaction is supported (simulates wrap_complete).""" + controls = ApplyControls() + controls.compaction_supported = True + tok = set_current_controls(controls) + return tok + + def test_trips_at_ctx_max_and_mutates(): det, pol = context_compaction.build(ctx_max=10_000) sig = det.pre_call(_req(10_000), FakeView()) assert sig.severity.value == "warn" - assert pol.decide(sig, FakeView()).kind is ActionKind.MUTATE + tok = _with_compaction_supported() + try: + assert pol.decide(sig, FakeView()).kind is ActionKind.MUTATE + finally: + reset_current_controls(tok) def test_below_silent(): @@ -33,6 +47,8 @@ def test_rising_trend_trips_early(): def test_no_hook_is_telemetry_only(): - det, pol = context_compaction.build(ctx_max=10_000, has_hook=False) + """Without compaction_supported on controls, the policy returns ALLOW (telemetry only).""" + det, pol = context_compaction.build(ctx_max=10_000) sig = det.pre_call(_req(10_000), FakeView()) - assert pol.decide(sig, FakeView()).kind is ActionKind.ALLOW # never HALT, never mutate + # No controls in context → compaction not supported → ALLOW + assert pol.decide(sig, FakeView()).kind is ActionKind.ALLOW diff --git a/tests/test_policies_wrap_integration.py b/tests/test_policies_wrap_integration.py index d16a091..8134567 100644 --- a/tests/test_policies_wrap_integration.py +++ b/tests/test_policies_wrap_integration.py @@ -354,7 +354,7 @@ def same_tool(i: int): def test_it_context_compaction_rewrites_messages_via_wrap(): controls = ApplyControls() gov = Governor(Ledger(price=toy_price), controls) - gov.register(*context_compaction.build(ctx_max=10, has_hook=True)) + gov.register(*context_compaction.build(ctx_max=10)) attr = _attr("r-ccx") gov.ledger.open_run("r-ccx") dispatch, calls = _dispatch()