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
@@ -0,0 +1,697 @@
1
+ r"""One screen: providers, failover, effects, spend, policy.
2
+
3
+ agentctl dash the terminal view
4
+ agentctl dash --html out.html a page you can open or hand to someone
5
+ agentctl dash --refresh-quota also spend one metadata call per
6
+ OpenRouter account on its quota
7
+
8
+ Everything here is **derived**, never asserted. Each panel reads a real source
9
+ -- the environment, a running proxy, the effect ledger, the cost ledger, the
10
+ compiled policy -- and a panel with nothing behind it says so rather than
11
+ showing a confident zero. `docs/0021` §5 is the reason: a cost of `$0.00` that
12
+ actually means "no data" is worse than a blank, because it reads as good news.
13
+
14
+ ## The pool panel and the capacity panel are different claims
15
+
16
+ Set `AGENTCTL_PROXY_URL` and the PROVIDERS panel reports what a running proxy
17
+ actually **loaded** (`GET /model/info`) instead of what the environment
18
+ implies -- a stale or hand-edited config becomes visible instead of silently
19
+ assumed correct (`docs/0038` §5.2). That is a fact about *configuration*.
20
+
21
+ **It is never read as a fact about capacity right now.** Whether the pool can
22
+ actually serve a request depends on router cooldown state, and that state has
23
+ no HTTP surface at all -- the one endpoint that would show it (`GET /health`)
24
+ answers by spending one real completion per deployment, i.e. it spends the
25
+ very quota it would be reporting on
26
+ (`research/phase-10-3-model-selection.md` §6.4). So "CAN I WORK RIGHT NOW?"
27
+ always answers **UNKNOWN**, with the reasons composed from whatever is
28
+ actually known. A green light built from anything short of that would be a
29
+ guess dressed as a fact -- do not build one.
30
+
31
+ Key values are never displayed. The provider panel shows `set`, never the key.
32
+ OpenRouter's quota is the one honest quota number this pool has
33
+ (`docs/0038` §5.3); it is fetched only on `--refresh-quota`, never polled.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import html
38
+ import json
39
+ import os
40
+ import time
41
+ import urllib.error
42
+ import urllib.request
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ from .providers import BY_NAME, PROVIDERS, missing
47
+
48
+ # `/health/liveliness` must answer before `/model/info` is worth asking for;
49
+ # it is cheap and instant when the proxy is actually up, so it stays short.
50
+ _PROXY_LIVENESS_TIMEOUT = 2
51
+ _PROXY_INFO_TIMEOUT = 5
52
+
53
+
54
+ def collect(ledger: str | Path | None = None,
55
+ cost_ledger: str | Path | None = None,
56
+ proxy_url: str | None = None,
57
+ refresh_quota: bool = False) -> dict[str, Any]:
58
+ """Everything the dashboard shows. Pure gathering, no formatting.
59
+
60
+ `proxy_url` defaults to `AGENTCTL_PROXY_URL`. Unset, the pool panel is
61
+ exactly what it always was: derived from the environment. `refresh_quota`
62
+ must be passed explicitly -- nothing here polls OpenRouter on its own.
63
+ """
64
+ if proxy_url is None:
65
+ proxy_url = os.environ.get("AGENTCTL_PROXY_URL") or None
66
+ pool = _proxy_pool(proxy_url) if proxy_url else {"configured": False}
67
+ return {
68
+ "generated": time.time(),
69
+ "proxy": pool,
70
+ "providers": _providers(pool),
71
+ "failover": _failover(),
72
+ "capacity": _capacity(pool),
73
+ "quota": _quota(refresh_quota),
74
+ "effects": _effects(ledger),
75
+ "cost": _cost(cost_ledger),
76
+ "policy": _policy(),
77
+ }
78
+
79
+
80
+ # ── panels ─────────────────────────────────────────────────────────────
81
+ def _proxy_pool(url: str) -> dict:
82
+ """What a running proxy actually loaded. `docs/0038` §5.2;
83
+ `research/phase-10-3-model-selection.md` §2.2 (VERIFIED V9-V18), §6.1.
84
+
85
+ Two calls, in order -- the second is only worth making once the first
86
+ says something is listening:
87
+
88
+ * `GET /health/liveliness` -- the literal text `"I'm alive!"`, **not
89
+ JSON** (measured, V12). A reachability check, nothing more; it is not
90
+ parsed as JSON on purpose.
91
+ * `GET /model/info` -- one row per DEPLOYMENT (not per model group --
92
+ `/v1/models` collapses everything to `pool`/`paid` and is useless
93
+ here, V9). Unauthenticated, because the config this project generates
94
+ sets no `master_key` (V11); `api_key` is stripped server-side before
95
+ it ever reaches here (V10).
96
+
97
+ Deployment counts are never assumed. Whatever the proxy reports --
98
+ 3, 48, or 0 -- is what gets counted; nothing here hardcodes a pool size.
99
+ """
100
+ from .proxy import parse_deployment_id
101
+
102
+ base = url.rstrip("/")
103
+ now = time.time()
104
+ try:
105
+ req = urllib.request.Request(base + "/health/liveliness")
106
+ with urllib.request.urlopen(req, timeout=_PROXY_LIVENESS_TIMEOUT) as fh:
107
+ fh.read(200)
108
+ except Exception as e: # noqa: BLE001
109
+ return {"configured": True, "reachable": False, "source": url,
110
+ "observed_at": now,
111
+ "why": f"proxy not reachable at {url}: {type(e).__name__}"}
112
+
113
+ try:
114
+ req = urllib.request.Request(base + "/model/info",
115
+ headers={"Accept": "application/json"})
116
+ with urllib.request.urlopen(req, timeout=_PROXY_INFO_TIMEOUT) as fh:
117
+ info = json.loads(fh.read())
118
+ except Exception as e: # noqa: BLE001
119
+ return {"configured": True, "reachable": True, "source": url,
120
+ "observed_at": now,
121
+ "why": f"proxy is up but /model/info failed: "
122
+ f"{type(e).__name__}: {e}"}
123
+
124
+ rows = info.get("data") if isinstance(info, dict) else None
125
+ if not isinstance(rows, list):
126
+ return {"configured": True, "reachable": True, "source": url,
127
+ "observed_at": now,
128
+ "why": "/model/info answered but its shape was not the "
129
+ "one this proxy version was measured to return"}
130
+
131
+ # Group deployments back into accounts. An id that does not match
132
+ # `agentctl`'s own `provider-aN-mI` format is counted as unmatched,
133
+ # never guessed at -- it may belong to a hand-edited or foreign config.
134
+ by_acct: dict[str, dict] = {}
135
+ unmatched = 0
136
+ for row in rows:
137
+ mi = (row.get("model_info") or {}) if isinstance(row, dict) else {}
138
+ parsed = parse_deployment_id(mi.get("id") or "")
139
+ if parsed is None:
140
+ unmatched += 1
141
+ continue
142
+ provider, n, _model_idx = parsed
143
+ label = f"{provider}-a{n}"
144
+ acct = by_acct.setdefault(
145
+ label, {"provider": provider, "label": label, "n": n,
146
+ "deployments": 0, "free": True})
147
+ acct["deployments"] += 1
148
+ acct["free"] = acct["free"] and bool(mi.get("free", True))
149
+
150
+ return {"configured": True, "reachable": True, "source": url,
151
+ "observed_at": now, "deployments": len(rows),
152
+ "unmatched": unmatched,
153
+ "accounts": sorted(by_acct.values(),
154
+ key=lambda a: (a["provider"], a["n"]))}
155
+
156
+
157
+ def _providers(pool: dict | None = None) -> list[dict]:
158
+ """One row per ACCOUNT, not per provider.
159
+
160
+ Two keys at one provider are two quotas, and collapsing them into a single
161
+ row would hide the failover you actually bought (`docs/0033`).
162
+
163
+ When a proxy is configured and its `/model/info` answered, rows come from
164
+ what it actually loaded rather than from what the environment implies --
165
+ that is the upgrade in `docs/0038` §5.2. Any other case (no proxy
166
+ configured, unreachable, or an unreadable response -- see `pool["why"]`)
167
+ falls back to the environment, exactly as before this existed.
168
+ """
169
+ if pool and pool.get("accounts"):
170
+ when = time.strftime("%H:%M:%S", time.localtime(pool["observed_at"]))
171
+ rows = []
172
+ for acct in pool["accounts"]:
173
+ p = BY_NAME.get(acct["provider"])
174
+ rows.append({
175
+ "name": acct["label"], "env": p.key if p else "?",
176
+ "configured": True,
177
+ "detail": f"{acct['deployments']} deployment(s) loaded by "
178
+ f"the proxy -- source: /model/info at {when}",
179
+ "free": acct["free"],
180
+ "model": p.default_model if p else "",
181
+ })
182
+ return rows
183
+ return _providers_from_env()
184
+
185
+
186
+ def _providers_from_env() -> list[dict]:
187
+ """The original panel: what the environment implies is configured."""
188
+ from .providers import accounts_for
189
+
190
+ rows = []
191
+ for p in PROVIDERS:
192
+ mine = accounts_for(p)
193
+ if not mine:
194
+ rows.append({"name": p.name, "env": p.key, "configured": False,
195
+ "detail": p.console, "free": p.free_tier,
196
+ "model": p.default_model})
197
+ continue
198
+ for a in mine:
199
+ rows.append({
200
+ "name": a.label, "env": a.env, "configured": True,
201
+ # Length only. Never the value.
202
+ "detail": f"set ({len(os.environ.get(a.env, ''))} chars)",
203
+ "free": p.free_tier, "model": p.default_model,
204
+ })
205
+ return rows
206
+
207
+
208
+ def _capacity(pool: dict) -> dict:
209
+ """Can I work right now? Always **UNKNOWN** -- see the module docstring.
210
+
211
+ Not a placeholder waiting for a better source: research established there
212
+ is no honest zero-cost source. Cooldown state lives in the router's
213
+ in-process cache, reachable from no endpoint (V14); `healthy_only` fails
214
+ open and measures background-health-check state, a different thing
215
+ (V15); the Prometheus gauge was measured still reporting complete
216
+ outage 15 seconds after a cooldown expired (V16); `GET /health` costs
217
+ one real completion per deployment (V13). There is deliberately no code
218
+ path in this function that returns anything but `UNKNOWN`.
219
+ """
220
+ if not pool or not pool.get("configured"):
221
+ return {"verdict": "UNKNOWN",
222
+ "detail": "no proxy is configured (set AGENTCTL_PROXY_URL) "
223
+ "-- and capacity would not be knowable even with "
224
+ "one: cooldown state has no HTTP surface."}
225
+ if not pool.get("reachable"):
226
+ return {"verdict": "UNKNOWN",
227
+ "detail": pool.get("why", "the proxy is configured but "
228
+ "not reachable right now.")}
229
+ if "accounts" not in pool:
230
+ return {"verdict": "UNKNOWN",
231
+ "detail": pool.get("why", "the proxy answered, but its "
232
+ "/model/info response could not "
233
+ "be read.")}
234
+ n_dep, n_acct = pool.get("deployments", 0), len(pool["accounts"])
235
+ return {"verdict": "UNKNOWN",
236
+ "detail": (f"the proxy is up and {n_dep} deployment(s) across "
237
+ f"{n_acct} account(s) are configured, but the "
238
+ f"router's cooldown state is not exposed on any "
239
+ f"endpoint, and a live probe (GET /health) would "
240
+ f"spend {n_dep} requests of the quota it reports on.")}
241
+
242
+
243
+ # Fixed reasons for the providers that expose no usable quota signal to an
244
+ # ordinary inference key (`docs/0038` §5.3, `research/phase-10-3-model-
245
+ # selection.md` §2.3). Structural, not a docstring promise: `_quota` below
246
+ # has no code path that turns one of these into a number.
247
+ _QUOTA_REASON = {
248
+ "gemini": "Gemini publishes no quota endpoint or header; the only "
249
+ "signal is the 429 when it arrives",
250
+ "mistral": "the usage API needs an Admin key from the Backoffice, and "
251
+ "reports consumed spend, not what remains",
252
+ "cerebras": "no documented quota signal for an ordinary inference key",
253
+ "groq": "no quota endpoint; remaining is reported only on a response "
254
+ "header, and only once a request has actually been made",
255
+ "anthropic": "the Rate Limits API needs an Admin key, documented as "
256
+ "unavailable for individual accounts",
257
+ "openai": "no free tier here; not polled",
258
+ }
259
+
260
+
261
+ def _quota(refresh: bool) -> list[dict]:
262
+ """Per-account quota. **OpenRouter is the only provider with an honest
263
+ number here** (`docs/0038` §5.3) -- every other row says why it has none,
264
+ never a `0`.
265
+
266
+ Manual refresh only, enforced structurally: the network call happens if
267
+ and only if `refresh` is `True`. Nothing in this function is ever polled.
268
+ """
269
+ from .providers import all_accounts
270
+
271
+ rows = []
272
+ for a in all_accounts():
273
+ if a.provider.name != "openrouter":
274
+ rows.append({"label": a.label, "value": None,
275
+ "reason": _QUOTA_REASON.get(
276
+ a.provider.name, "no documented quota signal")})
277
+ continue
278
+ if not refresh:
279
+ rows.append({"label": a.label, "value": None,
280
+ "reason": "not checked this session "
281
+ "(agentctl dash --refresh-quota)"})
282
+ continue
283
+ from .probe import openrouter_quota
284
+ q = openrouter_quota(a)
285
+ when = time.strftime("%H:%M:%S", time.localtime(q.checked_at))
286
+ if q.ok:
287
+ rows.append({
288
+ "label": a.label,
289
+ "value": f"{q.remaining} of {q.limit} free requests remaining",
290
+ "reason": f"source: openrouter /api/v1/key at {when}"})
291
+ else:
292
+ rows.append({"label": a.label, "value": None,
293
+ "reason": f"could not check ({q.detail}) at {when}"})
294
+ return rows
295
+
296
+
297
+ def _failover() -> dict:
298
+ from .providers import PROVIDERS, all_accounts, quotas_for
299
+
300
+ accts = all_accounts()
301
+ n = len(accts)
302
+
303
+ # Keys are not allowances. Gemini bills per Google PROJECT, so six keys in
304
+ # one project are one quota -- and this panel exists to say whether a cap
305
+ # has anywhere to fail over to, which is a question about quotas
306
+ # (`docs/0033`, and the Account docstring's own warning one level down).
307
+ quotas = sum(quotas_for(p) for p in PROVIDERS)
308
+ shared = [(p.name, len([a for a in accts if a.provider is p]))
309
+ for p in PROVIDERS
310
+ if not p.quota_per_key and len([a for a in accts
311
+ if a.provider is p]) > 1]
312
+ providers = {a.provider.name for a in accts}
313
+ free = [a for a in accts if a.provider.free_tier]
314
+
315
+ if n == 0:
316
+ verdict, detail = "NONE", "no provider key is set; nothing can run"
317
+ elif n == 1:
318
+ verdict = "SINGLE ACCOUNT"
319
+ detail = (f"only {accts[0].label}. A per-model limit or a transient "
320
+ f"outage is survivable; an account-wide daily cap is not -- "
321
+ f"there is nowhere to go. A key at a second provider is "
322
+ f"what survives one.")
323
+ elif len(providers) == 1:
324
+ # Better than one, and still one provider outage away from zero.
325
+ verdict = "MULTI-ACCOUNT"
326
+ detail = (f"{n} accounts, all at {next(iter(providers))} "
327
+ f"({', '.join(a.label for a in accts)}). A daily cap on one "
328
+ f"leaves {n - 1}; the provider going down takes all of them.")
329
+ else:
330
+ verdict = "READY"
331
+ # Counts per provider, not 31 labels. A detail line nobody reads is
332
+ # the same as no detail line.
333
+ per: dict[str, int] = {}
334
+ for a in accts:
335
+ per[a.provider.name] = per.get(a.provider.name, 0) + 1
336
+ shape = ", ".join(f"{name} x{k}" if k > 1 else name
337
+ for name, k in per.items())
338
+ # "Accounts you HOLD", never "accounts that work". Six Cerebras keys
339
+ # authenticate happily and every completion returns "Payment
340
+ # required" (`docs/0034` §7), so a banner counting credentials as
341
+ # capacity overstates the pool by however many of those you have --
342
+ # and it sits directly above a capacity panel that scrupulously
343
+ # answers UNKNOWN. One honest panel under one confident wrong one is
344
+ # worse than neither (`docs/0039`).
345
+ caveat = ""
346
+ if shared:
347
+ caveat = (" " + "; ".join(
348
+ f"{name}'s {k} keys share ONE quota (one Google project), so "
349
+ f"they are {k - 1} fewer allowances than they look"
350
+ for name, k in shared) + ".")
351
+ detail = (f"{n} keys across {len(providers)} providers ({shape}) are "
352
+ f"CONFIGURED, and they are {quotas} independent quota(s)."
353
+ f"{caveat} A count of credentials, not of what can serve --"
354
+ f" `agentctl keys --check` and `agentctl models --verify` "
355
+ f"answer that.")
356
+ return {"accounts": n, "free_accounts": len(free), "quotas": quotas,
357
+ "providers": len(providers),
358
+ "verdict": verdict, "detail": detail,
359
+ "missing": [{"name": p.name, "console": p.console, "free": p.free_tier}
360
+ for p in missing()]}
361
+
362
+
363
+ def _effects(path: str | Path | None) -> dict:
364
+ """Effects by state and class: one ledger, or every indexed one.
365
+
366
+ With no path and no `./ledger.db`, every ledger a recorded run wrote
367
+ (`~/.agentctl/runs.db`). The panel used to read only `./ledger.db`, and
368
+ said "no effect ledger" from inside a workspace that had one
369
+ (`docs/0044` F4, left open in `docs/0049` §5).
370
+ """
371
+ if path:
372
+ paths, where = [Path(path)], str(path)
373
+ elif Path("ledger.db").exists():
374
+ paths, where = [Path("ledger.db")], "ledger.db"
375
+ else:
376
+ try:
377
+ from agentctl.runtime import runs
378
+ paths = runs.ledgers()
379
+ except Exception: # noqa: BLE001
380
+ paths = []
381
+ where = f"{len(paths)} ledger(s), from the run index"
382
+ paths = [p for p in paths if p.exists()]
383
+ if not paths:
384
+ return {"available": False,
385
+ "why": (f"no effect ledger at {path}" if path else
386
+ "no runs recorded yet -- agentctl run \"<task>\"")}
387
+ rows, convs = [], 0
388
+ try:
389
+ from agentctl.kernel.ledger.store import LedgerStore
390
+ for p in paths:
391
+ with LedgerStore(p, holder="dash") as s:
392
+ rows += s._db.execute(
393
+ "SELECT state, effect_class, COUNT(*) n FROM effect_record "
394
+ "GROUP BY state, effect_class").fetchall()
395
+ convs += s._db.execute("SELECT COUNT(DISTINCT conversation_id) n "
396
+ "FROM effect_record").fetchone()["n"]
397
+ except Exception as e: # noqa: BLE001
398
+ return {"available": False, "why": f"{type(e).__name__}: {e}"}
399
+
400
+ by_state: dict[str, int] = {}
401
+ by_class: dict[str, int] = {}
402
+ for r in rows:
403
+ by_state[r["state"]] = by_state.get(r["state"], 0) + r["n"]
404
+ by_class[r["effect_class"]] = by_class.get(r["effect_class"], 0) + r["n"]
405
+ return {"available": True, "path": where, "conversations": convs,
406
+ "total": sum(by_state.values()),
407
+ "by_state": by_state, "by_class": by_class,
408
+ "blocked": by_state.get("BLOCKED", 0),
409
+ "pending": by_state.get("INTENT", 0)}
410
+
411
+
412
+ def _cost(path: str | Path | None) -> dict:
413
+ p = Path(path) if path else Path("cost.db")
414
+ if not p.exists():
415
+ return {"available": False,
416
+ "why": f"no cost ledger at {p} -- run: agentctl ingest "
417
+ f"<hook_telemetry.json>"}
418
+ try:
419
+ from .cost import CostLedger
420
+ with CostLedger(p) as c:
421
+ all_time = c.totals()
422
+ today = c.totals(since=time.time() - 86400)
423
+ except Exception as e: # noqa: BLE001
424
+ return {"available": False, "why": f"{type(e).__name__}: {e}"}
425
+
426
+ def pack(t):
427
+ return {"spend": t.describe_cost(), "calls": t.calls,
428
+ "coverage": t.coverage, "trustworthy": t.trustworthy,
429
+ "prompt_tokens": t.prompt_tokens,
430
+ "cache_hit_ratio": t.cache_hit_ratio}
431
+ return {"available": True, "path": str(p),
432
+ "today": pack(today), "all_time": pack(all_time)}
433
+
434
+
435
+ def _policy() -> dict:
436
+ try:
437
+ from agentctl.kernel.policy import DEFAULT_POLICY, Policy
438
+ if not DEFAULT_POLICY.exists():
439
+ return {"available": False,
440
+ "why": "not compiled -- agentctl policy "
441
+ "agentctl/control/policy/data/policy.yaml"}
442
+ pol = Policy.load()
443
+ except Exception as e: # noqa: BLE001
444
+ return {"available": False, "why": f"{type(e).__name__}: {e}"}
445
+ return {"available": True,
446
+ "per_task": pol.limit("per_task"), "daily": pol.limit("daily"),
447
+ "on_exceeded": pol.on_exceeded, "on_unpriced": pol.on_unpriced,
448
+ "default_pool": pol.default_pool,
449
+ "effects": {c: pol.effect_rule(c) for c in
450
+ ("DESTRUCTIVE", "EXTERNAL", "NON_IDEMPOTENT_WRITE")
451
+ if pol.effect_rule(c)}}
452
+
453
+
454
+ # ── terminal ───────────────────────────────────────────────────────────
455
+ def render(d: dict) -> str:
456
+ L: list[str] = []
457
+ w = 74
458
+ L.append("=" * w)
459
+ L.append(" agentctl dashboard" + time.strftime(
460
+ "%Y-%m-%d %H:%M", time.localtime(d["generated"])).rjust(w - 21))
461
+ L.append("=" * w)
462
+
463
+ f = d["failover"]
464
+ proxy = d["proxy"]
465
+ L.append("")
466
+ L.append(f" FAILOVER {f['verdict']}")
467
+ for line in _wrap(f["detail"], w - 14):
468
+ L.append(f" {line}")
469
+ if proxy.get("configured"):
470
+ # `docs/0038` §6.4's rule: this verdict describes configured shape,
471
+ # not live capacity, and must not be re-read as the second thing.
472
+ L.append(" (configured shape, not live capacity --")
473
+ L.append(" see CAN I WORK RIGHT NOW below)")
474
+
475
+ L.append("")
476
+ L.append(" PROXY")
477
+ if not proxy.get("configured"):
478
+ L.append(" not configured set AGENTCTL_PROXY_URL to read the "
479
+ "live pool instead of the environment")
480
+ elif not proxy.get("reachable"):
481
+ L.append(f" NOT reachable {proxy['source']}")
482
+ for line in _wrap(proxy.get("why", ""), w - 8):
483
+ L.append(f" {line}")
484
+ elif "accounts" not in proxy:
485
+ L.append(f" up, but unreadable {proxy['source']}")
486
+ for line in _wrap(proxy.get("why", ""), w - 8):
487
+ L.append(f" {line}")
488
+ else:
489
+ when = time.strftime("%H:%M:%S", time.localtime(proxy["observed_at"]))
490
+ L.append(f" up {proxy['source']} source: /health/liveliness "
491
+ f"at {when}")
492
+ L.append(f" {proxy['deployments']} deployment(s) across "
493
+ f"{len(proxy['accounts'])} account(s) "
494
+ f"source: /model/info at {when}")
495
+ if proxy.get("unmatched"):
496
+ L.append(f" {proxy['unmatched']} deployment(s) did not match "
497
+ f"agentctl's own id format -- not counted above")
498
+
499
+ cap = d["capacity"]
500
+ L.append("")
501
+ L.append(f" CAN I WORK RIGHT NOW? {cap['verdict']}")
502
+ for line in _wrap(cap["detail"], w - 4):
503
+ L.append(f" {line}")
504
+
505
+ L.append("")
506
+ L.append(" PROVIDERS")
507
+ for p in d["providers"]:
508
+ mark = "ok " if p["configured"] else "-- "
509
+ tag = "" if p["free"] else " (paid)"
510
+ L.append(f" {mark} {p['name']:<12}{tag:<8} {p['detail']}")
511
+
512
+ if f["missing"]:
513
+ L.append("")
514
+ L.append(" ADD AN ACCOUNT (a second provider is what makes a cap survivable)")
515
+ for m in f["missing"]:
516
+ if m["free"]:
517
+ L.append(f" {m['name']:<12} {m['console']}")
518
+
519
+ L.append("")
520
+ L.append(" QUOTA")
521
+ for q in d["quota"]:
522
+ if q["value"]:
523
+ L.append(f" {q['label']:<16} {q['value']}")
524
+ L.append(f" {'':<16} {q['reason']}")
525
+ else:
526
+ L.append(f" {q['label']:<16} --")
527
+ for line in _wrap(q["reason"], w - 20):
528
+ L.append(f" {'':<16} {line}")
529
+
530
+ e = d["effects"]
531
+ L.append("")
532
+ L.append(" EFFECTS")
533
+ if not e["available"]:
534
+ L.append(f" (none) {e['why']}")
535
+ else:
536
+ L.append(f" {e['total']} effect(s) across {e['conversations']} "
537
+ f"conversation(s)")
538
+ for cls, n in sorted(e["by_class"].items(), key=lambda kv: -kv[1]):
539
+ L.append(f" {cls:<24} {n}")
540
+ if e["blocked"]:
541
+ L.append(f" !! {e['blocked']} BLOCKED -> agentctl blocked")
542
+ if e["pending"]:
543
+ L.append(f" .. {e['pending']} still INTENT (a run may be live)")
544
+
545
+ c = d["cost"]
546
+ L.append("")
547
+ L.append(" SPEND")
548
+ if not c["available"]:
549
+ L.append(f" (none) {c['why']}")
550
+ else:
551
+ for label in ("today", "all_time"):
552
+ t = c[label]
553
+ L.append(f" {label:<10} {t['spend']} {t['calls']} call(s)")
554
+ if t["calls"] and not t["trustworthy"]:
555
+ L.append(f" ! only {t['coverage']:.0%} of calls "
556
+ f"could be priced; the real figure is HIGHER")
557
+
558
+ p = d["policy"]
559
+ L.append("")
560
+ L.append(" POLICY")
561
+ if not p["available"]:
562
+ L.append(f" (none) {p['why']}")
563
+ else:
564
+ caps = " ".join(f"{k} ${v:.2f}" for k, v in
565
+ (("per-task", p["per_task"]), ("daily", p["daily"]))
566
+ if v is not None)
567
+ L.append(f" caps {caps or 'none'} on-exceeded: {p['on_exceeded']}")
568
+ for cls, rule in p["effects"].items():
569
+ L.append(f" {cls:<10} {rule}")
570
+ L.append("")
571
+ return "\n".join(L)
572
+
573
+
574
+ def _wrap(text: str, width: int) -> list[str]:
575
+ import textwrap
576
+ return textwrap.wrap(text, width) or [""]
577
+
578
+
579
+ # ── html ───────────────────────────────────────────────────────────────
580
+ def to_html(d: dict) -> str:
581
+ """A single self-contained page. No CDN, no fonts, no network."""
582
+ def esc(x):
583
+ return html.escape(str(x))
584
+
585
+ f, proxy, cap = d["failover"], d["proxy"], d["capacity"]
586
+ tone = {"READY": "#1a7f37", "MULTI-ACCOUNT": "#7a6a00",
587
+ "SINGLE ACCOUNT": "#9a6700",
588
+ "NONE": "#b3261e"}.get(f["verdict"], "#57606a")
589
+ # Capacity has exactly one verdict, UNKNOWN, and it must never borrow the
590
+ # green FAILOVER tone -- a fixed neutral colour, not a lookup, so there is
591
+ # no dict entry to someday add "READY" to by accident.
592
+ CAPACITY_TONE = "#57606a"
593
+
594
+ def provider_row(p: dict) -> str:
595
+ dot = "&#9679;" if p["configured"] else "&#9675;"
596
+ paid = "" if p["free"] else " <i>paid</i>"
597
+ # `detail` is "set (N chars)" when configured and the console URL when
598
+ # not. Only the second is ever rendered as a link, and the first never
599
+ # contains the key itself.
600
+ if p["configured"]:
601
+ action = "set"
602
+ else:
603
+ action = f'<a href="{esc(p["detail"])}">get a key</a>'
604
+ return (f'<tr class="{"on" if p["configured"] else "off"}">'
605
+ f"<td>{dot}</td>"
606
+ f"<td><b>{esc(p['name'])}</b>{paid}</td>"
607
+ f"<td><code>{esc(p['env'])}</code></td>"
608
+ f"<td>{action}</td></tr>")
609
+
610
+ rows = "".join(provider_row(p) for p in d["providers"])
611
+
612
+ if not proxy.get("configured"):
613
+ proxy_line = "<span class='muted'>not configured -- set AGENTCTL_PROXY_URL</span>"
614
+ elif not proxy.get("reachable") or "accounts" not in proxy:
615
+ proxy_line = f"<span class='warn'>{esc(proxy.get('why', 'unreachable'))}</span>"
616
+ else:
617
+ proxy_line = (f"up -- {proxy['deployments']} deployment(s) across "
618
+ f"{len(proxy['accounts'])} account(s) "
619
+ f"<span class='muted'>(source: /model/info)</span>")
620
+
621
+ def quota_row(q: dict) -> str:
622
+ if q["value"]:
623
+ return (f"<tr><td><b>{esc(q['label'])}</b></td>"
624
+ f"<td>{esc(q['value'])}<br>"
625
+ f"<span class='muted'>{esc(q['reason'])}</span></td></tr>")
626
+ return (f"<tr class='off'><td><b>{esc(q['label'])}</b></td>"
627
+ f"<td class='muted'>-- {esc(q['reason'])}</td></tr>")
628
+
629
+ quota = "<table>" + "".join(quota_row(q) for q in d["quota"]) + "</table>"
630
+
631
+ e, c, pol = d["effects"], d["cost"], d["policy"]
632
+ eff = ("<p class='muted'>" + esc(e["why"]) + "</p>" if not e["available"]
633
+ else "<table>" + "".join(
634
+ f"<tr><td>{esc(k)}</td><td class='n'>{v}</td></tr>"
635
+ for k, v in sorted(e["by_class"].items(), key=lambda kv: -kv[1]))
636
+ + "</table>")
637
+ cost = ("<p class='muted'>" + esc(c["why"]) + "</p>" if not c["available"]
638
+ else "".join(
639
+ f"<p><b>{k.replace('_',' ')}</b> {esc(c[k]['spend'])} "
640
+ f"<span class='muted'>{c[k]['calls']} calls</span>"
641
+ + ("" if c[k]["trustworthy"] or not c[k]["calls"] else
642
+ f"<br><span class='warn'>only {c[k]['coverage']:.0%} priced "
643
+ f"-- the real figure is higher</span>")
644
+ + "</p>" for k in ("today", "all_time")))
645
+ poli = ("<p class='muted'>" + esc(pol["why"]) + "</p>" if not pol["available"]
646
+ else "".join(
647
+ f"<p><b>{esc(k)}</b> {esc(v)}</p>" for k, v in
648
+ [("per-task cap", f"${pol['per_task']:.2f}" if pol["per_task"] else "none"),
649
+ ("daily cap", f"${pol['daily']:.2f}" if pol["daily"] else "none"),
650
+ ("on exceeded", pol["on_exceeded"]),
651
+ *pol["effects"].items()]))
652
+
653
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(d["generated"]))
654
+ return f"""<!doctype html><meta charset="utf-8">
655
+ <title>agentctl dashboard</title>
656
+ <style>
657
+ :root {{ color-scheme: light dark; }}
658
+ body {{ font: 15px/1.5 ui-sans-serif, system-ui, sans-serif; margin: 0;
659
+ background: #f6f8fa; color: #1f2328; }}
660
+ @media (prefers-color-scheme: dark) {{
661
+ body {{ background:#0d1117; color:#e6edf3; }}
662
+ .card {{ background:#161b22 !important; border-color:#30363d !important; }}
663
+ code {{ background:#21262d !important; }} }}
664
+ .wrap {{ max-width: 980px; margin: 0 auto; padding: 28px 20px 60px; }}
665
+ h1 {{ font-size: 20px; margin: 0 0 4px; }}
666
+ .muted {{ color: #57606a; }} .warn {{ color:#9a6700; }}
667
+ .grid {{ display:grid; gap:16px; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); }}
668
+ .card {{ background:#fff; border:1px solid #d0d7de; border-radius:10px; padding:16px; }}
669
+ .card h2 {{ font-size:12px; letter-spacing:.08em; text-transform:uppercase;
670
+ color:#57606a; margin:0 0 10px; }}
671
+ table {{ width:100%; border-collapse:collapse; }}
672
+ td {{ padding:4px 6px; border-bottom:1px solid rgba(128,128,128,.18); }}
673
+ td.n {{ text-align:right; font-variant-numeric:tabular-nums; }}
674
+ tr.off {{ opacity:.55; }}
675
+ code {{ background:#eef1f4; padding:1px 5px; border-radius:4px; font-size:13px; }}
676
+ .banner {{ border-left:4px solid {tone}; padding:10px 14px; margin:0 0 12px;
677
+ background:#fff; border-radius:0 8px 8px 0; }}
678
+ .banner b {{ color:{tone}; }}
679
+ .banner.capacity {{ border-left-color:{CAPACITY_TONE}; margin:0 0 20px; }}
680
+ .banner.capacity b {{ color:{CAPACITY_TONE}; }}
681
+ </style>
682
+ <div class="wrap">
683
+ <h1>agentctl</h1>
684
+ <p class="muted">generated {when} &middot; values are never shown, only whether a key is set</p>
685
+ <div class="banner"><b>FAILOVER: {esc(f['verdict'])}</b><br>{esc(f['detail'])}
686
+ {"<br><span class='muted'>configured shape, not live capacity -- see below</span>" if proxy.get("configured") else ""}</div>
687
+ <p class="muted">proxy: {proxy_line}</p>
688
+ <div class="banner capacity"><b>CAN I WORK RIGHT NOW? {esc(cap['verdict'])}</b><br>{esc(cap['detail'])}</div>
689
+ <div class="grid">
690
+ <div class="card"><h2>Providers</h2><table>{rows}</table></div>
691
+ <div class="card"><h2>Quota</h2>{quota}</div>
692
+ <div class="card"><h2>Effects</h2>{eff}</div>
693
+ <div class="card"><h2>Spend</h2>{cost}</div>
694
+ <div class="card"><h2>Policy</h2>{poli}</div>
695
+ </div>
696
+ </div>
697
+ """