ai-push-hooks 0.1.19 → 0.2.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 +63 -0
- package/README.md +296 -62
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +25 -4
- package/pyproject.toml +11 -4
- package/src/ai_push_hooks/artifacts.py +57 -9
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +281 -15
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +845 -43
- package/src/ai_push_hooks/executors/exec.py +734 -114
- package/src/ai_push_hooks/executors/llm.py +369 -42
- package/src/ai_push_hooks/hook.py +129 -22
- package/src/ai_push_hooks/install.py +205 -0
- package/src/ai_push_hooks/modules/beads.py +21 -6
- package/src/ai_push_hooks/modules/docs.py +147 -27
- package/src/ai_push_hooks/modules/pr.py +53 -7
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +6 -2
- package/src/ai_push_hooks/types.py +82 -3
|
@@ -1,15 +1,126 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import pathlib
|
|
5
6
|
import re
|
|
6
7
|
import shutil
|
|
8
|
+
import tempfile
|
|
7
9
|
from dataclasses import dataclass
|
|
8
10
|
from typing import Any
|
|
9
11
|
|
|
12
|
+
from ..paths import (
|
|
13
|
+
ensure_private_directory,
|
|
14
|
+
is_path_within,
|
|
15
|
+
path_has_symlink,
|
|
16
|
+
resolve_contained_path,
|
|
17
|
+
write_text_no_follow,
|
|
18
|
+
)
|
|
10
19
|
from ..types import HookError, RuntimeContext, StepConfig
|
|
11
20
|
from .exec import ensure_dir, extract_pr_url, resolve_storage_path, run_command
|
|
12
21
|
|
|
22
|
+
OPENCODE_READ_ONLY_AGENT = "ai-push-hooks-readonly"
|
|
23
|
+
OPENCODE_APPLY_AGENT = "ai-push-hooks-apply"
|
|
24
|
+
OPENCODE_AGENT_POLICIES = frozenset({"read-only", "apply"})
|
|
25
|
+
PROVIDER_ENV_PREFIXES = (
|
|
26
|
+
"ANTHROPIC_",
|
|
27
|
+
"AWS_",
|
|
28
|
+
"AZURE_",
|
|
29
|
+
"COHERE_",
|
|
30
|
+
"DEEPSEEK_",
|
|
31
|
+
"GEMINI_",
|
|
32
|
+
"GOOGLE_",
|
|
33
|
+
"GROQ_",
|
|
34
|
+
"MISTRAL_",
|
|
35
|
+
"OPENAI_",
|
|
36
|
+
"OPENROUTER_",
|
|
37
|
+
"VERTEX_",
|
|
38
|
+
"XAI_",
|
|
39
|
+
)
|
|
40
|
+
SAFE_PROCESS_ENV_NAMES = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"PATH",
|
|
43
|
+
"TMPDIR",
|
|
44
|
+
"TMP",
|
|
45
|
+
"TEMP",
|
|
46
|
+
"LANG",
|
|
47
|
+
"LC_ALL",
|
|
48
|
+
"LC_CTYPE",
|
|
49
|
+
"SYSTEMROOT",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _actual_xdg_data_home() -> pathlib.Path:
|
|
55
|
+
configured = os.environ.get("XDG_DATA_HOME", "").strip()
|
|
56
|
+
if configured:
|
|
57
|
+
return pathlib.Path(configured).expanduser().resolve(strict=False)
|
|
58
|
+
return (pathlib.Path.home() / ".local" / "share").resolve(strict=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def opencode_isolation_env(
|
|
62
|
+
context: RuntimeContext,
|
|
63
|
+
security_config: dict[str, Any],
|
|
64
|
+
stage_name: str,
|
|
65
|
+
) -> dict[str, str | None]:
|
|
66
|
+
lexical_isolation_root = (
|
|
67
|
+
context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
|
|
68
|
+
)
|
|
69
|
+
if path_has_symlink(context.run_dir, lexical_isolation_root):
|
|
70
|
+
raise HookError(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
|
|
71
|
+
isolation_root = resolve_contained_path(
|
|
72
|
+
context.run_dir,
|
|
73
|
+
f"opencode-isolation/{sanitize_filename_component(stage_name)}",
|
|
74
|
+
"OpenCode isolation directory",
|
|
75
|
+
)
|
|
76
|
+
home = isolation_root / "home"
|
|
77
|
+
config_home = isolation_root / "config"
|
|
78
|
+
cache_home = isolation_root / "cache"
|
|
79
|
+
state_home = isolation_root / "state"
|
|
80
|
+
ensure_private_directory(isolation_root)
|
|
81
|
+
for path in (home, config_home, cache_home, state_home):
|
|
82
|
+
ensure_private_directory(path, private_root=isolation_root)
|
|
83
|
+
|
|
84
|
+
isolated: dict[str, str | None] = {
|
|
85
|
+
name: value
|
|
86
|
+
for name, value in os.environ.items()
|
|
87
|
+
if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
|
|
88
|
+
}
|
|
89
|
+
isolated.update({
|
|
90
|
+
"HOME": str(home),
|
|
91
|
+
"XDG_CONFIG_HOME": str(config_home),
|
|
92
|
+
"XDG_CACHE_HOME": str(cache_home),
|
|
93
|
+
"XDG_STATE_HOME": str(state_home),
|
|
94
|
+
"XDG_DATA_HOME": str(_actual_xdg_data_home()),
|
|
95
|
+
"OPENCODE_CONFIG_CONTENT": json.dumps(security_config, ensure_ascii=True),
|
|
96
|
+
"OPENCODE_CONFIG_DIR": str(config_home),
|
|
97
|
+
"OPENCODE_PURE": "true",
|
|
98
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
|
|
99
|
+
"OPENCODE_DISABLE_EXTERNAL_SKILLS": "true",
|
|
100
|
+
"OPENCODE_DISABLE_CLAUDE_CODE": "true",
|
|
101
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
|
|
102
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
|
|
103
|
+
"OPENCODE_DISABLE_DEFAULT_PLUGINS": "true",
|
|
104
|
+
"OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
|
|
105
|
+
"OPENCODE_DISABLE_SHARE": "true",
|
|
106
|
+
"OPENCODE_DISABLE_AUTOUPDATE": "true",
|
|
107
|
+
})
|
|
108
|
+
return isolated
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def non_agent_opencode_config() -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"$schema": "https://opencode.ai/config.json",
|
|
114
|
+
"plugin": [],
|
|
115
|
+
"mcp": {},
|
|
116
|
+
"share": "disabled",
|
|
117
|
+
"instructions": [],
|
|
118
|
+
"formatter": False,
|
|
119
|
+
"lsp": False,
|
|
120
|
+
"command": {},
|
|
121
|
+
"permission": {"*": "deny"},
|
|
122
|
+
}
|
|
123
|
+
|
|
13
124
|
|
|
14
125
|
@dataclass
|
|
15
126
|
class OpenCodeRunResult:
|
|
@@ -135,31 +246,58 @@ def export_opencode_session_json(
|
|
|
135
246
|
session_id: str,
|
|
136
247
|
export_path: pathlib.Path,
|
|
137
248
|
) -> bool:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
249
|
+
with tempfile.TemporaryDirectory(
|
|
250
|
+
prefix="ai-push-hooks-session-export-"
|
|
251
|
+
) as temporary_directory:
|
|
252
|
+
completed = run_command(
|
|
253
|
+
[
|
|
254
|
+
context.opencode_executable or resolve_opencode_executable(),
|
|
255
|
+
"export",
|
|
256
|
+
session_id,
|
|
257
|
+
"--pure",
|
|
258
|
+
],
|
|
259
|
+
cwd=pathlib.Path(temporary_directory).resolve(strict=True),
|
|
260
|
+
timeout=context.config.llm.timeout_seconds,
|
|
261
|
+
check=False,
|
|
262
|
+
env=opencode_isolation_env(context, non_agent_opencode_config(), "session-export"),
|
|
263
|
+
inherit_env=False,
|
|
264
|
+
)
|
|
144
265
|
if completed.returncode != 0:
|
|
145
266
|
return False
|
|
146
267
|
payload = (completed.stdout or "").strip()
|
|
147
268
|
if not payload:
|
|
148
269
|
return False
|
|
149
|
-
export_path
|
|
270
|
+
write_text_no_follow(export_path, payload + "\n")
|
|
150
271
|
return True
|
|
151
272
|
|
|
152
273
|
|
|
153
274
|
def delete_opencode_session(context: RuntimeContext, session_id: str) -> None:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
275
|
+
with tempfile.TemporaryDirectory(
|
|
276
|
+
prefix="ai-push-hooks-session-delete-"
|
|
277
|
+
) as temporary_directory:
|
|
278
|
+
run_command(
|
|
279
|
+
[
|
|
280
|
+
context.opencode_executable or resolve_opencode_executable(),
|
|
281
|
+
"session",
|
|
282
|
+
"delete",
|
|
283
|
+
session_id,
|
|
284
|
+
"--pure",
|
|
285
|
+
],
|
|
286
|
+
cwd=pathlib.Path(temporary_directory).resolve(strict=True),
|
|
287
|
+
timeout=context.config.llm.timeout_seconds,
|
|
288
|
+
check=False,
|
|
289
|
+
env=opencode_isolation_env(context, non_agent_opencode_config(), "session-delete"),
|
|
290
|
+
inherit_env=False,
|
|
291
|
+
)
|
|
160
292
|
|
|
161
293
|
|
|
162
294
|
def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_id: str | None) -> None:
|
|
295
|
+
"""Capture and finalize a session without retaining it on export failure.
|
|
296
|
+
|
|
297
|
+
Transcript capture is best effort. A failed or interrupted export emits a
|
|
298
|
+
visible warning, then the configured deletion policy still runs so a
|
|
299
|
+
failed capture does not silently retain provider data.
|
|
300
|
+
"""
|
|
163
301
|
if not session_id:
|
|
164
302
|
return
|
|
165
303
|
transcript_dir = _transcript_dir(context)
|
|
@@ -169,26 +307,192 @@ def finalize_opencode_session(context: RuntimeContext, stage_name: str, session_
|
|
|
169
307
|
f"{sanitize_filename_component(stage_name)}-"
|
|
170
308
|
f"{sanitize_filename_component(session_id)}.json"
|
|
171
309
|
)
|
|
172
|
-
|
|
310
|
+
export_path = resolve_contained_path(
|
|
311
|
+
transcript_dir,
|
|
312
|
+
export_name,
|
|
313
|
+
"OpenCode transcript path",
|
|
314
|
+
)
|
|
315
|
+
export_failure: str | None = None
|
|
316
|
+
try:
|
|
317
|
+
exported = export_opencode_session_json(context, session_id, export_path)
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
exported = False
|
|
320
|
+
export_failure = type(exc).__name__
|
|
321
|
+
if not exported:
|
|
322
|
+
context.logger.warn(
|
|
323
|
+
"llm.transcript_export_failed",
|
|
324
|
+
"Could not capture the OpenCode transcript; applying configured session deletion.",
|
|
325
|
+
stage_name=stage_name,
|
|
326
|
+
session_id=session_id,
|
|
327
|
+
reason=export_failure or "export returned no transcript",
|
|
328
|
+
)
|
|
173
329
|
if context.config.llm.delete_session_after_run:
|
|
174
330
|
delete_opencode_session(context, session_id)
|
|
175
331
|
|
|
176
332
|
|
|
333
|
+
def build_opencode_security_config(
|
|
334
|
+
agent_policy: str,
|
|
335
|
+
allow_paths: tuple[str, ...] = (),
|
|
336
|
+
*,
|
|
337
|
+
non_vcs_working_directory: pathlib.Path | None = None,
|
|
338
|
+
) -> tuple[str, dict[str, Any]]:
|
|
339
|
+
permissions: dict[str, Any] = {
|
|
340
|
+
"*": "deny",
|
|
341
|
+
"read": "deny",
|
|
342
|
+
"glob": "deny",
|
|
343
|
+
"grep": "deny",
|
|
344
|
+
"list": "deny",
|
|
345
|
+
"edit": "deny",
|
|
346
|
+
"bash": "deny",
|
|
347
|
+
"task": "deny",
|
|
348
|
+
"external_directory": "deny",
|
|
349
|
+
"webfetch": "deny",
|
|
350
|
+
"websearch": "deny",
|
|
351
|
+
"lsp": "deny",
|
|
352
|
+
"skill": "deny",
|
|
353
|
+
"todowrite": "deny",
|
|
354
|
+
"question": "deny",
|
|
355
|
+
}
|
|
356
|
+
if agent_policy == "read-only":
|
|
357
|
+
agent_name = OPENCODE_READ_ONLY_AGENT
|
|
358
|
+
description = "Read-only ai-push-hooks analysis agent"
|
|
359
|
+
elif agent_policy == "apply":
|
|
360
|
+
if not allow_paths:
|
|
361
|
+
raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
|
|
362
|
+
agent_name = OPENCODE_APPLY_AGENT
|
|
363
|
+
description = "Path-restricted ai-push-hooks apply agent"
|
|
364
|
+
permissions["read"] = "allow"
|
|
365
|
+
permission_patterns: set[str] = set()
|
|
366
|
+
for pattern in allow_paths:
|
|
367
|
+
permission_patterns.add(pattern)
|
|
368
|
+
collapsed = pattern
|
|
369
|
+
while "**/" in collapsed:
|
|
370
|
+
collapsed = collapsed.replace("**/", "", 1)
|
|
371
|
+
permission_patterns.add(collapsed)
|
|
372
|
+
protected_patterns = {".git", ".git/**"}
|
|
373
|
+
if non_vcs_working_directory is not None:
|
|
374
|
+
# OpenCode 1.18.29 assigns `/` as the worktree for a directory
|
|
375
|
+
# without VCS metadata. Its write and edit tools then request the
|
|
376
|
+
# `edit` permission using paths relative to that filesystem root,
|
|
377
|
+
# not relative to the process cwd. Keep the staging checkout free
|
|
378
|
+
# of Git metadata and qualify only its allowlisted paths.
|
|
379
|
+
anchor = pathlib.Path(non_vcs_working_directory.anchor)
|
|
380
|
+
prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
|
|
381
|
+
permission_patterns = {
|
|
382
|
+
f"{prefix}/{pattern}" if prefix else pattern
|
|
383
|
+
for pattern in permission_patterns
|
|
384
|
+
}
|
|
385
|
+
protected_patterns = {
|
|
386
|
+
f"{prefix}/{pattern}" if prefix else pattern
|
|
387
|
+
for pattern in protected_patterns
|
|
388
|
+
}
|
|
389
|
+
edit_permissions = {
|
|
390
|
+
"*": "deny",
|
|
391
|
+
**{pattern: "allow" for pattern in sorted(permission_patterns)},
|
|
392
|
+
}
|
|
393
|
+
for pattern in sorted(protected_patterns):
|
|
394
|
+
edit_permissions[pattern] = "deny"
|
|
395
|
+
permissions["edit"] = edit_permissions
|
|
396
|
+
else:
|
|
397
|
+
raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
|
|
398
|
+
|
|
399
|
+
return agent_name, {
|
|
400
|
+
"$schema": "https://opencode.ai/config.json",
|
|
401
|
+
"plugin": [],
|
|
402
|
+
"mcp": {},
|
|
403
|
+
"share": "disabled",
|
|
404
|
+
"instructions": [],
|
|
405
|
+
"formatter": False,
|
|
406
|
+
"lsp": False,
|
|
407
|
+
"command": {},
|
|
408
|
+
"agent": {
|
|
409
|
+
agent_name: {
|
|
410
|
+
"mode": "primary",
|
|
411
|
+
"description": description,
|
|
412
|
+
"permission": permissions,
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def validate_opencode_attachments(
|
|
419
|
+
context: RuntimeContext,
|
|
420
|
+
files: list[pathlib.Path],
|
|
421
|
+
) -> list[pathlib.Path]:
|
|
422
|
+
run_root = context.run_dir.resolve(strict=True)
|
|
423
|
+
validated: list[pathlib.Path] = []
|
|
424
|
+
for file_path in files:
|
|
425
|
+
lexical_path = pathlib.Path(os.path.abspath(file_path))
|
|
426
|
+
if not is_path_within(lexical_path, run_root):
|
|
427
|
+
raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
|
|
428
|
+
if path_has_symlink(run_root, lexical_path):
|
|
429
|
+
raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
|
|
430
|
+
resolved_path = lexical_path.resolve(strict=True)
|
|
431
|
+
if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
|
|
432
|
+
raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
|
|
433
|
+
validated.append(resolved_path)
|
|
434
|
+
return validated
|
|
435
|
+
|
|
436
|
+
|
|
177
437
|
def call_opencode(
|
|
178
438
|
context: RuntimeContext,
|
|
179
439
|
stage_name: str,
|
|
180
440
|
purpose: str,
|
|
181
441
|
prompt: str,
|
|
182
442
|
files: list[pathlib.Path],
|
|
443
|
+
*,
|
|
444
|
+
agent: str,
|
|
445
|
+
allow_paths: tuple[str, ...] = (),
|
|
446
|
+
working_directory: pathlib.Path | None = None,
|
|
183
447
|
attempt: int | None = None,
|
|
184
448
|
total_attempts: int | None = None,
|
|
185
449
|
existing_session_id: str | None = None,
|
|
186
450
|
) -> OpenCodeRunResult:
|
|
451
|
+
"""Run OpenCode with a policy-specific isolated working directory.
|
|
452
|
+
|
|
453
|
+
Apply callers must provide the hook-owned, non-VCS staging directory built
|
|
454
|
+
by ``run_apply_step``; this is an internal precondition rather than a
|
|
455
|
+
general repository-working-directory interface.
|
|
456
|
+
"""
|
|
457
|
+
if agent not in OPENCODE_AGENT_POLICIES:
|
|
458
|
+
raise HookError(f"Unsupported OpenCode agent policy: {agent}")
|
|
459
|
+
if agent == "apply" and not allow_paths:
|
|
460
|
+
raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
|
|
461
|
+
if agent == "apply" and working_directory is None:
|
|
462
|
+
raise HookError("OpenCode apply agent requires an isolated staging directory")
|
|
463
|
+
if agent == "read-only" and allow_paths:
|
|
464
|
+
raise HookError("OpenCode read-only agent does not accept write paths")
|
|
465
|
+
|
|
466
|
+
validated_files = validate_opencode_attachments(context, files)
|
|
467
|
+
if working_directory is None:
|
|
468
|
+
resolved_working_directory = None
|
|
469
|
+
else:
|
|
470
|
+
try:
|
|
471
|
+
resolved_working_directory = working_directory.resolve(strict=True)
|
|
472
|
+
except (OSError, RuntimeError) as exc:
|
|
473
|
+
raise HookError(
|
|
474
|
+
f"OpenCode working directory must be an existing directory: {working_directory}"
|
|
475
|
+
) from exc
|
|
476
|
+
if not resolved_working_directory.is_dir():
|
|
477
|
+
raise HookError(
|
|
478
|
+
f"OpenCode working directory must be an existing directory: {working_directory}"
|
|
479
|
+
)
|
|
480
|
+
agent_name, security_config = build_opencode_security_config(
|
|
481
|
+
agent,
|
|
482
|
+
allow_paths,
|
|
483
|
+
non_vcs_working_directory=(
|
|
484
|
+
resolved_working_directory if agent == "apply" else None
|
|
485
|
+
),
|
|
486
|
+
)
|
|
187
487
|
executable = context.opencode_executable or resolve_opencode_executable()
|
|
188
488
|
context.logger.llm_call(stage_name, purpose, context.config.llm.model, attempt, total_attempts)
|
|
489
|
+
isolated_env = opencode_isolation_env(context, security_config, stage_name)
|
|
189
490
|
cmd = [
|
|
190
491
|
executable,
|
|
191
492
|
"run",
|
|
493
|
+
"--agent",
|
|
494
|
+
agent_name,
|
|
495
|
+
"--pure",
|
|
192
496
|
"--format",
|
|
193
497
|
"json",
|
|
194
498
|
"--model",
|
|
@@ -200,17 +504,29 @@ def call_opencode(
|
|
|
200
504
|
cmd.extend(["--session", existing_session_id])
|
|
201
505
|
else:
|
|
202
506
|
cmd.extend(["--title", f"{context.config.llm.session_title_prefix} {context.run_id} {stage_name}"])
|
|
203
|
-
for file_path in
|
|
507
|
+
for file_path in validated_files:
|
|
204
508
|
cmd.extend(["--file", str(file_path)])
|
|
205
509
|
cmd.extend(["--", prompt])
|
|
206
510
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
511
|
+
if working_directory is None:
|
|
512
|
+
with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as temporary_directory:
|
|
513
|
+
completed = run_command(
|
|
514
|
+
cmd,
|
|
515
|
+
cwd=pathlib.Path(temporary_directory).resolve(strict=True),
|
|
516
|
+
timeout=context.config.llm.timeout_seconds,
|
|
517
|
+
check=False,
|
|
518
|
+
env=isolated_env,
|
|
519
|
+
inherit_env=False,
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
completed = run_command(
|
|
523
|
+
cmd,
|
|
524
|
+
cwd=resolved_working_directory,
|
|
525
|
+
timeout=context.config.llm.timeout_seconds,
|
|
526
|
+
check=False,
|
|
527
|
+
env=isolated_env,
|
|
528
|
+
inherit_env=False,
|
|
529
|
+
)
|
|
214
530
|
session_id, text_output = parse_opencode_json_run_output(completed.stdout or "")
|
|
215
531
|
stdout = completed.stdout or ""
|
|
216
532
|
stderr = completed.stderr or ""
|
|
@@ -240,16 +556,21 @@ def run_llm_step(
|
|
|
240
556
|
wants_json = bool(step.schema)
|
|
241
557
|
expects_json_array = step.schema in {"string_array", "docs_issue_array"}
|
|
242
558
|
for attempt in range(1, total_attempts + 1):
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
559
|
+
try:
|
|
560
|
+
result = call_opencode(
|
|
561
|
+
context,
|
|
562
|
+
stage_name=stage_name,
|
|
563
|
+
purpose=f"{step.type}:{step.id}",
|
|
564
|
+
prompt=prompt_text,
|
|
565
|
+
files=input_paths,
|
|
566
|
+
agent="read-only",
|
|
567
|
+
attempt=attempt,
|
|
568
|
+
total_attempts=total_attempts,
|
|
569
|
+
existing_session_id=session_id,
|
|
570
|
+
)
|
|
571
|
+
except Exception: # noqa: BLE001
|
|
572
|
+
finalize_opencode_session(context, stage_name, session_id)
|
|
573
|
+
raise
|
|
253
574
|
session_id = result.session_id
|
|
254
575
|
if result.return_code != 0:
|
|
255
576
|
finalize_opencode_session(context, stage_name, session_id)
|
|
@@ -257,19 +578,22 @@ def run_llm_step(
|
|
|
257
578
|
raise HookError(f"OpenCode command failed: {details}")
|
|
258
579
|
try:
|
|
259
580
|
if not wants_json:
|
|
260
|
-
|
|
261
|
-
return result.output_text
|
|
262
|
-
if expects_json_array:
|
|
263
|
-
payload = extract_json_array(result.output_text)
|
|
581
|
+
payload = result.output_text
|
|
264
582
|
else:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
583
|
+
if expects_json_array:
|
|
584
|
+
payload = extract_json_array(result.output_text)
|
|
585
|
+
else:
|
|
586
|
+
payload = extract_json_object(result.output_text)
|
|
587
|
+
payload = validate_schema(step.schema, payload)
|
|
268
588
|
except HookError as exc:
|
|
269
589
|
last_error = str(exc)
|
|
270
590
|
last_output = result.output_text
|
|
271
591
|
if attempt >= total_attempts:
|
|
272
|
-
|
|
592
|
+
finalize_opencode_session(context, stage_name, session_id)
|
|
593
|
+
raise HookError(
|
|
594
|
+
f"Model failed to return valid JSON for {stage_name}: "
|
|
595
|
+
f"{last_error}. {last_output[:400]}"
|
|
596
|
+
) from exc
|
|
273
597
|
snippet = last_output[: context.config.llm.invalid_json_feedback_max_chars]
|
|
274
598
|
if expects_json_array:
|
|
275
599
|
suffix = "Return ONLY valid JSON array."
|
|
@@ -285,6 +609,7 @@ def run_llm_step(
|
|
|
285
609
|
+ "\n```"
|
|
286
610
|
)
|
|
287
611
|
if context.config.llm.json_retry_new_session:
|
|
612
|
+
finalize_opencode_session(context, stage_name, session_id)
|
|
288
613
|
session_id = None
|
|
289
614
|
pr_url = extract_pr_url(last_output)
|
|
290
615
|
if pr_url:
|
|
@@ -294,5 +619,7 @@ def run_llm_step(
|
|
|
294
619
|
stage_name=stage_name,
|
|
295
620
|
url=pr_url,
|
|
296
621
|
)
|
|
297
|
-
|
|
298
|
-
|
|
622
|
+
continue
|
|
623
|
+
finalize_opencode_session(context, stage_name, session_id)
|
|
624
|
+
return payload
|
|
625
|
+
raise HookError(f"Model failed to return valid JSON for {stage_name}") # pragma: no cover
|