From d7122d6bab0bf6e05ea70104cfd5cdcc8db293be Mon Sep 17 00:00:00 2001 From: chopratejas Date: Wed, 8 Apr 2026 11:28:23 -0700 Subject: [PATCH] Fix Strands SDK tool pairing, CCR marker format, and startup timeout (#114 #116 #117) - Increase headroom wrap startup timeout from 15s to 45s (#114) ML components (Kompress, Magika, Tree-sitter) need 20-30s on slower machines - Detect Strands SDK toolUse/toolResult blocks in find_tool_units (#116) Strands wraps tools as {"toolUse": {"toolUseId": ...}} not {"type": "tool_use"} Without this, tool pairs aren't grouped and dropping one breaks Anthropic validation - Match CCR marker content format to conversation style (#117) Strands expects list-of-blocks content, not plain strings Detect format from existing messages and match it --- headroom/cli/wrap.py | 8 +++--- headroom/parser.py | 30 ++++++++++++++++------ headroom/transforms/intelligent_context.py | 14 +++++++++- headroom/transforms/rolling_window.py | 11 +++++++- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 128db09f6..c5e642595 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -108,8 +108,10 @@ def _start_proxy( env=proxy_env, ) - # Wait for proxy to be ready (up to 15 seconds) - for _i in range(15): + # Wait for proxy to be ready (up to 45 seconds). + # ML components (Kompress, Magika, Tree-sitter) load synchronously before + # uvicorn binds the port. On slower machines this can take 20-30 seconds. + for _i in range(45): time.sleep(1) if _check_proxy(port): click.echo(f" Logs: {log_path}") @@ -126,7 +128,7 @@ def _start_proxy( proc.kill() log_file.close() - raise RuntimeError(f"Proxy failed to start on port {port} within 15 seconds") + raise RuntimeError(f"Proxy failed to start on port {port} within 45 seconds") def _setup_rtk(verbose: bool = False) -> Path | None: diff --git a/headroom/parser.py b/headroom/parser.py index 970693547..d6fe65515 100644 --- a/headroom/parser.py +++ b/headroom/parser.py @@ -267,14 +267,21 @@ def find_tool_units(messages: list[dict[str, Any]]) -> list[tuple[int, list[int] tool_response_map[tc_id] = i # Anthropic format: role="user" with content blocks containing tool_result + # Also handles Strands SDK format: {"toolResult": {"toolUseId": "..."}} if msg.get("role") == "user": content = msg.get("content") if isinstance(content, list): for block in content: - if isinstance(block, dict) and block.get("type") == "tool_result": - tc_id = block.get("tool_use_id") - if tc_id: - tool_response_map[tc_id] = i + if isinstance(block, dict): + if block.get("type") == "tool_result": + tc_id = block.get("tool_use_id") + if tc_id: + tool_response_map[tc_id] = i + elif "toolResult" in block: + # Strands SDK format + tc_id = block["toolResult"].get("toolUseId") + if tc_id: + tool_response_map[tc_id] = i # Find assistant messages with tool calls for i, msg in enumerate(messages): @@ -292,13 +299,20 @@ def find_tool_units(messages: list[dict[str, Any]]) -> list[tuple[int, list[int] response_indices.append(tool_response_map[tc_id]) # Anthropic format: content blocks with type=tool_use + # Also handles Strands SDK format: {"toolUse": {"toolUseId": "..."}} content = msg.get("content") if isinstance(content, list): for block in content: - if isinstance(block, dict) and block.get("type") == "tool_use": - tc_id = block.get("id") - if tc_id and tc_id in tool_response_map: - response_indices.append(tool_response_map[tc_id]) + if isinstance(block, dict): + if block.get("type") == "tool_use": + tc_id = block.get("id") + if tc_id and tc_id in tool_response_map: + response_indices.append(tool_response_map[tc_id]) + elif "toolUse" in block: + # Strands SDK format + tc_id = block["toolUse"].get("toolUseId") + if tc_id and tc_id in tool_response_map: + response_indices.append(tool_response_map[tc_id]) if response_indices: # Use set to deduplicate in case same message has both formats diff --git a/headroom/transforms/intelligent_context.py b/headroom/transforms/intelligent_context.py index 281f676ad..1e33b48f9 100644 --- a/headroom/transforms/intelligent_context.py +++ b/headroom/transforms/intelligent_context.py @@ -449,9 +449,21 @@ class IntelligentContextManager(Transform): else: insert_idx = len(result_messages) + # Match the content format of existing messages. + # Strands SDK uses list-of-blocks: [{"text": "..."}] + # Anthropic/OpenAI use plain strings. + # Check what format the conversation uses and match it. + _uses_block_format = any( + isinstance(m.get("content"), list) + for m in result_messages + if m.get("role") == "user" + ) + marker_content: str | list[dict[str, str]] = ( + [{"type": "text", "text": marker}] if _uses_block_format else marker + ) result_messages.insert( insert_idx, - {"role": "user", "content": marker}, + {"role": "user", "content": marker_content}, ) transforms_applied.append(f"intelligent_cap:{dropped_count}") diff --git a/headroom/transforms/rolling_window.py b/headroom/transforms/rolling_window.py index f65bd00a4..9ae939885 100644 --- a/headroom/transforms/rolling_window.py +++ b/headroom/transforms/rolling_window.py @@ -175,11 +175,20 @@ class RollingWindow(Transform): else: insert_idx = len(result_messages) + # Match content format (Strands uses list-of-blocks, Anthropic uses string) + _uses_block_format = any( + isinstance(m.get("content"), list) + for m in result_messages + if m.get("role") == "user" + ) + marker_content: str | list[dict[str, str]] = ( + [{"type": "text", "text": marker}] if _uses_block_format else marker + ) result_messages.insert( insert_idx, { "role": "user", - "content": marker, + "content": marker_content, }, )