Skip to content
Merged
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
35 changes: 22 additions & 13 deletions headroom/transforms/content_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -3704,7 +3704,11 @@ def _apply_strategy_to_content(
)
if output is not None and output.compressed:
compressed = output.content
compressed_tokens = len(output.content.split())
# Tokens, like ``original_tokens`` and every other
# branch: a word count here (code runs ~2.2
# tokens/word) made an unchanged block look like a
# 55% compression and no fallback could beat it.
compressed_tokens = _estimate_tokens(output.content)
decision_reason = "code_aware"
if compressed is None:
# Fallback to Kompress
Expand All @@ -3728,19 +3732,17 @@ def _apply_strategy_to_content(
# lossless has no savings. Reads are protected upstream, so
# only NON-read code reaches here. Keep Kompress ONLY if it
# actually shrinks (never inflate).
_k, _kt = self._try_ml_compressor(content, context, question)
if (
_k is not None
and _kt is not None
and _kt < original_tokens
and len(_k) < len(content)
):
# Recorded as tried even if it loses, so the no-savings
# fallback below does not run the same inference again.
strategy_chain.append(CompressionStrategy.KOMPRESS.value)
_k, _ = self._try_ml_compressor(content, context, question)
_kt = _estimate_tokens(_k)
if _kt < original_tokens and len(_k) < len(content):
compressed, compressed_tokens = _k, _kt
strategy = CompressionStrategy.KOMPRESS
actual_strategy = strategy
compressor_name = "KompressCompressor"
decision_reason = "code_aware_no_shrink_fallback_kompress"
strategy_chain.append(CompressionStrategy.KOMPRESS.value)

elif strategy == CompressionStrategy.SMART_CRUSHER:
# SmartCrusher handles its own TOIN recording
Expand Down Expand Up @@ -3965,9 +3967,11 @@ def _apply_strategy_to_content(
already_tried_kompress = CompressionStrategy.KOMPRESS.value in strategy_chain
if not already_tried_kompress:
strategy_chain.append(CompressionStrategy.KOMPRESS.value)
fallback_compressed, fallback_tokens = self._try_ml_compressor(
content, context, question
)
fallback_compressed, _ = self._try_ml_compressor(content, context, question)
# Measure with the router's estimator, the unit of
# ``compressed_tokens``. Kompress reports a passthrough in
# WORDS, which would let an unchanged block "win".
fallback_tokens = _estimate_tokens(fallback_compressed)
else:
fallback_compressed = compressed
fallback_tokens = compressed_tokens
Expand Down Expand Up @@ -5505,6 +5509,7 @@ def apply(
runtime_read_protection_window = kwargs.get("read_protection_window")
if (
runtime_read_protection_window is not None
and int(runtime_read_protection_window) > 0
and self.config.protect_recent_reads_fraction > 0
):
# A profile-derived window may only narrow protection when the
Expand All @@ -5513,7 +5518,11 @@ def apply(
# See #1374's documented contract: protected tool output must never
# lossy-compress "regardless of conversation depth" -- a per-request
# savings-profile kwarg must not silently weaken that.
read_protection_window = max(0, int(runtime_read_protection_window))
# A window of 0 is "no override", not "protect nothing": it comes
# from a profile's protect_recent=0 (coding, general), a positional
# guard for cache mode, and would otherwise strip file reads of all
# protection in token mode, down to the newest message.
read_protection_window = int(runtime_read_protection_window)

# Adaptive compression ratio: scale with context pressure
if model_limit > 0:
Expand Down
5 changes: 4 additions & 1 deletion sdk/typescript/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ export function vercelToOpenAI(messages: any[]): OpenAIMessage[] {

export function openAIToVercel(messages: OpenAIMessage[]): any[] {
const result: any[] = [];
// tool_call id -> function name, so tool-result parts carry the name the AI SDK expects.
const toolNames = new Map<string, string>();

for (const msg of messages) {
if (msg.role === "system") {
Expand All @@ -354,6 +356,7 @@ export function openAIToVercel(messages: OpenAIMessage[]): any[] {
if (msg.content) parts.push({ type: "text", text: msg.content });
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
toolNames.set(tc.id, tc.function.name);
let input: any;
try { input = JSON.parse(tc.function.arguments); } catch { input = tc.function.arguments ?? {}; }
parts.push({
Expand All @@ -380,7 +383,7 @@ export function openAIToVercel(messages: OpenAIMessage[]): any[] {
content: [{
type: "tool-result",
toolCallId: msg.tool_call_id,
toolName: "unknown",
toolName: toolNames.get(msg.tool_call_id) ?? "unknown",
output,
}],
});
Expand Down
33 changes: 33 additions & 0 deletions sdk/typescript/test/adapters/vercel-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,39 @@ describe("compressVercelMessages", () => {
expect(result.messages[1].role).toBe("user");
expect(result.messages[1].content[0].type).toBe("text");
});

it("keeps tool names on tool-result parts after compression", async () => {
mockFetch.mockResolvedValueOnce(
mockCompressResponse([
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_1", type: "function", function: { name: "getWeather", arguments: '{"city":"Paris"}' } },
],
},
{ role: "tool", content: '{"tempC":21}', tool_call_id: "call_1" },
]),
);

const result = await compressVercelMessages(
[
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call_1", toolName: "getWeather", input: { city: "Paris" } }],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: "call_1", toolName: "getWeather", output: { type: "json", value: { tempC: 21 } } },
],
},
],
{ model: "gpt-4o", baseUrl: "http://localhost:8787" },
);

expect(result.messages[1].content[0].toolName).toBe("getWeather");
});
});

describe("withHeadroom", () => {
Expand Down
41 changes: 41 additions & 0 deletions sdk/typescript/test/utils/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,3 +638,44 @@ describe("round-trip conversion", () => {
});
});
});

describe("openAIToVercel tool names", () => {
it("carries the tool name from the matching tool-call onto the tool-result part", () => {
const msgs: OpenAIMessage[] = [
{
role: "assistant",
content: null,
tool_calls: [
{ id: "tc_1", type: "function", function: { name: "getWeather", arguments: '{"city":"Paris"}' } },
],
},
{ role: "tool", content: '{"tempC":21}', tool_call_id: "tc_1" },
];
const result = openAIToVercel(msgs);
expect(result[1].content[0]).toEqual({
type: "tool-result",
toolCallId: "tc_1",
toolName: "getWeather",
output: { type: "json", value: { tempC: 21 } },
});
});

it("preserves tool names through a Vercel -> OpenAI -> Vercel round trip", () => {
const original = [
{ role: "user", content: [{ type: "text", text: "weather in Paris?" }] },
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call_1", toolName: "getWeather", input: { city: "Paris" } }],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: "call_1", toolName: "getWeather", output: { type: "json", value: { tempC: 21 } } },
],
},
];
const back = openAIToVercel(vercelToOpenAI(original));
expect(back[2].content[0].toolName).toBe("getWeather");
});
});

