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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
r"""Compile `policy.yaml` into the flat artifact the kernel reads. M7.
|
|
2
|
+
|
|
3
|
+
The point is not the file format. It is **where the errors happen**.
|
|
4
|
+
|
|
5
|
+
`docs/0008` R2 says the kernel must keep working when the control plane is
|
|
6
|
+
dead, and `docs/0012` §5.2 says policy compiles to a flat lookup artifact with
|
|
7
|
+
no evaluation in-band. Together those mean every question a policy could raise
|
|
8
|
+
-- is that pool defined? is that effect class real? is a daily cap smaller than
|
|
9
|
+
a per-task cap? -- has to be answered **here**, out of band, where a person is
|
|
10
|
+
watching, and never in the middle of a run where the only available response is
|
|
11
|
+
to fail closed and stop the work.
|
|
12
|
+
|
|
13
|
+
A compiler that merely reformats YAML would be pointless. This one refuses:
|
|
14
|
+
|
|
15
|
+
pool "paid" is not defined (escalate_to.pool)
|
|
16
|
+
daily_usd 0.50 is below per_task_usd 2.00 -- the daily cap can never bind
|
|
17
|
+
unknown effect class "DESCTRUCTIVE" -- did you mean DESTRUCTIVE?
|
|
18
|
+
|
|
19
|
+
The third is the one that matters most. A typo in an effect class is not a
|
|
20
|
+
syntax error and would compile cleanly into an artifact where `DESTRUCTIVE`
|
|
21
|
+
simply has no rule -- so the most dangerous class silently stops requiring
|
|
22
|
+
approval. Policy that fails **open** on a typo is worse than no policy, because
|
|
23
|
+
it reads like protection. So unknown keys are errors, never warnings.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
from agentctl.kernel.ledger.models import EffectClass
|
|
36
|
+
|
|
37
|
+
# What an effect rule is allowed to say. Extending this means teaching the
|
|
38
|
+
# enforcement layer the new verb first -- a rule nothing implements would
|
|
39
|
+
# compile and then do nothing, which is the failure mode this file exists to
|
|
40
|
+
# prevent.
|
|
41
|
+
EFFECT_RULES = {"require_human_approval", "reconcile_or_block", "allow", "block"}
|
|
42
|
+
ON_EXCEEDED = {"block", "warn"}
|
|
43
|
+
ON_UNPRICED = {"block", "warn", "ignore"}
|
|
44
|
+
BUDGET_SCOPES = {"daily_usd", "per_task_usd"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PolicyError(ValueError):
|
|
48
|
+
"""A policy that cannot be compiled. Carries every problem, not the first."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, problems: list[str]):
|
|
51
|
+
self.problems = problems
|
|
52
|
+
super().__init__("\n".join(f" - {p}" for p in problems))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compile_policy(source: str | Path | dict) -> dict:
|
|
56
|
+
"""Validate and flatten. Raises `PolicyError` listing every problem."""
|
|
57
|
+
if isinstance(source, dict):
|
|
58
|
+
raw, digest = source, ""
|
|
59
|
+
else:
|
|
60
|
+
text = Path(source).read_text(encoding="utf-8")
|
|
61
|
+
raw = yaml.safe_load(text) or {}
|
|
62
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
if not isinstance(raw, dict):
|
|
65
|
+
raise PolicyError(["the policy file is not a mapping"])
|
|
66
|
+
|
|
67
|
+
problems: list[str] = []
|
|
68
|
+
out: dict[str, Any] = {
|
|
69
|
+
"version": raw.get("version", 1),
|
|
70
|
+
"compiled_at": time.time(),
|
|
71
|
+
"source_sha256": digest,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pools = _pools(raw, problems)
|
|
75
|
+
out["routing"] = _routing(raw, pools, problems)
|
|
76
|
+
out["budget"] = _budget(raw, problems)
|
|
77
|
+
out["effects"] = _effects(raw, problems)
|
|
78
|
+
out["tiering"] = _tiering(raw, problems)
|
|
79
|
+
|
|
80
|
+
if problems:
|
|
81
|
+
raise PolicyError(problems)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── sections ───────────────────────────────────────────────────────────
|
|
86
|
+
def _pools(raw: dict, problems: list[str]) -> dict[str, list[str]]:
|
|
87
|
+
pools = raw.get("pools") or {}
|
|
88
|
+
if not isinstance(pools, dict):
|
|
89
|
+
problems.append("`pools` must be a mapping of name -> [deployments]")
|
|
90
|
+
return {}
|
|
91
|
+
out = {}
|
|
92
|
+
for name, members in pools.items():
|
|
93
|
+
if not isinstance(members, list) or not members:
|
|
94
|
+
problems.append(f"pool {name!r} must be a non-empty list")
|
|
95
|
+
continue
|
|
96
|
+
if len(set(members)) != len(members):
|
|
97
|
+
problems.append(f"pool {name!r} lists the same deployment twice")
|
|
98
|
+
out[str(name)] = [str(m) for m in members]
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _routing(raw: dict, pools: dict, problems: list[str]) -> dict:
|
|
103
|
+
routing = raw.get("routing") or {}
|
|
104
|
+
if not isinstance(routing, dict):
|
|
105
|
+
problems.append("`routing` must be a mapping")
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
default = routing.get("default_pool")
|
|
109
|
+
if default is not None and str(default) not in pools:
|
|
110
|
+
problems.append(f"pool {str(default)!r} is not defined "
|
|
111
|
+
f"(routing.default_pool)")
|
|
112
|
+
|
|
113
|
+
escalation: dict[str, Any] = {}
|
|
114
|
+
esc = routing.get("escalate_to") or {}
|
|
115
|
+
if esc:
|
|
116
|
+
target = esc.get("pool")
|
|
117
|
+
if target is None:
|
|
118
|
+
problems.append("routing.escalate_to needs a `pool`")
|
|
119
|
+
elif str(target) not in pools:
|
|
120
|
+
problems.append(f"pool {str(target)!r} is not defined "
|
|
121
|
+
f"(routing.escalate_to.pool)")
|
|
122
|
+
if str(target) == str(default):
|
|
123
|
+
problems.append("routing.escalate_to.pool is the default pool, "
|
|
124
|
+
"so escalation would change nothing")
|
|
125
|
+
when = esc.get("when") or {}
|
|
126
|
+
after = when.get("or_after_failures")
|
|
127
|
+
if after is not None and (not isinstance(after, int) or after < 1):
|
|
128
|
+
problems.append("routing.escalate_to.when.or_after_failures must "
|
|
129
|
+
"be a positive integer")
|
|
130
|
+
escalation = {
|
|
131
|
+
"pool": str(target) if target else None,
|
|
132
|
+
"requires_capability": [str(c) for c in
|
|
133
|
+
(when.get("requires_capability") or [])],
|
|
134
|
+
"after_failures": after,
|
|
135
|
+
# `docs/0002` §5: never silently spend. The DEFAULT is to confirm,
|
|
136
|
+
# so omitting the key cannot accidentally authorise paid traffic.
|
|
137
|
+
"require_confirmation": bool(esc.get("require_confirmation", True)),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {"default_pool": str(default) if default else None,
|
|
141
|
+
"pools": pools, "escalation": escalation}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _budget(raw: dict, problems: list[str]) -> dict:
|
|
145
|
+
if "budget" not in raw:
|
|
146
|
+
return {} # no budget is a valid choice, not an error
|
|
147
|
+
budget = raw.get("budget") or {}
|
|
148
|
+
if not isinstance(budget, dict):
|
|
149
|
+
problems.append("`budget` must be a mapping")
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
out: dict[str, Any] = {}
|
|
153
|
+
for key in budget:
|
|
154
|
+
if key not in BUDGET_SCOPES | {"on_exceeded", "on_unpriced"}:
|
|
155
|
+
problems.append(f"unknown budget key {key!r} "
|
|
156
|
+
f"(allowed: {', '.join(sorted(BUDGET_SCOPES))}, "
|
|
157
|
+
f"on_exceeded, on_unpriced)")
|
|
158
|
+
|
|
159
|
+
for scope in BUDGET_SCOPES:
|
|
160
|
+
if (v := budget.get(scope)) is not None:
|
|
161
|
+
try:
|
|
162
|
+
amount = float(v)
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
problems.append(f"budget.{scope} must be a number, got {v!r}")
|
|
165
|
+
continue
|
|
166
|
+
if amount <= 0:
|
|
167
|
+
problems.append(f"budget.{scope} must be positive, got {amount}")
|
|
168
|
+
out[scope] = amount
|
|
169
|
+
|
|
170
|
+
daily, per_task = out.get("daily_usd"), out.get("per_task_usd")
|
|
171
|
+
if daily is not None and per_task is not None and daily < per_task:
|
|
172
|
+
# Compiles fine and is incoherent: one task may exceed the whole day.
|
|
173
|
+
problems.append(f"budget.daily_usd {daily} is below per_task_usd "
|
|
174
|
+
f"{per_task} -- the daily cap can never bind")
|
|
175
|
+
|
|
176
|
+
on_exceeded = str(budget.get("on_exceeded", "block"))
|
|
177
|
+
if on_exceeded not in ON_EXCEEDED:
|
|
178
|
+
problems.append(f"budget.on_exceeded must be one of "
|
|
179
|
+
f"{sorted(ON_EXCEEDED)}, got {on_exceeded!r}")
|
|
180
|
+
out["on_exceeded"] = on_exceeded
|
|
181
|
+
|
|
182
|
+
on_unpriced = str(budget.get("on_unpriced", "warn"))
|
|
183
|
+
if on_unpriced not in ON_UNPRICED:
|
|
184
|
+
problems.append(f"budget.on_unpriced must be one of "
|
|
185
|
+
f"{sorted(ON_UNPRICED)}, got {on_unpriced!r}")
|
|
186
|
+
out["on_unpriced"] = on_unpriced
|
|
187
|
+
|
|
188
|
+
if not (daily or per_task):
|
|
189
|
+
problems.append("`budget` is present but sets no cap; remove it or "
|
|
190
|
+
"add daily_usd / per_task_usd")
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _effects(raw: dict, problems: list[str]) -> dict[str, str]:
|
|
195
|
+
effects = raw.get("effects") or {}
|
|
196
|
+
if not isinstance(effects, dict):
|
|
197
|
+
problems.append("`effects` must be a mapping of class -> rule")
|
|
198
|
+
return {}
|
|
199
|
+
|
|
200
|
+
known = {e.value for e in EffectClass}
|
|
201
|
+
out = {}
|
|
202
|
+
for key, rule in effects.items():
|
|
203
|
+
name = str(key).upper()
|
|
204
|
+
if name not in known:
|
|
205
|
+
# NOT a warning. A typo here fails OPEN -- the real class keeps no
|
|
206
|
+
# rule and silently stops requiring approval.
|
|
207
|
+
hint = _nearest(name, known)
|
|
208
|
+
problems.append(f"unknown effect class {str(key)!r}"
|
|
209
|
+
+ (f" -- did you mean {hint}?" if hint else "")
|
|
210
|
+
+ f" (known: {', '.join(sorted(known))})")
|
|
211
|
+
continue
|
|
212
|
+
if str(rule) not in EFFECT_RULES:
|
|
213
|
+
problems.append(f"unknown rule {str(rule)!r} for {name} "
|
|
214
|
+
f"(known: {', '.join(sorted(EFFECT_RULES))})")
|
|
215
|
+
continue
|
|
216
|
+
out[name] = str(rule)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _tiering(raw: dict, problems: list[str]) -> dict[str, str]:
|
|
221
|
+
"""`tiering:` is refused, not compiled. Removed by Phase 10.4.
|
|
222
|
+
|
|
223
|
+
It used to compile into the artifact, validate cleanly, and be read by
|
|
224
|
+
nothing. `docs/0030` -- M7's own decision document -- recorded that at the
|
|
225
|
+
time: *"declarations ... recorded, not acted on"*, on the understanding
|
|
226
|
+
that `control/proxy.py` would grow a routing loop to consume them. It
|
|
227
|
+
never did, and 17 commits later `Policy.min_tier()` still had no caller.
|
|
228
|
+
|
|
229
|
+
A block that compiles and validates reads like protection. This document's
|
|
230
|
+
own title is that a policy failing open is worse than no policy, so the
|
|
231
|
+
honest handling of a declaration nothing enforces is to refuse it at
|
|
232
|
+
compile time -- where the user is watching -- rather than accept it and
|
|
233
|
+
quietly do nothing. Silently dropping it would be the same lie with less
|
|
234
|
+
evidence.
|
|
235
|
+
"""
|
|
236
|
+
if raw.get("tiering"):
|
|
237
|
+
problems.append(
|
|
238
|
+
"`tiering` is not implemented and nothing routes by it. It was "
|
|
239
|
+
"compiled but never read (`docs/0030`, Phase 10.4); accepting it "
|
|
240
|
+
"would look like protection you do not have. Remove the block.")
|
|
241
|
+
return {}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _nearest(word: str, candidates: set[str]) -> str | None:
|
|
245
|
+
"""A cheap did-you-mean. A typo should cost a second, not an afternoon."""
|
|
246
|
+
import difflib
|
|
247
|
+
hits = difflib.get_close_matches(word, sorted(candidates), n=1, cutoff=0.6)
|
|
248
|
+
return hits[0] if hits else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── writing ────────────────────────────────────────────────────────────
|
|
252
|
+
def compile_to(source: str | Path, out_path: str | Path) -> Path:
|
|
253
|
+
compiled = compile_policy(source)
|
|
254
|
+
p = Path(out_path)
|
|
255
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
p.write_text(json.dumps(compiled, indent=2, sort_keys=True) + "\n",
|
|
257
|
+
encoding="utf-8")
|
|
258
|
+
return p
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"budget": {
|
|
3
|
+
"daily_usd": 2.0,
|
|
4
|
+
"on_exceeded": "block",
|
|
5
|
+
"on_unpriced": "warn",
|
|
6
|
+
"per_task_usd": 0.5
|
|
7
|
+
},
|
|
8
|
+
"compiled_at": 1790203425.3882647,
|
|
9
|
+
"effects": {
|
|
10
|
+
"DESTRUCTIVE": "require_human_approval",
|
|
11
|
+
"EXTERNAL": "reconcile_or_block"
|
|
12
|
+
},
|
|
13
|
+
"routing": {
|
|
14
|
+
"default_pool": "free_tier",
|
|
15
|
+
"escalation": {
|
|
16
|
+
"after_failures": null,
|
|
17
|
+
"pool": "paid",
|
|
18
|
+
"require_confirmation": true,
|
|
19
|
+
"requires_capability": []
|
|
20
|
+
},
|
|
21
|
+
"pools": {
|
|
22
|
+
"free_tier": [
|
|
23
|
+
"openai/pool",
|
|
24
|
+
"openai/pool-openrouter",
|
|
25
|
+
"openai/pool-gemini",
|
|
26
|
+
"openai/pool-mistral",
|
|
27
|
+
"openai/pool-cerebras",
|
|
28
|
+
"openai/pool-groq"
|
|
29
|
+
],
|
|
30
|
+
"paid": [
|
|
31
|
+
"openai/paid"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"source_sha256": "50c85d221bafe8a82ef9bc589167486d578e45b08954a7c2c5fa714a07175f54",
|
|
36
|
+
"tiering": {},
|
|
37
|
+
"version": 1
|
|
38
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# The default policy: what `docs/0013` calls the real daily driver.
|
|
2
|
+
#
|
|
3
|
+
# Edit this, run `agentctl policy compile`, and the kernel picks up the
|
|
4
|
+
# compiled artifact. Nothing here is evaluated in-band -- see
|
|
5
|
+
# `agentctl/kernel/policy.py`.
|
|
6
|
+
version: 1
|
|
7
|
+
|
|
8
|
+
# Pool members are the model strings `agentctl run` actually sends through the
|
|
9
|
+
# proxy: `--model openai/pool`, or `--source <name>` -> `openai/pool-<name>`.
|
|
10
|
+
# They used to name `openrouter/free` and friends, which no run ever sends, so
|
|
11
|
+
# every `--policy` run stopped at "spend on it?" (docs/0042 I-16).
|
|
12
|
+
pools:
|
|
13
|
+
free_tier:
|
|
14
|
+
- openai/pool
|
|
15
|
+
- openai/pool-openrouter
|
|
16
|
+
- openai/pool-gemini
|
|
17
|
+
- openai/pool-mistral
|
|
18
|
+
- openai/pool-cerebras
|
|
19
|
+
- openai/pool-groq
|
|
20
|
+
paid:
|
|
21
|
+
- openai/paid
|
|
22
|
+
|
|
23
|
+
# No automatic triggers: `when: {or_after_failures, requires_capability}` used
|
|
24
|
+
# to sit here, compiled and read by nothing. Escalation is only ever a model
|
|
25
|
+
# you name, confirmed before the first token.
|
|
26
|
+
routing:
|
|
27
|
+
default_pool: free_tier
|
|
28
|
+
escalate_to:
|
|
29
|
+
pool: paid
|
|
30
|
+
# docs/0002 section 5: never silently spend.
|
|
31
|
+
require_confirmation: true
|
|
32
|
+
|
|
33
|
+
budget:
|
|
34
|
+
daily_usd: 2.00
|
|
35
|
+
per_task_usd: 0.50
|
|
36
|
+
on_exceeded: block
|
|
37
|
+
# litellm reports 0.0 for endpoints it cannot price (docs/0021 section 5), so
|
|
38
|
+
# measured spend is a LOWER bound. `warn` keeps working and says so, which is
|
|
39
|
+
# fail-open -- consistent with docs/0008 section 6.5, where cost decisions
|
|
40
|
+
# fail open and only effect decisions fail closed. `block` is the honest
|
|
41
|
+
# alternative and stops most free-tier work.
|
|
42
|
+
on_unpriced: warn
|
|
43
|
+
|
|
44
|
+
effects:
|
|
45
|
+
destructive: require_human_approval
|
|
46
|
+
external: reconcile_or_block
|