Skip to content

Commit c7834cf

Browse files
Ashish-dwi99claude
andcommitted
Router + hooks hardening: production-grade fixes from user-simulation stress test
- handle_dhee_read: coerce offset/limit, return error dict on non-numeric input instead of raising ValueError up the MCP stack. - Rename digest tags <dhee:read>/<dhee:bash>/<dhee:agent> to underscore variants. Colon-prefixed names require a namespace declaration; any downstream XML parse chokes on them (same class as the <dhee:ctx> fix). - Scrub internal degradation warnings at the assembler boundary before they reach the renderer. Prevents 'Context assembly degraded: ...' error strings (including API-key-laden 401 bodies) from burning tokens and leaking into every injected context. - PreToolUse heavy-pattern gate: strip quoted regions before regex scan so echo 'git log stuff' is no longer denied. Real git log and piped variants still deny. Adds 15 regression tests covering argument validation, digest XML safety, warning scrub, and quote-safety in the enforcement gate. 1098 pass, 10 skipped, 0 failed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b6ec410 commit c7834cf

10 files changed

Lines changed: 1784 additions & 18 deletions

File tree

dhee/hooks/claude_code/assembler.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
only the chunks relevant to THIS specific prompt and assembles them into
66
a token-budgeted injection.
77
8-
The economics:
9-
CLAUDE.md is ~2000 tokens. Over a 20-turn conversation, that's 40K
10-
input tokens of mostly-irrelevant context. If Dhee injects ~200 tokens
11-
of relevant chunks per turn, that's 4K tokens — 10x savings on the
12-
costliest model (Opus).
13-
14-
Even with a Dhee-side embedding call for retrieval (~$0.0001), the
15-
savings on Opus input ($0.015/1K tokens) yield >100x ROI.
8+
The economics (revised 2026-04-17):
9+
Phase 0 audit (`dhee/benchmarks/phase0_context_audit.py`) parsed real
10+
Anthropic `usage` fields across 14 sessions / 4,680 assistant turns.
11+
CLAUDE.md first-turn cost bundled with system prompt + skill catalog
12+
+ tool schemas is ~5–8K tokens — a rounding error, not the fat. The
13+
actual fat is **tool-result accumulation** (~57% of growing content)
14+
and tool-use inputs (~31%). Top 10% of turns account for 75% of new
15+
input.
16+
17+
This assembler therefore does relevance-filtering on *docs* (a small
18+
marginal win), not the primary win. The primary router lever is
19+
`dhee.router` — digest-at-source MCP wrappers (`dhee_read`, etc.)
20+
that keep raw tool output out of the context entirely.
1621
1722
The assembler is a pure selection pipeline:
1823
query → vector search → filter(kind, score) → budget → render
@@ -110,7 +115,7 @@ def assemble(
110115
user_id=os.environ.get("DHEE_USER_ID", "default"),
111116
)
112117
if isinstance(ctx, dict):
113-
typed = ctx
118+
typed = _strip_internal_warnings(ctx)
114119
except Exception:
115120
pass
116121

@@ -121,6 +126,32 @@ def assemble(
121126
)
122127

123128