14 changes: 8 additions & 6 deletions tests/test_code_aware_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
2. Multi-line Go signature (`) error {`) — the brace-bearing signature line
survives truncation instead of being silently dropped.
3. ContentRouter CODE_AWARE token accounting — compressed_tokens must come
from the same word-count metric as original_tokens, not the compressor's
own (differently-scaled) estimator, or real savings get misread as none.
from the same metric as original_tokens (the router's token estimator),
not the compressor's own (differently-scaled) estimator, or real savings
get misread as none.
4. prefer_code_aware_for_code defaults to True, both on the dataclass and via
the HEADROOM_PREFER_CODE_AWARE_FOR_CODE env var.
"""
Expand All @@ -27,6 +28,7 @@
CompressionStrategy,
ContentRouter,
ContentRouterConfig,
_estimate_tokens,
)

pytestmark = pytest.mark.skipif(
Expand Down Expand Up @@ -96,13 +98,13 @@ class _Result:
r.compressed = self._compressed
# Deliberately inflated/differently-scaled "own" token estimate —
# simulates a compressor whose internal counter isn't comparable to
# the router's len(text.split()) word count.
# the router's own token estimate.
r.compressed_tokens = 10_000_000
return r


def test_content_router_code_aware_token_accounting_matches_word_count() -> None:
"""compressed_tokens must be derived from len(result.compressed.split()),
def test_content_router_code_aware_token_accounting_matches_router_estimate() -> None:
"""compressed_tokens must be derived from _estimate_tokens(result.compressed),
the same metric as original_tokens, not the compressor's own estimator.
Using the mismatched estimator made a real compression look like "no
savings" and forced a needless fallback to Kompress."""
Expand All @@ -117,7 +119,7 @@ def test_content_router_code_aware_token_accounting_matches_word_count() -> None
)

assert compressed == compressed_text
assert compressed_tokens == len(compressed_text.split())
assert compressed_tokens == _estimate_tokens(compressed_text)
assert strategy_chain == ["code_aware"], (
f"real compression must not trigger a Kompress fallback, got {strategy_chain}"
)
Expand Down
91 changes: 91 additions & 0 deletions tests/test_content_router_exclude_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,97 @@ def test_protect_tool_results_survives_runtime_read_protection_window_kwarg() ->
assert "router:excluded:tool" in result.transforms_applied


