@ssheleg/agent-stack 0.8.0 → 0.10.0

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,327 @@
1
+ # Graph engineering — deciding the shape of the work before doing it
2
+
3
+ **Load this when** a job has more than two steps and you are about to write them in a
4
+ line: choosing between a chain and a graph, finding the dependencies that are not real,
5
+ placing a check between a parallel layer and the node that consumes it, or deciding
6
+ whether the structure may be discovered while it runs.
7
+
8
+ **Spec pinned:** *Graph Engineering with Claude*, `https://x.com/Mahaximus_/status/2082442856417956173`
9
+ (published 2026-07-29); Claude Code `CHANGELOG.md` v2.1.154 – v2.1.229 · read 2026-08-15
10
+
11
+ `SKILL.md` §2 owns the tool-calling loop and §5 the multi-stage pipeline. Both assume
12
+ the shape is already decided. **This file is how it gets decided**, and it is upstream of
13
+ every constant in the rest of the pack: a threshold tuned inside the wrong shape is a
14
+ well-measured answer to the wrong question.
15
+
16
+ ## Contents
17
+
18
+ - [The source, and what this file adds](#the-source-and-what-this-file-adds)
19
+ - [1. Node and edge](#1-node-and-edge)
20
+ - [2. Your loop is already a graph, and most of its edges are fake](#2-your-loop-is-already-a-graph-and-most-of-its-edges-are-fake)
21
+ - [3. The fake-edge test](#3-the-fake-edge-test)
22
+ - [4. The diamond](#4-the-diamond)
23
+ - [5. Two ways a diamond fails silently](#5-two-ways-a-diamond-fails-silently)
24
+ - [6. The checker node](#6-the-checker-node)
25
+ - [7. Static or dynamic](#7-static-or-dynamic)
26
+ - [8. When not to build a graph at all](#8-when-not-to-build-a-graph-at-all)
27
+ - [9. What Claude Code actually executes](#9-what-claude-code-actually-executes)
28
+ - [10. Barrier or no barrier](#10-barrier-or-no-barrier)
29
+ - [11. Project defaults, written once](#11-project-defaults-written-once)
30
+ - [12. The source's four diagrams, and what each one is for](#12-the-sources-four-diagrams-and-what-each-one-is-for)
31
+ - [Where this file disagrees with its source](#where-this-file-disagrees-with-its-source)
32
+
33
+ ## The source, and what this file adds
34
+
35
+ The model below — node, edge, the fake-edge test, the diamond, the checker node, static
36
+ versus dynamic — is taken from the article pinned above. It is the clearest short
37
+ statement of the idea available, and the link is kept so the original can be re-read
38
+ rather than remembered through this summary.
39
+
40
+ Four things are **this pack's**, not the source's, and each is marked where it appears:
41
+
42
+ | Added here | Why the source could not carry it |
43
+ |---|---|
44
+ | §9 — what the host actually runs, with version evidence | the article's one operational claim aged out six weeks after publication (see §9) |
45
+ | §10 — the barrier distinction | the article's diamond has a barrier at every convergence; most convergences do not need one |
46
+ | §6 — what a checker costs, and when it is a rubber stamp | a check nobody measures is a node that always says yes |
47
+ | §7 — the auditability rule as a **hard** rule, not a preference | this pack's own doctrine is that a green nobody watched fail is not evidence |
48
+
49
+ ## 1. Node and edge
50
+
51
+ **A node is one unit of work.** One input, one output, one job. Not *"research the topic,
52
+ summarise it, and check the sources"* — that is three nodes wearing one name. The
53
+ smaller and more defined the job, the more useful the node, because a node is also the
54
+ unit you retry, cache, review and replace.
55
+
56
+ **An edge is a dependency, and it carries data.** It exists when the second node
57
+ genuinely consumes what the first produced. Not when the second merely *happens after*
58
+ the first.
59
+
60
+ That distinction is the whole discipline. Write it on the edge and it stops being
61
+ abstract: `research --findings--> write --draft--> verify`. **An edge you cannot label
62
+ with what crosses it is not an edge.**
63
+
64
+ ## 2. Your loop is already a graph, and most of its edges are fake
65
+
66
+ A prompt that says *"research this, then summarise, then draft"* is a graph — a single
67
+ unbranching chain in which every step waits for its predecessor. It is correct. It is
68
+ also the slowest possible arrangement of that work and the most brittle: one bad step
69
+ takes the whole chain, and nothing runs while any step is running.
70
+
71
+ The first move is therefore not to learn a new structure. It is to look at the one you
72
+ already have and ask which of its waits are real.
73
+
74
+ ## 3. The fake-edge test
75
+
76
+ Five minutes, no tooling, and it is the highest-yield thing in this file.
77
+
78
+ 1. Write every step as a box.
79
+ 2. Draw an arrow between each pair of consecutive steps.
80
+ 3. For each arrow ask: **does data from A actually enter B?** — not *"does B come after
81
+ A"*.
82
+ 4. Yes → keep it, and **write the payload on the arrow**.
83
+ 5. No → delete it. That wait was free to give away and you were paying for it.
84
+ 6. Everything with no incoming arrow starts immediately.
85
+ 7. Everything with no outgoing arrow is a final output.
86
+
87
+ The tell that the test is being done honestly is step 4: if the payload cell is empty,
88
+ the edge is fake, and the person drawing it now has to say so out loud rather than
89
+ leaving the arrow in place because it looked orderly.
90
+
91
+ **Expect two or three fake edges in any workflow you have not run this against.** The
92
+ classic is *"review file A, then review file B"*: it reads as a sequence, and the review
93
+ of B never once looks at what A returned.
94
+
95
+ ## 4. The diamond
96
+
97
+ One node fans out into several independent nodes; those all feed one node that combines
98
+ them. Drawn out, it is a diamond, and it is the shape that makes graphs worth the setup.
99
+
100
+ ```
101
+ ┌──────────┐
102
+ │ SPLIT │
103
+ └────┬─────┘
104
+ ┌─────────────┼─────────────┐
105
+ ▼ ▼ ▼ ← parallel layer
106
+ ┌─────────┐ ┌─────────┐ ┌─────────┐
107
+ │ source1 │ │ source2 │ │ source3 │
108
+ └────┬────┘ └────┬────┘ └────┬────┘
109
+ └─────────────┼─────────────┘
110
+
111
+ ┌──────────┐
112
+ │SYNTHESIZE│ ← convergence
113
+ └──────────┘
114
+ ```
115
+
116
+ The convergence waits for the slowest branch, not for the sum of all of them.
117
+
118
+ **Two rules, and both have to hold:**
119
+
120
+ 1. **The parallel nodes are genuinely independent** — no fake edge dressed as a real one,
121
+ and no shared mutable state. Two "independent" workers writing one file are one node
122
+ with a race in it.
123
+ 2. **The convergence genuinely needs all of them.** If it needs only the first to answer,
124
+ the rest are paid-for waste and you wanted a race, not a diamond.
125
+
126
+ Once you look for it, the shape is everywhere there is a *gather-then-combine*: research,
127
+ multi-file review, market analysis, a fan of checks over one artifact.
128
+
129
+ ## 5. Two ways a diamond fails silently
130
+
131
+ Both are failures **of the convergence**, which is why sequential chains do not have
132
+ them.
133
+
134
+ **A bad node goes undetected.** Three branches run, one returns a hallucination, an empty
135
+ result or a misread file, and that output arrives at the synthesis node beside two good
136
+ ones. The synthesis node does not know one of its inputs is wrong. It produces a
137
+ confident answer built partly on garbage. Parallelism bought the speed by deleting the
138
+ checkpoints where a human would have noticed.
139
+
140
+ **The error cascades and dilutes.** In a chain, a bad step produces a visibly bad output.
141
+ At a convergence, the bad output is *mixed* with good ones, so the damage is spread thin
142
+ and the trace back to its source is gone. By the time anything looks wrong, three nodes
143
+ have averaged it into plausibility.
144
+
145
+ Both are the same defect: **the convergence trusts its inputs because they arrived.**
146
+
147
+ ## 6. The checker node
148
+
149
+ A node between the parallel layer and the convergence whose only job is to decide whether
150
+ each output may proceed. It synthesises nothing and writes nothing. It answers *is this
151
+ usable* and then passes, flags, retries or drops.
152
+
153
+ Five things it must catch — the list is the contract, and a checker that cannot say which
154
+ of the five it is asserting is not a checker:
155
+
156
+ 1. **Empty or null** — the node returned nothing usable.
157
+ 2. **Mutually contradictory** — two outputs that cannot both be true.
158
+ 3. **Off-topic** — an output that answers a different question than the one asked.
159
+ 4. **Under-confident** — a confidence signal below the bar for the downstream decision.
160
+ 5. **Malformed** — a shape that will break the convergence node's parsing.
161
+
162
+ Three of the five are code checks (1, 4, 5) and cost nothing; only 2 and 3 need a model.
163
+ Run them in that order — this is `agent-evals` §5's *cheap checks first*, applied to a
164
+ position in the graph rather than to a test suite.
165
+
166
+ **What a checker costs, and how it turns into a rubber stamp — this pack's addition.** A
167
+ checker is a node, so it has the failure mode of every node: it can be wrong. A model
168
+ checker that has never been shown a bad input will pass everything, and a graph with a
169
+ checker that always says yes is strictly worse than one with no checker, because the
170
+ absent checkpoint has been replaced by a false one. So:
171
+
172
+ - **Give it a planted bad input at least once and watch it refuse.** Same rule as any
173
+ other guard in this family.
174
+ - **Record its verdicts as scores with a source** (`agent-evals` §7), or you can never
175
+ ask afterwards how often it fired.
176
+ - **A checker that has never rejected anything is a finding**, not a reassurance.
177
+
178
+ **Wire the convergence to the checker, not to the layer.** The synthesis node depends on
179
+ the checker; the checker depends on the branches. If synthesis also takes a direct edge
180
+ from a branch, the gate has a bypass and the shape is decoration.
181
+
182
+ ## 7. Static or dynamic
183
+
184
+ A **static** graph has its nodes and edges decided before it runs. A **dynamic** graph
185
+ grows: a node finishes, looks at what it found, and decides what should come next.
186
+
187
+ | Reach for | When |
188
+ |---|---|
189
+ | **static** | the task repeats and the structure is the same each time |
190
+ | **static** | predictability and speed matter more than flexibility |
191
+ | **static** | **always first** — switch only after the static version hits a wall you can name |
192
+ | dynamic | the scope of the work depends on what is discovered along the way |
193
+ | dynamic | a node must choose its successors from its own output |
194
+ | **never dynamic** | **you will need to audit exactly what ran and why** |
195
+
196
+ The last row is a hard rule in this pack, not a preference. A dynamic graph's executed
197
+ shape is not the shape anybody drew, so *"here is the graph"* and *"here is what
198
+ happened"* stop being the same document — and every claim about the run becomes
199
+ unfalsifiable from the outside. That is the same failure `agent-evals` names when a
200
+ system has no durable trace.
201
+
202
+ **Most workflows that feel like they need a dynamic graph need a better static one.**
203
+ Dynamic is more powerful and much harder to control; it is the second reach, never the
204
+ first.
205
+
206
+ ## 8. When not to build a graph at all
207
+
208
+ The honest cost table. A graph is not free, and for a one-off it usually loses:
209
+
210
+ | | Chain | Graph |
211
+ |---|---|---|
212
+ | Time to build | low | higher — the dependencies have to be worked out |
213
+ | Time to run | the sum of the steps | the longest path |
214
+ | Debugging | easy — one line to walk | harder — concurrent state, diluted errors |
215
+ | Mid-run failure | poor, but visible immediately | good **only if** there is a checker |
216
+ | A one-off task | right answer | overkill |
217
+ | Something you run weekly | works | better, and the setup amortises |
218
+ | Growth in task size | does not scale | scales |
219
+
220
+ **Build the graph when the work repeats, or when a mid-run error is expensive enough that
221
+ the checker pays for itself.** Otherwise write the chain and move on — this is
222
+ `agent-harness`'s *start at the simplest thing that works* applied to shape.
223
+
224
+ ## 9. What Claude Code actually executes
225
+
226
+ **This section is the pack's, not the source's, and it exists because the source's one
227
+ operational claim has since changed.** The article tells the reader that Claude Code has
228
+ a `workflow` keyword which parses a YAML block of `nodes:` and `depends_on:` and
229
+ parallelises it. Two corrections, both from the vendor's own changelog:
230
+
231
+ | Version | Entry (quoted from `anthropics/claude-code` `CHANGELOG.md`) |
232
+ |---|---|
233
+ | v2.1.154 | "Introducing dynamic workflows: ask Claude to create a workflow and it orchestrates work across tens to hundreds of agents in the background" |
234
+ | v2.1.160 | "Renamed the dynamic-workflow trigger keyword from `workflow` to `ultracode`. The word 'workflow' no longer triggers a run; asking for one in your own words still works" |
235
+ | v2.1.178 | the keyword "trigger[s] only on explicit phrases like 'run a workflow' or 'workflow:', not on any mention of the word" |
236
+ | v2.1.219 | dynamic workflows "default to a medium size guideline (aim for fewer than 15 agents)"; settable via `workflowSizeGuideline` |
237
+ | v2.1.229 | fan-outs "stagger same-prefix sibling agents so subsequent agents read the cached prompt prefix instead of re-paying it" |
238
+
239
+ So the keyword named in the article stopped being the keyword in v2.1.160, and the
240
+ opt-in today is `ultracode` or an explicit phrase.
241
+
242
+ **And the YAML is not what runs.** The host does not parse `nodes:`/`depends_on:`. It
243
+ authors and executes a **script** whose primitives are the real contract:
244
+
245
+ | Primitive | Is | Note |
246
+ |---|---|---|
247
+ | `agent(prompt, opts)` | one subagent | `opts.schema` forces a validated object back, so downstream stages get data, not prose to parse |
248
+ | `parallel(thunks)` | concurrent, **with a barrier** | awaits all; a thrower resolves to `null` rather than rejecting the call |
249
+ | `pipeline(items, ...stages)` | each item through all stages, **no barrier** | item A can be in stage 3 while B is still in stage 1 |
250
+ | `phase(title)` | a progress grouping | display, not semantics |
251
+ | `isolation: "worktree"` | a private checkout per agent | the only safe way to fan out writers |
252
+
253
+ Concurrency is capped at `min(16, cores − 2)` per run, and a run's total agents at 1000.
254
+ Passing 100 items is fine — they queue.
255
+
256
+ **Why this matters for the model above:** the article's diamond is `parallel()`, and the
257
+ next section is the distinction it does not draw.
258
+
259
+ ## 10. Barrier or no barrier
260
+
261
+ **This section is the pack's.** A convergence node is a barrier: nothing downstream of it
262
+ starts until every branch has finished. The article treats that as the definition of a
263
+ diamond. It is actually a *choice*, and the wrong default.
264
+
265
+ A barrier is correct only when the downstream stage needs **cross-item** context:
266
+
267
+ - deduplicating or merging across the whole result set before expensive work;
268
+ - an early exit that depends on the total ("zero findings → skip verification");
269
+ - a stage whose prompt genuinely compares one item against the others — **which is
270
+ exactly what a checker node does**, and is why the checker is a legitimate barrier.
271
+
272
+ A barrier is **not** justified by:
273
+
274
+ - *"I need to flatten or filter the results first"* — do that inside a stage;
275
+ - *"the stages are conceptually separate"* — separate is not the same as synchronised;
276
+ - *"it reads more cleanly"* — the cost is real. With five branches where the slowest takes
277
+ three times the fastest, a barrier idles the four fast ones for two thirds of the wait.
278
+
279
+ The rule: **pipeline by default; barrier when a stage names the cross-item fact it
280
+ needs.** If it cannot name one, it does not need one.
281
+
282
+ ## 11. Project defaults, written once
283
+
284
+ Anything you run more than twice deserves its graph conventions recorded where the agent
285
+ reads them (`CLAUDE.md`, or the equivalent for the host), so they are not re-derived per
286
+ session:
287
+
288
+ ```markdown
289
+ ## Workflow defaults
290
+
291
+ - A node with no declared dependency starts immediately; do not serialise by habit.
292
+ - Every declared dependency names the data it carries. No payload named ⇒ delete the edge.
293
+ - A checker sits between any parallel layer and the node that consumes it, and the
294
+ consumer depends on the checker rather than on the layer.
295
+ - A checker flags; it never silently passes an incomplete output.
296
+ - A node that fails pauses the run and reports; nothing downstream consumes a flagged output.
297
+ - Outputs are files with the node's name; the graph passes paths, not transcripts.
298
+ ```
299
+
300
+ The last line is `context-engineering.md`'s *filesystem as context* stated as a graph
301
+ rule: an edge that carries a path costs a few tokens, and an edge that carries a
302
+ transcript costs the window.
303
+
304
+ ## 12. The source's four diagrams, and what each one is for
305
+
306
+ The article carries four hand-drawn figures. They are not decoration — each one is doing
307
+ a specific job, and knowing which one saves re-reading the prose:
308
+
309
+ | Figure | What it shows | The job it does |
310
+ |---|---|---|
311
+ | **Cover — "Graph Engineering explained"** | `START` (define the task) → `SPLIT` (break into nodes) → a fan of three workers labelled *research / compare / check* → `CHECKER` (catch errors early) → `OUTPUT` (one clean answer) | The whole argument in one line, and the only figure in which the checker appears as a first-class stage rather than an afterthought |
312
+ | **Node / edge** | Three boxes — `Research` (in: topic, out: findings) → `Write` (in: findings, out: draft) → `Verify` (in: draft, out: final) — with `NODE` and `EDGE` labelled, and **the arrows themselves labelled with the data they carry** | Makes §1 concrete: the payload written on the arrow is what turns "comes after" into "depends on". This is the figure to copy when teaching the model |
313
+ | **The diamond** | One `RESEARCH` node fanning into `SOURCE 1/2/3` (bracketed *parallel layer*), all three converging on `SYNTHESIZE` | The ideal shape, drawn **before** the failure modes — deliberately without a checker, which is what §5 then attacks |
314
+ | **Workflow — how the code runs** | The same shape in code terms: `research_a/b/c` in a parallel layer, three arrows into `checker` annotated *waits for all three*, one arrow from `checker` into `compare` | The repaired shape. Its point is the single edge out of the checker: `compare` depends on the **gate**, not on the branches — §6's last paragraph, drawn |
315
+
316
+ ## Where this file disagrees with its source
317
+
318
+ - **The keyword.** The source's `workflow` is `ultracode` since v2.1.160 (§9). Treated as
319
+ a version-dated fact rather than a correction of the author: it was true when written.
320
+ - **The YAML.** The source presents `workflow:` / `nodes:` / `depends_on:` as a syntax the
321
+ host parses. It is a way of *describing* a graph in a prompt, and it works for that; the
322
+ execution contract is the script in §9.
323
+ - **The barrier.** The source's diamond always synchronises. This file makes the barrier a
324
+ decision with a named justification (§10).
325
+ - **The checker's own reliability.** The source introduces the checker and stops. This
326
+ file requires it to have been watched refusing a planted input, and treats a checker
327
+ that has never rejected anything as a finding (§6).
@@ -248,7 +248,7 @@ Map your public model names to provider ids in **one** function, and give every
248
248
  provider a default and a fallback:
249
249
 
250
250
  ```
251
- toUpstreamModel(provider, model) // "gpt-4o" → "openai/gpt-4o"
251
+ toUpstreamModel(provider, model) // "<public-name>" → "<provider>/<upstream-id>"
252
252
  getDefaultModel(provider) // when the caller names none
253
253
  getFallbackModels(provider) // ordered, tried on 5xx / overload
254
254
  ```
@@ -139,6 +139,10 @@ class ExecutionPlan:
139
139
  @classmethod
140
140
  def from_json(cls, raw: str) -> ExecutionPlan: ...
141
141
 
142
+ def layers(self) -> list[list[PlanStage]]:
143
+ """Kahn's algorithm over `depends_on`. Each returned list may run concurrently."""
144
+ ...
145
+
142
146
  @dataclass
143
147
  class StageResult:
144
148
  stage_id: str
@@ -149,6 +153,14 @@ class StageResult:
149
153
  error: str | None = None
150
154
  ```
151
155
 
156
+ **`depends_on` is a claim the executor has to honour.** A plan that declares dependencies
157
+ and is then executed in list order has serialised itself: `stages[3]` waits for
158
+ `stages[2]` whether or not it consumes anything it produced. Execute by **layer** —
159
+ everything whose dependencies are satisfied goes together — and the declaration starts
160
+ paying for itself. Two rules come with it: a cycle is a plan defect and fails the plan
161
+ rather than deadlocking the run, and a layer of more than one stage needs the convergence
162
+ check in `graph-engineering.md` §6 before anything downstream consumes it.
163
+
152
164
  ---
153
165
 
154
166
  ## Validation Loop (SQL Execution)
@@ -187,21 +199,33 @@ for attempt in range(1, max_retries + 1):
187
199
 
188
200
  ## Context Window Sizes
189
201
 
202
+ **Do not ship a table of model ids.** This file carried one until 2026-08-15 — nine
203
+ vendor ids with their windows, and a `DEFAULT_CONTEXT_WINDOW` of 16 000. Every number in
204
+ it was correct when written and none of it survived a year: generations shipped, ids were
205
+ renamed, long-context variants appeared under the same family name, and a system reading
206
+ that table would have sized its budget for a window an order of magnitude smaller than
207
+ the one it was actually given. A lookup table of somebody else's identifiers is a cache
208
+ with no invalidation.
209
+
210
+ **Resolve the window at one boundary instead**, in this order, and let every caller ask
211
+ that boundary rather than a constant:
212
+
213
+ 1. **Configuration** — an explicit per-model entry the operator set. It outranks
214
+ everything, because it is the only source that can encode a limit you have chosen (a
215
+ budget cap below the real window, a provider tier).
216
+ 2. **The provider** — the model list or metadata endpoint most APIs expose. Fetched once
217
+ per process, cached with a TTL, refreshed on a miss.
218
+ 3. **A conservative floor** for a model nothing knows about, plus a **loud log line**
219
+ naming the model. A silent default is how a new model runs at a fraction of its
220
+ window for months with nobody noticing.
221
+
222
+ The floor is a number to be small about, not accurate about: being early to compact costs
223
+ one avoidable rung of the ladder, and being late costs the request.
224
+
190
225
  ```python
191
- MODEL_CONTEXT_WINDOWS = {
192
- "gpt-4o": 128_000,
193
- "gpt-4o-mini": 128_000,
194
- "gpt-4-turbo": 128_000,
195
- "gpt-4": 8_192,
196
- "gpt-3.5-turbo": 16_385,
197
- "claude-sonnet-4-20250514": 200_000,
198
- "claude-3-5-sonnet-20241022": 200_000,
199
- "claude-3-haiku-20240307": 200_000,
200
- "claude-3-opus-20240229": 200_000,
201
- }
202
- DEFAULT_CONTEXT_WINDOW = 16_000
203
-
204
- # Token estimation: tiktoken for OpenAI models, ~4 chars/token fallback
226
+ # Token estimation: a real tokenizer where one is available, ~4 chars/token otherwise.
227
+ # The fallback runs LOW on code, JSON and non-Latin text — see context-engineering.md
228
+ # → Estimating what you have left, and apply the padding factor described there.
205
229
  def estimate_tokens(text):
206
230
  try:
207
231
  import tiktoken