codereview-loop 0.13.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.
- codereview/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
codereview/providers.py
ADDED
|
@@ -0,0 +1,1681 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview providers — build reviewer callables from API keys.
|
|
3
|
+
|
|
4
|
+
A provider is a callable: prompt (str) -> raw model text (str).
|
|
5
|
+
Keys are read from environment variables only; nothing is stored,
|
|
6
|
+
logged, or written to disk by this module. Redirects are refused on
|
|
7
|
+
authenticated requests.
|
|
8
|
+
|
|
9
|
+
Presets: the registry in registry.py (openai, gemini, anthropic,
|
|
10
|
+
deepseek, minimax, moonshot, nvidia, xai, mistral,
|
|
11
|
+
openrouter), each also as preset:model to pick the model.
|
|
12
|
+
Custom: name|openai-compatible|https://base.url/v1|model|KEY_ENV
|
|
13
|
+
name|gemini|-|model|KEY_ENV
|
|
14
|
+
name|anthropic|-|model|KEY_ENV
|
|
15
|
+
openai-compatible:<base>|<model>|<KEY_ENV (escape hatch)
|
|
16
|
+
Local: local:<name>:<base-url>|<model> — a named local model
|
|
17
|
+
server (Epic L5), plus the registry's local presets
|
|
18
|
+
(ollama, lmstudio). Keyless: no key env is read and no
|
|
19
|
+
Authorization header is sent — the endpoint is the
|
|
20
|
+
user's own machine. Family tag 'local'.
|
|
21
|
+
Agent: agent:<name>:<command> — a local agent CLI (Claude Code,
|
|
22
|
+
Codex, Gemini CLI, a wrapper script, ...) seated as a
|
|
23
|
+
reviewer or solver. The prompt is written to a temp file
|
|
24
|
+
whose path is in $CODEREVIEW_PROMPT and also piped to the
|
|
25
|
+
command's stdin; the agent's stdout is the response. The
|
|
26
|
+
command line is split like a shell line (shlex) and run
|
|
27
|
+
WITHOUT a shell — pipes and redirects belong in a wrapper
|
|
28
|
+
script. The command is operator-supplied — the same trust
|
|
29
|
+
level as the loop's --builder-cmd — and is never taken
|
|
30
|
+
from a repo or a diff under review.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import datetime
|
|
36
|
+
import http.client
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shlex
|
|
41
|
+
import shutil
|
|
42
|
+
import signal
|
|
43
|
+
import subprocess
|
|
44
|
+
import tempfile
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
import urllib.error
|
|
48
|
+
import urllib.request
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
from . import seatstate
|
|
52
|
+
except ImportError: # standalone module load
|
|
53
|
+
import seatstate # type: ignore
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from . import herdrhost
|
|
57
|
+
except ImportError: # standalone module load
|
|
58
|
+
import herdrhost # type: ignore
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from . import discovery as _discovery
|
|
62
|
+
except ImportError: # standalone module load
|
|
63
|
+
try:
|
|
64
|
+
import discovery as _discovery # type: ignore
|
|
65
|
+
except ImportError:
|
|
66
|
+
_discovery = None
|
|
67
|
+
|
|
68
|
+
DEFAULT_TIMEOUT = 240
|
|
69
|
+
AGENT_TIMEOUT = 900
|
|
70
|
+
|
|
71
|
+
# Herdr watch discipline (P4-07): a failed pane poll is an event
|
|
72
|
+
# break, reconciled via snapshot — but a herdr that keeps
|
|
73
|
+
# failing the poll while the snapshot stays healthy must not be
|
|
74
|
+
# polled at full loop pace forever. Consecutive pane-get
|
|
75
|
+
# failures back off exponentially from the loop pace to the
|
|
76
|
+
# cap, and a bounded streak of them ends the seat with the
|
|
77
|
+
# recorded herdr-unreachable outcome.
|
|
78
|
+
_HERDR_GET_BACKOFF_BASE_S = 0.05
|
|
79
|
+
_HERDR_GET_BACKOFF_CAP_S = 2.0
|
|
80
|
+
_HERDR_GET_MAX_FAILURES = 20
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ProviderError(Exception):
|
|
84
|
+
"""A provider/seat failure. `retryable` tells the account pool
|
|
85
|
+
whether rotating to another account could help: True/False when
|
|
86
|
+
the failure class is known (quota vs. a local agent command that
|
|
87
|
+
will fail identically on every account), None to fall back to
|
|
88
|
+
the message heuristics in is_retryable.
|
|
89
|
+
|
|
90
|
+
`kind` is the typed failure kind when the failure class is
|
|
91
|
+
known at the raise site (today: "empty" — the provider
|
|
92
|
+
answered with an empty content channel, the content-class
|
|
93
|
+
failure the bench generation layer re-draws; see
|
|
94
|
+
bench/generation.py). `retry_after` carries the server's
|
|
95
|
+
Retry-After hint in seconds when one was sent, for the retry
|
|
96
|
+
layer to honour as a delay floor (P4-01)."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, message, retryable=None, kind=None,
|
|
99
|
+
retry_after=None):
|
|
100
|
+
super().__init__(message)
|
|
101
|
+
self.retryable = retryable
|
|
102
|
+
self.kind = kind
|
|
103
|
+
self.retry_after = retry_after
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
107
|
+
def redirect_request(self, *args):
|
|
108
|
+
raise ProviderError("redirect refused on authenticated request")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Response discipline for _post_json (Phase 4 failure injection):
|
|
112
|
+
# an HTTP error's body is read (bounded) so the retry classifier
|
|
113
|
+
# can see the provider's own error codes (P4-01); the whole call
|
|
114
|
+
# lives under ONE deadline derived from `timeout`, not urllib's
|
|
115
|
+
# per-socket-operation timeout, so a slow-trickle endpoint cannot
|
|
116
|
+
# hold a seat past it (P4-02); a success body is streamed under a
|
|
117
|
+
# hard size cap instead of being buffered unbounded (P4-03); and
|
|
118
|
+
# transport garbage — malformed JSON, non-UTF-8 bytes, a body
|
|
119
|
+
# truncated in transit — is raised as ProviderError with a
|
|
120
|
+
# transient reading, never as a raw decode/parse error the retry
|
|
121
|
+
# layer would mistake for a model-output failure (P4-04).
|
|
122
|
+
_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
123
|
+
_ERROR_BODY_BYTES = 64 * 1024
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _retry_after_seconds(headers):
|
|
127
|
+
"""The Retry-After header as a delay in seconds, or None.
|
|
128
|
+
Both wire forms (RFC 9110): a delay in seconds, or an
|
|
129
|
+
HTTP-date (converted against now; a past date means no
|
|
130
|
+
wait). An unparseable header is no hint at all."""
|
|
131
|
+
raw = None
|
|
132
|
+
if headers is not None:
|
|
133
|
+
try:
|
|
134
|
+
raw = headers.get("Retry-After")
|
|
135
|
+
except Exception: # noqa: BLE001 — header containers vary
|
|
136
|
+
raw = None
|
|
137
|
+
if not raw:
|
|
138
|
+
return None
|
|
139
|
+
raw = str(raw).strip()
|
|
140
|
+
try:
|
|
141
|
+
return max(0.0, float(raw))
|
|
142
|
+
except ValueError:
|
|
143
|
+
pass
|
|
144
|
+
try:
|
|
145
|
+
from email.utils import parsedate_to_datetime
|
|
146
|
+
when = parsedate_to_datetime(raw)
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
return None
|
|
149
|
+
if when is None:
|
|
150
|
+
return None
|
|
151
|
+
if when.tzinfo is None:
|
|
152
|
+
when = when.replace(tzinfo=datetime.timezone.utc)
|
|
153
|
+
return max(0.0, (when - datetime.datetime.now(
|
|
154
|
+
datetime.timezone.utc)).total_seconds())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _read_response_body(resp, deadline, host, timeout):
|
|
158
|
+
"""Stream a response body under the call's overall deadline
|
|
159
|
+
and the size cap. read1() performs at most one socket read
|
|
160
|
+
per call, so a trickling endpoint is re-checked against the
|
|
161
|
+
deadline after every byte instead of once per buffer-fill.
|
|
162
|
+
The per-socket timeout urllib set still bounds any single
|
|
163
|
+
silent stretch; this deadline bounds the whole body."""
|
|
164
|
+
chunks = []
|
|
165
|
+
total = 0
|
|
166
|
+
declared = None
|
|
167
|
+
try:
|
|
168
|
+
declared = int(resp.headers.get("Content-Length"))
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
declared = None
|
|
171
|
+
while True:
|
|
172
|
+
if time.monotonic() > deadline:
|
|
173
|
+
raise ProviderError(
|
|
174
|
+
f"response from {host} did not finish within "
|
|
175
|
+
f"{timeout:g}s overall (temporary transport "
|
|
176
|
+
f"timeout)")
|
|
177
|
+
try:
|
|
178
|
+
chunk = resp.read1(65536)
|
|
179
|
+
except http.client.HTTPException as e:
|
|
180
|
+
raise ProviderError(
|
|
181
|
+
f"response body from {host} broke in transit "
|
|
182
|
+
f"(temporary transport failure): {e}") from e
|
|
183
|
+
if not chunk:
|
|
184
|
+
break
|
|
185
|
+
total += len(chunk)
|
|
186
|
+
if total > _MAX_RESPONSE_BYTES:
|
|
187
|
+
raise ProviderError(
|
|
188
|
+
f"response body from {host} exceeded the "
|
|
189
|
+
f"{_MAX_RESPONSE_BYTES}-byte cap (temporary "
|
|
190
|
+
f"transport failure)")
|
|
191
|
+
chunks.append(chunk)
|
|
192
|
+
if declared is not None and total < declared:
|
|
193
|
+
raise ProviderError(
|
|
194
|
+
f"response body from {host} was truncated: {total} "
|
|
195
|
+
f"of {declared} bytes arrived (temporary transport "
|
|
196
|
+
f"failure)")
|
|
197
|
+
return b"".join(chunks)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _post_json(url, headers, payload, timeout=DEFAULT_TIMEOUT):
|
|
201
|
+
host = url.split("/")[2]
|
|
202
|
+
req = urllib.request.Request(
|
|
203
|
+
url, data=json.dumps(payload).encode("utf-8"),
|
|
204
|
+
headers={"Content-Type": "application/json", **headers},
|
|
205
|
+
method="POST")
|
|
206
|
+
opener = urllib.request.build_opener(_NoRedirect())
|
|
207
|
+
try:
|
|
208
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
209
|
+
body = _read_response_body(
|
|
210
|
+
resp, time.monotonic() + timeout, host, timeout)
|
|
211
|
+
except urllib.error.HTTPError as e:
|
|
212
|
+
# The provider's error body carries the class-deciding
|
|
213
|
+
# codes (insufficient_quota, RESOURCE_EXHAUSTED, ...):
|
|
214
|
+
# read it (bounded, tolerant of a broken error body) and
|
|
215
|
+
# carry an excerpt in the error, so _classify_error's
|
|
216
|
+
# quota rules can fire for API seats exactly as they do
|
|
217
|
+
# for agent seats (P4-01).
|
|
218
|
+
excerpt = ""
|
|
219
|
+
try:
|
|
220
|
+
raw = e.read(_ERROR_BODY_BYTES + 1)
|
|
221
|
+
excerpt = raw[:_ERROR_BODY_BYTES].decode(
|
|
222
|
+
"utf-8", "replace").strip()
|
|
223
|
+
except Exception: # noqa: BLE001 — an unreadable error
|
|
224
|
+
excerpt = "" # body must not mask the status
|
|
225
|
+
msg = f"HTTP {e.code} from {host}"
|
|
226
|
+
if excerpt:
|
|
227
|
+
# The body is provider-controlled text that lands in
|
|
228
|
+
# the record and report: redact secret-shaped values
|
|
229
|
+
# (an auth error can echo the key it rejected) before
|
|
230
|
+
# it enters the message (P4-01b). Redact the whole
|
|
231
|
+
# excerpt first, then truncate, so a secret cannot
|
|
232
|
+
# straddle the cut.
|
|
233
|
+
try:
|
|
234
|
+
from . import core as _core
|
|
235
|
+
excerpt = _core.redact_secrets(excerpt)
|
|
236
|
+
except Exception: # noqa: BLE001 — never mask status
|
|
237
|
+
excerpt = ""
|
|
238
|
+
msg += f": {excerpt[:2000]}"
|
|
239
|
+
raise ProviderError(
|
|
240
|
+
msg, retry_after=_retry_after_seconds(
|
|
241
|
+
getattr(e, "headers", None)
|
|
242
|
+
or getattr(e, "hdrs", None))) from e
|
|
243
|
+
except (TimeoutError, ConnectionError, urllib.error.URLError) as e:
|
|
244
|
+
raise ProviderError(f"network error: {e}") from e
|
|
245
|
+
try:
|
|
246
|
+
text = body.decode("utf-8")
|
|
247
|
+
except UnicodeDecodeError as e:
|
|
248
|
+
raise ProviderError(
|
|
249
|
+
f"response body from {host} is not valid UTF-8 "
|
|
250
|
+
f"(temporary transport failure): {e}") from e
|
|
251
|
+
try:
|
|
252
|
+
return json.loads(text)
|
|
253
|
+
except json.JSONDecodeError as e:
|
|
254
|
+
raise ProviderError(
|
|
255
|
+
f"response body from {host} is not valid JSON "
|
|
256
|
+
f"(temporary transport failure): {e}") from e
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _post(url, headers, payload, timeout):
|
|
260
|
+
"""_post_json under the factory timeout convention: `timeout`
|
|
261
|
+
None keeps _post_json's own default (DEFAULT_TIMEOUT), so the
|
|
262
|
+
default lives in exactly one place; a value bounds the call
|
|
263
|
+
overall (P4-02)."""
|
|
264
|
+
if timeout is None:
|
|
265
|
+
return _post_json(url, headers, payload)
|
|
266
|
+
return _post_json(url, headers, payload, timeout=timeout)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _key(key_env):
|
|
270
|
+
key = os.environ.get(key_env, "").strip()
|
|
271
|
+
if not key:
|
|
272
|
+
raise ProviderError(f"{key_env} is not set")
|
|
273
|
+
return key
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_SYSTEM = ("You are an independent, strict code reviewer in a multi-model "
|
|
277
|
+
"review loop. Output only the JSON object the user prompt "
|
|
278
|
+
"asks for.")
|
|
279
|
+
|
|
280
|
+
_SOLVER_SYSTEM = ("You are the solver in a software-maintenance "
|
|
281
|
+
"workflow. You turn an issue report into an "
|
|
282
|
+
"executable solution plan in Markdown. You never "
|
|
283
|
+
"write the fix yourself; a builder implements your "
|
|
284
|
+
"plan and an independent review gates the result.")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def openai_compatible(base_url, model, key_env, extra_payload=None,
|
|
288
|
+
system=_SYSTEM, omit_temperature=False,
|
|
289
|
+
completion_tokens=False, timeout=None):
|
|
290
|
+
"""Callable for any OpenAI-compatible chat completions endpoint.
|
|
291
|
+
|
|
292
|
+
`omit_temperature` drops the temperature field entirely for
|
|
293
|
+
models whose API rejects a caller-set value (registry quirk
|
|
294
|
+
table — e.g. OpenAI's gpt-5 line answers HTTP 400); the field
|
|
295
|
+
is omitted, never replaced with a fabricated default.
|
|
296
|
+
|
|
297
|
+
`completion_tokens` sends the token cap as
|
|
298
|
+
`max_completion_tokens` instead of `max_tokens`, for models
|
|
299
|
+
whose API rejects the old name (registry quirk table — the
|
|
300
|
+
same gpt-5 / o-series lines answer HTTP 400 to `max_tokens`;
|
|
301
|
+
the L3 smoke's first pass failed on exactly this, the openai
|
|
302
|
+
preset's default model being gpt-5-mini). Models outside the
|
|
303
|
+
quirk table keep `max_tokens` — several compatible endpoints
|
|
304
|
+
require it.
|
|
305
|
+
|
|
306
|
+
`key_env` None is the keyless local form (Epic L5): no
|
|
307
|
+
credential is read and NO Authorization header is sent — the
|
|
308
|
+
endpoint is a model server on the user's own machine, which
|
|
309
|
+
expects none. Keyed callers are untouched: same header, same
|
|
310
|
+
_key failure when the env var is unset."""
|
|
311
|
+
def call(prompt):
|
|
312
|
+
payload = {
|
|
313
|
+
"model": model,
|
|
314
|
+
"messages": [{"role": "system", "content": system},
|
|
315
|
+
{"role": "user", "content": prompt}],
|
|
316
|
+
("max_completion_tokens" if completion_tokens
|
|
317
|
+
else "max_tokens"): 8192,
|
|
318
|
+
}
|
|
319
|
+
if not omit_temperature:
|
|
320
|
+
payload["temperature"] = 0.1
|
|
321
|
+
if extra_payload:
|
|
322
|
+
payload.update(extra_payload)
|
|
323
|
+
headers = {} if key_env is None else {
|
|
324
|
+
"Authorization": f"Bearer {_key(key_env)}"}
|
|
325
|
+
data = _post(
|
|
326
|
+
base_url.rstrip("/") + "/chat/completions",
|
|
327
|
+
headers, payload, timeout)
|
|
328
|
+
try:
|
|
329
|
+
text = (data["choices"][0]["message"].get("content")
|
|
330
|
+
or "").strip()
|
|
331
|
+
except (KeyError, IndexError, TypeError, AttributeError) as e:
|
|
332
|
+
raise ProviderError(f"bad response shape: {e}") from e
|
|
333
|
+
if not text:
|
|
334
|
+
raise ProviderError("empty content from model",
|
|
335
|
+
kind="empty")
|
|
336
|
+
return text
|
|
337
|
+
return call
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def gemini(model, key_env="GEMINI_API_KEY", system=_SYSTEM,
|
|
341
|
+
json_mode=True, timeout=None):
|
|
342
|
+
"""Callable for the Gemini generateContent API.
|
|
343
|
+
|
|
344
|
+
json_mode (the reviewer default) asks the API for a JSON body;
|
|
345
|
+
solver callables pass json_mode=False because their answer is a
|
|
346
|
+
Markdown plan, not a JSON object."""
|
|
347
|
+
def call(prompt):
|
|
348
|
+
url = ("https://generativelanguage.googleapis.com/v1beta/models/"
|
|
349
|
+
f"{model}:generateContent")
|
|
350
|
+
generation = {"temperature": 0.1, "maxOutputTokens": 8192}
|
|
351
|
+
if json_mode:
|
|
352
|
+
generation["responseMimeType"] = "application/json"
|
|
353
|
+
payload = {
|
|
354
|
+
"systemInstruction": {"parts": [{"text": system}]},
|
|
355
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
356
|
+
"generationConfig": generation,
|
|
357
|
+
}
|
|
358
|
+
data = _post(url, {"x-goog-api-key": _key(key_env)},
|
|
359
|
+
payload, timeout)
|
|
360
|
+
try:
|
|
361
|
+
parts = data["candidates"][0]["content"]["parts"]
|
|
362
|
+
text = "".join(p.get("text", "") for p in parts).strip()
|
|
363
|
+
except (KeyError, IndexError, TypeError, AttributeError) as e:
|
|
364
|
+
raise ProviderError(f"bad response shape: {e}") from e
|
|
365
|
+
if not text:
|
|
366
|
+
raise ProviderError("empty content from model",
|
|
367
|
+
kind="empty")
|
|
368
|
+
return text
|
|
369
|
+
return call
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def anthropic(model, key_env="ANTHROPIC_API_KEY", system=_SYSTEM,
|
|
373
|
+
timeout=None):
|
|
374
|
+
"""Callable for the Anthropic Messages API (native wire shape:
|
|
375
|
+
x-api-key + anthropic-version headers, system as a top-level
|
|
376
|
+
field, the answer in content blocks)."""
|
|
377
|
+
def call(prompt):
|
|
378
|
+
payload = {
|
|
379
|
+
"model": model,
|
|
380
|
+
"max_tokens": 8192,
|
|
381
|
+
"temperature": 0.1,
|
|
382
|
+
"system": system,
|
|
383
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
384
|
+
}
|
|
385
|
+
data = _post(
|
|
386
|
+
"https://api.anthropic.com/v1/messages",
|
|
387
|
+
{"x-api-key": _key(key_env),
|
|
388
|
+
"anthropic-version": "2023-06-01"}, payload, timeout)
|
|
389
|
+
try:
|
|
390
|
+
text = "".join(
|
|
391
|
+
b.get("text", "") for b in data["content"]
|
|
392
|
+
if b.get("type") == "text").strip()
|
|
393
|
+
except (KeyError, TypeError, AttributeError) as e:
|
|
394
|
+
raise ProviderError(f"bad response shape: {e}") from e
|
|
395
|
+
if not text:
|
|
396
|
+
raise ProviderError("empty content from model",
|
|
397
|
+
kind="empty")
|
|
398
|
+
return text
|
|
399
|
+
return call
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
MAX_AGENT_OUTPUT = 1_000_000
|
|
403
|
+
|
|
404
|
+
# The only variables an agent seat inherits by default. Everything
|
|
405
|
+
# else in the operator's environment — above all every provider API
|
|
406
|
+
# key and token — stays out of the agent process unless the operator
|
|
407
|
+
# explicitly opts a name in via CODEREVIEW_AGENT_ENV (comma-separated).
|
|
408
|
+
_AGENT_ENV_BASE = (
|
|
409
|
+
"PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "TMPDIR",
|
|
410
|
+
"USER", "LOGNAME", "SHELL",
|
|
411
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
412
|
+
"http_proxy", "https_proxy", "no_proxy",
|
|
413
|
+
"SYSTEMROOT", "WINDIR", "PATHEXT", "COMSPEC",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _agent_env(prompt_path):
|
|
418
|
+
# By design (v0.7.0 external review requirement; review-v14
|
|
419
|
+
# finding [14] asked for the intent to be visible here): an
|
|
420
|
+
# agent seat NEVER inherits the operator's provider API keys or
|
|
421
|
+
# tokens by default — it authenticates as itself, with its own
|
|
422
|
+
# account. The allow-list above is the whole inheritance; the
|
|
423
|
+
# only way in for anything else is an explicit operator opt-in
|
|
424
|
+
# via CODEREVIEW_AGENT_ENV.
|
|
425
|
+
names = [n for n in _AGENT_ENV_BASE if n in os.environ]
|
|
426
|
+
extra = [n.strip() for n in
|
|
427
|
+
os.environ.get("CODEREVIEW_AGENT_ENV", "").split(",")
|
|
428
|
+
if n.strip()]
|
|
429
|
+
env = {n: os.environ[n] for n in names + extra if n in os.environ}
|
|
430
|
+
env["CODEREVIEW_PROMPT"] = prompt_path
|
|
431
|
+
return env
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _kill_tree(proc, pgid=None):
|
|
435
|
+
"""Kill the agent's whole process group (it was started in its
|
|
436
|
+
own session), so no orphaned grandchildren survive — called on
|
|
437
|
+
timeout AND after a normal exit (an agent may leave daemons
|
|
438
|
+
behind; v0.7.1 external review, finding 3.2). A group that is
|
|
439
|
+
already gone is not an error."""
|
|
440
|
+
try:
|
|
441
|
+
if os.name == "posix":
|
|
442
|
+
os.killpg(pgid if pgid is not None
|
|
443
|
+
else os.getpgid(proc.pid), signal.SIGKILL)
|
|
444
|
+
else:
|
|
445
|
+
proc.kill()
|
|
446
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
447
|
+
try:
|
|
448
|
+
proc.kill()
|
|
449
|
+
except OSError:
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
MAX_AGENT_FILE = 8 * 1024 * 1024
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _limit_files():
|
|
457
|
+
"""preexec_fn (POSIX): cap the size of any file the agent
|
|
458
|
+
writes, so a runaway agent cannot fill the disk while its
|
|
459
|
+
output is being captured (finding 3.3). The child dies with
|
|
460
|
+
SIGXFSZ past the cap; the read side keeps the verdict tail."""
|
|
461
|
+
import resource
|
|
462
|
+
resource.setrlimit(resource.RLIMIT_FSIZE,
|
|
463
|
+
(MAX_AGENT_FILE, MAX_AGENT_FILE))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _read_tail(path, limit):
|
|
467
|
+
try:
|
|
468
|
+
size = os.path.getsize(path)
|
|
469
|
+
with open(path, "rb") as fh:
|
|
470
|
+
if size > limit:
|
|
471
|
+
fh.seek(-limit, os.SEEK_END)
|
|
472
|
+
return fh.read().decode("utf-8", "replace")
|
|
473
|
+
except OSError:
|
|
474
|
+
return ""
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _run_seat_process(proc, pgid, prompt, timeout, out_path,
|
|
478
|
+
err_path, cli_key, log, stall_after,
|
|
479
|
+
block_grace):
|
|
480
|
+
"""Supervised wait for one agent-seat process (Epic K2).
|
|
481
|
+
|
|
482
|
+
Feeds the prompt on a writer thread (a seat may never read
|
|
483
|
+
stdin; a blocked pipe must not stall supervision), polls the
|
|
484
|
+
capture files for output activity, and returns an outcome
|
|
485
|
+
dict: {"kind": "exit"} when the process ended on its own
|
|
486
|
+
(returncode set); {"kind": "timeout", "facts": ...} when the
|
|
487
|
+
per-call ceiling killed it (facts classify blocked/stalled
|
|
488
|
+
when the seat had been silent past the stall bound, else
|
|
489
|
+
timed-out); {"kind": "blocked", "facts": ...} when a
|
|
490
|
+
verified failure pattern matched on stderr and output then
|
|
491
|
+
stayed silent for the whole grace bound — the seat is stuck
|
|
492
|
+
on a condition it cannot recover from by waiting, so it is
|
|
493
|
+
terminated early instead of burning the full timeout. Live
|
|
494
|
+
stall episodes are emitted to `log` (the job's SeatStateLog)
|
|
495
|
+
as they happen; the terminal classification itself is the
|
|
496
|
+
caller's, taken from the outcome's facts."""
|
|
497
|
+
|
|
498
|
+
def _feed():
|
|
499
|
+
try:
|
|
500
|
+
proc.stdin.write(prompt.encode("utf-8"))
|
|
501
|
+
proc.stdin.close()
|
|
502
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
threading.Thread(target=_feed, daemon=True).start()
|
|
506
|
+
|
|
507
|
+
def _size(path):
|
|
508
|
+
try:
|
|
509
|
+
return os.path.getsize(path)
|
|
510
|
+
except OSError:
|
|
511
|
+
return -1
|
|
512
|
+
|
|
513
|
+
started = time.monotonic()
|
|
514
|
+
deadline = started + timeout
|
|
515
|
+
last_size = _size(out_path) + _size(err_path)
|
|
516
|
+
last_activity = started
|
|
517
|
+
stalled = False
|
|
518
|
+
matched = None
|
|
519
|
+
while True:
|
|
520
|
+
if proc.poll() is not None:
|
|
521
|
+
# A seat's final write can land in the same poll as
|
|
522
|
+
# its exit: an open stall episode still closes, so
|
|
523
|
+
# the trace never ends on a stall the seat recovered
|
|
524
|
+
# from by answering.
|
|
525
|
+
if stalled and log is not None and (
|
|
526
|
+
_size(out_path) + _size(err_path) != last_size):
|
|
527
|
+
log.emit("working")
|
|
528
|
+
return {"kind": "exit"}
|
|
529
|
+
now = time.monotonic()
|
|
530
|
+
size = _size(out_path) + _size(err_path)
|
|
531
|
+
if size != last_size:
|
|
532
|
+
last_size = size
|
|
533
|
+
last_activity = now
|
|
534
|
+
if stalled:
|
|
535
|
+
stalled = False
|
|
536
|
+
if log is not None:
|
|
537
|
+
log.emit("working")
|
|
538
|
+
silent = now - last_activity
|
|
539
|
+
if log is not None and not stalled \
|
|
540
|
+
and silent >= stall_after:
|
|
541
|
+
stalled = True
|
|
542
|
+
log.emit("blocked", "stalled",
|
|
543
|
+
{"silent_s": round(silent, 1),
|
|
544
|
+
"stall_after_s": stall_after,
|
|
545
|
+
"transient": True})
|
|
546
|
+
if matched is None and cli_key is not None:
|
|
547
|
+
matched = seatstate.match_failure(
|
|
548
|
+
cli_key, _read_tail(err_path, 8192))
|
|
549
|
+
if matched is not None and silent >= block_grace:
|
|
550
|
+
_kill_tree(proc, pgid)
|
|
551
|
+
proc.wait()
|
|
552
|
+
return {"kind": "blocked", "facts": {
|
|
553
|
+
"state": "blocked", "class": matched["class"],
|
|
554
|
+
"evidence": {"pattern": matched["pattern"],
|
|
555
|
+
"matched": matched["matched"],
|
|
556
|
+
"source": "stderr",
|
|
557
|
+
"early_terminated": True,
|
|
558
|
+
"silent_s": round(silent, 1)}}}
|
|
559
|
+
if now >= deadline:
|
|
560
|
+
_kill_tree(proc, pgid)
|
|
561
|
+
proc.wait()
|
|
562
|
+
if silent >= stall_after:
|
|
563
|
+
facts = {"state": "blocked", "class": "stalled",
|
|
564
|
+
"evidence": {
|
|
565
|
+
"silent_s": round(silent, 1),
|
|
566
|
+
"timeout_s": timeout}}
|
|
567
|
+
else:
|
|
568
|
+
facts = {"state": "timed-out", "class": None,
|
|
569
|
+
"evidence": {"timeout_s": timeout}}
|
|
570
|
+
return {"kind": "timeout", "facts": facts}
|
|
571
|
+
time.sleep(0.05)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _redact_tail(text):
|
|
575
|
+
"""Redact secret-shaped values from a seat's stderr tail
|
|
576
|
+
before the tail is quoted into a ProviderError (P4-01b,
|
|
577
|
+
audit extension): the error message is persisted into the
|
|
578
|
+
record verbatim, and a seat's stderr can echo a key just
|
|
579
|
+
as a provider's error body can — a CLI explaining an auth
|
|
580
|
+
failure is exactly where that happens. On any redaction
|
|
581
|
+
failure the tail is dropped rather than quoted raw:
|
|
582
|
+
diagnostics are expendable, secrets are not."""
|
|
583
|
+
try:
|
|
584
|
+
from . import core as _core
|
|
585
|
+
return _core.redact_secrets(text)
|
|
586
|
+
except Exception: # noqa: BLE001 — never quote it raw
|
|
587
|
+
return ""
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _hosted_outcome_error(outcome, timeout, block_grace):
|
|
591
|
+
"""The ProviderError a hosted seat's non-exit outcome maps
|
|
592
|
+
to — the headless messages, verbatim, plus seat_facts, so a
|
|
593
|
+
hosted failure reads exactly like its headless twin in the
|
|
594
|
+
record (the hosting receipt carries the transport facts)."""
|
|
595
|
+
if outcome["kind"] == "timeout":
|
|
596
|
+
err = ProviderError(
|
|
597
|
+
f"agent command timed out after {timeout}s",
|
|
598
|
+
retryable=False)
|
|
599
|
+
err.seat_facts = outcome["facts"]
|
|
600
|
+
return err
|
|
601
|
+
if outcome["kind"] == "blocked":
|
|
602
|
+
facts = outcome["facts"]
|
|
603
|
+
err = ProviderError(
|
|
604
|
+
f"agent command blocked ({facts['class']}): "
|
|
605
|
+
f"matched {facts['evidence']['pattern']} on "
|
|
606
|
+
f"stderr; no output for {block_grace:g}s — "
|
|
607
|
+
f"terminated early", retryable=False)
|
|
608
|
+
err.seat_facts = facts
|
|
609
|
+
return err
|
|
610
|
+
# kind == "herdr-failure": the transport died under the
|
|
611
|
+
# seat (herdr exited, the pane vanished, the session was
|
|
612
|
+
# restarted). A recorded seat failure — never a retry under
|
|
613
|
+
# a new pane inside this attempt, never a silent resume.
|
|
614
|
+
err = ProviderError(
|
|
615
|
+
f"herdr hosting failed ({outcome['stage']}): "
|
|
616
|
+
f"{outcome['detail']}", retryable=False)
|
|
617
|
+
err.seat_facts = {
|
|
618
|
+
"state": "failed", "class": "error",
|
|
619
|
+
"evidence": {"stage": outcome["stage"],
|
|
620
|
+
"transport": "herdr",
|
|
621
|
+
"detail": outcome["detail"][:300]}}
|
|
622
|
+
return err
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _run_hosted_seat(host, receipt, pane_id, out_path, err_path,
|
|
626
|
+
rc_path, cli_key, log, timeout, stall_after,
|
|
627
|
+
block_grace):
|
|
628
|
+
"""Supervised wait for one herdr-hosted seat (Epic K3a).
|
|
629
|
+
|
|
630
|
+
The headless wait supervises OUR subprocess; here the
|
|
631
|
+
process lives in a herdr pane, so the truth is recovered
|
|
632
|
+
from the same artefacts the headless runner reads — the
|
|
633
|
+
capture files (activity, stderr patterns, the response)
|
|
634
|
+
plus the exit-code file the pane command writes when the
|
|
635
|
+
seat command finishes. Herdr's agent_status is polled as
|
|
636
|
+
the wrapper-layer subscription (see herdrhost.py): it
|
|
637
|
+
annotates the receipt and may raise an ADVISORY
|
|
638
|
+
blocked:input episode in the seat-state log; it never
|
|
639
|
+
decides the outcome. A failed poll is an event break
|
|
640
|
+
(events_lost at the socket layer): reconcile via
|
|
641
|
+
session.snapshot — same pane, same terminal_id, or the
|
|
642
|
+
seat fails. Consecutive poll failures back off
|
|
643
|
+
exponentially (P4-07) and a bounded streak of them, with
|
|
644
|
+
reconciliation still proving the session healthy, ends
|
|
645
|
+
the seat as herdr-unreachable. Returns the headless
|
|
646
|
+
outcome shapes ({"kind": "exit", "returncode": N} /
|
|
647
|
+
"timeout" / "blocked") plus {"kind": "herdr-failure",
|
|
648
|
+
...}."""
|
|
649
|
+
|
|
650
|
+
def _size(path):
|
|
651
|
+
try:
|
|
652
|
+
return os.path.getsize(path)
|
|
653
|
+
except OSError:
|
|
654
|
+
return -1
|
|
655
|
+
|
|
656
|
+
def _rc():
|
|
657
|
+
try:
|
|
658
|
+
with open(rc_path, encoding="utf-8") as fh:
|
|
659
|
+
return int(fh.read().strip())
|
|
660
|
+
except (OSError, ValueError):
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
started = time.monotonic()
|
|
664
|
+
deadline = started + timeout
|
|
665
|
+
last_size = _size(out_path) + _size(err_path)
|
|
666
|
+
last_activity = started
|
|
667
|
+
stalled = False
|
|
668
|
+
matched = None
|
|
669
|
+
last_status = None
|
|
670
|
+
advisory_blocked = False
|
|
671
|
+
terminal_id = None
|
|
672
|
+
get_failures = 0
|
|
673
|
+
pace = _HERDR_GET_BACKOFF_BASE_S
|
|
674
|
+
|
|
675
|
+
def _observe(pane, via):
|
|
676
|
+
"""Fold one pane observation into the receipt (+ the
|
|
677
|
+
advisory log episodes). Returns a herdr-failure
|
|
678
|
+
outcome when the observation proves the session was
|
|
679
|
+
restarted under the seat, else None."""
|
|
680
|
+
nonlocal last_status, advisory_blocked, terminal_id
|
|
681
|
+
if not isinstance(pane, dict):
|
|
682
|
+
return None
|
|
683
|
+
tid = pane.get("terminal_id")
|
|
684
|
+
if tid is not None:
|
|
685
|
+
if terminal_id is None:
|
|
686
|
+
terminal_id = tid
|
|
687
|
+
receipt["terminal_id"] = tid
|
|
688
|
+
elif tid != terminal_id:
|
|
689
|
+
return {"kind": "herdr-failure",
|
|
690
|
+
"stage": "session-restarted",
|
|
691
|
+
"detail": "the pane's terminal changed "
|
|
692
|
+
"mid-run (herdr restarted); a resumed "
|
|
693
|
+
"session is never silently continued"}
|
|
694
|
+
status = pane.get("agent_status")
|
|
695
|
+
if status is not None and status != last_status:
|
|
696
|
+
last_status = status
|
|
697
|
+
observations = receipt.setdefault("observations", [])
|
|
698
|
+
if len(observations) < 100:
|
|
699
|
+
observations.append({
|
|
700
|
+
"at_ms": int((time.monotonic() - started)
|
|
701
|
+
* 1000),
|
|
702
|
+
"agent_status": status, "via": via})
|
|
703
|
+
if log is not None:
|
|
704
|
+
if status == "blocked" and not advisory_blocked:
|
|
705
|
+
advisory_blocked = True
|
|
706
|
+
# K2 signal #4: herdr's blocked is an
|
|
707
|
+
# approval/question UI — in a non-
|
|
708
|
+
# interactive seat that is an input wait.
|
|
709
|
+
# Advisory only: non-terminal, and process
|
|
710
|
+
# truth (the files) still decides the seat.
|
|
711
|
+
log.emit("blocked", "input", {
|
|
712
|
+
"source": "herdr",
|
|
713
|
+
"herdr_status": "blocked",
|
|
714
|
+
"advisory": True})
|
|
715
|
+
elif status != "blocked" and advisory_blocked:
|
|
716
|
+
advisory_blocked = False
|
|
717
|
+
log.emit("working", None, {
|
|
718
|
+
"source": "herdr",
|
|
719
|
+
"herdr_status": status,
|
|
720
|
+
"advisory": True})
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
def _reconcile():
|
|
724
|
+
"""After an event break: session.snapshot is the
|
|
725
|
+
documented recovery. Returns None when the snapshot
|
|
726
|
+
proves the same pane under the same terminal (the
|
|
727
|
+
watch resumes), else the seat-failure outcome."""
|
|
728
|
+
try:
|
|
729
|
+
snap = host.snapshot()
|
|
730
|
+
except herdrhost.HerdrError as exc:
|
|
731
|
+
return {"kind": "herdr-failure",
|
|
732
|
+
"stage": "herdr-unreachable",
|
|
733
|
+
"detail": f"event break, and reconciliation "
|
|
734
|
+
f"failed: {exc}"}
|
|
735
|
+
receipt["reconciled"] = True
|
|
736
|
+
panes = snap.get("panes") or []
|
|
737
|
+
pane = next((p for p in panes
|
|
738
|
+
if isinstance(p, dict)
|
|
739
|
+
and p.get("pane_id") == pane_id), None)
|
|
740
|
+
if pane is None:
|
|
741
|
+
if _rc() is not None:
|
|
742
|
+
return None # the seat finished as the pane
|
|
743
|
+
# went away; the exit file is senior truth
|
|
744
|
+
return {"kind": "herdr-failure",
|
|
745
|
+
"stage": "pane-exited",
|
|
746
|
+
"detail": "the pane exited before the seat "
|
|
747
|
+
"command wrote its exit code"}
|
|
748
|
+
return _observe(pane, "snapshot")
|
|
749
|
+
|
|
750
|
+
while True:
|
|
751
|
+
rc = _rc()
|
|
752
|
+
if rc is not None:
|
|
753
|
+
if stalled and log is not None and (
|
|
754
|
+
_size(out_path) + _size(err_path) != last_size):
|
|
755
|
+
log.emit("working")
|
|
756
|
+
return {"kind": "exit", "returncode": rc}
|
|
757
|
+
now = time.monotonic()
|
|
758
|
+
size = _size(out_path) + _size(err_path)
|
|
759
|
+
if size != last_size:
|
|
760
|
+
last_size = size
|
|
761
|
+
last_activity = now
|
|
762
|
+
if stalled:
|
|
763
|
+
stalled = False
|
|
764
|
+
if log is not None:
|
|
765
|
+
log.emit("working")
|
|
766
|
+
silent = now - last_activity
|
|
767
|
+
if log is not None and not stalled \
|
|
768
|
+
and silent >= stall_after:
|
|
769
|
+
stalled = True
|
|
770
|
+
log.emit("blocked", "stalled",
|
|
771
|
+
{"silent_s": round(silent, 1),
|
|
772
|
+
"stall_after_s": stall_after,
|
|
773
|
+
"transient": True})
|
|
774
|
+
if matched is None and cli_key is not None:
|
|
775
|
+
matched = seatstate.match_failure(
|
|
776
|
+
cli_key, _read_tail(err_path, 8192))
|
|
777
|
+
if matched is not None and silent >= block_grace:
|
|
778
|
+
return {"kind": "blocked", "facts": {
|
|
779
|
+
"state": "blocked", "class": matched["class"],
|
|
780
|
+
"evidence": {"pattern": matched["pattern"],
|
|
781
|
+
"matched": matched["matched"],
|
|
782
|
+
"source": "stderr",
|
|
783
|
+
"early_terminated": True,
|
|
784
|
+
"silent_s": round(silent, 1),
|
|
785
|
+
"transport": "herdr"}}}
|
|
786
|
+
# The watch: one pane poll per loop is the subscription
|
|
787
|
+
# at the wrapper layer. Herdr CLI calls are quick; the
|
|
788
|
+
# seat timeout bounds the whole loop regardless. A
|
|
789
|
+
# failed poll is reconciled, and consecutive failures
|
|
790
|
+
# back the loop off exponentially (P4-07): a persistent
|
|
791
|
+
# failure with a healthy snapshot otherwise spawned a
|
|
792
|
+
# herdr process ~13 times a second for the whole seat
|
|
793
|
+
# timeout. A bounded streak ends the seat with the same
|
|
794
|
+
# herdr-unreachable outcome a failed reconciliation
|
|
795
|
+
# produces.
|
|
796
|
+
try:
|
|
797
|
+
failure = _observe(host.pane_get(pane_id), "pane.get")
|
|
798
|
+
get_failures = 0
|
|
799
|
+
pace = _HERDR_GET_BACKOFF_BASE_S
|
|
800
|
+
except herdrhost.HerdrError:
|
|
801
|
+
receipt["events_lost"] = \
|
|
802
|
+
receipt.get("events_lost", 0) + 1
|
|
803
|
+
get_failures += 1
|
|
804
|
+
failure = _reconcile()
|
|
805
|
+
if failure is None \
|
|
806
|
+
and get_failures >= _HERDR_GET_MAX_FAILURES:
|
|
807
|
+
failure = {
|
|
808
|
+
"kind": "herdr-failure",
|
|
809
|
+
"stage": "herdr-unreachable",
|
|
810
|
+
"detail": f"pane get failed {get_failures} "
|
|
811
|
+
f"times in a row while "
|
|
812
|
+
f"reconciliation kept proving the "
|
|
813
|
+
f"session healthy; the watch is "
|
|
814
|
+
f"given up as unreachable"}
|
|
815
|
+
pace = min(_HERDR_GET_BACKOFF_CAP_S,
|
|
816
|
+
_HERDR_GET_BACKOFF_BASE_S
|
|
817
|
+
* (2 ** get_failures))
|
|
818
|
+
if failure is not None:
|
|
819
|
+
return failure
|
|
820
|
+
# Fresh clock, not the iteration-start sample: a slow
|
|
821
|
+
# herdr call must not push the timeout outcome past
|
|
822
|
+
# the deadline by its own duration. Silence is
|
|
823
|
+
# recomputed on the same clock so the terminal
|
|
824
|
+
# classification is taken at the decision instant.
|
|
825
|
+
now = time.monotonic()
|
|
826
|
+
silent = now - last_activity
|
|
827
|
+
if now >= deadline:
|
|
828
|
+
if silent >= stall_after:
|
|
829
|
+
facts = {"state": "blocked", "class": "stalled",
|
|
830
|
+
"evidence": {
|
|
831
|
+
"silent_s": round(silent, 1),
|
|
832
|
+
"timeout_s": timeout,
|
|
833
|
+
"transport": "herdr"}}
|
|
834
|
+
else:
|
|
835
|
+
facts = {"state": "timed-out", "class": None,
|
|
836
|
+
"evidence": {"timeout_s": timeout,
|
|
837
|
+
"transport": "herdr"}}
|
|
838
|
+
return {"kind": "timeout", "facts": facts}
|
|
839
|
+
time.sleep(min(pace, max(0.05, deadline - now)))
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _call_hosted(host, argv, prompt, timeout, private_home,
|
|
843
|
+
stall_after, block_grace):
|
|
844
|
+
"""One herdr-hosted seat call: the headless seat contract
|
|
845
|
+
(providers.command_agent) executed in a herdr pane.
|
|
846
|
+
|
|
847
|
+
Isolation mirrors the headless runner where the transport
|
|
848
|
+
allows it: a fresh scratch directory containing ONLY the
|
|
849
|
+
prompt file is the pane's cwd; stdout/stderr are captured
|
|
850
|
+
to files outside it and read back capped. Two honest
|
|
851
|
+
differences: there is no process-group kill or RLIMIT_FSIZE
|
|
852
|
+
to give (the pane is herdr's process, not ours — the seat
|
|
853
|
+
is bounded by closing its workspace and by the same
|
|
854
|
+
timeout), and the pane session carries the user's own
|
|
855
|
+
shell environment (herdrhost.py's U7 note: we add only
|
|
856
|
+
CODEREVIEW_PROMPT, plus HOME for a home=private seat;
|
|
857
|
+
operator keys are never added). Response and failure
|
|
858
|
+
semantics — messages, seat_facts, classifications — are
|
|
859
|
+
the headless ones."""
|
|
860
|
+
log = seatstate.current_log()
|
|
861
|
+
cli_key = seatstate.cli_key_for_argv(argv)
|
|
862
|
+
receipt = {"transport": "herdr",
|
|
863
|
+
"herdr_version": host.version_text}
|
|
864
|
+
box = herdrhost.RECEIPTS.get()
|
|
865
|
+
workdir = tempfile.mkdtemp(prefix="codereview-agent-")
|
|
866
|
+
cap_fds = []
|
|
867
|
+
workspace_id = None
|
|
868
|
+
try:
|
|
869
|
+
prompt_path = os.path.join(workdir, "prompt.md")
|
|
870
|
+
with open(prompt_path, "w", encoding="utf-8") as fh:
|
|
871
|
+
fh.write(prompt)
|
|
872
|
+
out_fd, out_path = tempfile.mkstemp(
|
|
873
|
+
prefix="codereview-agent-out-")
|
|
874
|
+
err_fd, err_path = tempfile.mkstemp(
|
|
875
|
+
prefix="codereview-agent-err-")
|
|
876
|
+
os.close(out_fd)
|
|
877
|
+
os.close(err_fd)
|
|
878
|
+
rc_path = out_path + ".rc"
|
|
879
|
+
cap_fds = [out_path, err_path, rc_path]
|
|
880
|
+
pane_env = {"CODEREVIEW_PROMPT": prompt_path}
|
|
881
|
+
if private_home:
|
|
882
|
+
pane_env["HOME"] = workdir
|
|
883
|
+
try:
|
|
884
|
+
workspace_id, pane_id = host.create_workspace(
|
|
885
|
+
cwd=workdir, label="codereview-seat",
|
|
886
|
+
env=pane_env)
|
|
887
|
+
except herdrhost.HerdrError as exc:
|
|
888
|
+
err = ProviderError(
|
|
889
|
+
f"herdr hosting failed (workspace-create): "
|
|
890
|
+
f"{exc}", retryable=False)
|
|
891
|
+
err.seat_facts = {
|
|
892
|
+
"state": "failed", "class": "error",
|
|
893
|
+
"evidence": {"stage": "workspace-create",
|
|
894
|
+
"transport": "herdr",
|
|
895
|
+
"detail": str(exc)[:300]}}
|
|
896
|
+
raise err
|
|
897
|
+
receipt["workspace_id"] = workspace_id
|
|
898
|
+
receipt["pane_id"] = pane_id
|
|
899
|
+
# The launcher line: the seat's own argv, stdin FROM
|
|
900
|
+
# the prompt file (never typed — the packet travels by
|
|
901
|
+
# file), output to the capture files, and the exit code
|
|
902
|
+
# to its own file, which is what lets supervision read
|
|
903
|
+
# process truth instead of scraping the screen. POSIX
|
|
904
|
+
# redirection + $? — the pane runs the user's shell.
|
|
905
|
+
line = (shlex.join(argv)
|
|
906
|
+
+ f" < {shlex.quote(prompt_path)}"
|
|
907
|
+
+ f" > {shlex.quote(out_path)}"
|
|
908
|
+
+ f" 2> {shlex.quote(err_path)}"
|
|
909
|
+
+ f"; echo $? > {shlex.quote(rc_path)}")
|
|
910
|
+
try:
|
|
911
|
+
host.pane_run(pane_id, line)
|
|
912
|
+
except herdrhost.HerdrError as exc:
|
|
913
|
+
err = ProviderError(
|
|
914
|
+
f"herdr hosting failed (pane-run): {exc}",
|
|
915
|
+
retryable=False)
|
|
916
|
+
err.seat_facts = {
|
|
917
|
+
"state": "failed", "class": "error",
|
|
918
|
+
"evidence": {"stage": "pane-run",
|
|
919
|
+
"transport": "herdr",
|
|
920
|
+
"detail": str(exc)[:300]}}
|
|
921
|
+
raise err
|
|
922
|
+
outcome = _run_hosted_seat(
|
|
923
|
+
host, receipt, pane_id, out_path, err_path, rc_path,
|
|
924
|
+
cli_key, log, timeout, stall_after, block_grace)
|
|
925
|
+
if outcome["kind"] in ("timeout", "blocked"):
|
|
926
|
+
# Early termination / deadline: closing the
|
|
927
|
+
# workspace is the pane-side tree kill.
|
|
928
|
+
try:
|
|
929
|
+
host.close_workspace(workspace_id)
|
|
930
|
+
except herdrhost.HerdrError:
|
|
931
|
+
pass
|
|
932
|
+
raise _hosted_outcome_error(
|
|
933
|
+
outcome, timeout, block_grace)
|
|
934
|
+
if outcome["kind"] == "herdr-failure":
|
|
935
|
+
raise _hosted_outcome_error(
|
|
936
|
+
outcome, timeout, block_grace)
|
|
937
|
+
returncode = outcome["returncode"]
|
|
938
|
+
if returncode != 0:
|
|
939
|
+
state, cls, evidence = seatstate.classify_exit(
|
|
940
|
+
cli_key, returncode,
|
|
941
|
+
_read_tail(out_path, 8192),
|
|
942
|
+
_read_tail(err_path, 8192))
|
|
943
|
+
tail = _redact_tail(
|
|
944
|
+
_read_tail(err_path, 4096).strip())[-300:]
|
|
945
|
+
err = ProviderError(
|
|
946
|
+
f"agent command exited {returncode}"
|
|
947
|
+
+ (f": {tail}" if tail else ""), retryable=False)
|
|
948
|
+
err.seat_facts = {"state": state, "class": cls,
|
|
949
|
+
"evidence": evidence}
|
|
950
|
+
raise err
|
|
951
|
+
text = _read_tail(out_path, MAX_AGENT_OUTPUT).strip()
|
|
952
|
+
if not text:
|
|
953
|
+
err = ProviderError("agent command produced no output",
|
|
954
|
+
retryable=False)
|
|
955
|
+
err.seat_facts = {
|
|
956
|
+
"state": "failed", "class": "error",
|
|
957
|
+
"evidence": {"exit_code": 0,
|
|
958
|
+
"reason": "no output"}}
|
|
959
|
+
raise err
|
|
960
|
+
return text
|
|
961
|
+
finally:
|
|
962
|
+
if workspace_id is not None:
|
|
963
|
+
try:
|
|
964
|
+
host.close_workspace(workspace_id)
|
|
965
|
+
except Exception: # noqa: BLE001 — cleanup is
|
|
966
|
+
pass # best-effort; the outcome stands
|
|
967
|
+
if box is not None:
|
|
968
|
+
box.append(receipt)
|
|
969
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
970
|
+
for cap in cap_fds:
|
|
971
|
+
try:
|
|
972
|
+
os.unlink(cap)
|
|
973
|
+
except OSError:
|
|
974
|
+
pass
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def command_agent(command, timeout=AGENT_TIMEOUT, private_home=False,
|
|
978
|
+
stall_after=seatstate.STALL_AFTER_S,
|
|
979
|
+
block_grace=seatstate.BLOCK_GRACE_S,
|
|
980
|
+
host=None):
|
|
981
|
+
"""Callable backed by a local agent command.
|
|
982
|
+
|
|
983
|
+
`timeout` is the per-call ceiling in seconds: None means the
|
|
984
|
+
AGENT_TIMEOUT default; an explicit value is honored verbatim,
|
|
985
|
+
and a non-positive value is refused HERE, at construction —
|
|
986
|
+
never silently swapped for the default (review-v13 finding:
|
|
987
|
+
the library seam used `agent_timeout or AGENT_TIMEOUT`, so a
|
|
988
|
+
caller's explicit 0 became 900 without a word).
|
|
989
|
+
|
|
990
|
+
Isolation contract (v0.7.0 external review, findings 1/3/5/6;
|
|
991
|
+
v0.7.1 review, findings 3.2/3.3/3.5): the agent runs in a fresh
|
|
992
|
+
scratch directory containing ONLY the prompt file (its cwd —
|
|
993
|
+
never the repo), with an allow-listed environment (no provider
|
|
994
|
+
keys unless the operator opts names in via CODEREVIEW_AGENT_ENV),
|
|
995
|
+
in its own process group — killed whole on timeout and after a
|
|
996
|
+
normal exit alike, so background children never leak — with a
|
|
997
|
+
per-file write cap (RLIMIT_FSIZE) on POSIX, and its output
|
|
998
|
+
captured to files outside the cwd with the read side capped
|
|
999
|
+
(the verdict tail is kept). With private_home=True the seat's
|
|
1000
|
+
HOME is the scratch directory instead of the operator's (for
|
|
1001
|
+
agents that need no login state in HOME). The command line is
|
|
1002
|
+
split with shlex and executed WITHOUT a shell: metacharacters
|
|
1003
|
+
are literal; pipes/redirection belong in a wrapper script.
|
|
1004
|
+
Every failure is a ProviderError with retryable=False — the
|
|
1005
|
+
seat degrades honestly in the run report, like any provider
|
|
1006
|
+
failure, and never triggers account rotation.
|
|
1007
|
+
|
|
1008
|
+
Seat-state supervision (Epic K2): the wait is supervised —
|
|
1009
|
+
output activity on the capture files is polled, live stall
|
|
1010
|
+
episodes are emitted to the job's seat-state log when one is
|
|
1011
|
+
installed (seatstate.CURRENT), a verified per-CLI failure
|
|
1012
|
+
pattern matched on stderr followed by `block_grace` seconds
|
|
1013
|
+
of total silence terminates the seat early as blocked, and
|
|
1014
|
+
every ProviderError raised here carries `seat_facts` (the
|
|
1015
|
+
terminal state/class/evidence the job owner records). The
|
|
1016
|
+
error MESSAGES are unchanged from the unsupervised wait.
|
|
1017
|
+
|
|
1018
|
+
Herdr hosting (Epic K3a): with `host` (a herdrhost.HerdrHost)
|
|
1019
|
+
the same contract executes inside a herdr pane instead of a
|
|
1020
|
+
subprocess — see _call_hosted. The default None is the
|
|
1021
|
+
headless path, byte-identical to before hosting existed."""
|
|
1022
|
+
if timeout is None:
|
|
1023
|
+
timeout = AGENT_TIMEOUT
|
|
1024
|
+
if timeout <= 0:
|
|
1025
|
+
raise ProviderError(
|
|
1026
|
+
f"agent timeout must be a positive number of seconds, "
|
|
1027
|
+
f"got {timeout!r}", retryable=False)
|
|
1028
|
+
if stall_after <= 0 or block_grace <= 0:
|
|
1029
|
+
raise ProviderError(
|
|
1030
|
+
f"seat supervision bounds must be positive seconds, "
|
|
1031
|
+
f"got stall_after={stall_after!r} "
|
|
1032
|
+
f"block_grace={block_grace!r}", retryable=False)
|
|
1033
|
+
|
|
1034
|
+
def call(prompt):
|
|
1035
|
+
try:
|
|
1036
|
+
argv = shlex.split(command)
|
|
1037
|
+
except ValueError as exc:
|
|
1038
|
+
raise ProviderError(f"bad agent command {command!r}: {exc}",
|
|
1039
|
+
retryable=False)
|
|
1040
|
+
if not argv:
|
|
1041
|
+
raise ProviderError("empty agent command", retryable=False)
|
|
1042
|
+
if host is not None:
|
|
1043
|
+
# Epic K3a: this seat is hosted in a herdr pane.
|
|
1044
|
+
# The contract, messages, and classifications are
|
|
1045
|
+
# the headless ones; only the transport differs.
|
|
1046
|
+
return _call_hosted(host, argv, prompt, timeout,
|
|
1047
|
+
private_home, stall_after,
|
|
1048
|
+
block_grace)
|
|
1049
|
+
log = seatstate.current_log()
|
|
1050
|
+
cli_key = seatstate.cli_key_for_argv(argv)
|
|
1051
|
+
workdir = tempfile.mkdtemp(prefix="codereview-agent-")
|
|
1052
|
+
cap_fds = []
|
|
1053
|
+
try:
|
|
1054
|
+
prompt_path = os.path.join(workdir, "prompt.md")
|
|
1055
|
+
with open(prompt_path, "w", encoding="utf-8") as fh:
|
|
1056
|
+
fh.write(prompt)
|
|
1057
|
+
# Capture files live OUTSIDE the scratch cwd: the agent
|
|
1058
|
+
# sees a directory containing only its prompt.
|
|
1059
|
+
out_fd, out_path = tempfile.mkstemp(
|
|
1060
|
+
prefix="codereview-agent-out-")
|
|
1061
|
+
err_fd, err_path = tempfile.mkstemp(
|
|
1062
|
+
prefix="codereview-agent-err-")
|
|
1063
|
+
cap_fds = [out_path, err_path]
|
|
1064
|
+
env = _agent_env(prompt_path)
|
|
1065
|
+
if private_home:
|
|
1066
|
+
env["HOME"] = workdir
|
|
1067
|
+
proc = None
|
|
1068
|
+
pgid = None
|
|
1069
|
+
try:
|
|
1070
|
+
with os.fdopen(out_fd, "wb") as out_f, \
|
|
1071
|
+
os.fdopen(err_fd, "wb") as err_f:
|
|
1072
|
+
proc = subprocess.Popen(
|
|
1073
|
+
argv, shell=False, stdin=subprocess.PIPE,
|
|
1074
|
+
stdout=out_f, stderr=err_f,
|
|
1075
|
+
env=env, cwd=workdir,
|
|
1076
|
+
start_new_session=True,
|
|
1077
|
+
preexec_fn=_limit_files
|
|
1078
|
+
if os.name == "posix" else None)
|
|
1079
|
+
if os.name == "posix":
|
|
1080
|
+
pgid = os.getpgid(proc.pid)
|
|
1081
|
+
outcome = _run_seat_process(
|
|
1082
|
+
proc, pgid, prompt, timeout, out_path,
|
|
1083
|
+
err_path, cli_key, log,
|
|
1084
|
+
stall_after=stall_after,
|
|
1085
|
+
block_grace=block_grace)
|
|
1086
|
+
except OSError as exc:
|
|
1087
|
+
err = ProviderError(
|
|
1088
|
+
f"agent command failed to start: "
|
|
1089
|
+
f"{exc.strerror or exc}", retryable=False)
|
|
1090
|
+
err.seat_facts = {
|
|
1091
|
+
"state": "failed", "class": "error",
|
|
1092
|
+
"evidence": {"stage": "start"}}
|
|
1093
|
+
raise err
|
|
1094
|
+
finally:
|
|
1095
|
+
if proc is not None:
|
|
1096
|
+
# Normal exit included: an agent's background
|
|
1097
|
+
# children die with their seat (finding 3.2).
|
|
1098
|
+
_kill_tree(proc, pgid)
|
|
1099
|
+
if outcome["kind"] == "timeout":
|
|
1100
|
+
err = ProviderError(
|
|
1101
|
+
f"agent command timed out after {timeout}s",
|
|
1102
|
+
retryable=False)
|
|
1103
|
+
err.seat_facts = outcome["facts"]
|
|
1104
|
+
raise err
|
|
1105
|
+
if outcome["kind"] == "blocked":
|
|
1106
|
+
facts = outcome["facts"]
|
|
1107
|
+
err = ProviderError(
|
|
1108
|
+
f"agent command blocked ({facts['class']}): "
|
|
1109
|
+
f"matched {facts['evidence']['pattern']} on "
|
|
1110
|
+
f"stderr; no output for {block_grace:g}s — "
|
|
1111
|
+
f"terminated early", retryable=False)
|
|
1112
|
+
err.seat_facts = facts
|
|
1113
|
+
raise err
|
|
1114
|
+
if proc.returncode != 0:
|
|
1115
|
+
if os.name == "posix" and proc.returncode \
|
|
1116
|
+
== -signal.SIGXFSZ:
|
|
1117
|
+
err = ProviderError(
|
|
1118
|
+
"agent output exceeded the per-file size "
|
|
1119
|
+
f"limit ({MAX_AGENT_FILE} bytes)",
|
|
1120
|
+
retryable=False)
|
|
1121
|
+
err.seat_facts = {
|
|
1122
|
+
"state": "failed", "class": "error",
|
|
1123
|
+
"evidence": {
|
|
1124
|
+
"exit_code": proc.returncode,
|
|
1125
|
+
"signal": "SIGXFSZ"}}
|
|
1126
|
+
raise err
|
|
1127
|
+
state, cls, evidence = seatstate.classify_exit(
|
|
1128
|
+
cli_key, proc.returncode,
|
|
1129
|
+
_read_tail(out_path, 8192),
|
|
1130
|
+
_read_tail(err_path, 8192))
|
|
1131
|
+
tail = _redact_tail(
|
|
1132
|
+
_read_tail(err_path, 4096).strip())[-300:]
|
|
1133
|
+
err = ProviderError(
|
|
1134
|
+
f"agent command exited {proc.returncode}"
|
|
1135
|
+
+ (f": {tail}" if tail else ""), retryable=False)
|
|
1136
|
+
err.seat_facts = {"state": state, "class": cls,
|
|
1137
|
+
"evidence": evidence}
|
|
1138
|
+
raise err
|
|
1139
|
+
text = _read_tail(out_path, MAX_AGENT_OUTPUT).strip()
|
|
1140
|
+
if not text:
|
|
1141
|
+
err = ProviderError("agent command produced no output",
|
|
1142
|
+
retryable=False)
|
|
1143
|
+
err.seat_facts = {
|
|
1144
|
+
"state": "failed", "class": "error",
|
|
1145
|
+
"evidence": {"exit_code": 0,
|
|
1146
|
+
"reason": "no output"}}
|
|
1147
|
+
raise err
|
|
1148
|
+
return text
|
|
1149
|
+
finally:
|
|
1150
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
1151
|
+
for cap in cap_fds:
|
|
1152
|
+
try:
|
|
1153
|
+
os.unlink(cap)
|
|
1154
|
+
except OSError:
|
|
1155
|
+
pass
|
|
1156
|
+
return call
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
try:
|
|
1160
|
+
from . import registry
|
|
1161
|
+
except ImportError: # standalone module load
|
|
1162
|
+
import registry # type: ignore
|
|
1163
|
+
|
|
1164
|
+
# Legacy tuple view of the registry (kind, base_url, model,
|
|
1165
|
+
# key_env, extra_payload) — registry.PRESETS is the source of
|
|
1166
|
+
# truth; this shape is kept for existing consumers.
|
|
1167
|
+
PRESETS = {
|
|
1168
|
+
name: (p["kind"], p["base_url"], p["model"], p["key_env"],
|
|
1169
|
+
p["extra"])
|
|
1170
|
+
for name, p in registry.PRESETS.items()
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def parse_local_spec(spec):
|
|
1175
|
+
"""local:<name>:<base-url>|<model> -> (name, base_url, model).
|
|
1176
|
+
|
|
1177
|
+
The named local-endpoint form (Epic L5). The base URL keeps
|
|
1178
|
+
its own colons (scheme, port) — only the FIRST colon after
|
|
1179
|
+
the name separates, and the model rides after the '|' the
|
|
1180
|
+
other spec forms already use. Name and model are required:
|
|
1181
|
+
a local seat is an ordinary seat, and a seat without a model
|
|
1182
|
+
cannot be called. Raises ProviderError on a malformed spec."""
|
|
1183
|
+
rest = spec.strip()[len(registry.LOCAL_SPEC_PREFIX):]
|
|
1184
|
+
name, colon, tail = rest.partition(":")
|
|
1185
|
+
base, pipe, model = tail.partition("|")
|
|
1186
|
+
name, base, model = name.strip(), base.strip(), model.strip()
|
|
1187
|
+
if not colon or not name or not base or not pipe or not model:
|
|
1188
|
+
raise ProviderError(
|
|
1189
|
+
f"bad local provider spec {spec!r}: want "
|
|
1190
|
+
"local:<name>:<base-url>|<model> (e.g. "
|
|
1191
|
+
"local:box:http://localhost:8080/v1|my-model)")
|
|
1192
|
+
if not (base.startswith("http://")
|
|
1193
|
+
or base.startswith("https://")):
|
|
1194
|
+
raise ProviderError(
|
|
1195
|
+
f"bad local provider spec {spec!r}: base-url must "
|
|
1196
|
+
f"start with http:// or https://, got {base!r}")
|
|
1197
|
+
return name, base, model
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _agent_vendor_family(name, command):
|
|
1201
|
+
"""Vendor family for an agent seat (P6-06): the CLI the
|
|
1202
|
+
seat RUNS decides — the command's program, matched
|
|
1203
|
+
against discovery's known-CLI table by binary name.
|
|
1204
|
+
Before this, every agent seat was family 'agent', so a
|
|
1205
|
+
Claude+Codex panel computed as ONE family and could
|
|
1206
|
+
never satisfy two-family release eligibility without a
|
|
1207
|
+
hand-written --seat-meta.
|
|
1208
|
+
|
|
1209
|
+
The seat NAME never confers a family (review-v11,
|
|
1210
|
+
pinned by test_v090_fixes): an agent seat named
|
|
1211
|
+
'gemini' running `echo hi` is family 'agent', not
|
|
1212
|
+
'google' — the name is operator-chosen text, the
|
|
1213
|
+
command is the fact. A seat whose command is a wrapper
|
|
1214
|
+
script around a known CLI therefore keeps 'agent' too,
|
|
1215
|
+
unless the operator says otherwise via --seat-meta or
|
|
1216
|
+
the saved setup's [seat_meta]. A CLI the table does
|
|
1217
|
+
not know keeps the generic 'agent' family: recorded,
|
|
1218
|
+
never guessed."""
|
|
1219
|
+
if _discovery is not None and command:
|
|
1220
|
+
try:
|
|
1221
|
+
tokens = shlex.split(command)
|
|
1222
|
+
except ValueError:
|
|
1223
|
+
tokens = command.split()
|
|
1224
|
+
if tokens:
|
|
1225
|
+
program = os.path.basename(
|
|
1226
|
+
tokens[0]).strip("\"'").lower()
|
|
1227
|
+
for entry in _discovery.KNOWN_CLIS:
|
|
1228
|
+
if program in entry["binaries"]:
|
|
1229
|
+
return entry["family"]
|
|
1230
|
+
return "agent"
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def describe_spec(spec):
|
|
1234
|
+
"""spec -> {seat, kind, preset, model, key_env, family,
|
|
1235
|
+
base_url} WITHOUT building or calling anything. The CLI uses
|
|
1236
|
+
this to record which provider/model served which role, and to
|
|
1237
|
+
tag seats with their vendor family for the independence
|
|
1238
|
+
checks. Raises ProviderError on an unparseable spec."""
|
|
1239
|
+
if spec.startswith("agent:"):
|
|
1240
|
+
name, _options, _cmd = _parse_agent_spec(spec)
|
|
1241
|
+
return {"seat": name, "kind": "agent", "preset": None,
|
|
1242
|
+
"model": None, "key_env": None,
|
|
1243
|
+
"family": _agent_vendor_family(name, _cmd),
|
|
1244
|
+
"base_url": None}
|
|
1245
|
+
stripped = spec.strip()
|
|
1246
|
+
if stripped.lower().startswith(registry.LOCAL_SPEC_PREFIX):
|
|
1247
|
+
name, base, model = parse_local_spec(stripped)
|
|
1248
|
+
return {"seat": name, "kind": "openai-compatible",
|
|
1249
|
+
"preset": None, "model": model, "key_env": None,
|
|
1250
|
+
"family": registry.LOCAL_FAMILY, "base_url": base}
|
|
1251
|
+
if stripped.lower().startswith("openai-compatible:"):
|
|
1252
|
+
rest = stripped[len("openai-compatible:"):]
|
|
1253
|
+
parts = [p.strip() for p in rest.split("|")]
|
|
1254
|
+
if len(parts) != 3 or not all(parts):
|
|
1255
|
+
raise ProviderError(
|
|
1256
|
+
f"bad provider spec {spec!r}: want "
|
|
1257
|
+
"openai-compatible:<base>|<model>|<KEY_ENV>")
|
|
1258
|
+
base, model, key_env = parts
|
|
1259
|
+
return {"seat": "openai-compatible",
|
|
1260
|
+
"kind": "openai-compatible", "preset": None,
|
|
1261
|
+
"model": model, "key_env": key_env,
|
|
1262
|
+
"family": "unknown", "base_url": base}
|
|
1263
|
+
if "|" in spec:
|
|
1264
|
+
parts = [p.strip() for p in spec.split("|")]
|
|
1265
|
+
if len(parts) != 5:
|
|
1266
|
+
raise ProviderError(
|
|
1267
|
+
f"bad provider spec {spec!r}: want "
|
|
1268
|
+
"name|type|base_url|model|key_env")
|
|
1269
|
+
name, kind, base, model, key_env = parts
|
|
1270
|
+
return {"seat": name, "kind": kind, "preset": None,
|
|
1271
|
+
"model": model, "key_env": key_env,
|
|
1272
|
+
"family": "unknown", "base_url": base}
|
|
1273
|
+
name, colon, model_override = stripped.partition(":")
|
|
1274
|
+
name = name.strip().lower()
|
|
1275
|
+
if name not in registry.PRESETS:
|
|
1276
|
+
raise ProviderError(
|
|
1277
|
+
f"unknown provider {spec!r}; presets: "
|
|
1278
|
+
f"{', '.join(sorted(registry.PRESETS))}, or use the "
|
|
1279
|
+
"custom name|type|base_url|model|key_env form")
|
|
1280
|
+
preset = registry.PRESETS[name]
|
|
1281
|
+
model = (model_override.strip()
|
|
1282
|
+
if colon and model_override.strip()
|
|
1283
|
+
else preset["model"])
|
|
1284
|
+
return {"seat": name, "kind": preset["kind"], "preset": name,
|
|
1285
|
+
"model": model, "key_env": preset["key_env"],
|
|
1286
|
+
"family": preset["family"],
|
|
1287
|
+
"base_url": preset["base_url"]}
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _parse_agent_spec(spec):
|
|
1291
|
+
"""agent:<name>[<options>]:<command> -> (name, options, command).
|
|
1292
|
+
|
|
1293
|
+
The only option today is home=private (the seat's HOME is its
|
|
1294
|
+
scratch directory instead of the operator's). Unknown options
|
|
1295
|
+
are rejected loudly rather than ignored."""
|
|
1296
|
+
parts = spec.split(":", 2)
|
|
1297
|
+
if len(parts) != 3 or not parts[2].strip():
|
|
1298
|
+
raise ProviderError(
|
|
1299
|
+
f"bad agent spec {spec!r}: want agent:<name>:<command>")
|
|
1300
|
+
raw_name, command = parts[1].strip(), parts[2].strip()
|
|
1301
|
+
options = {}
|
|
1302
|
+
if raw_name.endswith("]") and "[" in raw_name:
|
|
1303
|
+
raw_name, _, opt_str = raw_name.partition("[")
|
|
1304
|
+
for opt in opt_str.rstrip("]").split(","):
|
|
1305
|
+
opt = opt.strip()
|
|
1306
|
+
if not opt:
|
|
1307
|
+
continue
|
|
1308
|
+
if opt == "home=private":
|
|
1309
|
+
options["home"] = "private"
|
|
1310
|
+
else:
|
|
1311
|
+
raise ProviderError(
|
|
1312
|
+
f"unknown agent seat option {opt!r} in {spec!r} "
|
|
1313
|
+
"(supported: home=private)")
|
|
1314
|
+
name = raw_name.strip()
|
|
1315
|
+
if not name:
|
|
1316
|
+
raise ProviderError(
|
|
1317
|
+
f"bad agent spec {spec!r}: empty seat name")
|
|
1318
|
+
return name, options, command
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def build_provider(spec, occurrence=1, text_mode=False,
|
|
1322
|
+
agent_timeout=None, host=None):
|
|
1323
|
+
"""spec -> (name, callable, account_label).
|
|
1324
|
+
|
|
1325
|
+
`occurrence` > 1 (the same seat requested again) selects the
|
|
1326
|
+
next account's key env for presets: GEMINI_API_KEY_2, _3, ...
|
|
1327
|
+
|
|
1328
|
+
`text_mode` builds a solver callable instead of a reviewer: a
|
|
1329
|
+
solver-persona system prompt and, for Gemini, no JSON response
|
|
1330
|
+
mode — the answer is a Markdown plan.
|
|
1331
|
+
|
|
1332
|
+
`agent_timeout` is the operator's per-seat timeout (the CLI's
|
|
1333
|
+
--timeout): it bounds agent seats (default AGENT_TIMEOUT) and,
|
|
1334
|
+
since the Phase 4 failure-injection fixes, API seats too —
|
|
1335
|
+
an API callable built with it passes it to _post_json as the
|
|
1336
|
+
call's overall deadline; without it, API seats keep
|
|
1337
|
+
DEFAULT_TIMEOUT (P4-02)."""
|
|
1338
|
+
system = _SOLVER_SYSTEM if text_mode else _SYSTEM
|
|
1339
|
+
if spec.startswith("agent:"):
|
|
1340
|
+
agent_name, options, command = _parse_agent_spec(spec)
|
|
1341
|
+
# text_mode changes nothing here: the seat receives the
|
|
1342
|
+
# full prompt (reviewer JSON instructions or the solver's
|
|
1343
|
+
# plan request) as its input document. Naming: the name
|
|
1344
|
+
# returned here is the BASE name — build_providers assigns
|
|
1345
|
+
# the final, globally-unique seat names, which only it can
|
|
1346
|
+
# see (v0.7.1 external review, finding 3.1).
|
|
1347
|
+
return agent_name, command_agent(
|
|
1348
|
+
command,
|
|
1349
|
+
timeout=(AGENT_TIMEOUT if agent_timeout is None
|
|
1350
|
+
else agent_timeout),
|
|
1351
|
+
private_home=options.get("home") == "private",
|
|
1352
|
+
host=host), \
|
|
1353
|
+
f"agent:{agent_name}"
|
|
1354
|
+
if spec.strip().lower().startswith("openai-compatible:"):
|
|
1355
|
+
desc = describe_spec(spec)
|
|
1356
|
+
return desc["seat"], openai_compatible(
|
|
1357
|
+
desc["base_url"], desc["model"], desc["key_env"],
|
|
1358
|
+
system=system, timeout=agent_timeout), desc["key_env"]
|
|
1359
|
+
if spec.strip().lower().startswith(registry.LOCAL_SPEC_PREFIX):
|
|
1360
|
+
# Epic L5 named local endpoint: keyless by construction
|
|
1361
|
+
# (key_env None — no credential read, no Authorization
|
|
1362
|
+
# header). The account label names the seat, never a
|
|
1363
|
+
# credential: there is none.
|
|
1364
|
+
desc = describe_spec(spec)
|
|
1365
|
+
return desc["seat"], openai_compatible(
|
|
1366
|
+
desc["base_url"], desc["model"], None,
|
|
1367
|
+
system=system, timeout=agent_timeout), \
|
|
1368
|
+
f"local:{desc['seat']}"
|
|
1369
|
+
if "|" in spec:
|
|
1370
|
+
parts = [p.strip() for p in spec.split("|")]
|
|
1371
|
+
if len(parts) != 5:
|
|
1372
|
+
raise ProviderError(
|
|
1373
|
+
f"bad provider spec {spec!r}: want "
|
|
1374
|
+
"name|type|base_url|model|key_env")
|
|
1375
|
+
name, kind, base, model, key_env = parts
|
|
1376
|
+
if kind == "gemini":
|
|
1377
|
+
return name, gemini(model, key_env, system=system,
|
|
1378
|
+
json_mode=not text_mode,
|
|
1379
|
+
timeout=agent_timeout), key_env
|
|
1380
|
+
if kind == "anthropic":
|
|
1381
|
+
return name, anthropic(model, key_env,
|
|
1382
|
+
system=system,
|
|
1383
|
+
timeout=agent_timeout), key_env
|
|
1384
|
+
if kind == "openai-compatible":
|
|
1385
|
+
return name, openai_compatible(
|
|
1386
|
+
base, model, key_env, system=system,
|
|
1387
|
+
timeout=agent_timeout), key_env
|
|
1388
|
+
raise ProviderError(f"unknown provider type {kind!r}")
|
|
1389
|
+
desc = describe_spec(spec)
|
|
1390
|
+
name = desc["preset"]
|
|
1391
|
+
kind, base, model, key_env, extra = PRESETS[name]
|
|
1392
|
+
model = desc["model"]
|
|
1393
|
+
if occurrence > 1 and key_env is not None:
|
|
1394
|
+
# Account rotation renames the KEY env; a keyless local
|
|
1395
|
+
# preset (Epic L5) has no accounts to rotate — every
|
|
1396
|
+
# occurrence is the same local server.
|
|
1397
|
+
key_env = f"{key_env}_{occurrence}"
|
|
1398
|
+
if kind == "gemini":
|
|
1399
|
+
return name, gemini(model, key_env, system=system,
|
|
1400
|
+
json_mode=not text_mode,
|
|
1401
|
+
timeout=agent_timeout), key_env
|
|
1402
|
+
if kind == "anthropic":
|
|
1403
|
+
return name, anthropic(model, key_env,
|
|
1404
|
+
system=system,
|
|
1405
|
+
timeout=agent_timeout), key_env
|
|
1406
|
+
# The account label is the key env for keyed presets; a
|
|
1407
|
+
# keyless local preset (Epic L5) labels itself local:<preset>
|
|
1408
|
+
# — records show what served, and there is no credential
|
|
1409
|
+
# the label could leak.
|
|
1410
|
+
label = key_env if key_env is not None else f"local:{name}"
|
|
1411
|
+
return name, openai_compatible(
|
|
1412
|
+
base, model, key_env, extra, system=system,
|
|
1413
|
+
omit_temperature=registry.omit_temperature(name, model),
|
|
1414
|
+
completion_tokens=registry.completion_tokens(name, model),
|
|
1415
|
+
timeout=agent_timeout), \
|
|
1416
|
+
label
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
_RETRYABLE_MARKERS = ("429", "500", "502", "503", "504", "401", "403",
|
|
1420
|
+
"quota", "rate limit", "network", "timed out",
|
|
1421
|
+
"timeout")
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def is_retryable(exc):
|
|
1425
|
+
"""Quota / availability / dead-key errors are worth rotating to
|
|
1426
|
+
the next account for; a malformed request is not. A typed
|
|
1427
|
+
`retryable` flag on the error wins over the message heuristics
|
|
1428
|
+
(an agent command's stderr mentioning "timeout" must not trigger
|
|
1429
|
+
rotation — see the v0.7.0 external review, finding 6.3)."""
|
|
1430
|
+
flag = getattr(exc, "retryable", None)
|
|
1431
|
+
if flag is not None:
|
|
1432
|
+
return flag
|
|
1433
|
+
msg = str(exc).lower()
|
|
1434
|
+
return any(m in msg for m in _RETRYABLE_MARKERS)
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
class AccountPool:
|
|
1438
|
+
"""One reviewer seat backed by several accounts, tried in order.
|
|
1439
|
+
|
|
1440
|
+
On a retryable error (quota, rate limit, 5xx, dead key) the next
|
|
1441
|
+
account takes the seat. Every rotation is recorded on
|
|
1442
|
+
`rotations` and the serving account on `last_account`, so run
|
|
1443
|
+
reports show exactly which account reviewed and when a switch
|
|
1444
|
+
happened — never a silent substitution. Account labels are key
|
|
1445
|
+
env names, never key values."""
|
|
1446
|
+
|
|
1447
|
+
def __init__(self, accounts):
|
|
1448
|
+
# accounts: [(label, callable)]
|
|
1449
|
+
self.accounts = list(accounts)
|
|
1450
|
+
self.rotations = []
|
|
1451
|
+
self.last_account = None
|
|
1452
|
+
import threading
|
|
1453
|
+
self._lock = threading.Lock()
|
|
1454
|
+
|
|
1455
|
+
def __call__(self, prompt):
|
|
1456
|
+
last_exc = None
|
|
1457
|
+
for i, (label, fn) in enumerate(self.accounts):
|
|
1458
|
+
try:
|
|
1459
|
+
text = fn(prompt)
|
|
1460
|
+
except Exception as exc: # noqa: BLE001
|
|
1461
|
+
last_exc = exc
|
|
1462
|
+
if not is_retryable(exc):
|
|
1463
|
+
raise
|
|
1464
|
+
if i + 1 < len(self.accounts):
|
|
1465
|
+
with self._lock:
|
|
1466
|
+
self.rotations.append({
|
|
1467
|
+
"from": label,
|
|
1468
|
+
"to": self.accounts[i + 1][0],
|
|
1469
|
+
"error": str(exc)[:200]})
|
|
1470
|
+
continue
|
|
1471
|
+
with self._lock:
|
|
1472
|
+
self.last_account = label
|
|
1473
|
+
return text
|
|
1474
|
+
raise last_exc
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def _bump_name(name):
|
|
1478
|
+
"""bot -> bot-2 -> bot-3: increment a trailing number if the
|
|
1479
|
+
name has one, else start the suffix sequence."""
|
|
1480
|
+
m = re.match(r"^(.*-)(\d+)$", name)
|
|
1481
|
+
if m:
|
|
1482
|
+
return f"{m.group(1)}{int(m.group(2)) + 1}"
|
|
1483
|
+
return name + "-2"
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def build_providers(specs, text_mode=False, agent_timeout=None,
|
|
1487
|
+
herdr_host=None, herdr_seats=None):
|
|
1488
|
+
"""specs -> {seat name: callable}. Repeating a seat (the same
|
|
1489
|
+
preset twice, or custom specs sharing a name) builds an
|
|
1490
|
+
AccountPool for that seat with one account per occurrence.
|
|
1491
|
+
Agent seats are the exception: each occurrence runs a different
|
|
1492
|
+
command, so occurrences become distinct seats (name, name-2,
|
|
1493
|
+
...) instead of accounts of one seat.
|
|
1494
|
+
|
|
1495
|
+
Herdr hosting (Epic K3a): `herdr_host` (a detected
|
|
1496
|
+
herdrhost.HerdrHost) plus `herdr_seats` (the seat names the
|
|
1497
|
+
saved config marks for hosting) host those agent seats in
|
|
1498
|
+
herdr panes; the match is on the seat's FINAL name (the one
|
|
1499
|
+
build_providers assigns), case-insensitively. Both default
|
|
1500
|
+
to None — every existing caller builds headless seats,
|
|
1501
|
+
byte-identical to before hosting existed.
|
|
1502
|
+
|
|
1503
|
+
Seat names live in one namespace per KIND: an agent seat and an
|
|
1504
|
+
API seat sharing a name (agent:gemini next to the gemini preset)
|
|
1505
|
+
is a configuration error, raised loudly in either spec order —
|
|
1506
|
+
silently merging them into one pool would drop a configured
|
|
1507
|
+
reviewer (v0.7.0 external review, finding 4).
|
|
1508
|
+
|
|
1509
|
+
Agent final names are assigned in a pre-pass over ALL specs, so
|
|
1510
|
+
a suffixed name can never collide with a name another seat owns
|
|
1511
|
+
— including a literal agent name like bot-2 meeting the second
|
|
1512
|
+
bot seat (v0.7.1 external review, finding 3.1): suffixes keep
|
|
1513
|
+
incrementing until the name is unique, and a candidate owned by
|
|
1514
|
+
an API seat raises the cross-kind error. Either way, no
|
|
1515
|
+
configured seat is ever silently dropped or pooled."""
|
|
1516
|
+
parsed = []
|
|
1517
|
+
api_names = set()
|
|
1518
|
+
for spec in specs:
|
|
1519
|
+
if spec.startswith("agent:"):
|
|
1520
|
+
base, _options, _cmd = _parse_agent_spec(spec)
|
|
1521
|
+
parsed.append(("agent", base, spec))
|
|
1522
|
+
elif spec.strip().lower().startswith(
|
|
1523
|
+
registry.LOCAL_SPEC_PREFIX):
|
|
1524
|
+
# Epic L5: the seat's base name is the name INSIDE
|
|
1525
|
+
# the spec (local:<name>:<base-url>|<model>), so two
|
|
1526
|
+
# distinct local endpoints are two seats — pooling
|
|
1527
|
+
# them under 'local' would merge different servers
|
|
1528
|
+
# into one seat — and the family-tag pass below finds
|
|
1529
|
+
# each seat by that name.
|
|
1530
|
+
nm, _base, _model = parse_local_spec(spec.strip())
|
|
1531
|
+
parsed.append(("api", nm, spec))
|
|
1532
|
+
api_names.add(nm.lower())
|
|
1533
|
+
elif spec.strip().lower().startswith("openai-compatible:"):
|
|
1534
|
+
parsed.append(("api", "openai-compatible", spec))
|
|
1535
|
+
api_names.add("openai-compatible")
|
|
1536
|
+
elif "|" in spec:
|
|
1537
|
+
nm = spec.split("|")[0].strip().lower()
|
|
1538
|
+
parsed.append(("api", nm, spec))
|
|
1539
|
+
api_names.add(nm)
|
|
1540
|
+
else:
|
|
1541
|
+
# A preset spec may carry a model override
|
|
1542
|
+
# (preset:model); the seat's base name is the preset.
|
|
1543
|
+
nm = spec.strip().partition(":")[0].strip().lower()
|
|
1544
|
+
parsed.append(("api", nm, spec))
|
|
1545
|
+
api_names.add(nm)
|
|
1546
|
+
agent_final = {}
|
|
1547
|
+
used = set()
|
|
1548
|
+
counters = {}
|
|
1549
|
+
for idx, (kind, base, _spec) in enumerate(parsed):
|
|
1550
|
+
if kind != "agent":
|
|
1551
|
+
continue
|
|
1552
|
+
n = counters.get(base.lower(), 0) + 1
|
|
1553
|
+
counters[base.lower()] = n
|
|
1554
|
+
candidate = base if n == 1 else f"{base}-{n}"
|
|
1555
|
+
while True:
|
|
1556
|
+
# Cross-kind and uniqueness checks compare on the
|
|
1557
|
+
# canonical (lowercased) name: seat identity is
|
|
1558
|
+
# case-insensitive, so an agent named 'Bot' must not
|
|
1559
|
+
# slip past an API seat named 'bot' (review-v11
|
|
1560
|
+
# finding — the custom-spec branch above once kept
|
|
1561
|
+
# its case while preset names were lowered).
|
|
1562
|
+
if candidate.lower() in api_names:
|
|
1563
|
+
raise ProviderError(
|
|
1564
|
+
f"seat name {candidate!r} is used by both an "
|
|
1565
|
+
f"agent seat and an API seat; rename the agent "
|
|
1566
|
+
f"seat (e.g. agent:{base}-cli:<command>)")
|
|
1567
|
+
if candidate.lower() not in used:
|
|
1568
|
+
break
|
|
1569
|
+
candidate = _bump_name(candidate)
|
|
1570
|
+
used.add(candidate.lower())
|
|
1571
|
+
agent_final[idx] = candidate
|
|
1572
|
+
seats = {}
|
|
1573
|
+
counts = {}
|
|
1574
|
+
name_kind = {}
|
|
1575
|
+
seat_models = {}
|
|
1576
|
+
agent_spec_by_name = {}
|
|
1577
|
+
for idx, (kind, base, spec) in enumerate(parsed):
|
|
1578
|
+
seat_key = (kind, base.lower())
|
|
1579
|
+
counts[seat_key] = counts.get(seat_key, 0) + 1
|
|
1580
|
+
seat_host = None
|
|
1581
|
+
if kind == "agent" and herdr_host is not None \
|
|
1582
|
+
and agent_final[idx].lower() in {
|
|
1583
|
+
str(s).lower() for s in (herdr_seats or ())}:
|
|
1584
|
+
seat_host = herdr_host
|
|
1585
|
+
name, fn, label = build_provider(
|
|
1586
|
+
spec, counts[seat_key], text_mode=text_mode,
|
|
1587
|
+
agent_timeout=agent_timeout, host=seat_host)
|
|
1588
|
+
if kind == "agent":
|
|
1589
|
+
name = agent_final[idx]
|
|
1590
|
+
seats[name] = [(f"agent:{name}", fn)]
|
|
1591
|
+
name_kind[name] = kind
|
|
1592
|
+
agent_spec_by_name[name] = spec
|
|
1593
|
+
continue
|
|
1594
|
+
owner = name_kind.setdefault(name, kind)
|
|
1595
|
+
if owner != kind:
|
|
1596
|
+
raise ProviderError(
|
|
1597
|
+
f"seat name {name!r} is used by both an agent seat "
|
|
1598
|
+
f"and an API seat; rename the agent seat "
|
|
1599
|
+
f"(e.g. agent:{base}-cli:<command>)")
|
|
1600
|
+
seats.setdefault(name, []).append((label, fn))
|
|
1601
|
+
# Remember which model each account of this seat serves,
|
|
1602
|
+
# so a later consumer (the CLI's role assignments) can
|
|
1603
|
+
# tell what an existing seat will ACTUALLY send — a pool
|
|
1604
|
+
# built from repeated specs serves its specs' models, not
|
|
1605
|
+
# whatever a later same-name spec requests (78ddf4d5ea27).
|
|
1606
|
+
try:
|
|
1607
|
+
seat_models.setdefault(name, []).append(
|
|
1608
|
+
describe_spec(spec)["model"])
|
|
1609
|
+
except ProviderError:
|
|
1610
|
+
pass
|
|
1611
|
+
providers = {}
|
|
1612
|
+
for name, accounts in seats.items():
|
|
1613
|
+
providers[name] = accounts[0][1] if len(accounts) == 1 \
|
|
1614
|
+
else AccountPool(accounts)
|
|
1615
|
+
# Tag the seat with its vendor family (registry truth, not
|
|
1616
|
+
# the display name): the CLI's seat metadata and the
|
|
1617
|
+
# independence checks read family from here, so a seat
|
|
1618
|
+
# named 'kimi' built from the moonshot preset is family
|
|
1619
|
+
# 'moonshot', and a custom-named DeepSeek seat is family
|
|
1620
|
+
# 'deepseek'. Unknown for fully custom specs, as before —
|
|
1621
|
+
# --seat-meta remains the operator's override.
|
|
1622
|
+
# Agent seats carry the vendor family of the CLI
|
|
1623
|
+
# they run (P6-06: claude -> anthropic, codex ->
|
|
1624
|
+
# openai, gemini -> google; an unknown CLI keeps the
|
|
1625
|
+
# generic 'agent'), read off the seat's own spec via
|
|
1626
|
+
# describe_spec — the same derivation the CLI's role
|
|
1627
|
+
# records use, so the two can never disagree.
|
|
1628
|
+
fam = None
|
|
1629
|
+
if name_kind.get(name) == "agent":
|
|
1630
|
+
try:
|
|
1631
|
+
fam = describe_spec(
|
|
1632
|
+
agent_spec_by_name[name])["family"]
|
|
1633
|
+
except ProviderError:
|
|
1634
|
+
fam = "agent"
|
|
1635
|
+
if fam is None:
|
|
1636
|
+
for (_k, base, spec) in parsed:
|
|
1637
|
+
if base.lower() == name.lower() and _k == "api":
|
|
1638
|
+
try:
|
|
1639
|
+
fam = describe_spec(spec)["family"]
|
|
1640
|
+
except ProviderError:
|
|
1641
|
+
fam = None
|
|
1642
|
+
break
|
|
1643
|
+
try:
|
|
1644
|
+
providers[name]._codereview_family = fam or "unknown"
|
|
1645
|
+
except Exception: # noqa: BLE001 — tagging is advisory
|
|
1646
|
+
pass
|
|
1647
|
+
# The distinct model(s) this seat's accounts serve
|
|
1648
|
+
# (78ddf4d5ea27): read by the CLI's role assignments so a
|
|
1649
|
+
# role record credits the model that serves, never a
|
|
1650
|
+
# discarded same-name override.
|
|
1651
|
+
try:
|
|
1652
|
+
providers[name]._codereview_models = tuple(sorted(
|
|
1653
|
+
{m for m in seat_models.get(name, []) if m}))
|
|
1654
|
+
except Exception: # noqa: BLE001 — tagging is advisory
|
|
1655
|
+
pass
|
|
1656
|
+
if name_kind.get(name) == "agent":
|
|
1657
|
+
# Tag the seat callable so consumers that only see the
|
|
1658
|
+
# providers dict (the CLI's default seat metadata) can
|
|
1659
|
+
# still tell an agent seat from a preset seat — even
|
|
1660
|
+
# when the agent's name matches a preset's, and after
|
|
1661
|
+
# suffixing (bot, bot-2, ...). Resolved seat names
|
|
1662
|
+
# carry no 'agent:' prefix, so the kind has to travel
|
|
1663
|
+
# with the callable (review-v11 finding).
|
|
1664
|
+
try:
|
|
1665
|
+
providers[name]._codereview_kind = "agent"
|
|
1666
|
+
except Exception: # noqa: BLE001 — tagging is advisory
|
|
1667
|
+
pass
|
|
1668
|
+
return providers
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def build_solvers(specs, agent_timeout=None):
|
|
1672
|
+
"""specs -> {seat name: solver callable} for intake mode: same
|
|
1673
|
+
seats and account pools as build_providers, but each callable
|
|
1674
|
+
answers in plain Markdown (see build_provider's text_mode).
|
|
1675
|
+
Agent solver seats default to a 300s timeout — the intake
|
|
1676
|
+
deadline — so a hung agent dies with its run instead of keeping
|
|
1677
|
+
the process alive past it (finding 5)."""
|
|
1678
|
+
if agent_timeout is None:
|
|
1679
|
+
agent_timeout = 300
|
|
1680
|
+
return build_providers(specs, text_mode=True,
|
|
1681
|
+
agent_timeout=agent_timeout)
|