Skip to content

Commit 3752a69

Browse files
Add the Agent Factories authoring surface
Agent Factories let a trusted extension declare a JavaScript closure that orchestrates a fleet of subagents, and invoke it by name. The runtime half shipped in copilot-agent-runtime#12953 and #13077; this is the SDK half. The feature is dark behind the runtime's `agent_factories` flag plus its billing gate, and every public type is marked `@experimental`. RPC surface - Generated wire types for the factory session methods (`run`, `resume`, `getRun`, `listRuns`, `getRunDetail`, `getRunProgress`, `cancel`), the reverse `factory.execute` / `factory.abort` calls, run envelopes, registration metadata, and the observability DTOs. - Fixes opaque-JSON codegen so a factory's args and result can be any JSON value rather than an object alone, which also lets a void factory complete without a result. Authoring harness - `defineFactory({ meta, run })` returns an opaque handle; an extension registers it through `joinSession({ factories: [...] })`. Only metadata crosses to the runtime — the closure stays in the extension process and is invoked over the reverse `factory.execute` RPC. - The `run()` context: `args`, the extension's full `session`, a per-run `signal`, `runId`, plus `agent()` (one single-turn subagent, optionally schema-constrained), `step()` (journal-backed memoization), `parallel()`, `pipeline()`, and `phase()` / `log()` progress markers. `factory()` throws, because nesting is forbidden. - `session.factory.*` friendly wrappers, including `resume(runId)`, which reuses the persisted name, arguments, journal, and accounting so a caller never re-sends them. - Declared limits (`maxConcurrentSubagents`, `maxTotalSubagents`, `timeoutSeconds`, `maxAiCredits`) validated at the authoring boundary. - Strict JSON validation shared by factory results and journaled `step()` producers, so a lossy value is rejected rather than silently mutating on a resumed replay. `run()` and `resume()` resolve with the run envelope for every outcome — completed, error, halted, or cancelled — and reject only when no run exists (an unknown factory, a declined approval, an already-active session). This keeps the signature stable for the planned background-only execution mode, which changes a run's timing and status but not its shape. Authoring documentation lives in `nodejs/docs/factories.md`.
1 parent 0015a98 commit 3752a69

16 files changed

Lines changed: 4297 additions & 1218 deletions

nodejs/docs/extensions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,5 @@ The `session` object provides methods for sending messages, logging to the timel
5656
## Further Reading
5757

5858
- `examples.md` — Practical code examples for tools, hooks, events, and complete extensions
59+
- `factories.md`: Authoring, running, resuming, and observing Agent Factories
5960
- `agent-author.md` — Step-by-step workflow for agents authoring extensions programmatically