# ---------------------------------------------------------------------------
# Test 5: token mode + coding profile keeps recent file reads byte-exact
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
("trailing_turns", "window", "protected"),
[
(0, None, True), # newest Read, the profile's window of 0
(1, None, True), # 3 from the end, inside max(4, 0.3*n)
(1, 2, False), # a positive profile window still narrows it
(20, None, False), # past the 0.3 window: ages out
],
)
def test_token_mode_coding_profile_keeps_recent_read_byte_exact(
trailing_turns: int, window: int | None, protected: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The coding profile's protect_recent=0 reaches the router as
read_protection_window=0. In token mode (fraction 0.3) that used to replace
the max(4, 0.3*n) window with 0, so a `Read` result lossy-compressed even
as the newest message. Recent reads must stay byte-exact; reads past the
fraction window still age out to compression, and a positive profile
window still narrows the fraction window."""
pytest.importorskip("tiktoken")

from types import SimpleNamespace

from headroom.agent_savings import proxy_pipeline_kwargs
from headroom.providers import OpenAIProvider
from headroom.tokenizer import Tokenizer

tokenizer = Tokenizer(OpenAIProvider().get_token_counter("gpt-4o"), "gpt-4o")
proxy = _build(mode="token", savings_profile="coding")
router = _router(proxy)
assert router.config.protect_recent_reads_fraction == 0.3

class FakeKompress:
def is_ready(self) -> bool:
return True

def ensure_background_load(self) -> None:
pass

def compress(self, content, **kwargs):
compressed = " ".join(content.split()[:20]) + " Retrieve more: hash=deadbeef"
return SimpleNamespace(compressed=compressed, compressed_tokens=len(compressed.split()))

monkeypatch.setattr(router, "_get_kompress", lambda: FakeKompress())

read_output = "\n".join(
f"{i:>6}\tThe release notes describe change number {i} and its rollout plan."
for i in range(1, 121)
)
messages: list[dict[str, object]] = [
{"role": "user", "content": "read the notes"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_read_1",
"name": "Read",
"input": {"file_path": "/repo/NOTES.md"},
}
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_read_1", "content": read_output}
],
},
]
for i in range(trailing_turns):
messages.append({"role": "assistant", "content": f"reply {i}"})
messages.append({"role": "user", "content": f"follow-up {i}"})

kwargs = proxy_pipeline_kwargs(proxy.config)
assert kwargs["read_protection_window"] == 0
if window is not None:
kwargs["read_protection_window"] = window
result = router.apply(messages, tokenizer, **kwargs)

content = result.messages[2]["content"][0]["content"]
if protected:
assert content == read_output, "a recent Read must stay byte-exact in token mode"
assert "router:excluded:tool" in result.transforms_applied
else:
assert content != read_output, "a Read outside the window still ages out"


# ---------------------------------------------------------------------------
# Baseline: Bash NOT in DEFAULT_EXCLUDE_TOOLS (unchanged by this PR)
# ---------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion tests/test_openai_responses_context_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CompressionStrategy,
ContentRouter,
ContentRouterConfig,
_estimate_tokens,
)


Expand Down Expand Up @@ -432,7 +433,8 @@ def crush(self, value: str, query: str = "", bias: float = 1.0):
)

assert compressed == "short summary"
assert compressed_tokens == 2
# Measured with the router's estimator, the unit of the strategy result.
assert compressed_tokens == _estimate_tokens("short summary")
# The fallback chain must record both strategies it tried.
assert strategy_chain == ["smart_crusher", "kompress"]

Expand Down
4 changes: 2 additions & 2 deletions tests/test_router_registry_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ def _fake_compress(content: str, language: object = None, context: str = "") ->
content, CompressionStrategy.CODE_AWARE, "q", language="python", bias=1.0
)
assert out == shrunk
# CODE_AWARE's historical token metric is len(compressed.split()), NOT _estimate_tokens.
assert tokens == len(shrunk.split())
# Measured in the router's token estimate, the unit of original_tokens.
assert tokens == _estimate_tokens(shrunk)
assert chain == [CompressionStrategy.CODE_AWARE.value]
# The flip forwarded content, language, and context through the registry adapter.
assert seen == {"content": content, "language": "python", "context": "q"}
Expand Down
9 changes: 6 additions & 3 deletions tests/test_router_registry_smartcrusher.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,15 @@ def test_smart_crusher_kompress_fallback_matches_direct(monkeypatch: pytest.Monk
lambda: SimpleNamespace(crush=lambda c, query="", bias=1.0: SimpleNamespace(compressed=c)),
)
# Kompress shrinks (fallback_tokens < compressed_tokens) so it is adopted.
monkeypatch.setattr(router, "_try_ml_compressor", lambda c, ctx, q: ("KOMPRESSED::" + c, 3))
# The fallback is measured with the router's estimator, not the count the
# ML compressor reports, so the mock must actually return less text.
kompressed = "KOMPRESSED::" + _JSON[:40]
monkeypatch.setattr(router, "_try_ml_compressor", lambda c, ctx, q: (kompressed, 3))
out, tokens, chain = router._apply_strategy_to_content(
_JSON, CompressionStrategy.SMART_CRUSHER, "ctx", bias=1.0
)
assert out == "KOMPRESSED::" + _JSON
assert tokens == 3
assert out == kompressed
assert tokens == _estimate_tokens(kompressed)
assert chain == [
CompressionStrategy.SMART_CRUSHER.value,
CompressionStrategy.KOMPRESS.value,
Expand Down
Loading
Loading