handcode 0.3.0rc1__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.
Files changed (67) hide show
  1. agentctl/__init__.py +0 -0
  2. agentctl/adapters/__init__.py +0 -0
  3. agentctl/adapters/litellm/__init__.py +9 -0
  4. agentctl/adapters/litellm/hook.py +49 -0
  5. agentctl/adapters/litellm/recorder.py +187 -0
  6. agentctl/adapters/openhands/__init__.py +169 -0
  7. agentctl/adapters/openhands/handoff.py +155 -0
  8. agentctl/adapters/openhands/seam_b.py +259 -0
  9. agentctl/adapters/openhands/seam_c.py +209 -0
  10. agentctl/cli.py +1450 -0
  11. agentctl/control/__init__.py +0 -0
  12. agentctl/control/cost/__init__.py +4 -0
  13. agentctl/control/cost/ledger.py +210 -0
  14. agentctl/control/dash.py +697 -0
  15. agentctl/control/keys.py +440 -0
  16. agentctl/control/matrix/__init__.py +0 -0
  17. agentctl/control/matrix/data/tools.yaml +149 -0
  18. agentctl/control/policy/__init__.py +10 -0
  19. agentctl/control/policy/compile.py +258 -0
  20. agentctl/control/policy/data/policy.compiled.json +38 -0
  21. agentctl/control/policy/data/policy.yaml +46 -0
  22. agentctl/control/probe.py +399 -0
  23. agentctl/control/providers.py +293 -0
  24. agentctl/control/proxy.py +536 -0
  25. agentctl/control/proxyenv.py +309 -0
  26. agentctl/control/replay/__init__.py +14 -0
  27. agentctl/control/replay/cassette.py +281 -0
  28. agentctl/control/replay/server.py +109 -0
  29. agentctl/demo/__init__.py +214 -0
  30. agentctl/demo/child.py +84 -0
  31. agentctl/demo/mock.py +79 -0
  32. agentctl/demo/tool.py +62 -0
  33. agentctl/gha.py +488 -0
  34. agentctl/kernel/__init__.py +0 -0
  35. agentctl/kernel/classify.py +170 -0
  36. agentctl/kernel/gate.py +391 -0
  37. agentctl/kernel/hook.py +229 -0
  38. agentctl/kernel/ledger/__init__.py +0 -0
  39. agentctl/kernel/ledger/models.py +160 -0
  40. agentctl/kernel/ledger/schema.sql +62 -0
  41. agentctl/kernel/ledger/store.py +596 -0
  42. agentctl/kernel/paths.py +203 -0
  43. agentctl/kernel/policy.py +160 -0
  44. agentctl/kernel/reconcile/__init__.py +31 -0
  45. agentctl/kernel/reconcile/base.py +106 -0
  46. agentctl/kernel/reconcile/external.py +137 -0
  47. agentctl/kernel/reconcile/filesystem.py +162 -0
  48. agentctl/kernel/reconcile/git.py +162 -0
  49. agentctl/runtime/__init__.py +20 -0
  50. agentctl/runtime/citations.py +179 -0
  51. agentctl/runtime/config.py +97 -0
  52. agentctl/runtime/doctor.py +335 -0
  53. agentctl/runtime/init.py +148 -0
  54. agentctl/runtime/lease.py +143 -0
  55. agentctl/runtime/orchestrate.py +187 -0
  56. agentctl/runtime/plugins.py +130 -0
  57. agentctl/runtime/report.py +361 -0
  58. agentctl/runtime/runner.py +787 -0
  59. agentctl/runtime/runs.py +191 -0
  60. agentctl/runtime/subagent.py +274 -0
  61. agentctl/runtime/tools.py +350 -0
  62. handcode-0.3.0rc1.dist-info/METADATA +659 -0
  63. handcode-0.3.0rc1.dist-info/RECORD +67 -0
  64. handcode-0.3.0rc1.dist-info/WHEEL +5 -0
  65. handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
  66. handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
  67. handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
