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/config.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Layered configuration.
|
|
2
|
+
|
|
3
|
+
Precedence, lowest to highest::
|
|
4
|
+
|
|
5
|
+
defaults < ~/.hx/settings.json < <cwd>/.hx/settings.json < environment < CLI flags
|
|
6
|
+
|
|
7
|
+
Settings are a plain dataclass so the whole config is hashable/serialisable and
|
|
8
|
+
can be diffed in tests. Permission rules are kept as raw strings here; parsing
|
|
9
|
+
into matchers is ``hx.permissions.engine``'s job.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from dataclasses import fields as dataclasses_fields
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from hx.paths import project_settings_file, user_settings_file
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermissionMode(StrEnum):
|
|
26
|
+
"""How aggressively HX asks before acting."""
|
|
27
|
+
|
|
28
|
+
PLAN = "plan"
|
|
29
|
+
"""Read-only. No writes, no command execution."""
|
|
30
|
+
|
|
31
|
+
DEFAULT = "default"
|
|
32
|
+
"""Ask before writes and command execution."""
|
|
33
|
+
|
|
34
|
+
ACCEPT_EDITS = "acceptEdits"
|
|
35
|
+
"""Auto-approve file edits; still ask for command execution."""
|
|
36
|
+
|
|
37
|
+
BYPASS = "bypass"
|
|
38
|
+
"""Approve everything. Sandbox still applies."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class PermissionSettings:
|
|
43
|
+
mode: PermissionMode = PermissionMode.DEFAULT
|
|
44
|
+
allow: tuple[str, ...] = ()
|
|
45
|
+
ask: tuple[str, ...] = ()
|
|
46
|
+
deny: tuple[str, ...] = ()
|
|
47
|
+
sandbox: bool = True
|
|
48
|
+
"""Use the OS sandbox when available (seatbelt / bubblewrap)."""
|
|
49
|
+
allow_network: bool = False
|
|
50
|
+
"""Permit outbound network from sandboxed commands."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ContextSettings:
|
|
55
|
+
compact_at: float = 0.80
|
|
56
|
+
"""Fraction of the model context window that triggers auto-compaction."""
|
|
57
|
+
keep_recent_turns: int = 6
|
|
58
|
+
"""Turns kept verbatim across a compaction."""
|
|
59
|
+
tool_output_char_cap: int = 25_000
|
|
60
|
+
tool_output_line_cap: int = 2_000
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class ModelSettings:
|
|
65
|
+
model: str = "anthropic/claude-sonnet-4.5"
|
|
66
|
+
subagent_model: str | None = None
|
|
67
|
+
"""Model used by subagents. Falls back to ``model``."""
|
|
68
|
+
max_tokens: int = 8192
|
|
69
|
+
temperature: float | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class BashSettings:
|
|
74
|
+
timeout_seconds: int = 120
|
|
75
|
+
max_timeout_seconds: int = 600
|
|
76
|
+
shell: str | None = None
|
|
77
|
+
"""Override the login shell used for the persistent Bash session."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class Settings:
|
|
82
|
+
"""Fully resolved configuration for one HX session."""
|
|
83
|
+
|
|
84
|
+
cwd: Path = field(default_factory=Path.cwd)
|
|
85
|
+
models: ModelSettings = field(default_factory=ModelSettings)
|
|
86
|
+
permissions: PermissionSettings = field(default_factory=PermissionSettings)
|
|
87
|
+
context: ContextSettings = field(default_factory=ContextSettings)
|
|
88
|
+
bash: BashSettings = field(default_factory=BashSettings)
|
|
89
|
+
theme: str = "dark"
|
|
90
|
+
telemetry: bool = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_settings(
|
|
94
|
+
cwd: Path | None = None,
|
|
95
|
+
overrides: dict[str, Any] | None = None,
|
|
96
|
+
) -> Settings:
|
|
97
|
+
"""Resolve the settings layers into a single :class:`Settings`.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
cwd: Project root. Defaults to the process working directory.
|
|
101
|
+
overrides: CLI-flag layer, applied last.
|
|
102
|
+
"""
|
|
103
|
+
root = (cwd or Path.cwd()).resolve()
|
|
104
|
+
layers = [
|
|
105
|
+
read_settings_file(user_settings_file()),
|
|
106
|
+
read_settings_file(project_settings_file(root)),
|
|
107
|
+
settings_from_env(dict(os.environ)),
|
|
108
|
+
overrides or {},
|
|
109
|
+
]
|
|
110
|
+
merged = merge_layers(layers)
|
|
111
|
+
return _build_settings(merged, root)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _default(cls: type, name: str) -> Any:
|
|
115
|
+
"""Default value of a dataclass field.
|
|
116
|
+
|
|
117
|
+
``slots=True`` replaces class attributes with descriptors, so
|
|
118
|
+
``ModelSettings.max_tokens`` is not the default - it is a member_descriptor.
|
|
119
|
+
"""
|
|
120
|
+
for f in dataclasses_fields(cls):
|
|
121
|
+
if f.name == name:
|
|
122
|
+
return f.default
|
|
123
|
+
raise KeyError(name)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_settings(data: dict[str, Any], cwd: Path) -> Settings:
|
|
127
|
+
models = data.get("models", {})
|
|
128
|
+
permissions = data.get("permissions", {})
|
|
129
|
+
context = data.get("context", {})
|
|
130
|
+
bash = data.get("bash", {})
|
|
131
|
+
|
|
132
|
+
mode_raw = permissions.get("mode", PermissionMode.DEFAULT)
|
|
133
|
+
try:
|
|
134
|
+
mode = PermissionMode(mode_raw)
|
|
135
|
+
except ValueError as exc:
|
|
136
|
+
raise ConfigError(f"unknown permission mode: {mode_raw!r}") from exc
|
|
137
|
+
|
|
138
|
+
return Settings(
|
|
139
|
+
cwd=cwd,
|
|
140
|
+
models=ModelSettings(
|
|
141
|
+
model=models.get("model", _default(ModelSettings, "model")),
|
|
142
|
+
subagent_model=models.get("subagent_model"),
|
|
143
|
+
max_tokens=int(models.get("max_tokens", _default(ModelSettings, "max_tokens"))),
|
|
144
|
+
temperature=models.get("temperature"),
|
|
145
|
+
),
|
|
146
|
+
permissions=PermissionSettings(
|
|
147
|
+
mode=mode,
|
|
148
|
+
allow=tuple(permissions.get("allow", ())),
|
|
149
|
+
ask=tuple(permissions.get("ask", ())),
|
|
150
|
+
deny=tuple(permissions.get("deny", ())),
|
|
151
|
+
sandbox=bool(permissions.get("sandbox", True)),
|
|
152
|
+
allow_network=bool(permissions.get("allow_network", False)),
|
|
153
|
+
),
|
|
154
|
+
context=ContextSettings(
|
|
155
|
+
compact_at=float(context.get("compact_at", _default(ContextSettings, "compact_at"))),
|
|
156
|
+
keep_recent_turns=int(
|
|
157
|
+
context.get("keep_recent_turns", _default(ContextSettings, "keep_recent_turns"))
|
|
158
|
+
),
|
|
159
|
+
tool_output_char_cap=int(
|
|
160
|
+
context.get(
|
|
161
|
+
"tool_output_char_cap", _default(ContextSettings, "tool_output_char_cap")
|
|
162
|
+
)
|
|
163
|
+
),
|
|
164
|
+
tool_output_line_cap=int(
|
|
165
|
+
context.get(
|
|
166
|
+
"tool_output_line_cap", _default(ContextSettings, "tool_output_line_cap")
|
|
167
|
+
)
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
bash=BashSettings(
|
|
171
|
+
timeout_seconds=int(
|
|
172
|
+
bash.get("timeout_seconds", _default(BashSettings, "timeout_seconds"))
|
|
173
|
+
),
|
|
174
|
+
max_timeout_seconds=int(
|
|
175
|
+
bash.get("max_timeout_seconds", _default(BashSettings, "max_timeout_seconds"))
|
|
176
|
+
),
|
|
177
|
+
shell=bash.get("shell"),
|
|
178
|
+
),
|
|
179
|
+
theme=data.get("theme", "dark"),
|
|
180
|
+
telemetry=bool(data.get("telemetry", False)),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def read_settings_file(path: Path) -> dict[str, Any]:
|
|
185
|
+
"""Read one ``settings.json`` layer. Missing file -> empty dict.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ConfigError: if the file exists but is not valid JSON.
|
|
189
|
+
"""
|
|
190
|
+
if not path.is_file():
|
|
191
|
+
return {}
|
|
192
|
+
try:
|
|
193
|
+
data = json.loads(path.read_text())
|
|
194
|
+
except json.JSONDecodeError as exc:
|
|
195
|
+
raise ConfigError(f"{path}: invalid JSON ({exc})") from exc
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
198
|
+
if not isinstance(data, dict):
|
|
199
|
+
raise ConfigError(f"{path}: expected a JSON object")
|
|
200
|
+
return data
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def write_settings_file(path: Path, data: dict[str, Any]) -> None:
|
|
204
|
+
"""Write a settings layer atomically (temp file + rename)."""
|
|
205
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
tmp = path.with_suffix(".json.tmp")
|
|
207
|
+
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
208
|
+
tmp.replace(path)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
#: Permission rule lists are unioned across layers rather than replaced - a
|
|
212
|
+
#: project must be able to add a deny rule without discarding the user's.
|
|
213
|
+
_UNIONED_KEYS = frozenset({"allow", "ask", "deny"})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def merge_layers(layers: list[dict[str, Any]]) -> dict[str, Any]:
|
|
217
|
+
"""Deep-merge settings layers. Later layers win; lists are replaced, not concatenated,
|
|
218
|
+
except permission rule lists which are unioned."""
|
|
219
|
+
result: dict[str, Any] = {}
|
|
220
|
+
for layer in layers:
|
|
221
|
+
_merge_into(result, layer)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _merge_into(target: dict[str, Any], source: dict[str, Any]) -> None:
|
|
226
|
+
for key, value in source.items():
|
|
227
|
+
existing = target.get(key)
|
|
228
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
229
|
+
_merge_into(existing, value)
|
|
230
|
+
elif key in _UNIONED_KEYS and isinstance(existing, list) and isinstance(value, list):
|
|
231
|
+
target[key] = existing + [item for item in value if item not in existing]
|
|
232
|
+
else:
|
|
233
|
+
target[key] = value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_ENV_MAP: dict[str, tuple[str, ...]] = {
|
|
237
|
+
"HX_MODEL": ("models", "model"),
|
|
238
|
+
"HX_SUBAGENT_MODEL": ("models", "subagent_model"),
|
|
239
|
+
"HX_MAX_TOKENS": ("models", "max_tokens"),
|
|
240
|
+
"HX_PERMISSION_MODE": ("permissions", "mode"),
|
|
241
|
+
"HX_SANDBOX": ("permissions", "sandbox"),
|
|
242
|
+
"HX_COMPACT_AT": ("context", "compact_at"),
|
|
243
|
+
"HX_THEME": ("theme",),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def settings_from_env(env: dict[str, str]) -> dict[str, Any]:
|
|
248
|
+
"""Extract the ``HX_*`` environment layer (e.g. ``HX_MODEL``, ``HX_PERMISSION_MODE``)."""
|
|
249
|
+
layer: dict[str, Any] = {}
|
|
250
|
+
for name, path in _ENV_MAP.items():
|
|
251
|
+
if name not in env:
|
|
252
|
+
continue
|
|
253
|
+
cursor = layer
|
|
254
|
+
for part in path[:-1]:
|
|
255
|
+
cursor = cursor.setdefault(part, {})
|
|
256
|
+
cursor[path[-1]] = _coerce(env[name])
|
|
257
|
+
return layer
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _coerce(raw: str) -> Any:
|
|
261
|
+
lowered = raw.strip().lower()
|
|
262
|
+
if lowered in {"true", "1", "yes"}:
|
|
263
|
+
return True
|
|
264
|
+
if lowered in {"false", "0", "no"}:
|
|
265
|
+
return False
|
|
266
|
+
try:
|
|
267
|
+
return int(raw)
|
|
268
|
+
except ValueError:
|
|
269
|
+
pass
|
|
270
|
+
try:
|
|
271
|
+
return float(raw)
|
|
272
|
+
except ValueError:
|
|
273
|
+
return raw
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class ConfigError(Exception):
|
|
277
|
+
"""Raised for malformed configuration files."""
|
hx/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Headless agent core. Knows nothing about the TUI."""
|
hx/core/compaction.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Conversation compaction.
|
|
2
|
+
|
|
3
|
+
Fires when the assembled context crosses ``context.compact_at`` of the model's
|
|
4
|
+
window, or on an explicit ``/compact [instructions]``. Older turns are replaced
|
|
5
|
+
by one structured summary; the last N turns and the full todo list survive
|
|
6
|
+
verbatim. The pre-compaction messages stay in the session JSONL (flagged
|
|
7
|
+
``compacted``) so resume and undo still work.
|
|
8
|
+
|
|
9
|
+
Compaction reseeds the cache prefix by definition - that cost is accepted and
|
|
10
|
+
surfaced in the status bar rather than hidden.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
17
|
+
|
|
18
|
+
from hx.core.messages import (
|
|
19
|
+
Message,
|
|
20
|
+
TextBlock,
|
|
21
|
+
ThinkingBlock,
|
|
22
|
+
ToolResultBlock,
|
|
23
|
+
ToolUseBlock,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from hx.core.context import ContextBuilder
|
|
28
|
+
from hx.providers.base import Provider
|
|
29
|
+
|
|
30
|
+
SUMMARY_SECTIONS = (
|
|
31
|
+
"Goal",
|
|
32
|
+
"Decisions made",
|
|
33
|
+
"Files touched",
|
|
34
|
+
"Current state",
|
|
35
|
+
"Next steps",
|
|
36
|
+
"Open questions",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
SUMMARY_MARKER = "<hx-compacted-summary>"
|
|
40
|
+
SUMMARY_END = "</hx-compacted-summary>"
|
|
41
|
+
|
|
42
|
+
SUMMARY_PROMPT = """\
|
|
43
|
+
Summarise the conversation below so that work can continue without it.
|
|
44
|
+
|
|
45
|
+
Write these sections, in order, using the exact headings:
|
|
46
|
+
|
|
47
|
+
{sections}
|
|
48
|
+
|
|
49
|
+
Be specific and concrete. Name files by path, record decisions with their
|
|
50
|
+
reasons, and state exactly where the work stopped. Preserve anything the next
|
|
51
|
+
turn would otherwise have to re-derive: chosen approaches, rejected approaches
|
|
52
|
+
and why, error messages still unresolved, and commands that must be re-run.
|
|
53
|
+
|
|
54
|
+
Do not summarise the summary instructions. Do not add commentary.
|
|
55
|
+
{extra}
|
|
56
|
+
--- conversation ---
|
|
57
|
+
|
|
58
|
+
{transcript}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class CompactionResult:
|
|
64
|
+
summary: Message
|
|
65
|
+
kept: list[Message]
|
|
66
|
+
dropped: list[Message]
|
|
67
|
+
tokens_before: int
|
|
68
|
+
tokens_after: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Compactor:
|
|
72
|
+
#: Cap on the summary itself. A summary that can grow without bound just
|
|
73
|
+
#: moves the context problem one turn later.
|
|
74
|
+
SUMMARY_MAX_TOKENS: ClassVar[int] = 2048
|
|
75
|
+
MIN_MESSAGES_TO_COMPACT: ClassVar[int] = 4
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
provider: Provider | None = None,
|
|
80
|
+
model: str = "",
|
|
81
|
+
keep_recent_turns: int = 6,
|
|
82
|
+
context: ContextBuilder | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.provider = provider
|
|
85
|
+
self.model = model
|
|
86
|
+
self.keep_recent_turns = keep_recent_turns
|
|
87
|
+
self.context = context
|
|
88
|
+
|
|
89
|
+
def should_compact(self, context_fraction: float, threshold: float) -> bool:
|
|
90
|
+
return context_fraction >= threshold > 0
|
|
91
|
+
|
|
92
|
+
def split(self, messages: list[Message]) -> tuple[list[Message], list[Message]]:
|
|
93
|
+
"""Partition into (to summarise, to keep verbatim).
|
|
94
|
+
|
|
95
|
+
The boundary snaps to a turn edge: never split an assistant tool-use
|
|
96
|
+
message from its tool results, or the next request is malformed.
|
|
97
|
+
"""
|
|
98
|
+
if len(messages) <= self.keep_recent_turns:
|
|
99
|
+
return [], list(messages)
|
|
100
|
+
|
|
101
|
+
boundary = len(messages) - self.keep_recent_turns
|
|
102
|
+
while boundary > 0 and not _is_turn_edge(messages, boundary):
|
|
103
|
+
boundary -= 1
|
|
104
|
+
|
|
105
|
+
return list(messages[:boundary]), list(messages[boundary:])
|
|
106
|
+
|
|
107
|
+
async def compact(
|
|
108
|
+
self,
|
|
109
|
+
messages: list[Message],
|
|
110
|
+
instructions: str | None = None,
|
|
111
|
+
) -> CompactionResult:
|
|
112
|
+
"""Summarise via a provider call and assemble the replacement transcript."""
|
|
113
|
+
dropped, kept = self.split(messages)
|
|
114
|
+
if len(dropped) < self.MIN_MESSAGES_TO_COMPACT:
|
|
115
|
+
return CompactionResult(
|
|
116
|
+
summary=_summary_message("(nothing to compact)"),
|
|
117
|
+
kept=list(messages),
|
|
118
|
+
dropped=[],
|
|
119
|
+
tokens_before=self._estimate(messages),
|
|
120
|
+
tokens_after=self._estimate(messages),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
text = await self._summarise(dropped, instructions)
|
|
124
|
+
summary = _summary_message(text)
|
|
125
|
+
return CompactionResult(
|
|
126
|
+
summary=summary,
|
|
127
|
+
kept=kept,
|
|
128
|
+
dropped=dropped,
|
|
129
|
+
tokens_before=self._estimate(messages),
|
|
130
|
+
tokens_after=self._estimate([summary, *kept]),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def _summarise(self, messages: list[Message], instructions: str | None) -> str:
|
|
134
|
+
if self.provider is None:
|
|
135
|
+
# Without a provider there is nothing to call; fall back to a
|
|
136
|
+
# mechanical digest rather than silently dropping the history.
|
|
137
|
+
return _mechanical_summary(messages)
|
|
138
|
+
|
|
139
|
+
from hx.core.context import AssembledContext, PromptSection
|
|
140
|
+
from hx.providers.base import ProviderRequest, StreamDelta
|
|
141
|
+
|
|
142
|
+
prompt = self.build_summary_prompt(messages, instructions)
|
|
143
|
+
context = AssembledContext(
|
|
144
|
+
system=[PromptSection("system", "You write precise handover summaries.")],
|
|
145
|
+
messages=[Message(role="user", content=[TextBlock(text=prompt)])],
|
|
146
|
+
tools=[],
|
|
147
|
+
)
|
|
148
|
+
request = ProviderRequest(
|
|
149
|
+
context=context,
|
|
150
|
+
model=self.model,
|
|
151
|
+
max_tokens=self.SUMMARY_MAX_TOKENS,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
parts: list[str] = []
|
|
155
|
+
async for item in self.provider.astream(request):
|
|
156
|
+
if isinstance(item, StreamDelta) and item.text:
|
|
157
|
+
parts.append(item.text)
|
|
158
|
+
|
|
159
|
+
return "".join(parts).strip() or _mechanical_summary(messages)
|
|
160
|
+
|
|
161
|
+
def build_summary_prompt(
|
|
162
|
+
self,
|
|
163
|
+
messages: list[Message],
|
|
164
|
+
instructions: str | None,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Prompt requesting the sections in :data:`SUMMARY_SECTIONS`."""
|
|
167
|
+
extra = f"\nAlso: {instructions.strip()}\n" if instructions else ""
|
|
168
|
+
return SUMMARY_PROMPT.format(
|
|
169
|
+
sections="\n".join(f"## {section}" for section in SUMMARY_SECTIONS),
|
|
170
|
+
extra=extra,
|
|
171
|
+
transcript=render_transcript(messages),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _estimate(self, messages: list[Message]) -> int:
|
|
175
|
+
if self.context is not None:
|
|
176
|
+
return sum(self.context.estimate_tokens(_message_text(m)) for m in messages)
|
|
177
|
+
return sum(len(_message_text(m)) // 4 for m in messages)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _is_turn_edge(messages: list[Message], index: int) -> bool:
|
|
181
|
+
"""True when a split before ``index`` leaves every tool call with its results."""
|
|
182
|
+
if index <= 0 or index >= len(messages):
|
|
183
|
+
return True
|
|
184
|
+
previous = messages[index - 1]
|
|
185
|
+
current = messages[index]
|
|
186
|
+
if previous.tool_uses():
|
|
187
|
+
return False
|
|
188
|
+
return not any(isinstance(block, ToolResultBlock) for block in current.content)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render_transcript(messages: list[Message]) -> str:
|
|
192
|
+
"""Flatten messages to text for the summary prompt.
|
|
193
|
+
|
|
194
|
+
Rendering to text rather than replaying the structured messages keeps the
|
|
195
|
+
summary call free of tool-call pairing constraints, which a partial history
|
|
196
|
+
would otherwise violate.
|
|
197
|
+
"""
|
|
198
|
+
lines: list[str] = []
|
|
199
|
+
for message in messages:
|
|
200
|
+
for block in message.content:
|
|
201
|
+
if isinstance(block, TextBlock):
|
|
202
|
+
lines.append(f"[{message.role}] {block.text}")
|
|
203
|
+
elif isinstance(block, ToolUseBlock):
|
|
204
|
+
lines.append(f"[{message.role}] calls {block.name}({block.input})")
|
|
205
|
+
elif isinstance(block, ToolResultBlock):
|
|
206
|
+
marker = "error" if block.is_error else "result"
|
|
207
|
+
lines.append(f"[tool {marker}] {_clip(block.content)}")
|
|
208
|
+
elif isinstance(block, ThinkingBlock):
|
|
209
|
+
continue
|
|
210
|
+
return "\n".join(lines)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _mechanical_summary(messages: list[Message]) -> str:
|
|
214
|
+
"""Last-resort digest when no model is available to write one."""
|
|
215
|
+
users = [m.text() for m in messages if m.role == "user" and m.text()]
|
|
216
|
+
tools = sorted({b.name for m in messages for b in m.tool_uses()})
|
|
217
|
+
lines = ["## Goal", users[0][:500] if users else "(unknown)", "", "## Current state"]
|
|
218
|
+
lines.append(f"{len(messages)} earlier messages were compacted without a model summary.")
|
|
219
|
+
if tools:
|
|
220
|
+
lines.append(f"Tools used: {', '.join(tools)}.")
|
|
221
|
+
if len(users) > 1:
|
|
222
|
+
lines += ["", "## Open questions", *[f"- {text[:200]}" for text in users[1:6]]]
|
|
223
|
+
return "\n".join(lines)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _summary_message(text: str) -> Message:
|
|
227
|
+
body = f"{SUMMARY_MARKER}\nThis replaces the earlier conversation.\n\n{text}\n{SUMMARY_END}"
|
|
228
|
+
return Message(
|
|
229
|
+
role="user",
|
|
230
|
+
content=[TextBlock(text=body)],
|
|
231
|
+
metadata={"compaction_summary": True},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _message_text(message: Message) -> str:
|
|
236
|
+
parts: list[str] = []
|
|
237
|
+
for block in message.content:
|
|
238
|
+
text = getattr(block, "text", None) or getattr(block, "content", None)
|
|
239
|
+
if isinstance(text, str):
|
|
240
|
+
parts.append(text)
|
|
241
|
+
return "\n".join(parts)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _clip(text: str, limit: int = 2000) -> str:
|
|
245
|
+
return text if len(text) <= limit else text[:limit] + " … [clipped]"
|