nodejs/docs/factories.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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.
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.
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+
- `ctx.session`: The full session returned by `joinSession`.
59+
- `ctx.signal`: Cooperative cancellation signal for extension work and subprocesses.
60+
- `ctx.factory(...)`: Always rejects because nested factories are not supported.
61+
62+
Factory-owned subagents are intentionally hidden from `read_agent` and `write_agent`. Use the factory observability APIs instead.
63+
64+
## Resource limits
65+
66+
Limits may be declared in `meta.limits` and overridden per invocation. All limits must be positive when present.
67+
68+
- `maxConcurrentSubagents`: Positive integer concurrent-subagent cap. Additional subagents wait in a queue. Queueing applies backpressure and does not fail the run.
69+
- `maxTotalSubagents`: Positive integer cumulative admission cap. An attempted subagent beyond the cap ends the attempt with failure kind `maxTotalSubagents`.
70+
- `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`.
71+
- `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`.
72+
73+
`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.
74+
75+
## Run and resume
76+
77+
Run by registered name or handle:
78+
79+
```ts
80+
const run = await session.factory.run("review-changed", {
81+
args: { files: ["src/a.ts"] },
82+
limits: { maxAiCredits: 3 },
83+
});
84+
85+
if (run.status === "completed") {
86+
console.log(run.result);
87+
} else {
88+
console.error(`run ${run.runId} ended as ${run.status}`, run.failure ?? run.error);
89+
}
90+
```
91+
92+
The name overload is:
93+
94+
```ts
95+
session.factory.run(
96+
name: string,
97+
options?: { args?: JsonValue; limits?: FactoryLimits },
98+
): Promise<FactoryRunResult>;
99+
```
100+
101+
Resume by run ID without resending the name or arguments:
102+
103+
```ts
104+
const run = await session.factory.resume(runId, {
105+
limits: { maxAiCredits: 6 },
106+
});
107+
```
108+
109+
The signature is:
110+
111+
```ts
112+
session.factory.resume(
113+
runId: string,
114+
options?: { limits?: FactoryLimits },
115+
): Promise<FactoryRunResult>;
116+
```
117+
118+
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`. Only failures that occur *before* a run exists reject: an unknown factory name, a declined approval, or an already-active session. Pre-execution resume failures throw `FactoryResumeError`, whose `code` is one of `not_found`, `non_resumable`, `already_active`, `reapproval_declined`, or `no_approval_provider`.
119+
120+
The agent-facing `run_factory` tool has exactly two input branches:
121+
122+
```ts
123+
{ name: string; args?: JsonValue; limits?: FactoryLimits }
124+
{ resumeFromRunId: string; limits?: FactoryLimits }
125+
```
126+
127+
## Observe a run
128+
129+
The calling session can inspect its own factory runs:
130+
131+
```ts
132+
const runs = await session.factory.listRuns();
133+
const detail = await session.factory.getRunDetail(runId);
134+
const page = await session.factory.getRunProgress(runId, {
135+
phaseId,
136+
afterSeq,
137+
beforeSeq,
138+
limit,
139+
});
140+
```
141+
142+
- `listRuns()` returns summaries in durable creation order.
143+
- `getRunDetail(runId)` returns phases, prompt-safe agent summaries, and the latest progress page.
144+
- `getRunProgress(runId, options?)` pages progress forward, backward, by phase, or from the latest tail.
145+
146+
`getRun(runId)` reads the latest run envelope, and `cancel(runId)` cancels a run and returns its terminal envelope.
147+
148+
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.
149+
150+
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.

nodejs/src/client.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "./generated/rpc.js";
3636
import type {
3737
GitHubTelemetryNotification,
38+
JsonValue,
3839
OpenCanvasInstance,
3940
SessionUpdateOptionsParams,
4041
} from "./generated/rpc.js";
@@ -85,6 +86,7 @@ import type {
8586
TypedSessionLifecycleHandler,
8687
} from "./types.js";
8788
import { defaultJoinSessionPermissionHandler } from "./types.js";
89+
import type { FactoryHandle } from "./factory.js";
8890

