Skip to content

Commit f75d222

Browse files
[SDK/Factories] Make The Agent Factories Surface Match The Wire Contract (#2309)
* [SDK/Factories] Make The Agent Factories Surface Match The Wire Contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * [SDK/Codegen] Map Both Opaque Schema Markers In The TypeScript Generator A bare x-opaque-json node now renders as JsonValue and a bare x-opaque-in-process node as OpaqueInProcessValue, instead of both collapsing to an object index signature. Nodes that also carry a real constraint keep it, so declarations like ExternalToolResult and McpServerConfig retain their unions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * [SDK/Factories] Drop The FactoryRunResult Override And The Factory Casts The regenerated wire types now type the factory result and argument fields as JsonValue, so the hand-written FactoryRunResult override, the toPublicFactoryRunResult boundary helper, and four casts are all unnecessary. A compile-time assertion pins the result type so the override cannot creep back. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * [SDK/Factories] Refuse A Factory Run Started From Inside A Factory Body A factory body could start a second top-level run through any session reference it could reach, escaping the limits the user approved. An AsyncLocalStorage guard now refuses factory.run and factory.resume on the body's call path, before the RPC is dispatched, so no durable run row is created. The guard is per-call-path, so an unrelated concurrent run started elsewhere in the extension still succeeds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * [SDK/Factories] Correct The Factory Resume Error Code Union The union exported two codes no runtime path raises and omitted five it does, so a caller could branch on a dead code and receive a raw RpcResponseError for a real one. It now names exactly the codes execute_resume raises before a resumed run starts. permission_denied is deliberately excluded: an SDK-initiated resume dispatches with RunOrigin::default(), so the approval branch never runs and the code is unreachable from this path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * [SDK/Factories] Forward Every Declared Subagent Option From ctx.agent The hand-written FactoryAgentOptions declared only label, schema and model, and the agent implementation rebuilt the request from those three, so agent, reasoningEffort and contextTier were dropped before the request was sent. The options are now declared once as a key tuple and copied from it, and two compile-time assertions pin that tuple to both the public and the wire interface, so a future wire option fails the build instead of being silently dropped. Undeclared keys are still filtered out, because the wire schema forbids them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * [SDK/Factories] Stop A Latched Progress Flush Error From Downgrading A Run A background progress flush that failed earlier latched its error, and close() rethrew it from the factory execute finally block, so a factory body that succeeded settled as an error. The latched error is now best effort and warns, matching the treatment the final send already had. A mid-body flush failure stays fatal, because a running body that cannot record progress must not continue. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * [SDK/Factories] Correct The Factories Guide And Published API Comments The guide and four JSDoc comments described behavior that does not exist: a declined SDK-initiated run resolving as cancelled, a single-active-run limit, two error codes no runtime path raises, an unpaginated listRuns, and a three-option ctx.agent. They now match the shipped surface, including that the SDK forwards agent, reasoningEffort and contextTier while the current runtime does not yet honor them. File-content assertions guard both files, which nothing else covers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * Add changelog entry for the Agent Factories wire-contract corrections Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * Drop the transient runtime-support caveat from the factories docs The claim that the runtime does not yet honor agent, reasoningEffort and contextTier is a point-in-time fact about another repository. It rots as soon as the runtime lands support, so the SDK docs no longer carry it. Also wraps three over-length test assertions that the prettier check flagged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * Simplify factory docs so they do not encode transient facts - Describe ctx.session by what it omits, and point at the extensions_manage guide - Drop the hardcoded active-run limit, which will become a setting - Drop the listRuns paging parenthetical Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * Stop the subagent-option E2E from waiting on a model response The factory awaited its subagent to completion, so the test hung wherever no cached model response exists and timed out at 30s on CI. Only the runtime's acceptance of the option payload is under test, and a refused request rejects before a subagent starts. The factory now races the call against a short timer and returns as soon as the request is accepted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * Describe ctx.session by how it behaves, not by what it lacks The context session is a full CopilotSession, so factory.run and factory.resume are present and callable. Saying the APIs are absent contradicted the exported type. The guide and the published comment now say the session refuses those calls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd * Drop the hand-written changelog entry The changelog is generated at release time, so an entry added by hand in a feature PR does not fit the file's convention. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd --------- Copilot-Session: 5ece6b29-8b10-47aa-ab17-b64c47f5fdcd
1 parent e2cd7ad commit f75d222

14 files changed

Lines changed: 1245 additions & 411 deletions

nodejs/docs/factories.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ The `run()` context provides:
5353

5454
- `ctx.runId`: Stable ID reused across resumed attempts.
5555
- `ctx.args`: Invocation arguments, forwarded verbatim. When the caller omits `args`, this is `{}` rather than `undefined`.
56-
- `ctx.agent(prompt, options?)`: Runs one factory-owned subagent. Options are exactly `label`, `schema`, and `model`. See [Subagent calls](#subagent-calls).
56+
- `ctx.agent(prompt, options?)`: Runs one factory-owned subagent. Options are exactly `label`, `schema`, `model`, `agent`, `reasoningEffort`, and `contextTier`. See [Subagent calls](#subagent-calls).
5757
- `ctx.parallel(thunks)`: Runs thunks concurrently and awaits all of them (a barrier). A thunk that throws becomes `null` in the result array, so one failed item does not lose the rest. Cancellation and hard runtime failures (`ResponseError`, `ConnectionError`) are the exception — those propagate and reject the whole call, because they mean the run itself is in trouble rather than one item having failed. Handle them at run level; do not assume every failure arrives as a `null`. Rejects above 4096 items.
5858
- `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, with the same exception for cancellation and hard runtime failures. Rejects above 4096 items.
5959
- `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.
6060
- `ctx.log(message)`: Appends a progress line. When a factory bounds its own coverage (top-N, sampling), log what was dropped.
6161
- `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.
6262

6363
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.
64-
- `ctx.session`: The full session returned by `joinSession`.
64+
- `ctx.session`: The session returned by `joinSession`. It refuses calls that start or resume a factory run. Call `extensions_manage` with `operation: "guide"` to read more about the session APIs.
6565
- `ctx.signal`: Cooperative cancellation signal for extension work and subprocesses.
6666
- `ctx.factory(...)`: Always rejects because nested factories are not supported.
6767

@@ -156,7 +156,7 @@ session.factory.resume(
156156
): Promise<FactoryRunResult>;
157157
```
158158

159-
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`.
159+
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`. SDK-initiated `run` and `resume` do not request permission, so they have no declined outcome. The model's `run_factory` tool requests permission before the durable row exists; declining it creates no run row. An SDK-initiated run is refused only when the session already has its maximum number of active top-level runs. Pre-execution resume failures throw `FactoryResumeError`, whose `code` is one of `not_found`, `non_resumable`, `already_active`, `factory_already_running`, `factory_limits_invalid`, `factory_session_disposed`, `factory_storage_unavailable`, or `factory_storage_corrupt`.
160160

161161
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.
162162

@@ -210,7 +210,7 @@ const page = await session.factory.getRunProgress(runId, {
210210
});
211211
```
212212
213-
- `listRuns()` returns summaries in durable creation order.
213+
- `listRuns()` returns the newest default page of this session's durable factory runs.
214214
- `getRunDetail(runId)` returns phases, prompt-safe agent summaries, and the latest progress page.
215215
- `getRunProgress(runId, options?)` pages progress forward, backward, by phase, or from the latest tail.
216216

nodejs/src/factory.ts

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,15 @@ import type {
66
FactoryGetRunProgressRequest,
77
FactoryProgressPage,
88
FactoryRunDetail,
9-
FactoryRunResult as WireFactoryRunResult,
9+
FactoryRunResult,
1010
FactoryRunStatus,
1111
FactoryRunSummary,
1212
} from "./generated/rpc.js";
13+
import type { ContextTier } from "./generated/session-events.js";
1314
import type { CopilotSession } from "./session.js";
1415
import type { FactoryLimits, FactoryMeta } from "./types.js";
1516

16-
/**
17-
* The envelope describing a factory run: its identity, status, and — once it
18-
* has completed — its result. `getRun` returns this for an in-flight run too,
19-
* so `status` may be `pending` or `running` and the outcome fields absent.
20-
*
21-
* `result` is re-typed here rather than taken from the generated wire type. The
22-
* runtime returns any JSON value — including `null`, a string, a number, or an
23-
* array — but the schema models the field as an opaque node, which the
24-
* generator renders as an object. Narrowing the correction to this surface
25-
* keeps the `x-opaque-json` handling unchanged for every other consumer.
26-
*
27-
* This override is temporary. Once the schema distinguishes an opaque JSON
28-
* value from an opaque in-process value and that ships in a CLI release,
29-
* regenerating produces the right type directly, and this declaration, the
30-
* `toPublicFactoryRunResult` boundary helper, and the casts around it should
31-
* all be deleted. Tracked by github/copilot-agent-runtime#14122.
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-
export type FactoryRunResult = Omit<WireFactoryRunResult, "result"> & {
37-
/** Completed factory result. */
38-
result?: JsonValue;
39-
};
40-
17+
export type { FactoryRunResult };
4118
export type {
4219
FactoryAgentSummary,
4320
FactoryPhaseStatus,
@@ -115,8 +92,20 @@ export interface FactoryAgentOptions {
11592
label?: string;
11693
schema?: FactoryJsonSchema;
11794
model?: string;
95+
reasoningEffort?: string;
96+
contextTier?: ContextTier;
97+
agent?: string;
11898
}
11999

100+
export const FACTORY_AGENT_OPTION_KEYS = [
101+
"label",
102+
"schema",
103+
"model",
104+
"reasoningEffort",
105+
"contextTier",
106+
"agent",
107+
] as const;
108+
120109
/**
121110
* Options for a durable factory step.
122111
*
@@ -185,7 +174,10 @@ export interface FactoryContext<TArgs extends JsonValue = JsonValue> {
185174
factory(name: string, args?: JsonValue): Promise<JsonValue | void>;
186175
/** Caller-supplied input, forwarded verbatim. */
187176
args: TArgs;
188-
/** The same full session instance returned by `joinSession`. */
177+
/**
178+
* The session instance returned by `joinSession`. It refuses calls that
179+
* start or resume a factory run.
180+
*/
189181
session: CopilotSession;
190182
/** Cooperative cancellation signal for the current factory run. */
191183
signal: AbortSignal;
@@ -275,8 +267,11 @@ export type FactoryResumeErrorCode =
275267
| "not_found"
276268
| "non_resumable"
277269
| "already_active"
278-
| "reapproval_declined"
279-
| "no_approval_provider";
270+
| "factory_already_running"
271+
| "factory_limits_invalid"
272+
| "factory_session_disposed"
273+
| "factory_storage_unavailable"
274+
| "factory_storage_corrupt";
280275

281276
/**
282277
* Friendly factory API exposed on a session.
@@ -290,9 +285,12 @@ export interface SessionFactoryApi {
290285
*
291286
* The envelope is returned for every outcome, including `error`, `halted`,
292287
* and `cancelled` — inspect `status` and read `result` only when the run
293-
* completed. A declined fresh run resolves with a terminal `cancelled`
294-
* envelope. Failures that occur before a run exists (such as an unknown
295-
* factory or an already-active session) still reject.
288+
* completed. SDK-initiated runs do not request permission, so they have no
289+
* declined outcome. The model's `run_factory` tool requests permission
290+
* before a durable row exists; declining it creates no run row. Failures
291+
* that occur before a run exists (such as an unknown factory or attempting
292+
* to start a run while the session is at its active top-level run limit)
293+
* still reject.
296294
*/
297295
run(name: string, options?: RunOptions): Promise<FactoryRunResult>;
298296
run<TArgs extends JsonValue>(
@@ -302,9 +300,9 @@ export interface SessionFactoryApi {
302300
/**
303301
* Resume a run from its persisted factory name, arguments, journal, and accounting.
304302
*
305-
* Resolves with the run envelope like {@link SessionFactoryApi.run}. A
306-
* pre-execution failure, including declined reapproval, rejects with
307-
* {@link FactoryResumeError}.
303+
* Resolves with the run envelope like {@link SessionFactoryApi.run}.
304+
* SDK-initiated resumes do not request permission. A pre-execution failure
305+
* with a documented resume code rejects with {@link FactoryResumeError}.
308306
*/
309307
resume(runId: string, options?: ResumeOptions): Promise<FactoryRunResult>;
310308
/** Read the latest durable envelope for a factory run. */
@@ -324,7 +322,9 @@ export interface SessionFactoryApi {
324322
* {@link SessionFactoryApi.cancel} to actually stop it.
325323
*/
326324
waitForRun(runId: string, options?: { signal?: AbortSignal }): Promise<FactoryRunResult>;
327-
/** List this session's durable factory runs in creation order. */
325+
/**
326+
* List the newest default page of this session's durable factory runs.
327+
*/
328328
listRuns(): Promise<FactoryRunSummary[]>;
329329
/** Read durable phases, direct agents, and the latest progress tail for a run. */
330330
getRunDetail(runId: string): Promise<FactoryRunDetail>;

0 commit comments

Comments
 (0)