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.
- agentctl/__init__.py +0 -0
- agentctl/adapters/__init__.py +0 -0
- agentctl/adapters/litellm/__init__.py +9 -0
- agentctl/adapters/litellm/hook.py +49 -0
- agentctl/adapters/litellm/recorder.py +187 -0
- agentctl/adapters/openhands/__init__.py +169 -0
- agentctl/adapters/openhands/handoff.py +155 -0
- agentctl/adapters/openhands/seam_b.py +259 -0
- agentctl/adapters/openhands/seam_c.py +209 -0
- agentctl/cli.py +1450 -0
- agentctl/control/__init__.py +0 -0
- agentctl/control/cost/__init__.py +4 -0
- agentctl/control/cost/ledger.py +210 -0
- agentctl/control/dash.py +697 -0
- agentctl/control/keys.py +440 -0
- agentctl/control/matrix/__init__.py +0 -0
- agentctl/control/matrix/data/tools.yaml +149 -0
- agentctl/control/policy/__init__.py +10 -0
- agentctl/control/policy/compile.py +258 -0
- agentctl/control/policy/data/policy.compiled.json +38 -0
- agentctl/control/policy/data/policy.yaml +46 -0
- agentctl/control/probe.py +399 -0
- agentctl/control/providers.py +293 -0
- agentctl/control/proxy.py +536 -0
- agentctl/control/proxyenv.py +309 -0
- agentctl/control/replay/__init__.py +14 -0
- agentctl/control/replay/cassette.py +281 -0
- agentctl/control/replay/server.py +109 -0
- agentctl/demo/__init__.py +214 -0
- agentctl/demo/child.py +84 -0
- agentctl/demo/mock.py +79 -0
- agentctl/demo/tool.py +62 -0
- agentctl/gha.py +488 -0
- agentctl/kernel/__init__.py +0 -0
- agentctl/kernel/classify.py +170 -0
- agentctl/kernel/gate.py +391 -0
- agentctl/kernel/hook.py +229 -0
- agentctl/kernel/ledger/__init__.py +0 -0
- agentctl/kernel/ledger/models.py +160 -0
- agentctl/kernel/ledger/schema.sql +62 -0
- agentctl/kernel/ledger/store.py +596 -0
- agentctl/kernel/paths.py +203 -0
- agentctl/kernel/policy.py +160 -0
- agentctl/kernel/reconcile/__init__.py +31 -0
- agentctl/kernel/reconcile/base.py +106 -0
- agentctl/kernel/reconcile/external.py +137 -0
- agentctl/kernel/reconcile/filesystem.py +162 -0
- agentctl/kernel/reconcile/git.py +162 -0
- agentctl/runtime/__init__.py +20 -0
- agentctl/runtime/citations.py +179 -0
- agentctl/runtime/config.py +97 -0
- agentctl/runtime/doctor.py +335 -0
- agentctl/runtime/init.py +148 -0
- agentctl/runtime/lease.py +143 -0
- agentctl/runtime/orchestrate.py +187 -0
- agentctl/runtime/plugins.py +130 -0
- agentctl/runtime/report.py +361 -0
- agentctl/runtime/runner.py +787 -0
- agentctl/runtime/runs.py +191 -0
- agentctl/runtime/subagent.py +274 -0
- agentctl/runtime/tools.py +350 -0
- handcode-0.3.0rc1.dist-info/METADATA +659 -0
- handcode-0.3.0rc1.dist-info/RECORD +67 -0
- handcode-0.3.0rc1.dist-info/WHEEL +5 -0
- handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
- handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
- handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
agentctl/control/dash.py
ADDED
|
@@ -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 = "●" if p["configured"] else "○"
|
|
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} · 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
|
+
"""
|