agents-gl 0.0.1__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.
- agents_gl-0.0.1/.gitignore +11 -0
- agents_gl-0.0.1/ARCHITECTURE.md +570 -0
- agents_gl-0.0.1/LICENSE +21 -0
- agents_gl-0.0.1/PKG-INFO +53 -0
- agents_gl-0.0.1/README.md +29 -0
- agents_gl-0.0.1/pyproject.toml +222 -0
- agents_gl-0.0.1/src/agl/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/claude_code/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/claude_code/_session.py +127 -0
- agents_gl-0.0.1/src/agl/adapters/claude_code/_tools.py +72 -0
- agents_gl-0.0.1/src/agl/adapters/claude_code/fake.py +191 -0
- agents_gl-0.0.1/src/agl/adapters/claude_code/runner.py +160 -0
- agents_gl-0.0.1/src/agl/adapters/claude_code/translate.py +241 -0
- agents_gl-0.0.1/src/agl/adapters/filesystem/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/filesystem/_documents.py +35 -0
- agents_gl-0.0.1/src/agl/adapters/filesystem/memory_store.py +67 -0
- agents_gl-0.0.1/src/agl/adapters/filesystem/store.py +140 -0
- agents_gl-0.0.1/src/agl/adapters/git/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/git/_changes.py +53 -0
- agents_gl-0.0.1/src/agl/adapters/git/_conflicts.py +61 -0
- agents_gl-0.0.1/src/agl/adapters/git/_merging.py +144 -0
- agents_gl-0.0.1/src/agl/adapters/git/_patches.py +69 -0
- agents_gl-0.0.1/src/agl/adapters/git/_runner.py +192 -0
- agents_gl-0.0.1/src/agl/adapters/git/_snapshots.py +178 -0
- agents_gl-0.0.1/src/agl/adapters/git/_trees.py +139 -0
- agents_gl-0.0.1/src/agl/adapters/git/_working.py +82 -0
- agents_gl-0.0.1/src/agl/adapters/git/fake.py +306 -0
- agents_gl-0.0.1/src/agl/adapters/git/history.py +125 -0
- agents_gl-0.0.1/src/agl/adapters/git/integrator.py +98 -0
- agents_gl-0.0.1/src/agl/adapters/git/workspace.py +175 -0
- agents_gl-0.0.1/src/agl/adapters/openai/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/openai/_http.py +137 -0
- agents_gl-0.0.1/src/agl/adapters/openai/_session.py +220 -0
- agents_gl-0.0.1/src/agl/adapters/openai/_tools.py +153 -0
- agents_gl-0.0.1/src/agl/adapters/openai/fake.py +183 -0
- agents_gl-0.0.1/src/agl/adapters/openai/runner.py +177 -0
- agents_gl-0.0.1/src/agl/adapters/openai/translate.py +247 -0
- agents_gl-0.0.1/src/agl/adapters/rich_terminal/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/rich_terminal/_display.py +81 -0
- agents_gl-0.0.1/src/agl/adapters/rich_terminal/_render.py +49 -0
- agents_gl-0.0.1/src/agl/adapters/rich_terminal/headless.py +65 -0
- agents_gl-0.0.1/src/agl/adapters/rich_terminal/queues.py +117 -0
- agents_gl-0.0.1/src/agl/adapters/rich_terminal/scripted.py +95 -0
- agents_gl-0.0.1/src/agl/adapters/rich_terminal/terminal.py +235 -0
- agents_gl-0.0.1/src/agl/adapters/routing.py +55 -0
- agents_gl-0.0.1/src/agl/adapters/shell/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/adapters/shell/fake.py +25 -0
- agents_gl-0.0.1/src/agl/adapters/shell/verifier.py +102 -0
- agents_gl-0.0.1/src/agl/adapters/system_clock.py +39 -0
- agents_gl-0.0.1/src/agl/api.py +204 -0
- agents_gl-0.0.1/src/agl/cli/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/cli/commands/__init__.py +19 -0
- agents_gl-0.0.1/src/agl/cli/commands/clear.py +50 -0
- agents_gl-0.0.1/src/agl/cli/commands/init.py +35 -0
- agents_gl-0.0.1/src/agl/cli/commands/resume.py +51 -0
- agents_gl-0.0.1/src/agl/cli/commands/run.py +88 -0
- agents_gl-0.0.1/src/agl/cli/commands/workflows.py +68 -0
- agents_gl-0.0.1/src/agl/cli/exit_codes.py +21 -0
- agents_gl-0.0.1/src/agl/cli/main.py +173 -0
- agents_gl-0.0.1/src/agl/config/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/config/container.py +198 -0
- agents_gl-0.0.1/src/agl/config/registry.py +69 -0
- agents_gl-0.0.1/src/agl/config/schema.py +81 -0
- agents_gl-0.0.1/src/agl/config/sources.py +202 -0
- agents_gl-0.0.1/src/agl/config/toml_file.py +347 -0
- agents_gl-0.0.1/src/agl/ports/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/ports/agent.py +183 -0
- agents_gl-0.0.1/src/agl/ports/clock.py +13 -0
- agents_gl-0.0.1/src/agl/ports/errors.py +73 -0
- agents_gl-0.0.1/src/agl/ports/history.py +118 -0
- agents_gl-0.0.1/src/agl/ports/home_layout.py +169 -0
- agents_gl-0.0.1/src/agl/ports/ids.py +136 -0
- agents_gl-0.0.1/src/agl/ports/integration.py +82 -0
- agents_gl-0.0.1/src/agl/ports/run.py +253 -0
- agents_gl-0.0.1/src/agl/ports/store.py +69 -0
- agents_gl-0.0.1/src/agl/ports/terminal.py +105 -0
- agents_gl-0.0.1/src/agl/ports/tree_layout.py +90 -0
- agents_gl-0.0.1/src/agl/ports/verifier.py +24 -0
- agents_gl-0.0.1/src/agl/ports/workspace.py +91 -0
- agents_gl-0.0.1/src/agl/sdk/__init__.py +99 -0
- agents_gl-0.0.1/src/agl/sdk/_declarations.py +19 -0
- agents_gl-0.0.1/src/agl/sdk/_engine/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/sdk/_engine/integration.py +251 -0
- agents_gl-0.0.1/src/agl/sdk/_engine/journal.py +376 -0
- agents_gl-0.0.1/src/agl/sdk/_engine/preflight.py +127 -0
- agents_gl-0.0.1/src/agl/sdk/_engine/services.py +31 -0
- agents_gl-0.0.1/src/agl/sdk/_engine/steps.py +188 -0
- agents_gl-0.0.1/src/agl/sdk/_engine/worktrees.py +48 -0
- agents_gl-0.0.1/src/agl/sdk/errors.py +23 -0
- agents_gl-0.0.1/src/agl/sdk/params.py +235 -0
- agents_gl-0.0.1/src/agl/sdk/roles.py +187 -0
- agents_gl-0.0.1/src/agl/sdk/terminal.py +23 -0
- agents_gl-0.0.1/src/agl/sdk/testing.py +45 -0
- agents_gl-0.0.1/src/agl/sdk/tools.py +333 -0
- agents_gl-0.0.1/src/agl/sdk/workflow.py +249 -0
- agents_gl-0.0.1/src/agl/testing.py +237 -0
- agents_gl-0.0.1/src/agl/workflows/__init__.py +0 -0
- agents_gl-0.0.1/src/agl/workflows/fix/__init__.py +20 -0
- agents_gl-0.0.1/src/agl/workflows/fix/asking.py +60 -0
- agents_gl-0.0.1/src/agl/workflows/fix/findings.py +51 -0
- agents_gl-0.0.1/src/agl/workflows/fix/prompts/implement.md +112 -0
- agents_gl-0.0.1/src/agl/workflows/fix/prompts/review.md +79 -0
- agents_gl-0.0.1/src/agl/workflows/fix/questions.py +33 -0
- agents_gl-0.0.1/src/agl/workflows/fix/roles.py +24 -0
- agents_gl-0.0.1/src/agl/workflows/fix/views/__init__.py +4 -0
- agents_gl-0.0.1/src/agl/workflows/fix/views/board.py +6 -0
- agents_gl-0.0.1/src/agl/workflows/fix/views/question.py +15 -0
- agents_gl-0.0.1/src/agl/workflows/split/__init__.py +35 -0
- agents_gl-0.0.1/src/agl/workflows/split/chunks.py +63 -0
- agents_gl-0.0.1/src/agl/workflows/split/prompts/implement.md +105 -0
- agents_gl-0.0.1/src/agl/workflows/split/prompts/plan.md +120 -0
- agents_gl-0.0.1/src/agl/workflows/split/roles.py +23 -0
- agents_gl-0.0.1/src/agl/workflows/split/views/__init__.py +4 -0
- agents_gl-0.0.1/src/agl/workflows/split/views/board.py +11 -0
- agents_gl-0.0.1/src/agl/workflows/split/views/conflict.py +24 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
# AGL architecture
|
|
2
|
+
|
|
3
|
+
Eight layers, one dependency rule, one composition root. For the gates, see `CLAUDE.md`.
|
|
4
|
+
|
|
5
|
+
## The layers
|
|
6
|
+
|
|
7
|
+
**`ports/`** — The ABCs AGL is written against and the plain types they speak: `Store`,
|
|
8
|
+
`Workspace`, `WorkspaceProvider`, `Integrator`, `History`, `Verifier`, `Terminal`, `Clock`,
|
|
9
|
+
`AgentRunner`, plus `RunSpec`, the `AglError` hierarchy with the one exception-to-exit-code table
|
|
10
|
+
in the codebase, and the id types in `ids.py`, which expose a casefold-then-NFC `collision_key` and
|
|
11
|
+
do **not** compare through it — `RunLabel("T-01") != RunLabel("t-01")`, and a caller that must not
|
|
12
|
+
collide asks for the key, because two names differing only in case are one directory on a
|
|
13
|
+
case-insensitive filesystem. It imports nothing but stdlib and its own ring: everything imports
|
|
14
|
+
`ports`, so what `ports` drags in reaches every layer at once.
|
|
15
|
+
|
|
16
|
+
**`adapters/`** — The implementations. Anything that imports a vendor SDK, opens a socket or
|
|
17
|
+
shells out lives here and only here. What holds of every one of them is the grading: each class
|
|
18
|
+
here that implements a port is subclassed into a suite under `tests/contracts/`, which is what
|
|
19
|
+
keeps a stand-in from drifting from the thing it stands in for. "A fake beside a real" does not.
|
|
20
|
+
Four packages spell one `fake.py`, `filesystem/`'s is `memory_store.py`, `system_clock.py` is one
|
|
21
|
+
module holding both clocks and no package at all, and `rich_terminal/` has none: it ships three
|
|
22
|
+
terminals across two suites — `RichTerminal` for `real()`, `ScriptedTerminal` for `answering()`,
|
|
23
|
+
`HeadlessTerminal` for `fakes()` — no one of them a stand-in for another. And a fake here fakes
|
|
24
|
+
the *port*, never the vendor: `openai/fake.py` starts no process at all.
|
|
25
|
+
|
|
26
|
+
**`sdk/`** — What a workflow author builds from: `@workflow`, the `Run` a workflow is handed,
|
|
27
|
+
`@role`, `Tool` and the `tool()` and `reporting_tool()` that derive one from a payload dataclass,
|
|
28
|
+
`arg()`, the terminal components, `Stop`. `sdk/__init__.py` is the front door and re-exports the
|
|
29
|
+
authoring surface with `__all__` typed out rather than computed; `_engine/` is the private
|
|
30
|
+
machinery behind `Run` and is not on it. That underscore names the **workflow author's** surface
|
|
31
|
+
and nothing narrower — `api`, `config` and `sdk`'s own modules import those seven modules freely,
|
|
32
|
+
so there is no import for a contract to forbid and this sentence is the whole of the rule, unlike
|
|
33
|
+
the same underscore under `adapters/`, which means private to that package and is enforced by
|
|
34
|
+
`tests/test_naming_convention.py`. Something belongs here when two workflows would otherwise
|
|
35
|
+
write it themselves.
|
|
36
|
+
|
|
37
|
+
**`workflows/`** — One package per workflow, found through the `agl.workflows` entry points in
|
|
38
|
+
`pyproject.toml`; no central table to edit. `fix` is one worktree run sequentially, Claude
|
|
39
|
+
implementing and OpenAI reviewing; `split` is N chunks run concurrently, each landed into the
|
|
40
|
+
run's base. A workflow imports `sdk` — never an adapter, never `config`. `ports` sits below it and
|
|
41
|
+
is permitted, and neither shipped workflow names it: the authoring surface re-exports what a
|
|
42
|
+
workflow speaks.
|
|
43
|
+
|
|
44
|
+
**`config/`** — Settings and the composition root. `sources.py` resolves flags > env > file >
|
|
45
|
+
defaults once into an immutable object, `toml_file.py` is the only module that knows TOML,
|
|
46
|
+
`registry.py` resolves entry points, and **`container.py` is the only module that constructs an
|
|
47
|
+
adapter**.
|
|
48
|
+
|
|
49
|
+
**`cli/`** — argv in, exit code out. `main.py` dispatches to one module per subcommand (run,
|
|
50
|
+
resume, clear, init, workflows) and is the one place `Path.cwd()` is read. Composition is
|
|
51
|
+
per-command: the container sits behind a callable, so `init` and `workflows` never build one.
|
|
52
|
+
**Commands stay dumb.** A command declares its own arguments, reads them off the parsed namespace,
|
|
53
|
+
calls `api` and turns what comes back into output and an exit status; everything that decides
|
|
54
|
+
anything is one call away. So `clear` names one `api` function rather than a worktree walk and a
|
|
55
|
+
`shutil.rmtree` past the `Store` port, `init` one rather than build-tool detection and TOML
|
|
56
|
+
rendering, and `workflows` two only because the listing and the help are two operations — one of
|
|
57
|
+
them imports a package and the other must never. Each suite under `tests/cli/` scans its own
|
|
58
|
+
command's source for the `api.` names it reaches, so a use case moving back into the CLI fails a
|
|
59
|
+
test instead of passing review.
|
|
60
|
+
|
|
61
|
+
**`api.py`** — AGL's operations, callable without a terminal: `run`, `resume`, `clear`, `init`,
|
|
62
|
+
`list_workflows`, `workflow_help`.
|
|
63
|
+
|
|
64
|
+
**`testing.py`** — The workflow author's harness: `harness(tmp_path, agent=…)` builds an all-fakes
|
|
65
|
+
bundle, `run(...)` and `resume(...)` drive `api` over it, `recorded` is every journal entry,
|
|
66
|
+
`answering([...])` is a terminal that can answer a screen. A sibling of `cli/`, not a layer above
|
|
67
|
+
it — a second caller of `api`.
|
|
68
|
+
|
|
69
|
+
## The dependency rule
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
{cli, testing} → api → config → workflows → {sdk, adapters} → ports
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`sdk` and `adapters` are siblings and may not import each other; so are `cli` and `testing`.
|
|
76
|
+
`config` may import everything under it and nothing above it, and only `config/container.py` may
|
|
77
|
+
name an adapter. `.importlinter` holds six contracts and `lint-imports` enforces them: the layering
|
|
78
|
+
above, the inner ring (a pure type never imports the ABC that speaks it), vendor containment,
|
|
79
|
+
adapter independence, the composition root, and workflows-build-on-`sdk`-alone.
|
|
80
|
+
|
|
81
|
+
**One clause cannot be a contract.** "`ports` imports nothing but stdlib" is an *allow* list, and
|
|
82
|
+
every import-linter contract type names what is forbidden or how modules are ordered — saying it
|
|
83
|
+
there means enumerating every distribution that is not the standard library. It is enforced
|
|
84
|
+
instead by `tests/test_ports_stdlib_only.py`, an AST scan over every import under `ports/`. With
|
|
85
|
+
`import pydantic` in `ports/clock.py`, all six contracts still report kept.
|
|
86
|
+
|
|
87
|
+
## Vendor containment
|
|
88
|
+
|
|
89
|
+
`claude_agent_sdk` may be imported only inside `agl.adapters.claude_code`, `rich` only inside
|
|
90
|
+
`agl.adapters.rich_terminal`; one contract holds both. The OpenAI adapter shells out to the Codex
|
|
91
|
+
CLI binary and has no import to contain, so its *name* is guarded by a grep gate in `scripts/check`
|
|
92
|
+
that fails on any mention in a `.py` under `src/` outside `agl/adapters/openai/`. The asymmetry is
|
|
93
|
+
deliberate: the two SDKs are pip extras, the Codex CLI is installed separately and resolved at
|
|
94
|
+
preflight, and installing one vendor never drags in the other's.
|
|
95
|
+
|
|
96
|
+
## The terminal
|
|
97
|
+
|
|
98
|
+
**A view is a function, and `show` registers the function and its arguments rather than the
|
|
99
|
+
`Screen` they produce.** The adapter's redraw loop invokes a registered view again every frame,
|
|
100
|
+
compares the `Screen` it returns against the last one, and writes only on a change — so handing a
|
|
101
|
+
view the live dict of child runs is how a dashboard stays current, and a workflow re-`show`s only
|
|
102
|
+
to put a *different* view on screen. Every component is a frozen dataclass with value equality
|
|
103
|
+
because that comparison is the whole reason per-frame re-invocation is cheap: the expensive part is
|
|
104
|
+
the write, and the write is skipped when nothing moved.
|
|
105
|
+
|
|
106
|
+
`Screen`, `Rows`, `Row`, `Text`, `Choice` and `TextInput` live in `ports/terminal.py` and not
|
|
107
|
+
beside the authors who write views, because `Terminal.show` takes a view returning a `Screen[T]`
|
|
108
|
+
and a component under `sdk/` would invert the dependency rule on its lowest edge; `sdk/terminal.py`
|
|
109
|
+
is a re-export facade holding no logic. `Component` and `Response` are closed unions, so an adapter
|
|
110
|
+
can `match` them exhaustively and a fourth component is a build failure in every implementation
|
|
111
|
+
that has not learned to draw it. Anywhere a component is expected a bare `str` means `Text`,
|
|
112
|
+
coerced on the way in, which is why `Row`, `Rows` and `Screen` write their own `__init__` instead
|
|
113
|
+
of taking a generated one that would accept each field at the field's own type. `Screen` is a
|
|
114
|
+
dashboard and `Screen[T]` is a question — the parameter says which, and `responses` being empty is
|
|
115
|
+
what the terminal dispatches on at run time. Terminal-*shaped* is not
|
|
116
|
+
terminal-*implementation*-shaped: no styling, no colour, no sizes, no positions, no notion of a
|
|
117
|
+
screen having a size at all, because that is mechanism and mechanism is `adapters/`.
|
|
118
|
+
|
|
119
|
+
**One slot, two queues, and this is the whole contract that every implementation satisfies
|
|
120
|
+
identically.** A passive `Screen` goes to the slot — size one, replaced on write, no ordering, and
|
|
121
|
+
`priority` means nothing for one. A `Screen[T]` joins a queue at its priority, FIFO within a
|
|
122
|
+
priority, because a person answers one thing at a time. The highest-priority screen is always the
|
|
123
|
+
one shown, and the slot keeps updating while a question is up, so when the queue empties the
|
|
124
|
+
current dashboard reappears with no extra machinery. Preemption is not cosmetic: `integrate()`
|
|
125
|
+
leaves its target held mid-landing, so a conflict screen queued behind two agent questions would
|
|
126
|
+
stall the merge queue on something unrelated. Priority is a plain `int` and not named levels, which
|
|
127
|
+
would encode one workflow's concepts into the framework. Known and accepted: preemption loses text
|
|
128
|
+
somebody was part-way through typing, and there are no timeouts anywhere, so "stuck" and "waiting
|
|
129
|
+
for you" look alike from outside — `pending` exists because of the second, and reports every
|
|
130
|
+
priority the terminal has been asked for, zeroes included, excluding whatever is on screen.
|
|
131
|
+
|
|
132
|
+
`adapters/rich_terminal/queues.py` never dequeues in order to display. An entry joins its queue in
|
|
133
|
+
`queue()` and leaves it in `answer()`, and that is the whole of its movement; current is derived,
|
|
134
|
+
as the head of the highest non-empty queue, recomputed on every read. Preemption is then not an
|
|
135
|
+
operation at all — a higher-priority arrival changes what the derivation returns — and "the
|
|
136
|
+
displaced question keeps its place" becomes a thing the module could not express otherwise.
|
|
137
|
+
Identity is the `show` registration and never the `Screen` value, which is what `eq=False` on
|
|
138
|
+
`Registration` and `Queued` buys: a view returns a fresh equal `Screen` every frame, and two agents
|
|
139
|
+
asking the same question produce equal screens, so keying on the value would hand one agent the
|
|
140
|
+
answer to a question it never asked.
|
|
141
|
+
|
|
142
|
+
**Headless is this port's contract and not one adapter's quirk.** A terminal with no display no-ops
|
|
143
|
+
a passive screen and raises `UpstreamUnavailable` on an interactive one, because a workflow needing
|
|
144
|
+
human input genuinely cannot run without a person and saying so at the first question beats
|
|
145
|
+
blocking forever on nobody. That is what lets `HeadlessTerminal` double as the fake, and it shares
|
|
146
|
+
nothing with the terminal that draws — no queues, no slot, no loop, and no import of `rich`,
|
|
147
|
+
`terminal.py`, `queues.py`, `_display.py` or `_render.py`. `ScriptedTerminal` runs no loop either,
|
|
148
|
+
and a script that runs out idles rather than raising: a test that then shows a question hangs
|
|
149
|
+
instead of failing, which is what a real terminal with nobody sitting at it does, and is what lets
|
|
150
|
+
the contract suite show a question before any response exists.
|
|
151
|
+
|
|
152
|
+
**A `Terminal` is an async context manager, and the framework opens it around the workflow's
|
|
153
|
+
function and around nothing else.** `api._walk` holds the one `async with services.terminal`, so
|
|
154
|
+
`run` and `resume` cannot disagree about it. It opens after the record and after the base checkout
|
|
155
|
+
is provisioned, because the context is exactly the region in which `show` is legal
|
|
156
|
+
and the only thing there that can `show` is the workflow — opening earlier would widen that region
|
|
157
|
+
over code where a `show` is AGL's own bug, and would take a person's display over in order to draw
|
|
158
|
+
nothing across the refusals they have to read. A `show` outside it is `InternalError`. `__aexit__`
|
|
159
|
+
is annotated `-> None` on the port and suppressing an exception means returning something truthy,
|
|
160
|
+
so no conforming terminal can swallow a workflow's `Stop`.
|
|
161
|
+
|
|
162
|
+
**Agent activity is one string, held and handed back, and never written down.** Each adapter
|
|
163
|
+
formats its own line — `Bash: ./gradlew build`, `Edit: domain/usecase.kt` — and the router passes
|
|
164
|
+
it through untouched: no `Activity` type, no shared verb taxonomy, no framework lookup table, so no
|
|
165
|
+
later backend has to map its vocabulary onto another's and the cost is cosmetic inconsistency
|
|
166
|
+
between them. `Steps` holds the cell and `run.activity` reads it through a property; one `Steps`
|
|
167
|
+
per namespace means a child reports its own steps and not its parent's. It is `None` when nothing
|
|
168
|
+
is running, cleared in a `finally` around the dispatch alone — an assignment does not suspend, so a
|
|
169
|
+
`CancelledError` cannot land between deciding to clear the cell and clearing it. A replayed step
|
|
170
|
+
has no activity at all, structurally rather than by a check: `on_activity` is passed inside the
|
|
171
|
+
worker, and a hit returns the stored value without building one. Nothing about activity reaches an
|
|
172
|
+
`Entry`, a fingerprint or the store.
|
|
173
|
+
|
|
174
|
+
## Errors at the boundary
|
|
175
|
+
|
|
176
|
+
**An adapter translates what it catches — a `CalledProcessError`, an `OSError`, a vendor
|
|
177
|
+
exception — into the `AglError` hierarchy at its own boundary, and nothing above an adapter ever
|
|
178
|
+
handles anything else.** A workflow catches `UpstreamUnavailable`, never whatever the thing
|
|
179
|
+
underneath happened to throw; nothing above `adapters/git/` sees a porcelain exit status, a
|
|
180
|
+
`MERGE_HEAD` or an unmerged-path listing, and nothing above `adapters/claude_code/` sees an SDK
|
|
181
|
+
exception or a vendor's stop string. This is a different rule from vendor containment, which is
|
|
182
|
+
about where vendor *code* may live. Which subclass is chosen is decided by what the reader of an
|
|
183
|
+
exit code should do — `DeniedError` for a refusal that stands until something changes,
|
|
184
|
+
`UpstreamUnavailable` for a state of the world the same call may get past later,
|
|
185
|
+
`UpstreamUnexpected` for a far side that answered in terms the adapter cannot read — and
|
|
186
|
+
`adapters/git/_trees.py`'s `_translated` is that decision in four lines, pinned over both of its
|
|
187
|
+
callers by `tests/adapters/test_git_denied.py`. The rule holds one layer up wherever a module does
|
|
188
|
+
its own I/O rather than reaching through a port: `config/toml_file.py` and `sdk/roles.py`'s
|
|
189
|
+
`prompt_file` each turn an `OSError` into an `InputError` at the line that raised it.
|
|
190
|
+
|
|
191
|
+
**`ports/errors.py` holds the one exception-to-exit-code table and `cli/exit_codes.py` consumes it
|
|
192
|
+
without adding a number of its own.** An exception that is not an `AglError` arriving at the top of
|
|
193
|
+
the CLI is a translation that did not happen in our code, so it exits 70 — the same answer
|
|
194
|
+
`exit_code_for` gives an `AglError` on a branch nobody mapped, because the two are one fault seen
|
|
195
|
+
from either side and a script cannot act on them differently. Resolution walks the class tree
|
|
196
|
+
rather than indexing the table, so a workflow's own `ReviewNotConverging(Stop)` exits 7 without
|
|
197
|
+
appearing anywhere, and there is no clause order for a handler to get wrong.
|
|
198
|
+
|
|
199
|
+
**A `TaskGroup` hands back several answers at once, so a group has its own rule**: unwrap a
|
|
200
|
+
single-exception group and map its leaf; several leaves whose codes agree take that code; leaves
|
|
201
|
+
that disagree take 70, naming all of them, because a run that failed several different ways is
|
|
202
|
+
genuinely not attributable to one code and a guess would be this module inventing a precedence over
|
|
203
|
+
the table. `leaves` flattens recursively — `split` opens a `TaskGroup` and a chunk may open its
|
|
204
|
+
own — so "a single-leaf group" is a fact about what the run did rather than about how deeply the
|
|
205
|
+
workflow nested its concurrency. Agreement is compared on the resolved *code* and never on the
|
|
206
|
+
class, which is why `UpstreamUnavailable` beside `UpstreamUnexpected` agrees at 6 with no second
|
|
207
|
+
rule to keep in step with the table. `exit_status` and `leaves` take `Exception` and walk
|
|
208
|
+
`ExceptionGroup`, deliberately not `BaseException` and `BaseExceptionGroup`: a Ctrl-C is the
|
|
209
|
+
operator taking the process back rather than an outcome to report, and the faithful way to end on
|
|
210
|
+
one is to die of the signal, which is what CPython does when nothing catches it. A shell tells the
|
|
211
|
+
two apart even though `$?` reads 130 for both — a child that *died of* `SIGINT` stops the enclosing
|
|
212
|
+
loop and one that merely exited 130 does not — so a handler answering 130 here would make
|
|
213
|
+
`for label in a b c; do agl run ...; done` unstoppable by the key that was pressed to stop it.
|
|
214
|
+
`mypy --strict` is the enforcement, since `except BaseException as error: return exit_status(error)`
|
|
215
|
+
will not type-check, and `tests/cli/test_exit_codes.py` pins both the annotation and the group rule.
|
|
216
|
+
|
|
217
|
+
## Invariants where a mistake is silent
|
|
218
|
+
|
|
219
|
+
No gate catches these and no exception announces them. Everything else in AGL fails by raising or
|
|
220
|
+
by costing a re-run. **Three of them destroy work.**
|
|
221
|
+
|
|
222
|
+
**A step with no `commit=` wipes its worktree.** `Journal._ending` in `sdk/_engine/journal.py`
|
|
223
|
+
ends every step by committing everything or calling `Workspace.restore(last_good)` — `git reset
|
|
224
|
+
--hard` then `git clean -ffd`. Tracked edits, untracked files and any commit the agent made itself
|
|
225
|
+
all go, and none of it is on the ledger either: an entry is still written, and the head it records
|
|
226
|
+
is the one the worktree was restored to. Nothing checks the pairing. A step whose role can touch
|
|
227
|
+
the worktree must pass `commit=`; the only two that omit it are `fix`'s reviewer and `split`'s
|
|
228
|
+
planner, and both roles declare `Restriction.NO_FILE_WRITES`.
|
|
229
|
+
|
|
230
|
+
**A landing must be handed back to the parent's chain.** `Integration._conclude` in
|
|
231
|
+
`sdk/_engine/integration.py` settles a clean landing with `self._journal.advance(head)`. A child's
|
|
232
|
+
landing moves the parent's real head, but `last_good` is chained from step *entries* and
|
|
233
|
+
`integrate()` writes none, so skipping that call leaves the parent believing it is where its last
|
|
234
|
+
step ended. The next step to miss its fingerprint restores to that stale head and resets past every
|
|
235
|
+
landing at once.
|
|
236
|
+
|
|
237
|
+
**A red build gate discards a hand-resolved conflict.** `Integration._gated` reverts a landing
|
|
238
|
+
with `restore(self._before)` when the verifier fails. If a person resolved a merge by hand and
|
|
239
|
+
pressed retry, the text they typed lived only in that worktree — in no git object, on no entry —
|
|
240
|
+
and it goes. A known cost pinned by `tests/sdk/test_integrate_acceptance.py`: the gate has to run
|
|
241
|
+
on that landing too.
|
|
242
|
+
|
|
243
|
+
**Every path out of a hold must settle it.** `Integration.retry` and `Integration.abort` in
|
|
244
|
+
`sdk/_engine/integration.py` are the two verbs a workflow calls on a live conflict, and each reaches
|
|
245
|
+
an `Integrator` that may raise — `land` refuses over unrecorded work in the target, which is exactly
|
|
246
|
+
what a person editing that checkout at a refusal screen leaves behind. `integrate()` guards its own
|
|
247
|
+
construction with `except BaseException: lease.release()`, and `api.run` sweeps with `finally:
|
|
248
|
+
leases.release_all()`; neither covers a raise out of a verb called on an object the workflow is
|
|
249
|
+
already holding. The target's lease and its namespace's step lock then stay taken for the life of
|
|
250
|
+
the process, and the next landing into that parent blocks inside `Leases.claim` — a hang rather than
|
|
251
|
+
a failure, with nothing raised and no predicate to ask. So both verbs settle on the way out, and the
|
|
252
|
+
tests in `tests/sdk/test_run_integrate.py` bound the claim that follows rather than awaiting it.
|
|
253
|
+
Settling is also what ends the workflow's own loop: `Integration.conflicted` is *is there a conflict
|
|
254
|
+
here that has not settled*, so `while outcome.conflicted:` terminates for every path out of a hold
|
|
255
|
+
and never sends a workflow back to a `retry()` that would refuse it. The `Conflict` itself outlives
|
|
256
|
+
the settling — it is the record of why nothing landed, and a workflow reads it after the loop.
|
|
257
|
+
|
|
258
|
+
**A hold and a liveness claim are facts about the world, never about AGL's memory.**
|
|
259
|
+
`adapters/git/integrator.py` holds nothing of its own: a conflicted `git merge` writes `MERGE_HEAD`
|
|
260
|
+
into the target worktree's own git directory, and that file *is* the hold — no attribute records a
|
|
261
|
+
pending landing, and `_held` is the single predicate all three verbs ask. A `GitIntegrator` built
|
|
262
|
+
in a later process and handed the same target asks git the same question and gets the same answer,
|
|
263
|
+
so `abort` after a crash releases a hold this process never took. An in-memory hold makes a resumed
|
|
264
|
+
run's `abort()` a no-op that reports success and leaves the target half-combined forever, and no
|
|
265
|
+
contract suite catches it — both implementations pass with one — which is why
|
|
266
|
+
`tests/adapters/test_git_integrator.py` asserts it against the real adapter with a second
|
|
267
|
+
integrator over the same repository. Two consequences follow from `MERGE_HEAD` being per worktree:
|
|
268
|
+
a landing held in one run's `_base` is invisible to every other run, which is the isolation the
|
|
269
|
+
trees layout is built on, and only a merge is ever a hold, so a rebase or a cherry-pick somebody
|
|
270
|
+
left in the target is not something `abort` will touch. The run lock is the same primitive
|
|
271
|
+
answering a different question — `_trees.run_lock` is a non-blocking `flock` on `.trees/<label>/`,
|
|
272
|
+
taken by `run` and `resume` for the life of the process and briefly by `clear`, so a `clear` aimed
|
|
273
|
+
at a live run refuses at once rather than waiting hours for a lock or taking its checkouts away
|
|
274
|
+
underneath it. Both refuse the recorded alternative for the reason `ports/run.py` has no
|
|
275
|
+
`RunStatus`: a claim written down is a claim a crash leaves behind as a lie, and a claim the kernel
|
|
276
|
+
drops when its holder dies is the one kind no crash can falsify.
|
|
277
|
+
|
|
278
|
+
**A workflow branches only on step results.** Resume is not a continuation — `api.resume`
|
|
279
|
+
re-invokes the workflow from its first line in a fresh process, so every line runs again and only
|
|
280
|
+
`run.step(...)` short-circuits. It fingerprints the role, its tools, the inputs and the head the
|
|
281
|
+
previous step ended at, appends an ordinal for repeats, and looks it up. A hit returns the
|
|
282
|
+
recorded value; **a miss just runs the step — a miss is not an error, it is the definition of a
|
|
283
|
+
new step**, so divergence has nothing to raise. Branch on wall-clock time, an environment
|
|
284
|
+
variable, a directory listing, randomness or a mutable global, and a resume can take another path:
|
|
285
|
+
paid-for work is silently redone, and where an off-branch fingerprint happens to match, a recorded
|
|
286
|
+
result comes back for a call that never produced it. The ordinal is never persisted — it is
|
|
287
|
+
rebuilt by re-walking — so order counts too: swap two same-fingerprint steps and each returns the
|
|
288
|
+
other's answer.
|
|
289
|
+
|
|
290
|
+
**Fingerprint canonicalisation must be order-stable.** `_canonical` walks a value before
|
|
291
|
+
`json.dumps(..., sort_keys=True)` hashes it with SHA-256. Mappings get sorted keys; lists and
|
|
292
|
+
tuples keep their order, because for a sequence order *is* meaning; sets are emitted as
|
|
293
|
+
`sorted(..., key=_dumps)`, by each element's own serialised text, because set iteration order is
|
|
294
|
+
not stable across processes. Get it wrong and a step fingerprints differently in the process that
|
|
295
|
+
resumes it — and a miss is not an error, so nothing complains: the run wipes the worktree, re-runs
|
|
296
|
+
every step it had already recorded, finishes, and returns the right answer, the symptoms being the
|
|
297
|
+
bill and the wall clock. Hence tests that spawn interpreters under several `PYTHONHASHSEED`
|
|
298
|
+
values; an in-process one passes just as happily against the bug.
|
|
299
|
+
|
|
300
|
+
**A payload class's identity travels only inside its schema's `title`.** `_object_schema` in
|
|
301
|
+
`sdk/tools.py` writes `"title": f"{kind.__module__}.{kind.__qualname__}"` at every depth, and
|
|
302
|
+
`base_of` takes a tool's name, its description and its derived schema — so the payload *type* is a
|
|
303
|
+
fingerprint term reached through that one string and through nothing else. Both failure directions
|
|
304
|
+
are silent and they run opposite ways. Rename the payload class, move its module, or re-nest it,
|
|
305
|
+
and the digest moves although nothing about what the agent is asked has changed: every recorded step
|
|
306
|
+
that reported through it misses, and the run re-buys work it already had. Add a method to it — or a
|
|
307
|
+
`__post_init__` that *rejects values the old one accepted* — and the digest is byte-identical: a
|
|
308
|
+
vocabulary enforced in code and named nowhere else is invisible to the schema, so an entry recorded
|
|
309
|
+
under the old rules replays under the new ones, or stops converting with its fingerprint still
|
|
310
|
+
matching and surfaces as an `InternalError` out of `ReportingTool.read` on a resume.
|
|
311
|
+
`workflows/fix/findings.py` holds exactly such a `__post_init__` over `SEVERITIES` and takes the
|
|
312
|
+
price; `describe()` is the way out of it, a vocabulary interpolated into a field's description being
|
|
313
|
+
schema and therefore fingerprint. Four tests in `tests/sdk/test_tools.py` pin the pieces.
|
|
314
|
+
|
|
315
|
+
**Everything a step does must land inside its workspace.** A replayed step returns a recorded
|
|
316
|
+
value and never calls the worker, so an effect that is not a file in the checkout — an HTTP POST,
|
|
317
|
+
a write to `$HOME`, a database row — happens twice on a miss and not at all on a hit. The ledger
|
|
318
|
+
holds a value and a head, not what the world looked like.
|
|
319
|
+
|
|
320
|
+
**Bump `@workflow(version=…)` when a workflow's shape changes.** `api.resume` compares the
|
|
321
|
+
installed version against the one stamped in `run.json` and refuses a mismatch rather than
|
|
322
|
+
migrating — the only thing between edited code and a ledger replayed into reordered steps. Edits
|
|
323
|
+
reaching a fingerprint term merely re-run those steps; inserting, removing or reordering steps
|
|
324
|
+
without a bump is silently wrong.
|
|
325
|
+
|
|
326
|
+
**Preflight's registry scan is best-effort; containment at every step is the guarantee.**
|
|
327
|
+
`sdk/_engine/preflight.py`'s `check` runs once, before the record is written and before anything is
|
|
328
|
+
provisioned, and reads the `@role(model=…)` factories bound in the module the workflow's `def` ran
|
|
329
|
+
in and in any module bound there — exactly one level, never recursing — asking each distinct
|
|
330
|
+
model's backend `check_ready`. It over-approximates deliberately: a factory imported and never
|
|
331
|
+
stepped with demands its provider, and so does every other factory in a module imported for one of
|
|
332
|
+
them. That is a false refusal, which is loud, names its factory and both modules, and is one import
|
|
333
|
+
from being fixed. What the scan cannot see is the silent half — a factory held in a container, one
|
|
334
|
+
built at run time by a call or a comprehension, one bound two modules deep, one reached through
|
|
335
|
+
anything that is not a module — and there `Capabilities.require` at every `run.step` is what still
|
|
336
|
+
runs, over the role the workflow actually handed in. Delete that as a duplicate of preflight's work
|
|
337
|
+
and the failure has nothing to raise: the role a module declares and the role a workflow steps with
|
|
338
|
+
are different values, because `fix` writes `implementer(ask=asking(run.terminal))` inside its own
|
|
339
|
+
function — a tool whose handler closes over a `Run` that did not exist when the module was
|
|
340
|
+
imported — and `Role.__post_init__` folds `TOOL_CALLING` into `requires` behind it. A role
|
|
341
|
+
reaching a backend that cannot call a tool then ends its step with `RoleIncompleteError` — the
|
|
342
|
+
reporting tool never reaches the model, so the agent cannot fire it — instead of the refusal it was
|
|
343
|
+
owed. `capabilities()` is contracted stable for the duration of a run, which is what makes one
|
|
344
|
+
memoised call per model per run the whole bill; `check_ready` is deliberately not repeated per
|
|
345
|
+
step, because it costs a turn to re-learn a state of the world preflight already asked about.
|
|
346
|
+
`tests/sdk/test_preflight.py` asserts the over-approximation as behaviour and measures the two
|
|
347
|
+
halves against each other rather than separately.
|
|
348
|
+
|
|
349
|
+
**`check` asks more than the backends now, and the order it asks in is chosen on what a question
|
|
350
|
+
costs.** First the repository, through `History.check_committer_identity`: `commit_all` invents no
|
|
351
|
+
identity, so where git can derive none it refuses inside `Journal._ending` — after the agent has
|
|
352
|
+
finished and before the entry is written, which is the one preflight failure a resume cannot
|
|
353
|
+
repair, there being no entry for it to hit. Then the backends, cheapest probe leading, ranked by
|
|
354
|
+
`Provider` inside `preflight.py` rather than by a third member on `AgentRunner`: the OpenAI
|
|
355
|
+
adapter's `check_ready` spawns `codex login status` and the Claude adapter's spends a turn, so a
|
|
356
|
+
machine logged into one and out of the other is refused without buying anything. `sorted` is stable,
|
|
357
|
+
so binding order in the workflow's module namespace still decides between two models whose probes
|
|
358
|
+
cost the same. Both refusals are `UpstreamUnavailable` — a state of the world the operator changes,
|
|
359
|
+
after which the same run works — so both leave on exit 6.
|
|
360
|
+
|
|
361
|
+
**A run's label is held in two places, and the repository has to be asked as well as the store.**
|
|
362
|
+
`api.run` asks `History.exists(run_branch(label))` beside reading the record, and refuses before it
|
|
363
|
+
has written anything. Without that check `WorkspaceProvider.open` takes its **attaching** path,
|
|
364
|
+
because `base` is consulted only when provisioning: the new run continues that branch from its tip
|
|
365
|
+
with `--from` silently ignored, and nothing anywhere says so. The two questions look like one and
|
|
366
|
+
are not. `clear` takes a run's records and every branch it held away together, and `api.run` writes
|
|
367
|
+
a record before it cuts a checkout, so nothing of AGL's leaves `agl/<label>` standing with nothing
|
|
368
|
+
recorded beside it — which is what makes the check look redundant and is exactly why it is not: what
|
|
369
|
+
it catches is somebody's own `git branch agl/auth`, or a name that outlived the repository AGL was
|
|
370
|
+
pointed at. `git branch -D` is what frees such a label, a second `agl clear` having no record to
|
|
371
|
+
address.
|
|
372
|
+
`tests/test_api.py::test_a_deliverable_branch_that_already_exists_refuses_the_run` pins the refusal,
|
|
373
|
+
and `tests/test_clear.py::test_a_cleared_label_starts_a_fresh_run_because_clear_left_no_branch_behind`
|
|
374
|
+
walks the round trip.
|
|
375
|
+
|
|
376
|
+
**A view must be pure, and `TextInput.maps` is excluded from comparison because it is.** A view is
|
|
377
|
+
re-invoked every frame and builds a fresh function object each time, and two lambdas are never
|
|
378
|
+
equal — so comparing `maps` would make every frame of an interactive screen differ from the last,
|
|
379
|
+
and the terminal would rewrite the screen ten times a second on exactly the screens somebody is
|
|
380
|
+
part-way through typing into. `field(compare=False, repr=False)` is what makes the frame diff work
|
|
381
|
+
at all, and it is sound only while purity holds: when two frames compare equal, the two `maps` were
|
|
382
|
+
built by the same function from the same arguments and are interchangeable, so the adapter may keep
|
|
383
|
+
either object. A view that returns a *different* mapping from the same inputs breaks here in
|
|
384
|
+
silence, as does one that reads a store, does I/O, or sorts a thousand items ten times a second. No
|
|
385
|
+
port can enforce any of it. `tests/ports/test_terminal.py` pins the exclusion, so a later reader
|
|
386
|
+
has to break an assertion before they can tidy that `compare=False` away.
|
|
387
|
+
|
|
388
|
+
## Deliberately not built
|
|
389
|
+
|
|
390
|
+
The reasoning is the point — without it these get re-proposed.
|
|
391
|
+
|
|
392
|
+
- **No `RunStatus` enum in `ports/run.py`.** A step is done when its entry file exists, so a
|
|
393
|
+
stored status would be a second source of truth that nothing updates.
|
|
394
|
+
- **No `presentation/` layer or `Display` port.** A shared abstraction would be the intersection
|
|
395
|
+
of a terminal and a browser, which is a worse terminal and a worse browser.
|
|
396
|
+
- **No config-level model override.** The choice is semantic — this role touches sensitive code,
|
|
397
|
+
that one needs judgement — so it is bound by `@role(model=…)`; an override buys only *why is my
|
|
398
|
+
Opus role running GPT-5?*
|
|
399
|
+
- **No scrubbed or replaced environment for an agent harness.** What each adapter closes is the
|
|
400
|
+
*target repository* as a configuration channel: `claude_code/runner.py` passes
|
|
401
|
+
`setting_sources=[]`, `strict_mcp_config=True`, `settings=None` and `add_dirs=[]`, and
|
|
402
|
+
`openai/runner.py` passes `--ignore-rules`, `--ignore-user-config`,
|
|
403
|
+
`-c project_doc_max_bytes=0` and `-c skills.include_instructions=false`, and hands the workspace
|
|
404
|
+
as `cwd=` rather than on argv so that no path is interpolated into a configuration expression.
|
|
405
|
+
What neither does is build an environment for the child — neither agent adapter passes `env=` at
|
|
406
|
+
all, so a harness inherits this process's and the operator's own machine stays visible to it
|
|
407
|
+
(`adapters/git/_runner.py` is the only adapter that touches the variable, and it *adds* one key
|
|
408
|
+
to what it inherited rather than replacing anything). The one
|
|
409
|
+
thing that would take that away is moving the harness's home directory, and that directory is
|
|
410
|
+
where its credential lives, so an isolated environment is an unauthenticated one: the choice is
|
|
411
|
+
between a run that inherits a machine and a run that cannot start. Two harnesses whose flags have
|
|
412
|
+
nothing in common landing on the same boundary independently is what settles that it is the real
|
|
413
|
+
one. `tests/contracts/_agent_hermeticity.py` asserts the half that is closed, against one
|
|
414
|
+
repository poisoned for every harness at once with markers that ride three channels, and it reads
|
|
415
|
+
no environment variable anywhere — deliberately, because the inherited half is not a thing it
|
|
416
|
+
could assert about without pinning the decision it declines to make.
|
|
417
|
+
- **No CLI positionals.** `agl run <workflow>` already occupies that slot, so `arg()` refuses a
|
|
418
|
+
flagless field where it is written rather than at the parse that would have gone wrong.
|
|
419
|
+
- **`@workflow` takes `version` and nothing else.** `params=`, `name=` and `roles=` each restated
|
|
420
|
+
something the framework could already read, and the copy is the half free to be wrong — `Run` is
|
|
421
|
+
covariant, so `@workflow(params=FixParams)` over `async def fix(run: Run)` type-checked fine.
|
|
422
|
+
- **No fan-out or parallelism helper.** The framework never spawns a task for a workflow; steps
|
|
423
|
+
serialise within a namespace, so real concurrency is more worktrees, and a helper would wrap
|
|
424
|
+
`asyncio.TaskGroup` while owning nothing.
|
|
425
|
+
- **No general subprocess helper.** Four modules run children — `shell/verifier.py`,
|
|
426
|
+
`git/_runner.py`, `openai/runner.py`, `openai/_session.py` — and disagree on six axes of how one
|
|
427
|
+
is *started and read*: shell or exec, buffered or streamed, stdin, stderr, deadline, failure
|
|
428
|
+
signal. A helper would take a flag per axis to say which caller it was being. Stopping is not a
|
|
429
|
+
seventh axis, because it is where all three of the modules that stop a child agree on purpose:
|
|
430
|
+
the `_signal` in `verifier.py`, in `_session.py` and in `git/_runner.py` escalates SIGTERM,
|
|
431
|
+
grace, SIGKILL; none of them signals a child whose `returncode` is already set,
|
|
432
|
+
because a reaped pid is the kernel's to hand out again and what dies is then whatever holds that
|
|
433
|
+
number now; and none of them lets a denied signal out of a stopping path, where a raw `OSError`
|
|
434
|
+
would replace whatever was being reported — a deadline, or an unwinding `CancelledError`. They
|
|
435
|
+
differ on the one line that names *what* is signalled, and that follows from how they start:
|
|
436
|
+
`verifier.py` and `_session.py` gave their child a session, so they signal the group and fall
|
|
437
|
+
back to the child itself when the group is denied; `git/_runner.py` gave its child none, so it
|
|
438
|
+
signals the process and has no group to fall back from. All three were brought into line rather
|
|
439
|
+
than born that way — a difference there was a defect, not a caller's business — and
|
|
440
|
+
`openai/runner.py`'s readiness probe was given a session so that it could spend `_session.py`'s
|
|
441
|
+
`_halt` and `_signal` unchanged rather than grow a fourth copy of them — a sibling module
|
|
442
|
+
inside one adapter, which is the one place a stopping sequence can be shared for free. It stopped
|
|
443
|
+
nothing at all until it was given both that session and a deadline. The helper would also have
|
|
444
|
+
nowhere to live: the adapter-independence contract in `.importlinter` forbids one adapter
|
|
445
|
+
importing another — the entry below is that sentence in its general form.
|
|
446
|
+
- **No shared module under `adapters/`.** The adapters repeat themselves, and every one of the
|
|
447
|
+
repeats stays. The port fakes are the bulk of it: `claude_code/fake.py` (204 lines) and
|
|
448
|
+
`openai/fake.py` (195) hold 182 lines in common line for line and the same four-name `__all__`,
|
|
449
|
+
with `Conversation`, `_payload`, `_value` and `_said` byte-identical and `_as_json` differing in
|
|
450
|
+
one clause of its error prose. All that differs is vendor-shaped: the model check — `_check_model`,
|
|
451
|
+
Claude-only, against `translate.model_slug` — the backend's name in three message constants, and
|
|
452
|
+
the activity line, `f"{declared.name}: {said}"` against `f"{_LABEL_CALLING}: {declared.name}"`,
|
|
453
|
+
each fake keeping the shape of the line its own real adapter emits. Beside them, `Caller` with
|
|
454
|
+
`_FAILED` and `_STOPPING` is 24 byte-identical lines in two `_tools.py` that are otherwise an MCP
|
|
455
|
+
server registration and a JSON-RPC listener; `_shortened` is seven byte-identical lines in
|
|
456
|
+
`claude_code/translate.py` and `openai/translate.py`; `_translated` is four lines that
|
|
457
|
+
`git/_trees.py` writes and `filesystem/store.py` writes again with one parameter renamed —
|
|
458
|
+
`git/_working.py` held a third copy and now imports `_trees.py`'s, a sibling inside one package
|
|
459
|
+
being the one place that is free; and
|
|
460
|
+
`_GRACE: Final = 5.0` stands in each of the three modules above that stop a child.
|
|
461
|
+
`_ENCODING: Final = "utf-8"` stands three times inside `git/` alone — the free kind — and is
|
|
462
|
+
refused anyway, at net zero lines: what each site decides is the handler beside it,
|
|
463
|
+
`surrogatepass` where `_snapshots.py` encodes a path into a commit digest and two paths must not
|
|
464
|
+
collapse into one, `replace` where `_patches.py` and `_runner.py` decode something only to be
|
|
465
|
+
read. Folding the half they agree on would leave the half they do not, and `_runner.py` imports
|
|
466
|
+
nothing from the package, so the real subprocess runner would be reaching into the fake
|
|
467
|
+
repository's module for a string. Contract 4 forbids one adapter importing another, so an
|
|
468
|
+
adapter-spanning duplicate folds into `ports/`, into
|
|
469
|
+
something new under `adapters/`, or nowhere. `ports/` is wrong for all of it — it is the ABCs and
|
|
470
|
+
the plain types they speak and everything imports it, so what lands there reaches every layer at
|
|
471
|
+
once: a fake implements a port and is not one, `Caller` is adapter mechanics, and how a filesystem
|
|
472
|
+
error or a vendor CLI's output line is phrased is that adapter's own business. **The peer package
|
|
473
|
+
under `adapters/` is not an available shape.** `adapter_drift` in
|
|
474
|
+
`tests/test_contract_listings.py` requires every *directory* under `src/agl/adapters/` to appear
|
|
475
|
+
in contract 4's `modules =`, and a directory has no exemption route at all — `ADAPTER_EXEMPT`
|
|
476
|
+
there is keyed by filename and holds only single-file members. The package would therefore be
|
|
477
|
+
listed, and being listed is exactly what forbids the two adapters importing it. The one shape that
|
|
478
|
+
folds is a top-level `.py` with an `ADAPTER_EXEMPT` entry — `routing.py`'s shape — and it has been
|
|
479
|
+
refused in writing already, for a structurally identical case: that constant's comment carries a
|
|
480
|
+
hypothetical shared `_process.py`, left there to say that nothing is pre-authorised, and warns
|
|
481
|
+
against taking an exemption to spare an edit to `.importlinter`, an exemption removing a module
|
|
482
|
+
from the rule where a listing applies it. `routing.py`'s own exemption is no precedent for a
|
|
483
|
+
second. It *is* an adapter: it implements `AgentRunner`, and dispatching on `task.model.provider`
|
|
484
|
+
to the vendor runners is its whole job, so importing them is the thing it does. A shared fake or a
|
|
485
|
+
shared `Caller` would be the first module under `adapters/` that is neither an adapter nor the
|
|
486
|
+
router — a library the adapters depend on, which is a different kind of thing and creates a
|
|
487
|
+
dependency edge contract 4 would see in neither direction. `tests/test_contract_listings.py`'s
|
|
488
|
+
docstring names that ending as the one contract 4 exists to catch: without the guard, "the first
|
|
489
|
+
sign of it would have been two vendors quietly sharing a helper". **The price is paid rather than
|
|
490
|
+
hidden.** Those 182 lines and those 24 get fixed twice, and both `_tools.py` have already been
|
|
491
|
+
edited in parallel once. What holds the two fakes together is grading and not sharing: each is
|
|
492
|
+
subclassed into the same `AgentContract` suite under `tests/contracts/`, so a divergence in what
|
|
493
|
+
they *promise* fails the build, while a divergence in how they spell it does not.
|
|
494
|
+
- **No single `Tool` class.** `ReportingTool[P]` in `sdk/tools.py` reads as "a `ports/` `Tool` with
|
|
495
|
+
a payload and no handler", and `tool()` beside it — an ordinary `Tool` whose schema is derived
|
|
496
|
+
from a payload dataclass and whose handler is called with the built instance — makes the
|
|
497
|
+
resemblance closer rather than weaker. What one class would buy is that resemblance written down.
|
|
498
|
+
What it costs is the only static check `Role[P]` has: that parameter binds from the one member of
|
|
499
|
+
`Sequence[Tool | ReportingTool[P]]` which carries a payload type, and it binds **because the two
|
|
500
|
+
classes are disjoint** — make `ReportingTool` a subclass of one `Tool` and every mismatched
|
|
501
|
+
declaration type-checks clean, a `ReportingTool[Other]` satisfying the bare `Tool` arm so that `P`
|
|
502
|
+
is never bound at all. Four things pay for the merge. *The layering*:
|
|
503
|
+
`tests/sdk/test_tools.py` already writes that half down — "`Tool` is a port type that must not
|
|
504
|
+
learn what a payload class is" — and one class is that sentence reversed, with either ~205 lines
|
|
505
|
+
of schema derivation following `payload` into `ports/`, or `payload: type[P]` going without them,
|
|
506
|
+
which is the split that lets a bare `Tool(payload=…)` be written carrying no schema at all. *The
|
|
507
|
+
price of recovering the check*: the one spelling that keeps it is
|
|
508
|
+
`tools: Sequence[Tool[P] | Tool[None]]` — more machinery rather than less, `Tool[Any]` in
|
|
509
|
+
`AgentTask.tools` and in `base_of`, and a hand-written overloaded `__init__` on a `ports/`
|
|
510
|
+
dataclass, a generated one being public and reopening what the overloads closed. *A refusal
|
|
511
|
+
deleted*: under that spelling a mixed **list** display quietly infers `Role[Findings | None]`
|
|
512
|
+
where today it is refused at the declaration
|
|
513
|
+
(`tests/sdk/test_roles.py::test_a_mixed_list_display_does_not_infer_p_and_says_so_at_the_declaration`,
|
|
514
|
+
whose docstring says "the `type: ignore` is the assertion"). *A new hole on the shape `tool()`
|
|
515
|
+
exists for*: a tool carrying both a payload and a handler binds `P`, so `Role(tools=(that_one,))`
|
|
516
|
+
infers `Role[Findings]` while `run.step` returns `None` and the `.summary` after it is an
|
|
517
|
+
`AttributeError` with mypy clean. And the distinction the merge would erase is not conventional.
|
|
518
|
+
A reporting tool's payload is the only value a tool call can put on the journal —
|
|
519
|
+
`sdk/_engine/steps.py`'s `return None if capture is None else capture.reported(outcome)` is the
|
|
520
|
+
whole of it, and that value becomes `Entry.value`; every other tool answers with a `ToolResult`
|
|
521
|
+
that each adapter turns into content for the model and that reaches no store. One class buries
|
|
522
|
+
that in `handler is None`.
|
|
523
|
+
- **No safe mode on `agl clear`.** It takes the whole run — every checkout, every branch, the run's
|
|
524
|
+
own included, and the records — whether the work is uncommitted, committed and unlanded, or
|
|
525
|
+
already in the base ref, and no flag changes that. The obvious alternative is a `git branch -d`
|
|
526
|
+
gate on the run's own branch, keeping it when `History.contains` says the base ref does not hold
|
|
527
|
+
it yet, with a `-f` to override. What that buys is a label that reads as free to the `Store` and
|
|
528
|
+
is taken in the repository; a warning an operator cannot act on, because the same call removed the
|
|
529
|
+
record any second `agl clear` would need; and a `--force` everybody learns to type by reflex,
|
|
530
|
+
which is a confirmation nobody reads. The honest ordering is the other way round — `git log
|
|
531
|
+
agl/<label>` before the verb, and a listing of the branches and the checkouts after it, so what
|
|
532
|
+
went is on the terminal rather than in a manual. `api.clear` therefore answers with a `Cleared`
|
|
533
|
+
rather than printing anything, `api.py` starting no output.
|
|
534
|
+
- **No `Integrator.revert()`.** Undoing a landing that succeeded is `Workspace.restore(head)`,
|
|
535
|
+
which already exists; a second spelling would be owed by every integrator.
|
|
536
|
+
- **No auto-generated commit message.** The message is domain vocabulary — `implement T-01` is
|
|
537
|
+
something only the workflow knows — so `commit=` on `run.step` is the workflow author's one
|
|
538
|
+
step-ending decision and the framework composes nothing to put beside it. Where AGL does need a
|
|
539
|
+
message it spends one the tool already wrote rather than inventing a second voice:
|
|
540
|
+
`adapters/git/integrator.py` merges and concludes with `--no-edit`, so a landing is called what
|
|
541
|
+
git calls it, and `adapters/git/fake.py`'s `_merged` writes that same sentence in the same words
|
|
542
|
+
— which is legitimate here because it describes an *event* and not a piece of work. What the
|
|
543
|
+
refusal owes in return is a way to read the author's sentence back, and `History.message` is that
|
|
544
|
+
member: without it a workflow's own test could assert that some commit happened, which is also
|
|
545
|
+
what a *missing* `commit=` produces. It promises that trailing whitespace is not part of a
|
|
546
|
+
message, because git stores one with a final newline and an implementation that kept what it was
|
|
547
|
+
given would not, and it deliberately promises nothing about the interior of a multi-line one —
|
|
548
|
+
requiring that would be the port asking every implementation for one program's text formatting.
|
|
549
|
+
- **No second `IntegrationOutcome` case for a build gate's refusal.** `Integration.conflicted` in
|
|
550
|
+
`sdk/_engine/integration.py` is one shape over two causes — a textual collision the `Integrator`
|
|
551
|
+
reported, and a landing that combined cleanly and was then reverted by `_gated` — because both
|
|
552
|
+
hold the lease and end with the same two verbs, so a workflow's conflict loop is written once. A
|
|
553
|
+
third case on the port would be a value no adapter can produce: the refusal is fabricated in the
|
|
554
|
+
engine, after `land` has already answered. `Integration.refused_by_the_gate` is what tells the two
|
|
555
|
+
apart, and it reads the verdict `_gated` sets and `_conclude` clears — never `paths == ()`, which
|
|
556
|
+
`adapters/git/_conflicts.py` also emits when git names no unmerged file. It answers about the
|
|
557
|
+
shape rather than about the record, so it is false wherever `conflicted` is: a settled outcome
|
|
558
|
+
still carries the verdict that refused it, and there is no longer a conflict for it to be the
|
|
559
|
+
cause of.
|
|
560
|
+
- **`Store` has six members and no more.** No `exists`, because a read returning `None` is that
|
|
561
|
+
question already answered; no listing of entries, because replay computes the digest it wants; no
|
|
562
|
+
transaction, because a batch needs a boundary and a flush would admit to a buffer.
|
|
563
|
+
- **No retry or exit-status cleverness on the build gate.** A dead daemon, an OOM kill and a real
|
|
564
|
+
test failure are not distinguishable from an exit code, so an OOM reads as a failed build and
|
|
565
|
+
the cost is a re-run.
|
|
566
|
+
- **No `auto()` on any enum.** Enum values reach the fingerprint as text and are therefore a
|
|
567
|
+
stored format; `auto()` would hand that format to declaration order.
|
|
568
|
+
- **No lock in `adapters/filesystem/`.** Every document has its own address, so a write is one
|
|
569
|
+
`os.replace`. A mutex would be correct, slower, and invisible — it leaves every store test
|
|
570
|
+
green, so `tests/adapters/test_filesystem_no_lock.py` reads the source instead.
|
agents_gl-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jan
|
|
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.
|