|
| 1 | +# Agent Factories |
| 2 | + |
| 3 | +Agent Factories are extension-authored, session-scoped workflows that coordinate subagents and durable steps. The API is experimental. |
| 4 | + |
| 5 | +## Define and register a factory |
| 6 | + |
| 7 | +Use `defineFactory` and pass the returned handle to `joinSession`: |
| 8 | + |
| 9 | +```js |
| 10 | +import { defineFactory, joinSession } from "@github/copilot-sdk/extension"; |
| 11 | + |
| 12 | +const reviewChanged = defineFactory({ |
| 13 | + meta: { |
| 14 | + name: "review-changed", |
| 15 | + description: "Review changed files and verify the findings", |
| 16 | + phases: [{ title: "Review" }, { title: "Verify" }], |
| 17 | + limits: { |
| 18 | + maxConcurrentSubagents: 3, |
| 19 | + maxTotalSubagents: 10, |
| 20 | + timeoutSeconds: 90.5, |
| 21 | + maxAiCredits: 5, |
| 22 | + }, |
| 23 | + }, |
| 24 | + run: async (ctx) => { |
| 25 | + ctx.phase("Review"); |
| 26 | + const reviews = await ctx.parallel( |
| 27 | + ctx.args.files.map( |
| 28 | + (file) => () => ctx.agent(`Review ${file}`, { label: `Review ${file}` }) |
| 29 | + ) |
| 30 | + ); |
| 31 | + |
| 32 | + ctx.phase("Verify"); |
| 33 | + const report = await ctx.step("report", () => ({ reviews })); |
| 34 | + ctx.log(`Completed factory run ${ctx.runId}`); |
| 35 | + return report; |
| 36 | + }, |
| 37 | +}); |
| 38 | + |
| 39 | +const session = await joinSession({ factories: [reviewChanged] }); |
| 40 | +``` |
| 41 | + |
| 42 | +Factory metadata contains a stable `name`, a human-readable `description`, declared `phases`, and optional `limits`. Phase entries contain a `title` and optional `detail`. |
| 43 | + |
| 44 | +`defineFactory<TArgs, TResult>` accepts a `run(context)` function returning `Promise<TResult>`, where `TResult` is `JsonValue | void`. Objects, arrays, strings, numbers, booleans, and `null` are valid results. Returning `undefined` completes the factory with no result. Other non-JSON values are rejected. |
| 45 | + |
| 46 | +## Factory context |
| 47 | + |
| 48 | +The `run()` context provides: |
| 49 | + |
| 50 | +- `ctx.runId`: Stable ID reused across resumed attempts. |
| 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. |
| 57 | +- `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. |
| 60 | +- `ctx.session`: The full session returned by `joinSession`. |
| 61 | +- `ctx.signal`: Cooperative cancellation signal for extension work and subprocesses. |
| 62 | +- `ctx.factory(...)`: Always rejects because nested factories are not supported. |
| 63 | + |
| 64 | +Factory-owned subagents are intentionally hidden from `read_agent` and `write_agent`. Use the factory observability APIs instead. |
| 65 | + |
| 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 | + |
| 101 | +## Resource limits |
| 102 | + |
| 103 | +Limits may be declared in `meta.limits` and overridden per invocation. All limits must be positive when present. |
| 104 | + |
| 105 | +- `maxConcurrentSubagents`: Positive integer concurrent-subagent cap. Additional subagents wait in a queue. Queueing applies backpressure and does not fail the run. |
| 106 | +- `maxTotalSubagents`: Positive integer cumulative admission cap. An attempted subagent beyond the cap ends the attempt with failure kind `maxTotalSubagents`. |
| 107 | +- `timeoutSeconds`: Positive finite number of seconds, including positive fractions, capped at `2_147_483.647`. It measures accumulated active-execution time across attempts, including the extension body, subprocess waits, queued-agent waits, and sleeps. Time between attempts is excluded. The timeout is soft because already-running work may take time to stop. Its failure kind is `timeoutSeconds`. |
| 108 | +- `maxAiCredits`: Positive finite AI-credit budget for the whole run's factory subagent subtree, including descendants. AI credits are GitHub Copilot's universal usage metric. This is a soft, post-paid ceiling, so completed or parallel turns can settle above it before the run stops. Accounting is fail-closed: an accounting failure stops a budgeted run rather than allowing untracked use. Its failure kind is `maxAiCredits`. |
| 109 | + |
| 110 | +`maxTotalSubagents`, `timeoutSeconds`, and `maxAiCredits` use reject-and-retry semantics. A rejected attempt ends with run status `error` and `failure.type` set to `factory_limit_reached`. The failed run keeps its ID, arguments, journal, and accounting. Resume the run with a raised limit when additional work is approved. Previously consumed resources still count. |
| 111 | + |
| 112 | +## Run and resume |
| 113 | + |
| 114 | +Run by registered name or handle: |
| 115 | + |
| 116 | +```ts |
| 117 | +const run = await session.factory.run("review-changed", { |
| 118 | + args: { files: ["src/a.ts"] }, |
| 119 | + limits: { maxAiCredits: 3 }, |
| 120 | +}); |
| 121 | + |
| 122 | +if (run.status === "completed") { |
| 123 | + console.log(run.result); |
| 124 | +} else { |
| 125 | + console.error(`run ${run.runId} ended as ${run.status}`, run.failure ?? run.error); |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +The name overload is: |
| 130 | + |
| 131 | +```ts |
| 132 | +session.factory.run( |
| 133 | + name: string, |
| 134 | + options?: { args?: JsonValue; limits?: FactoryLimits }, |
| 135 | +): Promise<FactoryRunResult>; |
| 136 | +``` |
| 137 | + |
| 138 | +Resume by run ID without resending the name or arguments: |
| 139 | + |
| 140 | +```ts |
| 141 | +const run = await session.factory.resume(runId, { |
| 142 | + limits: { maxAiCredits: 6 }, |
| 143 | +}); |
| 144 | +``` |
| 145 | + |
| 146 | +The signature is: |
| 147 | + |
| 148 | +```ts |
| 149 | +session.factory.resume( |
| 150 | + runId: string, |
| 151 | + options?: { limits?: FactoryLimits }, |
| 152 | +): Promise<FactoryRunResult>; |
| 153 | +``` |
| 154 | + |
| 155 | +Both resolve with the run envelope (`FactoryRunResult`) for **every** outcome — `completed`, `error`, `halted`, and `cancelled` alike. Inspect `status` and read `result` only when the run completed; a limit breach carries a typed `failure`. A declined fresh run is not a pre-execution failure: the run row already exists by the time the prompt is answered, so it resolves with a terminal `cancelled` envelope carrying the run ID. Only failures that occur *before* a run exists reject: an unknown factory name or an already-active session. Pre-execution resume failures, including a declined reapproval, throw `FactoryResumeError`, whose `code` is one of `not_found`, `non_resumable`, `already_active`, `reapproval_declined`, or `no_approval_provider`. |
| 156 | + |
| 157 | +An agent that no longer has a prior run's ID in context can recover it with `factories_manage` and `operation: "runs"`, which lists the session's factory runs with their IDs and statuses. This matters for resume: a run that reached a limit keeps its journal, so resuming it replays completed work for free, while restarting it from scratch pays for that work twice. |
| 158 | + |
| 159 | +The agent-facing `run_factory` tool has exactly two input branches: |
| 160 | + |
| 161 | +```ts |
| 162 | +{ name: string; args?: JsonValue; limits?: FactoryLimits } |
| 163 | +{ resumeFromRunId: string; limits?: FactoryLimits } |
| 164 | +``` |
| 165 | + |
| 166 | +## Authoring a factory from inside a session |
| 167 | + |
| 168 | +The agent-facing `factories_manage` tool writes a factory into a session-scoped extension at runtime with `operation: "author"`. The rules above all apply, plus one constraint that does not affect an extension author. |
| 169 | + |
| 170 | +**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. |
| 171 | + |
| 172 | +```js |
| 173 | +async ({ args, agent, phase }) => { |
| 174 | + // Defined inside — there is no outer scope to close over. |
| 175 | + const VERDICT = { type: "object", properties: { real: { type: "boolean" } }, required: ["real"] }; |
| 176 | + |
| 177 | + phase("Inspect"); |
| 178 | + const finding = await agent(`Name one likely bug in ${args.file ?? "the code"}.`, { |
| 179 | + label: "inspector", |
| 180 | + }); |
| 181 | + if (!finding) return { finding: null, real: false }; |
| 182 | +
|
| 183 | + phase("Verify"); |
| 184 | + const verdict = await agent(`Is this a real bug? Claim: ${finding}`, { |
| 185 | + label: "verifier", |
| 186 | + schema: VERDICT, |
| 187 | + }); |
| 188 | + return { finding, real: verdict?.real === true }; |
| 189 | +}; |
| 190 | +``` |
| 191 | +
|
| 192 | +Authoring registers the factory but does not run it. Invoke it afterwards with `run_factory`. Use `factories_manage` with `operation: "list"` to see the factories already registered in the session and `operation: "inspect"` to read one factory's description, phases, and limits before running it. |
| 193 | +
|
| 194 | +## Observe a run |
| 195 | +
|
| 196 | +The calling session can inspect its own factory runs: |
| 197 | +
|
| 198 | +```ts |
| 199 | +const runs = await session.factory.listRuns(); |
| 200 | +const detail = await session.factory.getRunDetail(runId); |
| 201 | +const page = await session.factory.getRunProgress(runId, { |
| 202 | + phaseId, |
| 203 | + afterSeq, |
| 204 | + beforeSeq, |
| 205 | + limit, |
| 206 | +}); |
| 207 | +``` |
| 208 | +
|
| 209 | +- `listRuns()` returns summaries in durable creation order. |
| 210 | +- `getRunDetail(runId)` returns phases, prompt-safe agent summaries, and the latest progress page. |
| 211 | +- `getRunProgress(runId, options?)` pages progress forward, backward, by phase, or from the latest tail. |
| 212 | +
|
| 213 | +`getRun(runId)` reads the latest run envelope, and `cancel(runId)` cancels a run and returns its terminal envelope. |
| 214 | +
|
| 215 | +`waitForRun(runId, options?)` resolves with the terminal envelope once the run settles into `completed`, `error`, `halted`, or `cancelled`, and resolves immediately when it has already settled: |
| 216 | +
|
| 217 | +```ts |
| 218 | +const settled = await session.factory.waitForRun(runId); |
| 219 | +if (settled.status === "completed") { |
| 220 | + console.log(settled.result); |
| 221 | +} |
| 222 | +``` |
| 223 | +
|
| 224 | +It watches `factory.run_updated` and re-reads the durable envelope rather than polling on a timer, and it collapses a burst of invalidation events into a single in-flight read. Pass a `signal` to stop waiting: |
| 225 | +
|
| 226 | +```ts |
| 227 | +const controller = new AbortController(); |
| 228 | +setTimeout(() => controller.abort(), 30_000); |
| 229 | +const settled = await session.factory.waitForRun(runId, { signal: controller.signal }); |
| 230 | +``` |
| 231 | +
|
| 232 | +Aborting rejects the wait and has no effect on the run, which keeps executing — use `cancel(runId)` to actually stop it. Because a terminal envelope is final, the resolved value never changes afterwards. `isFactoryRunTerminal(status)` exposes the same terminal-status test for callers driving their own loop. |
| 233 | +
|
| 234 | +Listen for the ephemeral `factory.run_updated` event. Its `{ runId, revision }` payload is an invalidation signal. Re-read the desired API when a newer monotonic revision arrives. |
| 235 | +
|
| 236 | +Revisions cover durable lifecycle, accounting, phase, agent, and progress changes. Continuous read-time fields can change without a new revision. These include `observedAt`, active-time calculations, live counts, and a live agent's status or prompt-safe activity text. Factory prompts are never exposed by these APIs. A run is visible only through the session that owns it. |
0 commit comments