129+
# Degradation messages come from internal error paths (e.g. embedder 401s,
130+
# store read failures). They're meant for logs and telemetry, not for the
131+
# LLM — surfacing them wastes tokens and can leak error-string content
132+
# (headers, stack traces, model names). Stripped here so the renderer
133+
# never sees them.
134+
_INTERNAL_WARNING_PREFIXES = (
135+
"Context assembly degraded:",
136+
"Cognitive state degraded:",
137+
)
138+
139+
140+
def _strip_internal_warnings(ctx: dict[str, Any]) -> dict[str, Any]:
141+
warnings = ctx.get("warnings")
142+
if not isinstance(warnings, list):
143+
return ctx
144+
visible = [
145+
w for w in warnings
146+
if isinstance(w, str) and not w.startswith(_INTERNAL_WARNING_PREFIXES)
147+
]
148+
if len(visible) == len(warnings):
149+
return ctx
150+
scrubbed = dict(ctx)
151+
scrubbed["warnings"] = visible
152+
return scrubbed
153+
154+
124155
def assemble_docs_only(
125156
dhee: Any,
126157
query: str,

dhee/hooks/claude_code/renderer.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@
88
Token philosophy (Caveman-inspired): drop structural fluff, keep
99
technical substance exact. No indentation, short tags, no wrapper
1010
nesting, no redundant metadata. Every byte earns its place.
11+
12+
Phase 5 (2026-04-17): root is ``<dhee v="1">`` with typed
13+
sections. Doc chunks carry ``src``/``head``/``s`` so the model knows
14+
the origin, not just a weighted fragment. ``<edits>`` is a first-class
15+
section, fed in from the edit ledger. Schema stays terse — JSON-heavy
16+
envelopes were rejected as more expensive than the caveman tags.
1117
"""
1218

1319
from __future__ import annotations
1420

21+
import os
1522
from typing import Any
1623
from xml.sax.saxutils import escape as _xml_escape
1724

@@ -28,12 +35,15 @@ def render_context(
2835
max_insights: int = 5,
2936
max_intentions: int = 3,
3037
doc_matches: list | None = None,
38+
edits_block: str | None = None,
3139
) -> str:
3240
"""Render Dhee context dict as flat XML for Claude Code injection.
3341
3442
Returns empty string when nothing to inject.
3543
"""
3644
sections: list[tuple[int, list[str]]] = [
45+
(120, _router_block()),
46+
(115, _edits_section(edits_block)),
3747
(110, _docs_block(doc_matches)),
3848
(100, _session_block(ctx.get("last_session"))),
3949
(90, _performance_block(ctx.get("performance", []))),
@@ -55,6 +65,7 @@ def render_context(
5565
attrs = ""
5666
if task_description:
5767
attrs = f' task="{_esc_attr(task_description[:120])}"'
68+
attrs += ' v="1"'
5869

5970
open_tag = f"<dhee{attrs}>"
6071
close_tag = "</dhee>"
@@ -88,22 +99,50 @@ def estimate_tokens(text: str) -> int:
8899
# ---------------------------------------------------------------------------
89100

90101

102+
_ROUTER_NUDGE = (
103+
"router=on. Prefer mcp__dhee__dhee_read over Read for files >200 lines "
104+
"(pass offset/limit for ranges). Prefer mcp__dhee__dhee_bash over Bash "
105+
"for commands likely >2KB output (git log/diff, pytest, find, grep, "
106+
"ls -R). After a subagent/large tool return, pass the text through "
107+
"mcp__dhee__dhee_agent to keep raw out of context. Call "
108+
"mcp__dhee__dhee_expand_result(ptr) only when a digest is genuinely "
109+
"insufficient — raw re-enters context."
110+
)
111+
112+
113+
def _router_block() -> list[str]:
114+
"""One-time router nudge. Only rendered when DHEE_ROUTER=1."""
115+
if os.environ.get("DHEE_ROUTER") != "1":
116+
return []
117+
return [f"<router>{_xml_escape(_ROUTER_NUDGE)}</router>"]
118+
119+
91120
def _docs_block(doc_matches: list | None) -> list[str]:
92121
if not doc_matches:
93122
return []
94123
items: list[str] = []
95124
for m in doc_matches:
96-
path = getattr(m, "heading_breadcrumb", "") or ""
125+
head = getattr(m, "heading_breadcrumb", "") or ""
126+
src = getattr(m, "source_name", "") or ""
97127
score = getattr(m, "score", 0.0)
98128
text = getattr(m, "text", "")
99129
if not text:
100130
continue
101-
if path and text.startswith(path):
102-
text = text[len(path):].lstrip("\n")
103-
items.append(_tag("r", _score_attr(score), text))
131+
if head and text.startswith(head):
132+
text = text[len(head):].lstrip("\n")
133+
attrs = _attrs(src=src, head=head)
134+
score_attr = _score_attr(score)
135+
a = f"{attrs} {score_attr}" if attrs else score_attr
136+
items.append(_tag("doc", a, text))
104137
return items
105138

106139

140+
def _edits_section(edits_block: str | None) -> list[str]:
141+
if not edits_block:
142+
return []
143+
return [edits_block]
144+
145+
107146
def _session_block(session: dict[str, Any] | None) -> list[str]:
108147
if not session or not isinstance(session, dict):
109148
return []

dhee/router/agent_digest.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Digest for subagent / long-text tool returns.
2+
3+
`dhee_agent` accepts a text blob (typically a subagent's final message)
4+
and returns a factual, compact digest: which files were referenced,
5+
bulleted findings, any error indicators, and head/tail excerpts. Raw is
6+
stored behind a ptr. Honest about what was extracted — never invents
7+
references.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import re
13+
from dataclasses import dataclass, field
14+
15+
16+
CHARS_PER_TOKEN = 3.5
17+
18+
# file:line patterns — matches path/to/file.py:123 (optionally :col)
19+
_FILE_LINE_RE = re.compile(
20+
r"(?<![\w/])" # not preceded by word/slash
21+
r"([A-Za-z0-9_./\-]+\.[A-Za-z0-9]{1,8})" # path with extension
22+
r":(\d+)" # :lineno
23+
r"(?::\d+)?" # optional :col
24+
)
25+
26+
_BULLET_RE = re.compile(r"^\s*(?:[-*+]|\d+\.)\s+(.+)$", re.MULTILINE)
27+
28+
_ERROR_RE = re.compile(
29+
r"\b(error|exception|failed|failure|traceback|fatal)\b",
30+
re.IGNORECASE,
31+
)
32+
33+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$", re.MULTILINE)
34+
35+
36+
@dataclass
37+
class AgentDigest:
38+
char_count: int
39+
line_count: int
40+
est_tokens: int
41+
kind: str
42+
file_refs: list[str] = field(default_factory=list)
43+
headings: list[str] = field(default_factory=list)
44+
bullets: list[str] = field(default_factory=list)
45+
error_hits: int = 0
46+
head: str = ""
47+
tail: str = ""
48+
notes: list[str] = field(default_factory=list)
49+
50+
def render(self, ptr: str) -> str:
51+
lines: list[str] = [f'<dhee_agent ptr="{ptr}">']
52+
lines.append(
53+
f"size={self.line_count} lines, {self.char_count} chars, "
54+
f"~{self.est_tokens} tokens"
55+
)
56+
lines.append(f"kind={self.kind}")
57+
if self.headings:
58+
lines.append("headings:")
59+
for h in self.headings[:8]:
60+
lines.append(f" {h}")
61+
if len(self.headings) > 8:
62+
lines.append(f" (+{len(self.headings)-8} more)")
63+
if self.file_refs:
64+
shown = self.file_refs[:15]
65+
lines.append("file_refs:")
66+
for r in shown:
67+
lines.append(f" {r}")
68+
if len(self.file_refs) > 15:
69+
lines.append(f" (+{len(self.file_refs)-15} more)")
70+
if self.bullets:
71+
lines.append("bullets:")
72+
for b in self.bullets[:10]:
73+
lines.append(f" - {b}")
74+
if len(self.bullets) > 10:
75+
lines.append(f" (+{len(self.bullets)-10} more)")
76+
if self.error_hits:
77+
lines.append(f"error_signals={self.error_hits}")
78+
if self.head:
79+
lines.append("head:")
80+
for hl in self.head.splitlines()[:6]:
81+
lines.append(f" {hl}")
82+
if self.tail:
83+
lines.append("tail:")
84+
for tl in self.tail.splitlines()[-4:]:
85+
lines.append(f" {tl}")
86+
for n in self.notes:
87+
lines.append(f"note: {n}")
88+
lines.append(f'(expand: dhee_expand_result(ptr="{ptr}"))')
89+
lines.append("</dhee_agent>")
90+
return "\n".join(lines)
91+
92+
93+
def _head_tail(text: str, head_lines: int = 6, tail_lines: int = 4) -> tuple[str, str]:
94+
lines = text.splitlines()
95+
if len(lines) <= head_lines + tail_lines:
96+
return text, ""
97+
return "\n".join(lines[:head_lines]), "\n".join(lines[-tail_lines:])
98+
99+
100+
def digest_agent(text: str, *, kind: str | None = None) -> AgentDigest:
101+
"""Build an AgentDigest from an arbitrary text blob."""
102+
char_count = len(text)
103+
line_count = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
104+
if text == "":
105+
line_count = 0
106+
107+
# file:line refs — de-dupe, preserve order
108+
seen: set[str] = set()
109+
file_refs: list[str] = []
110+
for m in _FILE_LINE_RE.finditer(text):
111+
ref = f"{m.group(1)}:{m.group(2)}"
112+
if ref in seen:
113+
continue
114+
seen.add(ref)
115+
file_refs.append(ref)
116+
117+
headings = [
118+
f"{'#' * len(m.group(1))} {m.group(2)}"
119+
for m in _HEADING_RE.finditer(text)
120+
]
121+
122+
bullets: list[str] = []
123+
for m in _BULLET_RE.finditer(text):
124+
b = m.group(1).strip()
125+
if b and len(b) <= 200:
126+
bullets.append(b)
127+
128+
error_hits = len(_ERROR_RE.findall(text))
129+
130+
head, tail = _head_tail(text)
131+
132+
# Auto-classify kind if not given.
133+
if kind is None:
134+
if "```" in text and file_refs:
135+
kind = "code-review"
136+
elif error_hits and file_refs:
137+
kind = "error-report"
138+
elif headings:
139+
kind = "structured-summary"
140+
elif file_refs:
141+
kind = "code-survey"
142+
elif bullets:
143+
kind = "bulleted-findings"
144+
else:
145+
kind = "prose"
146+
147+
return AgentDigest(
148+
char_count=char_count,
149+
line_count=line_count,
150+
est_tokens=int(char_count / CHARS_PER_TOKEN),
151+
kind=kind,
152+
file_refs=file_refs,
153+
headings=headings,
154+
bullets=bullets,
155+
error_hits=error_hits,
156+
head=head,
157+
tail=tail,
158+
)

0 commit comments

Comments
 (0)