agentctl/cli.py ADDED
@@ -0,0 +1,1450 @@
1
+ """agentctl — inspect and resolve the effect ledger.
2
+
3
+ The human interface to fail-closed. When the gate cannot tell whether an effect
4
+ landed it blocks and waits; without a way to see and answer those, the design
5
+ is correct and unusable (`docs/0013` §3, Panel 3).
6
+
7
+ agentctl keys provider keys: what is set, where to get more
8
+ agentctl dash one screen: providers, effects, spend, policy
9
+ agentctl doctor is everything ready? check before running
10
+ agentctl models sources you can route to, and what each costs
11
+ agentctl run "<task>" run an agent on a real workspace
12
+ agentctl subagent <name> "<q>" delegate a READ to a read-only subagent
13
+ agentctl plugins <dir> what a plugin would contribute, and what is not
14
+ agentctl status what is in the ledger
15
+ agentctl cost what the work cost, and how much is known
16
+ agentctl ingest <telemetry> load Seam A telemetry into the cost ledger
17
+ agentctl blocked effects awaiting a decision
18
+ agentctl show <tool_call_id> everything known about one effect
19
+ agentctl resolve <id> --landed record that it did happen
20
+ agentctl resolve <id> --retry record that it did not; allow a retry
21
+
22
+ Read-only by default. The two `resolve` forms are the only writes, and both
23
+ require an explicit choice — there is no "probably fine".
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import sys
30
+ import time
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+
34
+ from agentctl.control.cost import CostLedger
35
+ from agentctl.kernel.ledger.models import EffectState
36
+ from agentctl.kernel.ledger.store import (
37
+ SHORT_ID,
38
+ AmbiguousPrefix,
39
+ LedgerStore,
40
+ short_id,
41
+ )
42
+
43
+ DEFAULT_LEDGER = Path("ledger.db")
44
+ DEFAULT_COST_LEDGER = Path("cost.db")
45
+
46
+
47
+ def _ascii_stdout() -> None:
48
+ try:
49
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
50
+ except Exception:
51
+ pass
52
+
53
+
54
+ def _ts(v: float | None) -> str:
55
+ if not v:
56
+ return "-"
57
+ return datetime.fromtimestamp(v, timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
58
+
59
+
60
+ def _open(path: Path) -> LedgerStore:
61
+ if not path.exists():
62
+ print(f"no ledger at {path}", file=sys.stderr)
63
+ raise SystemExit(2)
64
+ return LedgerStore(path, holder="cli")
65
+
66
+
67
+
68
+ def _resolve_id(store: LedgerStore, given: str) -> str | None:
69
+ """An id the user typed -- exact, or an unambiguous prefix.
70
+
71
+ Gemini's `tool_call_id` carries a thought signature and runs past 300
72
+ characters (`research/phase-10-3` V4), so nobody is retyping one. An
73
+ ambiguous prefix is refused with the candidates rather than guessed at:
74
+ this is the path that records an effect as having happened.
75
+ """
76
+ try:
77
+ return store.resolve_id(given)
78
+ except AmbiguousPrefix as e:
79
+ print(f"{given!r} matches {len(e.matches)} effects:", file=sys.stderr)
80
+ for m in e.matches:
81
+ print(f" {short_id(m)} ({m[:40]}...)", file=sys.stderr)
82
+ print(" give more characters.", file=sys.stderr)
83
+ raise SystemExit(2) from None
84
+
85
+
86
+ # ── commands ───────────────────────────────────────────────────────────
87
+ def _row_cost(r: dict) -> str:
88
+ """One row's cost, in the ledger's three states (docs/0042 I-10)."""
89
+ if r["priced_calls"]:
90
+ extra = " +free" if r["free_calls"] else ""
91
+ return f"${r['cost']:.4f}{extra}" + ("" if r["priced_calls"] + r["free_calls"]
92
+ == r["calls"] else " +?")
93
+ if r["free_calls"] == r["calls"]:
94
+ return "free"
95
+ return "unknown"
96
+
97
+
98
+ def cmd_init(args) -> int:
99
+ from agentctl.runtime.init import run_init
100
+ return run_init(provider=args.provider, model=args.model,
101
+ verify=args.verify, key_stdin=args.key_stdin,
102
+ check_paid=args.check_paid)
103
+
104
+
105
+ def _status_runs() -> int:
106
+ """What happened while you were away (`docs/0013` §3, Panel 3)."""
107
+ from agentctl.runtime import runs
108
+ rows = runs.recent(15)
109
+ if not rows:
110
+ print("no runs recorded yet. Start one: agentctl run \"<task>\"")
111
+ return 0
112
+ print(f" {'RUN':<9} {'STARTED (UTC)':<20} {'STATE':<13} {'OUTCOME':<12} "
113
+ f"{'REQ':>4} WORKSPACE")
114
+ for r in rows:
115
+ print(f" {r.conversation_id[:8]:<9} {_ts(r.started):<20} {r.state:<13} "
116
+ f"{(r.outcome or '-'):<12} {(r.requests if r.requests is not None else '-'):>4}"
117
+ f" {r.workspace}")
118
+ waiting = 0
119
+ for path in runs.ledgers():
120
+ with LedgerStore(path, holder="cli") as s:
121
+ waiting += len(s.blocked())
122
+ print()
123
+ if waiting:
124
+ print(f" {waiting} action(s) need you -> agentctl blocked")
125
+ died = [r for r in rows if r.state in ("died", "interrupted", "rate_limited")]
126
+ if died:
127
+ print(f" continue the latest unfinished one: agentctl resume "
128
+ f"{died[0].conversation_id[:8]}")
129
+ if not waiting and not died:
130
+ print(" nothing waiting")
131
+ return 0
132
+
133
+
134
+ def cmd_status(args) -> int:
135
+ if args.ledger == DEFAULT_LEDGER and not args.ledger.exists():
136
+ return _status_runs()
137
+ with _open(args.ledger) as s:
138
+ rows = s._db.execute(
139
+ "SELECT state, effect_class, COUNT(*) n FROM effect_record "
140
+ "GROUP BY state, effect_class ORDER BY state, effect_class"
141
+ ).fetchall()
142
+ total = s._db.execute("SELECT COUNT(*) n FROM effect_record").fetchone()["n"]
143
+ convs = s._db.execute(
144
+ "SELECT COUNT(DISTINCT conversation_id) n FROM effect_record"
145
+ ).fetchone()["n"]
146
+
147
+ print(f"ledger {args.ledger}")
148
+ print(f" {total} effect(s) across {convs} conversation(s)\n")
149
+ if not rows:
150
+ print(" empty")
151
+ return 0
152
+ print(f" {'STATE':<12} {'CLASS':<22} COUNT")
153
+ for r in rows:
154
+ print(f" {r['state']:<12} {r['effect_class']:<22} {r['n']}")
155
+
156
+ blocked = sum(r["n"] for r in rows if r["state"] == EffectState.BLOCKED.value)
157
+ pending = sum(r["n"] for r in rows if r["state"] == EffectState.INTENT.value)
158
+ print()
159
+ if blocked:
160
+ print(f" {blocked} effect(s) need a decision -> agentctl blocked")
161
+ if pending:
162
+ print(f" {pending} effect(s) still INTENT (a run may be in flight)")
163
+ if not blocked and not pending:
164
+ print(" nothing waiting")
165
+ return 0
166
+
167
+
168
+ def cmd_blocked(args) -> int:
169
+ from agentctl.runtime.runner import AWAITING
170
+ recs = []
171
+ for path in _ledgers(args):
172
+ with LedgerStore(path, holder="cli") as s:
173
+ recs += s.blocked(args.conversation)
174
+ if not recs:
175
+ print("nothing blocked")
176
+ return 0
177
+ approvals = [r for r in recs if (r.error or "").startswith(AWAITING)]
178
+ for r in approvals:
179
+ print(f" {short_id(r.tool_call_id)} waiting for your approval")
180
+ print(f" {(r.error or '').split('): ', 1)[-1][:120]}")
181
+ print(f" agentctl approve {r.tool_call_id[:SHORT_ID]} | "
182
+ f"agentctl deny {r.tool_call_id[:SHORT_ID]}")
183
+ print()
184
+ recs = [r for r in recs if r not in approvals]
185
+ if not recs:
186
+ return 0
187
+
188
+ print(f"{len(recs)} effect(s) awaiting a decision:\n")
189
+ for r in recs:
190
+ print(f" {short_id(r.tool_call_id)}")
191
+ print(f" tool {r.tool_name} [{r.effect_class.value}]")
192
+ print(f" started {_ts(r.started_at)} attempt {r.attempt}")
193
+ print(f" reason {r.error or '-'}")
194
+ if r.probe_verdict:
195
+ print(f" probe {r.probe_verdict}")
196
+ # The bare prefix, NOT short_id(): the `...` marks a truncation for a
197
+ # reader and is not part of the id, so a command carrying it cannot be
198
+ # pasted. A hint you have to edit before it works is worse than none.
199
+ print(f" resolve agentctl resolve {r.tool_call_id[:SHORT_ID]} "
200
+ f"--landed | --retry")
201
+ print()
202
+ return 0
203
+
204
+
205
+ def cmd_show(args) -> int:
206
+ if (found := _find_effect(args, args.tool_call_id)) is None:
207
+ return 2
208
+ path, full = found
209
+ with LedgerStore(path, holder="cli") as s:
210
+ r = s.lookup(full) if full else None
211
+ if r is None:
212
+ print(f"no such effect: {args.tool_call_id}", file=sys.stderr)
213
+ return 2
214
+
215
+ print(f"{short_id(r.tool_call_id)}")
216
+ for label, value in [
217
+ ("tool", r.tool_name), ("class", r.effect_class.value),
218
+ ("state", r.state.value), ("conversation", r.conversation_id),
219
+ ("turn", r.turn_id), ("attempt", r.attempt),
220
+ ("started", _ts(r.started_at)), ("committed", _ts(r.committed_at)),
221
+ ("fence", r.fence_token), ("probe", r.probe_verdict or "-"),
222
+ ("error", r.error or "-"), ("intent_hash", r.intent_hash[:16] + "..."),
223
+ ]:
224
+ print(f" {label:<13} {value}")
225
+
226
+ if r.pre_state:
227
+ print(" pre_state")
228
+ try:
229
+ for k, v in json.loads(r.pre_state).items():
230
+ print(f" {k:<11} {str(v)[:70]}")
231
+ except Exception:
232
+ print(f" {r.pre_state[:200]}")
233
+ print(f" observation {'recorded' if r.observation else 'none'}")
234
+ return 0
235
+
236
+
237
+ def cmd_resolve(args) -> int:
238
+ if (found := _find_effect(args, args.tool_call_id)) is None:
239
+ return 2
240
+ path, full = found
241
+ with LedgerStore(path, holder="cli") as s:
242
+ r = s.lookup(full) if full else None
243
+ if r is None:
244
+ print(f"no such effect: {args.tool_call_id}", file=sys.stderr)
245
+ return 2
246
+ if r.state is not EffectState.BLOCKED:
247
+ print(f"effect is {r.state.value}, not BLOCKED - nothing to resolve",
248
+ file=sys.stderr)
249
+ return 2
250
+
251
+ # The CLI holds no lease, so adopt the record's fence to write.
252
+ s._fences[r.conversation_id] = r.fence_token
253
+ s.reconcile(full, "HUMAN", landed=args.landed)
254
+
255
+ if args.landed:
256
+ print(f"{short_id(full)} -> COMMITTED (recorded as having happened)")
257
+ print(" it will not be re-run.")
258
+ else:
259
+ print(f"{short_id(full)} -> FAILED (recorded as not having happened)")
260
+ print(" the agent may retry it on the next run.")
261
+ return 0
262
+
263
+
264
+ def cmd_cost(args) -> int:
265
+ if not args.cost_ledger.exists():
266
+ print(f"no cost ledger at {args.cost_ledger}\n"
267
+ f" run: agentctl ingest <hook_telemetry.json>", file=sys.stderr)
268
+ return 2
269
+
270
+ since = (time.time() - 86400) if args.today else None
271
+ with CostLedger(args.cost_ledger) as c:
272
+ t = c.totals(conversation_id=args.conversation, since=since)
273
+ scope = ("today" if args.today else
274
+ f"conversation {args.conversation}" if args.conversation else "all time")
275
+
276
+ print(f"cost ({scope})")
277
+ print(f" spend {t.describe_cost()}")
278
+ print(f" calls {t.calls}")
279
+ print(f" tokens {t.prompt_tokens} in / {t.completion_tokens} out")
280
+ if t.prompt_tokens:
281
+ print(f" cache hits {t.cached_tokens} "
282
+ f"({t.cache_hit_ratio:.0%} of input)")
283
+
284
+ if not t.trustworthy and t.calls:
285
+ print()
286
+ print(f" ! PRICING COVERAGE {t.coverage:.0%} "
287
+ f"({t.known_calls}/{t.calls} calls known)")
288
+ print(" litellm reports 0.0 for endpoints it cannot price, so an")
289
+ print(" unpriced call is indistinguishable from a free one. The")
290
+ print(" real total is HIGHER than the figure above.")
291
+ if (blind := c.unpriced_deployments()):
292
+ print(f" unpriced: {', '.join(blind)}")
293
+
294
+ if args.by_deployment:
295
+ rows = c.by_deployment()
296
+ if rows:
297
+ print(f"\n {'DEPLOYMENT':<18} {'CALLS':>6} {'PRICED':>7} {'COST':>10}")
298
+ for r in rows:
299
+ priced = f"{r['priced_calls'] + r['free_calls']}/{r['calls']}"
300
+ cost = _row_cost(r)
301
+ print(f" {(r['deployment'] or '-'):<18} {r['calls']:>6} "
302
+ f"{priced:>7} {cost:>10}")
303
+
304
+ if args.by_conversation:
305
+ rows = c.by_conversation()
306
+ if rows:
307
+ print(f"\n {'CONVERSATION':<38} {'CALLS':>6} {'COST':>10}")
308
+ for r in rows:
309
+ cost = _row_cost(r)
310
+ print(f" {(r['conversation_id'] or '-')[:36]:<38} "
311
+ f"{r['calls']:>6} {cost:>10}")
312
+ return 0
313
+
314
+
315
+ def cmd_ingest(args) -> int:
316
+ with CostLedger(args.cost_ledger) as c:
317
+ n = c.ingest_telemetry(args.telemetry)
318
+ if n == 0:
319
+ print(f"nothing ingested from {args.telemetry}", file=sys.stderr)
320
+ return 2
321
+ print(f"ingested {n} record(s) into {args.cost_ledger}")
322
+ print(" agentctl cost --by-deployment")
323
+ return 0
324
+
325
+
326
+ def cmd_run(args) -> int:
327
+ from agentctl.runtime import config
328
+ from agentctl.runtime.runner import run
329
+ print("agentctl run")
330
+
331
+ cfg = config.load()
332
+ base_url = config.resolve("base_url", args.base_url, cfg).value
333
+ m = config.resolve("model", args.model, cfg)
334
+ model = m.value
335
+ if args.pool:
336
+ # One flag instead of three commands and two flags (docs/0044 F3).
337
+ from agentctl.control import proxyenv
338
+ from agentctl.control.proxy import POOL
339
+ s = proxyenv.status()
340
+ if s["state"] != "running":
341
+ proxyenv.up()
342
+ s = proxyenv.status()
343
+ base_url = proxyenv.url(s["port"])
344
+ if not args.source:
345
+ model = f"openai/{POOL}"
346
+ m = config.Setting(model, "--pool")
347
+ print(f" pool {base_url}")
348
+ if args.source:
349
+ # A source group only exists inside the proxy's config, so asking for
350
+ # one without a proxy would resolve to nothing. Refuse rather than
351
+ # silently fall back to the default model, which would run the task on
352
+ # a provider the user just said not to use.
353
+ if not base_url:
354
+ print(" --source needs a proxy to route through.")
355
+ print(" agentctl proxy --out ./proxy")
356
+ print(" bash ./proxy/start.sh 4000")
357
+ print(" ... then add --base-url http://localhost:4000")
358
+ return 2
359
+ from agentctl.control.proxy import SOURCE_PREFIX, sources
360
+ known = {r["source"] for r in sources()}
361
+ if args.source not in known:
362
+ print(f" no source called {args.source!r}. "
363
+ f"You have: {', '.join(sorted(known)) or 'none'}")
364
+ print(" `agentctl models` lists them and what each one gives up.")
365
+ return 2
366
+ model = f"openai/{SOURCE_PREFIX}{args.source}"
367
+ print(f" source {args.source} (narrower than the full pool)")
368
+ elif model is None and not args.replay:
369
+ print(" no model: no flag, no AGENTCTL_MODEL, no config, and no key to")
370
+ print(" derive one from. One command sets all of that up:")
371
+ print(" agentctl init")
372
+ return 2
373
+ elif model:
374
+ print(f" model from {m.source}")
375
+ from agentctl.runtime.runner import RateLimited
376
+
377
+ # `--wait`: a rate limit is waited out and the SAME conversation resumed,
378
+ # bounded in time and attempts (`docs/0042` I-05, wait-only half). Without
379
+ # it the run ends with the one command that continues it.
380
+ limit = _duration_s(getattr(args, "wait", None))
381
+ resume, waited, attempt = args.resume, 0.0, 0
382
+ while True:
383
+ try:
384
+ result = run(
385
+ task=args.task if not resume else "",
386
+ workspace=args.workspace, model=model or "replay",
387
+ base_url=base_url,
388
+ ledger=args.ledger if args.ledger != DEFAULT_LEDGER else None,
389
+ confirm_destructive=not args.allow_destructive,
390
+ max_iterations=args.max_iterations, max_budget_usd=args.max_budget,
391
+ resume=resume, record=args.record, replay=args.replay,
392
+ policy=args.policy, takeover=args.takeover, accept=args.accept,
393
+ )
394
+ break
395
+ except RateLimited as rl:
396
+ print(f"\n {rl.text}")
397
+ pause = _rate_limit_pause(rl, limit, waited, attempt)
398
+ short = rl.conversation_id[:8]
399
+ if pause is None:
400
+ print(f"\n continue later: agentctl resume {short}"
401
+ + ("" if limit else " (or add --wait 30m to wait it out)"))
402
+ return 1
403
+ print(f"\n waiting {_fmt_s(pause)} for the limit to reset, then "
404
+ f"resuming {short} (--wait {args.wait}) ...")
405
+ time.sleep(pause)
406
+ waited, attempt, resume = waited + pause, attempt + 1, rl.conversation_id
407
+
408
+ if rec := result.get("recorded"):
409
+ print(f" recorded {rec['turns']} turns -> {rec['cassette']}"
410
+ + (f" ({rec['errors']} errors)" if rec["errors"] else ""))
411
+ print(f" replay it: agentctl run '' --workspace "
412
+ f"{result['workspace']} --replay {rec['cassette']}")
413
+
414
+ if rep := result.get("replay"):
415
+ print(f" replayed {rep['turns_replayed']}/{rep['turns_recorded']}"
416
+ f" turns, $0.00")
417
+ if rep["diverged"]:
418
+ # The point of M6: a divergence is the finding, not an error.
419
+ print(f" DIVERGED {rep['misses']} miss(es), "
420
+ f"{rep['unplayed']} turn(s) never reached")
421
+ if rep["first_divergence"]:
422
+ print(f" {rep['first_divergence']}")
423
+ else:
424
+ print(" identical the run matched the recording exactly")
425
+
426
+ # One report in place of `decisions {'EXECUTE': 9}` (docs/0044 F6, F10).
427
+ # Exit 0 means: nothing failed a check and nothing waits on you. It used
428
+ # to be 1 for any blocked effect, including after a fully successful task.
429
+ report = result.get("report")
430
+ if report is None: # an embedder's stub, say
431
+ return 1 if result["blocked"] else 0
432
+ for line in report.lines():
433
+ print(line)
434
+ if getattr(args, "report_json", None):
435
+ import json
436
+ args.report_json.parent.mkdir(parents=True, exist_ok=True)
437
+ args.report_json.write_text(json.dumps(report.to_json(), indent=2),
438
+ encoding="utf-8")
439
+ return 0 if report.ok else 1
440
+
441
+
442
+ def _duration_s(text: str | None) -> float:
443
+ """`90s`, `30m`, `2h`, or bare seconds. 0 when not given."""
444
+ if not text:
445
+ return 0.0
446
+ text = str(text).strip().lower()
447
+ mult = {"s": 1, "m": 60, "h": 3600}.get(text[-1:], None)
448
+ try:
449
+ return float(text[:-1]) * mult if mult else float(text)
450
+ except ValueError:
451
+ raise SystemExit(f"--wait wants a duration like 90s, 30m or 2h, not {text!r}")
452
+
453
+
454
+ def _fmt_s(s: float) -> str:
455
+ s = int(round(s))
456
+ return f"{s // 60}m {s % 60:02d}s" if s >= 60 else f"{s}s"
457
+
458
+
459
+ def _rate_limit_pause(rl, limit: float, waited: float, attempt: int) -> float | None:
460
+ """How long to wait before resuming, or None to stop and say so.
461
+
462
+ The provider's reset time when it gave one; otherwise a doubling backoff
463
+ from a minute (per-minute limits give no header). Never past `--wait`,
464
+ and never more than five attempts: an auto-resume loop that cannot end
465
+ is a way to spend a day of quota on one failure.
466
+ """
467
+ if not limit or attempt >= 5:
468
+ return None
469
+ if rl.reset_at:
470
+ pause = max(5.0, rl.reset_at - time.time() + 5)
471
+ else:
472
+ pause = 60.0 * (2 ** attempt)
473
+ if waited + pause > limit:
474
+ print(f" the limit resets in {_fmt_s(pause)}, which is past --wait "
475
+ f"({_fmt_s(limit - waited)} left)")
476
+ return None
477
+ return pause
478
+
479
+
480
+ def _ledgers(args) -> list[Path]:
481
+ """The ledgers a follow-up command should read.
482
+
483
+ An explicit `--ledger`, or `./ledger.db` when there is one; otherwise
484
+ every ledger a recorded run wrote (`~/.agentctl/runs.db`). Phase 0 found
485
+ `status` saying "no ledger at ledger.db" from inside a workspace that had
486
+ one (`docs/0044` F4).
487
+ """
488
+ if args.ledger != DEFAULT_LEDGER or args.ledger.exists():
489
+ return [args.ledger]
490
+ from agentctl.runtime import runs
491
+ found = runs.ledgers()
492
+ if not found:
493
+ print("no runs recorded yet, and no ledger here. Start one: "
494
+ "agentctl run \"<task>\"", file=sys.stderr)
495
+ raise SystemExit(2)
496
+ return found
497
+
498
+
499
+ def _find_effect(args, given: str) -> tuple[Path, str] | None:
500
+ """(ledger, full tool_call_id) for an id prefix, across every ledger.
501
+
502
+ Refuses rather than guesses when the prefix is in more than one ledger:
503
+ this is the path that records an effect as having happened."""
504
+ hits: list[tuple[Path, str]] = []
505
+ for path in _ledgers(args):
506
+ with LedgerStore(path, holder="cli") as s:
507
+ if (full := _resolve_id(s, given)):
508
+ hits.append((path, full))
509
+ if not hits:
510
+ print(f"no such effect: {given}", file=sys.stderr)
511
+ return None
512
+ if len(hits) > 1:
513
+ print(f"{given!r} matches effects in {len(hits)} ledgers:", file=sys.stderr)
514
+ for path, full in hits:
515
+ print(f" {short_id(full)} in {path}", file=sys.stderr)
516
+ print(" give more characters, or --ledger <path>.", file=sys.stderr)
517
+ raise SystemExit(2)
518
+ return hits[0]
519
+
520
+
521
+ def cmd_resume(args) -> int:
522
+ """Continue a run: the last one here, or one named by an id prefix."""
523
+ import argparse as _ap
524
+
525
+ from agentctl.runtime import runs
526
+ r = runs.find(args.id, workspace=Path.cwd())
527
+ print(f"resuming {r.conversation_id[:8]} ({r.state}, started "
528
+ f"{_ts(r.started)})")
529
+ print(f" in {r.workspace}")
530
+ if r.task:
531
+ print(f" task {r.task[:90]}")
532
+ pool = bool(r.model and r.model.startswith("openai/pool"))
533
+ ns = _ap.Namespace(
534
+ task="", workspace=Path(r.workspace), model=args.model or r.model,
535
+ base_url=None if pool else r.base_url, pool=pool and not args.model,
536
+ source=None, resume=r.conversation_id, takeover=args.takeover,
537
+ accept=args.accept, wait=args.wait, ledger=args.ledger,
538
+ cost_ledger=getattr(args, "cost_ledger", DEFAULT_COST_LEDGER),
539
+ allow_destructive=False, max_iterations=args.max_iterations,
540
+ max_budget=None, record=None, replay=None, policy=None)
541
+ return cmd_run(ns)
542
+
543
+
544
+ def _decide(args, decision: str) -> int:
545
+ from agentctl.runtime.runner import AWAITING
546
+ if (found := _find_effect(args, args.tool_call_id)) is None:
547
+ return 2
548
+ path, full = found
549
+ with LedgerStore(path, holder="cli") as s:
550
+ rec = s.lookup(full)
551
+ if rec is None or rec.state is not EffectState.BLOCKED or \
552
+ not (rec.error or "").startswith(AWAITING):
553
+ what = rec.state.value if rec else "missing"
554
+ print(f"{short_id(full)} is not waiting for approval ({what}). "
555
+ f"An effect whose OUTCOME is unknown is answered with "
556
+ f"`agentctl resolve`.", file=sys.stderr)
557
+ return 2
558
+ summary = (rec.error or "").split("): ", 1)[-1]
559
+ s._fences[rec.conversation_id] = rec.fence_token # the CLI holds no lease
560
+ s.decide(rec, decision, summary)
561
+ verb = "approved" if decision == "approve" else "denied"
562
+ print(f"{short_id(full)} {verb}: {summary}")
563
+ print(f" continue the run, and the agent is told: agentctl resume "
564
+ f"{rec.conversation_id[:8]}")
565
+ return 0
566
+
567
+
568
+ def cmd_approve(args) -> int:
569
+ return _decide(args, "approve")
570
+
571
+
572
+ def cmd_deny(args) -> int:
573
+ return _decide(args, "deny")
574
+
575
+
576
+ def cmd_doctor(args) -> int:
577
+ """Preflight. What is ready, what is missing, what will stop you."""
578
+ from agentctl.runtime.doctor import check_all, report
579
+
580
+ print("agentctl doctor")
581
+ print()
582
+ rows = check_all(workspace=args.workspace, probe_network=not args.offline)
583
+ return report(rows)
584
+
585
+
586
+ def cmd_keys(args) -> int:
587
+ """Show which provider keys are set, and where to get the rest.
588
+
589
+ Never prints a value. `set (73 chars)` is the most it will say.
590
+ """
591
+ from agentctl.control.keys import (HOME_PATH, check_not_tracked, resolve,
592
+ write_template)
593
+ from agentctl.control.providers import (PROVIDERS, accounts_for,
594
+ all_accounts, missing)
595
+
596
+ path = Path(args.file) if args.file else (resolve() or HOME_PATH)
597
+
598
+ if args.install_hook:
599
+ from agentctl.control.keys import install_hook
600
+ h = install_hook(".")
601
+ print(f"installed {h}")
602
+ print(" any commit containing one of YOUR keys is now refused.")
603
+ print(" it compares against the keys you hold, not against a shape --")
604
+ print(" a check that flags every placeholder gets ignored.")
605
+ print()
606
+
607
+ if args.init:
608
+ from agentctl.control.keys import enclosing_repo
609
+ p, created = write_template(path)
610
+ print(f"{'wrote' if created else 'kept existing'} {p}")
611
+ if (repo := enclosing_repo(p)):
612
+ print(f" added ignore rules to {repo / '.gitignore'} "
613
+ f"BEFORE writing it")
614
+ else:
615
+ print(" it is in no git repository, so nothing can commit it")
616
+ if created:
617
+ print(" fill in the keys you have; blanks are fine")
618
+ print()
619
+
620
+ if (warn := check_not_tracked(path)):
621
+ print(f" !! {warn}", file=sys.stderr)
622
+ print(file=sys.stderr)
623
+
624
+ import os as _os
625
+
626
+ accts = all_accounts()
627
+ print(f"keys {path}{'' if path.exists() else ' (not created yet)'}")
628
+ print()
629
+ for p in PROVIDERS:
630
+ tag = "" if p.free_tier else " (paid)"
631
+ mine = accounts_for(p)
632
+ if not mine:
633
+ print(f" -- {p.name:<15}{tag:<7} {p.console}")
634
+ continue
635
+ for a in mine:
636
+ # Length only. The value never leaves the file.
637
+ print(f" ok {a.label:<15}{tag:<7} {a.env:<26} "
638
+ f"set ({len(_os.environ.get(a.env, ''))} chars)")
639
+
640
+ if args.check:
641
+ from agentctl.control.probe import check_all, summarise
642
+
643
+ print()
644
+ print(" checking connectivity (metadata endpoints only -- no tokens spent)")
645
+ results = check_all(accts)
646
+ mark = {"live": "ok ", "limited": "!! ", "no-credit": "$$ ",
647
+ "rejected": "XX ", "unreachable": "?? ",
648
+ "skipped": "-- "}
649
+ for r in results:
650
+ print(f" {mark[r.status]} {r.account.label:<15} {r.status:<12} {r.detail}")
651
+ sm = summarise(results)
652
+ print()
653
+ print(f" {sm['working']}/{sm['total']} credentials authenticate "
654
+ f"across {sm['providers_working']} provider(s)")
655
+
656
+ # Authenticating is not the same as being allowed to infer. One call
657
+ # per provider settles it (docs/0034 section 6).
658
+ from agentctl.control.probe import check_inference
659
+ print()
660
+ print(" can they actually infer? (one completion per provider)")
661
+ usable = 0
662
+ for name in sorted({a.provider.name for a in accts}):
663
+ r = check_inference(name, allow_paid=args.check_paid)
664
+ if r is None:
665
+ continue
666
+ print(f" {mark.get(r.status, '?? ')} {name:<15} {r.status:<12} "
667
+ f"{r.detail}")
668
+ usable += 1 if r.status in ("live", "limited") else 0
669
+ print()
670
+ print(f" {usable} provider(s) can serve a request right now")
671
+ broken = [r for r in results if not r.ok]
672
+ if broken:
673
+ print(f" {len(broken)} not usable -- check the console for those")
674
+ print(" a CDN error is NOT a bad key; the request never reached the API")
675
+ return 0 if not broken else 1
676
+
677
+ print()
678
+ print(f" {len(accts)} account(s) across "
679
+ f"{len({a.provider.name for a in accts})} provider(s).")
680
+ if len(accts) <= 1:
681
+ print()
682
+ # docs/0044 N9: this used to lead with a second key at the SAME
683
+ # provider. Whether that is a second quota is unverified, and whether
684
+ # it is allowed is unchecked (docs/0042 §4.C), so it is not advice.
685
+ print()
686
+ print(" One account is fine to start with. A daily cap on it stops")
687
+ print(" work until it resets; a key at a second provider survives")
688
+ print(" that, and an outage too:")
689
+ for p in missing():
690
+ if p.free_tier:
691
+ print(f" {p.name:<11} {p.console}")
692
+ print()
693
+ print(" Extra keys at the same provider (_2, _WORK ...) are accepted,")
694
+ print(" but whether they add quota is unverified and may be against")
695
+ print(" that provider's terms. Check them first (docs/0042 §4.C).")
696
+ return 0
697
+
698
+
699
+ EXAMPLE_SUBAGENT = """---
700
+ name: reviewer
701
+ description: Reads code and reports what it finds. Cannot change anything.
702
+ model: inherit
703
+ tools:
704
+ - read_file
705
+ max_iteration_per_run: 15
706
+ ---
707
+
708
+ You are a careful reader. You can open files and nothing else -- no shell, no
709
+ writes. Answer the question you were asked, cite `path:line` for every claim,
710
+ and say plainly when you could not determine something rather than guessing.
711
+ """
712
+
713
+
714
+ def cmd_subagent(args) -> int:
715
+ """List or run read-only subagents.
716
+
717
+ Read-only is the whole design, not a starter limitation. A subagent that
718
+ cannot produce an effect needs none of the lease, ledger, probe and
719
+ handoff work that multi-agent was priced at (`docs/0038` §4.2), which is
720
+ why this one exists and a writing one does not.
721
+ """
722
+ from agentctl.runtime.subagent import (
723
+ AGENTS_DIR, READ_ONLY_TOOLS, discover, rejection,
724
+ )
725
+
726
+ ws = Path(args.workspace)
727
+ if args.init:
728
+ d = ws / AGENTS_DIR
729
+ d.mkdir(parents=True, exist_ok=True)
730
+ f = d / "reviewer.md"
731
+ if f.exists():
732
+ print(f"{f} already exists; leaving it alone")
733
+ return 0
734
+ f.write_text(EXAMPLE_SUBAGENT, encoding="utf-8")
735
+ print(f"wrote {f}")
736
+ print(f" run it: agentctl subagent reviewer \"what does the gate do?\"")
737
+ return 0
738
+
739
+ defns = discover(ws)
740
+ if not defns:
741
+ print(f"no subagents in {ws / AGENTS_DIR}")
742
+ print(" agentctl subagent --init writes an example")
743
+ return 0
744
+
745
+ if not args.name:
746
+ print(f"read-only subagents in {ws / AGENTS_DIR}\n")
747
+ for d in defns:
748
+ why = rejection(d)
749
+ print(f" {'--' if why else 'ok'} {d.name:<18} "
750
+ f"{(d.description or '')[:46]}")
751
+ if why:
752
+ print(f" REFUSED: {why.splitlines()[0]}")
753
+ print(f"\n They may hold only {sorted(READ_ONLY_TOOLS)} — no shell, no")
754
+ print(" writes, no MCP. That is what lets them run outside the effect")
755
+ print(" ledger: there is no effect to record.")
756
+ return 0
757
+
758
+ match = next((d for d in defns if d.name == args.name), None)
759
+ if match is None:
760
+ print(f"no subagent called {args.name!r}. "
761
+ f"Have: {', '.join(d.name for d in defns)}", file=sys.stderr)
762
+ return 2
763
+ if (why := rejection(match)):
764
+ print(why, file=sys.stderr)
765
+ return 2
766
+ if not args.task:
767
+ print("give it something to do: agentctl subagent "
768
+ f"{args.name} \"<question>\"", file=sys.stderr)
769
+ return 2
770
+
771
+ model = args.model
772
+ if args.source:
773
+ if not args.base_url:
774
+ print(" --source needs a proxy to route through. "
775
+ "`agentctl models` explains the trade.", file=sys.stderr)
776
+ return 2
777
+ from agentctl.control.proxy import SOURCE_PREFIX, sources
778
+ known = {r["source"] for r in sources()}
779
+ if args.source not in known:
780
+ print(f" no source called {args.source!r}. "
781
+ f"You have: {', '.join(sorted(known)) or 'none'}",
782
+ file=sys.stderr)
783
+ return 2
784
+ model = f"openai/{SOURCE_PREFIX}{args.source}"
785
+
786
+ from agentctl.runtime.subagent import run as run_subagent
787
+ print(f"agentctl subagent {match.name}")
788
+ out = run_subagent(match, args.task, workspace=ws, model=model,
789
+ base_url=args.base_url)
790
+ print()
791
+ print(out)
792
+ return 0
793
+
794
+
795
+ def cmd_recon(args) -> int:
796
+ """Fan a read-only question out across sources, split by scarcity.
797
+
798
+ The split is the feature. An even one lets a source with a single quota
799
+ set the pace for sources with six, which on this pool is the difference
800
+ between x1.55 and x4.33 recon jobs per day (`docs/0040` sec 6.1). The plan
801
+ is printed before anything is spent.
802
+ """
803
+ from agentctl.runtime.orchestrate import allocate, describe, fan_out
804
+ from agentctl.runtime.subagent import discover, rejection
805
+
806
+ ws = Path(args.workspace)
807
+ defns = [d for d in discover(ws) if rejection(d) is None]
808
+ match = next((d for d in defns if d.name == args.name), None)
809
+ if match is None:
810
+ have = ", ".join(d.name for d in defns) or "none"
811
+ print(f"no read-only subagent called {args.name!r}. Have: {have}",
812
+ file=sys.stderr)
813
+ return 2
814
+
815
+ items = [i for i in args.items if i.strip()]
816
+ if not items:
817
+ print("give it something to look at", file=sys.stderr)
818
+ return 2
819
+
820
+ sources = args.source or []
821
+ if not sources:
822
+ print("--source is required, at least twice -- a fan-out over one "
823
+ "source is just a subagent.\n `agentctl models` lists them.",
824
+ file=sys.stderr)
825
+ return 2
826
+ if not args.base_url:
827
+ print("--base-url is required: source groups live in the proxy's "
828
+ "config.", file=sys.stderr)
829
+ return 2
830
+
831
+ legs = allocate(items, sources, ratio=_parse_ratio(args.ratio))
832
+ print(f"agentctl recon {match.name} ({len(items)} item(s))\n")
833
+ print(describe(legs))
834
+ total = sum(lg.requests for lg in legs)
835
+ print(f"\n {total} request(s) total, none of them effects.\n")
836
+ if args.dry_run:
837
+ print(" --dry-run: nothing dispatched.")
838
+ return 0
839
+
840
+ def announce(leg):
841
+ mark = "!!" if leg.error else "ok"
842
+ print(f" {mark} {leg.source:<14} "
843
+ f"{leg.error or 'reported ' + str(len(leg.report or '')) + ' chars'}")
844
+
845
+ fan_out(match, args.question, legs, workspace=str(ws),
846
+ base_url=args.base_url, on_leg=announce)
847
+
848
+ print()
849
+ from agentctl.runtime.citations import describe as describe_cites
850
+ from agentctl.runtime.citations import verify as verify_cites
851
+
852
+ for leg in legs:
853
+ if leg.report:
854
+ print(f"---- {leg.source} ({len(leg.items)} item(s)) ----")
855
+ print(leg.report)
856
+ # A scout's report is a claim, not a finding. The first live run
857
+ # returned a confident, cited, wrong answer (`docs/0039` §7), so
858
+ # every citation is resolved against the workspace before the
859
+ # report is presented as anything.
860
+ print()
861
+ print(describe_cites(verify_cites(leg.report, ws)))
862
+ print()
863
+ missing = [lg.source for lg in legs if lg.items and not lg.report]
864
+ if missing:
865
+ print(f" INCOMPLETE: no report from {', '.join(missing)}. "
866
+ f"The findings above cover only what the other legs read.")
867
+ return 0
868
+
869
+
870
+ def _parse_ratio(raw: list[str] | None) -> dict[str, int] | None:
871
+ """`--ratio gemini=1 --ratio mistral=6`, for a caller who measured."""
872
+ if not raw:
873
+ return None
874
+ out = {}
875
+ for pair in raw:
876
+ name, _, n = pair.partition("=")
877
+ if n.isdigit():
878
+ out[name.strip()] = int(n)
879
+ return out or None
880
+
881
+
882
+ def cmd_plugins(args) -> int:
883
+ """What a Claude Code plugin would contribute, and what is refused.
884
+
885
+ Default-deny with an itemised receipt (`docs/0010` §9.2). Read-only agent
886
+ definitions are admitted; hooks, commands, skills and MCP servers are not,
887
+ and are counted rather than silently dropped — a broker that quietly
888
+ discarded half a plugin would leave you believing you had installed
889
+ something you had not.
890
+ """
891
+ from agentctl.runtime.plugins import load
892
+
893
+ info = load(args.path)
894
+ if (err := info.get("error")):
895
+ print(err, file=sys.stderr)
896
+ return 2
897
+
898
+ print(f"{info['name']} {info['version']}")
899
+ if info["description"]:
900
+ print(f" {info['description']}")
901
+ print(f" {info['path']}\n")
902
+
903
+ if info["admitted"]:
904
+ print(f" ADMITTED {len(info['admitted'])} read-only agent(s)")
905
+ for d in info["admitted"]:
906
+ print(f" ok {d.name:<18} {(d.description or '')[:44]}")
907
+ else:
908
+ print(" ADMITTED nothing")
909
+
910
+ if info["rejected"]:
911
+ print(f"\n REJECTED {len(info['rejected'])} agent(s) that could "
912
+ f"change the world")
913
+ for name, why in info["rejected"]:
914
+ print(f" -- {name:<18} {why.splitlines()[0][:60]}")
915
+
916
+ if info["refused"]:
917
+ print("\n REFUSED capabilities this project does not run")
918
+ for cap, n, why in info["refused"]:
919
+ print(f" -- {cap:<18} {n} declared")
920
+ for line in _wrap_plain(why, 58):
921
+ print(f" {line}")
922
+
923
+ print("\n Nothing here has been installed or run. This is the audit.")
924
+ return 0
925
+
926
+
927
+ def _wrap_plain(text: str, width: int) -> list[str]:
928
+ words, lines, cur = text.split(), [], ""
929
+ for w in words:
930
+ if len(cur) + len(w) + 1 > width:
931
+ lines.append(cur)
932
+ cur = w
933
+ else:
934
+ cur = f"{cur} {w}".strip()
935
+ if cur:
936
+ lines.append(cur)
937
+ return lines
938
+
939
+
940
+ def cmd_models(args) -> int:
941
+ """Choose a source. Every row says what choosing it gives up.
942
+
943
+ `pool` is the default and the widest thing you can ask for. A source group
944
+ is narrower on purpose, and narrower means an account-wide daily cap has
945
+ less to fail over to -- which is the failure the multi-account design
946
+ exists to escape (`docs/0033`). So the cost is printed beside every choice
947
+ rather than left for you to discover at the cap.
948
+
949
+ Without `--verify` this reports what is CONFIGURED, not what can serve. It
950
+ says so, because six Cerebras keys authenticate happily and every
951
+ completion returns "Payment required" (`docs/0034` §7).
952
+ """
953
+ from agentctl.control.proxy import POOL, sources
954
+
955
+ rows = sources()
956
+ if not rows:
957
+ print("no provider keys in the environment. `agentctl keys --init`")
958
+ return 1
959
+
960
+ live: set[str] | None = None
961
+ if args.verify:
962
+ from agentctl.control.proxy import verified_providers
963
+ print(" checking which sources can actually serve "
964
+ "(one completion per model id)...\n")
965
+ live, _ = verified_providers()
966
+
967
+ total = sum(r["deployments"] for r in rows)
968
+ accts = sum(r["accounts"] for r in rows)
969
+ print(f" {POOL:<22} {total:>3} deployments {accts:>2} accounts "
970
+ f"the default: every free source at once")
971
+ print(f" {'':<22} {'':>3} {'':>2} "
972
+ f"use this unless you have a reason not to\n")
973
+
974
+ for r in rows:
975
+ mark = " "
976
+ note = ""
977
+ if live is not None:
978
+ if r["source"] in live:
979
+ mark = "ok"
980
+ else:
981
+ mark, note = "$$", " cannot serve a request right now"
982
+ acct = f"{r['accounts']:>2} accounts"
983
+ if r.get("quotas", r["accounts"]) != r["accounts"]:
984
+ acct = f"{r['accounts']:>2} keys/{r['quotas']} quota"
985
+ print(f" {mark} {r['group']:<22} {r['deployments']:>3} deployments "
986
+ f"{acct} gives up {r['gives_up']}"
987
+ f" of {total}{note}")
988
+ for m in r["models"][:3]:
989
+ print(f" {m}")
990
+ if len(r["models"]) > 3:
991
+ print(f" ... and {len(r['models']) - 3} more")
992
+
993
+ if live is None:
994
+ print("\n These are the sources you hold KEYS for, not the ones that")
995
+ print(" can serve. `agentctl models --verify` sends one completion")
996
+ print(" each and marks the difference.")
997
+
998
+ print(f"\n Use one: agentctl run \"...\" --source <name> "
999
+ f"--base-url http://localhost:4000")
1000
+ print(f" Or ask for the group directly: --model openai/{POOL}-<name>")
1001
+ return 0
1002
+
1003
+
1004
+ def cmd_dash(args) -> int:
1005
+ """Providers, failover, effects, spend and policy on one screen."""
1006
+ from agentctl.control.dash import collect, render, to_html
1007
+
1008
+ # The default `ledger.db` that is not there means "every indexed run",
1009
+ # not "this one missing file" (docs/0049 §5).
1010
+ explicit = args.ledger != DEFAULT_LEDGER or args.ledger.exists()
1011
+ data = collect(ledger=args.ledger if explicit else None,
1012
+ cost_ledger=args.cost_ledger,
1013
+ refresh_quota=args.refresh_quota)
1014
+ if args.html:
1015
+ out = Path(args.html)
1016
+ out.parent.mkdir(parents=True, exist_ok=True)
1017
+ out.write_text(to_html(data), encoding="utf-8")
1018
+ print(f"wrote {out}")
1019
+ return 0
1020
+ print(render(data))
1021
+ return 0
1022
+
1023
+
1024
+ def _provider_of(env_var: str) -> str:
1025
+ from agentctl.control.providers import BY_KEY
1026
+ base = env_var.split("_API_KEY")[0] + "_API_KEY"
1027
+ return BY_KEY[base].name
1028
+
1029
+
1030
+ def cmd_proxy(args) -> int:
1031
+ """Run the managed proxy (up/down/status), or generate a config (no action)."""
1032
+ if args.action:
1033
+ from agentctl.control import proxyenv
1034
+ if args.action == "up":
1035
+ s = proxyenv.up(port=args.port, verify=args.verify)
1036
+ print(f"\n route a run through it: agentctl run \"...\" --pool")
1037
+ return 0 if s["answers"] else 1
1038
+ if args.action == "down":
1039
+ proxyenv.down()
1040
+ return 0
1041
+ s = proxyenv.status()
1042
+ print(f" state {s['state']}")
1043
+ print(f" url {proxyenv.url(s['port'])} "
1044
+ f"({'answers' if s['answers'] else 'no answer'})")
1045
+ if s["pid"]:
1046
+ print(f" pid {s['pid']}")
1047
+ print(f" log {s['log']}")
1048
+ print(f" env {s['env']}")
1049
+ return 0 if s["state"] in ("running", "stopped") else 1
1050
+
1051
+ from agentctl.control.proxy import accounts, available, write
1052
+
1053
+ only = None
1054
+ drop: set[str] = set()
1055
+ if not args.verify:
1056
+ print(" --no-verify: this pool is built from credentials, not from "
1057
+ "what can serve.")
1058
+ print(" a deployment that 402s or 404s will end a run rather than "
1059
+ "failing over.\n")
1060
+ if args.verify:
1061
+ from agentctl.control.proxy import verified_providers
1062
+ print(" verifying providers (one completion per model id; paid skipped)...")
1063
+ only, report = verified_providers()
1064
+ for name, r in sorted(report.items()):
1065
+ flag = "ok " if name in only else "-- "
1066
+ print(f" {flag} {name:<12} {r.status:<11} {r.detail[:54]}")
1067
+ from agentctl.control.providers import BY_NAME
1068
+ drop = {f"{BY_NAME[n].prefix}{m}"
1069
+ for n, r in report.items() for m in r.gone}
1070
+ for m in sorted(drop):
1071
+ print(f" left out {m} (the provider no longer serves it)")
1072
+ print()
1073
+
1074
+ entries = [e for e in available()
1075
+ if (only is None or _provider_of(e[0]) in only)
1076
+ and e[1] not in drop]
1077
+ cfg, hook = write(args.out, only=only, drop_models=drop)
1078
+ n_acct = len(accounts(entries))
1079
+
1080
+ print(f"wrote {cfg}")
1081
+ print(f"wrote {hook}")
1082
+ print()
1083
+ print(f" {len(entries)} deployment(s) across {n_acct} account(s):")
1084
+ for env_var, model, short, is_free in entries:
1085
+ print(f" {short:<18} {model} [{'free' if is_free else 'PAID'}]")
1086
+ print()
1087
+ if n_acct == 1:
1088
+ print(" ! ONE ACCOUNT. A pool over one key survives a transient")
1089
+ print(" overload or a per-model limit, but NOT an account-wide")
1090
+ print(" daily cap -- every entry above shares the same quota.")
1091
+ print(" Export a second provider key and re-run this.")
1092
+ print()
1093
+ print(" start it:")
1094
+ print(f" bash {args.out}/start.sh 4000 # or: {args.out}/start.ps1")
1095
+ print(" (the launcher forces UTF-8 -- the proxy banner otherwise")
1096
+ print(" kills startup on a redirected Windows console, docs/0034)")
1097
+ print()
1098
+ print(" then point runs at it (no key needed client-side):")
1099
+ print(" agentctl run \"...\" --workspace ./app \\")
1100
+ print(" --model openai/pool --base-url http://localhost:4000")
1101
+ return 0
1102
+
1103
+
1104
+ def cmd_policy(args) -> int:
1105
+ """Compile a policy, or show what the compiled one says.
1106
+
1107
+ Compiling is a separate, explicit step for the reason in `docs/0012` §5.2:
1108
+ every error a policy can contain should surface HERE, where a person is
1109
+ watching, and never in the middle of a run where the only safe response is
1110
+ to stop the work.
1111
+ """
1112
+ from agentctl.control.policy import PolicyError, compile_to
1113
+ from agentctl.kernel.policy import Policy
1114
+
1115
+ if args.source:
1116
+ try:
1117
+ out = compile_to(args.source, args.out)
1118
+ except PolicyError as e:
1119
+ print(f"{args.source} does not compile:\n{e}", file=sys.stderr)
1120
+ return 2
1121
+ except FileNotFoundError:
1122
+ print(f"no such policy: {args.source}", file=sys.stderr)
1123
+ return 2
1124
+ print(f"compiled {args.source} -> {out}")
1125
+
1126
+ try:
1127
+ pol = Policy.load(args.out)
1128
+ except FileNotFoundError as e:
1129
+ print(e, file=sys.stderr)
1130
+ return 2
1131
+
1132
+ print()
1133
+ print(f"policy {args.out}")
1134
+ print(f" source {pol.source_sha256[:16] or '(inline)'}")
1135
+ print(f" default pool {pol.default_pool} "
1136
+ f"{pol.pool(pol.default_pool or '')}")
1137
+ esc = pol.escalation
1138
+ if esc:
1139
+ print(f" escalate to {esc.get('pool')} "
1140
+ f"({'confirm' if esc.get('require_confirmation') else 'SILENT'})")
1141
+ for scope in ("per_task", "daily"):
1142
+ if (lim := pol.limit(scope)) is not None:
1143
+ print(f" {scope:<13} ${lim:.2f}")
1144
+ print(f" on exceeded {pol.on_exceeded}")
1145
+ print(f" on unpriced {pol.on_unpriced}"
1146
+ " (litellm prices some endpoints at 0.0 -- docs/0021)")
1147
+ for cls in ("PURE_READ", "IDEMPOTENT_WRITE", "NON_IDEMPOTENT_WRITE",
1148
+ "EXTERNAL", "DESTRUCTIVE"):
1149
+ if (rule := pol.effect_rule(cls)):
1150
+ print(f" {cls:<13} {rule}")
1151
+ return 0
1152
+
1153
+
1154
+ # ── entry point ────────────────────────────────────────────────────────
1155
+ def _version() -> str:
1156
+ """The installed distribution's version. A bug report needs it.
1157
+
1158
+ The distribution is `handcode`; the import package is `agentctl`
1159
+ (docs/0051 Stage 1). An install from before the rename is `agentctl`.
1160
+ """
1161
+ from importlib.metadata import PackageNotFoundError, version
1162
+ for dist in ("handcode", "agentctl"):
1163
+ try:
1164
+ return version(dist)
1165
+ except PackageNotFoundError:
1166
+ continue
1167
+ return "unknown"
1168
+
1169
+
1170
+ def build_parser() -> argparse.ArgumentParser:
1171
+ p = argparse.ArgumentParser(
1172
+ prog="agentctl",
1173
+ description="Run a coding agent whose work survives crashes, restarts "
1174
+ "and provider switches. New here: agentctl demo")
1175
+ p.add_argument("--version", action="version",
1176
+ version=f"handcode {_version()} (the agentctl command; python "
1177
+ f"{sys.version.split()[0]}, {sys.platform})")
1178
+ p.add_argument("--ledger", type=Path, default=DEFAULT_LEDGER,
1179
+ help=f"path to the effect ledger (default: {DEFAULT_LEDGER})")
1180
+ p.add_argument("--cost-ledger", type=Path, default=DEFAULT_COST_LEDGER,
1181
+ help=f"path to the cost ledger (default: {DEFAULT_COST_LEDGER})")
1182
+
1183
+ # The same two options AFTER the subcommand, because that is where people
1184
+ # type them: `agentctl status --ledger X` used to be an error telling you
1185
+ # the argument was unrecognised, while `agentctl --ledger X status` worked.
1186
+ # SUPPRESS is what makes this safe -- without it the subparser's default
1187
+ # would overwrite a value given before the subcommand.
1188
+ common = argparse.ArgumentParser(add_help=False)
1189
+ common.add_argument("--ledger", type=Path, default=argparse.SUPPRESS,
1190
+ help=argparse.SUPPRESS)
1191
+ common.add_argument("--cost-ledger", type=Path, default=argparse.SUPPRESS,
1192
+ help=argparse.SUPPRESS)
1193
+
1194
+ sub = p.add_subparsers(dest="command", required=True)
1195
+ _orig_add_parser = sub.add_parser
1196
+
1197
+ def add_parser(name, **kw):
1198
+ kw.setdefault("parents", [common])
1199
+ return _orig_add_parser(name, **kw)
1200
+
1201
+ sub.add_parser = add_parser # every subcommand gets them
1202
+
1203
+ dm = sub.add_parser("demo", help="see a crash duplicate a commit, and agentctl "
1204
+ "prevent it. No key, no network, $0")
1205
+ dm.add_argument("--keep", action="store_true",
1206
+ help="keep the demo's repos and logs, and print where")
1207
+ dm.set_defaults(fn=lambda a: __import__("agentctl.demo", fromlist=["run_demo"])
1208
+ .run_demo(keep=a.keep))
1209
+
1210
+ it = sub.add_parser("init", help="set up a key and a default model, once")
1211
+ it.add_argument("--provider", metavar="NAME",
1212
+ help="the provider to use (default: the first you hold a "
1213
+ "key for, free tiers first; asks if you hold none)")
1214
+ it.add_argument("--model", help="record this model instead of checking one")
1215
+ it.add_argument("--no-verify", dest="verify", action="store_false",
1216
+ help="do not send the one checking completion")
1217
+ it.add_argument("--key-stdin", action="store_true",
1218
+ help="read the key from stdin (for scripts), never echoed")
1219
+ it.add_argument("--check-paid", action="store_true",
1220
+ help="allow the check to call a PAID provider (a few tokens)")
1221
+ it.set_defaults(fn=cmd_init)
1222
+
1223
+ sub.add_parser("status", help="recent runs, and what needs you").set_defaults(fn=cmd_status)
1224
+
1225
+ rs = sub.add_parser("resume", help="continue the last run here, or one by id")
1226
+ rs.add_argument("id", nargs="?", help="a conversation id or its first "
1227
+ "characters (default: the latest run "
1228
+ "in this directory, else anywhere)")
1229
+ rs.add_argument("--takeover", action="store_true",
1230
+ help="take it from a run that may still be alive")
1231
+ rs.add_argument("--accept", metavar="CMD", help="check the result, as for run")
1232
+ rs.add_argument("--wait", metavar="DURATION", help="as for run")
1233
+ rs.add_argument("--model", help="continue on a different model")
1234
+ rs.add_argument("--max-iterations", type=int, default=30)
1235
+ rs.set_defaults(fn=cmd_resume)
1236
+
1237
+ for name, fn, what in (("approve", cmd_approve, "let a queued action run"),
1238
+ ("deny", cmd_deny, "refuse a queued action")):
1239
+ ap_ = sub.add_parser(name, help=f"{what} (the agent is told on resume)")
1240
+ ap_.add_argument("tool_call_id", help="its id, or the first characters")
1241
+ ap_.set_defaults(fn=fn)
1242
+
1243
+ b = sub.add_parser("blocked", help="effects awaiting a human decision")
1244
+ b.add_argument("--conversation", help="limit to one conversation")
1245
+ b.set_defaults(fn=cmd_blocked)
1246
+
1247
+ sh = sub.add_parser("show", help="everything known about one effect")
1248
+ sh.add_argument("tool_call_id")
1249
+ sh.set_defaults(fn=cmd_show)
1250
+
1251
+ r = sub.add_parser("resolve", help="decide a blocked effect")
1252
+ r.add_argument("tool_call_id")
1253
+ g = r.add_mutually_exclusive_group(required=True)
1254
+ g.add_argument("--landed", action="store_true",
1255
+ help="it DID happen; do not run it again")
1256
+ g.add_argument("--retry", dest="landed", action="store_false",
1257
+ help="it did NOT happen; allow a retry")
1258
+ r.set_defaults(fn=cmd_resolve)
1259
+
1260
+ rn = sub.add_parser("run", help="run an agent on a real workspace")
1261
+ rn.add_argument("task", help="what you want done")
1262
+ rn.add_argument("--workspace", type=Path, default=Path("."),
1263
+ help="directory the agent works in (default: cwd)")
1264
+ rn.add_argument("--model", help="litellm model id. Default: AGENTCTL_MODEL, "
1265
+ "then ~/.agentctl/config.toml (`agentctl "
1266
+ "init`), then your first provider's default")
1267
+ rn.add_argument("--base-url", help="an OpenAI-compatible endpoint, e.g. your "
1268
+ "proxy. Default: AGENTCTL_BASE_URL, then "
1269
+ "config.toml")
1270
+ rn.add_argument("--pool", action="store_true",
1271
+ help="route through the managed proxy pool, starting it "
1272
+ "if it is not running (`agentctl proxy up`)")
1273
+ rn.add_argument("--source", metavar="NAME",
1274
+ help="route this run to ONE provider (see `agentctl "
1275
+ "models`). Narrower than the default pool, so a "
1276
+ "daily cap has less to fail over to. Needs "
1277
+ "--base-url.")
1278
+ rn.add_argument("--accept", metavar="CMD",
1279
+ help="a command agentctl runs itself when the agent is "
1280
+ "done, e.g. \"python -m pytest -q\". Exit 0 = PASS. "
1281
+ "Without it the outcome is reported as not checked")
1282
+ rn.add_argument("--wait", metavar="DURATION",
1283
+ help="if a provider rate-limits the run, wait up to this "
1284
+ "long (90s, 30m, 2h) for the limit to reset and "
1285
+ "resume automatically")
1286
+ rn.add_argument("--report-json", type=Path, metavar="PATH",
1287
+ help="also write the end-of-run report as JSON, for a "
1288
+ "program to read (the GitHub Action does)")
1289
+ rn.add_argument("--max-iterations", type=int, default=30)
1290
+ rn.add_argument("--max-budget", type=float, help="hard USD ceiling for the run")
1291
+ rn.add_argument("--resume", help="conversation id to continue")
1292
+ rn.add_argument("--takeover", action="store_true",
1293
+ help="with --resume: take the conversation from a run "
1294
+ "that may still be alive. Not needed after a crash "
1295
+ "on this machine -- a dead holder is detected.")
1296
+ rn.add_argument("--policy", metavar="POLICY",
1297
+ help="policy.yaml or a compiled policy. Budget caps and "
1298
+ "effect rules are enforced before the run starts.")
1299
+ rn.add_argument("--record", metavar="CASSETTE",
1300
+ help="write every completion to a cassette for later replay")
1301
+ rn.add_argument("--replay", metavar="CASSETTE",
1302
+ help="serve completions from a cassette: no key, no "
1303
+ "network, no tokens, no sampling")
1304
+ rn.add_argument("--allow-destructive", action="store_true",
1305
+ help="do not ask before rm -rf, or before a write that "
1306
+ "lands outside the workspace. Think first.")
1307
+ rn.set_defaults(fn=cmd_run)
1308
+
1309
+ ky = sub.add_parser("keys", help="provider keys: what is set, where to get more")
1310
+ ky.add_argument("--init", action="store_true",
1311
+ help="write a keys.env template listing every provider")
1312
+ ky.add_argument("--file", help="path to the keys file")
1313
+ ky.add_argument("--install-hook", action="store_true",
1314
+ help="install a git pre-commit hook that refuses any "
1315
+ "commit containing one of your keys")
1316
+ ky.add_argument("--check-paid", action="store_true",
1317
+ help="also send one tiny completion to PAID providers. "
1318
+ "This costs money, so it is off by default.")
1319
+ ky.add_argument("--check", action="store_true",
1320
+ help="test every key against the provider. Uses metadata "
1321
+ "endpoints, so it costs no tokens and no quota.")
1322
+ ky.set_defaults(fn=cmd_keys)
1323
+
1324
+ rc = sub.add_parser("recon", help="fan a read-only question across "
1325
+ "sources, split by quota scarcity")
1326
+ rc.add_argument("name", help="which read-only subagent")
1327
+ rc.add_argument("question", help="what to find out")
1328
+ rc.add_argument("items", nargs="*", help="files or areas to divide up")
1329
+ rc.add_argument("--source", action="append", metavar="NAME",
1330
+ help="a source to use. Repeat it; see `agentctl models`.")
1331
+ rc.add_argument("--ratio", action="append", metavar="NAME=N",
1332
+ help="override a source's share, if you have measured "
1333
+ "its real limits today")
1334
+ rc.add_argument("--workspace", type=Path, default=Path("."))
1335
+ rc.add_argument("--base-url", help="the proxy. Required: source groups "
1336
+ "live in its config.")
1337
+ rc.add_argument("--dry-run", action="store_true",
1338
+ help="print the plan and dispatch nothing")
1339
+ rc.set_defaults(fn=cmd_recon)
1340
+
1341
+ pl = sub.add_parser("plugins", help="what a Claude Code plugin would "
1342
+ "contribute, and what is refused")
1343
+ pl.add_argument("path", help="the plugin directory")
1344
+ pl.set_defaults(fn=cmd_plugins)
1345
+
1346
+ sa = sub.add_parser("subagent", help="read-only subagents: list one, run one")
1347
+ sa.add_argument("name", nargs="?", help="which one (omit to list)")
1348
+ sa.add_argument("task", nargs="?", help="what to ask it")
1349
+ sa.add_argument("--workspace", type=Path, default=Path("."),
1350
+ help="directory it reads from (default: cwd)")
1351
+ sa.add_argument("--init", action="store_true",
1352
+ help="write an example definition and exit")
1353
+ sa.add_argument("--model", help="model for a definition saying `inherit`")
1354
+ sa.add_argument("--base-url", help="an OpenAI-compatible endpoint")
1355
+ sa.add_argument("--source", metavar="NAME",
1356
+ help="route it to ONE provider through the proxy, with "
1357
+ "failover across that provider's accounts. Needs "
1358
+ "--base-url. See `agentctl models`.")
1359
+ sa.set_defaults(fn=cmd_subagent)
1360
+
1361
+ mo = sub.add_parser("models", help="sources you can route to, and what "
1362
+ "choosing one gives up")
1363
+ mo.add_argument("--verify", action="store_true",
1364
+ help="send one completion per source to find out which "
1365
+ "can actually serve. Costs a request each.")
1366
+ mo.set_defaults(fn=cmd_models)
1367
+
1368
+ da = sub.add_parser("dash", help="one screen: providers, effects, spend, policy")
1369
+ da.add_argument("--html", metavar="OUT",
1370
+ help="write a self-contained HTML page instead")
1371
+ da.add_argument("--refresh-quota", action="store_true",
1372
+ help="also call openrouter.ai for each OpenRouter "
1373
+ "account's remaining free-tier quota. One metadata "
1374
+ "request per account, zero tokens -- never done "
1375
+ "automatically, only on this flag.")
1376
+ da.set_defaults(fn=cmd_dash)
1377
+
1378
+ px = sub.add_parser("proxy", help="run the managed LiteLLM pool (up / down "
1379
+ "/ status), or generate a config")
1380
+ px.add_argument("action", nargs="?", choices=("up", "down", "status"),
1381
+ help="manage the proxy agentctl runs in its own environment "
1382
+ "(~/.agentctl/proxy-env). Omit to only write a config")
1383
+ px.add_argument("--port", type=int, default=4000)
1384
+ px.add_argument("--out", default=".", help="where to write the config "
1385
+ "(generate-only mode)")
1386
+ # Verification is ON by default. `docs/0034` §7 measured what an
1387
+ # unverified pool costs: six Cerebras keys authenticate and return 402 on
1388
+ # every completion, litellm does not treat 402 as retryable, and a live
1389
+ # run died on one. Generating a config known to contain deployments that
1390
+ # cannot serve is not a default worth having -- the flag now buys speed,
1391
+ # not correctness, and says so.
1392
+ px.add_argument("--no-verify", dest="verify", action="store_false",
1393
+ default=True,
1394
+ help="skip the one-completion-per-provider check. Faster, "
1395
+ "and the pool may contain deployments that cannot "
1396
+ "serve -- a 402 from one of them is not retryable "
1397
+ "and will end a run.")
1398
+ px.set_defaults(fn=cmd_proxy)
1399
+
1400
+ dr = sub.add_parser("doctor", help="check everything before you run")
1401
+ dr.add_argument("--workspace", help="also check this workspace")
1402
+ dr.add_argument("--offline", action="store_true",
1403
+ help="skip the provider account probe")
1404
+ dr.set_defaults(fn=cmd_doctor)
1405
+
1406
+ po = sub.add_parser("policy", help="compile and inspect the policy")
1407
+ po.add_argument("source", nargs="?",
1408
+ help="policy.yaml to compile; omit to show the compiled one")
1409
+ # The package's own copy, not a cwd-relative path: that only resolved from
1410
+ # the root of a clone (docs/0044 N14).
1411
+ from agentctl.kernel.policy import DEFAULT_POLICY
1412
+ po.add_argument("--out", type=Path, default=DEFAULT_POLICY,
1413
+ help="where the compiled artifact lives")
1414
+ po.set_defaults(fn=cmd_policy)
1415
+
1416
+ c = sub.add_parser("cost", help="what the work cost, and how much is known")
1417
+ c.add_argument("--today", action="store_true", help="last 24 hours only")
1418
+ c.add_argument("--conversation", help="one conversation")
1419
+ c.add_argument("--by-deployment", action="store_true")
1420
+ c.add_argument("--by-conversation", action="store_true")
1421
+ c.set_defaults(fn=cmd_cost)
1422
+
1423
+ i = sub.add_parser("ingest", help="load Seam A telemetry into the cost ledger")
1424
+ i.add_argument("telemetry", type=Path)
1425
+ i.set_defaults(fn=cmd_ingest)
1426
+ return p
1427
+
1428
+
1429
+ def main(argv: list[str] | None = None) -> int:
1430
+ _ascii_stdout()
1431
+ # The SDK prints a ten-line banner on import, on every command -- `doctor`,
1432
+ # `keys`, `dash` -- ending "Report a bug: github.com/OpenHands/...", which
1433
+ # is the wrong place for an agentctl bug (docs/0031, 0044 N10). Set before
1434
+ # anything imports it; an explicit value in the shell still wins.
1435
+ import os
1436
+ os.environ.setdefault("OPENHANDS_SUPPRESS_BANNER", "1")
1437
+ # Load keys.env before anything reads the environment. An already-exported
1438
+ # variable wins: something you set deliberately in a shell should not be
1439
+ # replaced by a file. Values are never printed (`docs/0032`).
1440
+ try:
1441
+ from agentctl.control.keys import load_quietly
1442
+ load_quietly()
1443
+ except Exception: # noqa: BLE001
1444
+ pass # never block the CLI
1445
+ args = build_parser().parse_args(argv)
1446
+ return args.fn(args)
1447
+
1448
+
1449
+ if __name__ == "__main__":
1450
+ raise SystemExit(main())