@try-works/dsh-recursive-mode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
|
@@ -0,0 +1,2282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared helpers for recursive-router scripts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import queue
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import tomllib
|
|
18
|
+
import uuid
|
|
19
|
+
from copy import deepcopy
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CANONICAL_ROLES = [
|
|
26
|
+
"orchestrator",
|
|
27
|
+
"analyst",
|
|
28
|
+
"planner",
|
|
29
|
+
"implementer",
|
|
30
|
+
"code-reviewer",
|
|
31
|
+
"tester",
|
|
32
|
+
"memory-auditor",
|
|
33
|
+
]
|
|
34
|
+
LEGACY_ROLE_ALIASES = {
|
|
35
|
+
"phase-auditor": "analyst",
|
|
36
|
+
"traceability-auditor": "planner",
|
|
37
|
+
"bounded-implementer": "implementer",
|
|
38
|
+
"test-reviewer": "tester",
|
|
39
|
+
}
|
|
40
|
+
DEFAULT_ROLE_ROUTE_SPECS = {
|
|
41
|
+
"orchestrator": {
|
|
42
|
+
"enabled": True,
|
|
43
|
+
"mode": "local-only",
|
|
44
|
+
"cli": None,
|
|
45
|
+
"model": None,
|
|
46
|
+
"fallback": "local-controller",
|
|
47
|
+
},
|
|
48
|
+
"analyst": {
|
|
49
|
+
"enabled": True,
|
|
50
|
+
"mode": "external-cli",
|
|
51
|
+
"cli": None,
|
|
52
|
+
"model": None,
|
|
53
|
+
"fallback": "self-audit",
|
|
54
|
+
},
|
|
55
|
+
"planner": {
|
|
56
|
+
"enabled": True,
|
|
57
|
+
"mode": "external-cli",
|
|
58
|
+
"cli": None,
|
|
59
|
+
"model": None,
|
|
60
|
+
"fallback": "self-audit",
|
|
61
|
+
},
|
|
62
|
+
"implementer": {
|
|
63
|
+
"enabled": False,
|
|
64
|
+
"mode": "external-cli",
|
|
65
|
+
"cli": None,
|
|
66
|
+
"model": None,
|
|
67
|
+
"fallback": "local-controller",
|
|
68
|
+
},
|
|
69
|
+
"code-reviewer": {
|
|
70
|
+
"enabled": True,
|
|
71
|
+
"mode": "external-cli",
|
|
72
|
+
"cli": None,
|
|
73
|
+
"model": None,
|
|
74
|
+
"fallback": "self-audit",
|
|
75
|
+
},
|
|
76
|
+
"tester": {
|
|
77
|
+
"enabled": True,
|
|
78
|
+
"mode": "external-cli",
|
|
79
|
+
"cli": None,
|
|
80
|
+
"model": None,
|
|
81
|
+
"fallback": "self-audit",
|
|
82
|
+
},
|
|
83
|
+
"memory-auditor": {
|
|
84
|
+
"enabled": True,
|
|
85
|
+
"mode": "external-cli",
|
|
86
|
+
"cli": None,
|
|
87
|
+
"model": None,
|
|
88
|
+
"fallback": "self-audit",
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
CONFIG_VERSION = 1
|
|
92
|
+
DISCOVERY_VERSION = 1
|
|
93
|
+
ROUTER_NAME = "recursive-router"
|
|
94
|
+
LEGACY_ROUTER_NAME = "recursive-router-cli"
|
|
95
|
+
ROUTER_POLICY_FILENAME = "recursive-router.json"
|
|
96
|
+
LEGACY_ROUTER_POLICY_FILENAME = "recursive-router-cli.json"
|
|
97
|
+
ROUTER_DISCOVERY_FILENAME = "recursive-router-discovered.json"
|
|
98
|
+
LEGACY_ROUTER_DISCOVERY_FILENAME = "recursive-router-cli-discovered.json"
|
|
99
|
+
ALLOWED_ROLE_UNCONFIGURED = {"ask", "fallback-local", "block"}
|
|
100
|
+
ALLOWED_CLI_UNAVAILABLE = {"fallback-local", "ask", "block"}
|
|
101
|
+
ALLOWED_MODEL_UNKNOWN = {"ask", "use-as-literal", "fallback-local"}
|
|
102
|
+
ALLOWED_ROLE_MODES = {"external-cli", "local-only"}
|
|
103
|
+
ALLOWED_ROLE_FALLBACKS = {"self-audit", "local-controller", "ask", "block"}
|
|
104
|
+
ALLOWED_PROBE_STATUS = {"ok", "partial", "failed"}
|
|
105
|
+
ALLOWED_MODEL_SOURCE = {
|
|
106
|
+
"app-server-model-list",
|
|
107
|
+
"cli-list",
|
|
108
|
+
"adapter-defaults",
|
|
109
|
+
"cache-file",
|
|
110
|
+
"configured-static-list",
|
|
111
|
+
"config-file",
|
|
112
|
+
"unsupported",
|
|
113
|
+
"failed",
|
|
114
|
+
"unknown",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RouterConfigError(ValueError):
|
|
119
|
+
"""Raised when the router policy is invalid."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class CLIAdapter:
|
|
124
|
+
id: str
|
|
125
|
+
command: str | tuple[str, ...]
|
|
126
|
+
probe_args: tuple[str, ...] = ("--version",)
|
|
127
|
+
model_list_template: tuple[str, ...] | None = None
|
|
128
|
+
builtin: bool = True
|
|
129
|
+
default_models: tuple[str, ...] = ()
|
|
130
|
+
invoke_template: tuple[str, ...] = ()
|
|
131
|
+
transport: str = "cli-template"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
BUILTIN_ADAPTERS: tuple[CLIAdapter, ...] = (
|
|
135
|
+
CLIAdapter(
|
|
136
|
+
id="codex",
|
|
137
|
+
command="codex",
|
|
138
|
+
invoke_template=("exec", "--model", "{model}", "--input-file", "{prompt_file}"),
|
|
139
|
+
transport="app-server",
|
|
140
|
+
),
|
|
141
|
+
CLIAdapter(
|
|
142
|
+
id="kimi",
|
|
143
|
+
command="kimi",
|
|
144
|
+
invoke_template=(
|
|
145
|
+
"--model",
|
|
146
|
+
"{model}",
|
|
147
|
+
"--work-dir",
|
|
148
|
+
"{repo_root}",
|
|
149
|
+
"--print",
|
|
150
|
+
"--output-format",
|
|
151
|
+
"stream-json",
|
|
152
|
+
"--max-ralph-iterations",
|
|
153
|
+
"0",
|
|
154
|
+
"--prompt",
|
|
155
|
+
"{prompt}",
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
CLIAdapter(
|
|
159
|
+
id="opencode",
|
|
160
|
+
command="opencode",
|
|
161
|
+
model_list_template=("models",),
|
|
162
|
+
invoke_template=("run", "--model", "{model}", "--prompt-file", "{prompt_file}"),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
BUILTIN_ADAPTER_BY_ID = {adapter.id: adapter for adapter in BUILTIN_ADAPTERS}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def utc_now_iso() -> str:
|
|
169
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def normalize_repo_path(raw_path: str) -> str:
|
|
173
|
+
return "/" + raw_path.replace("\\", "/").strip().lstrip("/")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def canonicalize_router_role(role: str) -> str:
|
|
177
|
+
normalized = role.strip()
|
|
178
|
+
if normalized in CANONICAL_ROLES:
|
|
179
|
+
return normalized
|
|
180
|
+
legacy = LEGACY_ROLE_ALIASES.get(normalized)
|
|
181
|
+
if legacy is not None:
|
|
182
|
+
return legacy
|
|
183
|
+
raise RouterConfigError(f"Unknown router role: {role}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def default_role_routes() -> dict[str, dict[str, object]]:
|
|
187
|
+
return {role: deepcopy(route) for role, route in DEFAULT_ROLE_ROUTE_SPECS.items()}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def router_config_dir(repo_root: Path) -> Path:
|
|
191
|
+
return repo_root / ".recursive" / "config"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def router_policy_path(repo_root: Path) -> Path:
|
|
195
|
+
return router_config_dir(repo_root) / ROUTER_POLICY_FILENAME
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def legacy_router_policy_path(repo_root: Path) -> Path:
|
|
199
|
+
return router_config_dir(repo_root) / LEGACY_ROUTER_POLICY_FILENAME
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def router_discovery_path(repo_root: Path) -> Path:
|
|
203
|
+
return router_config_dir(repo_root) / ROUTER_DISCOVERY_FILENAME
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def legacy_router_discovery_path(repo_root: Path) -> Path:
|
|
207
|
+
return router_config_dir(repo_root) / LEGACY_ROUTER_DISCOVERY_FILENAME
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def default_router_policy() -> dict[str, object]:
|
|
211
|
+
return {
|
|
212
|
+
"version": CONFIG_VERSION,
|
|
213
|
+
"defaults": {
|
|
214
|
+
"when_role_unconfigured": "ask",
|
|
215
|
+
"when_cli_unavailable": "fallback-local",
|
|
216
|
+
"when_model_unknown": "ask",
|
|
217
|
+
"allow_auto_assign_if_single_cli": False,
|
|
218
|
+
"probe_timeout_ms": 50000,
|
|
219
|
+
"invoke_timeout_ms": 180000,
|
|
220
|
+
},
|
|
221
|
+
"role_routes": default_role_routes(),
|
|
222
|
+
"cli_overrides": {},
|
|
223
|
+
"custom_clis": [],
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def empty_discovery_inventory(*, probe_tool: str, probe_status: str = "partial", clis: list[dict[str, object]] | None = None) -> dict[str, object]:
|
|
228
|
+
return {
|
|
229
|
+
"version": DISCOVERY_VERSION,
|
|
230
|
+
"generated_at": utc_now_iso(),
|
|
231
|
+
"probe_tool": probe_tool,
|
|
232
|
+
"probe_status": probe_status,
|
|
233
|
+
"clis": clis or [],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def pretty_json(payload: object) -> str:
|
|
238
|
+
return json.dumps(payload, indent=2, ensure_ascii=True) + "\n"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def write_json(path: Path, payload: object) -> None:
|
|
242
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
path.write_text(pretty_json(payload), encoding="utf-8", newline="\n")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def read_json(path: Path) -> object:
|
|
247
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_policy(repo_root: Path) -> dict[str, object]:
|
|
251
|
+
policy_path = router_policy_path(repo_root)
|
|
252
|
+
if not policy_path.exists():
|
|
253
|
+
legacy_policy = legacy_router_policy_path(repo_root)
|
|
254
|
+
if legacy_policy.exists():
|
|
255
|
+
policy_path = legacy_policy
|
|
256
|
+
else:
|
|
257
|
+
raise RouterConfigError(f"Missing routing policy file: {normalize_repo_path(str(policy_path.relative_to(repo_root)))}")
|
|
258
|
+
try:
|
|
259
|
+
policy = read_json(policy_path)
|
|
260
|
+
except json.JSONDecodeError as exc:
|
|
261
|
+
raise RouterConfigError(f"Routing policy file is not valid JSON: {exc}") from exc
|
|
262
|
+
return validate_policy(policy)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def ensure_router_scaffold(repo_root: Path) -> tuple[Path, Path]:
|
|
266
|
+
config_dir = router_config_dir(repo_root)
|
|
267
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
policy_path = router_policy_path(repo_root)
|
|
269
|
+
discovery_path = router_discovery_path(repo_root)
|
|
270
|
+
legacy_policy_path = legacy_router_policy_path(repo_root)
|
|
271
|
+
legacy_discovery_path = legacy_router_discovery_path(repo_root)
|
|
272
|
+
|
|
273
|
+
if policy_path.exists():
|
|
274
|
+
try:
|
|
275
|
+
existing_raw = read_json(policy_path)
|
|
276
|
+
except json.JSONDecodeError as exc:
|
|
277
|
+
raise RouterConfigError(f"Existing routing policy is invalid JSON and will not be overwritten: {exc}") from exc
|
|
278
|
+
existing = validate_policy(existing_raw)
|
|
279
|
+
if existing != existing_raw:
|
|
280
|
+
write_json(policy_path, existing)
|
|
281
|
+
elif legacy_policy_path.exists():
|
|
282
|
+
try:
|
|
283
|
+
existing_raw = read_json(legacy_policy_path)
|
|
284
|
+
except json.JSONDecodeError as exc:
|
|
285
|
+
raise RouterConfigError(f"Existing legacy routing policy is invalid JSON and will not be migrated: {exc}") from exc
|
|
286
|
+
existing = validate_policy(existing_raw)
|
|
287
|
+
write_json(policy_path, existing)
|
|
288
|
+
legacy_policy_path.unlink(missing_ok=True)
|
|
289
|
+
else:
|
|
290
|
+
write_json(policy_path, default_router_policy())
|
|
291
|
+
|
|
292
|
+
if discovery_path.exists():
|
|
293
|
+
pass
|
|
294
|
+
elif legacy_discovery_path.exists():
|
|
295
|
+
legacy_discovery_path.replace(discovery_path)
|
|
296
|
+
|
|
297
|
+
return policy_path, discovery_path
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _as_dict(payload: object, label: str) -> dict[str, object]:
|
|
301
|
+
if not isinstance(payload, dict):
|
|
302
|
+
raise RouterConfigError(f"{label} must be a JSON object.")
|
|
303
|
+
return payload
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _as_list_of_strings(value: object, field_name: str, *, allow_empty: bool = True) -> list[str]:
|
|
307
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
308
|
+
raise RouterConfigError(f"{field_name} must be a list of strings.")
|
|
309
|
+
if not allow_empty and not value:
|
|
310
|
+
raise RouterConfigError(f"{field_name} must not be empty.")
|
|
311
|
+
return list(value)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _normalize_command_spec(value: object, field_name: str) -> str | tuple[str, ...]:
|
|
315
|
+
if isinstance(value, str):
|
|
316
|
+
command = value.strip()
|
|
317
|
+
if not command:
|
|
318
|
+
raise RouterConfigError(f"{field_name} must be a non-empty string when provided.")
|
|
319
|
+
return command
|
|
320
|
+
if isinstance(value, list) and value and all(isinstance(item, str) and item.strip() for item in value):
|
|
321
|
+
return tuple(item.strip() for item in value)
|
|
322
|
+
raise RouterConfigError(f"{field_name} must be a non-empty string or a non-empty list of strings.")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_invoke_template(template: list[str], field_name: str) -> None:
|
|
326
|
+
if not any("{model}" in piece for piece in template):
|
|
327
|
+
raise RouterConfigError(f"{field_name} must contain a {{model}} placeholder.")
|
|
328
|
+
if not any("{prompt_file}" in piece or "{prompt}" in piece for piece in template):
|
|
329
|
+
raise RouterConfigError(f"{field_name} must contain either a {{prompt_file}} or {{prompt}} placeholder.")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def normalize_role_routes(role_routes_payload: object) -> dict[str, object]:
|
|
333
|
+
raw_role_routes = _as_dict(role_routes_payload, "role_routes")
|
|
334
|
+
normalized_routes: dict[str, object] = default_role_routes()
|
|
335
|
+
sources_by_canonical: dict[str, str] = {}
|
|
336
|
+
for role_name, route_payload in raw_role_routes.items():
|
|
337
|
+
if not isinstance(role_name, str):
|
|
338
|
+
raise RouterConfigError("role_routes keys must be strings.")
|
|
339
|
+
canonical_role = canonicalize_router_role(role_name)
|
|
340
|
+
prior_source = sources_by_canonical.get(canonical_role)
|
|
341
|
+
if prior_source is not None and prior_source != role_name:
|
|
342
|
+
raise RouterConfigError(
|
|
343
|
+
f"role_routes.{role_name} duplicates canonical role {canonical_role!r}; keep only {canonical_role!r}."
|
|
344
|
+
)
|
|
345
|
+
normalized_routes[canonical_role] = route_payload
|
|
346
|
+
sources_by_canonical[canonical_role] = role_name
|
|
347
|
+
return normalized_routes
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def validate_policy(policy: object) -> dict[str, object]:
|
|
351
|
+
payload = deepcopy(_as_dict(policy, "Router policy"))
|
|
352
|
+
payload["role_routes"] = normalize_role_routes(payload.get("role_routes"))
|
|
353
|
+
default_defaults = _as_dict(default_router_policy()["defaults"], "default defaults")
|
|
354
|
+
|
|
355
|
+
version = payload.get("version")
|
|
356
|
+
if version != CONFIG_VERSION:
|
|
357
|
+
raise RouterConfigError(f"Unsupported config version: {version!r}. Expected {CONFIG_VERSION}.")
|
|
358
|
+
|
|
359
|
+
defaults = _as_dict(payload.get("defaults"), "defaults")
|
|
360
|
+
if defaults.get("when_role_unconfigured") not in ALLOWED_ROLE_UNCONFIGURED:
|
|
361
|
+
raise RouterConfigError("defaults.when_role_unconfigured must be one of: ask, fallback-local, block.")
|
|
362
|
+
if defaults.get("when_cli_unavailable") not in ALLOWED_CLI_UNAVAILABLE:
|
|
363
|
+
raise RouterConfigError("defaults.when_cli_unavailable must be one of: fallback-local, ask, block.")
|
|
364
|
+
if defaults.get("when_model_unknown") not in ALLOWED_MODEL_UNKNOWN:
|
|
365
|
+
raise RouterConfigError("defaults.when_model_unknown must be one of: ask, use-as-literal, fallback-local.")
|
|
366
|
+
if not isinstance(defaults.get("allow_auto_assign_if_single_cli"), bool):
|
|
367
|
+
raise RouterConfigError("defaults.allow_auto_assign_if_single_cli must be a boolean.")
|
|
368
|
+
probe_timeout_ms = defaults.get("probe_timeout_ms")
|
|
369
|
+
if not isinstance(probe_timeout_ms, int) or probe_timeout_ms < 500 or probe_timeout_ms > 60000:
|
|
370
|
+
raise RouterConfigError("defaults.probe_timeout_ms must be an integer between 500 and 60000.")
|
|
371
|
+
invoke_timeout_ms = defaults.get("invoke_timeout_ms", default_defaults["invoke_timeout_ms"])
|
|
372
|
+
if not isinstance(invoke_timeout_ms, int) or invoke_timeout_ms < 1000 or invoke_timeout_ms > 600000:
|
|
373
|
+
raise RouterConfigError("defaults.invoke_timeout_ms must be an integer between 1000 and 600000.")
|
|
374
|
+
|
|
375
|
+
role_routes = _as_dict(payload.get("role_routes"), "role_routes")
|
|
376
|
+
for role in CANONICAL_ROLES:
|
|
377
|
+
if role not in role_routes:
|
|
378
|
+
raise RouterConfigError(f"role_routes is missing canonical role: {role}")
|
|
379
|
+
|
|
380
|
+
for role_name, route_payload in role_routes.items():
|
|
381
|
+
route = _as_dict(route_payload, f"role_routes.{role_name}")
|
|
382
|
+
if not isinstance(route.get("enabled"), bool):
|
|
383
|
+
raise RouterConfigError(f"role_routes.{role_name}.enabled must be a boolean.")
|
|
384
|
+
if route.get("mode") not in ALLOWED_ROLE_MODES:
|
|
385
|
+
raise RouterConfigError(f"role_routes.{role_name}.mode must be one of: external-cli, local-only.")
|
|
386
|
+
cli_value = route.get("cli")
|
|
387
|
+
if cli_value is not None and not isinstance(cli_value, str):
|
|
388
|
+
raise RouterConfigError(f"role_routes.{role_name}.cli must be a string or null.")
|
|
389
|
+
model_value = route.get("model")
|
|
390
|
+
if model_value is not None and not isinstance(model_value, str):
|
|
391
|
+
raise RouterConfigError(f"role_routes.{role_name}.model must be a string or null.")
|
|
392
|
+
if route.get("fallback") not in ALLOWED_ROLE_FALLBACKS:
|
|
393
|
+
raise RouterConfigError(f"role_routes.{role_name}.fallback must be one of: self-audit, local-controller, ask, block.")
|
|
394
|
+
|
|
395
|
+
cli_overrides = payload.get("cli_overrides")
|
|
396
|
+
if not isinstance(cli_overrides, dict):
|
|
397
|
+
raise RouterConfigError("cli_overrides must be a JSON object.")
|
|
398
|
+
builtin_ids = set(BUILTIN_ADAPTER_BY_ID)
|
|
399
|
+
for cli_id, override_payload in cli_overrides.items():
|
|
400
|
+
if cli_id not in builtin_ids:
|
|
401
|
+
raise RouterConfigError(f"cli_overrides references unknown built-in CLI id: {cli_id}")
|
|
402
|
+
override = _as_dict(override_payload, f"cli_overrides.{cli_id}")
|
|
403
|
+
if "command" in override:
|
|
404
|
+
_normalize_command_spec(override["command"], f"cli_overrides.{cli_id}.command")
|
|
405
|
+
if "probe_args" in override:
|
|
406
|
+
_as_list_of_strings(override["probe_args"], f"cli_overrides.{cli_id}.probe_args")
|
|
407
|
+
if "invoke_template" in override:
|
|
408
|
+
invoke_template = _as_list_of_strings(override["invoke_template"], f"cli_overrides.{cli_id}.invoke_template", allow_empty=False)
|
|
409
|
+
_validate_invoke_template(invoke_template, f"cli_overrides.{cli_id}.invoke_template")
|
|
410
|
+
if "model_list_template" in override and override["model_list_template"] is not None:
|
|
411
|
+
_as_list_of_strings(override["model_list_template"], f"cli_overrides.{cli_id}.model_list_template")
|
|
412
|
+
if "default_models" in override:
|
|
413
|
+
_as_list_of_strings(override["default_models"], f"cli_overrides.{cli_id}.default_models")
|
|
414
|
+
transport = override.get("transport")
|
|
415
|
+
if transport is not None and (not isinstance(transport, str) or not transport.strip()):
|
|
416
|
+
raise RouterConfigError(f"cli_overrides.{cli_id}.transport must be a non-empty string when provided.")
|
|
417
|
+
|
|
418
|
+
custom_clis = payload.get("custom_clis")
|
|
419
|
+
if not isinstance(custom_clis, list):
|
|
420
|
+
raise RouterConfigError("custom_clis must be a list.")
|
|
421
|
+
|
|
422
|
+
known_ids = {adapter.id for adapter in BUILTIN_ADAPTERS}
|
|
423
|
+
for index, entry in enumerate(custom_clis):
|
|
424
|
+
item = _as_dict(entry, f"custom_clis[{index}]")
|
|
425
|
+
cli_id = item.get("id")
|
|
426
|
+
if not isinstance(cli_id, str) or not cli_id.strip():
|
|
427
|
+
raise RouterConfigError(f"custom_clis[{index}].id must be a non-empty string.")
|
|
428
|
+
if cli_id in known_ids:
|
|
429
|
+
raise RouterConfigError(f"custom_clis[{index}].id duplicates an existing CLI id: {cli_id}")
|
|
430
|
+
known_ids.add(cli_id)
|
|
431
|
+
|
|
432
|
+
command = _normalize_command_spec(item.get("command"), f"custom_clis[{index}].command")
|
|
433
|
+
|
|
434
|
+
probe_args = _as_list_of_strings(item.get("probe_args"), f"custom_clis[{index}].probe_args")
|
|
435
|
+
invoke_template = _as_list_of_strings(item.get("invoke_template"), f"custom_clis[{index}].invoke_template", allow_empty=False)
|
|
436
|
+
_validate_invoke_template(invoke_template, f"custom_clis[{index}].invoke_template")
|
|
437
|
+
model_list_template = item.get("model_list_template")
|
|
438
|
+
if model_list_template is not None:
|
|
439
|
+
_as_list_of_strings(model_list_template, f"custom_clis[{index}].model_list_template")
|
|
440
|
+
_as_list_of_strings(probe_args, f"custom_clis[{index}].probe_args")
|
|
441
|
+
|
|
442
|
+
valid_ids = known_ids
|
|
443
|
+
for role_name, route_payload in role_routes.items():
|
|
444
|
+
cli_value = route_payload.get("cli")
|
|
445
|
+
if cli_value is not None and cli_value not in valid_ids:
|
|
446
|
+
raise RouterConfigError(f"role_routes.{role_name}.cli references unknown CLI id: {cli_value}")
|
|
447
|
+
return payload
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def adapters_from_policy(policy: dict[str, object] | None) -> list[CLIAdapter]:
|
|
451
|
+
adapters: list[CLIAdapter] = []
|
|
452
|
+
override_map = policy.get("cli_overrides", {}) if policy else {}
|
|
453
|
+
for adapter in BUILTIN_ADAPTERS:
|
|
454
|
+
override = override_map.get(adapter.id, {})
|
|
455
|
+
model_list_template = adapter.model_list_template
|
|
456
|
+
if "model_list_template" in override:
|
|
457
|
+
raw_model_list = override["model_list_template"]
|
|
458
|
+
model_list_template = None if raw_model_list is None else tuple(raw_model_list)
|
|
459
|
+
adapters.append(
|
|
460
|
+
CLIAdapter(
|
|
461
|
+
id=adapter.id,
|
|
462
|
+
command=_normalize_command_spec(override["command"], f"cli_overrides.{adapter.id}.command")
|
|
463
|
+
if "command" in override
|
|
464
|
+
else adapter.command,
|
|
465
|
+
probe_args=tuple(override.get("probe_args", list(adapter.probe_args))),
|
|
466
|
+
model_list_template=model_list_template,
|
|
467
|
+
builtin=adapter.builtin,
|
|
468
|
+
default_models=tuple(override.get("default_models", list(adapter.default_models))),
|
|
469
|
+
invoke_template=tuple(override.get("invoke_template", list(adapter.invoke_template))),
|
|
470
|
+
transport=override.get("transport", adapter.transport),
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
if not policy:
|
|
474
|
+
return adapters
|
|
475
|
+
for index, entry in enumerate(policy.get("custom_clis", [])):
|
|
476
|
+
item = _as_dict(entry, "custom CLI")
|
|
477
|
+
model_list_template = item.get("model_list_template")
|
|
478
|
+
adapters.append(
|
|
479
|
+
CLIAdapter(
|
|
480
|
+
id=item["id"],
|
|
481
|
+
command=_normalize_command_spec(item["command"], f"custom_clis[{index}].command"),
|
|
482
|
+
probe_args=tuple(item["probe_args"]),
|
|
483
|
+
model_list_template=tuple(model_list_template) if model_list_template is not None else None,
|
|
484
|
+
builtin=False,
|
|
485
|
+
invoke_template=tuple(item["invoke_template"]),
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
return adapters
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _command_display(command: str | tuple[str, ...]) -> str | list[str]:
|
|
492
|
+
return command if isinstance(command, str) else list(command)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _resolve_executable_token(token: str) -> str | None:
|
|
496
|
+
stripped = token.strip().strip('"')
|
|
497
|
+
if not stripped:
|
|
498
|
+
return None
|
|
499
|
+
if os.path.isabs(stripped):
|
|
500
|
+
return stripped if Path(stripped).exists() else None
|
|
501
|
+
return shutil.which(stripped)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _parse_windows_wrapper_command(wrapper_path: Path) -> list[str] | None:
|
|
505
|
+
suffix = wrapper_path.suffix.lower()
|
|
506
|
+
if suffix not in {".ps1", ".cmd", ".bat"}:
|
|
507
|
+
return None
|
|
508
|
+
try:
|
|
509
|
+
text = wrapper_path.read_text(encoding="utf-8")
|
|
510
|
+
except OSError:
|
|
511
|
+
return None
|
|
512
|
+
if suffix == ".ps1":
|
|
513
|
+
match = re.search(r"^\s*&\s+((?:\"[^\"]+\"|'[^']+')(?:(?:\s+)(?:\"[^\"]+\"|'[^']+'))*)\s+@args\b", text, re.MULTILINE)
|
|
514
|
+
else:
|
|
515
|
+
match = re.search(r"^\s*@?(?:echo off\s*)?[\r\n]+((?:\"[^\"]+\")(?:\s+\"[^\"]+\")*)\s+%\*\s*$", text, re.MULTILINE)
|
|
516
|
+
if match is None:
|
|
517
|
+
return None
|
|
518
|
+
quoted = re.findall(r"\"([^\"]+)\"|'([^']+)'", match.group(1))
|
|
519
|
+
tokens = [first or second for first, second in quoted if first or second]
|
|
520
|
+
if not tokens:
|
|
521
|
+
return None
|
|
522
|
+
resolved = _resolve_executable_token(tokens[0])
|
|
523
|
+
if resolved is None:
|
|
524
|
+
return None
|
|
525
|
+
return [resolved, *tokens[1:]]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def resolve_command_argv(command: str | tuple[str, ...]) -> list[str] | None:
|
|
529
|
+
if isinstance(command, str):
|
|
530
|
+
tokens = [command]
|
|
531
|
+
else:
|
|
532
|
+
tokens = list(command)
|
|
533
|
+
if not tokens:
|
|
534
|
+
return None
|
|
535
|
+
stripped = tokens[0].strip().strip('"')
|
|
536
|
+
if not stripped:
|
|
537
|
+
return None
|
|
538
|
+
resolved = _resolve_executable_token(stripped)
|
|
539
|
+
if resolved is None:
|
|
540
|
+
return None
|
|
541
|
+
if os.name == "nt" and os.path.isabs(resolved):
|
|
542
|
+
parsed = _parse_windows_wrapper_command(Path(resolved))
|
|
543
|
+
if parsed is not None:
|
|
544
|
+
return [*parsed, *tokens[1:]]
|
|
545
|
+
return [resolved, *tokens[1:]]
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def resolve_command_path(command: str | tuple[str, ...]) -> str | None:
|
|
549
|
+
argv = resolve_command_argv(command)
|
|
550
|
+
return argv[0] if argv else None
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _run_command(command: list[str], *, timeout_ms: int, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
|
554
|
+
if command and os.name == "nt" and command[0].lower().endswith(".ps1"):
|
|
555
|
+
shell_path = resolve_command_path("pwsh") or resolve_command_path("powershell")
|
|
556
|
+
if shell_path is not None:
|
|
557
|
+
command = [shell_path, "-NoProfile", "-File", command[0], *command[1:]]
|
|
558
|
+
env = os.environ.copy()
|
|
559
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
560
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
561
|
+
try:
|
|
562
|
+
return subprocess.run(
|
|
563
|
+
command,
|
|
564
|
+
cwd=str(cwd) if cwd else None,
|
|
565
|
+
text=True,
|
|
566
|
+
encoding="utf-8",
|
|
567
|
+
errors="replace",
|
|
568
|
+
capture_output=True,
|
|
569
|
+
env=env,
|
|
570
|
+
timeout=max(0.5, timeout_ms / 1000.0),
|
|
571
|
+
check=False,
|
|
572
|
+
)
|
|
573
|
+
except OSError as exc:
|
|
574
|
+
return subprocess.CompletedProcess(
|
|
575
|
+
args=command,
|
|
576
|
+
returncode=127,
|
|
577
|
+
stdout="",
|
|
578
|
+
stderr=f"Failed to start command: {exc}",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _normalize_version(stdout: str, stderr: str) -> str | None:
|
|
583
|
+
for stream in (stdout, stderr):
|
|
584
|
+
for line in stream.splitlines():
|
|
585
|
+
if line.strip():
|
|
586
|
+
return line.strip()
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _parse_models(stdout: str) -> list[str]:
|
|
591
|
+
text = stdout.strip()
|
|
592
|
+
if not text:
|
|
593
|
+
return []
|
|
594
|
+
if text.startswith("["):
|
|
595
|
+
payload = json.loads(text)
|
|
596
|
+
if isinstance(payload, list):
|
|
597
|
+
return [str(item) for item in payload]
|
|
598
|
+
if text.startswith("{"):
|
|
599
|
+
payload = json.loads(text)
|
|
600
|
+
if isinstance(payload, dict) and isinstance(payload.get("models"), list):
|
|
601
|
+
return [str(item) for item in payload["models"]]
|
|
602
|
+
normalized_text = text.replace("\\r\\n", "\n").replace("\\n", "\n")
|
|
603
|
+
return [line.strip() for line in normalized_text.splitlines() if line.strip()]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _dedupe_strings(values: list[str]) -> list[str]:
|
|
607
|
+
result: list[str] = []
|
|
608
|
+
seen: set[str] = set()
|
|
609
|
+
for value in values:
|
|
610
|
+
if value not in seen:
|
|
611
|
+
seen.add(value)
|
|
612
|
+
result.append(value)
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _discover_codex_cached_models() -> tuple[list[str], str | None, str | None]:
|
|
617
|
+
cache_path = Path.home() / ".codex" / "models_cache.json"
|
|
618
|
+
if not cache_path.exists():
|
|
619
|
+
return [], None, f"Codex model cache not found at {cache_path}."
|
|
620
|
+
try:
|
|
621
|
+
payload = read_json(cache_path)
|
|
622
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
623
|
+
return [], None, f"Failed to parse Codex model cache at {cache_path}: {exc}"
|
|
624
|
+
|
|
625
|
+
model_entries: object
|
|
626
|
+
if isinstance(payload, dict):
|
|
627
|
+
model_entries = payload.get("models", [])
|
|
628
|
+
else:
|
|
629
|
+
model_entries = payload
|
|
630
|
+
|
|
631
|
+
if not isinstance(model_entries, list):
|
|
632
|
+
return [], None, f"Codex model cache at {cache_path} does not contain a models list."
|
|
633
|
+
|
|
634
|
+
models: list[str] = []
|
|
635
|
+
for entry in model_entries:
|
|
636
|
+
if isinstance(entry, str) and entry.strip():
|
|
637
|
+
models.append(entry.strip())
|
|
638
|
+
elif isinstance(entry, dict):
|
|
639
|
+
slug = entry.get("slug")
|
|
640
|
+
if isinstance(slug, str) and slug.strip():
|
|
641
|
+
models.append(slug.strip())
|
|
642
|
+
return _dedupe_strings(models), "cache-file", None
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _discover_codex_app_server_models(command_argv: list[str], *, timeout_ms: int) -> tuple[list[str], str | None, str | None]:
|
|
646
|
+
process = subprocess.Popen(
|
|
647
|
+
[*command_argv, "app-server", "--listen", "stdio://"],
|
|
648
|
+
stdin=subprocess.PIPE,
|
|
649
|
+
stdout=subprocess.PIPE,
|
|
650
|
+
stderr=subprocess.STDOUT,
|
|
651
|
+
text=True,
|
|
652
|
+
bufsize=1,
|
|
653
|
+
)
|
|
654
|
+
if process.stdin is None or process.stdout is None:
|
|
655
|
+
try:
|
|
656
|
+
process.terminate()
|
|
657
|
+
except OSError:
|
|
658
|
+
pass
|
|
659
|
+
return [], None, "Failed to open Codex app-server stdio streams."
|
|
660
|
+
|
|
661
|
+
events: "queue.Queue[str | None]" = queue.Queue()
|
|
662
|
+
transcript_lines: list[str] = []
|
|
663
|
+
|
|
664
|
+
def reader() -> None:
|
|
665
|
+
try:
|
|
666
|
+
for raw_line in process.stdout:
|
|
667
|
+
events.put(raw_line.rstrip("\r\n"))
|
|
668
|
+
finally:
|
|
669
|
+
events.put(None)
|
|
670
|
+
|
|
671
|
+
reader_thread = threading.Thread(target=reader, daemon=True)
|
|
672
|
+
reader_thread.start()
|
|
673
|
+
|
|
674
|
+
def send(message: dict[str, object]) -> None:
|
|
675
|
+
process.stdin.write(json.dumps(message) + "\n")
|
|
676
|
+
process.stdin.flush()
|
|
677
|
+
|
|
678
|
+
def read_until(predicate) -> dict[str, object]:
|
|
679
|
+
deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
|
|
680
|
+
while time.monotonic() < deadline:
|
|
681
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
682
|
+
try:
|
|
683
|
+
raw = events.get(timeout=remaining)
|
|
684
|
+
except queue.Empty as exc:
|
|
685
|
+
raise TimeoutError from exc
|
|
686
|
+
if raw is None:
|
|
687
|
+
break
|
|
688
|
+
transcript_lines.append(raw)
|
|
689
|
+
try:
|
|
690
|
+
payload = json.loads(raw)
|
|
691
|
+
except json.JSONDecodeError:
|
|
692
|
+
continue
|
|
693
|
+
if predicate(payload):
|
|
694
|
+
return payload
|
|
695
|
+
raise TimeoutError
|
|
696
|
+
|
|
697
|
+
try:
|
|
698
|
+
send(
|
|
699
|
+
{
|
|
700
|
+
"id": "initialize",
|
|
701
|
+
"method": "initialize",
|
|
702
|
+
"params": {
|
|
703
|
+
"clientInfo": {
|
|
704
|
+
"name": ROUTER_NAME,
|
|
705
|
+
"title": ROUTER_NAME,
|
|
706
|
+
"version": "1.0",
|
|
707
|
+
},
|
|
708
|
+
"capabilities": {"experimentalApi": True},
|
|
709
|
+
},
|
|
710
|
+
}
|
|
711
|
+
)
|
|
712
|
+
read_until(lambda payload: payload.get("id") == "initialize")
|
|
713
|
+
send({"method": "initialized"})
|
|
714
|
+
send({"id": "model-list", "method": "model/list", "params": {}})
|
|
715
|
+
response_line = read_until(lambda payload: payload.get("id") == "model-list")
|
|
716
|
+
except TimeoutError:
|
|
717
|
+
message = "\n".join(transcript_lines).strip()
|
|
718
|
+
if message:
|
|
719
|
+
return [], None, f"Codex app-server model/list timed out after {timeout_ms}ms. Transcript: {message}"
|
|
720
|
+
return [], None, f"Codex app-server model/list timed out after {timeout_ms}ms."
|
|
721
|
+
finally:
|
|
722
|
+
try:
|
|
723
|
+
process.terminate()
|
|
724
|
+
except OSError:
|
|
725
|
+
pass
|
|
726
|
+
try:
|
|
727
|
+
process.wait(timeout=5)
|
|
728
|
+
except subprocess.TimeoutExpired:
|
|
729
|
+
process.kill()
|
|
730
|
+
process.wait(timeout=5)
|
|
731
|
+
|
|
732
|
+
error = response_line.get("error")
|
|
733
|
+
if isinstance(error, dict):
|
|
734
|
+
message = error.get("message")
|
|
735
|
+
return [], None, f"Codex app-server model/list failed: {message or error!r}"
|
|
736
|
+
|
|
737
|
+
result = response_line.get("result")
|
|
738
|
+
if not isinstance(result, dict):
|
|
739
|
+
return [], None, "Codex app-server model/list returned an invalid result payload."
|
|
740
|
+
|
|
741
|
+
data = result.get("data", [])
|
|
742
|
+
if not isinstance(data, list):
|
|
743
|
+
return [], None, "Codex app-server model/list returned a non-list data payload."
|
|
744
|
+
|
|
745
|
+
models: list[str] = []
|
|
746
|
+
for entry in data:
|
|
747
|
+
if isinstance(entry, dict):
|
|
748
|
+
model_value = entry.get("model") or entry.get("id")
|
|
749
|
+
if isinstance(model_value, str) and model_value.strip():
|
|
750
|
+
models.append(model_value.strip())
|
|
751
|
+
elif isinstance(entry, str) and entry.strip():
|
|
752
|
+
models.append(entry.strip())
|
|
753
|
+
return _dedupe_strings(models), "app-server-model-list", None
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _discover_codex_models(command_argv: list[str], *, timeout_ms: int) -> tuple[list[str], str | None, str | None]:
|
|
757
|
+
app_server_models, app_server_source, app_server_note = _discover_codex_app_server_models(command_argv, timeout_ms=timeout_ms)
|
|
758
|
+
if app_server_source is not None:
|
|
759
|
+
return app_server_models, app_server_source, app_server_note
|
|
760
|
+
|
|
761
|
+
cached_models, cached_source, cached_note = _discover_codex_cached_models()
|
|
762
|
+
if cached_source is not None:
|
|
763
|
+
fallback_note = app_server_note
|
|
764
|
+
if cached_note:
|
|
765
|
+
fallback_note = f"{fallback_note} Falling back to local cache. {cached_note}" if fallback_note else cached_note
|
|
766
|
+
elif fallback_note:
|
|
767
|
+
fallback_note = f"{fallback_note} Falling back to local cache."
|
|
768
|
+
return cached_models, cached_source, fallback_note
|
|
769
|
+
return [], None, app_server_note or cached_note
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _discover_kimi_configured_models() -> tuple[list[str], str | None, str | None]:
|
|
773
|
+
config_path = Path.home() / ".kimi" / "config.toml"
|
|
774
|
+
if not config_path.exists():
|
|
775
|
+
return [], None, f"Kimi config file not found at {config_path}."
|
|
776
|
+
try:
|
|
777
|
+
payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
778
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
779
|
+
return [], None, f"Failed to parse Kimi config at {config_path}: {exc}"
|
|
780
|
+
|
|
781
|
+
models_section = payload.get("models")
|
|
782
|
+
if not isinstance(models_section, dict):
|
|
783
|
+
return [], None, f"Kimi config at {config_path} does not contain a models table."
|
|
784
|
+
|
|
785
|
+
models: list[str] = []
|
|
786
|
+
for alias, entry in models_section.items():
|
|
787
|
+
if isinstance(alias, str) and alias.strip() and isinstance(entry, dict):
|
|
788
|
+
model_value = entry.get("model")
|
|
789
|
+
if isinstance(model_value, str) and model_value.strip():
|
|
790
|
+
models.append(alias.strip())
|
|
791
|
+
return _dedupe_strings(models), "config-file", None
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _discover_local_models(adapter: CLIAdapter) -> tuple[list[str], str | None, str | None]:
|
|
795
|
+
if adapter.id == "codex":
|
|
796
|
+
return _discover_codex_cached_models()
|
|
797
|
+
if adapter.id == "kimi":
|
|
798
|
+
return _discover_kimi_configured_models()
|
|
799
|
+
return [], None, None
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def probe_adapter(adapter: CLIAdapter, *, timeout_ms: int) -> dict[str, object]:
|
|
803
|
+
command_argv = resolve_command_argv(adapter.command)
|
|
804
|
+
resolved_path = command_argv[0] if command_argv else None
|
|
805
|
+
notes: list[str] = []
|
|
806
|
+
entry: dict[str, object] = {
|
|
807
|
+
"id": adapter.id,
|
|
808
|
+
"available": False,
|
|
809
|
+
"builtin": adapter.builtin,
|
|
810
|
+
"command": _command_display(adapter.command),
|
|
811
|
+
"resolved_path": resolved_path,
|
|
812
|
+
"version": None,
|
|
813
|
+
"models": [],
|
|
814
|
+
"model_source": "unknown",
|
|
815
|
+
"transport": adapter.transport,
|
|
816
|
+
"notes": notes,
|
|
817
|
+
}
|
|
818
|
+
if resolved_path is None:
|
|
819
|
+
notes.append("Executable not found on PATH or at the configured absolute path.")
|
|
820
|
+
return entry
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
probe_result = _run_command([*command_argv, *adapter.probe_args], timeout_ms=timeout_ms)
|
|
824
|
+
except subprocess.TimeoutExpired:
|
|
825
|
+
notes.append(f"Probe command timed out after {timeout_ms}ms.")
|
|
826
|
+
return entry
|
|
827
|
+
|
|
828
|
+
if probe_result.returncode != 0:
|
|
829
|
+
message = (probe_result.stderr or probe_result.stdout).strip() or f"Probe command exited with code {probe_result.returncode}."
|
|
830
|
+
notes.append(message)
|
|
831
|
+
return entry
|
|
832
|
+
|
|
833
|
+
entry["available"] = True
|
|
834
|
+
entry["version"] = _normalize_version(probe_result.stdout, probe_result.stderr)
|
|
835
|
+
|
|
836
|
+
if adapter.id == "codex" and adapter.transport == "app-server" and adapter.model_list_template is None:
|
|
837
|
+
models, model_source, note = _discover_codex_models(command_argv, timeout_ms=timeout_ms)
|
|
838
|
+
if model_source is not None:
|
|
839
|
+
entry["models"] = models
|
|
840
|
+
entry["model_source"] = model_source
|
|
841
|
+
if note:
|
|
842
|
+
notes.append(note)
|
|
843
|
+
return entry
|
|
844
|
+
if note:
|
|
845
|
+
notes.append(note)
|
|
846
|
+
|
|
847
|
+
if adapter.model_list_template:
|
|
848
|
+
try:
|
|
849
|
+
models_result = _run_command([*command_argv, *adapter.model_list_template], timeout_ms=timeout_ms)
|
|
850
|
+
except subprocess.TimeoutExpired:
|
|
851
|
+
entry["model_source"] = "failed"
|
|
852
|
+
notes.append(f"Model-list command timed out after {timeout_ms}ms.")
|
|
853
|
+
return entry
|
|
854
|
+
|
|
855
|
+
if models_result.returncode != 0:
|
|
856
|
+
entry["model_source"] = "failed"
|
|
857
|
+
message = (models_result.stderr or models_result.stdout).strip() or f"Model-list command exited with code {models_result.returncode}."
|
|
858
|
+
notes.append(message)
|
|
859
|
+
return entry
|
|
860
|
+
|
|
861
|
+
entry["models"] = _parse_models(models_result.stdout)
|
|
862
|
+
entry["model_source"] = "cli-list"
|
|
863
|
+
return entry
|
|
864
|
+
|
|
865
|
+
if adapter.default_models:
|
|
866
|
+
entry["models"] = list(adapter.default_models)
|
|
867
|
+
entry["model_source"] = "adapter-defaults"
|
|
868
|
+
return entry
|
|
869
|
+
|
|
870
|
+
local_models, local_source, local_note = _discover_local_models(adapter)
|
|
871
|
+
if local_source is not None:
|
|
872
|
+
entry["models"] = local_models
|
|
873
|
+
entry["model_source"] = local_source
|
|
874
|
+
if local_note:
|
|
875
|
+
notes.append(local_note)
|
|
876
|
+
elif local_note:
|
|
877
|
+
entry["model_source"] = "unknown"
|
|
878
|
+
notes.append(local_note)
|
|
879
|
+
else:
|
|
880
|
+
entry["model_source"] = "unsupported"
|
|
881
|
+
notes.append("Model discovery is not supported for this CLI adapter.")
|
|
882
|
+
return entry
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def probe_inventory(
|
|
886
|
+
repo_root: Path,
|
|
887
|
+
*,
|
|
888
|
+
timeout_ms: int | None = None,
|
|
889
|
+
probe_tool: str,
|
|
890
|
+
write_discovery: bool = True,
|
|
891
|
+
policy: dict[str, object] | None = None,
|
|
892
|
+
) -> dict[str, object]:
|
|
893
|
+
effective_policy = policy
|
|
894
|
+
if effective_policy is None:
|
|
895
|
+
try:
|
|
896
|
+
effective_policy = load_policy(repo_root)
|
|
897
|
+
except RouterConfigError:
|
|
898
|
+
effective_policy = None
|
|
899
|
+
|
|
900
|
+
if effective_policy is not None and timeout_ms is None:
|
|
901
|
+
timeout_ms = int(_as_dict(effective_policy["defaults"], "defaults")["probe_timeout_ms"])
|
|
902
|
+
timeout_ms = 2500 if timeout_ms is None else timeout_ms
|
|
903
|
+
|
|
904
|
+
clis = [probe_adapter(adapter, timeout_ms=timeout_ms) for adapter in adapters_from_policy(effective_policy)]
|
|
905
|
+
if not clis:
|
|
906
|
+
probe_status = "failed"
|
|
907
|
+
elif all(entry["available"] for entry in clis):
|
|
908
|
+
probe_status = "ok"
|
|
909
|
+
elif any(entry["available"] for entry in clis):
|
|
910
|
+
probe_status = "partial"
|
|
911
|
+
else:
|
|
912
|
+
probe_status = "failed"
|
|
913
|
+
inventory = empty_discovery_inventory(probe_tool=probe_tool, probe_status=probe_status, clis=clis)
|
|
914
|
+
if write_discovery:
|
|
915
|
+
discovery_path = router_discovery_path(repo_root)
|
|
916
|
+
discovery_path.parent.mkdir(parents=True, exist_ok=True)
|
|
917
|
+
write_json(discovery_path, inventory)
|
|
918
|
+
return inventory
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _available_cli_entries(inventory: dict[str, object]) -> list[dict[str, object]]:
|
|
922
|
+
return [entry for entry in inventory["clis"] if entry["available"]]
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _cli_entry_by_id(inventory: dict[str, object], cli_id: str) -> dict[str, object] | None:
|
|
926
|
+
for entry in inventory["clis"]:
|
|
927
|
+
if entry["id"] == cli_id:
|
|
928
|
+
return entry
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _probe_timeout_ms_from_policy(policy: dict[str, object]) -> int:
|
|
933
|
+
return int(_as_dict(policy["defaults"], "defaults")["probe_timeout_ms"])
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _invoke_timeout_ms_from_policy(policy: dict[str, object]) -> int:
|
|
937
|
+
defaults = _as_dict(policy["defaults"], "defaults")
|
|
938
|
+
return int(defaults.get("invoke_timeout_ms", _as_dict(default_router_policy()["defaults"], "default defaults")["invoke_timeout_ms"]))
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def adapter_by_id(policy: dict[str, object] | None, cli_id: str) -> CLIAdapter | None:
|
|
942
|
+
for adapter in adapters_from_policy(policy):
|
|
943
|
+
if adapter.id == cli_id:
|
|
944
|
+
return adapter
|
|
945
|
+
return None
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _verification_prompt(token: str) -> str:
|
|
949
|
+
return f"Reply with exactly this token and no extra text: {token}"
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _token_found(token: str, *streams: str | None) -> bool:
|
|
953
|
+
return any(token in stream for stream in streams if stream)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _run_template_invocation(
|
|
957
|
+
*,
|
|
958
|
+
command_argv: list[str],
|
|
959
|
+
adapter: CLIAdapter,
|
|
960
|
+
repo_root: Path,
|
|
961
|
+
model: str,
|
|
962
|
+
prompt: str,
|
|
963
|
+
timeout_ms: int,
|
|
964
|
+
) -> subprocess.CompletedProcess[str]:
|
|
965
|
+
with tempfile.TemporaryDirectory(prefix="recursive-router-verify-") as temp_dir:
|
|
966
|
+
prompt_path = Path(temp_dir) / "prompt.txt"
|
|
967
|
+
prompt_path.write_text(prompt, encoding="utf-8")
|
|
968
|
+
command_path = command_argv[0]
|
|
969
|
+
if (
|
|
970
|
+
os.name == "nt"
|
|
971
|
+
and "\n" in prompt
|
|
972
|
+
and any("{prompt}" in piece for piece in adapter.invoke_template)
|
|
973
|
+
and command_path.lower().endswith((".cmd", ".bat", ".ps1"))
|
|
974
|
+
):
|
|
975
|
+
shell_path = resolve_command_path("pwsh") or resolve_command_path("powershell")
|
|
976
|
+
if shell_path is not None:
|
|
977
|
+
powershell_command_argv = list(command_argv)
|
|
978
|
+
if command_path.lower().endswith((".cmd", ".bat")):
|
|
979
|
+
companion_ps1 = Path(command_path).with_suffix(".ps1")
|
|
980
|
+
if companion_ps1.exists():
|
|
981
|
+
powershell_command_argv = [str(companion_ps1), *command_argv[1:]]
|
|
982
|
+
return _run_template_invocation_via_powershell(
|
|
983
|
+
shell_path=shell_path,
|
|
984
|
+
command_argv=powershell_command_argv,
|
|
985
|
+
adapter=adapter,
|
|
986
|
+
repo_root=repo_root,
|
|
987
|
+
model=model,
|
|
988
|
+
prompt_path=prompt_path,
|
|
989
|
+
timeout_ms=timeout_ms,
|
|
990
|
+
)
|
|
991
|
+
args = [
|
|
992
|
+
piece.format(model=model, prompt_file=str(prompt_path), prompt=prompt, repo_root=str(repo_root))
|
|
993
|
+
for piece in adapter.invoke_template
|
|
994
|
+
]
|
|
995
|
+
return _run_command([*command_argv, *args], timeout_ms=timeout_ms, cwd=repo_root)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _strip_ansi(text: str | None) -> str:
|
|
999
|
+
if text is None:
|
|
1000
|
+
return ""
|
|
1001
|
+
return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _collect_text_content(value: object) -> list[str]:
|
|
1005
|
+
if isinstance(value, str):
|
|
1006
|
+
return [value] if value.strip() else []
|
|
1007
|
+
if isinstance(value, list):
|
|
1008
|
+
fragments: list[str] = []
|
|
1009
|
+
for item in value:
|
|
1010
|
+
fragments.extend(_collect_text_content(item))
|
|
1011
|
+
return fragments
|
|
1012
|
+
if isinstance(value, dict):
|
|
1013
|
+
fragments = []
|
|
1014
|
+
for key in ("text", "content"):
|
|
1015
|
+
if key in value:
|
|
1016
|
+
fragments.extend(_collect_text_content(value[key]))
|
|
1017
|
+
return fragments
|
|
1018
|
+
return []
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _assistant_text_from_payload(value: object) -> list[str]:
|
|
1022
|
+
if isinstance(value, list):
|
|
1023
|
+
fragments: list[str] = []
|
|
1024
|
+
for item in value:
|
|
1025
|
+
fragments.extend(_assistant_text_from_payload(item))
|
|
1026
|
+
return fragments
|
|
1027
|
+
if not isinstance(value, dict):
|
|
1028
|
+
return []
|
|
1029
|
+
|
|
1030
|
+
role = str(value.get("role", "")).lower()
|
|
1031
|
+
payload_type = str(value.get("type", "")).lower()
|
|
1032
|
+
assistant_like = role == "assistant" or payload_type in {
|
|
1033
|
+
"assistant",
|
|
1034
|
+
"assistant_message",
|
|
1035
|
+
"agentmessage",
|
|
1036
|
+
"agent_message",
|
|
1037
|
+
}
|
|
1038
|
+
if assistant_like:
|
|
1039
|
+
fragments = _collect_text_content(value)
|
|
1040
|
+
if fragments:
|
|
1041
|
+
return fragments
|
|
1042
|
+
|
|
1043
|
+
fragments: list[str] = []
|
|
1044
|
+
for key in ("message", "delta", "part", "content", "data"):
|
|
1045
|
+
child = value.get(key)
|
|
1046
|
+
if isinstance(child, (dict, list)):
|
|
1047
|
+
fragments.extend(_assistant_text_from_payload(child))
|
|
1048
|
+
return fragments
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _normalize_kimi_output(text: str) -> str:
|
|
1052
|
+
fragments: list[str] = []
|
|
1053
|
+
plain_lines: list[str] = []
|
|
1054
|
+
for raw_line in text.splitlines():
|
|
1055
|
+
line = raw_line.strip()
|
|
1056
|
+
if not line or line == "<choice>STOP</choice>":
|
|
1057
|
+
continue
|
|
1058
|
+
if line.startswith("{"):
|
|
1059
|
+
try:
|
|
1060
|
+
payload = json.loads(line)
|
|
1061
|
+
except json.JSONDecodeError:
|
|
1062
|
+
plain_lines.append(raw_line)
|
|
1063
|
+
continue
|
|
1064
|
+
fragments.extend(_assistant_text_from_payload(payload))
|
|
1065
|
+
else:
|
|
1066
|
+
plain_lines.append(raw_line)
|
|
1067
|
+
if fragments:
|
|
1068
|
+
return "\n".join(fragment.strip() for fragment in fragments if fragment.strip()).strip()
|
|
1069
|
+
return "\n".join(line for line in plain_lines if line.strip()).strip()
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _normalize_opencode_output(text: str) -> str:
|
|
1073
|
+
stripped = text.strip()
|
|
1074
|
+
if not stripped:
|
|
1075
|
+
return ""
|
|
1076
|
+
fragments: list[str] = []
|
|
1077
|
+
for raw_line in stripped.splitlines():
|
|
1078
|
+
line = raw_line.strip()
|
|
1079
|
+
if not line or not line.startswith("{"):
|
|
1080
|
+
continue
|
|
1081
|
+
try:
|
|
1082
|
+
payload = json.loads(line)
|
|
1083
|
+
except json.JSONDecodeError:
|
|
1084
|
+
continue
|
|
1085
|
+
if not isinstance(payload, dict) or payload.get("type") != "text":
|
|
1086
|
+
continue
|
|
1087
|
+
part = payload.get("part")
|
|
1088
|
+
if not isinstance(part, dict):
|
|
1089
|
+
continue
|
|
1090
|
+
message = part.get("text")
|
|
1091
|
+
if isinstance(message, str) and message.strip():
|
|
1092
|
+
fragments.append(message.strip())
|
|
1093
|
+
if fragments:
|
|
1094
|
+
return "\n".join(fragments).strip()
|
|
1095
|
+
return stripped
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _single_quote_for_powershell(value: str) -> str:
|
|
1099
|
+
return value.replace("'", "''")
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _run_template_invocation_via_powershell(
|
|
1103
|
+
*,
|
|
1104
|
+
shell_path: str,
|
|
1105
|
+
command_argv: list[str],
|
|
1106
|
+
adapter: CLIAdapter,
|
|
1107
|
+
repo_root: Path,
|
|
1108
|
+
model: str,
|
|
1109
|
+
prompt_path: Path,
|
|
1110
|
+
timeout_ms: int,
|
|
1111
|
+
) -> subprocess.CompletedProcess[str]:
|
|
1112
|
+
script_path = prompt_path.with_name("invoke-router-template.ps1")
|
|
1113
|
+
command_lines = [f" '{_single_quote_for_powershell(part)}'" for part in command_argv]
|
|
1114
|
+
args_lines: list[str] = []
|
|
1115
|
+
prompt_expression = f"(Get-Content -Raw -LiteralPath '{_single_quote_for_powershell(str(prompt_path))}')"
|
|
1116
|
+
for piece in adapter.invoke_template:
|
|
1117
|
+
if "{prompt}" in piece:
|
|
1118
|
+
before, after = piece.split("{prompt}", 1)
|
|
1119
|
+
expression_parts: list[str] = []
|
|
1120
|
+
if before:
|
|
1121
|
+
formatted_before = before.format(model=model, prompt_file=str(prompt_path), prompt="", repo_root=str(repo_root))
|
|
1122
|
+
expression_parts.append(f"'{_single_quote_for_powershell(formatted_before)}'")
|
|
1123
|
+
expression_parts.append(prompt_expression)
|
|
1124
|
+
if after:
|
|
1125
|
+
formatted_after = after.format(model=model, prompt_file=str(prompt_path), prompt="", repo_root=str(repo_root))
|
|
1126
|
+
expression_parts.append(f"'{_single_quote_for_powershell(formatted_after)}'")
|
|
1127
|
+
if len(expression_parts) == 1:
|
|
1128
|
+
args_lines.append(f" {expression_parts[0]}")
|
|
1129
|
+
else:
|
|
1130
|
+
args_lines.append(f" ({' + '.join(expression_parts)})")
|
|
1131
|
+
continue
|
|
1132
|
+
formatted = piece.format(model=model, prompt_file=str(prompt_path), prompt="", repo_root=str(repo_root))
|
|
1133
|
+
args_lines.append(f" '{_single_quote_for_powershell(formatted)}'")
|
|
1134
|
+
script = "\n".join(
|
|
1135
|
+
[
|
|
1136
|
+
'$ErrorActionPreference = "Stop"',
|
|
1137
|
+
"$command = @(",
|
|
1138
|
+
*command_lines,
|
|
1139
|
+
")",
|
|
1140
|
+
"if ($command.Length -gt 1) {",
|
|
1141
|
+
" $commandPrefix = $command[1..($command.Length - 1)]",
|
|
1142
|
+
"} else {",
|
|
1143
|
+
" $commandPrefix = @()",
|
|
1144
|
+
"}",
|
|
1145
|
+
"$argsList = @(",
|
|
1146
|
+
*args_lines,
|
|
1147
|
+
")",
|
|
1148
|
+
"& $command[0] @commandPrefix @argsList",
|
|
1149
|
+
"exit $LASTEXITCODE",
|
|
1150
|
+
]
|
|
1151
|
+
)
|
|
1152
|
+
script_path.write_text(script, encoding="utf-8", newline="\n")
|
|
1153
|
+
return _run_command([shell_path, "-NoProfile", "-File", str(script_path)], timeout_ms=timeout_ms, cwd=repo_root)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _invoke_codex_app_server(
|
|
1157
|
+
*,
|
|
1158
|
+
command_argv: list[str],
|
|
1159
|
+
repo_root: Path,
|
|
1160
|
+
model: str,
|
|
1161
|
+
prompt: str,
|
|
1162
|
+
timeout_ms: int,
|
|
1163
|
+
) -> dict[str, object]:
|
|
1164
|
+
process = subprocess.Popen(
|
|
1165
|
+
[*command_argv, "app-server", "--listen", "stdio://"],
|
|
1166
|
+
cwd=str(repo_root),
|
|
1167
|
+
stdin=subprocess.PIPE,
|
|
1168
|
+
stdout=subprocess.PIPE,
|
|
1169
|
+
stderr=subprocess.STDOUT,
|
|
1170
|
+
text=True,
|
|
1171
|
+
bufsize=1,
|
|
1172
|
+
)
|
|
1173
|
+
if process.stdin is None or process.stdout is None:
|
|
1174
|
+
try:
|
|
1175
|
+
process.terminate()
|
|
1176
|
+
except OSError:
|
|
1177
|
+
pass
|
|
1178
|
+
return {
|
|
1179
|
+
"success": False,
|
|
1180
|
+
"transport": "app-server",
|
|
1181
|
+
"exit_code": None,
|
|
1182
|
+
"stdout": "",
|
|
1183
|
+
"stderr": "",
|
|
1184
|
+
"reason": "Failed to open Codex app-server stdio streams.",
|
|
1185
|
+
"transcript": "",
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
events: "queue.Queue[str | None]" = queue.Queue()
|
|
1189
|
+
transcript_lines: list[str] = []
|
|
1190
|
+
|
|
1191
|
+
def reader() -> None:
|
|
1192
|
+
try:
|
|
1193
|
+
for raw_line in process.stdout:
|
|
1194
|
+
events.put(raw_line.rstrip("\r\n"))
|
|
1195
|
+
finally:
|
|
1196
|
+
events.put(None)
|
|
1197
|
+
|
|
1198
|
+
reader_thread = threading.Thread(target=reader, daemon=True)
|
|
1199
|
+
reader_thread.start()
|
|
1200
|
+
|
|
1201
|
+
def send(message: dict[str, object]) -> None:
|
|
1202
|
+
process.stdin.write(json.dumps(message) + "\n")
|
|
1203
|
+
process.stdin.flush()
|
|
1204
|
+
|
|
1205
|
+
def read_until(predicate) -> dict[str, object]:
|
|
1206
|
+
deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
|
|
1207
|
+
while time.monotonic() < deadline:
|
|
1208
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
1209
|
+
try:
|
|
1210
|
+
raw = events.get(timeout=remaining)
|
|
1211
|
+
except queue.Empty as exc:
|
|
1212
|
+
raise TimeoutError from exc
|
|
1213
|
+
if raw is None:
|
|
1214
|
+
break
|
|
1215
|
+
transcript_lines.append(raw)
|
|
1216
|
+
try:
|
|
1217
|
+
payload = json.loads(raw)
|
|
1218
|
+
except json.JSONDecodeError:
|
|
1219
|
+
continue
|
|
1220
|
+
if predicate(payload):
|
|
1221
|
+
return payload
|
|
1222
|
+
raise TimeoutError
|
|
1223
|
+
|
|
1224
|
+
final_message = ""
|
|
1225
|
+
try:
|
|
1226
|
+
send(
|
|
1227
|
+
{
|
|
1228
|
+
"id": "initialize",
|
|
1229
|
+
"method": "initialize",
|
|
1230
|
+
"params": {
|
|
1231
|
+
"clientInfo": {
|
|
1232
|
+
"name": ROUTER_NAME,
|
|
1233
|
+
"title": ROUTER_NAME,
|
|
1234
|
+
"version": "1.0",
|
|
1235
|
+
},
|
|
1236
|
+
"capabilities": {"experimentalApi": True},
|
|
1237
|
+
},
|
|
1238
|
+
}
|
|
1239
|
+
)
|
|
1240
|
+
read_until(lambda payload: payload.get("id") == "initialize")
|
|
1241
|
+
send({"method": "initialized"})
|
|
1242
|
+
send(
|
|
1243
|
+
{
|
|
1244
|
+
"id": "thread-start",
|
|
1245
|
+
"method": "thread/start",
|
|
1246
|
+
"params": {
|
|
1247
|
+
"model": model,
|
|
1248
|
+
"approvalPolicy": "never",
|
|
1249
|
+
"sandbox": "read-only",
|
|
1250
|
+
"cwd": str(repo_root),
|
|
1251
|
+
},
|
|
1252
|
+
}
|
|
1253
|
+
)
|
|
1254
|
+
thread_start = read_until(lambda payload: payload.get("id") == "thread-start")
|
|
1255
|
+
if isinstance(thread_start.get("error"), dict):
|
|
1256
|
+
message = thread_start["error"].get("message") or repr(thread_start["error"])
|
|
1257
|
+
return {
|
|
1258
|
+
"success": False,
|
|
1259
|
+
"transport": "app-server",
|
|
1260
|
+
"exit_code": None,
|
|
1261
|
+
"stdout": "",
|
|
1262
|
+
"stderr": "",
|
|
1263
|
+
"reason": f"Codex thread/start failed: {message}",
|
|
1264
|
+
"transcript": "\n".join(transcript_lines),
|
|
1265
|
+
}
|
|
1266
|
+
thread_id = str(_as_dict(thread_start["result"], "thread/start result")["thread"]["id"])
|
|
1267
|
+
|
|
1268
|
+
send(
|
|
1269
|
+
{
|
|
1270
|
+
"id": "turn-start",
|
|
1271
|
+
"method": "turn/start",
|
|
1272
|
+
"params": {
|
|
1273
|
+
"threadId": thread_id,
|
|
1274
|
+
"model": model,
|
|
1275
|
+
"input": [{"type": "text", "text": prompt, "text_elements": []}],
|
|
1276
|
+
},
|
|
1277
|
+
}
|
|
1278
|
+
)
|
|
1279
|
+
turn_start = read_until(lambda payload: payload.get("id") == "turn-start")
|
|
1280
|
+
if isinstance(turn_start.get("error"), dict):
|
|
1281
|
+
message = turn_start["error"].get("message") or repr(turn_start["error"])
|
|
1282
|
+
return {
|
|
1283
|
+
"success": False,
|
|
1284
|
+
"transport": "app-server",
|
|
1285
|
+
"exit_code": None,
|
|
1286
|
+
"stdout": "",
|
|
1287
|
+
"stderr": "",
|
|
1288
|
+
"reason": f"Codex turn/start failed: {message}",
|
|
1289
|
+
"transcript": "\n".join(transcript_lines),
|
|
1290
|
+
}
|
|
1291
|
+
turn_id = str(_as_dict(turn_start["result"], "turn/start result")["turn"]["id"])
|
|
1292
|
+
|
|
1293
|
+
deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
|
|
1294
|
+
while time.monotonic() < deadline:
|
|
1295
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
1296
|
+
try:
|
|
1297
|
+
raw = events.get(timeout=remaining)
|
|
1298
|
+
except queue.Empty as exc:
|
|
1299
|
+
raise TimeoutError from exc
|
|
1300
|
+
if raw is None:
|
|
1301
|
+
break
|
|
1302
|
+
transcript_lines.append(raw)
|
|
1303
|
+
try:
|
|
1304
|
+
payload = json.loads(raw)
|
|
1305
|
+
except json.JSONDecodeError:
|
|
1306
|
+
continue
|
|
1307
|
+
if payload.get("method") == "item/completed":
|
|
1308
|
+
params = payload.get("params", {})
|
|
1309
|
+
item = params.get("item", {})
|
|
1310
|
+
if (
|
|
1311
|
+
params.get("threadId") == thread_id
|
|
1312
|
+
and params.get("turnId") == turn_id
|
|
1313
|
+
and item.get("type") == "agentMessage"
|
|
1314
|
+
and item.get("phase") == "final_answer"
|
|
1315
|
+
):
|
|
1316
|
+
final_message = str(item.get("text", ""))
|
|
1317
|
+
if payload.get("method") == "turn/completed":
|
|
1318
|
+
params = payload.get("params", {})
|
|
1319
|
+
turn = params.get("turn", {})
|
|
1320
|
+
if params.get("threadId") == thread_id and turn.get("id") == turn_id:
|
|
1321
|
+
return {
|
|
1322
|
+
"success": bool(final_message.strip()),
|
|
1323
|
+
"transport": "app-server",
|
|
1324
|
+
"exit_code": 0,
|
|
1325
|
+
"stdout": final_message,
|
|
1326
|
+
"stderr": "",
|
|
1327
|
+
"reason": "Prompt completed via Codex app-server." if final_message.strip() else "Codex returned no final answer text.",
|
|
1328
|
+
"transcript": "\n".join(transcript_lines),
|
|
1329
|
+
}
|
|
1330
|
+
raise TimeoutError
|
|
1331
|
+
except TimeoutError:
|
|
1332
|
+
return {
|
|
1333
|
+
"success": False,
|
|
1334
|
+
"transport": "app-server",
|
|
1335
|
+
"exit_code": None,
|
|
1336
|
+
"stdout": "",
|
|
1337
|
+
"stderr": "",
|
|
1338
|
+
"reason": f"Codex app-server invocation timed out after {timeout_ms}ms.",
|
|
1339
|
+
"transcript": "\n".join(transcript_lines),
|
|
1340
|
+
}
|
|
1341
|
+
finally:
|
|
1342
|
+
try:
|
|
1343
|
+
process.terminate()
|
|
1344
|
+
except OSError:
|
|
1345
|
+
pass
|
|
1346
|
+
try:
|
|
1347
|
+
process.wait(timeout=5)
|
|
1348
|
+
except subprocess.TimeoutExpired:
|
|
1349
|
+
process.kill()
|
|
1350
|
+
process.wait(timeout=5)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _verify_codex_app_server(
|
|
1354
|
+
*,
|
|
1355
|
+
command_argv: list[str],
|
|
1356
|
+
repo_root: Path,
|
|
1357
|
+
model: str,
|
|
1358
|
+
prompt: str,
|
|
1359
|
+
timeout_ms: int,
|
|
1360
|
+
) -> dict[str, object]:
|
|
1361
|
+
process = subprocess.Popen(
|
|
1362
|
+
[*command_argv, "app-server", "--listen", "stdio://"],
|
|
1363
|
+
cwd=str(repo_root),
|
|
1364
|
+
stdin=subprocess.PIPE,
|
|
1365
|
+
stdout=subprocess.PIPE,
|
|
1366
|
+
stderr=subprocess.STDOUT,
|
|
1367
|
+
text=True,
|
|
1368
|
+
bufsize=1,
|
|
1369
|
+
)
|
|
1370
|
+
if process.stdin is None or process.stdout is None:
|
|
1371
|
+
try:
|
|
1372
|
+
process.terminate()
|
|
1373
|
+
except OSError:
|
|
1374
|
+
pass
|
|
1375
|
+
return {
|
|
1376
|
+
"verified": False,
|
|
1377
|
+
"transport": "app-server",
|
|
1378
|
+
"exit_code": None,
|
|
1379
|
+
"token_found": False,
|
|
1380
|
+
"stdout": "",
|
|
1381
|
+
"stderr": "",
|
|
1382
|
+
"reason": "Failed to open Codex app-server stdio streams.",
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
events: "queue.Queue[str | None]" = queue.Queue()
|
|
1386
|
+
transcript_lines: list[str] = []
|
|
1387
|
+
|
|
1388
|
+
def reader() -> None:
|
|
1389
|
+
try:
|
|
1390
|
+
for raw_line in process.stdout:
|
|
1391
|
+
events.put(raw_line.rstrip("\r\n"))
|
|
1392
|
+
finally:
|
|
1393
|
+
events.put(None)
|
|
1394
|
+
|
|
1395
|
+
reader_thread = threading.Thread(target=reader, daemon=True)
|
|
1396
|
+
reader_thread.start()
|
|
1397
|
+
|
|
1398
|
+
def send(message: dict[str, object]) -> None:
|
|
1399
|
+
process.stdin.write(json.dumps(message) + "\n")
|
|
1400
|
+
process.stdin.flush()
|
|
1401
|
+
|
|
1402
|
+
def read_until(predicate) -> dict[str, object]:
|
|
1403
|
+
deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
|
|
1404
|
+
while time.monotonic() < deadline:
|
|
1405
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
1406
|
+
try:
|
|
1407
|
+
raw = events.get(timeout=remaining)
|
|
1408
|
+
except queue.Empty as exc:
|
|
1409
|
+
raise TimeoutError from exc
|
|
1410
|
+
if raw is None:
|
|
1411
|
+
break
|
|
1412
|
+
transcript_lines.append(raw)
|
|
1413
|
+
try:
|
|
1414
|
+
payload = json.loads(raw)
|
|
1415
|
+
except json.JSONDecodeError:
|
|
1416
|
+
continue
|
|
1417
|
+
if predicate(payload):
|
|
1418
|
+
return payload
|
|
1419
|
+
raise TimeoutError
|
|
1420
|
+
|
|
1421
|
+
final_message = ""
|
|
1422
|
+
try:
|
|
1423
|
+
send(
|
|
1424
|
+
{
|
|
1425
|
+
"id": "initialize",
|
|
1426
|
+
"method": "initialize",
|
|
1427
|
+
"params": {
|
|
1428
|
+
"clientInfo": {
|
|
1429
|
+
"name": ROUTER_NAME,
|
|
1430
|
+
"title": ROUTER_NAME,
|
|
1431
|
+
"version": "1.0",
|
|
1432
|
+
},
|
|
1433
|
+
"capabilities": {"experimentalApi": True},
|
|
1434
|
+
},
|
|
1435
|
+
}
|
|
1436
|
+
)
|
|
1437
|
+
read_until(lambda payload: payload.get("id") == "initialize")
|
|
1438
|
+
send({"method": "initialized"})
|
|
1439
|
+
send(
|
|
1440
|
+
{
|
|
1441
|
+
"id": "thread-start",
|
|
1442
|
+
"method": "thread/start",
|
|
1443
|
+
"params": {
|
|
1444
|
+
"model": model,
|
|
1445
|
+
"approvalPolicy": "never",
|
|
1446
|
+
"sandbox": "read-only",
|
|
1447
|
+
"cwd": str(repo_root),
|
|
1448
|
+
},
|
|
1449
|
+
}
|
|
1450
|
+
)
|
|
1451
|
+
thread_start = read_until(lambda payload: payload.get("id") == "thread-start")
|
|
1452
|
+
if isinstance(thread_start.get("error"), dict):
|
|
1453
|
+
message = thread_start["error"].get("message") or repr(thread_start["error"])
|
|
1454
|
+
return {
|
|
1455
|
+
"verified": False,
|
|
1456
|
+
"transport": "app-server",
|
|
1457
|
+
"exit_code": None,
|
|
1458
|
+
"token_found": False,
|
|
1459
|
+
"stdout": "\n".join(transcript_lines),
|
|
1460
|
+
"stderr": "",
|
|
1461
|
+
"reason": f"Codex thread/start failed: {message}",
|
|
1462
|
+
}
|
|
1463
|
+
thread_id = str(_as_dict(thread_start["result"], "thread/start result")["thread"]["id"])
|
|
1464
|
+
|
|
1465
|
+
send(
|
|
1466
|
+
{
|
|
1467
|
+
"id": "turn-start",
|
|
1468
|
+
"method": "turn/start",
|
|
1469
|
+
"params": {
|
|
1470
|
+
"threadId": thread_id,
|
|
1471
|
+
"model": model,
|
|
1472
|
+
"input": [{"type": "text", "text": prompt, "text_elements": []}],
|
|
1473
|
+
},
|
|
1474
|
+
}
|
|
1475
|
+
)
|
|
1476
|
+
turn_start = read_until(lambda payload: payload.get("id") == "turn-start")
|
|
1477
|
+
if isinstance(turn_start.get("error"), dict):
|
|
1478
|
+
message = turn_start["error"].get("message") or repr(turn_start["error"])
|
|
1479
|
+
return {
|
|
1480
|
+
"verified": False,
|
|
1481
|
+
"transport": "app-server",
|
|
1482
|
+
"exit_code": None,
|
|
1483
|
+
"token_found": False,
|
|
1484
|
+
"stdout": "\n".join(transcript_lines),
|
|
1485
|
+
"stderr": "",
|
|
1486
|
+
"reason": f"Codex turn/start failed: {message}",
|
|
1487
|
+
}
|
|
1488
|
+
turn_id = str(_as_dict(turn_start["result"], "turn/start result")["turn"]["id"])
|
|
1489
|
+
|
|
1490
|
+
deadline = time.monotonic() + max(0.5, timeout_ms / 1000.0)
|
|
1491
|
+
while time.monotonic() < deadline:
|
|
1492
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
1493
|
+
try:
|
|
1494
|
+
raw = events.get(timeout=remaining)
|
|
1495
|
+
except queue.Empty as exc:
|
|
1496
|
+
raise TimeoutError from exc
|
|
1497
|
+
if raw is None:
|
|
1498
|
+
break
|
|
1499
|
+
transcript_lines.append(raw)
|
|
1500
|
+
try:
|
|
1501
|
+
payload = json.loads(raw)
|
|
1502
|
+
except json.JSONDecodeError:
|
|
1503
|
+
continue
|
|
1504
|
+
if payload.get("method") == "item/completed":
|
|
1505
|
+
params = payload.get("params", {})
|
|
1506
|
+
item = params.get("item", {})
|
|
1507
|
+
if (
|
|
1508
|
+
params.get("threadId") == thread_id
|
|
1509
|
+
and params.get("turnId") == turn_id
|
|
1510
|
+
and item.get("type") == "agentMessage"
|
|
1511
|
+
and item.get("phase") == "final_answer"
|
|
1512
|
+
):
|
|
1513
|
+
final_message = str(item.get("text", ""))
|
|
1514
|
+
if payload.get("method") == "turn/completed":
|
|
1515
|
+
params = payload.get("params", {})
|
|
1516
|
+
turn = params.get("turn", {})
|
|
1517
|
+
if params.get("threadId") == thread_id and turn.get("id") == turn_id:
|
|
1518
|
+
transcript = "\n".join(transcript_lines)
|
|
1519
|
+
token = prompt.rsplit(":", 1)[-1].strip()
|
|
1520
|
+
token_found = _token_found(token, final_message, transcript)
|
|
1521
|
+
return {
|
|
1522
|
+
"verified": token_found,
|
|
1523
|
+
"transport": "app-server",
|
|
1524
|
+
"exit_code": 0,
|
|
1525
|
+
"token_found": token_found,
|
|
1526
|
+
"stdout": final_message,
|
|
1527
|
+
"stderr": "",
|
|
1528
|
+
"reason": "Verification prompt completed via Codex app-server." if token_found else "Verification token not found in Codex response.",
|
|
1529
|
+
"transcript": transcript,
|
|
1530
|
+
}
|
|
1531
|
+
raise TimeoutError
|
|
1532
|
+
except TimeoutError:
|
|
1533
|
+
transcript = "\n".join(transcript_lines)
|
|
1534
|
+
return {
|
|
1535
|
+
"verified": False,
|
|
1536
|
+
"transport": "app-server",
|
|
1537
|
+
"exit_code": None,
|
|
1538
|
+
"token_found": False,
|
|
1539
|
+
"stdout": transcript,
|
|
1540
|
+
"stderr": "",
|
|
1541
|
+
"reason": f"Codex app-server verification timed out after {timeout_ms}ms.",
|
|
1542
|
+
}
|
|
1543
|
+
finally:
|
|
1544
|
+
try:
|
|
1545
|
+
process.terminate()
|
|
1546
|
+
except OSError:
|
|
1547
|
+
pass
|
|
1548
|
+
try:
|
|
1549
|
+
exit_code = process.wait(timeout=5)
|
|
1550
|
+
except subprocess.TimeoutExpired:
|
|
1551
|
+
process.kill()
|
|
1552
|
+
exit_code = process.wait(timeout=5)
|
|
1553
|
+
# Preserve a best-effort process exit code in the transcript for callers that need it.
|
|
1554
|
+
transcript_lines.append(f"[process-exit:{exit_code}]")
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def verify_route_binding(
|
|
1558
|
+
repo_root: Path,
|
|
1559
|
+
*,
|
|
1560
|
+
role: str,
|
|
1561
|
+
cli_id: str,
|
|
1562
|
+
model: str,
|
|
1563
|
+
timeout_ms: int | None = None,
|
|
1564
|
+
policy: dict[str, object] | None = None,
|
|
1565
|
+
) -> dict[str, object]:
|
|
1566
|
+
effective_policy = load_policy(repo_root) if policy is None else policy
|
|
1567
|
+
resolved_role = canonicalize_router_role(role)
|
|
1568
|
+
if not model.strip():
|
|
1569
|
+
raise RouterConfigError("Configured model must be a non-empty string.")
|
|
1570
|
+
|
|
1571
|
+
adapter = adapter_by_id(effective_policy, cli_id)
|
|
1572
|
+
if adapter is None:
|
|
1573
|
+
raise RouterConfigError(f"Unknown CLI id: {cli_id}")
|
|
1574
|
+
|
|
1575
|
+
effective_timeout_ms = _probe_timeout_ms_from_policy(effective_policy) if timeout_ms is None else timeout_ms
|
|
1576
|
+
command_argv = resolve_command_argv(adapter.command)
|
|
1577
|
+
command_path = command_argv[0] if command_argv else None
|
|
1578
|
+
token = f"ROUTER_VERIFY_{uuid.uuid4().hex[:12].upper()}"
|
|
1579
|
+
prompt = _verification_prompt(token)
|
|
1580
|
+
result: dict[str, object] = {
|
|
1581
|
+
"role": resolved_role,
|
|
1582
|
+
"cli": cli_id,
|
|
1583
|
+
"model": model,
|
|
1584
|
+
"transport": adapter.transport,
|
|
1585
|
+
"verified": False,
|
|
1586
|
+
"token": token,
|
|
1587
|
+
"token_found": False,
|
|
1588
|
+
"exit_code": None,
|
|
1589
|
+
"stdout": "",
|
|
1590
|
+
"stderr": "",
|
|
1591
|
+
"reason": "",
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if command_path is None:
|
|
1595
|
+
result["reason"] = f"Executable for CLI {cli_id!r} was not found."
|
|
1596
|
+
return result
|
|
1597
|
+
|
|
1598
|
+
builtin_adapter = BUILTIN_ADAPTER_BY_ID.get(adapter.id)
|
|
1599
|
+
template_overridden = builtin_adapter is not None and adapter.invoke_template != builtin_adapter.invoke_template
|
|
1600
|
+
transport_overridden = builtin_adapter is not None and adapter.transport != builtin_adapter.transport
|
|
1601
|
+
|
|
1602
|
+
if adapter.id == "codex" and adapter.transport == "app-server":
|
|
1603
|
+
completed = _run_command(
|
|
1604
|
+
[
|
|
1605
|
+
*command_argv,
|
|
1606
|
+
"exec",
|
|
1607
|
+
"--model",
|
|
1608
|
+
model,
|
|
1609
|
+
"--json",
|
|
1610
|
+
"--skip-git-repo-check",
|
|
1611
|
+
"--ephemeral",
|
|
1612
|
+
"-c",
|
|
1613
|
+
"mcp_servers.playwright.enabled=false",
|
|
1614
|
+
"-c",
|
|
1615
|
+
"mcp_servers.subframe.enabled=false",
|
|
1616
|
+
"-c",
|
|
1617
|
+
'mcp_servers["subframe-docs"].enabled=false',
|
|
1618
|
+
"-c",
|
|
1619
|
+
"mcp_servers.pencil.enabled=false",
|
|
1620
|
+
"-C",
|
|
1621
|
+
str(repo_root),
|
|
1622
|
+
prompt,
|
|
1623
|
+
],
|
|
1624
|
+
timeout_ms=effective_timeout_ms,
|
|
1625
|
+
cwd=repo_root,
|
|
1626
|
+
)
|
|
1627
|
+
token_found = _token_found(token, completed.stdout, completed.stderr)
|
|
1628
|
+
verification = {
|
|
1629
|
+
"verified": token_found and completed.returncode == 0,
|
|
1630
|
+
"transport": adapter.transport,
|
|
1631
|
+
"exit_code": completed.returncode,
|
|
1632
|
+
"token_found": token_found,
|
|
1633
|
+
"stdout": completed.stdout,
|
|
1634
|
+
"stderr": completed.stderr,
|
|
1635
|
+
"reason": (
|
|
1636
|
+
"Verification token observed in Codex exec output."
|
|
1637
|
+
if token_found and completed.returncode == 0
|
|
1638
|
+
else f"Codex verification failed with exit code {completed.returncode}."
|
|
1639
|
+
),
|
|
1640
|
+
}
|
|
1641
|
+
elif adapter.id == "kimi" and not template_overridden and not transport_overridden:
|
|
1642
|
+
completed = _run_command(
|
|
1643
|
+
[
|
|
1644
|
+
*command_argv,
|
|
1645
|
+
"--model",
|
|
1646
|
+
model,
|
|
1647
|
+
"--work-dir",
|
|
1648
|
+
str(repo_root),
|
|
1649
|
+
"--print",
|
|
1650
|
+
"--output-format",
|
|
1651
|
+
"stream-json",
|
|
1652
|
+
"--max-ralph-iterations",
|
|
1653
|
+
"0",
|
|
1654
|
+
"--prompt",
|
|
1655
|
+
prompt,
|
|
1656
|
+
],
|
|
1657
|
+
timeout_ms=effective_timeout_ms,
|
|
1658
|
+
cwd=repo_root,
|
|
1659
|
+
)
|
|
1660
|
+
combined = (completed.stdout or "") + "\n" + (completed.stderr or "")
|
|
1661
|
+
token_found = _token_found(token, completed.stdout, completed.stderr)
|
|
1662
|
+
verification = {
|
|
1663
|
+
"verified": token_found,
|
|
1664
|
+
"transport": adapter.transport,
|
|
1665
|
+
"exit_code": completed.returncode,
|
|
1666
|
+
"token_found": token_found,
|
|
1667
|
+
"stdout": completed.stdout,
|
|
1668
|
+
"stderr": completed.stderr,
|
|
1669
|
+
"reason": (
|
|
1670
|
+
"Verification token observed in Kimi output."
|
|
1671
|
+
if token_found
|
|
1672
|
+
else f"Kimi verification failed with exit code {completed.returncode}."
|
|
1673
|
+
),
|
|
1674
|
+
"combined_output": combined,
|
|
1675
|
+
}
|
|
1676
|
+
elif adapter.id == "opencode" and not template_overridden and not transport_overridden:
|
|
1677
|
+
completed = _run_command(
|
|
1678
|
+
[
|
|
1679
|
+
*command_argv,
|
|
1680
|
+
"run",
|
|
1681
|
+
"--model",
|
|
1682
|
+
model,
|
|
1683
|
+
"--format",
|
|
1684
|
+
"json",
|
|
1685
|
+
"--dir",
|
|
1686
|
+
str(repo_root),
|
|
1687
|
+
prompt,
|
|
1688
|
+
],
|
|
1689
|
+
timeout_ms=effective_timeout_ms,
|
|
1690
|
+
cwd=repo_root,
|
|
1691
|
+
)
|
|
1692
|
+
token_found = _token_found(token, completed.stdout, completed.stderr)
|
|
1693
|
+
verification = {
|
|
1694
|
+
"verified": token_found and completed.returncode == 0,
|
|
1695
|
+
"transport": adapter.transport,
|
|
1696
|
+
"exit_code": completed.returncode,
|
|
1697
|
+
"token_found": token_found,
|
|
1698
|
+
"stdout": completed.stdout,
|
|
1699
|
+
"stderr": completed.stderr,
|
|
1700
|
+
"reason": (
|
|
1701
|
+
"Verification token observed in Opencode output."
|
|
1702
|
+
if token_found and completed.returncode == 0
|
|
1703
|
+
else f"Opencode verification failed with exit code {completed.returncode}."
|
|
1704
|
+
),
|
|
1705
|
+
}
|
|
1706
|
+
else:
|
|
1707
|
+
completed = _run_template_invocation(
|
|
1708
|
+
command_argv=command_argv,
|
|
1709
|
+
adapter=adapter,
|
|
1710
|
+
repo_root=repo_root,
|
|
1711
|
+
model=model,
|
|
1712
|
+
prompt=prompt,
|
|
1713
|
+
timeout_ms=effective_timeout_ms,
|
|
1714
|
+
)
|
|
1715
|
+
token_found = _token_found(token, completed.stdout, completed.stderr)
|
|
1716
|
+
verification = {
|
|
1717
|
+
"verified": token_found and completed.returncode == 0,
|
|
1718
|
+
"transport": adapter.transport,
|
|
1719
|
+
"exit_code": completed.returncode,
|
|
1720
|
+
"token_found": token_found,
|
|
1721
|
+
"stdout": completed.stdout,
|
|
1722
|
+
"stderr": completed.stderr,
|
|
1723
|
+
"reason": (
|
|
1724
|
+
f"Verification token observed in {cli_id} output."
|
|
1725
|
+
if token_found and completed.returncode == 0
|
|
1726
|
+
else f"{cli_id} verification failed with exit code {completed.returncode}."
|
|
1727
|
+
),
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
result.update(verification)
|
|
1731
|
+
return result
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def invoke_route_binding(
|
|
1735
|
+
repo_root: Path,
|
|
1736
|
+
*,
|
|
1737
|
+
role: str,
|
|
1738
|
+
prompt: str,
|
|
1739
|
+
timeout_ms: int | None = None,
|
|
1740
|
+
policy: dict[str, object] | None = None,
|
|
1741
|
+
write_discovery: bool = True,
|
|
1742
|
+
) -> dict[str, object]:
|
|
1743
|
+
effective_policy = load_policy(repo_root) if policy is None else policy
|
|
1744
|
+
resolved_role = canonicalize_router_role(role)
|
|
1745
|
+
decision = resolve_route(repo_root, role=resolved_role, timeout_ms=timeout_ms, write_discovery=write_discovery)
|
|
1746
|
+
effective_timeout_ms = _invoke_timeout_ms_from_policy(effective_policy) if timeout_ms is None else timeout_ms
|
|
1747
|
+
payload: dict[str, object] = {
|
|
1748
|
+
"role": resolved_role,
|
|
1749
|
+
"decision": decision,
|
|
1750
|
+
"cli": decision.get("cli"),
|
|
1751
|
+
"model": decision.get("model"),
|
|
1752
|
+
"transport": decision.get("transport"),
|
|
1753
|
+
"resolved_command": None,
|
|
1754
|
+
"output_text": "",
|
|
1755
|
+
"raw_stdout": "",
|
|
1756
|
+
"raw_stderr": "",
|
|
1757
|
+
"exit_code": None,
|
|
1758
|
+
"success": False,
|
|
1759
|
+
"reason": "",
|
|
1760
|
+
"transcript": "",
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if decision.get("decision") != "external-cli":
|
|
1764
|
+
payload["reason"] = f"Route did not resolve to external-cli: {decision.get('decision')}"
|
|
1765
|
+
return payload
|
|
1766
|
+
|
|
1767
|
+
cli_id = str(decision["cli"])
|
|
1768
|
+
model = str(decision["model"])
|
|
1769
|
+
adapter = adapter_by_id(effective_policy, cli_id)
|
|
1770
|
+
if adapter is None:
|
|
1771
|
+
raise RouterConfigError(f"Unknown CLI id: {cli_id}")
|
|
1772
|
+
command_argv = resolve_command_argv(adapter.command)
|
|
1773
|
+
command_path = command_argv[0] if command_argv else None
|
|
1774
|
+
payload["resolved_command"] = _command_display(tuple(command_argv)) if command_argv and len(command_argv) > 1 else command_path
|
|
1775
|
+
if command_path is None:
|
|
1776
|
+
payload["reason"] = f"Executable for CLI {cli_id!r} was not found."
|
|
1777
|
+
return payload
|
|
1778
|
+
|
|
1779
|
+
builtin_adapter = BUILTIN_ADAPTER_BY_ID.get(adapter.id)
|
|
1780
|
+
template_overridden = builtin_adapter is not None and adapter.invoke_template != builtin_adapter.invoke_template
|
|
1781
|
+
transport_overridden = builtin_adapter is not None and adapter.transport != builtin_adapter.transport
|
|
1782
|
+
|
|
1783
|
+
if adapter.id == "codex" and adapter.transport == "app-server":
|
|
1784
|
+
invocation = _invoke_codex_app_server(
|
|
1785
|
+
command_argv=command_argv,
|
|
1786
|
+
repo_root=repo_root,
|
|
1787
|
+
model=model,
|
|
1788
|
+
prompt=prompt,
|
|
1789
|
+
timeout_ms=effective_timeout_ms,
|
|
1790
|
+
)
|
|
1791
|
+
payload.update(
|
|
1792
|
+
{
|
|
1793
|
+
"transport": invocation["transport"],
|
|
1794
|
+
"output_text": str(invocation["stdout"]).strip(),
|
|
1795
|
+
"raw_stdout": str(invocation["stdout"]),
|
|
1796
|
+
"raw_stderr": str(invocation["stderr"]),
|
|
1797
|
+
"exit_code": invocation["exit_code"],
|
|
1798
|
+
"success": invocation["success"],
|
|
1799
|
+
"reason": invocation["reason"],
|
|
1800
|
+
"transcript": invocation["transcript"],
|
|
1801
|
+
}
|
|
1802
|
+
)
|
|
1803
|
+
return payload
|
|
1804
|
+
|
|
1805
|
+
if adapter.id == "kimi" and not template_overridden and not transport_overridden:
|
|
1806
|
+
completed = _run_command(
|
|
1807
|
+
[
|
|
1808
|
+
*command_argv,
|
|
1809
|
+
"--model",
|
|
1810
|
+
model,
|
|
1811
|
+
"--work-dir",
|
|
1812
|
+
str(repo_root),
|
|
1813
|
+
"--print",
|
|
1814
|
+
"--output-format",
|
|
1815
|
+
"stream-json",
|
|
1816
|
+
"--max-ralph-iterations",
|
|
1817
|
+
"0",
|
|
1818
|
+
"--prompt",
|
|
1819
|
+
prompt,
|
|
1820
|
+
],
|
|
1821
|
+
timeout_ms=effective_timeout_ms,
|
|
1822
|
+
cwd=repo_root,
|
|
1823
|
+
)
|
|
1824
|
+
output_text = _normalize_kimi_output(completed.stdout)
|
|
1825
|
+
payload.update(
|
|
1826
|
+
{
|
|
1827
|
+
"output_text": output_text,
|
|
1828
|
+
"raw_stdout": completed.stdout,
|
|
1829
|
+
"raw_stderr": _strip_ansi(completed.stderr),
|
|
1830
|
+
"exit_code": completed.returncode,
|
|
1831
|
+
"success": bool(output_text) and completed.returncode == 0,
|
|
1832
|
+
"reason": (
|
|
1833
|
+
"Prompt completed through Kimi."
|
|
1834
|
+
if output_text and completed.returncode == 0
|
|
1835
|
+
else f"Kimi returned output with exit code {completed.returncode}."
|
|
1836
|
+
if output_text
|
|
1837
|
+
else f"Kimi invocation failed with exit code {completed.returncode}."
|
|
1838
|
+
),
|
|
1839
|
+
}
|
|
1840
|
+
)
|
|
1841
|
+
return payload
|
|
1842
|
+
|
|
1843
|
+
if adapter.id == "opencode" and not template_overridden and not transport_overridden:
|
|
1844
|
+
completed = _run_command(
|
|
1845
|
+
[
|
|
1846
|
+
*command_argv,
|
|
1847
|
+
"run",
|
|
1848
|
+
"--model",
|
|
1849
|
+
model,
|
|
1850
|
+
"--format",
|
|
1851
|
+
"json",
|
|
1852
|
+
"--dir",
|
|
1853
|
+
str(repo_root),
|
|
1854
|
+
prompt,
|
|
1855
|
+
],
|
|
1856
|
+
timeout_ms=effective_timeout_ms,
|
|
1857
|
+
cwd=repo_root,
|
|
1858
|
+
)
|
|
1859
|
+
output_text = _normalize_opencode_output(completed.stdout)
|
|
1860
|
+
payload.update(
|
|
1861
|
+
{
|
|
1862
|
+
"output_text": output_text,
|
|
1863
|
+
"raw_stdout": completed.stdout,
|
|
1864
|
+
"raw_stderr": _strip_ansi(completed.stderr),
|
|
1865
|
+
"exit_code": completed.returncode,
|
|
1866
|
+
"success": bool(output_text) and completed.returncode == 0,
|
|
1867
|
+
"reason": (
|
|
1868
|
+
"Prompt completed through opencode."
|
|
1869
|
+
if output_text and completed.returncode == 0
|
|
1870
|
+
else f"opencode returned output with exit code {completed.returncode}."
|
|
1871
|
+
if output_text
|
|
1872
|
+
else f"opencode invocation failed with exit code {completed.returncode}."
|
|
1873
|
+
),
|
|
1874
|
+
}
|
|
1875
|
+
)
|
|
1876
|
+
return payload
|
|
1877
|
+
|
|
1878
|
+
completed = _run_template_invocation(
|
|
1879
|
+
command_argv=command_argv,
|
|
1880
|
+
adapter=adapter,
|
|
1881
|
+
repo_root=repo_root,
|
|
1882
|
+
model=model,
|
|
1883
|
+
prompt=prompt,
|
|
1884
|
+
timeout_ms=effective_timeout_ms,
|
|
1885
|
+
)
|
|
1886
|
+
output_text = completed.stdout.strip()
|
|
1887
|
+
payload.update(
|
|
1888
|
+
{
|
|
1889
|
+
"output_text": output_text,
|
|
1890
|
+
"raw_stdout": completed.stdout,
|
|
1891
|
+
"raw_stderr": _strip_ansi(completed.stderr),
|
|
1892
|
+
"exit_code": completed.returncode,
|
|
1893
|
+
"success": bool(output_text),
|
|
1894
|
+
"reason": (
|
|
1895
|
+
f"Prompt completed through {cli_id}."
|
|
1896
|
+
if output_text and completed.returncode == 0
|
|
1897
|
+
else f"{cli_id} returned output with exit code {completed.returncode}."
|
|
1898
|
+
if output_text
|
|
1899
|
+
else f"{cli_id} invocation failed with exit code {completed.returncode}."
|
|
1900
|
+
),
|
|
1901
|
+
}
|
|
1902
|
+
)
|
|
1903
|
+
return payload
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
def configure_verified_routes(
|
|
1907
|
+
repo_root: Path,
|
|
1908
|
+
*,
|
|
1909
|
+
assignments: dict[str, dict[str, str]],
|
|
1910
|
+
timeout_ms: int | None = None,
|
|
1911
|
+
) -> dict[str, object]:
|
|
1912
|
+
policy = load_policy(repo_root)
|
|
1913
|
+
normalized_assignments: dict[str, dict[str, str]] = {}
|
|
1914
|
+
for role, binding in assignments.items():
|
|
1915
|
+
canonical_role = canonicalize_router_role(role)
|
|
1916
|
+
if canonical_role in normalized_assignments:
|
|
1917
|
+
raise RouterConfigError(f"Duplicate role assignment provided for {canonical_role}.")
|
|
1918
|
+
normalized_assignments[canonical_role] = binding
|
|
1919
|
+
verification_results: dict[str, dict[str, object]] = {}
|
|
1920
|
+
verification_cache: dict[tuple[str, str], dict[str, object]] = {}
|
|
1921
|
+
for role, binding in normalized_assignments.items():
|
|
1922
|
+
cli_id = binding["cli"]
|
|
1923
|
+
model = binding["model"]
|
|
1924
|
+
cache_key = (cli_id, model)
|
|
1925
|
+
cached_result = verification_cache.get(cache_key)
|
|
1926
|
+
if cached_result is None:
|
|
1927
|
+
cached_result = verify_route_binding(
|
|
1928
|
+
repo_root,
|
|
1929
|
+
role=role,
|
|
1930
|
+
cli_id=cli_id,
|
|
1931
|
+
model=model,
|
|
1932
|
+
timeout_ms=timeout_ms,
|
|
1933
|
+
policy=policy,
|
|
1934
|
+
)
|
|
1935
|
+
verification_cache[cache_key] = cached_result
|
|
1936
|
+
role_result = deepcopy(cached_result)
|
|
1937
|
+
role_result["role"] = role
|
|
1938
|
+
verification_results[role] = role_result
|
|
1939
|
+
|
|
1940
|
+
all_verified = all(result["verified"] for result in verification_results.values())
|
|
1941
|
+
if all_verified:
|
|
1942
|
+
updated_policy = deepcopy(policy)
|
|
1943
|
+
for role, binding in normalized_assignments.items():
|
|
1944
|
+
route = _as_dict(updated_policy["role_routes"][role], f"role_routes.{role}")
|
|
1945
|
+
route["cli"] = binding["cli"]
|
|
1946
|
+
route["model"] = binding["model"]
|
|
1947
|
+
write_json(router_policy_path(repo_root), validate_policy(updated_policy))
|
|
1948
|
+
|
|
1949
|
+
return {
|
|
1950
|
+
"saved": all_verified,
|
|
1951
|
+
"policy_path": normalize_repo_path(str(router_policy_path(repo_root).relative_to(repo_root))),
|
|
1952
|
+
"verification_results": verification_results,
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def build_unresolved_prompt(*, roles: list[str], inventory: dict[str, object], reason: str, config_path: str, discovery_path: str) -> str:
|
|
1957
|
+
lines = ["I found these CLIs in this environment:"]
|
|
1958
|
+
for entry in inventory["clis"]:
|
|
1959
|
+
version = entry["version"] or "unknown version"
|
|
1960
|
+
models = entry["models"]
|
|
1961
|
+
model_suffix = f"; models: {', '.join(models)}" if models else ""
|
|
1962
|
+
lines.append(f"- {entry['id']} ({'available' if entry['available'] else 'unavailable'}, version {version}{model_suffix})")
|
|
1963
|
+
if len(lines) == 1:
|
|
1964
|
+
lines.append("- none discovered")
|
|
1965
|
+
lines.extend(
|
|
1966
|
+
[
|
|
1967
|
+
"",
|
|
1968
|
+
reason,
|
|
1969
|
+
f"Please choose a CLI and model for these roles in {config_path}.",
|
|
1970
|
+
f"For valid CLI ids and model ids, check {discovery_path}.",
|
|
1971
|
+
"",
|
|
1972
|
+
"Unresolved roles:",
|
|
1973
|
+
]
|
|
1974
|
+
)
|
|
1975
|
+
lines.extend(f"- {role}" for role in roles)
|
|
1976
|
+
lines.extend(
|
|
1977
|
+
[
|
|
1978
|
+
"",
|
|
1979
|
+
"You can answer in a compact mapping like:",
|
|
1980
|
+
"analyst=codex:gpt-5",
|
|
1981
|
+
"code-reviewer=kimi:kimi-code/kimi-for-coding",
|
|
1982
|
+
]
|
|
1983
|
+
)
|
|
1984
|
+
return "\n".join(lines)
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def resolve_route(
|
|
1988
|
+
repo_root: Path,
|
|
1989
|
+
*,
|
|
1990
|
+
role: str,
|
|
1991
|
+
timeout_ms: int | None = None,
|
|
1992
|
+
write_discovery: bool = True,
|
|
1993
|
+
) -> dict[str, object]:
|
|
1994
|
+
resolved_role = canonicalize_router_role(role)
|
|
1995
|
+
config_path = normalize_repo_path(str(router_policy_path(repo_root).relative_to(repo_root)))
|
|
1996
|
+
discovery_path = normalize_repo_path(str(router_discovery_path(repo_root).relative_to(repo_root)))
|
|
1997
|
+
policy_error = ""
|
|
1998
|
+
try:
|
|
1999
|
+
policy = load_policy(repo_root)
|
|
2000
|
+
except RouterConfigError as exc:
|
|
2001
|
+
policy = None
|
|
2002
|
+
policy_error = str(exc)
|
|
2003
|
+
|
|
2004
|
+
inventory = probe_inventory(
|
|
2005
|
+
repo_root,
|
|
2006
|
+
timeout_ms=timeout_ms,
|
|
2007
|
+
probe_tool="recursive-router-probe",
|
|
2008
|
+
write_discovery=write_discovery,
|
|
2009
|
+
policy=policy,
|
|
2010
|
+
)
|
|
2011
|
+
|
|
2012
|
+
if policy is None:
|
|
2013
|
+
reason = policy_error or f"Missing routing policy file at {config_path}."
|
|
2014
|
+
return {
|
|
2015
|
+
"role": resolved_role,
|
|
2016
|
+
"decision": "ask-user",
|
|
2017
|
+
"cli": None,
|
|
2018
|
+
"model": None,
|
|
2019
|
+
"available": False,
|
|
2020
|
+
"fallback_used": False,
|
|
2021
|
+
"fallback": "ask",
|
|
2022
|
+
"reason": reason,
|
|
2023
|
+
"config_path": config_path,
|
|
2024
|
+
"discovery_path": discovery_path,
|
|
2025
|
+
"prompt": build_unresolved_prompt(
|
|
2026
|
+
roles=[resolved_role],
|
|
2027
|
+
inventory=inventory,
|
|
2028
|
+
reason=reason,
|
|
2029
|
+
config_path=config_path,
|
|
2030
|
+
discovery_path=discovery_path,
|
|
2031
|
+
),
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
if resolved_role not in policy["role_routes"]:
|
|
2035
|
+
raise RouterConfigError(f"Unknown router role: {role}")
|
|
2036
|
+
|
|
2037
|
+
defaults = policy["defaults"]
|
|
2038
|
+
route = policy["role_routes"][resolved_role]
|
|
2039
|
+
if not route["enabled"] or route["mode"] == "local-only":
|
|
2040
|
+
return {
|
|
2041
|
+
"role": resolved_role,
|
|
2042
|
+
"decision": "local-only",
|
|
2043
|
+
"cli": None,
|
|
2044
|
+
"model": None,
|
|
2045
|
+
"available": True,
|
|
2046
|
+
"fallback_used": False,
|
|
2047
|
+
"fallback": route["fallback"],
|
|
2048
|
+
"reason": f"role_routes.{resolved_role} is configured for local-only execution.",
|
|
2049
|
+
"config_path": config_path,
|
|
2050
|
+
"discovery_path": discovery_path,
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
selected_cli = route["cli"]
|
|
2054
|
+
selected_model = route["model"]
|
|
2055
|
+
reason_parts: list[str] = [f"resolved from role_routes.{resolved_role}"]
|
|
2056
|
+
available_entries = _available_cli_entries(inventory)
|
|
2057
|
+
|
|
2058
|
+
if selected_cli is None:
|
|
2059
|
+
if defaults["allow_auto_assign_if_single_cli"] and len(available_entries) == 1:
|
|
2060
|
+
selected_cli = available_entries[0]["id"]
|
|
2061
|
+
reason_parts = ["auto-assigned the only discovered CLI for the unresolved role route"]
|
|
2062
|
+
else:
|
|
2063
|
+
policy_choice = defaults["when_role_unconfigured"]
|
|
2064
|
+
if policy_choice == "fallback-local":
|
|
2065
|
+
return {
|
|
2066
|
+
"role": resolved_role,
|
|
2067
|
+
"decision": "fallback-local",
|
|
2068
|
+
"cli": None,
|
|
2069
|
+
"model": None,
|
|
2070
|
+
"available": False,
|
|
2071
|
+
"fallback_used": True,
|
|
2072
|
+
"fallback": route["fallback"],
|
|
2073
|
+
"reason": f"role_routes.{resolved_role}.cli is unconfigured and defaults.when_role_unconfigured=fallback-local.",
|
|
2074
|
+
"config_path": config_path,
|
|
2075
|
+
"discovery_path": discovery_path,
|
|
2076
|
+
}
|
|
2077
|
+
if policy_choice == "block":
|
|
2078
|
+
return {
|
|
2079
|
+
"role": resolved_role,
|
|
2080
|
+
"decision": "blocked",
|
|
2081
|
+
"cli": None,
|
|
2082
|
+
"model": None,
|
|
2083
|
+
"available": False,
|
|
2084
|
+
"fallback_used": False,
|
|
2085
|
+
"fallback": route["fallback"],
|
|
2086
|
+
"reason": f"role_routes.{resolved_role}.cli is unconfigured and defaults.when_role_unconfigured=block.",
|
|
2087
|
+
"config_path": config_path,
|
|
2088
|
+
"discovery_path": discovery_path,
|
|
2089
|
+
}
|
|
2090
|
+
prompt_reason = f"role_routes.{resolved_role}.cli is unresolved."
|
|
2091
|
+
return {
|
|
2092
|
+
"role": resolved_role,
|
|
2093
|
+
"decision": "ask-user",
|
|
2094
|
+
"cli": None,
|
|
2095
|
+
"model": None,
|
|
2096
|
+
"available": False,
|
|
2097
|
+
"fallback_used": False,
|
|
2098
|
+
"fallback": "ask",
|
|
2099
|
+
"reason": prompt_reason,
|
|
2100
|
+
"config_path": config_path,
|
|
2101
|
+
"discovery_path": discovery_path,
|
|
2102
|
+
"prompt": build_unresolved_prompt(
|
|
2103
|
+
roles=[resolved_role],
|
|
2104
|
+
inventory=inventory,
|
|
2105
|
+
reason=prompt_reason,
|
|
2106
|
+
config_path=config_path,
|
|
2107
|
+
discovery_path=discovery_path,
|
|
2108
|
+
),
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
cli_entry = _cli_entry_by_id(inventory, selected_cli)
|
|
2112
|
+
if cli_entry is None or not cli_entry["available"]:
|
|
2113
|
+
policy_choice = defaults["when_cli_unavailable"]
|
|
2114
|
+
reason = f"Configured CLI {selected_cli!r} is unavailable in the current environment."
|
|
2115
|
+
if policy_choice == "ask":
|
|
2116
|
+
return {
|
|
2117
|
+
"role": resolved_role,
|
|
2118
|
+
"decision": "ask-user",
|
|
2119
|
+
"cli": selected_cli,
|
|
2120
|
+
"model": selected_model,
|
|
2121
|
+
"available": False,
|
|
2122
|
+
"fallback_used": False,
|
|
2123
|
+
"fallback": "ask",
|
|
2124
|
+
"reason": reason,
|
|
2125
|
+
"config_path": config_path,
|
|
2126
|
+
"discovery_path": discovery_path,
|
|
2127
|
+
"prompt": build_unresolved_prompt(
|
|
2128
|
+
roles=[resolved_role],
|
|
2129
|
+
inventory=inventory,
|
|
2130
|
+
reason=reason,
|
|
2131
|
+
config_path=config_path,
|
|
2132
|
+
discovery_path=discovery_path,
|
|
2133
|
+
),
|
|
2134
|
+
}
|
|
2135
|
+
if policy_choice == "block":
|
|
2136
|
+
return {
|
|
2137
|
+
"role": resolved_role,
|
|
2138
|
+
"decision": "blocked",
|
|
2139
|
+
"cli": selected_cli,
|
|
2140
|
+
"model": selected_model,
|
|
2141
|
+
"available": False,
|
|
2142
|
+
"fallback_used": False,
|
|
2143
|
+
"fallback": route["fallback"],
|
|
2144
|
+
"reason": reason,
|
|
2145
|
+
"config_path": config_path,
|
|
2146
|
+
"discovery_path": discovery_path,
|
|
2147
|
+
}
|
|
2148
|
+
return {
|
|
2149
|
+
"role": resolved_role,
|
|
2150
|
+
"decision": "fallback-local",
|
|
2151
|
+
"cli": selected_cli,
|
|
2152
|
+
"model": selected_model,
|
|
2153
|
+
"available": False,
|
|
2154
|
+
"fallback_used": True,
|
|
2155
|
+
"fallback": route["fallback"],
|
|
2156
|
+
"reason": reason,
|
|
2157
|
+
"config_path": config_path,
|
|
2158
|
+
"discovery_path": discovery_path,
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
models = list(cli_entry["models"])
|
|
2162
|
+
if selected_model is None:
|
|
2163
|
+
policy_choice = defaults["when_model_unknown"]
|
|
2164
|
+
reason = f"{'; '.join(reason_parts)}; model is unresolved for role {resolved_role}."
|
|
2165
|
+
if policy_choice == "fallback-local":
|
|
2166
|
+
return {
|
|
2167
|
+
"role": resolved_role,
|
|
2168
|
+
"decision": "fallback-local",
|
|
2169
|
+
"cli": selected_cli,
|
|
2170
|
+
"model": None,
|
|
2171
|
+
"available": True,
|
|
2172
|
+
"fallback_used": True,
|
|
2173
|
+
"fallback": route["fallback"],
|
|
2174
|
+
"reason": reason,
|
|
2175
|
+
"config_path": config_path,
|
|
2176
|
+
"discovery_path": discovery_path,
|
|
2177
|
+
}
|
|
2178
|
+
return {
|
|
2179
|
+
"role": resolved_role,
|
|
2180
|
+
"decision": "ask-user" if policy_choice in {"ask", "use-as-literal"} else "blocked",
|
|
2181
|
+
"cli": selected_cli,
|
|
2182
|
+
"model": None,
|
|
2183
|
+
"available": True,
|
|
2184
|
+
"fallback_used": False,
|
|
2185
|
+
"fallback": "ask" if policy_choice in {"ask", "use-as-literal"} else route["fallback"],
|
|
2186
|
+
"reason": reason,
|
|
2187
|
+
"config_path": config_path,
|
|
2188
|
+
"discovery_path": discovery_path,
|
|
2189
|
+
"prompt": build_unresolved_prompt(
|
|
2190
|
+
roles=[resolved_role],
|
|
2191
|
+
inventory=inventory,
|
|
2192
|
+
reason=reason,
|
|
2193
|
+
config_path=config_path,
|
|
2194
|
+
discovery_path=discovery_path,
|
|
2195
|
+
),
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
if models and selected_model not in models:
|
|
2199
|
+
policy_choice = defaults["when_model_unknown"]
|
|
2200
|
+
reason = f"{'; '.join(reason_parts)}; configured model {selected_model!r} was not listed by CLI {selected_cli!r}."
|
|
2201
|
+
if policy_choice == "use-as-literal":
|
|
2202
|
+
return {
|
|
2203
|
+
"role": resolved_role,
|
|
2204
|
+
"decision": "external-cli",
|
|
2205
|
+
"cli": selected_cli,
|
|
2206
|
+
"model": selected_model,
|
|
2207
|
+
"transport": cli_entry.get("transport"),
|
|
2208
|
+
"available": True,
|
|
2209
|
+
"fallback_used": False,
|
|
2210
|
+
"fallback": route["fallback"],
|
|
2211
|
+
"reason": reason + " Using the configured model string literally per policy.",
|
|
2212
|
+
"config_path": config_path,
|
|
2213
|
+
"discovery_path": discovery_path,
|
|
2214
|
+
}
|
|
2215
|
+
if policy_choice == "fallback-local":
|
|
2216
|
+
return {
|
|
2217
|
+
"role": resolved_role,
|
|
2218
|
+
"decision": "fallback-local",
|
|
2219
|
+
"cli": selected_cli,
|
|
2220
|
+
"model": selected_model,
|
|
2221
|
+
"available": True,
|
|
2222
|
+
"fallback_used": True,
|
|
2223
|
+
"fallback": route["fallback"],
|
|
2224
|
+
"reason": reason,
|
|
2225
|
+
"config_path": config_path,
|
|
2226
|
+
"discovery_path": discovery_path,
|
|
2227
|
+
}
|
|
2228
|
+
return {
|
|
2229
|
+
"role": resolved_role,
|
|
2230
|
+
"decision": "ask-user",
|
|
2231
|
+
"cli": selected_cli,
|
|
2232
|
+
"model": selected_model,
|
|
2233
|
+
"available": True,
|
|
2234
|
+
"fallback_used": False,
|
|
2235
|
+
"fallback": "ask",
|
|
2236
|
+
"reason": reason,
|
|
2237
|
+
"config_path": config_path,
|
|
2238
|
+
"discovery_path": discovery_path,
|
|
2239
|
+
"prompt": build_unresolved_prompt(
|
|
2240
|
+
roles=[resolved_role],
|
|
2241
|
+
inventory=inventory,
|
|
2242
|
+
reason=reason,
|
|
2243
|
+
config_path=config_path,
|
|
2244
|
+
discovery_path=discovery_path,
|
|
2245
|
+
),
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
if not models and defaults["when_model_unknown"] == "ask":
|
|
2249
|
+
reason = f"{'; '.join(reason_parts)}; model {selected_model!r} could not be verified because CLI {selected_cli!r} does not advertise models."
|
|
2250
|
+
return {
|
|
2251
|
+
"role": resolved_role,
|
|
2252
|
+
"decision": "ask-user",
|
|
2253
|
+
"cli": selected_cli,
|
|
2254
|
+
"model": selected_model,
|
|
2255
|
+
"available": True,
|
|
2256
|
+
"fallback_used": False,
|
|
2257
|
+
"fallback": "ask",
|
|
2258
|
+
"reason": reason,
|
|
2259
|
+
"config_path": config_path,
|
|
2260
|
+
"discovery_path": discovery_path,
|
|
2261
|
+
"prompt": build_unresolved_prompt(
|
|
2262
|
+
roles=[resolved_role],
|
|
2263
|
+
inventory=inventory,
|
|
2264
|
+
reason=reason,
|
|
2265
|
+
config_path=config_path,
|
|
2266
|
+
discovery_path=discovery_path,
|
|
2267
|
+
),
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
return {
|
|
2271
|
+
"role": resolved_role,
|
|
2272
|
+
"decision": "external-cli",
|
|
2273
|
+
"cli": selected_cli,
|
|
2274
|
+
"model": selected_model,
|
|
2275
|
+
"transport": cli_entry.get("transport"),
|
|
2276
|
+
"available": True,
|
|
2277
|
+
"fallback_used": False,
|
|
2278
|
+
"fallback": route["fallback"],
|
|
2279
|
+
"reason": "; ".join(reason_parts),
|
|
2280
|
+
"config_path": config_path,
|
|
2281
|
+
"discovery_path": discovery_path,
|
|
2282
|
+
}
|