handcode 0.3.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. agentctl/__init__.py +0 -0
  2. agentctl/adapters/__init__.py +0 -0
  3. agentctl/adapters/litellm/__init__.py +9 -0
  4. agentctl/adapters/litellm/hook.py +49 -0
  5. agentctl/adapters/litellm/recorder.py +187 -0
  6. agentctl/adapters/openhands/__init__.py +169 -0
  7. agentctl/adapters/openhands/handoff.py +155 -0
  8. agentctl/adapters/openhands/seam_b.py +259 -0
  9. agentctl/adapters/openhands/seam_c.py +209 -0
  10. agentctl/cli.py +1450 -0
  11. agentctl/control/__init__.py +0 -0
  12. agentctl/control/cost/__init__.py +4 -0
  13. agentctl/control/cost/ledger.py +210 -0
  14. agentctl/control/dash.py +697 -0
  15. agentctl/control/keys.py +440 -0
  16. agentctl/control/matrix/__init__.py +0 -0
  17. agentctl/control/matrix/data/tools.yaml +149 -0
  18. agentctl/control/policy/__init__.py +10 -0
  19. agentctl/control/policy/compile.py +258 -0
  20. agentctl/control/policy/data/policy.compiled.json +38 -0
  21. agentctl/control/policy/data/policy.yaml +46 -0
  22. agentctl/control/probe.py +399 -0
  23. agentctl/control/providers.py +293 -0
  24. agentctl/control/proxy.py +536 -0
  25. agentctl/control/proxyenv.py +309 -0
  26. agentctl/control/replay/__init__.py +14 -0
  27. agentctl/control/replay/cassette.py +281 -0
  28. agentctl/control/replay/server.py +109 -0
  29. agentctl/demo/__init__.py +214 -0
  30. agentctl/demo/child.py +84 -0
  31. agentctl/demo/mock.py +79 -0
  32. agentctl/demo/tool.py +62 -0
  33. agentctl/gha.py +488 -0
  34. agentctl/kernel/__init__.py +0 -0
  35. agentctl/kernel/classify.py +170 -0
  36. agentctl/kernel/gate.py +391 -0
  37. agentctl/kernel/hook.py +229 -0
  38. agentctl/kernel/ledger/__init__.py +0 -0
  39. agentctl/kernel/ledger/models.py +160 -0
  40. agentctl/kernel/ledger/schema.sql +62 -0
  41. agentctl/kernel/ledger/store.py +596 -0
  42. agentctl/kernel/paths.py +203 -0
  43. agentctl/kernel/policy.py +160 -0
  44. agentctl/kernel/reconcile/__init__.py +31 -0
  45. agentctl/kernel/reconcile/base.py +106 -0
  46. agentctl/kernel/reconcile/external.py +137 -0
  47. agentctl/kernel/reconcile/filesystem.py +162 -0
  48. agentctl/kernel/reconcile/git.py +162 -0
  49. agentctl/runtime/__init__.py +20 -0
  50. agentctl/runtime/citations.py +179 -0
  51. agentctl/runtime/config.py +97 -0
  52. agentctl/runtime/doctor.py +335 -0
  53. agentctl/runtime/init.py +148 -0
  54. agentctl/runtime/lease.py +143 -0
  55. agentctl/runtime/orchestrate.py +187 -0
  56. agentctl/runtime/plugins.py +130 -0
  57. agentctl/runtime/report.py +361 -0
  58. agentctl/runtime/runner.py +787 -0
  59. agentctl/runtime/runs.py +191 -0
  60. agentctl/runtime/subagent.py +274 -0
  61. agentctl/runtime/tools.py +350 -0
  62. handcode-0.3.0rc1.dist-info/METADATA +659 -0
  63. handcode-0.3.0rc1.dist-info/RECORD +67 -0
  64. handcode-0.3.0rc1.dist-info/WHEEL +5 -0
  65. handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
  66. handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
  67. handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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