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.
- bounded_agent-0.1.0/.github/workflows/ci.yml +20 -0
- bounded_agent-0.1.0/.gitignore +7 -0
- bounded_agent-0.1.0/LICENSE +21 -0
- bounded_agent-0.1.0/PKG-INFO +95 -0
- bounded_agent-0.1.0/README.md +81 -0
- bounded_agent-0.1.0/examples/sentinel/contracts.yaml +21 -0
- bounded_agent-0.1.0/examples/sentinel/sentinel.py +163 -0
- bounded_agent-0.1.0/pyproject.toml +26 -0
- bounded_agent-0.1.0/src/bounded_agent/__init__.py +22 -0
- bounded_agent-0.1.0/src/bounded_agent/actions.py +110 -0
- bounded_agent-0.1.0/src/bounded_agent/contracts.py +86 -0
- bounded_agent-0.1.0/src/bounded_agent/envelope.py +49 -0
- bounded_agent-0.1.0/src/bounded_agent/leaf.py +63 -0
- bounded_agent-0.1.0/src/bounded_agent/ledgers.py +76 -0
- bounded_agent-0.1.0/tests/__init__.py +0 -0
- bounded_agent-0.1.0/tests/test_actions.py +109 -0
- bounded_agent-0.1.0/tests/test_contracts.py +99 -0
- bounded_agent-0.1.0/tests/test_envelope.py +41 -0
- bounded_agent-0.1.0/tests/test_leaf.py +59 -0
- bounded_agent-0.1.0/tests/test_ledgers.py +65 -0
- bounded_agent-0.1.0/uv.lock +558 -0
|
@@ -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,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)}
|