Skip to content

Commit 9598252

Browse files
authored
Merge branch 'headroomlabs-ai:main' into main
2 parents 052d594 + 4e30dde commit 9598252

44 files changed

Lines changed: 2379 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ jobs:
4343
timeout-minutes: 5
4444
outputs:
4545
code: ${{ steps.filter.outputs.code }}
46-
e2e: ${{ steps.filter.outputs.e2e }}
46+
native: ${{ steps.filter.outputs.native }}
47+
dashboard: ${{ steps.filter.outputs.dashboard }}
48+
packaging: ${{ steps.filter.outputs.packaging }}
4749
workflows: ${{ steps.filter.outputs.workflows }}
4850
steps:
4951
- uses: actions/checkout@v7
@@ -61,14 +63,41 @@ jobs:
6163
- 'tests/**'
6264
- 'scripts/**'
6365
- '.github/workflows/**'
64-
e2e:
65-
- 'headroom/**'
66+
# native = anything that can change the compiled wrapper, the native
67+
# install flow, or the docker image (drives the scarce macOS/Windows
68+
# runners + docker E2E). A pure-Python logic change hits none of these.
69+
native:
70+
- 'headroom/cli/**'
71+
- 'headroom/install/**'
72+
- 'headroom/providers/**'
73+
- 'headroom/rtk/**'
6674
- 'crates/**'
75+
- '**/*.rs'
76+
- 'Cargo.toml'
77+
- 'Cargo.lock'
78+
- 'rust-toolchain.toml'
6779
- 'docker/**'
6880
- 'Dockerfile'
6981
- 'e2e/**'
7082
- 'scripts/install*'
7183
- 'pyproject.toml'
84+
- '.github/workflows/**'
85+
dashboard:
86+
- 'headroom/dashboard/**'
87+
- '.github/workflows/**'
88+
# packaging = anything that changes how the wheel is built (so the
89+
# cross-platform wheel build only reruns when the build actually changes).
90+
packaging:
91+
- 'pyproject.toml'
92+
- 'Cargo.toml'
93+
- 'Cargo.lock'
94+
- 'uv.lock'
95+
- 'rust-toolchain.toml'
96+
- 'crates/**'
97+
- '**/*.rs'
98+
- 'scripts/**'
99+
- 'MANIFEST.in'
100+
- '.github/workflows/**'
72101
workflows:
73102
- '.github/workflows/**'
74103
@@ -122,7 +151,7 @@ jobs:
122151

123152
build-wheel-windows:
124153
needs: changes
125-
if: needs.changes.outputs.code == 'true'
154+
if: needs.changes.outputs.packaging == 'true'
126155
runs-on: windows-latest
127156
timeout-minutes: 45
128157
steps:
@@ -354,7 +383,7 @@ jobs:
354383

355384
test-dashboard-ui:
356385
needs: [changes, build-wheel]
357-
if: needs.changes.outputs.code == 'true'
386+
if: needs.changes.outputs.dashboard == 'true'
358387
runs-on: ubuntu-latest
359388
timeout-minutes: 20
360389
steps:
@@ -474,7 +503,7 @@ jobs:
474503

475504
docker-native-e2e:
476505
needs: changes
477-
if: needs.changes.outputs.e2e == 'true'
506+
if: needs.changes.outputs.native == 'true'
478507
runs-on: ubuntu-latest
479508
timeout-minutes: 45
480509
steps:
@@ -518,7 +547,7 @@ jobs:
518547
519548
windows-native-wrapper:
520549
needs: changes
521-
if: needs.changes.outputs.e2e == 'true'
550+
if: needs.changes.outputs.native == 'true'
522551
runs-on: windows-latest
523552
timeout-minutes: 20
524553
steps:
@@ -535,7 +564,7 @@ jobs:
535564

536565
macos-native-wrapper:
537566
needs: changes
538-
if: needs.changes.outputs.e2e == 'true'
567+
if: needs.changes.outputs.native == 'true'
539568
runs-on: macos-latest
540569
timeout-minutes: 20
541570
steps:

.github/workflows/init-e2e.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ name: Init E2E
33
on:
44
pull_request:
55
branches: [main]
6+
# Scoped to what the `headroom init` flow actually exercises (mirrors
7+
# init-native-e2e), not all of headroom/** — a pure-Python logic change
8+
# elsewhere shouldn't spin up a docker init E2E.
69
paths:
7-
- 'headroom/**'
10+
- 'headroom/cli/**'
11+
- 'headroom/install/**'
812
- 'crates/**'
913
- 'docker/**'
1014
- 'Dockerfile'

