@ssheleg/agent-stack 0.10.1 → 0.11.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.11.1 — 2026-08-16
4
+
5
+ **This pack was the one place nothing was looking.** The family umbrella's shared checker
6
+ had an early exit for a member carrying no routed triggers, and `agent-stack` is that
7
+ member — so when a sibling's front matter turned out to be invalid YAML (B-56: a
8
+ colon-space inside an unquoted scalar, which every regex-based gate in the family reads
9
+ happily and a real parser refuses), nothing here would have caught the same mistake.
10
+
11
+ `test/validate.py` now asks that checker, which no longer exits early: it validates the
12
+ shipped front matter of every skill first, and only then the routed triggers a member may
13
+ or may not have. The table it reads is not copied here, so there is nothing to drift.
14
+ Watched refusing a planted `Broken: now a nested mapping.` in `agent-orchestrator`'s
15
+ description, and green after restore.
16
+
17
+ ## [0.11.0] — 2026-08-16
18
+
19
+ ### Changed
20
+
21
+ - **`agent-orchestrator`'s body is under its own budget for the first time: 5670 →
22
+ 4728 tokens** against the 4750 the pack set itself, and by **splitting rather than
23
+ trimming**. Three layers moved to where they belong instead of every section losing a
24
+ sentence:
25
+
26
+ - **`references/pipeline.md`** is new — the planned path and the interrupt that asks a
27
+ person, which are one suspend-and-resume seen from two sides rather than two features.
28
+ - **Observability folded into `references/runtime.md`**, beside the streaming contract it
29
+ was the concrete half of. The body had the API and the reference had the two properties
30
+ that decide whether it is a feed or a decoration; they are one thing now.
31
+ - **Sub-agent retry and the learning cycles went to `references/patterns.md`**, whose
32
+ mechanisms they were the surface of.
33
+
34
+ The body keeps the decisions and the checklist keeps only what a heading cannot say. The
35
+ v0.8.0 notes had already made this argument to justify a fourth skill and then the body
36
+ absorbed a layer anyway; the budget is now stated in the file itself.
37
+
38
+ ### Added
39
+
40
+ - **A check for one home per fact.** Every reference was checked for *existence* in both
41
+ directions and nothing checked whether two of them **say the same thing**. On 2026-08-15
42
+ the same six-row decision table was written into `agent-harness/SKILL.md` and into the
43
+ graph-engineering reference in one afternoon — 50 shared twelve-word runs, found by
44
+ measuring rather than by review. The floor is set above the legitimate maximum, measured
45
+ after that duplication was removed: 12 runs is a skill quoting the rule it defers to, and
46
+ 20 leaves headroom for a longer citation while still catching a restated section.
47
+
48
+ **It caught its own author within the minute.** Moving two sections into `patterns.md`
49
+ left one rule in both homes; the gate refused, and the copy was deleted.
50
+
51
+ - **A seventh scanner detector, `declared-deps-ignored`.** A model with a `depends_on`
52
+ field, and a loop over the collection in the order it happens to be stored: the plan says
53
+ it need not be serialised, and then is. This pack shipped exactly that in its own
54
+ reference until yesterday. Conservative — any sign of a topological pass anywhere in the
55
+ file (`layers`, `kahn`, `toposort`, `in_degree`, a `ready` set) and it says nothing. Its
56
+ entry in the blind-spot list is retired, because it is no longer blind.
57
+ `self-test: 9/9` → **`11/11`** (eight plants, three clean fixtures).
58
+
59
+ - **`agent-harness` cites the static-versus-dynamic model instead of restating it.** The
60
+ six-row table has one home, and it is the reference.
61
+
3
62
  ## [0.10.1] — 2026-08-15
4
63
 
5
64
  ### Changed
