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.
Files changed (64) hide show
  1. hx/__init__.py +5 -0
  2. hx/agents/__init__.py +1 -0
  3. hx/agents/definitions.py +106 -0
  4. hx/agents/subagent.py +190 -0
  5. hx/cli.py +667 -0
  6. hx/config.py +277 -0
  7. hx/core/__init__.py +1 -0
  8. hx/core/compaction.py +245 -0
  9. hx/core/context.py +271 -0
  10. hx/core/events.py +183 -0
  11. hx/core/lateinject.py +121 -0
  12. hx/core/loop.py +537 -0
  13. hx/core/messages.py +164 -0
  14. hx/core/session.py +208 -0
  15. hx/core/usage.py +129 -0
  16. hx/frontmatter.py +80 -0
  17. hx/mcp/__init__.py +1 -0
  18. hx/mcp/client.py +319 -0
  19. hx/mcp/manager.py +265 -0
  20. hx/paths.py +90 -0
  21. hx/permissions/__init__.py +7 -0
  22. hx/permissions/engine.py +406 -0
  23. hx/permissions/parser.py +306 -0
  24. hx/permissions/sandbox.py +227 -0
  25. hx/providers/__init__.py +1 -0
  26. hx/providers/base.py +77 -0
  27. hx/providers/fake.py +87 -0
  28. hx/providers/models.py +238 -0
  29. hx/providers/openrouter.py +468 -0
  30. hx/skills/__init__.py +1 -0
  31. hx/skills/loader.py +102 -0
  32. hx/skills/runtime.py +84 -0
  33. hx/tools/__init__.py +1 -0
  34. hx/tools/base.py +97 -0
  35. hx/tools/bash.py +544 -0
  36. hx/tools/edit.py +167 -0
  37. hx/tools/glob.py +75 -0
  38. hx/tools/grep.py +165 -0
  39. hx/tools/output.py +133 -0
  40. hx/tools/read.py +142 -0
  41. hx/tools/registry.py +149 -0
  42. hx/tools/task.py +76 -0
  43. hx/tools/todo.py +149 -0
  44. hx/tools/write.py +87 -0
  45. hx/tui/__init__.py +1 -0
  46. hx/tui/app.py +487 -0
  47. hx/tui/commands.py +399 -0
  48. hx/tui/hx.tcss +197 -0
  49. hx/tui/renderers.py +570 -0
  50. hx/tui/theme.py +322 -0
  51. hx/tui/widgets/__init__.py +1 -0
  52. hx/tui/widgets/configure.py +95 -0
  53. hx/tui/widgets/diff.py +25 -0
  54. hx/tui/widgets/input.py +145 -0
  55. hx/tui/widgets/palette.py +130 -0
  56. hx/tui/widgets/permission.py +97 -0
  57. hx/tui/widgets/statusbar.py +212 -0
  58. hx/tui/widgets/todos.py +116 -0
  59. hx/tui/widgets/transcript.py +316 -0
  60. hx/tui/widgets/working.py +78 -0
  61. hx_cli-0.1.0.dist-info/METADATA +430 -0
  62. hx_cli-0.1.0.dist-info/RECORD +64 -0
  63. hx_cli-0.1.0.dist-info/WHEEL +4 -0
  64. 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))