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/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())
|