stargate-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stargate/doctor.py ADDED
@@ -0,0 +1,407 @@
1
+ """`stargate doctor`: report the effective configuration and, on request,
2
+ probe each distinct agent for the capabilities its role needs."""
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import time
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from .config import (
18
+ AGENT_RETRIES_DEFAULT,
19
+ AGENT_RETRY_BACKOFF_DEFAULT,
20
+ PROMPTS,
21
+ ROLES,
22
+ TEST_COMMAND_PLACEHOLDER,
23
+ agent_command,
24
+ agent_entry,
25
+ agent_env,
26
+ blocking_severities,
27
+ commit_enabled,
28
+ env_summary,
29
+ expand_test_command,
30
+ find_prompt,
31
+ prompt_dirs,
32
+ pull_request_command,
33
+ task_sources,
34
+ test_command_grant,
35
+ token_cap,
36
+ value_source,
37
+ )
38
+ from .core import StargateError
39
+ from .detect import detection_mode, selected_test_command
40
+
41
+ PROBE_TIMEOUT_DEFAULT = 120
42
+
43
+
44
+ PROBE_CAPABILITIES = ("read", "write")
45
+
46
+
47
+ @dataclass
48
+ class Capability:
49
+ """A file operation that a probe must demonstrate, not merely describe."""
50
+
51
+ kind: str
52
+ path: Path
53
+ marker: str = ""
54
+
55
+
56
+ def unique_agents(config: dict[str, Any]) -> dict[Any, tuple[list[str], Any, dict[str, Any]]]:
57
+ """Distinct agent invocations: the four default roles map onto two commands,
58
+ and probing per role would bill twice for nothing.
59
+
60
+ Identity is command AND environment. Two roles running the same command
61
+ under different credentials are two different things to verify -- deduping
62
+ on the command alone would report one of them without ever calling it.
63
+ Keeping the entry that declared the probe also keeps its prompt and
64
+ capability expectation together when only a later duplicate declares one.
65
+ """
66
+ agents: dict[Any, tuple[list[str], Any, dict[str, Any]]] = {}
67
+ for role in ROLES:
68
+ entry = agent_entry(config, role)
69
+ declared = entry.get("env") or {}
70
+ key = (
71
+ tuple(agent_command(config, role)),
72
+ tuple(sorted((str(k), v) for k, v in declared.items()))
73
+ if isinstance(declared, dict) else None,
74
+ )
75
+ names, prober, first = agents.get(key, ([], None, entry))
76
+ names.append(config["workflow"][role])
77
+ agents[key] = (
78
+ names,
79
+ prober if prober is not None else (
80
+ entry if entry.get("probe") is not None else None
81
+ ),
82
+ first,
83
+ )
84
+ return agents
85
+
86
+
87
+ def probe_one(command: tuple[str, ...], prompt: str, cwd: Path, output: Path,
88
+ timeout: float | None, env: dict[str, str] | None,
89
+ test_command: str,
90
+ capability: Capability | None = None) -> str:
91
+ """Empty string on success, otherwise the reason it failed."""
92
+ writes_final = any("{output}" in part for part in command)
93
+ cmd = [part.replace("{output}", str(output)) for part in command]
94
+ cmd = expand_test_command(cmd, test_command)
95
+ try:
96
+ proc = subprocess.run(
97
+ [*cmd, prompt], cwd=cwd, text=True, stdout=subprocess.PIPE,
98
+ stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, timeout=timeout,
99
+ env=env,
100
+ )
101
+ except subprocess.TimeoutExpired:
102
+ return f"probe timed out after {timeout}s"
103
+ except OSError as exc:
104
+ return str(exc)
105
+
106
+ if proc.returncode:
107
+ return proc.stdout.strip() or f"agent exited with status {proc.returncode}"
108
+ # Exit 0 while writing nothing to {output} is the false positive this flag
109
+ # exists to remove: invoke_agent would kill the run at the first real stage.
110
+ if writes_final:
111
+ if not (output.read_text() if output.exists() else "").strip():
112
+ return ("agent declares {output} but wrote nothing; check that its "
113
+ "CLI supports the configured flag")
114
+ if capability is None:
115
+ return ""
116
+ if capability.kind == "write":
117
+ if not capability.path.is_file() or not capability.path.stat().st_size:
118
+ return (
119
+ f"agent exited 0 but did not write {capability.path.name}; "
120
+ "its file-editing tools are not working"
121
+ )
122
+ return ""
123
+
124
+ # A prose-only answer cannot guess this marker, so returning it proves the
125
+ # role actually used its file-reading tools in the isolated repository.
126
+ answer = (
127
+ output.read_text() if output.exists() else ""
128
+ ) if writes_final else proc.stdout
129
+ if capability.marker not in answer:
130
+ return (
131
+ "agent exited 0 but did not return the marker seeded in "
132
+ f"{capability.path.name}; its file-reading tools are not working"
133
+ )
134
+ return ""
135
+
136
+
137
+ def probe_agents(config: dict[str, Any], test_command: str) -> bool:
138
+ """Make one real, billable call per distinct agent. Opt-in only."""
139
+ print("\nAgent probes:")
140
+ git_bin = shutil.which("git")
141
+ if not git_bin:
142
+ print(" SKIP probes (git is required for the isolated probe directory)")
143
+ return False
144
+
145
+ settings = config.get("settings", {})
146
+ timeout = float(settings.get("probe_timeout_seconds", PROBE_TIMEOUT_DEFAULT)) or None
147
+ ok = True
148
+ with tempfile.TemporaryDirectory(prefix="stargate-doctor-") as tmp:
149
+ cwd = Path(tmp)
150
+ try:
151
+ # Probes run outside the repo: the default agents are
152
+ # --sandbox workspace-write, and codex refuses a non-git directory.
153
+ subprocess.run([git_bin, "init", "-q"], cwd=cwd, check=True,
154
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
155
+ except (OSError, subprocess.CalledProcessError) as exc:
156
+ detail = (getattr(exc, "stdout", None) or str(exc)).strip()
157
+ print(" FAIL probe setup")
158
+ print(" " + detail.replace("\n", "\n "))
159
+ return False
160
+
161
+ for index, (key, (names, prober, entry)) in enumerate(
162
+ unique_agents(config).items()
163
+ ):
164
+ command = key[0]
165
+ label = ", ".join(dict.fromkeys(names))
166
+ if overrides := env_summary(entry):
167
+ label += f" (env: {overrides})"
168
+ if prober is None:
169
+ if entry.get("probe_expect") is not None:
170
+ print(f" FAIL {label} (probe_expect needs a probe prompt)")
171
+ ok = False
172
+ continue
173
+ print(f" SKIP {label} (no probe configured)")
174
+ continue
175
+ prompt = prober.get("probe")
176
+ if not isinstance(prompt, str) or not prompt.strip():
177
+ print(f" FAIL {label} (probe must be a non-empty string)")
178
+ ok = False
179
+ continue
180
+ capability = None
181
+ expect = prober.get("probe_expect")
182
+ probe_path = cwd / f"probe-{index}.txt"
183
+ if expect is not None:
184
+ if expect not in PROBE_CAPABILITIES:
185
+ print(
186
+ f" FAIL {label} (probe_expect must be one of "
187
+ f"{', '.join(PROBE_CAPABILITIES)})"
188
+ )
189
+ ok = False
190
+ continue
191
+ marker = f"stargate-{uuid.uuid4().hex[:12]}"
192
+ if expect == "read":
193
+ probe_path.write_text(marker + "\n")
194
+ capability = Capability(expect, probe_path, marker)
195
+ label += f" ({expect})"
196
+ prompt = prompt.replace("{probe_file}", str(probe_path))
197
+ started = time.monotonic()
198
+ error = probe_one(
199
+ command, prompt, cwd, cwd / f"output-{index}.txt", timeout,
200
+ agent_env(entry), test_command, capability,
201
+ )
202
+ print(f" {'FAIL' if error else 'OK':4} {label} [{time.monotonic() - started:.1f}s]")
203
+ if error:
204
+ print(" " + error.replace("\n", "\n "))
205
+ ok = False
206
+ return ok
207
+
208
+
209
+ def doctor(
210
+ config: dict[str, Any],
211
+ layers: list[tuple[Path, dict[str, Any]]],
212
+ script_dir: Path,
213
+ *,
214
+ probe: bool = False,
215
+ explicit_config: bool = False,
216
+ ) -> int:
217
+ print("stargate doctor\n")
218
+ qualifier = (
219
+ "explicit --config; used exactly as given"
220
+ if explicit_config else "most specific first"
221
+ )
222
+ print(f"Config sources ({qualifier}):")
223
+ packaged_path = (script_dir / "agents.yaml").resolve()
224
+ for index, (path, _) in enumerate(layers, 1):
225
+ suffix = " (packaged defaults)" if path == packaged_path else ""
226
+ print(f" [{index}] {path}{suffix}")
227
+ print()
228
+ settings = config.get("settings", {})
229
+ mode = detection_mode(config)
230
+ commit_enabled(config)
231
+ configured_test_command = str(
232
+ settings.get("test_command", "") or ""
233
+ ).strip()
234
+ test_command, candidates = selected_test_command(config, Path.cwd())
235
+ commands = {
236
+ role: expand_test_command(agent_command(config, role), test_command)
237
+ for role in ROLES
238
+ }
239
+ ok = True
240
+ binaries = {"git"}
241
+ try:
242
+ sources = task_sources(config)
243
+ pr_command = pull_request_command(config)
244
+ except StargateError as exc:
245
+ # Returns instead of setting ok=False like blocking_severities does, and
246
+ # the difference is position: this runs before the probe block, so
247
+ # continuing would pay for real agent calls under a config `run` refuses.
248
+ print(f"\nERROR {exc}")
249
+ return 1
250
+ if pr_command:
251
+ binaries.add(pr_command[0])
252
+ for source in sources:
253
+ for command in source["commands"]:
254
+ binaries.add(command[0])
255
+ for role in ROLES:
256
+ binaries.add(commands[role][0])
257
+
258
+ for binary in sorted(binaries):
259
+ path = shutil.which(binary)
260
+ state = "FOUND" if path else "MISSING"
261
+ print(f"{state:8} {binary:12} {path or ''}")
262
+ ok = ok and bool(path)
263
+ print(
264
+ "\nFOUND means the executable is on PATH. Authentication, credits, quota\n"
265
+ "and model availability are NOT checked -- an agent can still fail on its\n"
266
+ "first call (e.g. \"Credit balance is too low\")."
267
+ )
268
+
269
+ if probe:
270
+ ok = probe_agents(config, test_command) and ok
271
+
272
+ packaged = yaml.safe_load((script_dir / "agents.yaml").read_text()) or {}
273
+ mine, theirs = config.get("version"), packaged.get("version")
274
+ if mine is not None and theirs is not None and mine != theirs:
275
+ print(
276
+ f"\nWARN config version {mine} differs from the packaged version "
277
+ f"{theirs}.\n Newer defaults may be missing; compare against "
278
+ f"{script_dir / 'agents.yaml'}."
279
+ )
280
+
281
+ try:
282
+ blocking_severities(config)
283
+ except StargateError as exc:
284
+ # Reporting a value that `run` will refuse would make doctor the wrong
285
+ # place to find out, which is the one thing it exists for.
286
+ print(f"\nERROR {exc}")
287
+ ok = False
288
+
289
+ print("\nEffective settings:")
290
+ for key, default in (
291
+ ("max_review_loops", 2),
292
+ ("blocking_severities", []),
293
+ ("max_fanout_tasks", 8),
294
+ ("max_parallel_tasks", 2),
295
+ ("test_command", ""),
296
+ ("test_command_detection", "report"),
297
+ ("commit", True),
298
+ ("max_task_tokens", 0),
299
+ ("agent_timeout_seconds", 1800),
300
+ ("agent_retries", AGENT_RETRIES_DEFAULT),
301
+ ("agent_retry_backoff_seconds", AGENT_RETRY_BACKOFF_DEFAULT),
302
+ ("test_timeout_seconds", 900),
303
+ ("worktree_root", ""),
304
+ ("prompts_dir", ""),
305
+ ):
306
+ value = settings.get(key, default)
307
+ print(f" {key:22} {value!r} {value_source(layers, 'settings', key)}")
308
+
309
+ print("\nTest command:")
310
+ if configured_test_command:
311
+ source = value_source(layers, "settings", "test_command")
312
+ print(f" configured {configured_test_command!r} {source}")
313
+ elif mode == "off":
314
+ print(" (not configured; detection is off)")
315
+ else:
316
+ print(" (not configured)")
317
+ for candidate in candidates:
318
+ print(f" detected {candidate.command:16} {candidate.source}")
319
+ if not candidates:
320
+ print(" No likely project test command detected.")
321
+ elif mode == "auto":
322
+ print(f" Detection is automatic; {candidates[0].command!r} will run.")
323
+ else:
324
+ print(
325
+ " Detection is report-only. To use the first candidate, "
326
+ "add to .stargate.yaml:"
327
+ )
328
+ print(" settings:")
329
+ print(f" test_command: {candidates[0].command!r}")
330
+
331
+ cap = token_cap(config)
332
+ print("\nAgents:")
333
+ architect_declares_test_command = False
334
+ for role in ROLES:
335
+ agent_name = config["workflow"][role]
336
+ agent_source = value_source(layers, "agents", agent_name)
337
+ workflow_source = value_source(layers, "workflow", role)
338
+ entry = agent_entry(config, role)
339
+ raw_command = agent_command(config, role)
340
+ declares_test_command = any(
341
+ TEST_COMMAND_PLACEHOLDER in part for part in raw_command
342
+ )
343
+ print(
344
+ f" {role:10} {agent_source:9} "
345
+ f"{shlex.join(commands[role])}"
346
+ )
347
+ if workflow_source != agent_source:
348
+ print(f" {'':10} {'':9} └─ role mapped by {workflow_source}")
349
+ if overrides := env_summary(entry):
350
+ print(f" {'':10} {'':9} └─ env: {overrides}")
351
+ if declares_test_command:
352
+ architect_declares_test_command = (
353
+ architect_declares_test_command or role == "architect"
354
+ )
355
+ if grant := test_command_grant(test_command):
356
+ print(
357
+ f" {'':10} {'':9} └─ may run the test command: {grant}"
358
+ )
359
+ elif test_command:
360
+ print(
361
+ f" {'':10} {'':9} └─ {{test_command}}: {test_command!r} "
362
+ "contains a permission-pattern metacharacter or control "
363
+ "character; it was not interpolated and the grant is dropped"
364
+ )
365
+ else:
366
+ print(
367
+ f" {'':10} {'':9} └─ {{test_command}}: no test command "
368
+ "will run; the grant and its flag are dropped"
369
+ )
370
+ if cap:
371
+ meters = "reports usage" if entry.get("usage_pattern") else "no usage_pattern"
372
+ print(f" {'':10} {'':9} └─ {meters}")
373
+
374
+ if architect_declares_test_command:
375
+ print(
376
+ "\nWARN the architect declares {test_command}. It runs in your real "
377
+ "repository,\n not the worktree, so this can grant command "
378
+ "execution there."
379
+ )
380
+
381
+ print("\nTask sources:")
382
+ if not sources:
383
+ print(" (none configured; 'stargate run --from' needs one)")
384
+ for source in sources:
385
+ print(f" {', '.join(source['hosts'])}")
386
+ for command in source["commands"]:
387
+ print(f" $ {shlex.join(command)}")
388
+ if overrides := env_summary(source):
389
+ print(f" └─ env: {overrides}")
390
+
391
+ if pr_command:
392
+ print("\nPull request:")
393
+ print(f" {shlex.join(pr_command)}")
394
+ if overrides := env_summary(config["pull_request"]):
395
+ print(f" └─ env: {overrides}")
396
+ print(" Published only when you pass --pr; configuration alone never publishes.")
397
+
398
+ print("\nPrompts:")
399
+ dirs = prompt_dirs(config, script_dir)
400
+ for role in PROMPTS:
401
+ try:
402
+ print(f" {role:10} {find_prompt(dirs, role)}")
403
+ except StargateError as exc:
404
+ print(f" {role:10} MISSING ({exc})")
405
+ ok = False
406
+
407
+ return 0 if ok else 1