hx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hx/__init__.py +5 -0
- hx/agents/__init__.py +1 -0
- hx/agents/definitions.py +106 -0
- hx/agents/subagent.py +190 -0
- hx/cli.py +667 -0
- hx/config.py +277 -0
- hx/core/__init__.py +1 -0
- hx/core/compaction.py +245 -0
- hx/core/context.py +271 -0
- hx/core/events.py +183 -0
- hx/core/lateinject.py +121 -0
- hx/core/loop.py +537 -0
- hx/core/messages.py +164 -0
- hx/core/session.py +208 -0
- hx/core/usage.py +129 -0
- hx/frontmatter.py +80 -0
- hx/mcp/__init__.py +1 -0
- hx/mcp/client.py +319 -0
- hx/mcp/manager.py +265 -0
- hx/paths.py +90 -0
- hx/permissions/__init__.py +7 -0
- hx/permissions/engine.py +406 -0
- hx/permissions/parser.py +306 -0
- hx/permissions/sandbox.py +227 -0
- hx/providers/__init__.py +1 -0
- hx/providers/base.py +77 -0
- hx/providers/fake.py +87 -0
- hx/providers/models.py +238 -0
- hx/providers/openrouter.py +468 -0
- hx/skills/__init__.py +1 -0
- hx/skills/loader.py +102 -0
- hx/skills/runtime.py +84 -0
- hx/tools/__init__.py +1 -0
- hx/tools/base.py +97 -0
- hx/tools/bash.py +544 -0
- hx/tools/edit.py +167 -0
- hx/tools/glob.py +75 -0
- hx/tools/grep.py +165 -0
- hx/tools/output.py +133 -0
- hx/tools/read.py +142 -0
- hx/tools/registry.py +149 -0
- hx/tools/task.py +76 -0
- hx/tools/todo.py +149 -0
- hx/tools/write.py +87 -0
- hx/tui/__init__.py +1 -0
- hx/tui/app.py +487 -0
- hx/tui/commands.py +399 -0
- hx/tui/hx.tcss +197 -0
- hx/tui/renderers.py +570 -0
- hx/tui/theme.py +322 -0
- hx/tui/widgets/__init__.py +1 -0
- hx/tui/widgets/configure.py +95 -0
- hx/tui/widgets/diff.py +25 -0
- hx/tui/widgets/input.py +145 -0
- hx/tui/widgets/palette.py +130 -0
- hx/tui/widgets/permission.py +97 -0
- hx/tui/widgets/statusbar.py +212 -0
- hx/tui/widgets/todos.py +116 -0
- hx/tui/widgets/transcript.py +316 -0
- hx/tui/widgets/working.py +78 -0
- hx_cli-0.1.0.dist-info/METADATA +430 -0
- hx_cli-0.1.0.dist-info/RECORD +64 -0
- hx_cli-0.1.0.dist-info/WHEEL +4 -0
- hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
hx/core/context.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Context assembly and cache-breakpoint placement.
|
|
2
|
+
|
|
3
|
+
This module owns the single most performance-sensitive invariant in HX:
|
|
4
|
+
**the request prefix must be byte-stable across turns.** Providers key their KV
|
|
5
|
+
cache on an exact prefix match, so any reordering, timestamp, or per-turn
|
|
6
|
+
counter placed above a breakpoint silently costs full price on every request.
|
|
7
|
+
|
|
8
|
+
Layout, in order::
|
|
9
|
+
|
|
10
|
+
[1] system prompt static for the session
|
|
11
|
+
[2] tool schemas deterministic sort: builtins, then mcp__* alphabetical
|
|
12
|
+
[3] skills index name + description only (progressive disclosure)
|
|
13
|
+
[4] project context HX.md, cwd, git branch, top-level listing
|
|
14
|
+
--- breakpoint A (static) ---
|
|
15
|
+
[5] conversation history
|
|
16
|
+
--- breakpoint B (rolling, before the last few turns) ---
|
|
17
|
+
[6] latest user turn + late-injected ephemeral blocks
|
|
18
|
+
|
|
19
|
+
Volatile data belongs below breakpoint B, via ``hx.core.lateinject``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import subprocess
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, ClassVar
|
|
30
|
+
|
|
31
|
+
from hx.core.messages import Message
|
|
32
|
+
|
|
33
|
+
MAX_CACHE_BREAKPOINTS = 4
|
|
34
|
+
"""Anthropic's per-request limit on explicit ``cache_control`` markers."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class PromptSection:
|
|
39
|
+
"""One addressable chunk of the prefix, tracked so ``/context`` can show a breakdown."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
text: str
|
|
43
|
+
tokens: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class AssembledContext:
|
|
48
|
+
"""The exact payload handed to a provider, plus the accounting behind it.
|
|
49
|
+
|
|
50
|
+
``messages`` holds the conversation only. The provider prepends the system
|
|
51
|
+
message when serialising, so ``breakpoints`` index the *payload* list where
|
|
52
|
+
position 0 is that system message.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
system: list[PromptSection]
|
|
56
|
+
messages: list[Message]
|
|
57
|
+
tools: list[dict[str, Any]]
|
|
58
|
+
breakpoints: tuple[int, ...] = ()
|
|
59
|
+
"""Indices into the payload message list that carry an explicit cache
|
|
60
|
+
breakpoint. Empty for implicit-caching models."""
|
|
61
|
+
total_tokens: int = 0
|
|
62
|
+
sections: list[PromptSection] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
def system_text(self) -> str:
|
|
65
|
+
"""Concatenated system sections, in declared order."""
|
|
66
|
+
return "\n\n".join(section.text for section in self.system if section.text)
|
|
67
|
+
|
|
68
|
+
def prefix_fingerprint(self) -> str:
|
|
69
|
+
"""Stable hash of everything above the first breakpoint.
|
|
70
|
+
|
|
71
|
+
Tests assert this is unchanged across turns; a change means the cache
|
|
72
|
+
was invalidated.
|
|
73
|
+
"""
|
|
74
|
+
digest = hashlib.sha256()
|
|
75
|
+
digest.update(self.system_text().encode())
|
|
76
|
+
digest.update(b"\x00")
|
|
77
|
+
digest.update(json.dumps(self.tools, sort_keys=True, separators=(",", ":")).encode())
|
|
78
|
+
return digest.hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ContextBuilder:
|
|
82
|
+
"""Assembles :class:`AssembledContext` from session state."""
|
|
83
|
+
|
|
84
|
+
#: Estimated tokens that must accumulate below the rolling breakpoint before
|
|
85
|
+
#: it is allowed to move. Advancing it every turn rewrites the cache
|
|
86
|
+
#: constantly and costs more than the hit it buys.
|
|
87
|
+
BREAKPOINT_HYSTERESIS_TOKENS: ClassVar[int] = 4_096
|
|
88
|
+
CHARS_PER_TOKEN: ClassVar[float] = 3.7
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
system_prompt: str,
|
|
93
|
+
cwd: Path,
|
|
94
|
+
keep_recent_turns: int = 6,
|
|
95
|
+
) -> None:
|
|
96
|
+
self.system_prompt = system_prompt
|
|
97
|
+
self.cwd = cwd
|
|
98
|
+
self.keep_recent_turns = keep_recent_turns
|
|
99
|
+
self._breakpoint_b: int | None = None
|
|
100
|
+
self._tokens_below_b: int = 0
|
|
101
|
+
|
|
102
|
+
def build(
|
|
103
|
+
self,
|
|
104
|
+
messages: list[Message],
|
|
105
|
+
tools: list[dict[str, Any]],
|
|
106
|
+
skills_index: str | None = None,
|
|
107
|
+
project_context: str | None = None,
|
|
108
|
+
cache_mode: str = "explicit",
|
|
109
|
+
) -> AssembledContext:
|
|
110
|
+
"""Assemble the request.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
cache_mode: ``explicit`` (place ``cache_control`` markers),
|
|
114
|
+
``implicit`` (rely on prefix stability alone), or ``none``.
|
|
115
|
+
"""
|
|
116
|
+
system = [PromptSection("system", self.system_prompt)]
|
|
117
|
+
if skills_index:
|
|
118
|
+
system.append(PromptSection("skills", skills_index))
|
|
119
|
+
if project_context:
|
|
120
|
+
system.append(PromptSection("project", project_context))
|
|
121
|
+
for section in system:
|
|
122
|
+
section.tokens = self.estimate_tokens(section.text)
|
|
123
|
+
|
|
124
|
+
active = [m for m in messages if not m.compacted]
|
|
125
|
+
sorted_tools = sort_tools(tools)
|
|
126
|
+
|
|
127
|
+
breakpoints = self.place_breakpoints(active) if cache_mode == "explicit" else ()
|
|
128
|
+
|
|
129
|
+
sections = [
|
|
130
|
+
*system,
|
|
131
|
+
PromptSection(
|
|
132
|
+
"tools",
|
|
133
|
+
json.dumps(sorted_tools, sort_keys=True),
|
|
134
|
+
self.estimate_tokens(json.dumps(sorted_tools, sort_keys=True)),
|
|
135
|
+
),
|
|
136
|
+
PromptSection(
|
|
137
|
+
"history",
|
|
138
|
+
"",
|
|
139
|
+
sum(self.estimate_tokens(_message_text(m)) for m in active),
|
|
140
|
+
),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
return AssembledContext(
|
|
144
|
+
system=system,
|
|
145
|
+
messages=active,
|
|
146
|
+
tools=sorted_tools,
|
|
147
|
+
breakpoints=breakpoints,
|
|
148
|
+
total_tokens=sum(s.tokens for s in sections),
|
|
149
|
+
sections=sections,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def place_breakpoints(self, messages: list[Message]) -> tuple[int, ...]:
|
|
153
|
+
"""Choose breakpoint indices into the payload list (0 = system message).
|
|
154
|
+
|
|
155
|
+
Breakpoint A sits on the system message, at the end of the static prefix.
|
|
156
|
+
Breakpoint B rolls forward to just before the last ``keep_recent_turns``
|
|
157
|
+
messages, and only moves once enough tokens have accumulated below it -
|
|
158
|
+
see :attr:`BREAKPOINT_HYSTERESIS_TOKENS`.
|
|
159
|
+
"""
|
|
160
|
+
breakpoints = [0]
|
|
161
|
+
if len(messages) <= self.keep_recent_turns:
|
|
162
|
+
self._breakpoint_b = None
|
|
163
|
+
return tuple(breakpoints)
|
|
164
|
+
|
|
165
|
+
# +1 converts a conversation index into a payload index.
|
|
166
|
+
candidate = len(messages) - self.keep_recent_turns
|
|
167
|
+
if self._breakpoint_b is None:
|
|
168
|
+
self._breakpoint_b = candidate
|
|
169
|
+
else:
|
|
170
|
+
below = sum(
|
|
171
|
+
self.estimate_tokens(_message_text(m))
|
|
172
|
+
for m in messages[self._breakpoint_b : candidate]
|
|
173
|
+
)
|
|
174
|
+
if below >= self.BREAKPOINT_HYSTERESIS_TOKENS:
|
|
175
|
+
self._breakpoint_b = candidate
|
|
176
|
+
|
|
177
|
+
payload_index = self._breakpoint_b
|
|
178
|
+
if payload_index > 0:
|
|
179
|
+
breakpoints.append(payload_index)
|
|
180
|
+
return tuple(breakpoints[:MAX_CACHE_BREAKPOINTS])
|
|
181
|
+
|
|
182
|
+
def estimate_tokens(self, text: str) -> int:
|
|
183
|
+
"""Cheap local token estimate for gauges and the compaction trigger.
|
|
184
|
+
|
|
185
|
+
Deliberately approximate; the authoritative counts come back from the
|
|
186
|
+
provider in ``usage``.
|
|
187
|
+
"""
|
|
188
|
+
return int(len(text) / self.CHARS_PER_TOKEN) + 1 if text else 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def sort_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
192
|
+
"""Canonical tool ordering: builtins first, then ``mcp__*``, each by name.
|
|
193
|
+
|
|
194
|
+
The serialised tool block sits in the cached prefix, so the order must not
|
|
195
|
+
depend on registration order, MCP connection order, or dict iteration.
|
|
196
|
+
"""
|
|
197
|
+
return sorted(
|
|
198
|
+
tools, key=lambda t: (str(t.get("name", "")).startswith("mcp__"), t.get("name", ""))
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _message_text(message: Message) -> str:
|
|
203
|
+
parts: list[str] = []
|
|
204
|
+
for block in message.content:
|
|
205
|
+
text = getattr(block, "text", None) or getattr(block, "content", None)
|
|
206
|
+
if isinstance(text, str):
|
|
207
|
+
parts.append(text)
|
|
208
|
+
return "\n".join(parts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_project_context(cwd: Path) -> str:
|
|
212
|
+
"""Static per-session project preamble: HX.md contents, cwd, git branch, listing.
|
|
213
|
+
|
|
214
|
+
Computed once at startup and then frozen - refreshing it mid-session would
|
|
215
|
+
invalidate the prefix.
|
|
216
|
+
"""
|
|
217
|
+
lines = [f"Working directory: {cwd}"]
|
|
218
|
+
|
|
219
|
+
branch = git_branch(cwd)
|
|
220
|
+
if branch:
|
|
221
|
+
lines.append(f"Git branch: {branch}")
|
|
222
|
+
|
|
223
|
+
entries = sorted(
|
|
224
|
+
p.name + ("/" if p.is_dir() else "") for p in cwd.iterdir() if not p.name.startswith(".")
|
|
225
|
+
)
|
|
226
|
+
if entries:
|
|
227
|
+
lines.append("Top level: " + ", ".join(entries[:60]))
|
|
228
|
+
|
|
229
|
+
for name in ("HX.md", ".hx/HX.md"):
|
|
230
|
+
candidate = cwd / name
|
|
231
|
+
if candidate.is_file():
|
|
232
|
+
lines.append(f"\n# Project instructions ({name})\n\n{candidate.read_text()}")
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
return "\n".join(lines)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def git_branch(cwd: Path) -> str | None:
|
|
239
|
+
try:
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
242
|
+
cwd=cwd,
|
|
243
|
+
capture_output=True,
|
|
244
|
+
text=True,
|
|
245
|
+
timeout=2,
|
|
246
|
+
check=False,
|
|
247
|
+
)
|
|
248
|
+
except (OSError, subprocess.SubprocessError):
|
|
249
|
+
return None
|
|
250
|
+
return result.stdout.strip() or None if result.returncode == 0 else None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
SYSTEM_PROMPT = """\
|
|
254
|
+
You are HX, an agentic coding assistant running in the user's terminal.
|
|
255
|
+
|
|
256
|
+
Work directly on the user's codebase using the tools available to you. Prefer
|
|
257
|
+
reading the relevant code over guessing at it. Make the change that was asked
|
|
258
|
+
for - do not narrow it, widen it, or substitute a different one.
|
|
259
|
+
|
|
260
|
+
Be concise. The user is reading your output in a terminal, so favour short
|
|
261
|
+
direct answers and code over prose. Reference files as `path/to/file.py:42`.
|
|
262
|
+
|
|
263
|
+
When a task takes several steps, track them so the user can see the plan and
|
|
264
|
+
what remains. Report what actually happened: if a command failed, say so and
|
|
265
|
+
show the output.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def load_system_prompt(cwd: Path) -> str:
|
|
270
|
+
"""The base HX system prompt. Contains no volatile values (no clock, no counters)."""
|
|
271
|
+
return SYSTEM_PROMPT
|
hx/core/events.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Async event bus.
|
|
2
|
+
|
|
3
|
+
The agent core is a producer; the TUI and ``hx -p`` print mode are consumers.
|
|
4
|
+
Keeping this one-way means the core never imports Textual and can be driven
|
|
5
|
+
head-lessly in tests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, ClassVar
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class Event:
|
|
19
|
+
"""Base class for everything on the bus."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class TurnStarted(Event):
|
|
24
|
+
turn_index: int
|
|
25
|
+
model: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class TextDelta(Event):
|
|
30
|
+
"""Streaming assistant text."""
|
|
31
|
+
|
|
32
|
+
text: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ThinkingDelta(Event):
|
|
37
|
+
text: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class ToolCallStarted(Event):
|
|
42
|
+
tool_use_id: str
|
|
43
|
+
name: str
|
|
44
|
+
input: dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class ToolCallProgress(Event):
|
|
49
|
+
"""Incremental output (e.g. Bash stdout) shown live in the TUI, uncapped."""
|
|
50
|
+
|
|
51
|
+
tool_use_id: str
|
|
52
|
+
chunk: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class ToolCallFinished(Event):
|
|
57
|
+
tool_use_id: str
|
|
58
|
+
is_error: bool
|
|
59
|
+
duration_ms: float
|
|
60
|
+
summary: str
|
|
61
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
"""Structured detail the renderer draws with - an edit's diff, a command's
|
|
63
|
+
exit code. Presentation only: the model never sees it."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class PermissionRequested(Event):
|
|
68
|
+
"""Core is blocked until a consumer answers via the paired future."""
|
|
69
|
+
|
|
70
|
+
request_id: str
|
|
71
|
+
tool_name: str
|
|
72
|
+
description: str
|
|
73
|
+
detail: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class UsageUpdated(Event):
|
|
78
|
+
input_tokens: int
|
|
79
|
+
output_tokens: int
|
|
80
|
+
cache_read_tokens: int
|
|
81
|
+
cache_write_tokens: int
|
|
82
|
+
context_tokens: int
|
|
83
|
+
context_window: int
|
|
84
|
+
cost_usd: float
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class TodosUpdated(Event):
|
|
89
|
+
todos: list[dict[str, Any]] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class SubagentStarted(Event):
|
|
94
|
+
subagent_id: str
|
|
95
|
+
agent_type: str
|
|
96
|
+
description: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(slots=True)
|
|
100
|
+
class SubagentFinished(Event):
|
|
101
|
+
subagent_id: str
|
|
102
|
+
is_error: bool
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True)
|
|
106
|
+
class CompactionStarted(Event):
|
|
107
|
+
reason: str
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(slots=True)
|
|
111
|
+
class CompactionFinished(Event):
|
|
112
|
+
tokens_before: int
|
|
113
|
+
tokens_after: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(slots=True)
|
|
117
|
+
class TurnFinished(Event):
|
|
118
|
+
turn_index: int
|
|
119
|
+
stop_reason: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(slots=True)
|
|
123
|
+
class ErrorRaised(Event):
|
|
124
|
+
message: str
|
|
125
|
+
recoverable: bool = True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class EventBus:
|
|
129
|
+
"""Fan-out pub/sub over ``asyncio.Queue``.
|
|
130
|
+
|
|
131
|
+
Slow consumers must not stall the agent loop: each subscriber gets its own
|
|
132
|
+
bounded queue and drops coalescible deltas rather than applying backpressure.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
#: Event types safe to drop when a subscriber falls behind. Losing a text
|
|
136
|
+
#: delta degrades the render; losing a ToolCallFinished corrupts it.
|
|
137
|
+
COALESCIBLE: ClassVar[tuple[type[Event], ...]] = (
|
|
138
|
+
TextDelta,
|
|
139
|
+
ThinkingDelta,
|
|
140
|
+
ToolCallProgress,
|
|
141
|
+
UsageUpdated,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def __init__(self, max_queue: int = 1024) -> None:
|
|
145
|
+
self._max_queue = max_queue
|
|
146
|
+
self._queues: list[asyncio.Queue[Event | None]] = []
|
|
147
|
+
self._closed = False
|
|
148
|
+
|
|
149
|
+
def publish(self, event: Event) -> None:
|
|
150
|
+
"""Non-blocking. Safe to call from inside the agent loop."""
|
|
151
|
+
if self._closed:
|
|
152
|
+
return
|
|
153
|
+
for queue in self._queues:
|
|
154
|
+
try:
|
|
155
|
+
queue.put_nowait(event)
|
|
156
|
+
except asyncio.QueueFull:
|
|
157
|
+
if isinstance(event, self.COALESCIBLE):
|
|
158
|
+
continue
|
|
159
|
+
# Structural events must survive: evict the oldest item instead.
|
|
160
|
+
with suppress(asyncio.QueueEmpty):
|
|
161
|
+
queue.get_nowait()
|
|
162
|
+
with suppress(asyncio.QueueFull):
|
|
163
|
+
queue.put_nowait(event)
|
|
164
|
+
|
|
165
|
+
async def subscribe(self) -> AsyncIterator[Event]:
|
|
166
|
+
"""Yield events until :meth:`close` is called."""
|
|
167
|
+
queue: asyncio.Queue[Event | None] = asyncio.Queue(maxsize=self._max_queue)
|
|
168
|
+
self._queues.append(queue)
|
|
169
|
+
try:
|
|
170
|
+
while True:
|
|
171
|
+
event = await queue.get()
|
|
172
|
+
if event is None:
|
|
173
|
+
return
|
|
174
|
+
yield event
|
|
175
|
+
finally:
|
|
176
|
+
if queue in self._queues:
|
|
177
|
+
self._queues.remove(queue)
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
self._closed = True
|
|
181
|
+
for queue in self._queues:
|
|
182
|
+
with suppress(asyncio.QueueFull):
|
|
183
|
+
queue.put_nowait(None)
|
hx/core/lateinject.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Late injection of per-turn context.
|
|
2
|
+
|
|
3
|
+
Anything that changes every turn - todo state, files modified since last read,
|
|
4
|
+
plan-mode notices, permission-mode changes - must NOT live in the system prompt.
|
|
5
|
+
Mutating the prefix invalidates the provider KV cache on every single turn,
|
|
6
|
+
which is the single most expensive mistake this harness can make.
|
|
7
|
+
|
|
8
|
+
Instead, injectors emit ``<hx-reminder>`` blocks that are appended to the tail
|
|
9
|
+
of the newest user message and flagged ``ephemeral=True``. Before each turn the
|
|
10
|
+
previous ephemeral blocks are stripped and regenerated. Nothing above the
|
|
11
|
+
rolling cache breakpoint is ever rewritten.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, replace
|
|
19
|
+
|
|
20
|
+
from hx.core.messages import ContentBlock, Message, TextBlock
|
|
21
|
+
|
|
22
|
+
REMINDER_OPEN = "<hx-reminder>"
|
|
23
|
+
REMINDER_CLOSE = "</hx-reminder>"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class Injection:
|
|
28
|
+
"""One block of late-injected context."""
|
|
29
|
+
|
|
30
|
+
source: str
|
|
31
|
+
"""Injector name, for debugging via ``/context``."""
|
|
32
|
+
text: str
|
|
33
|
+
priority: int = 0
|
|
34
|
+
"""Higher priority renders closer to the end of the message."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Injector = Callable[[], Injection | None]
|
|
38
|
+
"""Returns the current injection, or ``None`` to contribute nothing this turn."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InjectionRegistry:
|
|
42
|
+
"""Holds the active injectors and applies them to a message list."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._injectors: dict[str, Injector] = {}
|
|
46
|
+
|
|
47
|
+
def register(self, name: str, injector: Injector) -> None:
|
|
48
|
+
self._injectors[name] = injector
|
|
49
|
+
|
|
50
|
+
def unregister(self, name: str) -> None:
|
|
51
|
+
self._injectors.pop(name, None)
|
|
52
|
+
|
|
53
|
+
def collect(self) -> list[Injection]:
|
|
54
|
+
"""Run every injector, drop ``None`` results, sort by priority then name
|
|
55
|
+
so output is deterministic (a reordered prefix is a cache miss)."""
|
|
56
|
+
collected: list[Injection] = []
|
|
57
|
+
for name in sorted(self._injectors):
|
|
58
|
+
try:
|
|
59
|
+
injection = self._injectors[name]()
|
|
60
|
+
except Exception:
|
|
61
|
+
# A broken injector must not kill the turn.
|
|
62
|
+
continue
|
|
63
|
+
if injection is not None and injection.text.strip():
|
|
64
|
+
collected.append(injection)
|
|
65
|
+
return sorted(collected, key=lambda i: (i.priority, i.source))
|
|
66
|
+
|
|
67
|
+
def apply(self, messages: list[Message]) -> list[Message]:
|
|
68
|
+
"""Return a copy of ``messages`` with stale ephemeral content stripped and
|
|
69
|
+
fresh injections appended to the final user message.
|
|
70
|
+
|
|
71
|
+
The input list is never mutated: the caller keeps the clean transcript
|
|
72
|
+
and only the assembled request carries injections.
|
|
73
|
+
"""
|
|
74
|
+
cleaned = [strip_injections(m) if m.role == "user" else m for m in messages]
|
|
75
|
+
|
|
76
|
+
injections = self.collect()
|
|
77
|
+
if not injections:
|
|
78
|
+
return cleaned
|
|
79
|
+
|
|
80
|
+
block = render(injections)
|
|
81
|
+
tail = cleaned[-1] if cleaned else None
|
|
82
|
+
if tail is not None and tail.role == "user":
|
|
83
|
+
content = [*tail.content, TextBlock(text="\n\n" + block)]
|
|
84
|
+
cleaned[-1] = replace(tail, content=content, ephemeral=True)
|
|
85
|
+
else:
|
|
86
|
+
# No trailing user turn to ride on - carry the reminders on their own
|
|
87
|
+
# ephemeral message rather than touching anything already cached.
|
|
88
|
+
cleaned.append(Message(role="user", content=[TextBlock(text=block)], ephemeral=True))
|
|
89
|
+
return cleaned
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def strip_injections(message: Message) -> Message:
|
|
93
|
+
"""Remove ``<hx-reminder>`` blocks from a message's text content."""
|
|
94
|
+
if REMINDER_OPEN not in _text_of(message):
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
content: list[ContentBlock] = []
|
|
98
|
+
for block in message.content:
|
|
99
|
+
if isinstance(block, TextBlock):
|
|
100
|
+
stripped = _REMINDER_RE.sub("", block.text).rstrip()
|
|
101
|
+
if stripped:
|
|
102
|
+
content.append(TextBlock(text=stripped))
|
|
103
|
+
else:
|
|
104
|
+
content.append(block)
|
|
105
|
+
return replace(message, content=content, ephemeral=False)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render(injections: list[Injection]) -> str:
|
|
109
|
+
"""Render injections into a single reminder-wrapped string."""
|
|
110
|
+
body = "\n\n".join(i.text.strip() for i in injections)
|
|
111
|
+
return f"{REMINDER_OPEN}\n{body}\n{REMINDER_CLOSE}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _text_of(message: Message) -> str:
|
|
115
|
+
return "".join(b.text for b in message.content if isinstance(b, TextBlock))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_REMINDER_RE = re.compile(
|
|
119
|
+
re.escape(REMINDER_OPEN) + r".*?" + re.escape(REMINDER_CLOSE),
|
|
120
|
+
re.DOTALL,
|
|
121
|
+
)
|