bounded-agent 0.1.0__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.
- bounded_agent/__init__.py +22 -0
- bounded_agent/actions.py +110 -0
- bounded_agent/contracts.py +86 -0
- bounded_agent/envelope.py +49 -0
- bounded_agent/leaf.py +63 -0
- bounded_agent/ledgers.py +76 -0
- bounded_agent-0.1.0.dist-info/METADATA +95 -0
- bounded_agent-0.1.0.dist-info/RECORD +10 -0
- bounded_agent-0.1.0.dist-info/WHEEL +4 -0
- bounded_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
bounded_agent/actions.py
ADDED
|
@@ -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)}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Chapter 5 — The safety envelope: the gates every autonomous action passes through.
|
|
2
|
+
|
|
3
|
+
Four invariants, one small object. They read as paranoia until the first time an unattended
|
|
4
|
+
loop misbehaves; then they read as the minimum:
|
|
5
|
+
|
|
6
|
+
- **Ships disarmed.** ``armed=False`` is the constructor default. Autonomy is switched on
|
|
7
|
+
per deployment by a human, after the agent has earned a track record in observe/propose
|
|
8
|
+
mode — never on by virtue of the code existing.
|
|
9
|
+
- **Kill-switch.** A sentinel *file*, checked before anything runs. Files beat config flags
|
|
10
|
+
here: an operator (or another watchdog) can stop the agent out-of-band with ``touch STOP``,
|
|
11
|
+
with no deploy, no restart, no working control plane required.
|
|
12
|
+
- **Per-run breaker.** A hard cap on actions per run. Whatever goes wrong — a looping model,
|
|
13
|
+
a pathological finding, a bug — the blast radius is N actions and then a halt, not an
|
|
14
|
+
unbounded cascade.
|
|
15
|
+
- **Act-then-report.** Every action (including refusals and halts) goes through ``notify``.
|
|
16
|
+
An autonomous agent whose actions can be silent is unauditable; silence must be
|
|
17
|
+
*structurally impossible*, not a logging convention.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _noop(_msg: str) -> None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Envelope(BaseModel):
|
|
32
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
|
|
33
|
+
|
|
34
|
+
armed: bool = False
|
|
35
|
+
kill_switch_path: Path | None = None
|
|
36
|
+
max_actions_per_run: int = 1
|
|
37
|
+
notify: Callable[[str], None] = _noop
|
|
38
|
+
|
|
39
|
+
def blocked_reason(self) -> str | None:
|
|
40
|
+
"""Why NO action may run right now; ``None`` means clear to act."""
|
|
41
|
+
if not self.armed:
|
|
42
|
+
return "disarmed"
|
|
43
|
+
if self.kill_switch_path is not None and self.kill_switch_path.exists():
|
|
44
|
+
return "kill-switch"
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def breaker_tripped(self, executed: int) -> bool:
|
|
48
|
+
"""True once ``executed`` actions have already run this run — the next is refused."""
|
|
49
|
+
return executed >= self.max_actions_per_run
|
bounded_agent/leaf.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Chapter 2 — The typed leaf & the escalate-only floor.
|
|
2
|
+
|
|
3
|
+
This is the ONLY place the library touches an LLM, and the design point is what it does
|
|
4
|
+
*around* the call, not the call itself:
|
|
5
|
+
|
|
6
|
+
- **The leaf wraps a** ``pydantic_ai.Agent``, which already guarantees the model's output is
|
|
7
|
+
a validated instance of your type (or a typed failure). We do not rebuild that — the typed
|
|
8
|
+
call is a solved problem.
|
|
9
|
+
- **Error-as-value.** ``TypedLeaf.run`` returns your output type OR a ``LeafError`` — an API
|
|
10
|
+
outage, timeout, or refusal becomes a *value* the control loop folds into its verdict
|
|
11
|
+
(typically "cannot assess"), never an exception ripping through an unattended process.
|
|
12
|
+
An autonomous agent that crashes on a provider hiccup is an agent that silently stops
|
|
13
|
+
watching.
|
|
14
|
+
- **The escalate-only floor.** When an LLM shares a verdict with deterministic checks, the
|
|
15
|
+
merge rule must be asymmetric: the model may *raise* the alarm level (it noticed something
|
|
16
|
+
the checks cannot see), but it may never *lower* one (a plausible-sounding "all fine" must
|
|
17
|
+
not silence a failing probe). ``escalate_only`` is that rule as a function — write it once,
|
|
18
|
+
test it once, and every leaf in the system inherits the property.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Callable, Generic, Protocol, TypeVar
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict
|
|
25
|
+
|
|
26
|
+
OutputT = TypeVar("OutputT")
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LeafError(BaseModel):
|
|
31
|
+
"""A failed leaf run, as a value. The caller decides its verdict weight — the safe
|
|
32
|
+
default is 'at least cannot-assess', never 'ignore'."""
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
|
|
35
|
+
kind: str # exception type name
|
|
36
|
+
detail: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Runnable(Protocol):
|
|
40
|
+
"""Structurally, all we need from a pydantic_ai.Agent."""
|
|
41
|
+
def run_sync(self, prompt: str, *, deps: Any = ...) -> Any: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TypedLeaf(Generic[OutputT]):
|
|
45
|
+
"""Wraps an ``Agent[..., OutputT]``; ``run`` returns ``OutputT | LeafError``."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, agent: _Runnable) -> None:
|
|
48
|
+
self._agent = agent
|
|
49
|
+
|
|
50
|
+
def run(self, prompt: str, *, deps: Any = None) -> OutputT | LeafError:
|
|
51
|
+
try:
|
|
52
|
+
return self._agent.run_sync(prompt, deps=deps).output
|
|
53
|
+
except Exception as e: # noqa: BLE001 — the whole point: failure is a value
|
|
54
|
+
return LeafError(kind=type(e).__name__, detail=str(e))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def escalate_only(floor: T, proposed: T | None, *, rank: Callable[[T], int]) -> T:
|
|
58
|
+
"""Merge a deterministic ``floor`` with a leaf's ``proposed`` verdict so the result can
|
|
59
|
+
only ever be AS BAD OR WORSE than the floor. ``None`` (the leaf declined or errored)
|
|
60
|
+
keeps the floor. The caller supplies the domain's severity order via ``rank``."""
|
|
61
|
+
if proposed is None:
|
|
62
|
+
return floor
|
|
63
|
+
return proposed if rank(proposed) > rank(floor) else floor
|
bounded_agent/ledgers.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Chapter 4 — Ledgers: append-only evidence, and alerting once instead of forever.
|
|
2
|
+
|
|
3
|
+
Two small ledgers carry the entire audit story of a bounded agent:
|
|
4
|
+
|
|
5
|
+
- ``EvidenceLedger`` — **append-only truth.** Every action taken (and refused) and every
|
|
6
|
+
verdict formed is one JSONL line, partitioned by period. Nothing is ever rewritten; if a
|
|
7
|
+
later reading disagrees with an earlier one, that disagreement is itself a new line. This
|
|
8
|
+
is what lets a human audit an unattended agent after the fact — and what makes the agent's
|
|
9
|
+
own reports checkable against ground truth.
|
|
10
|
+
|
|
11
|
+
- ``IdempotencyLedger`` — **the anti-alert-rot mechanism.** A finding fingerprint is "new"
|
|
12
|
+
exactly once per period; repeats are recorded but not re-raised. Alert fatigue is a safety
|
|
13
|
+
failure — a pager that fires on the same stale finding every run trains its humans to stop
|
|
14
|
+
reading it, which is how real incidents get ignored.
|
|
15
|
+
|
|
16
|
+
Both write atomically (tmp + rename) into a directory the agent OWNS — a bounded agent never
|
|
17
|
+
writes outside its own namespace.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime as dt
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel
|
|
28
|
+
|
|
29
|
+
#: Periods are dates or plain strings ("2026-W31") — the dedup window is the caller's choice.
|
|
30
|
+
Period = dt.date | str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _key(period: Period) -> str:
|
|
34
|
+
return period.isoformat() if isinstance(period, dt.date) else str(period)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EvidenceLedger:
|
|
38
|
+
"""Append-only JSONL evidence log, partitioned by period."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, ledger_dir: Path | str) -> None:
|
|
41
|
+
self._dir = Path(ledger_dir)
|
|
42
|
+
|
|
43
|
+
def append(self, record: BaseModel | dict[str, Any], *, period: Period) -> None:
|
|
44
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
payload = record.model_dump() if isinstance(record, BaseModel) else dict(record)
|
|
46
|
+
with (self._dir / f"{_key(period)}.jsonl").open("a") as f:
|
|
47
|
+
f.write(json.dumps(payload) + "\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class IdempotencyLedger:
|
|
51
|
+
"""Fingerprint-per-period dedup with atomic writes."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, ledger_dir: Path | str) -> None:
|
|
54
|
+
self._dir = Path(ledger_dir)
|
|
55
|
+
|
|
56
|
+
def _path(self, period: Period) -> Path:
|
|
57
|
+
return self._dir / f"seen_{_key(period)}.json"
|
|
58
|
+
|
|
59
|
+
def seen(self, period: Period) -> set[str]:
|
|
60
|
+
p = self._path(period)
|
|
61
|
+
if not p.exists():
|
|
62
|
+
return set()
|
|
63
|
+
return set(json.loads(p.read_text()).get("fingerprints", []))
|
|
64
|
+
|
|
65
|
+
def new_fingerprints(self, period: Period, fingerprints: list[str]) -> list[str]:
|
|
66
|
+
seen = self.seen(period)
|
|
67
|
+
return [f for f in fingerprints if f not in seen]
|
|
68
|
+
|
|
69
|
+
def record(self, period: Period, fingerprints: list[str]) -> None:
|
|
70
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
merged = sorted(self.seen(period) | set(fingerprints))
|
|
72
|
+
p = self._path(period)
|
|
73
|
+
tmp = p.with_suffix(".tmp")
|
|
74
|
+
tmp.write_text(json.dumps({"period": _key(period), "fingerprints": merged},
|
|
75
|
+
indent=2) + "\n")
|
|
76
|
+
os.replace(tmp, p) # atomic: a crash never leaves a half-written dedup file
|
|
@@ -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,10 @@
|
|
|
1
|
+
bounded_agent/__init__.py,sha256=ERju5AUMYwJ8zxyUGBT75FFv64qztBgunIGrujKONqA,900
|
|
2
|
+
bounded_agent/actions.py,sha256=E-Ii-Xv1pHSAmUFBvsq5xzYTzycJduoAZS2hd-IEwqE,5117
|
|
3
|
+
bounded_agent/contracts.py,sha256=QNH8ynEctiPjJ1EW8xtaWRn5ykmGMMCzRbLDY_tLjVo,3878
|
|
4
|
+
bounded_agent/envelope.py,sha256=tqdAeTQ91O2sxqh57GOM_ymk9aHYp5zJtMkqLDgbrnU,2091
|
|
5
|
+
bounded_agent/leaf.py,sha256=eQTrQOiVg-9ndmrNABoxH2uCFTbqz1fC7m49HK-IVpg,2823
|
|
6
|
+
bounded_agent/ledgers.py,sha256=zybMFqxxdxzU6YwKBqltmZfnAHbri5rHCfLJswC7jbg,3101
|
|
7
|
+
bounded_agent-0.1.0.dist-info/METADATA,sha256=stUke1wSCoJmB5gNhPG93qysKhPQN6uhJgRgcvn33cc,5292
|
|
8
|
+
bounded_agent-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
bounded_agent-0.1.0.dist-info/licenses/LICENSE,sha256=gO_6kALpgQsz-8F8sXVQRZbYp2Yth785wo9J2c7rfgQ,1070
|
|
10
|
+
bounded_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|