package/README.md CHANGED
@@ -11,7 +11,7 @@ Part of the [ssheleg skill family](https://github.com/ssheleg/sshlg-skills).
11
11
 
12
12
  Four skills — `agent-orchestrator` for wiring the loop, `agent-evals` for proving it
13
13
  behaves, `agent-interop` for everything it talks to outside its own process,
14
- `agent-harness` for what it is **told** — and nineteen references they load on demand,
14
+ `agent-harness` for what it is **told** — and twenty references they load on demand,
15
15
  plus one scanner.
16
16
 
17
17
  **The orchestrator** (`SKILL.md`) — what the agent reads first:
@@ -78,7 +78,7 @@ eight extension seams where a permission gate or a context rewrite can actually
78
78
 
79
79
  It runs in both directions: **building a harness and auditing somebody else's are one
80
80
  checklist read forwards and backwards.** `scripts/audit_agent.py` is the mechanical half —
81
- six conservative detectors, and it always prints what it *cannot* see plus a denominator,
81
+ seven conservative detectors, and it always prints what it *cannot* see plus a denominator,
82
82
  so its silence is never read as a pass.
83
83
 
84
84
  **`references/graph-engineering.md`** — deciding the shape of the work before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-stack",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Production patterns for AI agent orchestrators \u2014 tool-calling loops, multi-stage pipelines with checkpoints, LLM provider routing with fallback, four-layer memory with confidence decay \u2014 plus the wallet side of reselling LLM access. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "agent-stack": "bin/agent-stack.js"
@@ -2,7 +2,7 @@
2
2
  "name": "agent-stack",
3
3
  "displayName": "Agent Stack",
4
4
  "description": "Two skills: agent-orchestrator \u2014 tool-calling loops, multi-stage pipelines with checkpoints, provider routing with fallback, four-layer memory, context engineering, plus the wallet side of reselling LLM access; and agent-evals \u2014 run/trace/thread evals, judges, and fixtures grown from production.",
5
- "version": "0.10.1",
5
+ "version": "0.11.1",
6
6
  "author": {
7
7
  "name": "ssheleg",
8
8
  "url": "https://x.com/sshlg93"
@@ -85,27 +85,20 @@ branches, you wanted routing and it is cheaper.
85
85
  ### Static or dynamic — the second question, and it is not the same one
86
86
 
87
87
  Having chosen a workflow, one thing is still open: **is its shape known before it runs?**
88
- A **static** graph has every node and edge decided up front. A **dynamic** one grows — a
89
- node finishes, reads what it found, and decides what comes next.
90
-
91
- | Reach for | When |
92
- |---|---|
93
- | **static** | the task repeats and the structure is the same every time |
94
- | **static** | predictability and speed matter more than flexibility |
95
- | **static** | **always first** go dynamic only after the static version hits a wall you can name |
96
- | dynamic | the scope of the work depends on what is discovered along the way |
97
- | dynamic | a node must choose its successors from its own output |
98
- | **never dynamic** | **the run has to be auditable** see below |
99
-
100
- **The audit rule is hard, not a preference.** A dynamic graph's executed shape is not the
101
- shape anybody drew, so *"here is the design"* and *"here is what happened"* stop being the
102
- same document and every claim about the run becomes unfalsifiable from outside. Most
103
- workflows that feel like they need a dynamic graph need a better static one.
104
-
105
- The rest of the model — the fake-edge test, the diamond, the checker node before a
106
- convergence, and what a host actually executes when it fans out — is
107
- `agent-orchestrator/references/graph-engineering.md`.
108
-
88
+ A **static** graph has every node and edge decided up front; a **dynamic** one grows as
89
+ nodes read their own output and decide what comes next.
90
+
91
+ **Static first, always** go dynamic only after the static version hits a wall you can
92
+ name, because dynamic is more powerful and much harder to control. And one row of that
93
+ decision is hard rather than preferential: **a run that has to be auditable is static.**
94
+ A dynamic graph's executed shape is not the shape anybody drew, so *"here is the design"*
95
+ and *"here is what happened"* stop being the same document, and every claim about the run
96
+ becomes unfalsifiable from outside.
97
+
98
+ The six-row table, the rest of the model the fake-edge test, the diamond, the checker
99
+ node before a convergence — and what a host actually executes when it fans out are one
100
+ home away: `agent-orchestrator/references/graph-engineering.md`. It is not restated here,
101
+ because a decision table with two homes is one that will disagree with itself.
109
102
  ---
110
103
 
111
104
  ## References
@@ -190,8 +190,40 @@ def check_unguarded_fanout(rel, text, lines):
190
190
  "a checker that can see which branch failed")
191
191
 
192
192
 
193
+ def check_declared_deps_ignored(rel, text, lines):
194
+ """A plan that declares dependencies and is then walked in list order.
195
+
196
+ A model with a `depends_on` (or `dependsOn`, or `depends`) field has gone to the trouble
197
+ of saying which steps need which — and then a loop over the collection in the order it
198
+ happens to be stored serialises the whole thing anyway. The declaration is not wrong and
199
+ the loop is not wrong; together they are a plan that says it need not be serialised,
200
+ serialised. This pack shipped exactly that in its own reference until 2026-08-15.
201
+
202
+ Conservative: the file must declare a dependency field AND iterate the collection that
203
+ holds it, and it must contain no sign of a topological pass anywhere — `layers`, `kahn`,
204
+ `toposort`, `in_degree` or a `ready`/`runnable` set. Any of those and this says nothing.
205
+ """
206
+ if re.search(r"\b(layers?|kahn|toposort|topological|in_degree|indegree|runnable|ready_set)\b",
207
+ text, re.I):
208
+ return
209
+ if not re.search(r"\bdepends?(_on|On)?\b\s*[:=]", text):
210
+ return
211
+ for i, l in enumerate(lines, 1):
212
+ m = re.search(r"for\s+\w+\s+in\s+(\w+)\.(stages|steps|nodes|tasks|plan)\b", l) or \
213
+ re.search(r"for\s+\w+\s+in\s+(plan|stages|steps|nodes|tasks)\b", l)
214
+ if m:
215
+ add("declared-deps-ignored", rel, i,
216
+ "a dependency field is declared and the collection is walked in list order — "
217
+ "the plan says it need not be serialised, and then is",
218
+ "Execute in dependency layers (Kahn over the declared edges); a cycle fails "
219
+ "the plan rather than deadlocking the run, and a layer of more than one gets "
220
+ "a checker before anything downstream consumes it")
221
+ return
222
+
223
+
193
224
  CHECKS = [check_unbounded_loop, check_tool_without_description, check_swallowed_error,
194
- check_no_timeout, check_hardcoded_model, check_unguarded_fanout]
225
+ check_no_timeout, check_hardcoded_model, check_unguarded_fanout,
226
+ check_declared_deps_ignored]
195
227
 
