@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.
@@ -158,14 +158,50 @@ def check_hardcoded_model(rel, text, lines):
158
158
  "tenant, system default — is the shape that bills correctly")
159
159
 
160
160
 
161
+ def check_unguarded_fanout(rel, text, lines):
162
+ """A fan-out whose siblings' failures are not captured.
163
+
164
+ `asyncio.gather` without `return_exceptions=True` cancels the whole batch on the first
165
+ exception: every other branch's completed work is discarded, and the node that consumes
166
+ the results cannot tell a branch that FAILED from one that returned nothing. `Promise.all`
167
+ has the identical shape. That is the failure a checker node between a parallel layer and
168
+ its convergence exists to stop, and it is invisible in a green test run because the happy
169
+ path never exercises it.
170
+
171
+ Conservative twice over, like every detector here: the file must already look
172
+ agent-related, and the capturing form must be absent from the WHOLE file — one
173
+ `return_exceptions` or `allSettled` anywhere is taken as evidence the author knows the
174
+ distinction, and this pass says nothing.
175
+ """
176
+ if re.search(r"return_exceptions|allSettled", text):
177
+ return
178
+ for i, l in enumerate(lines, 1):
179
+ if re.search(r"\basyncio\.gather\s*\(", l):
180
+ add("unguarded-fanout", rel, i,
181
+ "`asyncio.gather` with no `return_exceptions=True` — the first sibling to "
182
+ "raise cancels the batch and throws away what the others already produced",
183
+ "Capture each branch's outcome, then gate the convergence on a checker that "
184
+ "can tell a failed branch from an empty one")
185
+ if re.search(r"\bPromise\.all\s*\(", l) and ".catch" not in l:
186
+ add("unguarded-fanout", rel, i,
187
+ "`Promise.all` with no `allSettled` and no per-branch `.catch` — one "
188
+ "rejection discards every other branch's completed result",
189
+ "Use `Promise.allSettled` or catch per branch, then gate the convergence on "
190
+ "a checker that can see which branch failed")
191
+
192
+
161
193
  CHECKS = [check_unbounded_loop, check_tool_without_description, check_swallowed_error,
162
- check_no_timeout, check_hardcoded_model]
194
+ check_no_timeout, check_hardcoded_model, check_unguarded_fanout]
163
195
 
164
196
  # What no static pass can reach. Printed every run, never suppressed.
