@ssheleg/agent-stack 0.10.1 → 0.11.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.
- package/CHANGELOG.md +45 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/agent-stack/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-stack/skills/agent-harness/SKILL.md +14 -21
- package/plugins/agent-stack/skills/agent-harness/scripts/audit_agent.py +40 -3
- package/plugins/agent-stack/skills/agent-orchestrator/SKILL.md +78 -197
- package/plugins/agent-stack/skills/agent-orchestrator/references/patterns.md +63 -0
- package/plugins/agent-stack/skills/agent-orchestrator/references/pipeline.md +110 -0
- package/plugins/agent-stack/skills/agent-orchestrator/references/runtime.md +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.0] — 2026-08-16
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **`agent-orchestrator`'s body is under its own budget for the first time: 5670 →
|
|
8
|
+
4728 tokens** against the 4750 the pack set itself, and by **splitting rather than
|
|
9
|
+
trimming**. Three layers moved to where they belong instead of every section losing a
|
|
10
|
+
sentence:
|
|
11
|
+
|
|
12
|
+
- **`references/pipeline.md`** is new — the planned path and the interrupt that asks a
|
|
13
|
+
person, which are one suspend-and-resume seen from two sides rather than two features.
|
|
14
|
+
- **Observability folded into `references/runtime.md`**, beside the streaming contract it
|
|
15
|
+
was the concrete half of. The body had the API and the reference had the two properties
|
|
16
|
+
that decide whether it is a feed or a decoration; they are one thing now.
|
|
17
|
+
- **Sub-agent retry and the learning cycles went to `references/patterns.md`**, whose
|
|
18
|
+
mechanisms they were the surface of.
|
|
19
|
+
|
|
20
|
+
The body keeps the decisions and the checklist keeps only what a heading cannot say. The
|
|
21
|
+
v0.8.0 notes had already made this argument to justify a fourth skill and then the body
|
|
22
|
+
absorbed a layer anyway; the budget is now stated in the file itself.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **A check for one home per fact.** Every reference was checked for *existence* in both
|
|
27
|
+
directions and nothing checked whether two of them **say the same thing**. On 2026-08-15
|
|
28
|
+
the same six-row decision table was written into `agent-harness/SKILL.md` and into the
|
|
29
|
+
graph-engineering reference in one afternoon — 50 shared twelve-word runs, found by
|
|
30
|
+
measuring rather than by review. The floor is set above the legitimate maximum, measured
|
|
31
|
+
after that duplication was removed: 12 runs is a skill quoting the rule it defers to, and
|
|
32
|
+
20 leaves headroom for a longer citation while still catching a restated section.
|
|
33
|
+
|
|
34
|
+
**It caught its own author within the minute.** Moving two sections into `patterns.md`
|
|
35
|
+
left one rule in both homes; the gate refused, and the copy was deleted.
|
|
36
|
+
|
|
37
|
+
- **A seventh scanner detector, `declared-deps-ignored`.** A model with a `depends_on`
|
|
38
|
+
field, and a loop over the collection in the order it happens to be stored: the plan says
|
|
39
|
+
it need not be serialised, and then is. This pack shipped exactly that in its own
|
|
40
|
+
reference until yesterday. Conservative — any sign of a topological pass anywhere in the
|
|
41
|
+
file (`layers`, `kahn`, `toposort`, `in_degree`, a `ready` set) and it says nothing. Its
|
|
42
|
+
entry in the blind-spot list is retired, because it is no longer blind.
|
|
43
|
+
`self-test: 9/9` → **`11/11`** (eight plants, three clean fixtures).
|
|
44
|
+
|
|
45
|
+
- **`agent-harness` cites the static-versus-dynamic model instead of restating it.** The
|
|
46
|
+
six-row table has one home, and it is the reference.
|
|
47
|
+
|
|
3
48
|
## [0.10.1] — 2026-08-15
|
|
4
49
|
|
|
5
50
|
### 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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.0",
|
|
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.
|
|
5
|
+
"version": "0.11.0",
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
├─
|
|
32
|
-
├─ Context loading (parallel: staleness,
|
|
33
|
-
├─ History trimming
|
|
34
|
-
├─
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
258
|
+
The extractors, the confidence arithmetic and the promotion query:
|
|
259
|
+
[`references/patterns.md`](references/patterns.md).
|
|
349
260
|
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
- [ ] In-loop
|
|
464
|
-
|
|
465
|
-
- [ ]
|
|
466
|
-
|
|
467
|
-
- [ ]
|
|
468
|
-
|
|
469
|
-
- [ ]
|
|
470
|
-
|
|
471
|
-
- [ ] Chat history
|
|
472
|
-
|
|
473
|
-
- [ ]
|
|
474
|
-
|
|
475
|
-
- [ ]
|
|
476
|
-
|
|
477
|
-
- [ ]
|
|
478
|
-
|
|
479
|
-
- [ ]
|
|
480
|
-
|
|
481
|
-
- [ ]
|
|
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
|
-
|
|
492
|
-
|
|
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
|