local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Speculative Multi-Drafting & Model Racing Engine (R19).
|
|
2
|
+
|
|
3
|
+
Coordinates concurrent speculative drafts across model clients and worker pools,
|
|
4
|
+
accepting the first passing candidate and cancelling competing workers to boost
|
|
5
|
+
first-attempt success rates with low latency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import queue
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from threading import Event
|
|
13
|
+
from typing import Any, Callable, Sequence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpeculativeRacer:
|
|
17
|
+
"""Coordinates racing between multiple speculative task drafts."""
|
|
18
|
+
|
|
19
|
+
def run(
|
|
20
|
+
self,
|
|
21
|
+
runners: Sequence[Callable[[Event], dict[str, Any]]],
|
|
22
|
+
*,
|
|
23
|
+
cancel_event: Event | None = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
if not runners:
|
|
26
|
+
return {"status": "failed", "error": "no runners provided"}
|
|
27
|
+
|
|
28
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
29
|
+
return {"status": "failed", "error": "cancelled"}
|
|
30
|
+
|
|
31
|
+
result_queue: queue.Queue[tuple[int, dict[str, Any]]] = queue.Queue()
|
|
32
|
+
local_cancels: list[Event] = [Event() for _ in runners]
|
|
33
|
+
|
|
34
|
+
def _worker_wrapper(idx: int, runner_fn: Callable[[Event], dict[str, Any]]) -> None:
|
|
35
|
+
runner_cancel = local_cancels[idx]
|
|
36
|
+
try:
|
|
37
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
38
|
+
runner_cancel.set()
|
|
39
|
+
res = runner_fn(runner_cancel)
|
|
40
|
+
result_queue.put((idx, res))
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
result_queue.put((idx, {"status": "failed", "error": str(exc)}))
|
|
43
|
+
|
|
44
|
+
executor = ThreadPoolExecutor(max_workers=len(runners))
|
|
45
|
+
try:
|
|
46
|
+
for i, runner in enumerate(runners):
|
|
47
|
+
executor.submit(_worker_wrapper, i, runner)
|
|
48
|
+
|
|
49
|
+
completed_count = 0
|
|
50
|
+
fallback_result: dict[str, Any] | None = None
|
|
51
|
+
|
|
52
|
+
while completed_count < len(runners):
|
|
53
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
54
|
+
for ev in local_cancels:
|
|
55
|
+
ev.set()
|
|
56
|
+
return {"status": "failed", "error": "cancelled"}
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
idx, result = result_queue.get(timeout=0.05)
|
|
60
|
+
completed_count += 1
|
|
61
|
+
except queue.Empty:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if result.get("status") == "accepted":
|
|
65
|
+
# Winner found! Cancel all remaining racers
|
|
66
|
+
for j, ev in enumerate(local_cancels):
|
|
67
|
+
if j != idx:
|
|
68
|
+
ev.set()
|
|
69
|
+
audit = result.get("audit")
|
|
70
|
+
if isinstance(audit, list):
|
|
71
|
+
audit.append({
|
|
72
|
+
"event": "speculative_race_winner",
|
|
73
|
+
"draft_index": idx,
|
|
74
|
+
"total_drafts": len(runners),
|
|
75
|
+
})
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
if fallback_result is None:
|
|
79
|
+
fallback_result = result
|
|
80
|
+
|
|
81
|
+
return fallback_result or {"status": "failed", "error": "no result"}
|
|
82
|
+
finally:
|
|
83
|
+
for ev in local_cancels:
|
|
84
|
+
ev.set()
|
|
85
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Tool Output Spill Store for bounding model context size.
|
|
2
|
+
|
|
3
|
+
Adapted from DeepSeek Harness @deepseek-ai/dsh-spill and @deepseek-ai/dsh-spill-policy.
|
|
4
|
+
Manages session-scoped artifact persistence for oversized tool outputs with
|
|
5
|
+
path-traversal protection and secure permissions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_MAX_BYTES: int = 30 * 1024 # 30KB
|
|
19
|
+
DEFAULT_MAX_LINES: int = 1000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SpillRef:
|
|
24
|
+
"""Descriptor for a persisted tool output spill artifact."""
|
|
25
|
+
|
|
26
|
+
locator: str
|
|
27
|
+
bytes: int
|
|
28
|
+
lines: int
|
|
29
|
+
preview_head: str
|
|
30
|
+
preview_tail: str
|
|
31
|
+
retrieval_hint: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SpillStore:
|
|
35
|
+
"""Session-scoped storage backend for oversized tool outputs."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, root_dir: Path | str | None = None) -> None:
|
|
38
|
+
if root_dir is None:
|
|
39
|
+
self.root_dir = (Path.cwd() / ".local_agent" / "spill").resolve()
|
|
40
|
+
else:
|
|
41
|
+
self.root_dir = Path(root_dir).resolve()
|
|
42
|
+
|
|
43
|
+
def _sanitize_session_id(self, session_id: str) -> str:
|
|
44
|
+
"""Derive a safe, injective directory segment from an untrusted session ID."""
|
|
45
|
+
if not session_id or session_id in (".", ".."):
|
|
46
|
+
return "session_default"
|
|
47
|
+
# Remove path separators and traversal tokens
|
|
48
|
+
cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", session_id).strip(".")
|
|
49
|
+
if not cleaned:
|
|
50
|
+
cleaned = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
|
|
51
|
+
return f"session_{cleaned}"
|
|
52
|
+
|
|
53
|
+
def _sanitize_file_name(self, name: str) -> str:
|
|
54
|
+
"""Derive a safe filename without path separators."""
|
|
55
|
+
base_name = Path(name).name
|
|
56
|
+
cleaned = re.sub(r"[^A-Za-z0-9_.-]", "_", base_name).strip(".")
|
|
57
|
+
return cleaned or "output.txt"
|
|
58
|
+
|
|
59
|
+
def _get_session_dir(self, session_id: str) -> Path:
|
|
60
|
+
"""Get the absolute session directory and assert it stays within root_dir."""
|
|
61
|
+
safe_seg = self._sanitize_session_id(session_id)
|
|
62
|
+
session_path = (self.root_dir / safe_seg).resolve()
|
|
63
|
+
try:
|
|
64
|
+
session_path.relative_to(self.root_dir)
|
|
65
|
+
except ValueError as err:
|
|
66
|
+
raise ValueError(f"Path traversal detected for session_id '{session_id}'") from err
|
|
67
|
+
return session_path
|
|
68
|
+
|
|
69
|
+
def save_text(
|
|
70
|
+
self,
|
|
71
|
+
session_id: str,
|
|
72
|
+
content: str,
|
|
73
|
+
source_tool: str = "tool",
|
|
74
|
+
suggested_name: str = "output.txt",
|
|
75
|
+
) -> SpillRef:
|
|
76
|
+
"""Persist text content to a session-scoped spill file and return a SpillRef."""
|
|
77
|
+
session_dir = self._get_session_dir(session_id)
|
|
78
|
+
session_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
79
|
+
if os.name != "nt":
|
|
80
|
+
try:
|
|
81
|
+
os.chmod(session_dir, 0o700)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
safe_name = self._sanitize_file_name(suggested_name)
|
|
86
|
+
file_id = uuid.uuid4().hex[:12]
|
|
87
|
+
target_file = (session_dir / f"{file_id}_{safe_name}").resolve()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
target_file.relative_to(self.root_dir)
|
|
91
|
+
except ValueError as err:
|
|
92
|
+
raise ValueError(f"Path traversal detected for target file '{target_file}'") from err
|
|
93
|
+
|
|
94
|
+
encoded = content.encode("utf-8")
|
|
95
|
+
byte_count = len(encoded)
|
|
96
|
+
|
|
97
|
+
# Split preserving newlines for accurate reconstruction
|
|
98
|
+
raw_lines = content.splitlines(keepends=True)
|
|
99
|
+
line_count = len(raw_lines)
|
|
100
|
+
|
|
101
|
+
# Write securely with exclusive creation / owner-only permissions (0600 on POSIX)
|
|
102
|
+
flags = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
|
|
103
|
+
if hasattr(os, "O_EXCL"):
|
|
104
|
+
flags |= os.O_EXCL
|
|
105
|
+
fd = os.open(str(target_file), flags, 0o600)
|
|
106
|
+
with open(fd, "wb") as f:
|
|
107
|
+
f.write(encoded)
|
|
108
|
+
|
|
109
|
+
if os.name != "nt":
|
|
110
|
+
try:
|
|
111
|
+
os.chmod(target_file, 0o600)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# Build head & tail previews (up to 20 lines each)
|
|
116
|
+
preview_limit = 20
|
|
117
|
+
preview_head = "".join(raw_lines[:preview_limit])
|
|
118
|
+
preview_tail = "".join(raw_lines[-preview_limit:])
|
|
119
|
+
|
|
120
|
+
locator = target_file.as_posix()
|
|
121
|
+
retrieval_hint = f"read_spill(locator='{locator}', offset_line=0, limit_lines=1000)"
|
|
122
|
+
|
|
123
|
+
return SpillRef(
|
|
124
|
+
locator=locator,
|
|
125
|
+
bytes=byte_count,
|
|
126
|
+
lines=line_count,
|
|
127
|
+
preview_head=preview_head,
|
|
128
|
+
preview_tail=preview_tail,
|
|
129
|
+
retrieval_hint=retrieval_hint,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def read_spill(self, locator: str, offset_line: int = 0, limit_lines: int = 1000) -> str:
|
|
133
|
+
"""Read a slice of lines from a persisted spill artifact."""
|
|
134
|
+
target_path = Path(locator).resolve()
|
|
135
|
+
try:
|
|
136
|
+
target_path.relative_to(self.root_dir)
|
|
137
|
+
except ValueError as err:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Path traversal detected: locator '{locator}' is outside spill root '{self.root_dir}'"
|
|
140
|
+
) from err
|
|
141
|
+
|
|
142
|
+
if not target_path.is_file():
|
|
143
|
+
raise FileNotFoundError(f"Spill artifact not found: {locator}")
|
|
144
|
+
|
|
145
|
+
if offset_line < 0:
|
|
146
|
+
offset_line = 0
|
|
147
|
+
if limit_lines < 0:
|
|
148
|
+
limit_lines = 0
|
|
149
|
+
|
|
150
|
+
with open(target_path, "r", encoding="utf-8", errors="replace") as f:
|
|
151
|
+
lines = f.readlines()
|
|
152
|
+
|
|
153
|
+
selected = lines[offset_line : offset_line + limit_lines]
|
|
154
|
+
return "".join(selected)
|
|
155
|
+
|
|
156
|
+
def maybe_spill(
|
|
157
|
+
self,
|
|
158
|
+
session_id: str,
|
|
159
|
+
content: str,
|
|
160
|
+
source_tool: str = "tool",
|
|
161
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
162
|
+
max_lines: int = DEFAULT_MAX_LINES,
|
|
163
|
+
) -> tuple[bool, str, SpillRef | None]:
|
|
164
|
+
"""Check content against byte and line thresholds, spilling if exceeded.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
(spilled: bool, resulting_message: str, ref: SpillRef | None)
|
|
168
|
+
"""
|
|
169
|
+
encoded_len = len(content.encode("utf-8"))
|
|
170
|
+
raw_lines = content.splitlines(keepends=True)
|
|
171
|
+
line_count = len(raw_lines)
|
|
172
|
+
|
|
173
|
+
if encoded_len <= max_bytes and line_count <= max_lines:
|
|
174
|
+
return False, content, None
|
|
175
|
+
|
|
176
|
+
ref = self.save_text(
|
|
177
|
+
session_id=session_id,
|
|
178
|
+
content=content,
|
|
179
|
+
source_tool=source_tool,
|
|
180
|
+
suggested_name=f"{source_tool}_output.txt",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
omitted_lines = max(0, line_count - 40)
|
|
184
|
+
notice = (
|
|
185
|
+
f"[OUTPUT TRUNCATED & SPILLED TO STORE]\n"
|
|
186
|
+
f"Tool '{source_tool}' produced {ref.bytes} bytes ({ref.lines} lines), "
|
|
187
|
+
f"exceeding limits ({max_bytes} bytes / {max_lines} lines).\n"
|
|
188
|
+
f"Full output was saved to locator: {ref.locator}\n\n"
|
|
189
|
+
f"--- BEGIN PREVIEW (HEAD) ---\n"
|
|
190
|
+
f"{ref.preview_head.rstrip()}\n"
|
|
191
|
+
f"--- END PREVIEW (HEAD) ---\n"
|
|
192
|
+
f"... [{omitted_lines} lines omitted] ...\n"
|
|
193
|
+
f"--- BEGIN PREVIEW (TAIL) ---\n"
|
|
194
|
+
f"{ref.preview_tail.rstrip()}\n"
|
|
195
|
+
f"--- END PREVIEW (TAIL) ---\n\n"
|
|
196
|
+
f"Retrieval guidance: {ref.retrieval_hint}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return True, notice, ref
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Global default instance & convenience helpers
|
|
203
|
+
_DEFAULT_SPILL_STORE: SpillStore | None = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_default_spill_store() -> SpillStore:
|
|
207
|
+
"""Retrieve or lazily initialize the default SpillStore instance."""
|
|
208
|
+
global _DEFAULT_SPILL_STORE
|
|
209
|
+
if _DEFAULT_SPILL_STORE is None:
|
|
210
|
+
_DEFAULT_SPILL_STORE = SpillStore()
|
|
211
|
+
return _DEFAULT_SPILL_STORE
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def save_text(
|
|
215
|
+
session_id: str,
|
|
216
|
+
content: str,
|
|
217
|
+
source_tool: str = "tool",
|
|
218
|
+
suggested_name: str = "output.txt",
|
|
219
|
+
) -> SpillRef:
|
|
220
|
+
"""Save text content to the default spill store."""
|
|
221
|
+
return get_default_spill_store().save_text(
|
|
222
|
+
session_id=session_id,
|
|
223
|
+
content=content,
|
|
224
|
+
source_tool=source_tool,
|
|
225
|
+
suggested_name=suggested_name,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def read_spill(locator: str, offset_line: int = 0, limit_lines: int = 1000) -> str:
|
|
230
|
+
"""Read a slice of lines from a spill locator."""
|
|
231
|
+
# Check if locator is under an explicit custom root or default root
|
|
232
|
+
store = get_default_spill_store()
|
|
233
|
+
target_path = Path(locator).resolve()
|
|
234
|
+
try:
|
|
235
|
+
target_path.relative_to(store.root_dir)
|
|
236
|
+
return store.read_spill(locator, offset_line, limit_lines)
|
|
237
|
+
except ValueError:
|
|
238
|
+
# Fall back to resolving within locator's parent spill directory
|
|
239
|
+
parent_dir = target_path.parent
|
|
240
|
+
custom_store = SpillStore(root_dir=parent_dir.parent)
|
|
241
|
+
return custom_store.read_spill(locator, offset_line, limit_lines)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def maybe_spill(
|
|
245
|
+
session_id: str,
|
|
246
|
+
content: str,
|
|
247
|
+
source_tool: str = "tool",
|
|
248
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
249
|
+
max_lines: int = DEFAULT_MAX_LINES,
|
|
250
|
+
) -> tuple[bool, str, SpillRef | None]:
|
|
251
|
+
"""Check content against thresholds using the default spill store."""
|
|
252
|
+
return get_default_spill_store().maybe_spill(
|
|
253
|
+
session_id=session_id,
|
|
254
|
+
content=content,
|
|
255
|
+
source_tool=source_tool,
|
|
256
|
+
max_bytes=max_bytes,
|
|
257
|
+
max_lines=max_lines,
|
|
258
|
+
)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Minimal, stdlib-only statistics collector for delegation runs.
|
|
2
|
+
|
|
3
|
+
This is a lightweight observability seam: it counts outcomes and accumulates
|
|
4
|
+
latencies without any third-party dependency, and can optionally append one
|
|
5
|
+
JSONL record per terminal result. It is harness-agnostic — anything that
|
|
6
|
+
produces a controller/service result can feed it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Mapping
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DelegationStats:
|
|
19
|
+
"""Thread-safe counters and latency aggregates over delegation results."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._lock = threading.Lock()
|
|
23
|
+
self._started_ns = time.monotonic_ns()
|
|
24
|
+
self.total = 0
|
|
25
|
+
self.by_status: dict[str, int] = {}
|
|
26
|
+
self.by_model: dict[str, int] = {}
|
|
27
|
+
self.by_error_kind: dict[str, int] = {}
|
|
28
|
+
self.latency_count = 0
|
|
29
|
+
self.latency_total_ns = 0
|
|
30
|
+
self.latency_min_ns: int | None = None
|
|
31
|
+
self.latency_max_ns: int | None = None
|
|
32
|
+
self.tool_calls = 0
|
|
33
|
+
self.model_calls = 0
|
|
34
|
+
|
|
35
|
+
def record(
|
|
36
|
+
self,
|
|
37
|
+
result: Mapping[str, Any],
|
|
38
|
+
*,
|
|
39
|
+
model: str | None = None,
|
|
40
|
+
latency_ns: int | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
with self._lock:
|
|
43
|
+
self.total += 1
|
|
44
|
+
status = str(result.get("status", "unknown"))
|
|
45
|
+
self.by_status[status] = self.by_status.get(status, 0) + 1
|
|
46
|
+
if model:
|
|
47
|
+
self.by_model[model] = self.by_model.get(model, 0) + 1
|
|
48
|
+
error = result.get("error")
|
|
49
|
+
if isinstance(error, Mapping) and error.get("kind"):
|
|
50
|
+
kind = str(error["kind"])
|
|
51
|
+
self.by_error_kind[kind] = self.by_error_kind.get(kind, 0) + 1
|
|
52
|
+
audit = result.get("audit")
|
|
53
|
+
if isinstance(audit, (list, tuple)):
|
|
54
|
+
for event in audit:
|
|
55
|
+
if isinstance(event, Mapping):
|
|
56
|
+
name = event.get("event")
|
|
57
|
+
if name == "tool_call":
|
|
58
|
+
self.tool_calls += 1
|
|
59
|
+
elif name == "model_request":
|
|
60
|
+
self.model_calls += 1
|
|
61
|
+
if latency_ns is not None and latency_ns >= 0:
|
|
62
|
+
self.latency_count += 1
|
|
63
|
+
self.latency_total_ns += latency_ns
|
|
64
|
+
self.latency_min_ns = (
|
|
65
|
+
latency_ns
|
|
66
|
+
if self.latency_min_ns is None
|
|
67
|
+
else min(self.latency_min_ns, latency_ns)
|
|
68
|
+
)
|
|
69
|
+
self.latency_max_ns = (
|
|
70
|
+
latency_ns
|
|
71
|
+
if self.latency_max_ns is None
|
|
72
|
+
else max(self.latency_max_ns, latency_ns)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def snapshot(self) -> dict[str, Any]:
|
|
76
|
+
with self._lock:
|
|
77
|
+
elapsed_s = (time.monotonic_ns() - self._started_ns) / 1_000_000_000
|
|
78
|
+
latency_avg_ns = (
|
|
79
|
+
self.latency_total_ns / self.latency_count if self.latency_count else None
|
|
80
|
+
)
|
|
81
|
+
return {
|
|
82
|
+
"elapsed_seconds": round(elapsed_s, 3),
|
|
83
|
+
"total": self.total,
|
|
84
|
+
"by_status": dict(self.by_status),
|
|
85
|
+
"by_model": dict(self.by_model),
|
|
86
|
+
"by_error_kind": dict(self.by_error_kind),
|
|
87
|
+
"model_calls": self.model_calls,
|
|
88
|
+
"tool_calls": self.tool_calls,
|
|
89
|
+
"latency": {
|
|
90
|
+
"count": self.latency_count,
|
|
91
|
+
"avg_ms": (
|
|
92
|
+
round(latency_avg_ns / 1_000_000, 3)
|
|
93
|
+
if latency_avg_ns is not None
|
|
94
|
+
else None
|
|
95
|
+
),
|
|
96
|
+
"min_ms": (
|
|
97
|
+
round(self.latency_min_ns / 1_000_000, 3)
|
|
98
|
+
if self.latency_min_ns is not None
|
|
99
|
+
else None
|
|
100
|
+
),
|
|
101
|
+
"max_ms": (
|
|
102
|
+
round(self.latency_max_ns / 1_000_000, 3)
|
|
103
|
+
if self.latency_max_ns is not None
|
|
104
|
+
else None
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class JsonlStatsSink:
|
|
111
|
+
"""Append one JSON line per record to a UTF-8 file for later inspection."""
|
|
112
|
+
|
|
113
|
+
_write_lock = threading.Lock() # class-level: one lock across all sink instances/process threads.
|
|
114
|
+
|
|
115
|
+
def __init__(self, path: str | Path) -> None:
|
|
116
|
+
self._path = Path(path)
|
|
117
|
+
|
|
118
|
+
def write(self, record: Mapping[str, Any]) -> None:
|
|
119
|
+
with self._write_lock:
|
|
120
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
with self._path.open("a", encoding="utf-8") as handle:
|
|
122
|
+
handle.write(json.dumps(dict(record), ensure_ascii=False) + "\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TimedDelegationStats:
|
|
126
|
+
"""Convenience wrapper that times a delegate call and records the result."""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
stats: DelegationStats,
|
|
131
|
+
*,
|
|
132
|
+
sink: JsonlStatsSink | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.stats = stats
|
|
135
|
+
self.sink = sink
|
|
136
|
+
|
|
137
|
+
def __call__(
|
|
138
|
+
self,
|
|
139
|
+
delegate,
|
|
140
|
+
caller_id: str,
|
|
141
|
+
request,
|
|
142
|
+
*,
|
|
143
|
+
model: str | None = None,
|
|
144
|
+
) -> Mapping[str, Any]:
|
|
145
|
+
started = time.monotonic_ns()
|
|
146
|
+
result = delegate(caller_id, request)
|
|
147
|
+
latency_ns = time.monotonic_ns() - started
|
|
148
|
+
self.stats.record(result, model=model, latency_ns=latency_ns)
|
|
149
|
+
if self.sink is not None:
|
|
150
|
+
record = {
|
|
151
|
+
"ts": time.time(),
|
|
152
|
+
"caller_id": caller_id,
|
|
153
|
+
"request_id": getattr(request, "request_id", None),
|
|
154
|
+
"model": model,
|
|
155
|
+
"status": result.get("status"),
|
|
156
|
+
"error_kind": (
|
|
157
|
+
result.get("error", {}).get("kind")
|
|
158
|
+
if isinstance(result.get("error"), Mapping)
|
|
159
|
+
else None
|
|
160
|
+
),
|
|
161
|
+
"latency_ms": round(latency_ns / 1_000_000, 3),
|
|
162
|
+
}
|
|
163
|
+
self.sink.write(record)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def default_stats_path() -> Path:
|
|
168
|
+
"""Shared cross-process stats journal location (relative to the cwd)."""
|
|
169
|
+
return Path(".local-run") / "stats.jsonl"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def append_stats(
|
|
173
|
+
path: str | Path,
|
|
174
|
+
result: Mapping[str, Any],
|
|
175
|
+
*,
|
|
176
|
+
model: str | None = None,
|
|
177
|
+
latency_ns: int | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Append one slim, replayable delegation record. Never raises."""
|
|
180
|
+
error = result.get("error")
|
|
181
|
+
record = {
|
|
182
|
+
"ts": time.time(),
|
|
183
|
+
"model": model,
|
|
184
|
+
"status": result.get("status"),
|
|
185
|
+
"error": {"kind": error.get("kind")} if isinstance(error, Mapping) and error.get("kind") else None,
|
|
186
|
+
"latency_ms": round(latency_ns / 1_000_000, 3) if latency_ns is not None else None,
|
|
187
|
+
}
|
|
188
|
+
try:
|
|
189
|
+
JsonlStatsSink(path).write(record)
|
|
190
|
+
except OSError:
|
|
191
|
+
pass # ponytail: telemetry must never break a delegation.
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_stats(path: str | Path, *, max_records: int = 2000) -> DelegationStats:
|
|
195
|
+
"""Rebuild aggregate stats by replaying the JSONL journal written by append_stats."""
|
|
196
|
+
stats = DelegationStats()
|
|
197
|
+
try:
|
|
198
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
199
|
+
except (OSError, UnicodeError):
|
|
200
|
+
return stats
|
|
201
|
+
for line in lines[-max_records:]:
|
|
202
|
+
try:
|
|
203
|
+
rec = json.loads(line)
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
continue
|
|
206
|
+
if not isinstance(rec, dict):
|
|
207
|
+
continue
|
|
208
|
+
latency_ms = rec.get("latency_ms")
|
|
209
|
+
stats.record(
|
|
210
|
+
rec,
|
|
211
|
+
model=rec.get("model"),
|
|
212
|
+
latency_ns=int(latency_ms * 1_000_000) if isinstance(latency_ms, (int, float)) else None,
|
|
213
|
+
)
|
|
214
|
+
return stats
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def merge_stats_snapshots(base: Mapping[str, Any], overlay: Mapping[str, Any]) -> dict[str, Any]:
|
|
218
|
+
"""Merge two DelegationStats.snapshot() dicts (counters summed, latency combined)."""
|
|
219
|
+
merged = dict(base)
|
|
220
|
+
|
|
221
|
+
def _sum_latency(a: Mapping[str, Any], b: Mapping[str, Any]) -> dict[str, Any]:
|
|
222
|
+
count = (a.get("count") or 0) + (b.get("count") or 0)
|
|
223
|
+
total_ns = 0.0
|
|
224
|
+
minimums, maximums = [], []
|
|
225
|
+
for part in (a, b):
|
|
226
|
+
if part.get("count"):
|
|
227
|
+
total_ns += part["avg_ms"] * part["count"] * 1_000_000
|
|
228
|
+
if part.get("min_ms") is not None:
|
|
229
|
+
minimums.append(part["min_ms"])
|
|
230
|
+
if part.get("max_ms") is not None:
|
|
231
|
+
maximums.append(part["max_ms"])
|
|
232
|
+
if not count:
|
|
233
|
+
return {"count": 0, "avg_ms": None, "min_ms": None, "max_ms": None}
|
|
234
|
+
avg_ns = total_ns / count
|
|
235
|
+
return {
|
|
236
|
+
"count": count,
|
|
237
|
+
"avg_ms": round(avg_ns / 1_000_000, 3),
|
|
238
|
+
"min_ms": min(minimums) if minimums else None,
|
|
239
|
+
"max_ms": max(maximums) if maximums else None,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for key in ("total", "model_calls", "tool_calls"):
|
|
243
|
+
if key in base or key in overlay:
|
|
244
|
+
merged[key] = base.get(key, 0) + overlay.get(key, 0)
|
|
245
|
+
# elapsed_seconds is intentionally dropped: journal-replay uptime and
|
|
246
|
+
# server uptime measure different clocks; summing or max-ing misleads.
|
|
247
|
+
merged.pop("elapsed_seconds", None)
|
|
248
|
+
for key in ("by_status", "by_model", "by_error_kind"):
|
|
249
|
+
counts = dict(base.get(key) or {})
|
|
250
|
+
for name, value in (overlay.get(key) or {}).items():
|
|
251
|
+
counts[name] = counts.get(name, 0) + value
|
|
252
|
+
if key in base or key in overlay:
|
|
253
|
+
merged[key] = counts
|
|
254
|
+
merged["latency"] = _sum_latency(base.get("latency") or {}, overlay.get("latency") or {})
|
|
255
|
+
return merged
|