8991
/**
9092
* Minimum protocol version this SDK can communicate with.
@@ -1663,6 +1665,23 @@ export class CopilotClient {
16631665
* ```
16641666
*/
16651667
async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise<CopilotSession> {
1668+
return this.resumeSessionInternal(sessionId, config);
1669+
}
1670+
1671+
/** @internal */
1672+
async resumeSessionForExtension(
1673+
sessionId: string,
1674+
config: ResumeSessionConfig,
1675+
factories?: FactoryHandle[]
1676+
): Promise<CopilotSession> {
1677+
return this.resumeSessionInternal(sessionId, config, factories);
1678+
}
1679+
1680+
private async resumeSessionInternal(
1681+
sessionId: string,
1682+
config: ResumeSessionConfig,
1683+
factories?: FactoryHandle[]
1684+
): Promise<CopilotSession> {
16661685
if (!this.connection) {
16671686
await this.start();
16681687
}
@@ -1679,6 +1698,7 @@ export class CopilotClient {
16791698
session.registerTools(config.tools);
16801699
session.registerCanvases(config.canvases);
16811700
session.registerCommands(config.commands);
1701+
session.registerFactories(factories);
16821702
const {
16831703
wireProvider: bearerWireProvider,
16841704
wireProviders: bearerWireProviders,
@@ -1750,6 +1770,7 @@ export class CopilotClient {
17501770
})),
17511771
toolSearch: config.toolSearch,
17521772
canvases: config.canvases?.map((canvas) => canvas.declaration),
1773+
factories: factories?.map((factory) => factory.meta),
17531774
requestCanvasRenderer: config.requestCanvasRenderer,
17541775
requestExtensions: config.requestExtensions,
17551776
extensionSdkPath: config.extensionSdkPath,
@@ -2981,7 +3002,7 @@ export class CopilotClient {
29813002
sessionId: string;
29823003
hookType: string;
29833004
input: unknown;
2984-
}): Promise<{ output?: unknown }> {
3005+
}): Promise<{ output?: JsonValue }> {
29853006
if (
29863007
!params ||
29873008
typeof params.sessionId !== "string" ||
@@ -2996,7 +3017,7 @@ export class CopilotClient {
29963017
}
29973018

29983019
const output = await session._handleHooksInvoke(params.hookType, params.input);
2999-
return { output };
3020+
return output === undefined ? {} : { output: JSON.parse(JSON.stringify(output)) };
30003021
}
30013022

30023023
private async handleSystemMessageTransform(params: {

nodejs/src/extension.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { CopilotClient } from "./client.js";
66
import type { CopilotSession } from "./session.js";
77
import {
88
defaultJoinSessionPermissionHandler,
9-
type ExtensionInfo,
109
type PermissionHandler,
1110
type ResumeSessionConfig,
1211
} from "./types.js";
12+
import type { FactoryHandle } from "./factory.js";
1313

1414
export {
1515
Canvas,
@@ -27,9 +27,33 @@ export type JoinSessionConfig = Omit<
2727
"onPermissionRequest" | "extensionSdkPath"
2828
> & {
2929
onPermissionRequest?: PermissionHandler;
30+
/**
31+
* Factory handles to register when the extension joins the session.
32+
*
33+
* @experimental Part of the experimental Agent Factories surface and may
34+
* change or be removed in future SDK or CLI releases.
35+
*/
36+
factories?: FactoryHandle[];
3037
};
3138

32-
export type { ExtensionInfo };
39+
export type { ExtensionInfo, FactoryLimits, FactoryMeta } from "./types.js";
40+
export {
41+
defineFactory,
42+
FactoryResumeError,
43+
type RunOptions,
44+
type ResumeOptions,
45+
type FactoryResumeErrorCode,
46+
type SessionFactoryApi,
47+
type FactoryAgentOptions,
48+
type FactoryContext,
49+
type FactoryDefinition,
50+
type FactoryHandle,
51+
type FactoryJsonSchema,
52+
type JsonValue,
53+
type FactoryPipelineStage,
54+
type FactoryStepOptions,
55+
type FactoryRunResult,
56+
} from "./factory.js";
3357

3458
/**
3559
* Joins the current foreground session.
@@ -58,14 +82,22 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise<Copil
5882
// at the type level — untyped (JS) callers can still slip it through, and
5983
// honoring it here would be misleading since the extension subprocess has
6084
// already been forked by the host with the SDK the host chose.
61-
const { extensionSdkPath: _stripped, ...rest } = config as JoinSessionConfig & {
85+
const {
86+
extensionSdkPath: _stripped,
87+
factories,
88+
...rest
89+
} = config as JoinSessionConfig & {
6290
extensionSdkPath?: string;
6391
};
6492
void _stripped;
6593

66-
return client.resumeSession(sessionId, {
67-
...rest,
68-
onPermissionRequest: config.onPermissionRequest ?? defaultJoinSessionPermissionHandler,
69-
suppressResumeEvent: config.suppressResumeEvent ?? true,
70-
});
94+
return client.resumeSessionForExtension(
95+
sessionId,
96+
{
97+
...rest,
98+
onPermissionRequest: config.onPermissionRequest ?? defaultJoinSessionPermissionHandler,
99+
suppressResumeEvent: config.suppressResumeEvent ?? true,
100+
},
101+
factories
102+
);
71103
}

0 commit comments

Comments
 (0)