diff --git a/CHANGELOG.md b/CHANGELOG.md index dd342a574..395b3d338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Fixed +- **compress:** stop mutating the caller's `CompressConfig`. `compress(config=my_cfg, protect_recent=0, target_ratio=0.2)` used to write those kwargs onto `my_cfg`, so a shared per-agent config was silently rewritten by every request that overrode a single option. - **paths:** reject `.`, `..`, and NUL as plugin names so `plugin_config_dir` / `plugin_workspace_dir` cannot resolve outside the `plugins/` sandbox. Previously `plugin_config_dir("..")` returned the entire config root and `plugin_workspace_dir("..")` returned the workspace root (savings ledger, memory DB, license cache, logs). - **backends/litellm:** drop tool names over 64 chars before calling Bedrock Converse (`send_message` and `stream_message`), instead of letting the whole request 401. The Bedrock Converse API hard-rejects any tool name past that length, and Claude Code includes every globally-added claude.ai MCP connector tool in every request, even ones the user hasn't enabled locally, so a single oversized connector name broke every call through this backend. Only the `bedrock` provider filters; other providers forward tool names unfiltered. - **memory:** annotate `_EMBEDDER_CACHE` as `dict[tuple[str, str, str], Embedder]` to match the 3-element key (backend, model, ollama_base_url). The stale 2-tuple annotation made `mypy headroom` fail on `main`, which broke the `lint` CI job on every open PR. diff --git a/headroom/compress.py b/headroom/compress.py index 0b967026b..ca9f0dc73 100644 --- a/headroom/compress.py +++ b/headroom/compress.py @@ -202,14 +202,17 @@ def compress( if not messages or not optimize: return CompressResult(messages=messages) - # Build config from explicit config + kwargs - cfg = config or CompressConfig() + # Build config from explicit config + kwargs. ``replace(config)`` up front + # so kwargs overrides and any savings-profile pass never mutate the + # caller's long-lived ``CompressConfig`` — a shared per-agent config being + # silently rewritten by every request that overrode a single option is the + # scenario this guards against. + cfg = replace(config) if config is not None else CompressConfig() config_fields = {f.name for f in cfg.__dataclass_fields__.values()} for key, value in kwargs.items(): if key in config_fields: setattr(cfg, key, value) if cfg.savings_profile: - cfg = replace(cfg) apply_agent_savings_profile(cfg, cfg.savings_profile) pipeline = _get_pipeline() diff --git a/tests/test_compress_api.py b/tests/test_compress_api.py index 7f9956b53..1e7f6f423 100644 --- a/tests/test_compress_api.py +++ b/tests/test_compress_api.py @@ -1,10 +1,11 @@ """Tests for the one-function compress() API and integrations.""" import json +from dataclasses import replace as _dc_replace import pytest -from headroom.compress import CompressResult, compress +from headroom.compress import CompressConfig, CompressResult, compress from headroom.hooks import CompressionHooks try: @@ -97,6 +98,39 @@ class TestCompressFunction: assert result.messages is messages assert result.tokens_saved == 0 + def test_kwargs_do_not_mutate_caller_config(self): + """kwargs must not smuggle their values onto the caller's CompressConfig. + + Regression: ``compress`` did ``cfg = config or CompressConfig()`` and + then ``setattr(cfg, key, value)`` for every matching kwarg — so a caller + who passed ``config=my_cfg, protect_recent=0`` came back to find their + long-lived ``my_cfg`` silently rewritten. A shared, per-agent config + was corrupted by every request that overrode a single option. + """ + + big_data = json.dumps([{"id": i, "status": "active"} for i in range(200)]) + messages = [ + {"role": "user", "content": "analyze"}, + {"role": "tool", "content": big_data, "tool_call_id": "c1"}, + ] + cfg = CompressConfig(protect_recent=4, target_ratio=0.8) + snapshot = _dc_replace(cfg) + + compress( + messages, + model="claude-sonnet-4-5-20250929", + config=cfg, + protect_recent=0, + target_ratio=0.2, + ) + + assert cfg.protect_recent == snapshot.protect_recent, ( + "compress() mutated caller's config.protect_recent via kwargs" + ) + assert cfg.target_ratio == snapshot.target_ratio, ( + "compress() mutated caller's config.target_ratio via kwargs" + ) + def test_with_custom_hooks(self): """Hooks are called when provided.""" calls = []