.github/workflows/wrap-e2e.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ name: Wrap E2E
33
on:
44
pull_request:
55
branches: [main]
6+
# Scoped to what the `headroom wrap` flow actually exercises (mirrors
7+
# wrap-native-e2e), not all of headroom/** — a pure-Python logic change
8+
# elsewhere shouldn't spin up a docker wrap E2E.
69
paths:
7-
- 'headroom/**'
10+
- 'headroom/cli/**'
11+
- 'headroom/providers/**'
12+
- 'headroom/rtk/**'
813
- 'crates/**'
914
- 'docker/**'
1015
- 'Dockerfile'

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Large diffs are not rendered by default.

headroom/agent_savings.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,15 @@ def apply_proxy_env_defaults(self, env: MutableMapping[str, str]) -> MutableMapp
158158
# append-only forwarding), not by refusing to touch user turns.
159159
compress_user_messages=True,
160160
compress_system_messages=False, # system prompt is the hottest cache
161-
protect_recent=2, # keep the active code working set verbatim
161+
# Compress the newest observation delta. In cache mode the delta IS the
162+
# newest turn(s), so a positional guard here suppresses the only thing
163+
# cache mode can compress (large recent grep/test/build tool output).
164+
# The code working set stays byte-exact via protect_reads below — a TYPE
165+
# guard on file reads — which is the real fidelity protection, so 0 loses
166+
# no correctness while unblocking compression of recent non-read output.
167+
protect_recent=0,
162168
protect_analysis_context=True,
163-
min_tokens_to_compress=25, # low → compression is visible
169+
min_tokens_to_compress=10, # low → even modest deltas are eligible
164170
max_items_after_crush=15,
165171
smart_crusher_with_compaction=True,
166172
force_kompress=False, # don't override diff/log lossless with lossy ML

headroom/backends/litellm.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -669,13 +669,20 @@ def _convert_messages_for_litellm(self, messages: list[dict[str, Any]]) -> list[
669669
tr_content = "\n".join(
670670
b.get("text", "") for b in tr_content if b.get("type") == "text"
671671
)
672-
converted.append(
673-
{
674-
"role": "tool",
675-
"tool_call_id": tr["tool_use_id"],
676-
"content": str(tr_content),
677-
}
678-
)
672+
tool_msg: dict[str, Any] = {
673+
"role": "tool",
674+
"tool_call_id": tr["tool_use_id"],
675+
"content": str(tr_content),
676+
}
677+
# Claude Code's moving cache breakpoint usually lands on the
678+
# tail tool_result, not just the system prompt. Carry
679+
# cache_control through so LiteLLM's Bedrock Converse
680+
# transformation can inject a cachePoint here too (#1390
681+
# covers the system-prompt/text-block case; this is the
682+
# tool_result case, out of scope there).
683+
if "cache_control" in tr:
684+
tool_msg["cache_control"] = tr["cache_control"]
685+
converted.append(tool_msg)
679686
continue
680687

681688
# tool_use blocks → OpenAI assistant message with tool_calls
@@ -966,15 +973,39 @@ async def stream_message(
966973
},
967974
)
968975

976+
# Request usage in the final streaming chunk so cache metrics
977+
# (cache_read_input_tokens / cache_creation_input_tokens) come back at
978+
# all. Without this, LiteLLM/Bedrock never emits a usage chunk over SSE
979+
# and the caller's cache stats always read 0, even when caching is
980+
# working server-side.
981+
kwargs["stream_options"] = {"include_usage": True}
982+
969983
# Stream content — blocks emitted dynamically based on response
970984
response = await acompletion(**kwargs)
971985
output_tokens = 0
972986
current_block_index = -1
973987
active_block_type: str | None = None # "text" or "tool_use"
974988
tool_block_map: dict[int, int] = {} # litellm tc.index → SSE block index
975989
stop_reason = "end_turn"
990+
# Populated from the final usage chunk (stream_options.include_usage=True
991+
# above). The message_start emitted before this loop always carries
992+
# input_tokens=0 and no cache fields because LiteLLM/Bedrock only reports
993+
# usage on the trailing chunk. Carry the final cache stats on the terminal
994+
# message_delta instead of emitting a second protocol-invalid
995+
# message_start after content has already streamed.
996+
final_input_tokens = 0
997+
final_cache_read_tokens = 0
998+
final_cache_write_tokens = 0
976999