196
228
  # What no static pass can reach. Printed every run, never suppressed.
197
229
  BLIND = [
@@ -200,8 +232,6 @@ BLIND = [
200
232
  "whether the workflow/agent choice was made deliberately or defaulted to an agent",
201
233
  "whether a fan-out has a CHECKER between it and the node that consumes it — this pass "
202
234
  "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",
205
235
  "whether retries and fallbacks MULTIPLY (three providers x three retries is nine calls)",
206
236
  "whether compaction preserves decisions and open questions, or keeps the discussion",
207
237
  "whether tool output is treated as untrusted input",
@@ -277,6 +307,9 @@ PLANTS = [
277
307
  PY_HEADER + "out = await asyncio.gather(*(run(t) for t in tasks))\n"),
278
308
  ("unguarded-fanout (promise)", "unguarded-fanout", "agent.js",
279
309
  JS_HEADER + "const out = await Promise.all(tasks.map(t => run(t)));\n"),
310
+ ("declared-deps-ignored", "declared-deps-ignored", "agent.py",
311
+ PY_HEADER + "class Stage:\n depends_on = []\n"
312
+ "for stage in plan.stages:\n run(stage)\n"),
280
313
  ]
281
314
 
282
315
  # A detector that fires on the defect AND on its fix has no discriminating power. Each
@@ -288,6 +321,10 @@ CLEAN = [
288
321
  ("a fan-out that DOES capture its branches", "agent.py",
289
322
  PY_HEADER + "for _ in range(10):\n pass\n"
290
323
  "out = await asyncio.gather(*(run(t) for t in tasks), return_exceptions=True)\n"),
324
+ ("a plan that DOES execute in dependency layers", "agent.py",
325
+ PY_HEADER + "class Stage:\n depends_on = []\n"
326
+ "for layer in plan.layers():\n run_layer(layer)\n"
327
+ "r = requests.get('https://example.com', timeout=5)\n"),
291
328
  ]
292
329
 
293
330
 
@@ -17,31 +17,25 @@ description: >-
17
17
 
18
18
  # Agent Orchestrator — Production Best Practices
19
19
 
20
- Battle-tested patterns from a production multi-agent system. Apply these when building any agent
21
- orchestrator, LLM-powered tool system, or agentic workflow.
20
+ Patterns from a production multi-agent system. **The body is decisions; the mechanisms are
21
+ one file away**, and it is held under a 4750-token budget a body that grows absorbs the
22
+ layer that should have been split, and this one did until 2026-08-16.
22
23
 
23
24
  ## Architecture Overview
24
25
 
25
26
  ```
26
27
  User Question
27
28
 
28
- ConversationalAgent (thin wrapper, backward-compat)
29
-
30
29
  OrchestratorAgent.run(AgentContext)
31
- ├─ Complexity check → simple (tool loop) or complex (pipeline)
32
- ├─ Context loading (parallel: staleness, MCP sources, KB check)
33
- ├─ History trimming
34
- ├─ Context budget allocation
35
- ├─ System prompt construction (dynamic, capability-aware)
36
- └─ Execution:
37
- ├─ SIMPLE: iterative LLM tool-calling loop
38
- │ LLM → tool calls → sub-agent dispatch → results → LLM → ... → final text
39
- └─ COMPLEX: multi-stage pipeline
40
- QueryPlanner → ExecutionPlan → StageExecutor → checkpoints → final
30
+ ├─ Shape check → one loop, or a planned path (§5, references/pipeline.md)
31
+ ├─ Context loading (a parallel layer: staleness, sources, KB — §13)
32
+ ├─ History trimming, then context budget allocation
33
+ ├─ System prompt built from the live capabilities (§10)
34
+ └─ Execute:
35
+ ├─ SIMPLE: LLM → tools → sub-agents → results → LLM → … → answer (§2)
36
+ └─ PLANNED: plan dependency layers → checker → checkpoints → done (§5)
41
37
  ```
42
38
 
43
- ---
44
-
45
39
  ## 1. The Orchestrator Pattern
46
40
 
47
41
  ### Shared Context Object
@@ -170,112 +164,42 @@ when to use it, and the neighbour it is confused with — is
170
164
 
171
165
  ## 4. Sub-Agent Retry and Validation
172
166
 
173
- Wrap every sub-agent call in retry + validation:
174
-
175
- ```python
176
- MAX_SUB_AGENT_RETRIES = 2
177
-
178
- for attempt in range(MAX_SUB_AGENT_RETRIES + 1):
179
- try:
180
- result = await sub_agent.run(context, question=q)
181
- validation = validator.validate(result)
182
- if validation.passed or attempt == MAX_SUB_AGENT_RETRIES:
183
- return format_for_llm(result, validation.warnings), result
184
- continue # retry on validation failure
185
- except AgentRetryableError:
186
- if attempt < MAX_SUB_AGENT_RETRIES: continue
187
- return "Failed after retries", None
188
- except AgentFatalError as e:
189
- return f"Fatal: {e}", None # no retry
190
- ```
167
+ Wrap every sub-agent call in retry **and** validation, and keep the two apart: a call that
168
+ threw and a call that returned something unusable need different answers. Retry the first,
169
+ re-prompt or fail the second.
191
170
 
192
- **Error hierarchy:**
193
- ```
194
- AgentError (base)
195
- ├── AgentRetryableError → orchestrator retries with adjusted context
196
- ├── AgentFatalError → unrecoverable (bad config, auth failure)
197
- ├── AgentTimeoutError → retry with smaller context
198
- └── AgentValidationError → sub-agent result failed quality checks
199
- ```
171
+ Three decisions the rest follows from:
200
172
 
201
- **Result validation** (check before returning to user):
202
- - SQL: query present? execution error? zero rows (warn)? slow query >30s (warn)?
203
- - Viz: valid chart type? appropriate for data shape? (pie with 100 slices → bar)
204
- - Knowledge: non-empty answer? source citations present?
173
+ - **Split errors into retryable and fatal at the type level**, not at the call site. A bad
174
+ credential and an overloaded provider are both exceptions and only one is worth a second
175
+ attempt.
176
+ - **Validate the result before it reaches the user**, against the shape the caller
177
+ expects — rows present, columns named, a citation attached. A confident wrong answer
178
+ passes every check that only looks for an exception.
179
+ - **Cap the attempts and return the best partial**, because the alternative to a partial
180
+ answer is not a better answer, it is no answer and a spent budget.
205
181
 
206
- ---
182
+ The hierarchy, the loop and the per-domain validators:
183
+ [`references/patterns.md`](references/patterns.md).
207
184
 
208
185
  ## 5. Multi-Stage Pipeline (Complex Path)
209
186
 
210
- For complex queries requiring multiple data steps:
187
+ When one loop is not the shape — several data steps that depend on each other, a person
188
+ who has to approve something in the middle, a run that must survive the gap between two
189
+ messages — the orchestrator plans first and executes stages instead of tools.
211
190
 
212
- ### Complexity Detection
213
-
214
- Two-tier: fast heuristic + optional LLM check.
215
-
216
- ```python
217
- COMPLEXITY_KEYWORDS = ["summary table", "pivot", "cross-reference", "compare",
218
- "for each", "step 1", "first find", "then"]
219
-
220
- def detect_complexity(question, history) -> bool:
221
- return any(kw in question.lower() for kw in COMPLEXITY_KEYWORDS)
222
-
223
- async def detect_complexity_adaptive(question, llm, history) -> bool:
224
- # Lightweight LLM call: "Is this simple or complex? Reply 'simple' or 'complex'."
225
- resp = await llm.complete([...], max_tokens=10)
226
- return "complex" in resp.content.lower()
227
- ```
228
-
229
- ### Pipeline Components
230
-
231
- ```
232
- QueryPlanner → (single LLM call) → ExecutionPlan (stages + their depends_on)
233
- StageExecutor → runs stages in DEPENDENCY LAYERS with validation + retry (§13)
234
- StageValidator → checks data shape, row bounds, cross-stage consistency
235
- StageContext → in-memory state (plan, results per stage, user feedback)
236
- PipelineRun → DB-persisted state for resume/retry across requests
237
- ```
238
-
239
- ### Checkpoint Pattern (Human-in-the-Loop)
240
-
241
- ```python
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
244
-
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
251
-
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
256
-
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):
261
- persist_to_db(pipeline_run_id, stage_ctx)
262
- return CheckpointResult(layer, results) # pause for user review
263
- # User responds: "continue" | "modify" | "retry"
264
- ```
191
+ Three decisions belong here; the mechanism is
192
+ [`references/pipeline.md`](references/pipeline.md).
265
193
 
266
- ### Pipeline Resume
267
-
268
- ```python
269
- async def resume_pipeline(resume_info, context):
270
- pipeline_run = load_from_db(resume_info["pipeline_run_id"])
271
- plan = ExecutionPlan.from_json(pipeline_run.plan_json)
272
- stage_ctx = StageContext.from_persistence(...)
273
-
274
- resume_from = current_idx + 1 if action == "continue" else current_idx
275
- return await executor.execute(plan, context, resume_from=resume_from, stage_ctx=stage_ctx)
276
- ```
277
-
278
- ---
194
+ - **Detect complexity in two tiers**, cheap first: a keyword heuristic, then one small
195
+ model call only where the heuristic is unsure. Paying a model to classify every question
196
+ is a tax on the common case.
197
+ - **Execute in dependency layers, never in list order** (§13). A plan that declares
198
+ `depends_on` and is then walked down the list has serialised itself, and a layer of more
199
+ than one stage gets a checker before anything consumes it.
200
+ - **A checkpoint is a pause that frees the worker.** If waiting for a human costs a
201
+ process, long approvals get quietly designed out — which is how a human-in-the-loop
202
+ system stops having one.
279
203
 
280
204
  ## 6. LLM Provider Routing
281
205
 
@@ -324,51 +248,24 @@ a floor.
324
248
  mode cross a compaction boundary as copied typed blocks, not prose (§12).
325
249
  ## 8. Self-Learning Feedback Loops
326
250
 
327
- Three cycles feed layers 3 and 4, and they differ by what supplies the signal:
328
-
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 |
334
-
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.
251
+ Three cycles feed the memory layers, and they differ by what supplies the signal: a failed
252
+ attempt that was then fixed, a user's verdict, and time.
339
253
 
340
254
  **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.
343
-
344
- ---
345
-
346
- ## 9. Observability (SSE Event Streaming)
255
+ attempt that failed beside the attempt that worked — never from a single successful run. A
256
+ system that learns from its successes learns its own habits.
347
257
 
348
- Real-time progress via `WorkflowTracker`:
258
+ The extractors, the confidence arithmetic and the promotion query:
259
+ [`references/patterns.md`](references/patterns.md).
349
260
 
350
- ```python
351
- class WorkflowTracker:
352
- # In-memory event bus with asyncio.Queue subscribers
353
- async def begin(pipeline, context) -> workflow_id
354
- async def emit(wf_id, step, status, detail)
355
- async def end(wf_id, agent, status, detail)
356
-
357
- @asynccontextmanager
358
- async def step(wf_id, step_name, description):
359
- # Emits started/completed/failed with elapsed_ms
360
-
361
- # Event types:
362
- # pipeline_start/end, thinking, token (streaming), orchestrator:llm_call,
363
- # orchestrator:sql_agent, orchestrator:llm_retry, orchestrator:warning
364
- ```
365
-
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.
261
+ ## 9. Observability
370
262
 
371
- ---
263
+ One bus, an event per step, and the answer streamed as chunks on the same bus. Two
264
+ properties decide whether it is a feed or a decoration: every event carries a **monotonic
265
+ id**, so a reconnecting client resumes rather than missing the run, and the feed is a
266
+ **view over a durable trace**, never the record itself — a stream nobody stored is a run
267
+ `agent-evals` cannot evaluate. The tracker's shape:
268
+ [`references/runtime.md`](references/runtime.md).
372
269
 
373
270
  ## 10. Dynamic System Prompts
374
271
 
@@ -394,18 +291,8 @@ list is built from, so the two can never disagree.
394
291
 
395
292
  ## 11. Clarification Requests (ask_user)
396
293
 
397
- Interrupt the tool loop to ask the user:
398
-
399
- ```python
400
- async def handle_ask_user(tc, context, wf_id):
401
- payload = {"question": ..., "question_type": "multiple_choice",
402
- "options": [...], "context": "why I'm asking"}
403
- raise _ClarificationRequestError(json.dumps(payload))
404
- # Caught in orchestrator.run() → returns AgentResponse(response_type="clarification_request")
405
- # Frontend renders special UI, user responds, next message continues flow
406
- ```
407
-
408
- ---
294
+ Stopping to ask is the same suspend-and-resume as a checkpoint with a different caller —
295
+ one contract, not two ([`references/pipeline.md`](references/pipeline.md)).
409
296
 
410
297
  ## 12. Context Engineering
411
298
 
@@ -457,44 +344,38 @@ Four rules, and these are the ones that change code:
457
344
 
458
345
  ## Checklist — Building a New Orchestrator
459
346
 
460
- - [ ] Shared `AgentContext` dataclass with all sub-agents
461
- - [ ] `BaseAgent` protocol with typed results + `accum_usage()`
462
- - [ ] Tool-calling loop with max iterations guard
463
- - [ ] In-loop context trimming (80% compress, 70% wrap-up)
464
- - [ ] Parallel tool dispatch where independent, sequential where stateful
465
- - [ ] Sub-agent retry with validation (retryable vs fatal errors)
466
- - [ ] Multi-provider LLM router with fallback chain + health checks
467
- - [ ] Per-provider retry with exponential backoff (respect `retry_after`)
468
- - [ ] Unified LLM error hierarchy with `user_message` property
469
- - [ ] Context budget manager (priority-based allocation)
470
- - [ ] Dynamic system prompt (capability-aware, learning-injected)
471
- - [ ] Chat history trimming (tool condensing, LLM summarization)
472
- - [ ] Working memory (session notes, fuzzy dedup, confidence decay)
473
- - [ ] Long-term learnings (heuristic extraction, conflict resolution, global patterns)
474
- - [ ] Insight memory (lifecycle, trust scoring, decay)
475
- - [ ] Feedback pipeline (thumbs, data validation learnings/notes/benchmarks)
476
- - [ ] SSE event streaming for real-time progress
477
- - [ ] Complexity detection (heuristic + adaptive LLM)
478
- - [ ] Multi-stage pipeline with checkpoints and resume
479
- - [ ] `ask_user` clarification mechanism
480
- - [ ] Graceful degradation (partial answers on context overflow or max iterations)
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
486
-
487
- ---
347
+ The sections above are the map. These are the items a reader **cannot** derive from a
348
+ heading the ones that were learned by getting them wrong:
349
+
350
+ - [ ] In-loop trimming at ~80% of the window, wrap-up injected at ~70%, and a max-iteration
351
+ guard that composes a partial answer rather than returning nothing
352
+ - [ ] A recoverable provider error **refunds** its iteration; a misconfiguration must not
353
+ spend the budget that exists to stop a runaway
354
+ - [ ] Retries and fallbacks are capped **in total** — three providers × three retries is
355
+ nine calls for one prompt
356
+ - [ ] A provider marked unhealthy is probed on a schedule; a health check that only runs on
357
+ failure never recovers, and the chain runs one short with nobody seeing it
358
+ - [ ] Chat history has a **floor** a session that trims it to fit old learnings has chosen
359
+ generalities over what the user said a minute ago
360
+ - [ ] Every declared dependency names the data it carries; plans execute in layers, and a
361
+ layer of more than one gets a checker before anything consumes it (§13)
362
+ - [ ] That checker has been watched refusing a planted bad input, and its verdicts are
363
+ stored as scores one that has never rejected anything is a finding
364
+ - [ ] Sub-agents return **distilled summaries**, not transcripts; a return value proportional
365
+ to the input is a function call wearing a costume
366
+ - [ ] Model, window and price are resolved at one boundary from configuration or the
367
+ provider never from a table of vendor ids in source
368
+ - [ ] An eval exists before the prompt is tuned, or the tuning is folklore
488
369
 
489
370
  ## References
490
371
 
491
- The checklist above is the map, these are the territory. Each file opens with its
492
- own **Load this when** line the authoritative trigger lives there, so this table
493
- stays an index and the two cannot drift apart.
372
+ Each file opens with its own **Load this when** line the authoritative trigger lives
373
+ there, so this table stays an index and the two cannot drift apart.
494
374
 
495
375
  | File | Read it when |
496
376
  |---|---|
497
377
  | [`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 |
378
+ | [`references/pipeline.md`](references/pipeline.md) | one loop is **not the shape** — the planned path, its checkpoints, resume, and the interrupt that asks a person |
498
379
  | [`references/patterns.md`](references/patterns.md) | you need the **data models and algorithms** under the body |
499
380
  | [`references/context-engineering.md`](references/context-engineering.md) | the loop is **running out of window** |
500
381
  | [`references/runtime.md`](references/runtime.md) | the agent must **survive a crash, a pause, a second message or a schedule** |
@@ -12,6 +12,8 @@ that costs no LLM call.
12
12
  - [Pipeline Data Models](#pipeline-data-models)
13
13
  - [Validation Loop (SQL Execution)](#validation-loop-sql-execution)
14
14
  - [Context Window Sizes](#context-window-sizes)
15
+ - [Sub-agent retry, and the error hierarchy under it](#sub-agent-retry-and-the-error-hierarchy-under-it)
16
+ - [The three learning cycles, and what each one consumes](#the-three-learning-cycles-and-what-each-one-consumes)
15
17
  - [Learning Extraction Heuristics](#learning-extraction-heuristics)
16
18
  - [Confidence Management](#confidence-management)
17
19
  - [Fuzzy Deduplication Pattern](#fuzzy-deduplication-pattern)
@@ -391,3 +393,64 @@ def generate_followups(query, columns, row_count) -> list[str]:
391
393
  random.shuffle(pool)
392
394
  return pool[:3]
393
395
  ```
396
+
397
+ ## Sub-agent retry, and the error hierarchy under it
398
+
399
+ Wrap every sub-agent call in retry + validation:
400
+
401
+ ```python
402
+ MAX_SUB_AGENT_RETRIES = 2
403
+
404
+ for attempt in range(MAX_SUB_AGENT_RETRIES + 1):
405
+ try:
406
+ result = await sub_agent.run(context, question=q)
407
+ validation = validator.validate(result)
408
+ if validation.passed or attempt == MAX_SUB_AGENT_RETRIES:
409
+ return format_for_llm(result, validation.warnings), result
410
+ continue # retry on validation failure
411
+ except AgentRetryableError:
412
+ if attempt < MAX_SUB_AGENT_RETRIES: continue
413
+ return "Failed after retries", None
414
+ except AgentFatalError as e:
415
+ return f"Fatal: {e}", None # no retry
416
+ ```
417
+
418
+ **Error hierarchy:**
419
+ ```
420
+ AgentError (base)
421
+ ├── AgentRetryableError → orchestrator retries with adjusted context
422
+ ├── AgentFatalError → unrecoverable (bad config, auth failure)
423
+ ├── AgentTimeoutError → retry with smaller context
424
+ └── AgentValidationError → sub-agent result failed quality checks
425
+ ```
426
+
427
+ **Result validation** (check before returning to user):
428
+ - SQL: query present? execution error? zero rows (warn)? slow query >30s (warn)?
429
+ - Viz: valid chart type? appropriate for data shape? (pie with 100 slices → bar)
430
+ - Knowledge: non-empty answer? source citations present?
431
+
432
+ ---
433
+
434
+
435
+ ## The three learning cycles, and what each one consumes
436
+
437
+ Three cycles feed layers 3 and 4, and they differ by what supplies the signal:
438
+
439
+ | Cycle | Signal | Produces |
440
+ |---|---|---|
441
+ | **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 |
442
+ | **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 |
443
+ | **Lifecycle** | time, and contradiction | decay, conflict resolution by negation flip, and promotion of a pattern seen on two independent resources |
444
+
445
+ The extractors, the exact confidence arithmetic and the promotion query live in
446
+ `references/patterns.md` — **Learning Extraction Heuristics**, **Confidence Management**
447
+ and **Cross-Resource Learning Transfer** — and not here, because a decay rate is a
448
+ constant to tune and a constant with two homes is one that will disagree with itself.
449
+
450
+
451
+ ---
452
+
453
+
454
+ Both moved out of `SKILL.md` on 2026-08-16. The mechanisms they describe were already
455
+ in this file — the validation loop, the extractors, the confidence arithmetic — so the
456
+ body was holding a second copy of their surface. One home; the body keeps the decision.
@@ -0,0 +1,110 @@
1
+ # The pipeline path — planning, checkpoints, resume, and the human in the middle
2
+
3
+ **Load this when** one tool-calling loop is not the shape: the question needs several data
4
+ steps that depend on each other, a person has to approve something in the middle, or a run
5
+ must survive the gap between two of the user's messages.
6
+
7
+ **Spec pinned:** this pack's own patterns, from a production multi-agent system · read 2026-08-16
8
+
9
+ `SKILL.md` §2 owns the simple path — one loop, tools, an iteration guard — and §13 owns the
10
+ question that comes before both: what shape is this work. This file is the **complex**
11
+ path, and it was in the body until 2026-08-16, when the body went 920 tokens over the
12
+ budget this pack set itself and the honest fix was to split a layer rather than trim
13
+ sentences off every section.
14
+
15
+ Two things live here because they are one mechanism seen from two sides: a pipeline that
16
+ **pauses** at a checkpoint and a loop that **interrupts** to ask a question are the same
17
+ suspend-and-resume with different callers. `references/runtime.md` states that contract in
18
+ the abstract; this is what it looks like in the orchestrator.
19
+
20
+ ## Contents
21
+
22
+ - The complex path, end to end
23
+ - Clarification requests — interrupting the loop to ask
24
+
25
+ ## The complex path, end to end
26
+
27
+ For complex queries requiring multiple data steps:
28
+
29
+ ### Complexity Detection
30
+
31
+ Two-tier: fast heuristic + optional LLM check.
32
+
33
+ ```python
34
+ COMPLEXITY_KEYWORDS = ["summary table", "pivot", "cross-reference", "compare",
35
+ "for each", "step 1", "first find", "then"]
36
+
37
+ def detect_complexity(question, history) -> bool:
38
+ return any(kw in question.lower() for kw in COMPLEXITY_KEYWORDS)
39
+
40
+ async def detect_complexity_adaptive(question, llm, history) -> bool:
41
+ # Lightweight LLM call: "Is this simple or complex? Reply 'simple' or 'complex'."
42
+ resp = await llm.complete([...], max_tokens=10)
43
+ return "complex" in resp.content.lower()
44
+ ```
45
+
46
+ ### Pipeline Components
47
+
48
+ ```
49
+ QueryPlanner → (single LLM call) → ExecutionPlan (stages + their depends_on)
50
+ StageExecutor → runs stages in DEPENDENCY LAYERS with validation + retry (§13)
51
+ StageValidator → checks data shape, row bounds, cross-stage consistency
52
+ StageContext → in-memory state (plan, results per stage, user feedback)
53
+ PipelineRun → DB-persisted state for resume/retry across requests
54
+ ```
55
+
56
+ ### Checkpoint Pattern (Human-in-the-Loop)
57
+
58
+ ```python
59
+ for layer in plan.layers(): # Kahn over depends_on — never list order
60
+ results = await run_layer(layer, context) # execute_with_retries per stage, together
61
+
62
+ for i, (stage, result) in enumerate(zip(layer, results)):
63
+ validation = validator.validate(stage, result, stage_ctx)
64
+ if not validation.passed:
65
+ results[i] = await retry_failed_validation(stage, context, validation)
66
+ if results[i] is None:
67
+ return StageFailedResult(stage, validation) # ask user
68
+
69
+ if len(layer) > 1: # cheap per-stage checks ran first; this
70
+ verdict = checker.check(results) # one is the cross-item gate (§13)
71
+ if not verdict.passed:
72
+ return StageFailedResult(layer, verdict) # nothing converges on a flagged output
73
+
74
+ for stage, result in zip(layer, results):
75
+ stage_ctx.set_result(stage.id, result)
76
+
77
+ if any(s.checkpoint for s in layer):
78
+ persist_to_db(pipeline_run_id, stage_ctx)
79
+ return CheckpointResult(layer, results) # pause for user review
80
+ # User responds: "continue" | "modify" | "retry"
81
+ ```
82
+
83
+ ### Pipeline Resume
84
+
85
+ ```python
86
+ async def resume_pipeline(resume_info, context):
87
+ pipeline_run = load_from_db(resume_info["pipeline_run_id"])
88
+ plan = ExecutionPlan.from_json(pipeline_run.plan_json)
89
+ stage_ctx = StageContext.from_persistence(...)
90
+
91
+ resume_from = current_idx + 1 if action == "continue" else current_idx
92
+ return await executor.execute(plan, context, resume_from=resume_from, stage_ctx=stage_ctx)
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Clarification requests — interrupting the loop to ask
98
+
99
+ Interrupt the tool loop to ask the user:
100
+
101
+ ```python
102
+ async def handle_ask_user(tc, context, wf_id):
103
+ payload = {"question": ..., "question_type": "multiple_choice",
104
+ "options": [...], "context": "why I'm asking"}
105
+ raise _ClarificationRequestError(json.dumps(payload))
106
+ # Caught in orchestrator.run() → returns AgentResponse(response_type="clarification_request")
107
+ # Frontend renders special UI, user responds, next message continues flow
108
+ ```
109
+
110
+ ---
@@ -16,6 +16,7 @@ has only built the first one discovers the second in production.
16
16
  - The interrupt/resume contract
17
17
  - Double-texting: four policies
18
18
  - Streaming that survives a dropped connection
19
+ - The tracker, concretely — what the feed above is made of
19
20
  - Time travel and forking
20
21
  - Scheduled and sleep-time work
21
22
  - Middleware: the seven concerns, unwelded
@@ -95,6 +96,38 @@ client can rely on:
95
96
  of what happened is a stream nobody stored, evaluation is impossible — see the
96
97
  `agent-evals` skill, which cannot function without it.
97
98
 
99
+ ## The tracker, concretely — what the feed above is made of
100
+
101
+ Real-time progress via `WorkflowTracker`:
102
+
103
+ ```python
104
+ class WorkflowTracker:
105
+ # In-memory event bus with asyncio.Queue subscribers
106
+ async def begin(pipeline, context) -> workflow_id
107
+ async def emit(wf_id, step, status, detail)
108
+ async def end(wf_id, agent, status, detail)
109
+
110
+ @asynccontextmanager
111
+ async def step(wf_id, step_name, description):
112
+ # Emits started/completed/failed with elapsed_ms
113
+
114
+ # Event types:
115
+ # pipeline_start/end, thinking, token (streaming), orchestrator:llm_call,
116
+ # orchestrator:sql_agent, orchestrator:llm_retry, orchestrator:warning
117
+ ```
118
+
119
+ The final answer streams in chunks as `token` events on the same bus — a typing effect is
120
+ a chunked emit, not a second mechanism. What makes the feed reliable rather than decorative
121
+ is in `references/runtime.md`: a monotonic id per event so a reconnecting client can resume,
122
+ and the feed being a **view over the durable trace** rather than the record itself.
123
+
124
+ ---
125
+
126
+ This moved out of `SKILL.md` on 2026-08-16. It was the concrete half of the section
127
+ two headings up, in a different file: *streaming that survives a dropped connection*
128
+ stated the two properties that matter and the body stated the API without them. One
129
+ home, and the properties now sit beside the thing they are properties of.
130
+
98
131
  ## Time travel and forking
99
132
 
100
133
  Once every iteration is checkpointed, one capability follows nearly free: **pick a past