Add the Agent Factories authoring surface - #2114
Conversation
There was a problem hiding this comment.
Pull request overview
Adds the experimental Agent Factories authoring surface to the Node.js SDK, including registration, execution, orchestration, persistence, observability, and documentation.
Changes:
- Adds factory definition, registration, execution, resume, cancellation, and observability APIs.
- Generates supporting JSON-RPC and session-event types with recursive
JsonValuesupport. - Adds extensive factory tests and authoring documentation.
Show a summary per file
| File | Description |
|---|---|
scripts/codegen/typescript.ts |
Generates recursive opaque JSON types. |
nodejs/test/typescript-codegen.test.ts |
Tests opaque JSON generation. |
nodejs/test/session-event-types.test.ts |
Checks factory and JSON exports. |
nodejs/test/factory.test.ts |
Tests factory behavior and APIs. |
nodejs/test/extension.test.ts |
Updates extension resume tests. |
nodejs/src/types.ts |
Adds factory metadata and limit types. |
nodejs/src/sessionFsProvider.ts |
Adds SQLite transaction support. |
nodejs/src/session.ts |
Implements factory execution and orchestration. |
nodejs/src/index.ts |
Exports factory APIs. |
nodejs/src/generated/session-events.ts |
Adds factory events and JSON types. |
nodejs/src/generated/rpc.ts |
Adds generated factory RPC contracts. |
nodejs/src/factory.ts |
Defines the public factory authoring surface. |
nodejs/src/extension.ts |
Registers factories through joinSession. |
nodejs/src/client.ts |
Sends registration metadata and installs handlers. |
nodejs/docs/factories.md |
Documents factory authoring and operation. |
nodejs/docs/extensions.md |
Links factory documentation. |
Review details
- Files reviewed: 14/16 changed files
- Comments generated: 7
- Review effort level: Medium
There was a problem hiding this comment.
Review details
Still open (7)
- 🟠 nodejs/src/types.ts#L840 — This broadens elicitation responses beyond the values that the adjacent
ElicitationSchemaField… - 🟠 nodejs/src/extension.ts#L56 — The extension entry point has the same incomplete public surface:
SessionFactoryApi.listRuns(),… - 🟠 nodejs/src/index.ts#L181 — The new observability methods expose named DTOs (
FactoryRunSummary,FactoryRunDetail,… - 🟠 nodejs/src/factory.ts#L352 — The validated metadata is retained by reference and only the outer handle is frozen. Callers can…
- 🟠 nodejs/src/session.ts#L1939 — This strict validator accepts
-0, but JSON serialization changes it to0… - 🟠 nodejs/src/session.ts#L274 — The RPC promise has already been created—and therefore the request already sent—before this…
- 🟠 nodejs/src/sessionFsProvider.ts#L56 — Making
transactionrequired is a breaking change to the existing publicSessionFsSqliteProvider…
Comments suppressed due to low confidence (2)
nodejs/src/sessionFsProvider.ts:56
- Making
transactionrequired breaks every existingSessionFsProviderSQLite implementation in this repository: for examplenodejs/test/session_fs_adapter.test.ts:62,nodejs/test/e2e/session_fs.e2e.test.ts:287, andnodejs/test/e2e/session_fs_sqlite.e2e.test.ts:203implement onlyqueryandexists, so TypeScript compilation fails. Update all implementations (and transaction behavior tests), or make this capability optional with an adapter fallback to preserve compatibility.
transaction(
statements: SessionFsSqliteTransactionStatement[]
): Promise<SessionFsSqliteQueryResult[]>;
nodejs/src/types.ts:840
- This broadens elicitation responses beyond the primitive schema declared immediately above:
ElicitationSchemaFieldcan only produce strings, numbers, booleans, or string arrays, whileJsonValuealso permitsnull, objects, and mixed/nested arrays. Those values cannot satisfy the advertised MCP primitive form contract, so invalid handler responses now type-check. Keep the primitive value union here; factory JSON values should not alter this unrelated API.
* JSON field value in an elicitation result.
*/
export type ElicitationFieldValue = JsonValue;
- Files reviewed: 15/17 changed files
- Comments generated: 4
- Review effort level: Medium
e2d54fa to
44d9159
Compare
There was a problem hiding this comment.
Review details
Files not reviewed (6)
- go/rpc/zrpc.go: Generated file
- go/rpc/zrpc_encoding.go: Generated file
- go/rpc/zsession_encoding.go: Generated file
- go/rpc/zsession_events.go: Generated file
- go/zsession_events.go: Generated file
- nodejs/package-lock.json: Generated file
Resolved since last review (6)
- ✅ nodejs/src/session.ts#L182 — Resolved by Copilot -
pipeline()similarly converts hard RPC failures from a stage'sagent()call intonull,… - ✅ nodejs/src/session.ts#L154 — Resolved by Copilot -
parallel()swallows every non-abort rejection, includingResponseError/ConnectionErrorfrom… - ✅ scripts/codegen/typescript.ts#L293 — Resolved by Copilot - This blanket replacement collapses every marked schema to
JsonValue, including already-structured… - ✅ nodejs/src/extension.ts#L56 — Resolved by Copilot - The extension entry point has the same incomplete public surface:
SessionFactoryApi.listRuns(),… - ✅ nodejs/src/index.ts#L181 — Resolved by Copilot - The new observability methods expose named DTOs (
FactoryRunSummary,FactoryRunDetail,… - ✅ nodejs/src/sessionFsProvider.ts#L56 — Resolved by Copilot - Making
transactionrequired is a breaking change to the existing publicSessionFsSqliteProvider…
Still open (5)
- 🔴 nodejs/src/session.ts#L1192 — Indexing controllers only by
runIdloses the previous controller when overlapping execution… - 🟠 nodejs/src/types.ts#L840 — This broadens elicitation responses beyond the values that the adjacent
ElicitationSchemaField… - 🟠 nodejs/src/factory.ts#L352 — The validated metadata is retained by reference and only the outer handle is frozen. Callers can…
- 🟠 nodejs/src/session.ts#L1939 — This strict validator accepts
-0, but JSON serialization changes it to0… - 🟠 nodejs/src/session.ts#L274 — The RPC promise has already been created—and therefore the request already sent—before this…
Comments suppressed due to low confidence (6)
nodejs/src/session.ts:1215
- Overlapping
factory.executeattempts can share a run ID (the new overlapping-attempts test explicitly exercises this), but this assignment overwrites the previous controller. A subsequentfactory.aborttherefore aborts only the newest attempt while the stale factory body can continue running and performing local side effects. Track all controllers per run ID and abort every active attempt.
const controller = new AbortController();
self.factoryAbortControllers.set(params.runId, controller);
nodejs/src/factory.ts:348
- Validation is only performed here, but the returned handle exposes the same mutable
definition.metaobject. A caller can mutate phases or limits afterdefineFactoryand beforejoinSession, bypassing these checks; registration then serializes the invalid metadata. Snapshot/freeze the validated metadata, or revalidate it during registration.
validateLimits(definition.meta);
validatePhases(definition.meta);
const stored: StoredFactory = {
meta: definition.meta,
run: definition.run,
};
const handle = Object.freeze({ meta: definition.meta }) as FactoryHandle<TArgs, TResult>;
nodejs/src/session.ts:1954
- The numeric branch accepts negative zero, but JSON serialization converts
-0to0. That violates the stated lossless validation guarantee for factory results and journal replay. DetectObject.is(current, -0)and either reject it with a dedicated category or define an explicit normalization policy.
if (typeof current === "number") {
if (!Number.isFinite(current)) {
scripts/codegen/typescript.ts:326
- This replacement discards non-structural schema metadata such as
description,deprecated, and visibility tags. The generated diff already loses documentation from opaque fields such asCitationReference.providerMetadataandHookStartData.input. Preserve the rewritten node and only replace its type semantics after removing the opaque marker.
nodejs/src/factory.ts:197 FactoryResumeErrorCodeis exported as part of the new public authoring API but lacks the@experimentalmarker promised for every public factory type in the PR description. Add the same experimental notice used by the surrounding factory types.
/** Machine-readable pre-execution factory resume failure. */
export type FactoryResumeErrorCode =
nodejs/src/sessionFsProvider.ts:240
- The adapter now has several new behavioral branches—unsupported providers, parameter normalization, successful transactions, and three error classifications—but none are covered in the existing
nodejs/test/session_fs_adapter.test.tssuite that tests the other adapter operations. Add focused tests, especially for busy/locked versus post-commit-ambiguous classification, to prevent unsafe retry behavior.
sqliteTransaction: async ({ statements }) => {
if (!provider.sqlite?.transaction) {
return {
results: [],
error: {
- Files reviewed: 17/32 changed files
- Comments generated: 0 new
- Review effort level: Medium
There was a problem hiding this comment.
Review details
Files not reviewed (6)
- go/rpc/zrpc.go: Generated file
- go/rpc/zrpc_encoding.go: Generated file
- go/rpc/zsession_encoding.go: Generated file
- go/rpc/zsession_events.go: Generated file
- go/zsession_events.go: Generated file
- nodejs/package-lock.json: Generated file
Still open (5)
- 🔴 nodejs/src/session.ts#L1192 — Indexing controllers only by
runIdloses the previous controller when overlapping execution… - 🟠 nodejs/src/types.ts#L840 — This broadens elicitation responses beyond the values that the adjacent
ElicitationSchemaField… - 🟠 nodejs/src/factory.ts#L352 — The validated metadata is retained by reference and only the outer handle is frozen. Callers can…
- 🟠 nodejs/src/session.ts#L1939 — This strict validator accepts
-0, but JSON serialization changes it to0… - 🟠 nodejs/src/session.ts#L274 — The RPC promise has already been created—and therefore the request already sent—before this…
Comments suppressed due to low confidence (4)
nodejs/src/session.ts:1296
- Overlapping
factory.executecalls for the same run are explicitly supported, but this assignment replaces the earlier controller. A laterfactory.abort(and session cleanup) therefore aborts only the newest attempt; an older extension body can keep running and performing side effects indefinitely. Track all controllers per run (or key them by execution token and abort every controller for the run), and remove only the completing attempt.
const controller = new AbortController();
self.factoryAbortControllers.set(params.runId, controller);
nodejs/src/sessionFsProvider.ts:296
- SQLite extended busy/locked result codes are misclassified as
fatal. Standard codes such asSQLITE_BUSY_SNAPSHOT(numeric 517) andSQLITE_LOCKED_SHAREDCACHE(262) retain 5 or 6 only in the low byte, so providers exposing extended names or numbers will skip the intended retry path. Match theSQLITE_BUSY*/SQLITE_LOCKED*families and mask numeric extended codes to their base code.
const code = "code" in err ? err.code : undefined;
const errcode = "errcode" in err ? err.errcode : undefined;
return code === "SQLITE_BUSY" ||
code === "SQLITE_LOCKED" ||
code === "EBUSY" ||
errcode === 5 ||
errcode === 6
? "busyOrLocked"
: "fatal";
nodejs/src/factory.ts:223
FactoryResumeErrorCodeis a newly public Agent Factories type but is the only factory-specific public declaration here without@experimental, contrary to the PR's stated contract that every public type is marked experimental. Add the same experimental JSDoc used by the surrounding factory API types.
/** Machine-readable pre-execution factory resume failure. */
export type FactoryResumeErrorCode =
go/rpc/zrpc_encoding.go:1157
- Clear
Failurewhen the property is absent. As written, unmarshalling a terminal withoutfailureinto a reusedFactoryRunTerminalleaves the previous failure attached, while the other optional fields are reset fromraw; callers can therefore observe a stale failure from an earlier payload. Please updatescripts/codegen/go.tsto initialize this union field tonilbefore conditionally decoding it, then regenerate this file.
- Files reviewed: 17/32 changed files
- Comments generated: 0 new
- Review effort level: Medium
2f86165 to
07a25bc
Compare
There was a problem hiding this comment.
Review details
Files not reviewed (6)
- go/rpc/zrpc.go: Generated file
- go/rpc/zrpc_encoding.go: Generated file
- go/rpc/zsession_encoding.go: Generated file
- go/rpc/zsession_events.go: Generated file
- go/zsession_events.go: Generated file
- nodejs/package-lock.json: Generated file
Resolved since last review (4)
- ✅ nodejs/src/session.ts#L1192 — Resolved by Copilot - Indexing controllers only by
runIdloses the previous controller when overlapping execution… - ✅ nodejs/src/factory.ts#L352 — Resolved by Copilot - The validated metadata is retained by reference and only the outer handle is frozen. Callers can…
- ✅ nodejs/src/session.ts#L1939 — Resolved by Copilot - This strict validator accepts
-0, but JSON serialization changes it to0… - ✅ nodejs/src/session.ts#L274 — Resolved by Copilot - The RPC promise has already been created—and therefore the request already sent—before this…
Still open (1)
- 🟠 nodejs/src/types.ts#L840 — This broadens elicitation responses beyond the values that the adjacent
ElicitationSchemaField…
Comments suppressed due to low confidence (3)
scripts/codegen/typescript.ts:325
- Replacing an opaque node with a fresh object drops all schema metadata on that node, including its description and marker-derived JSDoc tags. This is already removing public field documentation such as
CitationReference.providerMetadataand the@experimentaltext onToolExecutionCompleteData.mcpMetafrom the regenerated output. Preserve the rewritten node, remove onlyx-opaque-json, and addtsTypeso code generation retains those annotations.
nodejs/src/sessionFsProvider.ts:236 - The new transaction adapter path is not exercised by
nodejs/test/session_fs_adapter.test.ts, although that suite covers the other adapter operations. Please add cases for successful parameter normalization, unsupported providers, and each retry classification; a regression here can incorrectly retry a possibly committed transaction or mishandle a rolled-back busy error.
sqliteTransaction: async ({ statements }) => {
nodejs/src/factory.ts:223
- This exported Agent Factories type is the only factory-specific public type here without the promised
@experimentalannotation, so generated API documentation will present it as stable while the related class and options are experimental.
/** Machine-readable pre-execution factory resume failure. */
export type FactoryResumeErrorCode =
- Files reviewed: 17/32 changed files
- Comments generated: 0 new
- Review effort level: Medium
07a25bc to
d188f14
Compare
There was a problem hiding this comment.
Review details
Files not reviewed (6)
- go/rpc/zrpc.go: Generated file
- go/rpc/zrpc_encoding.go: Generated file
- go/rpc/zsession_encoding.go: Generated file
- go/rpc/zsession_events.go: Generated file
- go/zsession_events.go: Generated file
- nodejs/package-lock.json: Generated file
Still open (1)
- 🟠 nodejs/src/types.ts#L840 — This broadens elicitation responses beyond the values that the adjacent
ElicitationSchemaField…
Comments suppressed due to low confidence (3)
scripts/codegen/typescript.ts:324
- The new test exercises only the branch that collapses an unconstrained opaque node. The preservation branch here protects existing public contracts, but has no regression coverage. Add cases where
x-opaque-jsonaccompaniestype/propertiesandanyOf, and assert those schemas retain their structure rather than becomingJsonValue.
nodejs/src/factory.ts:249 - For a typed handle,
argsremains optional throughRunOptions<TArgs>. A factory declared asdefineFactory<{ files: string[] }, ...>(...)can therefore be invoked assession.factory.run(handle); the wrapper sends{}, while the factory body is typed as thoughargs.filesalways exists. This makes the handle overload unsound and can turn an accepted call into a runtime failure. Requireargsfor handle invocations unlessTArgsexplicitly permits the default empty object (the name-based overload can remain permissive).
run<TArgs extends JsonValue>(
factory: FactoryHandle<TArgs, JsonValue | void>,
options?: RunOptions<TArgs>
): Promise<FactoryRunResult>;
nodejs/src/sessionFsProvider.ts:240
- The new transaction adapter path is untested even though this module already has SQLite adapter coverage. Add tests for successful statement/parameter forwarding, the no-transaction fallback, and error classification (
busyOrLocked,postCommitAmbiguous, and fatal) so protocol errors cannot be silently misclassified.
sqliteTransaction: async ({ statements }) => {
if (!provider.sqlite?.transaction) {
return {
results: [],
error: {
- Files reviewed: 20/35 changed files
- Comments generated: 0 new
- Review effort level: Medium
d188f14 to
eb17d19
Compare
There was a problem hiding this comment.
Review details
Files not reviewed (6)
- go/rpc/zrpc.go: Generated file
- go/rpc/zrpc_encoding.go: Generated file
- go/rpc/zsession_encoding.go: Generated file
- go/rpc/zsession_events.go: Generated file
- go/zsession_events.go: Generated file
- nodejs/package-lock.json: Generated file
Still open (1)
- 🟠 nodejs/src/types.ts#L840 — This broadens elicitation responses beyond the values that the adjacent
ElicitationSchemaField…
Comments suppressed due to low confidence (4)
scripts/codegen/typescript.ts:327
- Returning a new
{ tsType }object drops the original node'sdescriptionand other annotations. As a result, this regeneration removes JSDoc from every unconstrained opaque field (for example, theargumentsdocumentation inAssistantMessageToolRequest). SettsTypeonrewrittenand then strip the marker so the public documentation and annotations survive.
nodejs/src/factory.ts:249 - The typed-handle overload permits omitting
options.args, but the implementation then sends{}. For a factory declared withTArgs = { files: string[] },session.factory.run(handle)therefore compiles whilecontext.argsis typed as that object and actually receives{}, causing ordinary typed factory bodies to fail at runtime. Requireargswhen the handle'sTArgsdoes not admit the documented{}default, or reflect that default in the context type.
run<TArgs extends JsonValue>(
factory: FactoryHandle<TArgs, JsonValue | void>,
options?: RunOptions<TArgs>
): Promise<FactoryRunResult>;
go/rpc/zrpc_encoding.go:1163
- When
failureis absent, this custom unmarshaller leaves any previousr.Failurevalue intact. Reusing aFactoryRunTerminalfor successive decodes can therefore report a stale failure on a later successful/cancelled terminal value. Clear the destination union field before conditionally decoding it; the Go generator should be fixed and this file regenerated.
nodejs/src/factory.ts:228 - This newly exported factory-specific type is the exception to the PR's stated rule that every public Agent Factories type is marked
@experimental. Add the same experimental JSDoc used by the surrounding public factory types so generated API documentation does not present this error-code contract as stable.
export type FactoryResumeErrorCode =
| "not_found"
| "non_resumable"
| "already_active"
| "reapproval_declined"
| "no_approval_provider";
- Files reviewed: 21/36 changed files
- Comments generated: 0 new
- Review effort level: Medium
eb17d19 to
8697a23
Compare
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`.
Authoring
- `defineFactory({ meta, run })` validates limits and phases at the
authoring boundary and returns an opaque handle. The metadata is deep
snapshotted and frozen, so mutating the caller's object afterwards cannot
desynchronize what was validated from what is advertised.
- `joinSession({ factories })` registers handles for a session; names must
be unique within the call.
Running
- `session.factory.run()` and `.resume()` resolve with the run envelope
(`FactoryRunResult`) for every outcome — `completed`, `error`, `halted`,
and `cancelled` alike. A declined fresh run resolves with a terminal
`cancelled` envelope, because the run row already exists by the time the
prompt is answered; only failures that occur before a run exists reject.
- `session.factory.waitForRun()` settles once a run reaches a terminal
status. It subscribes to `factory.run_updated` before its first read so a
transition cannot be missed, collapses a burst of invalidations into a
single re-read, and re-reads periodically so a dropped event degrades into
a slightly late resolution rather than an unbounded wait.
Inside a run
- `context.agent()` spawns a subagent, `step()` journals a result so a
resumed run replays it for free, and `phase()`/`log()` report progress.
- `parallel()` and `pipeline()` compose subagent work; both propagate hard
RPC failures rather than folding them into per-branch results.
- Journaled values are validated as strict JSON, because a step result has
to survive serialization unchanged to be replayable. Negative zero is
rejected for that reason: it serializes to `0`.
- Cancellation is checked before an operation is dispatched, so an aborted
run does not start new runtime work. Abort controllers are keyed by run ID
and execution token so overlapping attempts stay individually addressable.
Codegen
- Narrows the `x-opaque-json` conversion to definitions carrying no
structural keywords. Only one of the annotated definitions is genuinely
unconstrained; blanket-converting the rest discarded the structure of
`McpServerConfig`, `ExternalToolResult`, `EventLogTypes`, and
`UIElicitationSchemaProperty`. Without this, a factory result generates as
an object alone, which a non-object or void result cannot satisfy.
- Genuinely opaque nodes emit `JsonValue` rather than `unknown`, a global
tightening. `ToolTelemetry`, the hooks-invoke response, and one
session-event assertion are realigned to match.
Docs are in `nodejs/docs/factories.md` and `nodejs/docs/factory-patterns.md`.
|
Latest CCR pass — took one, disproved the other. Taken ( The test asserts both halves, since either alone would be weak: expect(() => {
// @ts-expect-error handle.meta is deeply readonly.
handle.meta.name = "mutated";
}).toThrow(TypeError);The Declined — " |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (8)
nodejs/src/factory.ts:162
- The pipeline implementation propagates abort and hard RPC/connection failures rather than always converting a thrown stage to
null. The public JSDoc should state that exception.
/** Run each item through every stage without barriers between stages. */
pipeline(items: unknown[], ...stages: FactoryPipelineStage[]): Promise<unknown[]>;
nodejs/src/factory.ts:33
- This new public factory result type lacks the promised
@experimentalmarker, so generated API documentation can present it as stable while the rest of the surface warns that it may change.
export type FactoryRunResult = Omit<WireFactoryRunResult, "result"> & {
nodejs/src/factory.ts:19
FactoryRunResultis also returned bygetRun()forpendingandrunningruns, so describing it as terminal is inaccurate for consumers inspecting an in-flight run.
* The terminal envelope describing a factory run's outcome (status, result,
* reason). Re-exported so consumers can name the type returned by
* {@link SessionFactoryApi} methods.
nodejs/src/factory.ts:76
JsonValueis newly exported from both package entry points but is not marked@experimental, contrary to the stated contract that every public type in this surface carries that warning.
export type JsonValue =
nodejs/src/factory.ts:210
- The
FactoryHandleexperimental JSDoc is separated from the interface byDeepReadonly, so it attaches to that internal alias rather than the exported handle. Generated API docs therefore omit the experimental warning for this public type.
export interface FactoryHandle<
nodejs/src/factory.ts:156
- The runtime intentionally permits arbitrary values for
{ volatile: true }(the new test returns a function), but this signature always restricts producers and results toJsonValue. TypeScript authors therefore cannot use the supported volatile behavior without an unsafe cast. Add overloads that preserve a generic result forvolatile: truewhile retaining the JSON constraint for journaled steps.
step(
key: string,
producer: () => Promise<JsonValue> | JsonValue,
options?: FactoryStepOptions
): Promise<JsonValue>;
nodejs/src/factory.ts:160
- This public contract says every thrown thunk becomes
null, butrunFactoryParallelrethrows abort,ResponseError, andConnectionError. Document the distinction so callers do not treat hard runtime failures as per-item failures.
This issue also appears on line 161 of the same file.
/** Run thunks concurrently, returning null for a thunk that throws. */
parallel<TResult>(
thunks: Array<() => Promise<TResult> | TResult>
): Promise<Array<TResult | null>>;
nodejs/docs/factories.md:58
- These bullets say thrown thunks/stages become
nullexcept for cancellation, but the implementation also propagates hard runtimeResponseErrorandConnectionErrorfailures. Documenting those as item-levelnulloutcomes can cause authors to omit run-level error handling.
- `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.
- `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.
- Files reviewed: 15/15 changed files
- Comments generated: 0 new
- Review effort level: Medium
Correct the public contracts for `parallel` and `pipeline`. Both said a thrown thunk or stage becomes `null`, but cancellation and hard runtime failures (`ResponseError`, `ConnectionError`) propagate and reject the whole call — an author reading only the old wording would omit run-level error handling. Fixed in the JSDoc and in the guide. Restore the `@experimental` marker on `FactoryHandle`: the `DeepReadonly` alias added in the previous commit landed between the docblock and the interface, so the warning attached to the internal alias instead of the exported type. Describe `FactoryRunResult` as the run envelope rather than a terminal one, since `getRun` returns it for pending and running runs too, and give it the `@experimental` marker the rest of the surface carries.
|
Good round — took six of eight, including one regression I introduced last commit. Taken (
Declined (both previously argued)
Typecheck clean, 96 factory tests green, prettier clean. |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (3)
nodejs/src/session.ts:1
params.executionTokenis used as aMapkey and forwarded over RPC, but there’s no validation that it’s a non-empty string. If it’s missing/undefined, overlapping attempts can collide under the same key, and the forwarded RPC payload may be invalid depending on the wire schema. Recommend validatingexecutionTokenearly (and throwing aResponseError(ErrorCodes.InvalidParams, ...)when absent/invalid), or generating an internal unique token when missing while ensuring the RPC contract remains satisfied.
/*---------------------------------------------------------------------------------------------
nodejs/src/session.ts:1352
params.executionTokenis used as aMapkey and forwarded over RPC, but there’s no validation that it’s a non-empty string. If it’s missing/undefined, overlapping attempts can collide under the same key, and the forwarded RPC payload may be invalid depending on the wire schema. Recommend validatingexecutionTokenearly (and throwing aResponseError(ErrorCodes.InvalidParams, ...)when absent/invalid), or generating an internal unique token when missing while ensuring the RPC contract remains satisfied.
controllersForRun.set(params.executionToken, controller);
const progress = new FactoryProgressBuffer(async (lines) => {
await self.rpc.factory.log({
runId: params.runId,
executionToken: params.executionToken,
lines,
});
nodejs/src/types.ts:450
- This narrows
ToolTelemetryfromunknownvalues to strictlyJsonValue, which is an API-breaking type change for SDK consumers who may currently attach non-JSON values (e.g.Date,Error,bigint, class instances). If the intention is to guarantee telemetry is JSON-wire-safe, consider (mandatory) documenting this constraint prominently (and ideally validating/enforcing it at the serialization boundary), or (optional) keeping a broader input type and normalizing/serializing explicitly.
export type ToolTelemetry = Record<string, { [key: string]: JsonValue } | undefined>;
- Files reviewed: 15/15 changed files
- Comments generated: 3
- Review effort level: Low
This comment has been minimized.
This comment has been minimized.
Stop deep-cloning values through JSON on the way to the wire. The hooks-invoke response and the tool-result path were round-tripped through `JSON.parse(JSON.stringify(...))`, added only to satisfy the `JsonValue` tightening. That is a behaviour change for a typing reason: the round trip throws on a BigInt or a cycle, and silently rewrites `NaN`/`Infinity` to `null` and drops `undefined` members. Now that the generated types are back to their previous shapes, the `ToolTelemetry` and hooks-invoke signatures revert to `unknown` as well, both call sites return the original value as they did before, and the imports the round trips required are gone. `client.ts` and `types.ts` now carry only factory additions.
There was a problem hiding this comment.
Review details
Resolved since last review (3)
- ✅ nodejs/src/session.ts#L1075 — Resolved by @MRayermannMSFT - The tool-result path now deep clones via
JSON.parse(JSON.stringify(rawResult)), which can throw… - ✅ nodejs/src/client.ts#L3020 — Resolved by @MRayermannMSFT -
JSON.parse(JSON.stringify(output))can (a) throw at runtime (e.g. BigInt/cycles), and (b)… - ✅ nodejs/src/client.ts#L3005 — Resolved by @MRayermannMSFT -
JSON.parse(JSON.stringify(output))can (a) throw at runtime (e.g. BigInt/cycles), and (b)…
Comments suppressed due to low confidence (2)
nodejs/src/session.ts:1461
- If
definition.run(context)throws andprogress.close()also throws (e.g., due to a prior flush failure), thefinallyblock will mask the original factory error with the close/flush error. If preserving the factory failure is important for diagnostics, consider capturing the original exception, attemptingprogress.close()infinally, and then either rethrowing the original error (optionally logging/suppressing the close error) or combining both into anAggregateErrorso neither is lost.
const result = await definition.run(context);
if (result === undefined) {
return {};
}
assertFactoryResult(result);
return { result } as FactoryExecuteResult;
} finally {
try {
await progress.close();
} finally {
const controllersForRun = self.factoryAbortControllers.get(params.runId);
if (controllersForRun?.get(params.executionToken) === controller) {
controllersForRun.delete(params.executionToken);
if (controllersForRun.size === 0) {
self.factoryAbortControllers.delete(params.runId);
}
}
}
}
nodejs/src/factory.ts:85
- The
JsonValuedocstring claims “losslessly on the SDK JSON wire”, but the implementation enforces stricter runtime rules elsewhere (e.g., rejectingNaN/Infinityand-0). Since TypeScript cannot express “finite non-negative-zero numbers”, consider updating this comment (and any related docs) to explicitly note the additional constraints (finite numbers only;-0rejected; no accessors/non-plain objects, etc.) so callers don’t assume anynumberis accepted.
/** A value that can be represented losslessly on the SDK JSON wire. */
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
- Files reviewed: 15/15 changed files
- Comments generated: 2
- Review effort level: Low
This comment has been minimized.
This comment has been minimized.
Install the abort listener before dispatching a factory operation. `awaitFactoryOperation` evaluated `operation()` as the first element of the race, so the listener was only attached afterwards. The window is synchronous and hard to hit in practice, but it does not need to exist: an abort raised while the thunk is starting would find no listener and the race would then wait on an operation whose run had already been cancelled. The listener is now attached first, and the aborted check moved after it, so the ordering guarantees both properties directly — no abort can be missed, and an already-aborted run still never dispatches.
There was a problem hiding this comment.
Review details
Resolved since last review (2)
- ✅ nodejs/src/session.ts#L1353 — Resolved by @MRayermannMSFT -
params.executionTokenis used as aMapkey and is forwarded over RPC, but this handler doesn’t… - ✅ nodejs/src/session.ts#L330 — Resolved by @MRayermannMSFT -
operation()is invoked before the abort listener is registered, so an abort that lands after…
Comments suppressed due to low confidence (5)
nodejs/src/session.ts:2081
- Using
ErrorCodes.InternalError(-32603) for “result/step is not JSON” validation failures makes these look like server faults rather than a contract violation in extension-provided data. Consider usingErrorCodes.InvalidParams(-32602) (or a dedicated code if you have one) so callers can reliably distinguish validation failures from runtime/transport failures.
function strictJsonValidationError(
context: StrictJsonValidationContext,
category: FactoryResultValidationCategory,
message: string,
path: string
): ResponseError<{ code: string; category: FactoryResultValidationCategory; path: string }> {
return new ResponseError(ErrorCodes.InternalError, message, {
code: context.code,
category,
path,
});
nodejs/src/session.ts:158
- The thrown message is identical for two distinct invalid inputs and is also inaccurate when the value is neither an array nor a promise (or when an element is a non-function value that isn’t a promise). Suggest splitting these into more precise diagnostics (e.g., “expects an array” vs “array must contain functions”) and keeping the “not promises” guidance only for the “array elements are promises” case.
async function runFactoryParallel<TResult>(
thunks: Array<() => Promise<TResult> | TResult>
): Promise<Array<TResult | null>> {
if (!Array.isArray(thunks)) {
throw new Error(
"parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)"
);
}
assertFactoryFanoutSize("parallel", thunks.length);
if (thunks.some((thunk) => typeof thunk !== "function")) {
throw new Error(
"parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)"
);
}
nodejs/test/factory.test.ts:493
Promise.withResolversrequires relatively new Node.js runtimes (Node 20+). If this repo/test matrix still supports older Node versions, these tests will fail at runtime; consider using a small local helper to create{ promise, resolve, reject }vianew Promise(...)(or a Vitest utility) to keep compatibility.
const contextSeen = Promise.withResolvers<{
runId: string;
args: unknown;
session: CopilotSession;
signal: AbortSignal;
}>();
nodejs/src/factory.ts:448
structuredCloneis only available in newer Node.js versions. If the SDK supports older Node runtimes, this will throw at runtime during factory registration. Consider either documenting a minimum Node version for the extension SDK, or adding a fallback cloning strategy (with known limitations) whenstructuredCloneis unavailable.
export function defineFactory<
TArgs extends JsonValue = JsonValue,
TResult extends JsonValue | void = JsonValue | void,
>(definition: FactoryDefinition<TArgs, TResult>): FactoryHandle<TArgs, TResult> {
// Snapshot before validating so post-registration mutation of the caller's
// object cannot slip past the authoring-boundary checks.
const meta = deepFreeze(structuredClone(definition.meta));
validateLimits(meta);
nodejs/src/session.ts:2145
- This branch appears unreachable given the preceding type checks (null/boolean/string/number/function/symbol/bigint are all handled earlier). Keeping unreachable code with a misleading message makes the validator harder to reason about; consider removing it or converting it to an assertion with an “unreachable” message.
if (typeof current !== "object") {
throw strictJsonValidationError(
context,
"unsupported_type",
`${context.label} contains a function, symbol, or BigInt at ${path}`,
path
);
}
- Files reviewed: 15/15 changed files
- Comments generated: 0 new
- Review effort level: Low
Cross-SDK Consistency ReviewThis PR adds the Agent Factories authoring surface exclusively to the Node.js/TypeScript SDK ( FindingsNo inconsistency issues at this time. The feature is:
None of the other SDKs (Python, Go, .NET, Java, Rust) currently expose any Agent Factory authoring surface, so this PR doesn't create a regression — it introduces the first implementation. Consistency suggestion (non-blocking)Once the Node.js implementation stabilizes, consider tracking parity work for the remaining SDKs. The key public API surface to port would be:
No action required on this PR — just flagging for future parity tracking.
|
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
nodejs/src/session.ts:158
- The thrown message is misleading for the
!Array.isArray(thunks)case (the problem is “not an array”, not “promises”), and it’s also misleading for the “array contains non-functions” case (could be numbers/objects/etc., not necessarily promises). Consider splitting these into distinct diagnostics (e.g., “parallel() expects an array” vs “parallel() expects an array of functions”) so callers get actionable feedback.
if (!Array.isArray(thunks)) {
throw new Error(
"parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)"
);
}
assertFactoryFanoutSize("parallel", thunks.length);
if (thunks.some((thunk) => typeof thunk !== "function")) {
throw new Error(
"parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)"
);
}
nodejs/src/session.ts:2145
- This branch appears to be unreachable given the preceding type checks (all other
typeofcases are already handled), and the message is also inaccurate for a generic “not object” case. Either remove the dead branch or replace it with a message that reflects the actual unsupported type (e.g., includetypeof current) to keep the validator easier to reason about and the diagnostics accurate.
if (typeof current !== "object") {
throw strictJsonValidationError(
context,
"unsupported_type",
`${context.label} contains a function, symbol, or BigInt at ${path}`,
path
);
}
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Low
What
Adds the Agent Factories authoring surface to the Node.js SDK. A factory is a trusted extension-authored closure that orchestrates a fleet of subagents and is invoked by name: an extension declares one with
defineFactory, registers it throughjoinSession({ factories }), and runs it withsession.factory.run(...). Only metadata crosses to the runtime — the closure stays in the extension process and is invoked back over a reverse RPC, where it gets primitives for spawning subagents, journaling results so a resumed run replays completed work for free, composing work in parallel or as a pipeline, and reporting progress.Runs resolve with a durable envelope for every outcome rather than throwing, and
waitForRunsettles once a run reaches a terminal status, so callers stay correct when execution later moves to background-only. The one generator change narrows an over-broad opaque-JSON conversion; without it a factory result generates as an object alone, which a non-object or void result cannot satisfy.Everything is dark behind the runtime's
agent_factoriesflag plus its billing gate, and every public type is marked@experimental.Why
The runtime half of Agent Factories already merged (
github/copilot-agent-runtime#12953and#13077), but the SDK half has never landed — the original PR was closed unmerged — so the shipped runtime currently has no authoring client and the feature cannot work end to end for any SDK consumer. Until this lands, the factory E2E suite can only run against a locally injected SDK build rather than the published package.