Skip to content

Commit e2d54fa

Browse files
Document factory subagent semantics and add the pattern library
1 parent 3752a69 commit e2d54fa

2 files changed

Lines changed: 265 additions & 6 deletions

File tree

nodejs/docs/factories.md

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,56 @@ Factory metadata contains a stable `name`, a human-readable `description`, decla
4848
The `run()` context provides:
4949

5050
- `ctx.runId`: Stable ID reused across resumed attempts.
51-
- `ctx.args`: Invocation arguments.
52-
- `ctx.agent(prompt, options?)`: Runs one factory-owned subagent. Options include `label`, `schema`, and `model`.
53-
- `ctx.parallel(thunks)`: Runs thunks concurrently and returns `null` for a thunk that throws, except cooperative cancellation propagates.
54-
- `ctx.pipeline(items, ...stages)`: Flows each item through every stage without a barrier between stages.
55-
- `ctx.phase(title)`: Starts a named progress phase.
56-
- `ctx.log(message)`: Appends a progress line.
51+
- `ctx.args`: Invocation arguments, forwarded verbatim. When the caller omits `args`, this is `{}` rather than `undefined`.
52+
- `ctx.agent(prompt, options?)`: Runs one factory-owned subagent. Options are exactly `label`, `schema`, and `model`. See [Subagent calls](#subagent-calls).
53+
- `ctx.parallel(thunks)`: Runs thunks concurrently and awaits all of them (a barrier). A thunk that throws becomes `null` in the result array, except cooperative cancellation propagates. Rejects above 4096 items.
54+
- `ctx.pipeline(items, ...stages)`: Flows each item through every stage without a barrier between stages, so one item can be in a later stage while another is still in an earlier one. Each stage is called as `(previous, item, index)`, where `previous` is the prior stage's result and `item` is the original input. A stage that throws drops that item to `null` and skips its remaining stages. Rejects above 4096 items.
55+
- `ctx.phase(title)`: Starts a named progress phase. This sets a single run-global value, so calling it from inside concurrent `parallel`/`pipeline` stages races. Call it at run-level transitions and distinguish concurrent work by `label` instead.
56+
- `ctx.log(message)`: Appends a progress line. When a factory bounds its own coverage (top-N, sampling), log what was dropped.
5757
- `ctx.step(key, producer, options?)`: Journals the producer's JSON result under a stable key so a resume replays it without re-running the producer. A journaled (default) producer must return a JSON-serializable value; `undefined` or a non-JSON value is rejected. Pass `{ volatile: true }` to bypass the journal and run the producer every time.
58+
59+
The key is the *sole* identity: neither the producer body nor its inputs contribute to it. A resume replays the cached value for a matching key even if the producer has since changed, so version the key (`"scan-v2"`) whenever its inputs or meaning change. Journaled producers are best-effort at-least-once and may run again across crashes or concurrent same-key callers, so keep side effects idempotent.
5860
- `ctx.session`: The full session returned by `joinSession`.
5961
- `ctx.signal`: Cooperative cancellation signal for extension work and subprocesses.
6062
- `ctx.factory(...)`: Always rejects because nested factories are not supported.
6163

6264
Factory-owned subagents are intentionally hidden from `read_agent` and `write_agent`. Use the factory observability APIs instead.
6365

66+
### Subagent calls
67+
68+
`ctx.agent(prompt, options?)` spawns one factory-scoped subagent and awaits it. Without a schema it resolves to the subagent's final text. With `options.schema` it resolves to the parsed JSON value.
69+
70+
**Identical calls are memoized into one subagent.** Each call is journaled by its canonical prompt and options, including `label`. Two calls with the same prompt and the same options return one shared result — even when issued concurrently. To spawn N *independent* subagents, give each a unique `label` or vary the prompt:
71+
72+
```js
73+
// One subagent, awaited five times — almost certainly not what you want.
74+
await ctx.parallel([1, 2, 3, 4, 5].map(() => () => ctx.agent("Find a bug")));
75+
76+
// Five independent subagents.
77+
await ctx.parallel(
78+
[1, 2, 3, 4, 5].map((i) => () => ctx.agent("Find a bug", { label: `finder:${i}` }))
79+
);
80+
```
81+
82+
**An ordinary failure resolves to `null` — it does not throw.** A subagent that errors, returns nothing, or (with a schema) produces output that still fails to parse or match after its one retry resolves `null`. Always guard the result before using it, including a bare `await ctx.agent(...)`:
83+
84+
```js
85+
const finding = await ctx.agent(prompt, { label: "inspector" });
86+
if (!finding) return { finding: null };
87+
```
88+
89+
Cancellation and hard runtime failures — a reached limit, a durable-state failure — reject instead, aborting the run. When filtering results, prefer `v => v !== null` over `Boolean`, which also discards a valid `false`, `0`, or `""`.
90+
91+
**`schema` is a structural subset of JSON Schema, not a validator.** Honored: `type`, `required`, `enum`, `const`, recursive `properties`/`items`, and `anyOf`/`oneOf`/`allOf` — where `oneOf` is treated as `anyOf`, meaning at least one branch matches rather than exactly one. Ignored and *not* enforced: `additionalProperties`, `pattern`, `minLength`/`maxLength`, `format`, numeric ranges, and boolean schemas. Do not rely on an ignored keyword to constrain a result. A schema call retries once on a parse or match failure, so it may spawn twice, and both spawns count toward `maxTotalSubagents`.
92+
93+
### Choosing between pipeline and parallel
94+
95+
Prefer `pipeline` for multi-stage work. It has no barrier between stages, so each item advances as soon as its own prior stage finishes.
96+
97+
Reach for a barrier — `parallel` between stages — only when a stage genuinely needs every prior result at once: deduplicating or merging across the full set, an early exit based on the total, or a prompt that compares one result against the others. Needing to map, filter, or flatten is not a reason to use a barrier; do that inside a pipeline stage. Barrier latency is real: if the slowest of N subagents takes three times the fastest, a barrier wastes the rest of the pool's time.
98+
99+
See [factory-patterns.md](./factory-patterns.md) for composable orchestration patterns built on these primitives.
100+
64101
## Resource limits
65102

66103
Limits may be declared in `meta.limits` and overridden per invocation. All limits must be positive when present.
@@ -124,6 +161,34 @@ The agent-facing `run_factory` tool has exactly two input branches:
124161
{ resumeFromRunId: string; limits?: FactoryLimits }
125162
```
126163

164+
## Authoring a factory from inside a session
165+
166+
The agent-facing `author_factory` tool writes a factory into a session-scoped extension at runtime. The rules above all apply, plus one constraint that does not affect an extension author.
167+
168+
**The `run` body is self-contained.** It is emitted verbatim into a generated module as a single async function expression. It closes over nothing: not the conversation that authored it, and not any authoring-time binding. Only its own locals, its `ctx` parameter, and standard Node and JavaScript globals are in scope, so every schema, constant, and helper must be defined *inside* the function. The generated module imports the SDK itself; the expression cannot add static `import` statements or use `require`. Load anything else with a dynamic `await import("...")` in the body.
169+
170+
```js
171+
async ({ args, agent, phase }) => {
172+
// Defined inside — there is no outer scope to close over.
173+
const VERDICT = { type: "object", properties: { real: { type: "boolean" } }, required: ["real"] };
174+
175+
phase("Inspect");
176+
const finding = await agent(`Name one likely bug in ${args.file ?? "the code"}.`, {
177+
label: "inspector",
178+
});
179+
if (!finding) return { finding: null, real: false };
180+
181+
phase("Verify");
182+
const verdict = await agent(`Is this a real bug? Claim: ${finding}`, {
183+
label: "verifier",
184+
schema: VERDICT,
185+
});
186+
return { finding, real: verdict?.real === true };
187+
};
188+
```
189+
190+
Authoring registers the factory but does not run it. Invoke it afterwards with `run_factory`.
191+
127192
## Observe a run
128193
129194
The calling session can inspect its own factory runs:

nodejs/docs/factory-patterns.md

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Agent Factory patterns
2+
3+
Composable orchestration patterns built on the factory context. Read [factories.md](./factories.md) first for the API and its semantics. The API is experimental.
4+
5+
Every snippet below assumes the surrounding `async (ctx) => { ... }` run body and destructures the hooks it uses. Three rules apply throughout, because breaking them fails silently:
6+
7+
- **Give every independent subagent a unique `label`.** Identical prompt-and-options pairs memoize into a single shared subagent.
8+
- **Guard every `agent()` result.** An ordinary failure resolves to `null` rather than throwing.
9+
- **Filter with `v => v !== null`,** not `Boolean`, which also discards a valid `false`, `0`, or `""`.
10+
11+
## Multi-stage review
12+
13+
The default shape: fan out across dimensions, and let each dimension verify as soon as its own review lands. No barrier, so a slow dimension never holds up a fast one.
14+
15+
```js
16+
async ({ pipeline, parallel, agent, phase, log }) => {
17+
const FINDINGS = {
18+
type: "object",
19+
properties: {
20+
findings: {
21+
type: "array",
22+
items: {
23+
type: "object",
24+
properties: { title: { type: "string" } },
25+
required: ["title"],
26+
},
27+
},
28+
},
29+
required: ["findings"],
30+
};
31+
const VERDICT = {
32+
type: "object",
33+
properties: { isReal: { type: "boolean" } },
34+
required: ["isReal"],
35+
};
36+
const DIMENSIONS = [
37+
{ key: "bugs", prompt: "Review the diff for correctness bugs. Return JSON {findings:[{title}]}." },
38+
{ key: "perf", prompt: "Review the diff for performance issues. Return JSON {findings:[{title}]}." },
39+
];
40+
41+
phase("Review"); // Run-global: set it before the fan-out, never inside a stage.
42+
const perDimension = await pipeline(
43+
DIMENSIONS,
44+
(d) => agent(d.prompt, { label: `review:${d.key}`, schema: FINDINGS }),
45+
(review, d) => {
46+
if (!review) {
47+
log(`review:${d.key} produced nothing`);
48+
return [];
49+
}
50+
return parallel(
51+
(review.findings ?? []).map((f, i) => () =>
52+
agent(`Adversarially verify this finding is real: ${f.title}`, {
53+
label: `verify:${d.key}:${i}`,
54+
schema: VERDICT,
55+
}).then((v) => (v && v.isReal ? f : null))
56+
)
57+
);
58+
}
59+
);
60+
61+
return { confirmed: perDimension.flat().filter((v) => v !== null) };
62+
};
63+
```
64+
65+
## When a barrier is correct
66+
67+
Deduplicating across every finding needs the whole set in hand, so the barrier earns its cost here. Dedup itself is plain JavaScript, done in the body between the two fan-outs. This excerpt reuses `FINDINGS`, `VERDICT`, and `DIMENSIONS` from the previous example — define them inside your own function.
68+
69+
```js
70+
const all = await parallel(
71+
DIMENSIONS.map((d) => () => agent(d.prompt, { label: `find:${d.key}`, schema: FINDINGS }))
72+
);
73+
const findings = all.filter((v) => v !== null).flatMap((r) => r.findings ?? []);
74+
const deduped = [...new Map(findings.map((f) => [f.title, f])).values()]; // Needs all of them.
75+
const verified = await parallel(
76+
deduped.map((f, i) => () => agent(`Verify: ${f.title}`, { label: `verify:${i}`, schema: VERDICT }))
77+
);
78+
```
79+
80+
## Loop until count
81+
82+
Accumulate toward a target. Each iteration needs a unique identity — a unique label plus a prompt that excludes what has already been found — a bounded attempt count, and a null guard.
83+
84+
```js
85+
const BUG = {
86+
type: "object",
87+
properties: { title: { type: "string" } },
88+
required: ["title"],
89+
};
90+
91+
const bugs = [];
92+
let attempt = 0;
93+
while (bugs.length < 10 && attempt < 30) {
94+
const r = await agent(
95+
`Find ONE distinct bug NOT already listed: ${JSON.stringify(bugs.map((b) => b.title))}. Return JSON {title}.`,
96+
{ label: `finder:${attempt}`, schema: BUG }
97+
);
98+
attempt++;
99+
if (r && r.title) bugs.push(r);
100+
log(`${bugs.length}/10 found`);
101+
}
102+
```
103+
104+
## Loop until dry
105+
106+
Keep spawning finders until some number of consecutive rounds surface nothing new. Deduplicate against everything *seen*, not just what was kept, or discarded findings resurface every round.
107+
108+
```js
109+
const BUGS = {
110+
type: "object",
111+
properties: {
112+
bugs: {
113+
type: "array",
114+
items: { type: "object", properties: { title: { type: "string" } }, required: ["title"] },
115+
},
116+
},
117+
required: ["bugs"],
118+
};
119+
const VERDICT = {
120+
type: "object",
121+
properties: { real: { type: "boolean" } },
122+
required: ["real"],
123+
};
124+
125+
const seen = new Set();
126+
const confirmed = [];
127+
const keyOf = (b) => b.title.toLowerCase();
128+
let dry = 0;
129+
let round = 0;
130+
131+
while (dry < 2 && round < 20) {
132+
const found = (
133+
await parallel(
134+
[0, 1, 2].map((i) => () =>
135+
agent(`Find bugs (finder ${i}, round ${round}). Return JSON {bugs:[{title}]}.`, {
136+
label: `find:${round}:${i}`,
137+
schema: BUGS,
138+
})
139+
)
140+
)
141+
)
142+
.filter((v) => v !== null)
143+
.flatMap((r) => r.bugs ?? []);
144+
145+
const fresh = found.filter((b) => {
146+
const k = keyOf(b);
147+
if (seen.has(k)) return false;
148+
seen.add(k);
149+
return true;
150+
});
151+
152+
if (!fresh.length) {
153+
dry++;
154+
round++;
155+
continue;
156+
}
157+
dry = 0;
158+
159+
const judged = await parallel(
160+
fresh.map((b, i) => () =>
161+
parallel(
162+
["correctness", "security", "repro"].map((lens) => () =>
163+
agent(`Judge via ${lens}: is "${b.title}" real? Return JSON {real}.`, {
164+
label: `judge:${round}:${i}:${lens}`,
165+
schema: VERDICT,
166+
})
167+
)
168+
).then((vs) => ({ b, real: vs.filter((v) => v !== null).filter((v) => v.real).length >= 2 }))
169+
)
170+
);
171+
172+
confirmed.push(...judged.filter((v) => v !== null && v.real).map((v) => v.b));
173+
round++;
174+
}
175+
```
176+
177+
## Quality patterns
178+
179+
Compose these freely.
180+
181+
- **Adversarial verify.** Spawn several independent skeptics per finding, each prompted to *refute* it and to default to refuted when uncertain. Keep only what a majority fails to refute.
182+
- **Perspective-diverse verify.** Give each verifier a distinct lens — correctness, security, performance, does-it-reproduce — instead of several identical skeptics. The distinct prompts also stop them memoizing into one subagent.
183+
- **Judge panel.** Generate several independent attempts from different angles, score them with parallel judges, then synthesize from the winner while grafting the best ideas from the runners-up.
184+
- **Multi-modal sweep.** Run parallel searchers that each look a different way: by container, by content, by entity, by time.
185+
- **Completeness critic.** End with an agent asking what is missing — an angle not run, a claim unverified, a source unread — and use its answer to seed the next round.
186+
- **No silent caps.** When the factory bounds its own coverage with a top-N, a sampling step, or a no-retry rule, `log()` what was dropped.
187+
188+
## Scaling
189+
190+
Match the orchestration to what was asked. A quick check wants a couple of subagents and single-vote verification; a request to be thorough or comprehensive wants a larger finder pool, a three-to-five vote adversarial pass, and a synthesis stage.
191+
192+
There is no in-script budget object. Scale with your own counters, as in the loop patterns above, and treat the declared limits as the safety ceiling rather than the control mechanism. Only `agent()` spawns are throttled, by `maxConcurrentSubagents` falling back to `maxTotalSubagents`; with neither declared there is no built-in concurrency cap, so declare one before fanning out widely. `parallel` itself is `Promise.all`, so non-agent work in a thunk runs fully concurrently regardless.
193+
194+
These patterns are not exhaustive. Compose novel harnesses — tournament brackets, self-repair loops, staged escalation — when the task calls for it.

0 commit comments

Comments
 (0)