9771000
async for chunk in response:
1001+
if hasattr(chunk, "usage") and chunk.usage:
1002+
cu = chunk.usage
1003+
final_input_tokens = int(getattr(cu, "prompt_tokens", 0) or 0)
1004+
final_cache_read_tokens = int(getattr(cu, "cache_read_input_tokens", 0) or 0)
1005+
final_cache_write_tokens = int(
1006+
getattr(cu, "cache_creation_input_tokens", 0) or 0
1007+
)
1008+
9781009
if not hasattr(chunk, "choices") or not chunk.choices:
9791010
continue
9801011

@@ -1080,13 +1111,21 @@ async def stream_message(
10801111
data={"type": "content_block_stop", "index": current_block_index},
10811112
)
10821113

1114+
delta_usage: dict[str, Any] = {"output_tokens": output_tokens}
1115+
if final_input_tokens or final_cache_read_tokens or final_cache_write_tokens:
1116+
delta_usage["input_tokens"] = final_input_tokens
1117+
if final_cache_read_tokens:
1118+
delta_usage["cache_read_input_tokens"] = final_cache_read_tokens
1119+
if final_cache_write_tokens:
1120+
delta_usage["cache_creation_input_tokens"] = final_cache_write_tokens
1121+
10831122
# Emit message_delta with correct stop reason
10841123
yield StreamEvent(
10851124
event_type="message_delta",
10861125
data={
10871126
"type": "message_delta",
10881127
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
1089-
"usage": {"output_tokens": output_tokens},
1128+
"usage": delta_usage,
10901129
},
10911130
)
10921131

headroom/cache/compression_cache.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,29 @@ def _is_tool_result_message(msg: dict) -> bool:
4040
return False
4141

4242