165
197
  BLIND = [
166
198
  "whether the SYSTEM PROMPT is at the right altitude — or whether it is in this repo at all",
167
199
  "whether two tool descriptions actually distinguish themselves to a model",
168
200
  "whether the workflow/agent choice was made deliberately or defaulted to an agent",
201
+ "whether a fan-out has a CHECKER between it and the node that consumes it — this pass "
202
+ "sees an unguarded gather, never a missing gate",
203
+ "whether a declared dependency graph is actually executed in dependency order, or in "
204
+ "the order the stages happen to be listed in",
169
205
  "whether retries and fallbacks MULTIPLY (three providers x three retries is nine calls)",
170
206
  "whether compaction preserves decisions and open questions, or keeps the discussion",
171
207
  "whether tool output is treated as untrusted input",
@@ -217,54 +253,86 @@ def report_text(root, seen, considered):
217
253
  return "\n".join(out)
218
254
 
219
255
 
256
+ PY_HEADER = ("import requests\n"
257
+ "system_prompt = 'x'\n"
258
+ "tools = [{'name': 't', 'description': 'does a thing'}]\n"
259
+ "messages = []\n")
260
+ JS_HEADER = ("const system_prompt = 'x';\n"
261
+ "const tools = [{name: 't', description: 'does a thing'}];\n"
262
+ "const messages = [];\n")
263
+
264
+ # (label, the check that MUST fire, filename, body)
265
+ PLANTS = [
266
+ ("unbounded-loop", "unbounded-loop", "agent.py",
267
+ PY_HEADER + "while True:\n pass\n"),
268
+ ("tool-no-description", "tool-no-description", "agent.py",
269
+ PY_HEADER + "T = [{'name': 'a', 'description': ''}]\n"),
270
+ ("swallowed-error", "swallowed-error", "agent.py",
271
+ PY_HEADER + "try:\n x = 1\nexcept Exception:\n pass\n"),
272
+ ("no-timeout", "no-timeout", "agent.py",
273
+ PY_HEADER + "r = requests.get('https://example.com')\n"),
274
+ ("hardcoded-model", "hardcoded-model", "agent.py",
275
+ PY_HEADER + "a = 'claude-opus-4'\nb = 'claude-opus-4'\n"),
276
+ ("unguarded-fanout (asyncio)", "unguarded-fanout", "agent.py",
277
+ PY_HEADER + "out = await asyncio.gather(*(run(t) for t in tasks))\n"),
278
+ ("unguarded-fanout (promise)", "unguarded-fanout", "agent.js",
279
+ JS_HEADER + "const out = await Promise.all(tasks.map(t => run(t)));\n"),
280
+ ]
281
+
282
+ # A detector that fires on the defect AND on its fix has no discriminating power. Each
283
+ # clean fixture is the half of the evidence that says which one this is.
284
+ CLEAN = [
285
+ ("the ordinary correct file", "agent.py",
286
+ PY_HEADER + "for _ in range(10):\n pass\n"
287
+ "r = requests.get('https://example.com', timeout=5)\n"),
288
+ ("a fan-out that DOES capture its branches", "agent.py",
289
+ PY_HEADER + "for _ in range(10):\n pass\n"
290
+ "out = await asyncio.gather(*(run(t) for t in tasks), return_exceptions=True)\n"),
291
+ ]
292
+
293
+
220
294
  def self_test():
221
295
  """Plant each defect and require the matching check to fire.
222
296
 
223
297
  A detector nobody has watched fire is not evidence that it works, and every plant
224
298
  asserts it changed something so a reworded fixture fails HERE rather than reporting a
225
299
  healthy checker as broken.
300
+
301
+ Two things the shape of this function is deliberate about. A detector that reads two
302
+ languages gets a plant in **each** — one passing shape is not evidence about the
303
+ other. And every run also asserts silence on the CORRECT shape of the same defect,
304
+ which is what separates a detector from a keyword search.
226
305
  """
227
306
  import tempfile
228
- header = ("import requests\n"
229
- "system_prompt = 'x'\n"
230
- "tools = [{'name': 't', 'description': 'does a thing'}]\n"
231
- "messages = []\n")
232
- cases = {
233
- "unbounded-loop": header + "while True:\n pass\n",
234
- "tool-no-description": header + "T = [{'name': 'a', 'description': ''}]\n",
235
- "swallowed-error": header + "try:\n x = 1\nexcept Exception:\n pass\n",
236
- "no-timeout": header + "r = requests.get('https://example.com')\n",
237
- "hardcoded-model": header + "a = 'claude-opus-4'\nb = 'claude-opus-4'\n",
238
- }
239
307
  failures = 0
240
- for kind, body in cases.items():
308
+ for label, kind, fname, body in PLANTS:
241
309
  FINDINGS.clear()
242
310
  with tempfile.TemporaryDirectory() as d:
243
- p = os.path.join(d, "agent.py")
244
- with open(p, "w", encoding="utf-8") as fh:
311
+ with open(os.path.join(d, fname), "w", encoding="utf-8") as fh:
245
312
  fh.write(body)
246
- assert agentish(body), f"PLANT DID NOT LAND: fixture for {kind} is not agent-related"
313
+ assert agentish(body), f"PLANT DID NOT LAND: fixture for {label} is not agent-related"
247
314
  scan(d)
248
315
  got = {f["check"] for f in FINDINGS}
249
316
  if kind in got:
250
- print(f" OK {kind}: detected")
317
+ print(f" OK {label}: detected")
251
318
  else:
252
- print(f" FAIL {kind}: NOT detected (found {sorted(got) or 'nothing'})")
319
+ print(f" FAIL {label}: NOT detected (found {sorted(got) or 'nothing'})")
253
320
  failures += 1
254
- # and a clean file must produce nothing, or every finding above is noise
255
- FINDINGS.clear()
256
- with tempfile.TemporaryDirectory() as d:
257
- with open(os.path.join(d, "agent.py"), "w", encoding="utf-8") as fh:
258
- fh.write(header + "for _ in range(10):\n pass\n"
259
- "r = requests.get('https://example.com', timeout=5)\n")
260
- scan(d)
261
- if FINDINGS:
262
- print(f" FAIL clean file produced {len(FINDINGS)} finding(s): "
263
- f"{[f['check'] for f in FINDINGS]}")
264
- failures += 1
265
- else:
266
- print(" OK clean file: silent")
267
- print(f"\nself-test: {len(cases) + 1 - failures}/{len(cases) + 1} passed")
321
+ for label, fname, body in CLEAN:
322
+ FINDINGS.clear()
323
+ with tempfile.TemporaryDirectory() as d:
324
+ with open(os.path.join(d, fname), "w", encoding="utf-8") as fh:
325
+ fh.write(body)
326
+ assert agentish(body), f"CLEAN FIXTURE NOT READ: {label} is not agent-related"
327
+ scan(d)
328
+ if FINDINGS:
329
+ print(f" FAIL {label}: produced {len(FINDINGS)} finding(s): "
330
+ f"{[f['check'] for f in FINDINGS]}")
331
+ failures += 1
332
+ else:
333
+ print(f" OK {label}: silent")
334
+ total = len(PLANTS) + len(CLEAN)
335
+ print(f"\nself-test: {total - failures}/{total} passed")
268
336
  return 1 if failures else 0
269
337
 
270
338
 
@@ -5,13 +5,14 @@ description: >-
5
5
  use, an AI pipeline — or when metering and billing the LLM access it burns. Covers tool-
6
6
  calling loops, multi-stage pipelines with human checkpoints, provider routing with fallback
7
7
  and retry, four-layer memory with confidence decay, context budgets, sub-agent coordination
8
- and error hierarchies; for resale: tiered wallets, the single markup boundary, two-phase
9
- commit across a database and a provider API, spend-delta polling, budget and loop guardrails,
8
+ and error hierarchies; the work as a graph parallel layers, fake edges, a checker before
9
+ a convergence; for resale: tiered wallets, the single markup boundary, two-phase commit
10
+ across a database and a provider API, spend-delta polling, budget and loop guardrails,
10
11
  per-tenant key lifecycle. Triggers - "agent", "orchestrator", "tool calling", "sub-agent",
11
12
  "LLM router", "fallback chain", "human in the loop", "memory layer", "LLM billing", "token
12
- wallet", "агент", "оркестратор", "суб-агент", "роутер моделей", "человек в цикле", "слой
13
- памяти", "биллинг LLM", "лимит бюджета". Not for a single LLM call in a script, or for prompt
14
- wording.
13
+ wallet", "checker node", "агент", "оркестратор", "суб-агент", "роутер моделей", "человек в
14
+ цикле", "слой памяти", "биллинг LLM", "граф задач". Not for a single LLM call in a script,
15
+ or for prompt wording.
15
16
  ---
16
17
 
17
18
  # Agent Orchestrator — Production Best Practices
@@ -59,7 +60,7 @@ class AgentContext:
59
60
  connection_config: ... | None # external resource config
60
61
  user_id: str | None
61
62
  preferred_provider: str | None # e.g. "openrouter"
62
- model: str | None # e.g. "openai/gpt-4o"
63
+ model: str | None # e.g. "<provider>/<model-id>"
63
64
  extra: dict[str, Any] # pipeline_action, flags, overrides
64
65
  ```
65
66
 
@@ -143,40 +144,28 @@ else:
143
144
 
144
145
  ## 3. Meta-Tools (Orchestrator-Level)
145
146
 
146
- Define tools that **delegate to sub-agents**, not execute directly:
147
-
148
- ```python
149
- QUERY_DATABASE_TOOL = Tool(
150
- name="query_database",
151
- description="Query the connected database. Handles SQL generation, validation, execution.",
152
- parameters=[ToolParameter(name="question", type="string", description="Data question")]
153
- )
154
- ASK_USER_TOOL = Tool(
155
- name="ask_user",
156
- description="Ask the user a structured clarification question.",
157
- parameters=[
158
- ToolParameter(name="question", type="string", ...),
159
- ToolParameter(name="question_type", type="string",
160
- enum=["yes_no", "multiple_choice", "free_text"]),
161
- ToolParameter(name="options", type="string", required=False),
162
- ]
163
- )
164
- ```
165
-
166
- **Assemble tools dynamically** based on available capabilities:
147
+ The orchestrator's tools **delegate to sub-agents** rather than execute:
148
+ `query_database` takes a question in natural language and the SQL agent behind it owns
149
+ generation, validation and execution. One parameter, one responsibility, and the caller
150
+ never learns the sub-agent exists.
167
151
 
168
152
  ```python
169
153
  def get_tools(*, has_db=False, has_kb=False, has_mcp=False) -> list[Tool]:
154
+ """Assembled per request from the same capability flags that build the prompt (§10)."""
170
155
  tools = []
171
- if has_db:
172
- tools.extend([QUERY_DB, PROCESS_DATA, MANAGE_RULES, ASK_USER])
173
- if has_kb:
174
- tools.append(SEARCH_CODEBASE)
175
- if has_mcp:
176
- tools.append(QUERY_MCP)
156
+ if has_db: tools.extend([QUERY_DB, PROCESS_DATA, MANAGE_RULES, ASK_USER])
157
+ if has_kb: tools.append(SEARCH_CODEBASE)
158
+ if has_mcp: tools.append(QUERY_MCP)
177
159
  return tools
178
160
  ```
179
161
 
162
+ Two rules that are this layer's and not the prompt's: **a capability the request does not
163
+ have contributes no tool**, and every enum a tool accepts is closed at the schema
164
+ (`ask_user`'s `question_type` is `yes_no | multiple_choice | free_text`, never free
165
+ prose). How to *describe* a tool so the model picks the right one — the sentence naming
166
+ when to use it, and the neighbour it is confused with — is
167
+ `agent-harness/references/tools.md`.
168
+
180
169
  ---
181
170
 
182
171
  ## 4. Sub-Agent Retry and Validation
@@ -240,8 +229,8 @@ async def detect_complexity_adaptive(question, llm, history) -> bool:
240
229
  ### Pipeline Components
241
230
 
242
231
  ```
243
- QueryPlanner → (single LLM call) → ExecutionPlan (ordered stages)
244
- StageExecutor → runs stages sequentially with validation + retry
232
+ QueryPlanner → (single LLM call) → ExecutionPlan (stages + their depends_on)
233
+ StageExecutor → runs stages in DEPENDENCY LAYERS with validation + retry (§13)
245
234
  StageValidator → checks data shape, row bounds, cross-stage consistency
246
235
  StageContext → in-memory state (plan, results per stage, user feedback)
247
236
  PipelineRun → DB-persisted state for resume/retry across requests
@@ -250,21 +239,27 @@ PipelineRun → DB-persisted state for resume/retry across requests
250
239
  ### Checkpoint Pattern (Human-in-the-Loop)
251
240
 
252
241
  ```python
253
- for idx, stage in enumerate(plan.stages):
254
- result = await execute_with_retries(stage, context)
255
- validation = validator.validate(stage, result, stage_ctx)
242
+ for layer in plan.layers(): # Kahn over depends_on — never list order
243
+ results = await run_layer(layer, context) # execute_with_retries per stage, together
256
244
 
257
- if not validation.passed:
258
- retried = await retry_failed_validation(stage, context, validation)
259
- if retried is None:
260
- return StageFailedResult(stage, validation) # ask user
261
- result = retried
245
+ for i, (stage, result) in enumerate(zip(layer, results)):
246
+ validation = validator.validate(stage, result, stage_ctx)
247
+ if not validation.passed:
248
+ results[i] = await retry_failed_validation(stage, context, validation)
249
+ if results[i] is None:
250
+ return StageFailedResult(stage, validation) # ask user
262
251
 
263
- stage_ctx.set_result(stage.id, result)
252
+ if len(layer) > 1: # cheap per-stage checks ran first; this
253
+ verdict = checker.check(results) # one is the cross-item gate (§13)
254
+ if not verdict.passed:
255
+ return StageFailedResult(layer, verdict) # nothing converges on a flagged output
264
256
 
265
- if stage.checkpoint:
257
+ for stage, result in zip(layer, results):
258
+ stage_ctx.set_result(stage.id, result)
259
+
260
+ if any(s.checkpoint for s in layer):
266
261
  persist_to_db(pipeline_run_id, stage_ctx)
267
- return CheckpointResult(stage, result) # pause for user review
262
+ return CheckpointResult(layer, results) # pause for user review
268
263
  # User responds: "continue" | "modify" | "retry"
269
264
  ```
270
265
 
@@ -329,37 +324,22 @@ a floor.
329
324
  mode cross a compaction boundary as copied typed blocks, not prose (§12).
330
325
  ## 8. Self-Learning Feedback Loops
331
326
 
332
- ### Cycle 1: Automatic (Validation Loop)
333
-
334
- After every SQL execution cycle, heuristic extractors analyze the attempt sequence:
335
-
336
- | Extractor | Detects | Creates |
337
- |-----------|---------|---------|
338
- | Table preference | Wrong table A fixed to B | "Use `B` instead of `A`" |
339
- | Column correction | column_not_found → suggested col | "Use `full_name` not `user_name`" |
340
- | Format discovery | Division by 100/1000 added | "Amounts in cents, divide by 100" |
341
- | Schema gotcha | `deleted_at IS NULL` added | "Soft-delete: filter active records" |
342
- | Performance hint | Timeout fixed by LIMIT/date filter | "Always add LIMIT to this table" |
343
-
344
- LLM-based deep analysis (3+ attempts, 1hr cooldown) for cross-query patterns.
345
-
346
- ### Cycle 2: User Feedback
327
+ Three cycles feed layers 3 and 4, and they differ by what supplies the signal:
347
328
 
348
- ```python
349
- # Thumbs down → analyze_negative_feedback() → learning
350
- # Data validation:
351
- # confirmed store benchmark
352
- # approximate benchmark + session note (deviation details)
353
- # rejected → learning + note + flag stale benchmark
354
- # Categorize rejection: currency/format → data_format, filter → schema_gotcha,
355
- # table → table_preference, join → schema_gotcha
356
- ```
329
+ | Cycle | Signal | Produces |
330
+ |---|---|---|
331
+ | **Validation** | the attempt sequence of a call that failed and was then fixed | a learning, extracted by heuristic — the wrong table, a renamed column, a unit divisor, a soft-delete filter, a missing `LIMIT`. Deep LLM analysis only past 3 attempts, on a cooldown |
332
+ | **User feedback** | a thumbs-down, or a data verdict of confirmed / approximate / rejected | a benchmark, a session note with the deviation, or a learning plus a flag on the now-stale benchmark |
333
+ | **Lifecycle** | time, and contradiction | decay, conflict resolution by negation flip, and promotion of a pattern seen on two independent resources |
357
334
 
358
- ### Cycle 3: Knowledge Lifecycle
335
+ The extractors, the exact confidence arithmetic and the promotion query live in
336
+ `references/patterns.md` — **Learning Extraction Heuristics**, **Confidence Management**
337
+ and **Cross-Resource Learning Transfer** — and not here, because a decay rate is a
338
+ constant to tune and a constant with two homes is one that will disagree with itself.
359
339
 
360
- - **Decay**: stale learnings -0.02/month, notes -0.1/60 days, insights -0.05/30 days
361
- - **Conflict resolution**: negation flips deactivate old conflicting lessons
362
- - **Global promotion**: patterns on 2+ resources promoted project-wide
340
+ **The rule the whole section exists for:** a learning is written from a **contrast** — the
341
+ attempt that failed beside the attempt that worked — never from a single successful run.
342
+ A system that learns from its successes learns its own habits.
363
343
 
364
344
  ---
365
345
 
@@ -383,39 +363,26 @@ class WorkflowTracker:
383
363
  # orchestrator:sql_agent, orchestrator:llm_retry, orchestrator:warning
384
364
  ```
385
365
 
386
- Stream final answer text in chunks for typing effect:
387
-
388
- ```python
389
- async def stream_tokens(wf_id, text, chunk_size=12):
390
- for i in range(0, len(text), chunk_size):
391
- await tracker.emit(wf_id, "token", "streaming", text[i:i+chunk_size])
392
- ```
366
+ The final answer streams in chunks as `token` events on the same bus — a typing effect is
367
+ a chunked emit, not a second mechanism. What makes the feed reliable rather than decorative
368
+ is in `references/runtime.md`: a monotonic id per event so a reconnecting client can resume,
369
+ and the feed being a **view over the durable trace** rather than the record itself.
393
370
 
394
371
  ---
395
372
 
396
373
  ## 10. Dynamic System Prompts
397
374
 
398
- Build system prompts dynamically based on available capabilities:
375
+ **Assemble the prompt from the capabilities that are actually present**, in the same pass
376
+ that assembles the tools (§3): one section naming each live capability, the resource map
377
+ if there is one, the current learnings, then the guidelines. A prompt that describes a
378
+ tool the agent was not given is how a model spends a turn calling something that is not
379
+ there.
399
380
 
400
- ```python
401
- def build_system_prompt(*, project_name, db_type, has_connection, has_kb, table_map,
402
- project_overview, recent_learnings):
403
- sections = [f"You are an AI data assistant for '{project_name}'."]
404
- sections.append("AVAILABLE CAPABILITIES:")
405
- if has_connection:
406
- sections.append("- query_database: ... SQL agent handles everything")
407
- sections.append("- process_data: ... enrich/aggregate/filter")
408
- sections.append("- manage_rules: ... CRUD project rules")
409
- if has_kb:
410
- sections.append("- search_codebase: ... RAG over indexed code")
411
-
412
- if table_map:
413
- sections.append(f"DATABASE TABLES: {table_map}")
414
- if recent_learnings:
415
- sections.append(recent_learnings) # "AGENT LEARNINGS: ..."
416
- sections.append("GUIDELINES: ...") # routing rules, verification protocol
417
- return "\n".join(sections)
418
- ```
381
+ What belongs in that text, at what altitude, and how to enumerate the vocabulary so the
382
+ agent stops inventing status values is the **`agent-harness`** skill's
383
+ `agent-harness/references/system-prompt.md` — one home, and it is not this one. What is *this* skill's
384
+ is the wiring: the prompt is rebuilt per request from the same capability flags the tool
385
+ list is built from, so the two can never disagree.
419
386
 
420
387
  **Data Verification Protocol** (inject when DB connected):
421
388
  - First-time metrics: ask user "Do these numbers match expectations?"
@@ -461,6 +428,33 @@ transcript.
461
428
 
462
429
  ---
463
430
 
431
+ ## 13. The Work as a Graph
432
+
433
+ Before the loop, the pipeline or the sub-agents: **decide the shape.** A node is one unit
434
+ of work; an edge is a dependency, and an edge carries data. The full model, the source it
435
+ comes from, and what this host actually executes are in
436
+ [`references/graph-engineering.md`](references/graph-engineering.md).
437
+
438
+ Four rules, and these are the ones that change code:
439
+
440
+ - **Label every edge with what crosses it. No payload, no edge.** Run the fake-edge test
441
+ over any chain you inherited: write the steps as boxes, ask of each arrow whether data
442
+ from A actually enters B, and delete the arrows that only encode the order somebody
443
+ typed. Two or three per workflow is the normal yield.
444
+ - **`depends_on` is a claim, so execute by layer.** §5's executor walked `plan.stages` in
445
+ list order beside a model that declared its dependencies — which serialises a plan that
446
+ went to the trouble of saying it need not be. Kahn the graph; a cycle fails the plan
447
+ rather than deadlocking the run.
448
+ - **A parallel layer needs a checker before its convergence.** Three branches run, one
449
+ returns a hallucination, and the synthesis node cannot tell: it combines all three and
450
+ answers confidently. The checker decides *usable / not usable* and nothing else, and
451
+ the convergence depends on **the checker**, never directly on a branch.
452
+ - **Static unless you can name what forces dynamic.** A graph that picks its own next
453
+ nodes cannot be audited afterwards, because the shape that ran is not the shape anyone
454
+ drew. Where a run has to be explainable, that settles it.
455
+
456
+ ---
457
+
464
458
  ## Checklist — Building a New Orchestrator
465
459
 
466
460
  - [ ] Shared `AgentContext` dataclass with all sub-agents
@@ -485,6 +479,10 @@ transcript.
485
479
  - [ ] `ask_user` clarification mechanism
486
480
  - [ ] Graceful degradation (partial answers on context overflow or max iterations)
487
481
  - [ ] Compaction ladder, tool-pair-safe boundaries, typed carryover, output offload
482
+ - [ ] Every declared dependency names the data it carries — the fake-edge test run once
483
+ - [ ] Plans executed in dependency layers, not in the order the stages were listed
484
+ - [ ] A checker between every parallel layer and the node that consumes it, and that
485
+ checker watched refusing a planted bad input at least once
488
486
 
489
487
  ---
490
488
 
@@ -496,6 +494,7 @@ stays an index and the two cannot drift apart.
496
494
 
497
495
  | File | Read it when |
498
496
  |---|---|
497
+ | [`references/graph-engineering.md`](references/graph-engineering.md) | you are deciding the **shape of the work** — the fake-edge test, the diamond, the checker node, static versus dynamic, and what the host actually runs |
499
498
  | [`references/patterns.md`](references/patterns.md) | you need the **data models and algorithms** under the body |
500
499
  | [`references/context-engineering.md`](references/context-engineering.md) | the loop is **running out of window** |
501
500
  | [`references/runtime.md`](references/runtime.md) | the agent must **survive a crash, a pause, a second message or a schedule** |