bounded-agent 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: astral-sh/setup-uv@v5
9
+ - run: uv sync
10
+ - run: uv run pytest tests -q
11
+ # Known-answer drill: the sentinel ships with a deliberately broken service, and a
12
+ # non-clean verdict exits 1 (the headless surface). CI asserts the plant IS caught —
13
+ # an exit code of 0 here would mean the watchdog missed its own fixture.
14
+ - name: sentinel known-answer (must catch the planted failure)
15
+ run: |
16
+ set +e
17
+ uv run python examples/sentinel/sentinel.py
18
+ code=$?
19
+ set -e
20
+ [ "$code" -eq 1 ] || { echo "expected exit 1 (plant caught), got $code"; exit 1; }
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ *.egg-info/
7
+ examples/sentinel/run_ledger/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hans Scholing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: bounded-agent
3
+ Version: 0.1.0
4
+ Summary: Contract-bounded autonomy for LLM agents: five small primitives that make an agent safe to leave alone.
5
+ Project-URL: Repository, https://github.com/scholih/bounded-agent
6
+ Project-URL: Manifesto, https://medium.com/@scholih/the-edge-is-the-harness-not-the-model-a543abade391
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: pydantic-ai-slim[anthropic]>=2.22.0
11
+ Requires-Dist: pydantic>=2.7
12
+ Requires-Dist: pyyaml>=6.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # bounded-agent
16
+
17
+ **Contract-bounded autonomy for LLM agents: five small primitives that make an agent safe to
18
+ leave alone.**
19
+
20
+ The model is table stakes. The reliability of an autonomous agent lives in the **harness** —
21
+ the deterministic structure around the LLM that decides what it may see, what it may say, what
22
+ it may do, and what happens when it fails. This library is that harness, reduced to its five
23
+ load-bearing primitives, each ~50 lines, each independently testable, built on top of
24
+ [PydanticAI](https://ai.pydantic.dev) (which already solves the typed-LLM-call problem — we
25
+ don't rebuild it, we add the autonomy layer it deliberately leaves to you).
26
+
27
+ The design stance, in one sentence: **deterministic spine, agentic leaves** — code you can
28
+ test decides the flow; the LLM reasons inside well-fenced steps and never pilots the loop.
29
+
30
+ **The manifesto:** [The Edge Is the Harness, Not the Model](https://medium.com/@scholih/the-edge-is-the-harness-not-the-model-a543abade391) — the argument this library implements, and the start of a series that takes each primitive in depth.
31
+
32
+ ## The five primitives
33
+
34
+ | Module | Primitive | The failure it prevents |
35
+ |---|---|---|
36
+ | `contracts.py` | **Contracts & gates** — a declared book of who may do what, at which autonomy tier (observe / propose / act) | an agent quietly acquiring capabilities nobody signed off on |
37
+ | `leaf.py` | **The typed leaf & the escalate-only floor** — LLM output is a validated type or an error *value*; an LLM may raise an alarm, never silence one | a plausible-sounding model response lowering a deterministic alarm |
38
+ | `actions.py` | **The double-closed action set** — an action runs only if it is both *contracted* and *implemented*; everything else is refused and recorded | prompt-injected or hallucinated actions; silent capability creep |
39
+ | `ledgers.py` | **Evidence & idempotency ledgers** — append-only truth for every action and verdict; a finding alerts once per period, not forever | un-auditable agents; alert fatigue that trains humans to ignore the pager |
40
+ | `envelope.py` | **The safety envelope** — ships disarmed, kill-switch checked before anything runs, a hard per-run breaker, and act-then-report (silence is impossible) | the runaway loop; the agent that did things nobody heard about |
41
+
42
+ ## Quickstart
43
+
44
+ ```bash
45
+ uv add bounded-agent # or: pip install bounded-agent
46
+ ```
47
+
48
+ ```python
49
+ from bounded_agent import ContractBook, Gate, ActionSet, Envelope, EvidenceLedger
50
+
51
+ book = ContractBook.load("contracts.yaml") # raises loudly if missing/malformed
52
+ gate = Gate(required_scope="checkout-stack", required_tier="act")
53
+ granted = book.granted(gate) # {contract-name: (allowed actions,)}
54
+
55
+ actions = ActionSet()
56
+ actions.register("restart_worker", restart_worker) # the implemented closed set
57
+
58
+ env = Envelope(armed=True, kill_switch_path=Path("STOP"),
59
+ max_actions_per_run=2, notify=page_the_operator)
60
+ outcome = actions.execute(list(granted.items()), env,
61
+ ledger=EvidenceLedger(Path("ledger")), period=today)
62
+ ```
63
+
64
+ An action fires only if it appears in **both** the contract's `allows` list **and** the
65
+ registered set — and only inside an armed envelope with no kill-switch present and breaker
66
+ budget remaining. Everything that happens (and everything refused) is one JSONL line.
67
+
68
+ For the LLM side, see `examples/sentinel/` — a complete service-health watchdog: deterministic
69
+ probes → an escalate-only LLM verdict → a typed diagnosis → contract-bounded remediation.
70
+ It runs **offline with no API key** (PydanticAI's `TestModel`) so you can study the whole loop
71
+ before connecting a real model.
72
+
73
+ ## What this deliberately is NOT
74
+
75
+ Stating the non-goals is part of the teaching — most agent failures start with adopting more
76
+ machinery than the problem earns:
77
+
78
+ - **Not a planner.** No GOAP, no A*-search over preconditions. If you can hardcode the flow,
79
+ hardcode the flow.
80
+ - **Not a durable-execution engine.** Design your steps idempotent and re-run from clean;
81
+ reach for Temporal/DBOS only when a mid-run crash would genuinely corrupt or double-charge.
82
+ - **Not a graph framework.** Small fixed pipelines and explicit state machines in plain code
83
+ beat a graph DSL until your control flow is genuinely graph-shaped.
84
+ - **Not a PydanticAI replacement.** The typed leaf is solved; this is the autonomy layer
85
+ above it. If PydanticAI grows this layer, this library should shrink.
86
+
87
+ ## Status
88
+
89
+ A **teaching artifact**, maintained best-effort. The patterns here are extracted from a
90
+ production system that runs unattended daily; the code is written fresh for clarity. Issues
91
+ and discussion welcome; roadmap promises are not made.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,81 @@
1
+ # bounded-agent
2
+
3
+ **Contract-bounded autonomy for LLM agents: five small primitives that make an agent safe to
4
+ leave alone.**
5
+
6
+ The model is table stakes. The reliability of an autonomous agent lives in the **harness** —
7
+ the deterministic structure around the LLM that decides what it may see, what it may say, what
8
+ it may do, and what happens when it fails. This library is that harness, reduced to its five
9
+ load-bearing primitives, each ~50 lines, each independently testable, built on top of
10
+ [PydanticAI](https://ai.pydantic.dev) (which already solves the typed-LLM-call problem — we
11
+ don't rebuild it, we add the autonomy layer it deliberately leaves to you).
12
+
13
+ The design stance, in one sentence: **deterministic spine, agentic leaves** — code you can
14
+ test decides the flow; the LLM reasons inside well-fenced steps and never pilots the loop.
15
+
16
+ **The manifesto:** [The Edge Is the Harness, Not the Model](https://medium.com/@scholih/the-edge-is-the-harness-not-the-model-a543abade391) — the argument this library implements, and the start of a series that takes each primitive in depth.
17
+
18
+ ## The five primitives
19
+
20
+ | Module | Primitive | The failure it prevents |
21
+ |---|---|---|
22
+ | `contracts.py` | **Contracts & gates** — a declared book of who may do what, at which autonomy tier (observe / propose / act) | an agent quietly acquiring capabilities nobody signed off on |
23
+ | `leaf.py` | **The typed leaf & the escalate-only floor** — LLM output is a validated type or an error *value*; an LLM may raise an alarm, never silence one | a plausible-sounding model response lowering a deterministic alarm |
24
+ | `actions.py` | **The double-closed action set** — an action runs only if it is both *contracted* and *implemented*; everything else is refused and recorded | prompt-injected or hallucinated actions; silent capability creep |
25
+ | `ledgers.py` | **Evidence & idempotency ledgers** — append-only truth for every action and verdict; a finding alerts once per period, not forever | un-auditable agents; alert fatigue that trains humans to ignore the pager |
26
+ | `envelope.py` | **The safety envelope** — ships disarmed, kill-switch checked before anything runs, a hard per-run breaker, and act-then-report (silence is impossible) | the runaway loop; the agent that did things nobody heard about |
27
+
28
+ ## Quickstart
29
+
30
+ ```bash
31
+ uv add bounded-agent # or: pip install bounded-agent
32
+ ```
33
+
34
+ ```python
35
+ from bounded_agent import ContractBook, Gate, ActionSet, Envelope, EvidenceLedger
36
+
37
+ book = ContractBook.load("contracts.yaml") # raises loudly if missing/malformed
38
+ gate = Gate(required_scope="checkout-stack", required_tier="act")
39
+ granted = book.granted(gate) # {contract-name: (allowed actions,)}
40
+
41
+ actions = ActionSet()
42
+ actions.register("restart_worker", restart_worker) # the implemented closed set
43
+
44
+ env = Envelope(armed=True, kill_switch_path=Path("STOP"),
45
+ max_actions_per_run=2, notify=page_the_operator)
46
+ outcome = actions.execute(list(granted.items()), env,
47
+ ledger=EvidenceLedger(Path("ledger")), period=today)
48
+ ```
49
+
50
+ An action fires only if it appears in **both** the contract's `allows` list **and** the
51
+ registered set — and only inside an armed envelope with no kill-switch present and breaker
52
+ budget remaining. Everything that happens (and everything refused) is one JSONL line.
53
+
54
+ For the LLM side, see `examples/sentinel/` — a complete service-health watchdog: deterministic
55
+ probes → an escalate-only LLM verdict → a typed diagnosis → contract-bounded remediation.
56
+ It runs **offline with no API key** (PydanticAI's `TestModel`) so you can study the whole loop
57
+ before connecting a real model.
58
+
59
+ ## What this deliberately is NOT
60
+
61
+ Stating the non-goals is part of the teaching — most agent failures start with adopting more
62
+ machinery than the problem earns:
63
+
64
+ - **Not a planner.** No GOAP, no A*-search over preconditions. If you can hardcode the flow,
65
+ hardcode the flow.
66
+ - **Not a durable-execution engine.** Design your steps idempotent and re-run from clean;
67
+ reach for Temporal/DBOS only when a mid-run crash would genuinely corrupt or double-charge.
68
+ - **Not a graph framework.** Small fixed pipelines and explicit state machines in plain code
69
+ beat a graph DSL until your control flow is genuinely graph-shaped.
70
+ - **Not a PydanticAI replacement.** The typed leaf is solved; this is the autonomy layer
71
+ above it. If PydanticAI grows this layer, this library should shrink.
72
+
73
+ ## Status
74
+
75
+ A **teaching artifact**, maintained best-effort. The patterns here are extracted from a
76
+ production system that runs unattended daily; the code is written fresh for clarity. Issues
77
+ and discussion welcome; roadmap promises are not made.
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,21 @@
1
+ # The book of what the sentinel may do. A human edits this file; the agent only reads it.
2
+ # tier: observe = read & report · propose = draft a fix, human executes · act = execute `allows`
3
+ contracts:
4
+ - name: queue-worker
5
+ status: active
6
+ scope: checkout-stack
7
+ tier: act
8
+ allows: [restart_worker]
9
+ owner: platform-team # extra fields are fine — the library ignores them
10
+
11
+ - name: cache
12
+ status: active
13
+ scope: checkout-stack
14
+ tier: propose # a human executes cache fixes; the sentinel may only draft
15
+ allows: [clear_cache]
16
+
17
+ - name: payments-db
18
+ status: proposed # not yet signed off — grants NOTHING until status: active
19
+ scope: checkout-stack
20
+ tier: act
21
+ allows: [failover_replica]
@@ -0,0 +1,163 @@
1
+ """The sentinel — a service-health watchdog you can leave alone.
2
+
3
+ The complete contract-bounded loop, end to end, in one file:
4
+
5
+ probes (deterministic) → floor verdict → LLM coherence verdict (escalate-only)
6
+ → typed diagnosis → contract-bounded remediation (double-closed, enveloped)
7
+ → everything ledgered
8
+
9
+ Run it with NO API key (the LLM leaves use PydanticAI's TestModel, so you can study the
10
+ whole control flow offline):
11
+
12
+ uv run python examples/sentinel/sentinel.py
13
+
14
+ Run it against a real model (set ANTHROPIC_API_KEY):
15
+
16
+ uv run python examples/sentinel/sentinel.py --live
17
+
18
+ The simulated stack ships with one deliberately broken service, so every run exercises the
19
+ full path: floor catches it → leaf confirms → diagnosis names it → the contract book decides
20
+ what may be done about it → the envelope bounds the doing → the ledger remembers all of it.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import datetime as dt
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Literal
30
+
31
+ from pydantic import BaseModel
32
+ from pydantic_ai import Agent
33
+ from pydantic_ai.models.test import TestModel
34
+
35
+ from bounded_agent import (
36
+ ActionSet, ContractBook, Envelope, EvidenceLedger, Gate, LeafError, TypedLeaf,
37
+ escalate_only,
38
+ )
39
+
40
+ HERE = Path(__file__).parent
41
+
42
+
43
+ # ── 1. Probes — deterministic sensors over the (simulated) stack ─────────────────────────
44
+
45
+ def run_probes() -> dict[str, dict]:
46
+ """In a real sentinel these hit HTTP health endpoints, disk, queue depth, container
47
+ states. Here they are simulated — with queue-worker deliberately unhealthy."""
48
+ return {
49
+ "checkout-api": {"status": "ok", "detail": "200 in 41ms"},
50
+ "cache": {"status": "ok", "detail": "hit-rate 0.94"},
51
+ "queue-worker": {"status": "fail", "detail": "no heartbeat for 22 min; depth 14 302 and rising"},
52
+ "payments-db": {"status": "ok", "detail": "replica lag 0.4s"},
53
+ }
54
+
55
+
56
+ # ── 2. The deterministic floor — the LLM never gets to overrule this ─────────────────────
57
+
58
+ LEVELS = {"ok": 0, "cannot-assess": 1, "alert": 2}
59
+
60
+ def floor_verdict(probes: dict[str, dict]) -> str:
61
+ statuses = {p["status"] for p in probes.values()}
62
+ if "fail" in statuses:
63
+ return "alert"
64
+ if "unavailable" in statuses: # missing evidence is never "ok"
65
+ return "cannot-assess"
66
+ return "ok"
67
+
68
+
69
+ # ── 3. Two typed leaves — the ONLY places a model runs ───────────────────────────────────
70
+
71
+ class Coherence(BaseModel):
72
+ """Does the evidence cohere as a whole? The leaf may only ESCALATE the floor."""
73
+ level: Literal["ok", "alert"]
74
+ note: str
75
+
76
+ class Diagnosis(BaseModel):
77
+ root_cause: str
78
+ evidence: list[str]
79
+ proposed_next_step: str
80
+ confidence: Literal["high", "medium", "low"]
81
+
82
+
83
+ def build_leaves(live: bool) -> tuple[TypedLeaf, TypedLeaf]:
84
+ if live:
85
+ coherence = Agent("anthropic:claude-sonnet-4-6", output_type=Coherence,
86
+ system_prompt="You review service-health evidence for cross-signal "
87
+ "contradictions the individual checks cannot see.")
88
+ diagnosis = Agent("anthropic:claude-sonnet-4-6", output_type=Diagnosis,
89
+ system_prompt="Diagnose the most likely root cause from the "
90
+ "evidence alone. One concrete next step. Be terse.")
91
+ else: # offline: scripted model outputs — the control flow is identical
92
+ coherence = Agent(TestModel(custom_output_args={
93
+ "level": "alert", "note": "queue depth rising while worker heartbeat silent"}),
94
+ output_type=Coherence)
95
+ diagnosis = Agent(TestModel(custom_output_args={
96
+ "root_cause": "queue-worker process wedged after cache failover",
97
+ "evidence": ["queue-worker heartbeat silent 22m", "depth 14302 rising"],
98
+ "proposed_next_step": "restart queue-worker; page if depth not draining in 10m",
99
+ "confidence": "high"}), output_type=Diagnosis)
100
+ return TypedLeaf(coherence), TypedLeaf(diagnosis)
101
+
102
+
103
+ # ── 4. The run ───────────────────────────────────────────────────────────────────────────
104
+
105
+ def main() -> int:
106
+ ap = argparse.ArgumentParser()
107
+ ap.add_argument("--live", action="store_true", help="use a real model (needs API key)")
108
+ ap.add_argument("--armed", action="store_true",
109
+ help="arm remediation (ships disarmed, like everything should)")
110
+ args = ap.parse_args()
111
+
112
+ today = dt.date.today()
113
+ ledger = EvidenceLedger(HERE / "run_ledger")
114
+ notify = lambda msg: print(f" [notify] {msg}") # stand-in for a pager/Slack hook
115
+
116
+ # observe
117
+ probes = run_probes()
118
+ floor = floor_verdict(probes)
119
+ print(f"floor verdict: {floor}")
120
+
121
+ # reason (escalate-only: the leaf can raise the floor, never lower it)
122
+ coherence_leaf, diagnosis_leaf = build_leaves(args.live)
123
+ coh = coherence_leaf.run(json.dumps(probes))
124
+ proposed = None if isinstance(coh, LeafError) else coh.level
125
+ verdict = escalate_only(floor, proposed, rank=LEVELS.__getitem__)
126
+ if isinstance(coh, LeafError): # leaf failure is a VALUE:
127
+ verdict = escalate_only(verdict, "cannot-assess", rank=LEVELS.__getitem__)
128
+ print(f"merged verdict: {verdict}" + (f" ({coh.note})" if isinstance(coh, Coherence) else ""))
129
+ ledger.append({"kind": "verdict", "floor": floor, "verdict": verdict}, period=today)
130
+
131
+ if verdict == "ok":
132
+ print("clean day — the sentinel goes back to sleep.")
133
+ return 0
134
+
135
+ # diagnose (typed, best-effort)
136
+ diag = diagnosis_leaf.run(json.dumps(probes))
137
+ if isinstance(diag, Diagnosis):
138
+ print(f"diagnosis [{diag.confidence}]: {diag.root_cause}")
139
+ print(f" next step: {diag.proposed_next_step}")
140
+ ledger.append({"kind": "diagnosis", **diag.model_dump()}, period=today)
141
+
142
+ # act — only inside the book, only inside the envelope
143
+ book = ContractBook.load(HERE / "contracts.yaml")
144
+ granted = book.granted(Gate(required_scope="checkout-stack", required_tier="act"))
145
+ failing = [name for name, p in probes.items() if p["status"] == "fail"]
146
+ targets = [(n, granted[n]) for n in failing if n in granted]
147
+ print(f"contract book grants ACT on: {sorted(granted)} → actionable now: "
148
+ f"{[t[0] for t in targets] or 'nothing (alert-only)'}")
149
+
150
+ acts = ActionSet()
151
+ acts.register("restart_worker", lambda: (True, "worker restarted (simulated)"))
152
+ env = Envelope(armed=args.armed, kill_switch_path=HERE / "STOP",
153
+ max_actions_per_run=1, notify=notify)
154
+ outcome = acts.execute(targets, env, ledger=ledger, period=today)
155
+ if outcome.blocked_reason:
156
+ print(f"remediation blocked: {outcome.blocked_reason} "
157
+ f"(run with --armed; `touch STOP` to kill-switch)")
158
+ print(f"ledger: {HERE / 'run_ledger'}")
159
+ return 1 if verdict != "ok" else 0
160
+
161
+
162
+ if __name__ == "__main__":
163
+ sys.exit(main())
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "bounded-agent"
3
+ version = "0.1.0"
4
+ description = "Contract-bounded autonomy for LLM agents: five small primitives that make an agent safe to leave alone."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "pydantic>=2.7",
10
+ "pydantic-ai-slim[anthropic]>=2.22.0",
11
+ "pyyaml>=6.0",
12
+ ]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/scholih/bounded-agent"
16
+ Manifesto = "https://medium.com/@scholih/the-edge-is-the-harness-not-the-model-a543abade391"
17
+
18
+ [dependency-groups]
19
+ dev = ["pytest>=8.0"]
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/bounded_agent"]
@@ -0,0 +1,22 @@
1
+ """bounded-agent — contract-bounded autonomy for LLM agents.
2
+
3
+ Five primitives that make an agent safe to leave alone. Deterministic spine, agentic
4
+ leaves: code you can test decides the flow; the LLM reasons inside well-fenced steps and
5
+ never pilots the loop. See README.md for the full argument; each module's docstring is a
6
+ chapter.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from bounded_agent.actions import ActionRecord, ActionSet, ExecOutcome
11
+ from bounded_agent.contracts import Contract, ContractBook, Gate
12
+ from bounded_agent.envelope import Envelope
13
+ from bounded_agent.leaf import LeafError, TypedLeaf, escalate_only
14
+ from bounded_agent.ledgers import EvidenceLedger, IdempotencyLedger
15
+
16
+ __all__ = [
17
+ "Contract", "ContractBook", "Gate",
18
+ "TypedLeaf", "LeafError", "escalate_only",
19
+ "ActionSet", "ActionRecord", "ExecOutcome",
20
+ "EvidenceLedger", "IdempotencyLedger",
21
+ "Envelope",
22
+ ]
@@ -0,0 +1,110 @@
1
+ """Chapter 3 — The double-closed action set: agent actions safe by construction.
2
+
3
+ An autonomous agent's actions must clear TWO independent closed sets before running:
4
+
5
+ 1. **Contracted** — the action name appears in a contract's ``allows`` list that a ``Gate``
6
+ granted (chapter 1). A human put that name in the book.
7
+ 2. **Registered** — the action name has an implementation registered here. An engineer wrote
8
+ and reviewed that handler.
9
+
10
+ The intersection is the executable surface; everything else — a typo'd contract, a
11
+ hallucinated action name, a prompt-injected "please also run …" — is **refused and
12
+ recorded**, not an error path someone forgot. Capability creep now requires changing two
13
+ artifacts, in two reviews.
14
+
15
+ Execution runs inside the envelope (chapter 5) and journals to the evidence ledger
16
+ (chapter 4). Two details that earn their keep:
17
+
18
+ - **A raising handler is a recorded failure, not an escape.** After an action *starts*,
19
+ every outcome — success, failure, crash — must become a record that is ledgered and
20
+ notified. An exception unwinding through an unattended loop is an agent that stops
21
+ watching mid-run and tells nobody.
22
+ - **Failed attempts spend breaker budget.** The breaker bounds *attempts*, not successes —
23
+ a handler that crashes N times in a loop is exactly the runaway the breaker exists for.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import Callable, Literal, Sequence
28
+
29
+ from pydantic import BaseModel, ConfigDict
30
+
31
+ from bounded_agent.envelope import Envelope
32
+ from bounded_agent.ledgers import EvidenceLedger, Period
33
+
34
+ #: A handler does the work and reports (ok, detail). What "the work" is — a subprocess, an
35
+ #: API call, a container restart — is its business; the set only knows names and outcomes.
36
+ Handler = Callable[[], tuple[bool, str]]
37
+
38
+ #: (contract_name, the action names that contract allows) — straight from ContractBook.granted.
39
+ Target = tuple[str, Sequence[str]]
40
+
41
+ Status = Literal["executed-ok", "executed-failed", "refused", "halted"]
42
+
43
+
44
+ class ActionRecord(BaseModel):
45
+ model_config = ConfigDict(frozen=True)
46
+
47
+ contract: str
48
+ action: str
49
+ status: Status
50
+ detail: str = ""
51
+
52
+
53
+ class ExecOutcome(BaseModel):
54
+ model_config = ConfigDict(frozen=True)
55
+
56
+ blocked_reason: str | None # disarmed | kill-switch | None (ran)
57
+ records: tuple[ActionRecord, ...] = ()
58
+ executed: int = 0 # attempts, successful or not
59
+
60
+
61
+ class ActionSet:
62
+ """The registered half of the double-closed check, plus the execution loop."""
63
+
64
+ def __init__(self) -> None:
65
+ self._handlers: dict[str, Handler] = {}
66
+
67
+ def register(self, name: str, handler: Handler) -> None:
68
+ self._handlers[name] = handler
69
+
70
+ def execute(self, targets: Sequence[Target], envelope: Envelope, *,
71
+ ledger: EvidenceLedger | None = None,
72
+ period: Period | None = None) -> ExecOutcome:
73
+ def emit(rec: ActionRecord) -> None:
74
+ if ledger is not None and period is not None:
75
+ ledger.append(rec, period=period)
76
+ envelope.notify(f"{rec.status}: {rec.action} on {rec.contract}"
77
+ + (f" ({rec.detail})" if rec.detail else ""))
78
+
79
+ blocked = envelope.blocked_reason()
80
+ if blocked is not None:
81
+ rec = ActionRecord(contract="*", action="*", status="halted", detail=blocked)
82
+ emit(rec)
83
+ return ExecOutcome(blocked_reason=blocked, records=(rec,))
84
+
85
+ records: list[ActionRecord] = []
86
+ executed = 0
87
+ for contract, actions in targets:
88
+ for action in actions:
89
+ if envelope.breaker_tripped(executed):
90
+ rec = ActionRecord(contract=contract, action=action, status="halted",
91
+ detail=f"breaker: {envelope.max_actions_per_run}/run")
92
+ records.append(rec); emit(rec)
93
+ return ExecOutcome(blocked_reason=None, records=tuple(records),
94
+ executed=executed)
95
+ handler = self._handlers.get(action)
96
+ if handler is None: # contracted but not implemented → refused
97
+ rec = ActionRecord(contract=contract, action=action, status="refused",
98
+ detail="not registered (closed set)")
99
+ records.append(rec); emit(rec)
100
+ continue
101
+ try:
102
+ ok, detail = handler()
103
+ status: Status = "executed-ok" if ok else "executed-failed"
104
+ except Exception as e: # noqa: BLE001 — a crash is a RECORD, never an escape
105
+ detail, status = f"{type(e).__name__}: {e}", "executed-failed"
106
+ rec = ActionRecord(contract=contract, action=action, status=status,
107
+ detail=detail)
108
+ records.append(rec); emit(rec)
109
+ executed += 1 # attempts spend breaker budget
110
+ return ExecOutcome(blocked_reason=None, records=tuple(records), executed=executed)
@@ -0,0 +1,86 @@
1
+ """Chapter 1 — Contracts & gates: deciding who may do what, before any agent runs.
2
+
3
+ The pattern: autonomy is granted by a **declared book of contracts**, never by code that
4
+ happens to be reachable. A human edits the book (and signs off on new entries); the machine
5
+ operates strictly inside it. Each contract names:
6
+
7
+ - a ``scope`` — the blast-radius label ("checkout-stack", "reporting-db") the grant is
8
+ confined to;
9
+ - a ``tier`` — how much autonomy: ``observe`` (read and report), ``propose`` (draft a fix a
10
+ human executes), ``act`` (execute its own ``allows`` list). Tiers are ORDERED: an ``act``
11
+ contract clears a ``propose`` gate, never the reverse;
12
+ - an ``allows`` list — the CLOSED set of action names the contract permits (see
13
+ ``actions.py`` for the second half of the double-closed check).
14
+
15
+ Two hard rules encoded here:
16
+
17
+ 1. **Absence is never authorization.** A missing or malformed book RAISES; an entry that is
18
+ not active, out of scope, or under-tiered grants nothing. There is no default-allow path.
19
+ 2. **The lifecycle vocabulary belongs to the caller.** Your workflow might call live entries
20
+ "active", "LIVE", or "ratified" — the gate's ``active_status`` is configurable rather than
21
+ baked into a type, because a library that hardcodes your status words will be wrong in
22
+ somebody's workflow.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+ from typing import Literal
28
+
29
+ import yaml
30
+ from pydantic import BaseModel, ConfigDict
31
+
32
+ Tier = Literal["observe", "propose", "act"]
33
+
34
+ #: The ordering that makes tiers meaningful: a gate requiring tier T grants any contract
35
+ #: whose tier ranks >= T.
36
+ _TIER_RANK: dict[str, int] = {"observe": 0, "propose": 1, "act": 2}
37
+
38
+
39
+ class Contract(BaseModel):
40
+ """One declared grant. Extra YAML fields (owners, SLAs, notes) are ignored — the book
41
+ can carry your operational metadata without this library needing to model it."""
42
+ model_config = ConfigDict(frozen=True, extra="ignore")
43
+
44
+ name: str
45
+ status: str = "active"
46
+ scope: str
47
+ tier: Tier = "observe"
48
+ allows: tuple[str, ...] = ()
49
+
50
+
51
+ class Gate(BaseModel):
52
+ """A composable authorization predicate — the question "may this contract act here?"
53
+ asked the same way every time, instead of scattered ``if`` statements."""
54
+ model_config = ConfigDict(frozen=True)
55
+
56
+ required_scope: str | None = None # None = any scope (rare; prefer naming one)
57
+ required_tier: Tier = "act"
58
+ active_status: str = "active" # the caller's word for "live", verbatim
59
+
60
+ def permits(self, contract: Contract) -> bool:
61
+ if contract.status != self.active_status:
62
+ return False
63
+ if self.required_scope is not None and contract.scope != self.required_scope:
64
+ return False
65
+ return _TIER_RANK[contract.tier] >= _TIER_RANK[self.required_tier]
66
+
67
+
68
+ class ContractBook(BaseModel):
69
+ """The parsed book. ``granted(gate)`` answers the only question the executor may ask:
70
+ which contracts may act, and what exactly does each allow."""
71
+ model_config = ConfigDict(frozen=True)
72
+
73
+ contracts: tuple[Contract, ...] = ()
74
+
75
+ @classmethod
76
+ def load(cls, path: Path | str) -> "ContractBook":
77
+ """Parse a ``contracts:`` YAML file. Raises on absence or malformation — the caller
78
+ decides whether that halts startup or degrades to observe-only, but it is never
79
+ silently treated as an empty (or worse, permissive) book."""
80
+ doc = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
81
+ if not isinstance(doc, dict) or "contracts" not in doc:
82
+ raise ValueError(f"{path}: expected a top-level 'contracts' list")
83
+ return cls(contracts=tuple(Contract(**e) for e in doc["contracts"]))
84
+
85
+ def granted(self, gate: Gate) -> dict[str, tuple[str, ...]]:
86
+ return {c.name: c.allows for c in self.contracts if gate.permits(c)}