ai-push-hooks 0.2.0 → 0.3.0
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.
- package/CHANGELOG.md +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
|
@@ -3,18 +3,25 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import pathlib
|
|
6
|
+
import re
|
|
6
7
|
import stat
|
|
7
8
|
import sys
|
|
9
|
+
import threading
|
|
8
10
|
from dataclasses import dataclass, field
|
|
9
11
|
from datetime import datetime, timezone
|
|
10
|
-
from typing import Any
|
|
12
|
+
from typing import Any, ClassVar
|
|
11
13
|
|
|
12
|
-
READ_ONLY_STEP_TYPES = frozenset({"collect", "
|
|
13
|
-
PROMPTABLE_STEP_TYPES = frozenset({"
|
|
14
|
-
SUPPORTED_STEP_TYPES = frozenset({"collect", "
|
|
14
|
+
READ_ONLY_STEP_TYPES = frozenset({"collect", "ask"})
|
|
15
|
+
PROMPTABLE_STEP_TYPES = frozenset({"ask", "apply"})
|
|
16
|
+
SUPPORTED_STEP_TYPES = frozenset({"collect", "ask", "apply", "exec", "assert"})
|
|
15
17
|
FEATURE_BRANCH_PREFIXES = ("feat/", "feature/")
|
|
16
18
|
ZERO_OID_LENGTHS = frozenset({40, 64})
|
|
17
19
|
|
|
20
|
+
_ANSI_ESCAPE_PATTERN = re.compile(
|
|
21
|
+
r"(?:\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-_])"
|
|
22
|
+
)
|
|
23
|
+
_UNSAFE_TERMINAL_CONTROL_PATTERN = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
24
|
+
|
|
18
25
|
|
|
19
26
|
class HookError(RuntimeError):
|
|
20
27
|
pass
|
|
@@ -85,6 +92,17 @@ class LlmConfig:
|
|
|
85
92
|
session_title_prefix: str = "ai-push-hooks"
|
|
86
93
|
|
|
87
94
|
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class RunnerProfile:
|
|
97
|
+
type: str
|
|
98
|
+
name: str = ""
|
|
99
|
+
model: str | None = None
|
|
100
|
+
variant: str | None = None
|
|
101
|
+
project_access: str = "artifacts"
|
|
102
|
+
command: tuple[str, ...] = ()
|
|
103
|
+
prompt_transport: str = "stdin"
|
|
104
|
+
|
|
105
|
+
|
|
88
106
|
@dataclass(frozen=True)
|
|
89
107
|
class LoggingConfig:
|
|
90
108
|
level: str = "status"
|
|
@@ -110,7 +128,13 @@ class StepConfig:
|
|
|
110
128
|
allow_paths: tuple[str, ...] = ()
|
|
111
129
|
executor: str | None = None
|
|
112
130
|
assertion: str | None = None
|
|
131
|
+
python: str | None = None
|
|
132
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
133
|
+
command: tuple[str, ...] = ()
|
|
134
|
+
stdin: str | None = None
|
|
135
|
+
timeout_seconds: int | None = None
|
|
113
136
|
when_env: str | None = None
|
|
137
|
+
runner: str | None = None
|
|
114
138
|
|
|
115
139
|
@property
|
|
116
140
|
def is_read_only(self) -> bool:
|
|
@@ -140,6 +164,7 @@ class HookConfig:
|
|
|
140
164
|
logging: LoggingConfig
|
|
141
165
|
workflow: WorkflowConfig
|
|
142
166
|
modules: dict[str, ModuleConfig]
|
|
167
|
+
runners: dict[str, RunnerProfile] = field(default_factory=dict)
|
|
143
168
|
|
|
144
169
|
|
|
145
170
|
@dataclass
|
|
@@ -180,7 +205,7 @@ class RuntimeContext:
|
|
|
180
205
|
repo_root: pathlib.Path
|
|
181
206
|
git_dir: pathlib.Path
|
|
182
207
|
config: HookConfig
|
|
183
|
-
logger:
|
|
208
|
+
logger: HookLogger
|
|
184
209
|
remote_name: str
|
|
185
210
|
remote_url: str
|
|
186
211
|
stdin_lines: list[str]
|
|
@@ -202,8 +227,14 @@ class HookLogger:
|
|
|
202
227
|
console_level: str = "status"
|
|
203
228
|
jsonl_write_failed: bool = False
|
|
204
229
|
llm_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
230
|
+
_lock: threading.RLock = field(
|
|
231
|
+
default_factory=threading.RLock,
|
|
232
|
+
init=False,
|
|
233
|
+
repr=False,
|
|
234
|
+
compare=False,
|
|
235
|
+
)
|
|
205
236
|
|
|
206
|
-
_verbosity_order = {"status": 0, "info": 1, "debug": 2}
|
|
237
|
+
_verbosity_order: ClassVar[dict[str, int]] = {"status": 0, "info": 1, "debug": 2}
|
|
207
238
|
|
|
208
239
|
def _level_is_enabled(self, level: str) -> bool:
|
|
209
240
|
if level in {"warn", "error"}:
|
|
@@ -212,55 +243,247 @@ class HookLogger:
|
|
|
212
243
|
required = self._verbosity_order.get(level, 0)
|
|
213
244
|
return configured >= required
|
|
214
245
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _safe_text(value: object) -> str:
|
|
248
|
+
"""Remove terminal escape sequences without changing ordinary text."""
|
|
249
|
+
|
|
250
|
+
return _UNSAFE_TERMINAL_CONTROL_PATTERN.sub(
|
|
251
|
+
"", _ANSI_ESCAPE_PATTERN.sub("", str(value))
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def _safe_json_value(cls, value: Any) -> Any:
|
|
256
|
+
"""Keep structured log values JSON-compatible and terminal-safe."""
|
|
257
|
+
|
|
258
|
+
if isinstance(value, str):
|
|
259
|
+
return cls._safe_text(value)
|
|
260
|
+
if isinstance(value, dict):
|
|
261
|
+
return {
|
|
262
|
+
cls._safe_text(key): cls._safe_json_value(item)
|
|
263
|
+
for key, item in value.items()
|
|
264
|
+
}
|
|
265
|
+
if isinstance(value, (list, tuple)):
|
|
266
|
+
return [cls._safe_json_value(item) for item in value]
|
|
267
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
268
|
+
return value
|
|
269
|
+
return cls._safe_text(value)
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _colors_enabled() -> bool:
|
|
273
|
+
"""Resolve color policy at emission time so tests and embedding callers can override it."""
|
|
274
|
+
|
|
275
|
+
if "NO_COLOR" in os.environ:
|
|
276
|
+
return False
|
|
277
|
+
force_color = os.environ.get("FORCE_COLOR")
|
|
278
|
+
if force_color == "0":
|
|
279
|
+
return False
|
|
280
|
+
if force_color:
|
|
281
|
+
return True
|
|
282
|
+
if os.environ.get("TERM") == "dumb":
|
|
283
|
+
return False
|
|
223
284
|
try:
|
|
285
|
+
return bool(sys.stderr.isatty())
|
|
286
|
+
except (AttributeError, OSError):
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def _style(cls, value: str, code: str, colors_enabled: bool) -> str:
|
|
291
|
+
if not colors_enabled:
|
|
292
|
+
return value
|
|
293
|
+
return f"\x1b[{code}m{value}\x1b[0m"
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def _stage_for_console(cls, stage_name: object, colors_enabled: bool) -> str:
|
|
297
|
+
safe_stage = cls._safe_text(stage_name).replace("\n", "\\n").replace("\t", " ")
|
|
298
|
+
if not colors_enabled:
|
|
299
|
+
return safe_stage
|
|
300
|
+
if "." not in safe_stage:
|
|
301
|
+
return cls._style(safe_stage, "36", colors_enabled)
|
|
302
|
+
module, step = safe_stage.split(".", 1)
|
|
303
|
+
return (
|
|
304
|
+
cls._style(module, "36", colors_enabled)
|
|
305
|
+
+ cls._style(".", "2", colors_enabled)
|
|
306
|
+
+ cls._style(step, "35", colors_enabled)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
@classmethod
|
|
310
|
+
def _semantic_body(
|
|
311
|
+
cls,
|
|
312
|
+
event: str,
|
|
313
|
+
message: str,
|
|
314
|
+
fields: dict[str, Any],
|
|
315
|
+
colors_enabled: bool,
|
|
316
|
+
level: str,
|
|
317
|
+
) -> str:
|
|
318
|
+
safe_message = cls._safe_text(message).replace("\n", "\\n").replace("\t", " ")
|
|
319
|
+
if not colors_enabled:
|
|
320
|
+
return safe_message
|
|
321
|
+
|
|
322
|
+
if event == "llm.call" and {
|
|
323
|
+
"call_number",
|
|
324
|
+
"stage_name",
|
|
325
|
+
"purpose",
|
|
326
|
+
}.issubset(fields):
|
|
327
|
+
call_number = cls._safe_text(fields.get("call_number", ""))
|
|
328
|
+
stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
|
|
329
|
+
purpose = cls._style(
|
|
330
|
+
cls._safe_text(fields.get("purpose", "")).replace("\n", "\\n").replace("\t", " "),
|
|
331
|
+
"34",
|
|
332
|
+
colors_enabled,
|
|
333
|
+
)
|
|
334
|
+
return (
|
|
335
|
+
"LLM call "
|
|
336
|
+
+ cls._style(f"#{call_number}", "1", colors_enabled)
|
|
337
|
+
+ cls._style(":", "2", colors_enabled)
|
|
338
|
+
+ " "
|
|
339
|
+
+ stage
|
|
340
|
+
+ cls._style(" - ", "2", colors_enabled)
|
|
341
|
+
+ purpose
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if event == "llm.complete" and {
|
|
345
|
+
"call_number",
|
|
346
|
+
"stage_name",
|
|
347
|
+
"runner_profile",
|
|
348
|
+
"runner_type",
|
|
349
|
+
}.issubset(fields):
|
|
350
|
+
call_number = cls._safe_text(fields.get("call_number", ""))
|
|
351
|
+
stage = cls._stage_for_console(fields.get("stage_name", ""), colors_enabled)
|
|
352
|
+
profile = cls._style(
|
|
353
|
+
cls._safe_text(fields.get("runner_profile", "")).replace("\n", "\\n").replace("\t", " "),
|
|
354
|
+
"34",
|
|
355
|
+
colors_enabled,
|
|
356
|
+
)
|
|
357
|
+
runner_type = cls._style(
|
|
358
|
+
cls._safe_text(fields.get("runner_type", "")).replace("\n", "\\n").replace("\t", " "),
|
|
359
|
+
"34",
|
|
360
|
+
colors_enabled,
|
|
361
|
+
)
|
|
362
|
+
failed = bool(fields.get("failed", False)) or level == "error"
|
|
363
|
+
label = "LLM failed" if failed else "LLM complete"
|
|
364
|
+
label_color = "31" if failed else "32"
|
|
365
|
+
body = (
|
|
366
|
+
cls._style(label, label_color, colors_enabled)
|
|
367
|
+
+ " "
|
|
368
|
+
+ cls._style(f"#{call_number}", "1", colors_enabled)
|
|
369
|
+
+ cls._style(":", "2", colors_enabled)
|
|
370
|
+
+ " "
|
|
371
|
+
+ stage
|
|
372
|
+
+ cls._style(" (", "2", colors_enabled)
|
|
373
|
+
+ profile
|
|
374
|
+
+ cls._style("/", "2", colors_enabled)
|
|
375
|
+
+ runner_type
|
|
376
|
+
+ cls._style(")", "2", colors_enabled)
|
|
377
|
+
)
|
|
378
|
+
if "; " in safe_message:
|
|
379
|
+
body += cls._style("; " + safe_message.split("; ", 1)[1], "2", colors_enabled)
|
|
380
|
+
return body
|
|
381
|
+
|
|
382
|
+
return safe_message
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def _console_prefix(cls, level: str, event: str, fields: dict[str, Any]) -> str:
|
|
386
|
+
prefix = "[ai-push-hooks]"
|
|
387
|
+
colors_enabled = cls._colors_enabled()
|
|
388
|
+
if not colors_enabled:
|
|
389
|
+
return prefix
|
|
390
|
+
color = {
|
|
391
|
+
"warn": "\x1b[33m",
|
|
392
|
+
"error": "\x1b[31m",
|
|
393
|
+
}.get(level, "\x1b[36m")
|
|
394
|
+
if event == "llm.complete" and {
|
|
395
|
+
"call_number",
|
|
396
|
+
"stage_name",
|
|
397
|
+
"runner_profile",
|
|
398
|
+
"runner_type",
|
|
399
|
+
}.issubset(fields):
|
|
400
|
+
color = (
|
|
401
|
+
"\x1b[31m"
|
|
402
|
+
if level == "error" or fields.get("failed", False)
|
|
403
|
+
else "\x1b[32m"
|
|
404
|
+
)
|
|
405
|
+
return f"{color}{prefix}\x1b[0m"
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def _console_message(
|
|
409
|
+
cls,
|
|
410
|
+
level: str,
|
|
411
|
+
message: str,
|
|
412
|
+
*,
|
|
413
|
+
event: str = "",
|
|
414
|
+
fields: dict[str, Any] | None = None,
|
|
415
|
+
) -> str:
|
|
416
|
+
# A log event is deliberately one physical line. This prevents an
|
|
417
|
+
# untrusted stage/profile/message from creating a fake prompt or log line.
|
|
418
|
+
safe_fields = fields or {}
|
|
419
|
+
colors_enabled = cls._colors_enabled()
|
|
420
|
+
body = cls._semantic_body(event, message, safe_fields, colors_enabled, level)
|
|
421
|
+
prefix = cls._console_prefix(level, event, safe_fields)
|
|
422
|
+
return f"{prefix} {body}\n"
|
|
423
|
+
|
|
424
|
+
def _emit(self, level: str, event: str, message: str, **fields: Any) -> None:
|
|
425
|
+
with self._lock:
|
|
426
|
+
if not self._level_is_enabled(level):
|
|
427
|
+
return
|
|
428
|
+
sys.stderr.write(
|
|
429
|
+
self._console_message(level, message, event=event, fields=fields)
|
|
430
|
+
)
|
|
431
|
+
if self.jsonl_path is None or self.jsonl_write_failed:
|
|
432
|
+
return
|
|
433
|
+
stamp = datetime.now(timezone.utc).isoformat()
|
|
434
|
+
safe_fields = self._safe_json_value(fields)
|
|
435
|
+
record = {
|
|
436
|
+
**safe_fields,
|
|
437
|
+
"ts": stamp,
|
|
438
|
+
"level": self._safe_text(level),
|
|
439
|
+
"event": self._safe_text(event),
|
|
440
|
+
"message": self._safe_text(message),
|
|
441
|
+
}
|
|
224
442
|
try:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
443
|
+
try:
|
|
444
|
+
initial_metadata = self.jsonl_path.lstat()
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
initial_metadata = None
|
|
447
|
+
if initial_metadata is not None:
|
|
448
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
449
|
+
if stat.S_ISLNK(initial_metadata.st_mode) or bool(
|
|
450
|
+
getattr(initial_metadata, "st_file_attributes", 0) & reparse_flag
|
|
451
|
+
):
|
|
452
|
+
raise HookError(
|
|
453
|
+
"JSONL log target must not be a symlink or reparse point: "
|
|
454
|
+
f"{self.jsonl_path}"
|
|
455
|
+
)
|
|
456
|
+
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
|
|
457
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
458
|
+
descriptor = os.open(self.jsonl_path, flags, 0o600)
|
|
459
|
+
try:
|
|
460
|
+
descriptor_metadata = os.fstat(descriptor)
|
|
461
|
+
path_metadata = self.jsonl_path.lstat()
|
|
462
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
463
|
+
if (
|
|
464
|
+
not stat.S_ISREG(descriptor_metadata.st_mode)
|
|
465
|
+
or stat.S_ISLNK(path_metadata.st_mode)
|
|
466
|
+
or bool(
|
|
467
|
+
getattr(path_metadata, "st_file_attributes", 0) & reparse_flag
|
|
468
|
+
)
|
|
469
|
+
or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
|
|
470
|
+
!= (path_metadata.st_dev, path_metadata.st_ino)
|
|
471
|
+
):
|
|
472
|
+
raise HookError(f"JSONL log target is not a regular file: {self.jsonl_path}")
|
|
473
|
+
os.fchmod(descriptor, 0o600)
|
|
474
|
+
os.write(
|
|
475
|
+
descriptor,
|
|
476
|
+
(json.dumps(record, ensure_ascii=True) + "\n").encode("utf-8"),
|
|
236
477
|
)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
not stat.S_ISREG(descriptor_metadata.st_mode)
|
|
246
|
-
or stat.S_ISLNK(path_metadata.st_mode)
|
|
247
|
-
or bool(
|
|
248
|
-
getattr(path_metadata, "st_file_attributes", 0) & reparse_flag
|
|
478
|
+
finally:
|
|
479
|
+
os.close(descriptor)
|
|
480
|
+
except Exception as exc: # noqa: BLE001
|
|
481
|
+
self.jsonl_write_failed = True
|
|
482
|
+
sys.stderr.write(
|
|
483
|
+
self._console_message(
|
|
484
|
+
"error", f"JSONL logging disabled after write failure: {exc}"
|
|
249
485
|
)
|
|
250
|
-
or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
|
|
251
|
-
!= (path_metadata.st_dev, path_metadata.st_ino)
|
|
252
|
-
):
|
|
253
|
-
raise HookError(f"JSONL log target is not a regular file: {self.jsonl_path}")
|
|
254
|
-
os.fchmod(descriptor, 0o600)
|
|
255
|
-
os.write(
|
|
256
|
-
descriptor,
|
|
257
|
-
(json.dumps(record, ensure_ascii=True) + "\n").encode("utf-8"),
|
|
258
486
|
)
|
|
259
|
-
finally:
|
|
260
|
-
os.close(descriptor)
|
|
261
|
-
except Exception as exc: # noqa: BLE001
|
|
262
|
-
self.jsonl_write_failed = True
|
|
263
|
-
sys.stderr.write(f"[ai-push-hooks] JSONL logging disabled after write failure: {exc}\n")
|
|
264
487
|
|
|
265
488
|
def debug(self, event: str, message: str, **fields: Any) -> None:
|
|
266
489
|
self._emit("debug", event, message, **fields)
|
|
@@ -284,33 +507,141 @@ class HookLogger:
|
|
|
284
507
|
model: str,
|
|
285
508
|
attempt: int | None = None,
|
|
286
509
|
total_attempts: int | None = None,
|
|
510
|
+
*,
|
|
511
|
+
runner_profile: str | None = None,
|
|
512
|
+
runner_type: str | None = None,
|
|
513
|
+
) -> int:
|
|
514
|
+
with self._lock:
|
|
515
|
+
call_number = len(self.llm_calls) + 1
|
|
516
|
+
safe_stage = self._safe_text(stage_name)
|
|
517
|
+
safe_purpose = self._safe_text(purpose)
|
|
518
|
+
record: dict[str, Any] = {
|
|
519
|
+
"call_number": call_number,
|
|
520
|
+
"stage_name": safe_stage,
|
|
521
|
+
"purpose": safe_purpose,
|
|
522
|
+
"model": self._safe_text(model),
|
|
523
|
+
"module": safe_stage.split(".", 1)[0],
|
|
524
|
+
"step": safe_stage.split(".", 1)[1] if "." in safe_stage else safe_stage,
|
|
525
|
+
}
|
|
526
|
+
if attempt is not None:
|
|
527
|
+
record["attempt"] = attempt
|
|
528
|
+
if total_attempts is not None:
|
|
529
|
+
record["total_attempts"] = total_attempts
|
|
530
|
+
if runner_profile is not None:
|
|
531
|
+
record["runner_profile"] = self._safe_text(runner_profile)
|
|
532
|
+
if runner_type is not None:
|
|
533
|
+
record["runner_type"] = self._safe_text(runner_type)
|
|
534
|
+
self.llm_calls.append(record)
|
|
535
|
+
self.status(
|
|
536
|
+
"llm.call",
|
|
537
|
+
f"LLM call #{call_number}: {safe_stage} - {safe_purpose}",
|
|
538
|
+
**record,
|
|
539
|
+
)
|
|
540
|
+
return call_number
|
|
541
|
+
|
|
542
|
+
def llm_complete(
|
|
543
|
+
self,
|
|
544
|
+
call_number: int,
|
|
545
|
+
stage_name: str,
|
|
546
|
+
runner_profile: str,
|
|
547
|
+
runner_type: str,
|
|
548
|
+
*,
|
|
549
|
+
session_id: str | None = None,
|
|
550
|
+
session_state: str | None = None,
|
|
551
|
+
resumable: bool = False,
|
|
552
|
+
transcript: str | None = None,
|
|
553
|
+
resume_command: str | None = None,
|
|
554
|
+
failed: bool = False,
|
|
287
555
|
) -> None:
|
|
288
|
-
|
|
289
|
-
|
|
556
|
+
"""Record truthful completion/session details without inventing resume data."""
|
|
557
|
+
|
|
558
|
+
safe_stage = self._safe_text(stage_name)
|
|
559
|
+
safe_profile = self._safe_text(runner_profile)
|
|
560
|
+
safe_type = self._safe_text(runner_type)
|
|
561
|
+
safe_state = self._safe_text(session_state) if session_state is not None else None
|
|
562
|
+
safe_session_id = self._safe_text(session_id) if session_id is not None else None
|
|
563
|
+
safe_transcript = self._safe_text(transcript) if transcript is not None else None
|
|
564
|
+
safe_resume_command = (
|
|
565
|
+
self._safe_text(resume_command) if resume_command is not None else None
|
|
566
|
+
)
|
|
567
|
+
effective_resume_command = (
|
|
568
|
+
safe_resume_command
|
|
569
|
+
if safe_state == "persisted" and resumable
|
|
570
|
+
else None
|
|
571
|
+
)
|
|
572
|
+
session_details: list[str] = []
|
|
573
|
+
if safe_state == "persisted":
|
|
574
|
+
if safe_session_id:
|
|
575
|
+
session_details.append(f"session persisted: {safe_session_id}")
|
|
576
|
+
else:
|
|
577
|
+
session_details.append("session persisted")
|
|
578
|
+
if effective_resume_command:
|
|
579
|
+
session_details.append(f"resume: {effective_resume_command}")
|
|
580
|
+
elif not resumable:
|
|
581
|
+
session_details.append("not resumable")
|
|
582
|
+
if safe_transcript:
|
|
583
|
+
session_details.append(f"transcript: {safe_transcript}")
|
|
584
|
+
elif safe_state == "deleted":
|
|
585
|
+
if safe_session_id:
|
|
586
|
+
session_details.append(f"session deleted: {safe_session_id}")
|
|
587
|
+
else:
|
|
588
|
+
session_details.append("session deleted")
|
|
589
|
+
if safe_transcript:
|
|
590
|
+
session_details.append(f"transcript: {safe_transcript}")
|
|
591
|
+
elif safe_state == "ephemeral":
|
|
592
|
+
if safe_session_id:
|
|
593
|
+
session_details.append(f"session: {safe_session_id}")
|
|
594
|
+
session_details.append("not resumable")
|
|
595
|
+
elif safe_state is not None:
|
|
596
|
+
session_details.append(f"session {safe_state}")
|
|
597
|
+
elif safe_session_id:
|
|
598
|
+
session_details.append(f"session: {safe_session_id}")
|
|
599
|
+
|
|
600
|
+
message = (
|
|
601
|
+
f"LLM failed #{call_number}: {safe_stage} ({safe_profile}/{safe_type})"
|
|
602
|
+
if failed
|
|
603
|
+
else f"LLM complete #{call_number}: {safe_stage} ({safe_profile}/{safe_type})"
|
|
604
|
+
)
|
|
605
|
+
if session_details:
|
|
606
|
+
message += "; " + "; ".join(session_details)
|
|
607
|
+
fields: dict[str, Any] = {
|
|
290
608
|
"call_number": call_number,
|
|
291
|
-
"stage_name":
|
|
292
|
-
"
|
|
293
|
-
"
|
|
609
|
+
"stage_name": safe_stage,
|
|
610
|
+
"module": safe_stage.split(".", 1)[0],
|
|
611
|
+
"step": safe_stage.split(".", 1)[1] if "." in safe_stage else safe_stage,
|
|
612
|
+
"runner_profile": safe_profile,
|
|
613
|
+
"runner_type": safe_type,
|
|
614
|
+
"failed": failed,
|
|
294
615
|
}
|
|
295
|
-
if
|
|
296
|
-
|
|
297
|
-
if
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
616
|
+
if safe_session_id is not None:
|
|
617
|
+
fields["session_id"] = safe_session_id
|
|
618
|
+
if safe_state is not None:
|
|
619
|
+
fields["session_state"] = safe_state
|
|
620
|
+
if safe_transcript is not None:
|
|
621
|
+
fields["transcript"] = safe_transcript
|
|
622
|
+
if effective_resume_command is not None:
|
|
623
|
+
fields["resume_command"] = effective_resume_command
|
|
624
|
+
if any(
|
|
625
|
+
value is not None
|
|
626
|
+
for value in (
|
|
627
|
+
safe_session_id,
|
|
628
|
+
safe_state,
|
|
629
|
+
safe_transcript,
|
|
630
|
+
effective_resume_command,
|
|
631
|
+
)
|
|
632
|
+
) or resumable:
|
|
633
|
+
fields["resumable"] = resumable
|
|
634
|
+
self.status("llm.complete", message, **fields)
|
|
305
635
|
|
|
306
636
|
def llm_summary(self) -> None:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
637
|
+
with self._lock:
|
|
638
|
+
stage_counts: dict[str, int] = {}
|
|
639
|
+
for call in self.llm_calls:
|
|
640
|
+
stage_name = str(call.get("stage_name", "")).strip() or "<unknown>"
|
|
641
|
+
stage_counts[stage_name] = stage_counts.get(stage_name, 0) + 1
|
|
642
|
+
self.status(
|
|
643
|
+
"llm.calls_total",
|
|
644
|
+
f"Total LLM calls this run: {len(self.llm_calls)}",
|
|
645
|
+
total_calls=len(self.llm_calls),
|
|
646
|
+
stage_counts=stage_counts,
|
|
647
|
+
)
|