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/loop.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""The agent turn engine.
|
|
2
|
+
|
|
3
|
+
One iteration: assemble context -> stream from the provider -> collect tool_use
|
|
4
|
+
blocks -> execute them -> append results -> repeat, until the model returns a
|
|
5
|
+
turn with no tool calls or the user cancels.
|
|
6
|
+
|
|
7
|
+
Read-only tools in the same assistant turn run concurrently; anything that
|
|
8
|
+
mutates state runs serially in the order the model emitted it, so side effects
|
|
9
|
+
stay predictable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
19
|
+
|
|
20
|
+
from hx.core.context import AssembledContext
|
|
21
|
+
from hx.core.events import (
|
|
22
|
+
CompactionFinished,
|
|
23
|
+
CompactionStarted,
|
|
24
|
+
ErrorRaised,
|
|
25
|
+
PermissionRequested,
|
|
26
|
+
TextDelta,
|
|
27
|
+
ThinkingDelta,
|
|
28
|
+
ToolCallFinished,
|
|
29
|
+
ToolCallStarted,
|
|
30
|
+
TurnFinished,
|
|
31
|
+
TurnStarted,
|
|
32
|
+
UsageUpdated,
|
|
33
|
+
)
|
|
34
|
+
from hx.core.messages import (
|
|
35
|
+
ContentBlock,
|
|
36
|
+
Message,
|
|
37
|
+
StopReason,
|
|
38
|
+
TextBlock,
|
|
39
|
+
ThinkingBlock,
|
|
40
|
+
ToolResultBlock,
|
|
41
|
+
ToolUseBlock,
|
|
42
|
+
assistant_message,
|
|
43
|
+
tool_result_message,
|
|
44
|
+
user_message,
|
|
45
|
+
)
|
|
46
|
+
from hx.core.usage import TurnUsage, compute_cost
|
|
47
|
+
from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd
|
|
48
|
+
from hx.tools.base import ToolContext
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from hx.config import Settings
|
|
52
|
+
from hx.core.compaction import Compactor
|
|
53
|
+
from hx.core.context import ContextBuilder
|
|
54
|
+
from hx.core.events import EventBus
|
|
55
|
+
from hx.core.lateinject import InjectionRegistry
|
|
56
|
+
from hx.core.session import Session
|
|
57
|
+
from hx.permissions.engine import PermissionEngine
|
|
58
|
+
from hx.providers.base import Provider
|
|
59
|
+
from hx.providers.models import ModelInfo
|
|
60
|
+
from hx.skills.runtime import ActiveSkills
|
|
61
|
+
from hx.tools.registry import ToolRegistry
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class TurnResult:
|
|
66
|
+
stop_reason: StopReason
|
|
67
|
+
messages: list[Message]
|
|
68
|
+
error: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AgentLoop:
|
|
72
|
+
"""Drives one conversation. Subagents each get their own instance."""
|
|
73
|
+
|
|
74
|
+
MAX_TURNS: ClassVar[int] = 100
|
|
75
|
+
"""Backstop against a model that calls tools forever. Hitting it is a bug
|
|
76
|
+
worth surfacing, not a condition to handle silently."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
provider: Provider,
|
|
82
|
+
session: Session,
|
|
83
|
+
tools: ToolRegistry,
|
|
84
|
+
permissions: PermissionEngine | None,
|
|
85
|
+
context: ContextBuilder,
|
|
86
|
+
compactor: Compactor | None,
|
|
87
|
+
injections: InjectionRegistry,
|
|
88
|
+
bus: EventBus,
|
|
89
|
+
settings: Settings,
|
|
90
|
+
model_info: ModelInfo | None = None,
|
|
91
|
+
active_skills: ActiveSkills | None = None,
|
|
92
|
+
skills_index: str | None = None,
|
|
93
|
+
project_context: str | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
self.provider = provider
|
|
96
|
+
self.session = session
|
|
97
|
+
self.tools = tools
|
|
98
|
+
self.permissions = permissions
|
|
99
|
+
self.context = context
|
|
100
|
+
self.compactor = compactor
|
|
101
|
+
self.injections = injections
|
|
102
|
+
self.bus = bus
|
|
103
|
+
self.settings = settings
|
|
104
|
+
self.model_info = model_info
|
|
105
|
+
self.active_skills = active_skills
|
|
106
|
+
self.skills_index = skills_index
|
|
107
|
+
self.project_context = project_context
|
|
108
|
+
self._cancelled = False
|
|
109
|
+
self._turn_index = 0
|
|
110
|
+
self.last_context: AssembledContext | None = None
|
|
111
|
+
self.origin: str | None = None
|
|
112
|
+
"""Set for subagents so approval prompts name who is asking."""
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def model(self) -> str:
|
|
116
|
+
return self.session.meta.model
|
|
117
|
+
|
|
118
|
+
def set_model(self, model_id: str, model_info: ModelInfo | None = None) -> None:
|
|
119
|
+
self.session.meta.model = model_id
|
|
120
|
+
self.model_info = model_info
|
|
121
|
+
|
|
122
|
+
async def run(self, user_input: str) -> TurnResult:
|
|
123
|
+
"""Run turns until the model stops calling tools.
|
|
124
|
+
|
|
125
|
+
Cancellation (Esc / Ctrl+C) raises ``asyncio.CancelledError`` into this
|
|
126
|
+
coroutine; the partial assistant message is still appended to the
|
|
127
|
+
transcript so the next turn has an honest history.
|
|
128
|
+
"""
|
|
129
|
+
self._cancelled = False
|
|
130
|
+
if user_input:
|
|
131
|
+
self.session.append(user_message(user_input))
|
|
132
|
+
|
|
133
|
+
produced: list[Message] = []
|
|
134
|
+
stop_reason = StopReason.END_TURN
|
|
135
|
+
|
|
136
|
+
for _ in range(self.MAX_TURNS):
|
|
137
|
+
if self._cancelled:
|
|
138
|
+
return TurnResult(StopReason.CANCELLED, produced)
|
|
139
|
+
|
|
140
|
+
await self._maybe_compact()
|
|
141
|
+
self._turn_index += 1
|
|
142
|
+
if self.origin is None:
|
|
143
|
+
self.bus.publish(TurnStarted(turn_index=self._turn_index, model=self.model))
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
message, stop_reason = await self._stream_turn()
|
|
147
|
+
except asyncio.CancelledError:
|
|
148
|
+
self.bus.publish(TurnFinished(self._turn_index, StopReason.CANCELLED))
|
|
149
|
+
raise
|
|
150
|
+
except ProviderError as exc:
|
|
151
|
+
self.bus.publish(ErrorRaised(message=str(exc), recoverable=True))
|
|
152
|
+
self.bus.publish(TurnFinished(self._turn_index, StopReason.ERROR))
|
|
153
|
+
return TurnResult(StopReason.ERROR, produced, error=str(exc))
|
|
154
|
+
|
|
155
|
+
self.session.append(message)
|
|
156
|
+
produced.append(message)
|
|
157
|
+
if self.origin is None:
|
|
158
|
+
self.bus.publish(TurnFinished(self._turn_index, stop_reason))
|
|
159
|
+
|
|
160
|
+
calls = message.tool_uses()
|
|
161
|
+
if not calls or self._cancelled:
|
|
162
|
+
return TurnResult(stop_reason, produced)
|
|
163
|
+
|
|
164
|
+
results = await self._execute_tools(calls)
|
|
165
|
+
result_message = tool_result_message(results)
|
|
166
|
+
self.session.append(result_message)
|
|
167
|
+
produced.append(result_message)
|
|
168
|
+
|
|
169
|
+
self.bus.publish(
|
|
170
|
+
ErrorRaised(message=f"Stopped after {self.MAX_TURNS} turns", recoverable=True)
|
|
171
|
+
)
|
|
172
|
+
return TurnResult(stop_reason, produced, error="turn limit reached")
|
|
173
|
+
|
|
174
|
+
async def _stream_turn(self) -> tuple[Message, StopReason]:
|
|
175
|
+
"""One provider call. Publishes deltas and the usage update."""
|
|
176
|
+
request = self._build_request()
|
|
177
|
+
|
|
178
|
+
text_parts: list[str] = []
|
|
179
|
+
thinking_parts: list[str] = []
|
|
180
|
+
tool_calls: list[ToolUseBlock] = []
|
|
181
|
+
stop_reason = StopReason.END_TURN
|
|
182
|
+
usage = TurnUsage()
|
|
183
|
+
started = time.monotonic()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
async for item in self.provider.astream(request):
|
|
187
|
+
if isinstance(item, StreamEnd):
|
|
188
|
+
stop_reason = item.stop_reason
|
|
189
|
+
usage = item.usage
|
|
190
|
+
continue
|
|
191
|
+
if not isinstance(item, StreamDelta): # pragma: no cover - defensive
|
|
192
|
+
continue
|
|
193
|
+
if item.text:
|
|
194
|
+
text_parts.append(item.text)
|
|
195
|
+
# A subagent's prose is not the assistant speaking to the
|
|
196
|
+
# user: it is an intermediate result that reaches them as
|
|
197
|
+
# the Task tool's output. Streaming it into the transcript
|
|
198
|
+
# would read as if the main assistant had said it.
|
|
199
|
+
if self.origin is None:
|
|
200
|
+
self.bus.publish(TextDelta(text=item.text))
|
|
201
|
+
if item.thinking:
|
|
202
|
+
thinking_parts.append(item.thinking)
|
|
203
|
+
if self.origin is None:
|
|
204
|
+
self.bus.publish(ThinkingDelta(text=item.thinking))
|
|
205
|
+
if item.tool_use_id and item.tool_name:
|
|
206
|
+
tool_calls.append(
|
|
207
|
+
ToolUseBlock(
|
|
208
|
+
id=item.tool_use_id,
|
|
209
|
+
name=item.tool_name,
|
|
210
|
+
input=_parse_tool_input(item.tool_input_json),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
except asyncio.CancelledError:
|
|
214
|
+
# Keep whatever streamed before the interrupt: the next turn must
|
|
215
|
+
# reflect what the user actually saw.
|
|
216
|
+
if text_parts or tool_calls:
|
|
217
|
+
self.session.append(self._assemble(text_parts, thinking_parts, []))
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
if not usage.latency_ms:
|
|
221
|
+
usage.latency_ms = (time.monotonic() - started) * 1000
|
|
222
|
+
self._record_usage(usage, request)
|
|
223
|
+
|
|
224
|
+
return self._assemble(text_parts, thinking_parts, tool_calls), stop_reason
|
|
225
|
+
|
|
226
|
+
def _build_request(self) -> ProviderRequest:
|
|
227
|
+
messages = self.injections.apply(self.session.active_messages())
|
|
228
|
+
cache_mode = self.model_info.cache_mode.value if self.model_info else "none"
|
|
229
|
+
assembled = self.context.build(
|
|
230
|
+
messages=messages,
|
|
231
|
+
tools=self.tools.schemas(self._allowed_tools()),
|
|
232
|
+
skills_index=self.skills_index,
|
|
233
|
+
project_context=self.project_context,
|
|
234
|
+
cache_mode=cache_mode,
|
|
235
|
+
)
|
|
236
|
+
self.last_context = assembled
|
|
237
|
+
self.session.usage.context_tokens = assembled.total_tokens
|
|
238
|
+
self.session.usage.context_window = self.model_info.context_window if self.model_info else 0
|
|
239
|
+
return ProviderRequest(
|
|
240
|
+
context=assembled,
|
|
241
|
+
model=self.model,
|
|
242
|
+
max_tokens=self.settings.models.max_tokens,
|
|
243
|
+
temperature=self.settings.models.temperature,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _allowed_tools(self) -> set[str] | None:
|
|
247
|
+
"""Which tools the model may see this turn.
|
|
248
|
+
|
|
249
|
+
A loaded skill's ``allowed-tools`` narrows the set - it is intersected,
|
|
250
|
+
never unioned, so a skill can only ever restrict. Without this the
|
|
251
|
+
Skill tool's own "use only these tools" line would be a claim with
|
|
252
|
+
nothing behind it.
|
|
253
|
+
"""
|
|
254
|
+
allowed: set[str] | None = None
|
|
255
|
+
if self.permissions is not None:
|
|
256
|
+
mutating = {name for name in self.tools.names() if self.tools.get(name).mutating}
|
|
257
|
+
allowed = self.permissions.allowed_tools(self.tools.names(), mutating)
|
|
258
|
+
|
|
259
|
+
if self.active_skills is not None and (
|
|
260
|
+
skill_allowed := self.active_skills.tool_allowlist()
|
|
261
|
+
):
|
|
262
|
+
# Skill and Task stay reachable so the model can switch skills or
|
|
263
|
+
# delegate; everything else narrows to the skill's list.
|
|
264
|
+
keep = skill_allowed | {"Skill", "Task"}
|
|
265
|
+
allowed = keep if allowed is None else (allowed & keep)
|
|
266
|
+
return allowed
|
|
267
|
+
|
|
268
|
+
def _assemble(
|
|
269
|
+
self,
|
|
270
|
+
text_parts: list[str],
|
|
271
|
+
thinking_parts: list[str],
|
|
272
|
+
tool_calls: list[ToolUseBlock],
|
|
273
|
+
) -> Message:
|
|
274
|
+
blocks: list[ContentBlock] = []
|
|
275
|
+
if thinking_parts:
|
|
276
|
+
blocks.append(ThinkingBlock(text="".join(thinking_parts)))
|
|
277
|
+
if text_parts:
|
|
278
|
+
blocks.append(TextBlock(text="".join(text_parts)))
|
|
279
|
+
blocks.extend(tool_calls)
|
|
280
|
+
return assistant_message(blocks, model=self.model)
|
|
281
|
+
|
|
282
|
+
def _record_usage(self, usage: TurnUsage, request: ProviderRequest) -> None:
|
|
283
|
+
if usage.cost_usd is None and self.model_info is not None:
|
|
284
|
+
usage.cost_usd = compute_cost(usage, self.model_info.pricing)
|
|
285
|
+
self.session.record_usage(usage)
|
|
286
|
+
ledger = self.session.usage
|
|
287
|
+
self.bus.publish(
|
|
288
|
+
UsageUpdated(
|
|
289
|
+
input_tokens=ledger.total_input,
|
|
290
|
+
output_tokens=ledger.total_output,
|
|
291
|
+
cache_read_tokens=ledger.total_cache_read,
|
|
292
|
+
cache_write_tokens=ledger.total_cache_write,
|
|
293
|
+
context_tokens=ledger.context_tokens,
|
|
294
|
+
context_window=ledger.context_window,
|
|
295
|
+
cost_usd=ledger.total_cost_usd,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async def _execute_tools(self, calls: list[ToolUseBlock]) -> list[ToolResultBlock]:
|
|
300
|
+
"""Permission-check, then run. Read-only calls are gathered concurrently;
|
|
301
|
+
mutating calls run in emission order.
|
|
302
|
+
|
|
303
|
+
A denied call becomes an ``is_error`` result rather than an exception, so
|
|
304
|
+
the model can react instead of the turn dying.
|
|
305
|
+
"""
|
|
306
|
+
results: dict[str, ToolResultBlock] = {}
|
|
307
|
+
concurrent: list[ToolUseBlock] = []
|
|
308
|
+
|
|
309
|
+
for call in calls:
|
|
310
|
+
if self._runs_serially(call):
|
|
311
|
+
for pending in concurrent:
|
|
312
|
+
results[pending.id] = await self._run_one(pending)
|
|
313
|
+
concurrent.clear()
|
|
314
|
+
results[call.id] = await self._run_one(call)
|
|
315
|
+
else:
|
|
316
|
+
concurrent.append(call)
|
|
317
|
+
|
|
318
|
+
if concurrent:
|
|
319
|
+
gathered = await asyncio.gather(*(self._run_one(c) for c in concurrent))
|
|
320
|
+
for call, result in zip(concurrent, gathered, strict=True):
|
|
321
|
+
results[call.id] = result
|
|
322
|
+
|
|
323
|
+
return [results[c.id] for c in calls]
|
|
324
|
+
|
|
325
|
+
def _runs_serially(self, call: ToolUseBlock) -> bool:
|
|
326
|
+
try:
|
|
327
|
+
tool = self.tools.get(call.name)
|
|
328
|
+
except Exception:
|
|
329
|
+
# An unknown tool is about to become an error result; treat it as
|
|
330
|
+
# serial so it cannot slip into the concurrent batch.
|
|
331
|
+
return True
|
|
332
|
+
return tool.mutating and not tool.parallel_safe
|
|
333
|
+
|
|
334
|
+
def _display_name(self, name: str) -> str:
|
|
335
|
+
"""Attribute a subagent's tool calls so they do not read as the parent's."""
|
|
336
|
+
return name if self.origin is None else f"{self.origin} > {name}"
|
|
337
|
+
|
|
338
|
+
async def _run_one(self, call: ToolUseBlock) -> ToolResultBlock:
|
|
339
|
+
self.bus.publish(
|
|
340
|
+
ToolCallStarted(
|
|
341
|
+
tool_use_id=call.id, name=self._display_name(call.name), input=call.input
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
started = time.monotonic()
|
|
345
|
+
|
|
346
|
+
denied = await self._check_permission(call)
|
|
347
|
+
if denied is not None:
|
|
348
|
+
self.bus.publish(
|
|
349
|
+
ToolCallFinished(
|
|
350
|
+
tool_use_id=call.id,
|
|
351
|
+
is_error=True,
|
|
352
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
353
|
+
summary="denied",
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
return denied
|
|
357
|
+
|
|
358
|
+
ctx = ToolContext(
|
|
359
|
+
cwd=self.settings.cwd,
|
|
360
|
+
session_id=self.session.meta.session_id,
|
|
361
|
+
tool_use_id=call.id,
|
|
362
|
+
settings=self.settings,
|
|
363
|
+
emit_progress=lambda chunk: self._emit_progress(call.id, chunk),
|
|
364
|
+
)
|
|
365
|
+
result = await self.tools.call(call.name, call.input, ctx)
|
|
366
|
+
|
|
367
|
+
self.bus.publish(
|
|
368
|
+
ToolCallFinished(
|
|
369
|
+
tool_use_id=call.id,
|
|
370
|
+
is_error=result.is_error,
|
|
371
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
372
|
+
summary=result.summary or ("error" if result.is_error else "done"),
|
|
373
|
+
metadata=dict(result.metadata),
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
return ToolResultBlock(
|
|
377
|
+
tool_use_id=call.id,
|
|
378
|
+
content=result.content,
|
|
379
|
+
is_error=result.is_error,
|
|
380
|
+
spilled_path=result.spilled_path,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def _check_permission(self, call: ToolUseBlock) -> ToolResultBlock | None:
|
|
384
|
+
"""Returns a denial result, or ``None`` when the call may proceed."""
|
|
385
|
+
if self.permissions is None or not self.tools.has(call.name):
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
from hx.permissions.engine import PermissionRequest
|
|
389
|
+
|
|
390
|
+
tool = self.tools.get(call.name)
|
|
391
|
+
request = PermissionRequest(
|
|
392
|
+
tool_name=call.name,
|
|
393
|
+
specifier=tool.permission_specifier(call.input),
|
|
394
|
+
params=call.input,
|
|
395
|
+
mutating=tool.mutating,
|
|
396
|
+
description=f"{call.name}({_brief(call.input)})",
|
|
397
|
+
detail=_permission_detail(call),
|
|
398
|
+
origin=self.origin,
|
|
399
|
+
)
|
|
400
|
+
self.bus.publish(
|
|
401
|
+
PermissionRequested(
|
|
402
|
+
request_id=call.id,
|
|
403
|
+
tool_name=call.name,
|
|
404
|
+
description=request.description,
|
|
405
|
+
detail=request.detail,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
allowed, reason = await self.permissions.request(request)
|
|
409
|
+
if allowed:
|
|
410
|
+
return None
|
|
411
|
+
return ToolResultBlock(
|
|
412
|
+
tool_use_id=call.id,
|
|
413
|
+
content=f"{call.name} was not permitted: {reason}",
|
|
414
|
+
is_error=True,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def _emit_progress(self, tool_use_id: str, chunk: str) -> None:
|
|
418
|
+
from hx.core.events import ToolCallProgress
|
|
419
|
+
|
|
420
|
+
self.bus.publish(ToolCallProgress(tool_use_id=tool_use_id, chunk=chunk))
|
|
421
|
+
|
|
422
|
+
async def _maybe_compact(self) -> None:
|
|
423
|
+
"""Compact if the context fraction crossed the configured threshold."""
|
|
424
|
+
if self.compactor is None:
|
|
425
|
+
return
|
|
426
|
+
fraction = self.session.usage.context_fraction
|
|
427
|
+
if not self.compactor.should_compact(fraction, self.settings.context.compact_at):
|
|
428
|
+
return
|
|
429
|
+
await self.compact(reason=f"context at {fraction:.0%} of the window")
|
|
430
|
+
|
|
431
|
+
async def compact(self, instructions: str | None = None, reason: str = "requested") -> bool:
|
|
432
|
+
"""Replace older turns with a summary. Returns False when there was
|
|
433
|
+
nothing worth compacting.
|
|
434
|
+
|
|
435
|
+
Everything below the static prefix changes, so the cached conversation
|
|
436
|
+
is discarded by definition. That is announced rather than done quietly:
|
|
437
|
+
the next turn re-reads its input at full price.
|
|
438
|
+
"""
|
|
439
|
+
if self.compactor is None:
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
self.bus.publish(CompactionStarted(reason=reason))
|
|
443
|
+
try:
|
|
444
|
+
result = await self.compactor.compact(self.session.active_messages(), instructions)
|
|
445
|
+
except ProviderError as exc:
|
|
446
|
+
self.bus.publish(ErrorRaised(message=f"Compaction failed: {exc}", recoverable=True))
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
if not result.dropped:
|
|
450
|
+
self.bus.publish(
|
|
451
|
+
CompactionFinished(
|
|
452
|
+
tokens_before=result.tokens_before, tokens_after=result.tokens_after
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
self.session.record_compaction(result.dropped, result.kept, result.summary)
|
|
458
|
+
self.session.usage.context_tokens = result.tokens_after
|
|
459
|
+
self.bus.publish(
|
|
460
|
+
CompactionFinished(tokens_before=result.tokens_before, tokens_after=result.tokens_after)
|
|
461
|
+
)
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
def cancel(self) -> None:
|
|
465
|
+
"""Request cancellation of the in-flight turn."""
|
|
466
|
+
self._cancelled = True
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _parse_tool_input(raw: str | None) -> dict[str, Any]:
|
|
470
|
+
"""Tool arguments arrive as a JSON string assembled from stream fragments.
|
|
471
|
+
|
|
472
|
+
Malformed JSON becomes an empty dict; the tool's own validation then reports
|
|
473
|
+
the missing arguments to the model, which is a better error than a crash.
|
|
474
|
+
"""
|
|
475
|
+
if not raw:
|
|
476
|
+
return {}
|
|
477
|
+
try:
|
|
478
|
+
parsed = json.loads(raw)
|
|
479
|
+
except json.JSONDecodeError:
|
|
480
|
+
return {}
|
|
481
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _permission_detail(call: ToolUseBlock) -> str:
|
|
485
|
+
"""What the approval modal shows: the command, or the exact edit.
|
|
486
|
+
|
|
487
|
+
For an edit this previews the change against the file on disk, so the user
|
|
488
|
+
approves a diff rather than a filename.
|
|
489
|
+
"""
|
|
490
|
+
if call.name == "Bash":
|
|
491
|
+
return str(call.input.get("command", ""))
|
|
492
|
+
|
|
493
|
+
if call.name in {"Edit", "Write"} and (raw_path := call.input.get("file_path")):
|
|
494
|
+
from pathlib import Path
|
|
495
|
+
|
|
496
|
+
from hx.tools.edit import parse_edits, unified_diff
|
|
497
|
+
|
|
498
|
+
path = Path(str(raw_path))
|
|
499
|
+
try:
|
|
500
|
+
before = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
501
|
+
if call.name == "Write":
|
|
502
|
+
after = str(call.input.get("content", ""))
|
|
503
|
+
else:
|
|
504
|
+
after = before
|
|
505
|
+
for edit in parse_edits(call.input):
|
|
506
|
+
after = after.replace(
|
|
507
|
+
edit.old_string, edit.new_string, -1 if edit.replace_all else 1
|
|
508
|
+
)
|
|
509
|
+
return unified_diff(before, after, str(path)) or f"{path} (no change)"
|
|
510
|
+
except Exception:
|
|
511
|
+
# Previewing is best effort; never block the prompt on it.
|
|
512
|
+
return str(raw_path)
|
|
513
|
+
|
|
514
|
+
return _brief(call.input, limit=400)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _brief(params: dict[str, Any], limit: int = 80) -> str:
|
|
518
|
+
"""One-line parameter preview for a tool-call header.
|
|
519
|
+
|
|
520
|
+
Nested structures collapse to a placeholder rather than being sliced
|
|
521
|
+
mid-literal - a header ending in ``{'active_for…`` tells the reader nothing
|
|
522
|
+
and looks broken.
|
|
523
|
+
"""
|
|
524
|
+
parts: list[str] = []
|
|
525
|
+
for key, value in params.items():
|
|
526
|
+
if isinstance(value, list):
|
|
527
|
+
rendered = f"[{len(value)} items]"
|
|
528
|
+
elif isinstance(value, dict):
|
|
529
|
+
rendered = "{…}"
|
|
530
|
+
elif isinstance(value, str) and len(value) > limit:
|
|
531
|
+
rendered = repr(value[: limit - 1] + "…")
|
|
532
|
+
else:
|
|
533
|
+
rendered = repr(value)
|
|
534
|
+
parts.append(f"{key}={rendered}")
|
|
535
|
+
|
|
536
|
+
joined = ", ".join(parts)
|
|
537
|
+
return joined if len(joined) <= limit else joined[: limit - 1] + "…"
|
hx/core/messages.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Canonical transcript model.
|
|
2
|
+
|
|
3
|
+
Every layer - provider, tools, session persistence, TUI - speaks these types.
|
|
4
|
+
Provider-specific wire formats are converted at the provider boundary only, so
|
|
5
|
+
the transcript on disk stays stable across model switches.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
Role = Literal["system", "user", "assistant", "tool"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StopReason(StrEnum):
|
|
19
|
+
END_TURN = "end_turn"
|
|
20
|
+
TOOL_USE = "tool_use"
|
|
21
|
+
MAX_TOKENS = "max_tokens"
|
|
22
|
+
STOP_SEQUENCE = "stop_sequence"
|
|
23
|
+
CANCELLED = "cancelled"
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class TextBlock:
|
|
29
|
+
text: str
|
|
30
|
+
type: Literal["text"] = "text"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class ThinkingBlock:
|
|
35
|
+
"""Reasoning content. Kept in the transcript but never re-sent as cacheable prefix
|
|
36
|
+
unless the provider requires it."""
|
|
37
|
+
|
|
38
|
+
text: str
|
|
39
|
+
signature: str | None = None
|
|
40
|
+
type: Literal["thinking"] = "thinking"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class ToolUseBlock:
|
|
45
|
+
id: str
|
|
46
|
+
name: str
|
|
47
|
+
input: dict[str, Any]
|
|
48
|
+
type: Literal["tool_use"] = "tool_use"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class ToolResultBlock:
|
|
53
|
+
tool_use_id: str
|
|
54
|
+
content: str
|
|
55
|
+
is_error: bool = False
|
|
56
|
+
spilled_path: str | None = None
|
|
57
|
+
"""Set when the full output was capped and written to disk."""
|
|
58
|
+
type: Literal["tool_result"] = "tool_result"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True)
|
|
65
|
+
class Message:
|
|
66
|
+
"""One transcript entry.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
ephemeral: Late-injected content that is regenerated every turn and must
|
|
70
|
+
never be treated as part of a stable cache prefix. See
|
|
71
|
+
``hx.core.lateinject``.
|
|
72
|
+
compacted: Marks messages superseded by a compaction summary. They stay
|
|
73
|
+
in the session file for resume/undo but are excluded from context.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
role: Role
|
|
77
|
+
content: list[ContentBlock]
|
|
78
|
+
timestamp: float = field(default_factory=time.time)
|
|
79
|
+
model: str | None = None
|
|
80
|
+
ephemeral: bool = False
|
|
81
|
+
compacted: bool = False
|
|
82
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
def text(self) -> str:
|
|
85
|
+
"""Concatenated text blocks. Ignores thinking and tool blocks."""
|
|
86
|
+
return "".join(b.text for b in self.content if isinstance(b, TextBlock))
|
|
87
|
+
|
|
88
|
+
def tool_uses(self) -> list[ToolUseBlock]:
|
|
89
|
+
return [b for b in self.content if isinstance(b, ToolUseBlock)]
|
|
90
|
+
|
|
91
|
+
def tool_results(self) -> list[ToolResultBlock]:
|
|
92
|
+
return [b for b in self.content if isinstance(b, ToolResultBlock)]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_BLOCK_TYPES: dict[str, type[ContentBlock]] = {
|
|
96
|
+
"text": TextBlock,
|
|
97
|
+
"thinking": ThinkingBlock,
|
|
98
|
+
"tool_use": ToolUseBlock,
|
|
99
|
+
"tool_result": ToolResultBlock,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def block_to_dict(block: ContentBlock) -> dict[str, Any]:
|
|
104
|
+
if isinstance(block, TextBlock):
|
|
105
|
+
return {"type": "text", "text": block.text}
|
|
106
|
+
if isinstance(block, ThinkingBlock):
|
|
107
|
+
return {"type": "thinking", "text": block.text, "signature": block.signature}
|
|
108
|
+
if isinstance(block, ToolUseBlock):
|
|
109
|
+
return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
|
|
110
|
+
return {
|
|
111
|
+
"type": "tool_result",
|
|
112
|
+
"tool_use_id": block.tool_use_id,
|
|
113
|
+
"content": block.content,
|
|
114
|
+
"is_error": block.is_error,
|
|
115
|
+
"spilled_path": block.spilled_path,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def block_from_dict(data: dict[str, Any]) -> ContentBlock:
|
|
120
|
+
kind = data.get("type")
|
|
121
|
+
cls = _BLOCK_TYPES.get(str(kind))
|
|
122
|
+
if cls is None:
|
|
123
|
+
raise ValueError(f"unknown content block type: {kind!r}")
|
|
124
|
+
payload = {k: v for k, v in data.items() if k != "type"}
|
|
125
|
+
return cls(**payload)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def to_dict(message: Message) -> dict[str, Any]:
|
|
129
|
+
"""Serialise for the session JSONL."""
|
|
130
|
+
return {
|
|
131
|
+
"role": message.role,
|
|
132
|
+
"content": [block_to_dict(b) for b in message.content],
|
|
133
|
+
"timestamp": message.timestamp,
|
|
134
|
+
"model": message.model,
|
|
135
|
+
"ephemeral": message.ephemeral,
|
|
136
|
+
"compacted": message.compacted,
|
|
137
|
+
"metadata": message.metadata,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def from_dict(data: dict[str, Any]) -> Message:
|
|
142
|
+
"""Inverse of :func:`to_dict`. Must round-trip exactly."""
|
|
143
|
+
return Message(
|
|
144
|
+
role=data["role"],
|
|
145
|
+
content=[block_from_dict(b) for b in data.get("content", [])],
|
|
146
|
+
timestamp=data.get("timestamp", 0.0),
|
|
147
|
+
model=data.get("model"),
|
|
148
|
+
ephemeral=data.get("ephemeral", False),
|
|
149
|
+
compacted=data.get("compacted", False),
|
|
150
|
+
metadata=data.get("metadata", {}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def user_message(text: str) -> Message:
|
|
155
|
+
return Message(role="user", content=[TextBlock(text=text)])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def assistant_message(blocks: list[ContentBlock], model: str | None = None) -> Message:
|
|
159
|
+
return Message(role="assistant", content=list(blocks), model=model)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def tool_result_message(results: list[ToolResultBlock]) -> Message:
|
|
163
|
+
"""Tool results are carried on a ``user``-role message, matching the provider wire format."""
|
|
164
|
+
return Message(role="user", content=list(results))
|