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.
@@ -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)}
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.