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.
@@ -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)