ai-push-hooks 0.1.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/.ai-push-hooks.toml +73 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/bin/ai-push-hooks.js +35 -0
- package/package.json +24 -0
- package/pyproject.toml +38 -0
- package/run.sh +29 -0
- package/src/ai_push_hooks/__init__.py +6 -0
- package/src/ai_push_hooks/__main__.py +3 -0
- package/src/ai_push_hooks/artifacts.py +86 -0
- package/src/ai_push_hooks/cli.py +49 -0
- package/src/ai_push_hooks/config.py +356 -0
- package/src/ai_push_hooks/engine.py +172 -0
- package/src/ai_push_hooks/executors/__init__.py +1 -0
- package/src/ai_push_hooks/executors/apply.py +55 -0
- package/src/ai_push_hooks/executors/assertions.py +44 -0
- package/src/ai_push_hooks/executors/exec.py +413 -0
- package/src/ai_push_hooks/executors/llm.py +308 -0
- package/src/ai_push_hooks/hook.py +130 -0
- package/src/ai_push_hooks/modules/__init__.py +11 -0
- package/src/ai_push_hooks/modules/beads.py +46 -0
- package/src/ai_push_hooks/modules/docs.py +159 -0
- package/src/ai_push_hooks/modules/pr.py +73 -0
- package/src/ai_push_hooks/prompts_builtin.py +135 -0
- package/src/ai_push_hooks/types.py +236 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..executors.exec import (
|
|
6
|
+
collect_commit_messages_for_ranges,
|
|
7
|
+
current_branch,
|
|
8
|
+
env_bool,
|
|
9
|
+
is_feature_branch,
|
|
10
|
+
lookup_open_pr_url,
|
|
11
|
+
)
|
|
12
|
+
from ..types import CollectorResult, RuntimeContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
16
|
+
branch_name = current_branch(context.repo_root)
|
|
17
|
+
flag_env = ""
|
|
18
|
+
for step in state.module.steps:
|
|
19
|
+
if step.when_env:
|
|
20
|
+
flag_env = step.when_env
|
|
21
|
+
break
|
|
22
|
+
if flag_env and env_bool(flag_env) is not True:
|
|
23
|
+
return CollectorResult(
|
|
24
|
+
artifacts={"pr-context.txt": f"branch={branch_name}\nflag_env={flag_env}\n"},
|
|
25
|
+
skip_module=True,
|
|
26
|
+
skip_reason="PR create env flag is not enabled",
|
|
27
|
+
)
|
|
28
|
+
if not branch_name or branch_name in {"HEAD", "main"} or not is_feature_branch(branch_name):
|
|
29
|
+
return CollectorResult(
|
|
30
|
+
artifacts={"pr-context.txt": f"branch={branch_name}\n"},
|
|
31
|
+
skip_module=True,
|
|
32
|
+
skip_reason="branch does not require PR creation",
|
|
33
|
+
)
|
|
34
|
+
existing_pr_url = ""
|
|
35
|
+
try:
|
|
36
|
+
existing_pr_url = lookup_open_pr_url(context.repo_root, branch_name)
|
|
37
|
+
except Exception: # noqa: BLE001
|
|
38
|
+
existing_pr_url = ""
|
|
39
|
+
if existing_pr_url:
|
|
40
|
+
return CollectorResult(
|
|
41
|
+
artifacts={"pr-context.txt": f"branch={branch_name}\nexisting_pr_url={existing_pr_url}\n"},
|
|
42
|
+
skip_module=True,
|
|
43
|
+
skip_reason="open PR already exists",
|
|
44
|
+
metadata={"existing_pr_url": existing_pr_url},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
ranges = context.cache.get("ranges", [])
|
|
48
|
+
changed_files = context.cache.get("changed_files", [])
|
|
49
|
+
diff_text = context.cache.get("diff_text", "")
|
|
50
|
+
commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
51
|
+
commit_lines = []
|
|
52
|
+
for commit in commits:
|
|
53
|
+
commit_lines.append(f"--- {commit['hash']}")
|
|
54
|
+
commit_lines.append(f"subject: {commit['subject']}")
|
|
55
|
+
if commit["body"]:
|
|
56
|
+
commit_lines.append("body:")
|
|
57
|
+
commit_lines.append(commit["body"])
|
|
58
|
+
commit_lines.append("")
|
|
59
|
+
return CollectorResult(
|
|
60
|
+
artifacts={
|
|
61
|
+
"pr-context.txt": "\n".join(
|
|
62
|
+
[
|
|
63
|
+
f"branch={branch_name}",
|
|
64
|
+
f"base_branch=main",
|
|
65
|
+
f"remote_name={context.remote_name or 'origin'}",
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
+ "\n",
|
|
69
|
+
"changed-files.txt": "\n".join(changed_files) + ("\n" if changed_files else ""),
|
|
70
|
+
"push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
|
|
71
|
+
"commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
DOCS_QUERY_PROMPT = """Given the attached diff and changed file list, output a JSON array of concise documentation search queries.
|
|
4
|
+
|
|
5
|
+
Requirements:
|
|
6
|
+
- Return JSON only.
|
|
7
|
+
- Prefer exact config keys, commands, flags, function names, and domain terms.
|
|
8
|
+
- Keep the list concise and unique.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
DOCS_ANALYSIS_PROMPT = """Review the attached diff and documentation context. Return JSON issues only for factual documentation drift caused by the code changes.
|
|
12
|
+
|
|
13
|
+
Each item must include:
|
|
14
|
+
- file
|
|
15
|
+
- line
|
|
16
|
+
- description
|
|
17
|
+
- doc_excerpt
|
|
18
|
+
- suggested_fix
|
|
19
|
+
|
|
20
|
+
Return [] when the docs remain factually correct.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
DOCS_APPLY_PROMPT = """Apply the minimum Markdown documentation changes required to fix the detected factual drift.
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
1. Modify only README.md and docs/**/*.md files allowed by this step.
|
|
27
|
+
2. Keep edits minimal and factual.
|
|
28
|
+
3. Update docs index files when a referenced Markdown file is added or renamed.
|
|
29
|
+
4. If no edits are required, do not modify files.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
BEADS_PLAN_PROMPT = """Check the attached branch context and output a JSON object describing Beads alignment work.
|
|
33
|
+
|
|
34
|
+
Return keys:
|
|
35
|
+
- commands: array of non-interactive br command strings to run
|
|
36
|
+
- unresolved: boolean
|
|
37
|
+
- report_markdown: markdown string or empty
|
|
38
|
+
|
|
39
|
+
Return JSON only.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
PR_COMPOSE_PROMPT = """Draft a pull request payload from the attached branch context.
|
|
43
|
+
|
|
44
|
+
Return a JSON object with:
|
|
45
|
+
- title
|
|
46
|
+
- body
|
|
47
|
+
- base_branch
|
|
48
|
+
- head_branch
|
|
49
|
+
- draft
|
|
50
|
+
|
|
51
|
+
Return JSON only.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
BUILTIN_PROMPTS = {
|
|
55
|
+
"docs-query-basic": DOCS_QUERY_PROMPT,
|
|
56
|
+
"docs-analysis-basic": DOCS_ANALYSIS_PROMPT,
|
|
57
|
+
"docs-apply-basic": DOCS_APPLY_PROMPT,
|
|
58
|
+
"beads-plan-basic": BEADS_PLAN_PROMPT,
|
|
59
|
+
"pr-compose-basic": PR_COMPOSE_PROMPT,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
MINIMAL_DOCS_TEMPLATE = '''[general]
|
|
63
|
+
enabled = true
|
|
64
|
+
allow_push_on_error = false
|
|
65
|
+
require_clean_worktree = false
|
|
66
|
+
skip_on_sync_branch = true
|
|
67
|
+
|
|
68
|
+
[llm]
|
|
69
|
+
runner = "opencode"
|
|
70
|
+
model = "openai/gpt-5.3-codex-spark"
|
|
71
|
+
variant = ""
|
|
72
|
+
timeout_seconds = 800
|
|
73
|
+
max_parallel = 2
|
|
74
|
+
json_max_retries = 2
|
|
75
|
+
invalid_json_feedback_max_chars = 6000
|
|
76
|
+
json_retry_new_session = true
|
|
77
|
+
delete_session_after_run = true
|
|
78
|
+
|
|
79
|
+
[logging]
|
|
80
|
+
level = "status"
|
|
81
|
+
jsonl = true
|
|
82
|
+
dir = ".git/ai-push-hooks/logs"
|
|
83
|
+
capture_llm_transcript = true
|
|
84
|
+
transcript_dir = ".git/ai-push-hooks/transcripts"
|
|
85
|
+
summary_dir = ".git/ai-push-hooks/summaries"
|
|
86
|
+
|
|
87
|
+
[workflow]
|
|
88
|
+
modules = ["docs"]
|
|
89
|
+
|
|
90
|
+
[modules.docs]
|
|
91
|
+
enabled = true
|
|
92
|
+
|
|
93
|
+
[[modules.docs.steps]]
|
|
94
|
+
id = "collect"
|
|
95
|
+
type = "collect"
|
|
96
|
+
collector = "docs_context"
|
|
97
|
+
|
|
98
|
+
[[modules.docs.steps]]
|
|
99
|
+
id = "query"
|
|
100
|
+
type = "llm"
|
|
101
|
+
prompt = """
|
|
102
|
+
Given the attached diff and changed file list, output a JSON array of concise
|
|
103
|
+
documentation search queries. Return JSON only.
|
|
104
|
+
"""
|
|
105
|
+
inputs = ["collect/push.diff", "collect/changed-files.txt"]
|
|
106
|
+
output = "queries.json"
|
|
107
|
+
schema = "string_array"
|
|
108
|
+
|
|
109
|
+
[[modules.docs.steps]]
|
|
110
|
+
id = "analyze"
|
|
111
|
+
type = "llm"
|
|
112
|
+
prompt = """
|
|
113
|
+
Review the diff and matched docs excerpts. Return JSON issues only for factual
|
|
114
|
+
documentation drift caused by the code changes.
|
|
115
|
+
"""
|
|
116
|
+
inputs = ["collect/push.diff", "collect/docs-context.txt", "query/queries.json", "collect/recent-commits.txt"]
|
|
117
|
+
output = "issues.json"
|
|
118
|
+
schema = "docs_issue_array"
|
|
119
|
+
|
|
120
|
+
[[modules.docs.steps]]
|
|
121
|
+
id = "apply"
|
|
122
|
+
type = "apply"
|
|
123
|
+
prompt = """
|
|
124
|
+
Apply the minimum Markdown documentation changes required to fix the detected
|
|
125
|
+
factual drift. Modify only files allowed by the step.
|
|
126
|
+
"""
|
|
127
|
+
inputs = ["collect/push.diff", "collect/docs-context.txt", "analyze/issues.json"]
|
|
128
|
+
allow_paths = ["README.md", "docs/**/*.md"]
|
|
129
|
+
|
|
130
|
+
[[modules.docs.steps]]
|
|
131
|
+
id = "assert"
|
|
132
|
+
type = "assert"
|
|
133
|
+
assertion = "docs_apply_requires_manual_commit"
|
|
134
|
+
inputs = ["apply/result.json"]
|
|
135
|
+
'''
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
READ_ONLY_STEP_TYPES = frozenset({"collect", "llm"})
|
|
11
|
+
PROMPTABLE_STEP_TYPES = frozenset({"llm", "apply"})
|
|
12
|
+
SUPPORTED_STEP_TYPES = frozenset({"collect", "llm", "apply", "exec", "assert"})
|
|
13
|
+
FEATURE_BRANCH_PREFIXES = ("feat/", "feature/")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HookError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class GeneralConfig:
|
|
22
|
+
enabled: bool = True
|
|
23
|
+
allow_push_on_error: bool = False
|
|
24
|
+
require_clean_worktree: bool = False
|
|
25
|
+
skip_on_sync_branch: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class LlmConfig:
|
|
30
|
+
runner: str = "opencode"
|
|
31
|
+
model: str = "openai/gpt-5.3-codex-spark"
|
|
32
|
+
variant: str = ""
|
|
33
|
+
timeout_seconds: int = 800
|
|
34
|
+
max_parallel: int = 2
|
|
35
|
+
json_max_retries: int = 2
|
|
36
|
+
invalid_json_feedback_max_chars: int = 6000
|
|
37
|
+
json_retry_new_session: bool = True
|
|
38
|
+
delete_session_after_run: bool = True
|
|
39
|
+
max_diff_bytes: int = 180000
|
|
40
|
+
session_title_prefix: str = "ai-push-hooks"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class LoggingConfig:
|
|
45
|
+
level: str = "status"
|
|
46
|
+
jsonl: bool = True
|
|
47
|
+
dir: str = ".git/ai-push-hooks/logs"
|
|
48
|
+
capture_llm_transcript: bool = True
|
|
49
|
+
transcript_dir: str = ".git/ai-push-hooks/transcripts"
|
|
50
|
+
summary_dir: str = ".git/ai-push-hooks/summaries"
|
|
51
|
+
print_llm_output: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class StepConfig:
|
|
56
|
+
id: str
|
|
57
|
+
type: str
|
|
58
|
+
inputs: tuple[str, ...] = ()
|
|
59
|
+
output: str | None = None
|
|
60
|
+
schema: str | None = None
|
|
61
|
+
prompt: str | None = None
|
|
62
|
+
prompt_file: str | None = None
|
|
63
|
+
fallback_prompt_id: str | None = None
|
|
64
|
+
collector: str | None = None
|
|
65
|
+
allow_paths: tuple[str, ...] = ()
|
|
66
|
+
executor: str | None = None
|
|
67
|
+
assertion: str | None = None
|
|
68
|
+
when_env: str | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_read_only(self) -> bool:
|
|
72
|
+
return self.type in READ_ONLY_STEP_TYPES
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_promptable(self) -> bool:
|
|
76
|
+
return self.type in PROMPTABLE_STEP_TYPES
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class ModuleConfig:
|
|
81
|
+
id: str
|
|
82
|
+
enabled: bool
|
|
83
|
+
steps: tuple[StepConfig, ...]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class WorkflowConfig:
|
|
88
|
+
modules: tuple[str, ...]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class HookConfig:
|
|
93
|
+
general: GeneralConfig
|
|
94
|
+
llm: LlmConfig
|
|
95
|
+
logging: LoggingConfig
|
|
96
|
+
workflow: WorkflowConfig
|
|
97
|
+
modules: dict[str, ModuleConfig]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class CollectorResult:
|
|
102
|
+
artifacts: dict[str, str | dict[str, Any] | list[Any]] = field(default_factory=dict)
|
|
103
|
+
skip_module: bool = False
|
|
104
|
+
skip_reason: str = ""
|
|
105
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class StepResult:
|
|
110
|
+
status: str = "completed"
|
|
111
|
+
artifacts: dict[str, pathlib.Path] = field(default_factory=dict)
|
|
112
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
113
|
+
message: str = ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class ModuleRuntimeState:
|
|
118
|
+
module: ModuleConfig
|
|
119
|
+
step_index: int = 0
|
|
120
|
+
status: str = "pending"
|
|
121
|
+
active_step_id: str | None = None
|
|
122
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
123
|
+
artifacts: dict[str, pathlib.Path] = field(default_factory=dict)
|
|
124
|
+
error: str | None = None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def next_step(self) -> StepConfig | None:
|
|
128
|
+
if self.step_index >= len(self.module.steps):
|
|
129
|
+
return None
|
|
130
|
+
return self.module.steps[self.step_index]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class RuntimeContext:
|
|
135
|
+
repo_root: pathlib.Path
|
|
136
|
+
git_dir: pathlib.Path
|
|
137
|
+
config: HookConfig
|
|
138
|
+
logger: "HookLogger"
|
|
139
|
+
remote_name: str
|
|
140
|
+
remote_url: str
|
|
141
|
+
stdin_lines: list[str]
|
|
142
|
+
run_id: str
|
|
143
|
+
run_dir: pathlib.Path
|
|
144
|
+
opencode_executable: str | None = None
|
|
145
|
+
cache: dict[str, Any] = field(default_factory=dict)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class WorkflowRunResult:
|
|
150
|
+
run_dir: pathlib.Path
|
|
151
|
+
modules: dict[str, str]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class HookLogger:
|
|
156
|
+
jsonl_path: pathlib.Path | None
|
|
157
|
+
console_level: str = "status"
|
|
158
|
+
jsonl_write_failed: bool = False
|
|
159
|
+
llm_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
160
|
+
|
|
161
|
+
_verbosity_order = {"status": 0, "info": 1, "debug": 2}
|
|
162
|
+
|
|
163
|
+
def _level_is_enabled(self, level: str) -> bool:
|
|
164
|
+
if level in {"warn", "error"}:
|
|
165
|
+
return True
|
|
166
|
+
configured = self._verbosity_order.get(self.console_level, 0)
|
|
167
|
+
required = self._verbosity_order.get(level, 0)
|
|
168
|
+
return configured >= required
|
|
169
|
+
|
|
170
|
+
def _emit(self, level: str, event: str, message: str, **fields: Any) -> None:
|
|
171
|
+
if not self._level_is_enabled(level):
|
|
172
|
+
return
|
|
173
|
+
stamp = datetime.now(timezone.utc).isoformat()
|
|
174
|
+
sys.stderr.write(f"[ai-push-hooks] {message}\n")
|
|
175
|
+
if self.jsonl_path is None or self.jsonl_write_failed:
|
|
176
|
+
return
|
|
177
|
+
record = {"ts": stamp, "level": level, "event": event, "message": message, **fields}
|
|
178
|
+
try:
|
|
179
|
+
with self.jsonl_path.open("a", encoding="utf-8") as handle:
|
|
180
|
+
handle.write(json.dumps(record, ensure_ascii=True) + "\n")
|
|
181
|
+
except Exception as exc: # noqa: BLE001
|
|
182
|
+
self.jsonl_write_failed = True
|
|
183
|
+
sys.stderr.write(f"[ai-push-hooks] JSONL logging disabled after write failure: {exc}\n")
|
|
184
|
+
|
|
185
|
+
def debug(self, event: str, message: str, **fields: Any) -> None:
|
|
186
|
+
self._emit("debug", event, message, **fields)
|
|
187
|
+
|
|
188
|
+
def info(self, event: str, message: str, **fields: Any) -> None:
|
|
189
|
+
self._emit("info", event, message, **fields)
|
|
190
|
+
|
|
191
|
+
def status(self, event: str, message: str, **fields: Any) -> None:
|
|
192
|
+
self._emit("status", event, message, **fields)
|
|
193
|
+
|
|
194
|
+
def warn(self, event: str, message: str, **fields: Any) -> None:
|
|
195
|
+
self._emit("warn", event, message, **fields)
|
|
196
|
+
|
|
197
|
+
def error(self, event: str, message: str, **fields: Any) -> None:
|
|
198
|
+
self._emit("error", event, message, **fields)
|
|
199
|
+
|
|
200
|
+
def llm_call(
|
|
201
|
+
self,
|
|
202
|
+
stage_name: str,
|
|
203
|
+
purpose: str,
|
|
204
|
+
model: str,
|
|
205
|
+
attempt: int | None = None,
|
|
206
|
+
total_attempts: int | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
call_number = len(self.llm_calls) + 1
|
|
209
|
+
record: dict[str, Any] = {
|
|
210
|
+
"call_number": call_number,
|
|
211
|
+
"stage_name": stage_name,
|
|
212
|
+
"purpose": purpose,
|
|
213
|
+
"model": model,
|
|
214
|
+
}
|
|
215
|
+
if attempt is not None:
|
|
216
|
+
record["attempt"] = attempt
|
|
217
|
+
if total_attempts is not None:
|
|
218
|
+
record["total_attempts"] = total_attempts
|
|
219
|
+
self.llm_calls.append(record)
|
|
220
|
+
self.status(
|
|
221
|
+
"llm.call",
|
|
222
|
+
f"LLM call #{call_number}: {stage_name} - {purpose}",
|
|
223
|
+
**record,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def llm_summary(self) -> None:
|
|
227
|
+
stage_counts: dict[str, int] = {}
|
|
228
|
+
for call in self.llm_calls:
|
|
229
|
+
stage_name = str(call.get("stage_name", "")).strip() or "<unknown>"
|
|
230
|
+
stage_counts[stage_name] = stage_counts.get(stage_name, 0) + 1
|
|
231
|
+
self.status(
|
|
232
|
+
"llm.calls_total",
|
|
233
|
+
f"Total LLM calls this run: {len(self.llm_calls)}",
|
|
234
|
+
total_calls=len(self.llm_calls),
|
|
235
|
+
stage_counts=stage_counts,
|
|
236
|
+
)
|