43+
def _extract_text_from_blocks(blocks: list) -> str | None:
44+
"""Extract joined text from a list-of-blocks content (e.g. Anthropic list-of-text-blocks).
45+
46+
Modern Claude Code sends ``tool_result`` content as a list of typed
47+
blocks (``[{"type": "text", "text": "..."}]``) instead of a plain
48+
string. This helper extracts text from ``type == "text"`` blocks and
49+
joins them.
50+
"""
51+
texts = [b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text"]
52+
return "\n".join(t for t in texts if t != "") or None
53+
54+
4355
def _extract_tool_result_content(msg: dict) -> str | None:
4456
"""Extract text content from a tool result message (both formats)."""
4557
# OpenAI format
4658
if msg.get("role") == "tool":
4759
content = msg.get("content")
48-
return content if isinstance(content, str) else None
60+
if isinstance(content, str):
61+
return content
62+
# OpenAI content can also be a list of content parts
63+
if isinstance(content, list):
64+
return _extract_text_from_blocks(content)
65+
return None
4966
# Anthropic format
5067
content = msg.get("content")
5168
if isinstance(content, list):
@@ -54,6 +71,8 @@ def _extract_tool_result_content(msg: dict) -> str | None:
5471
inner = block.get("content")
5572
if isinstance(inner, str):
5673
return inner
74+
if isinstance(inner, list):
75+
return _extract_text_from_blocks(inner)
5776
return None
5877

5978

@@ -69,7 +88,16 @@ def _swap_tool_result_content(msg: dict, new_content: str) -> dict:
6988
if isinstance(content, list):
7089
for block in content:
7190
if isinstance(block, dict) and block.get("type") == "tool_result":
72-
block["content"] = new_content
91+
inner = block.get("content")
92+
if isinstance(inner, list):
93+
# Collapse list-of-blocks to a single text block.
94+
# The compressed content is a single string; preserving
95+
# multiple text blocks would produce a different joined
96+
# output on re-extraction (first text block replaced,
97+
# remaining text blocks still joined).
98+
block["content"] = [{"type": "text", "text": new_content}]
99+
else:
100+
block["content"] = new_content
73101
break
74102
return new_msg
75103

headroom/ccr/response_handler.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@
3030

3131
logger = logging.getLogger(__name__)
3232

33+
# Residual-CCR status signals (provider-generic).
34+
#
35+
# ``handle_response`` may return a response that still contains
36+
# ``headroom_retrieve`` tool calls. Callers need to know *why* so they can
37+
# decide whether that is a safe passthrough or a genuine failure:
38+
#
39+
# - RESIDUAL_CCR_RESOLVED: no CCR tool calls remain — fully handled.
40+
# - RESIDUAL_CCR_SKIPPED_MIXED: CCR was intentionally skipped because the model
41+
# emitted headroom_retrieve alongside a non-CCR
42+
# client tool (#839). The client must resolve both
43+
# tool calls; the proxy must pass the turn through
44+
# unchanged (200), not fail closed.
45+
# - RESIDUAL_CCR_ERROR: CCR tool calls remain with no accompanying client
46+
# tool — i.e. a real conversion/handling failure the
47+
# proxy could not resolve. Callers should fail closed.
48+
RESIDUAL_CCR_RESOLVED = "resolved"
49+
RESIDUAL_CCR_SKIPPED_MIXED = "skipped_mixed_tools"
50+
RESIDUAL_CCR_ERROR = "error"
51+
3352

3453
@dataclass
3554
class CCRToolResult:
@@ -110,6 +129,33 @@ def has_ccr_tool_calls(
110129
"""
111130
return has_ccr_tool_calls(response, provider)
112131

132+
def residual_ccr_status(
133+
self,
134+
response: dict[str, Any],
135+
provider: str = "anthropic",
136+
) -> str:
137+
"""Classify why (if at all) CCR tool calls remain in a handled response.
138+
139+
This is a stateless, provider-generic signal derived from the same
140+
parsing ``handle_response`` uses, so it stays correct under concurrency
141+
and works identically for every provider/harness.
142+
143+
Returns one of:
144+
- ``RESIDUAL_CCR_RESOLVED``: no headroom_retrieve tool calls remain.
145+
- ``RESIDUAL_CCR_SKIPPED_MIXED``: headroom_retrieve remains *alongside*
146+
a non-CCR client tool call. This is an intentional skip (#839) — the
147+
proxy cannot synthesize the client tool_result, so the turn must be
148+
handed back to the client unchanged rather than failed closed.
149+
- ``RESIDUAL_CCR_ERROR``: headroom_retrieve remains with no accompanying
150+
client tool call — a genuine handling/conversion failure.
151+
"""
152+
ccr_calls, other_calls = self._parse_ccr_tool_calls(response, provider)
153+
if not ccr_calls:
154+
return RESIDUAL_CCR_RESOLVED
155+
if other_calls:
156+
return RESIDUAL_CCR_SKIPPED_MIXED
157+
return RESIDUAL_CCR_ERROR
158+
113159
def _extract_tool_calls(
114160
self,
115161
response: dict[str, Any],

headroom/ccr/tool_injection.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,14 @@ class CCRToolInjector:
199199
# `<<ccr:HASH,KIND,SIZE>>`. HASH is 12-24 hex chars, terminated by a
200200
# space, comma, or the closing `>>`.
201201
re.compile(r"<<ccr:([a-f0-9]{12,24})\b"),
202+
# read_lifecycle STALE/SUPERSEDED markers:
203+
# `[Read content stale/superseded: ... Retrieve original: hash=xxx]`.
204+
# These carry a retrievable CCR hash but never contain the word
205+
# "compressed", so the patterns above miss them -- and the retrieve
206+
# tool is then not injected, leaving the model a marker it cannot
207+
# redeem (silent data loss, #1006). Match the load-bearing
208+
# "Retrieve original: hash=" phrase directly.
209+
re.compile(r"Retrieve original: hash=([a-f0-9]{12,24})"),
202210
]
203211
)
204212

headroom/memory/adapters/hnsw.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,16 @@ async def index_batch(self, memories: list[Memory]) -> int:
458458
new_memories.append((memory, embedding, hnsw_id))
459459
self._next_hnsw_id += 1
460460

461-
# Resize if needed
462-
required_capacity = len(self._memory_to_hnsw) + len(new_memories)
461+
# Resize if needed. hnswlib never frees a slot on mark_deleted
462+
# (remove/evict), so its capacity is bounded by the high-water mark
463+
# of assigned ids (_next_hnsw_id, already incremented for the new
464+
# memories above), NOT the live entry count. After deletions or
465+
# evictions the live count is well below _next_hnsw_id, so keying the
466+
# resize off `len(self._memory_to_hnsw)` under-provisions and the
467+
# add_items below raises "number of elements exceeds the specified
468+
# limit". This mirrors the single-item index() guard, which resizes
469+
# off _next_hnsw_id.
470+
required_capacity = self._next_hnsw_id
463471
if required_capacity > self._max_elements:
464472
new_max = max(self._max_elements * 2, required_capacity + 1000)
465473
self._resize_index(new_max)

0 commit comments

Comments
 (0)