diff --git a/CHANGELOG.md b/CHANGELOG.md index d695ed6fb..9d579216d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu ## [Unreleased] +### Feature: model policy for built-in sub-agents (`subagentModel`) + +Session create and resume accept a new optional `subagentModel` option that configures which model the runtime's built-in `task` sub-agents (e.g. `explore`, `general-purpose`) use, addressing [github/copilot-sdk#1640](https://github.com/github/copilot-sdk/issues/1640). It is built entirely on the CLI's existing (experimental) subagent settings mechanism (`session.tools.updateSubagentSettings` / `SubagentSettingsEntry`): the SDK issues that call automatically right after the session is created, for the agent types you name. + +Two policy modes are available: `{ mode: "inherit-parent", agentTypes }` applies the session's own `model` to the listed built-in agent types (requires `model` to be set), and `{ mode: "fixed", model, agentTypes }` applies an explicit model. Because the runtime's `agent_type` values are open-ended and not enumerated by the wire protocol, there is no wildcard for "all built-ins" — name the agent types you want affected. Omitting `subagentModel` entirely makes no settings call and preserves the runtime's existing model-selection behavior for built-in sub-agents exactly as before. + +This does not change custom agents, which already accept their own `model` via `CustomAgentConfig`. Currently implemented for the Node.js SDK; the underlying `updateSubagentSettings` RPC is already generated for every SDK, so the same convenience wrapper can be ported to the other language bindings following this pattern. + ### Feature: rotating session-scoped GitHub credentials All six SDKs can now acquire short-lived GitHub credentials through a session-scoped callback. The SDK registers the callback before session create or resume, maps `initial` and `refresh` requests to the owning session, and removes registrations on rollback, replacement, session close, and client close. Static per-session `gitHubToken` credentials remain supported and are mutually exclusive with the callback. diff --git a/docs/features/custom-agents.md b/docs/features/custom-agents.md index 9e2f59768..7286afd8b 100644 --- a/docs/features/custom-agents.md +++ b/docs/features/custom-agents.md @@ -266,6 +266,34 @@ In addition to per-agent configuration above, you can set `agent` on the **sessi | Session Config Property | Type | Description | |-------------------------|------|-------------| | `agent` | `string` | Name of the custom agent to pre-select at session creation. Must match a `name` in `customAgents`. | +| `subagentModel` | `SubagentModelPolicy` | Model policy for the runtime's **built-in** sub-agents (e.g. `explore`, `general-purpose`). See [Model policy for built-in sub-agents](#model-policy-for-built-in-sub-agents) below. | + +## Model policy for built-in sub-agents + +`model` and `reasoningEffort` on a `customAgents` entry only affect agents *you* define. The runtime's own **built-in** sub-agents — `explore`, `general-purpose`, and any others spawned by the built-in `task` tool — historically had no supported way to be pointed at the parent session's model or an explicit model (see [github/copilot-sdk#1640](https://github.com/github/copilot-sdk/issues/1640)). + +`subagentModel` closes that gap for the Node.js SDK by wrapping the CLI's existing (experimental) subagent settings mechanism (`session.tools.updateSubagentSettings`). The SDK calls it automatically right after the session is created: + + +```typescript +const session = await client.createSession({ + model: "claude-sonnet-4.5", + subagentModel: { + mode: "inherit-parent", + agentTypes: ["explore", "general-purpose"], + }, + onPermissionRequest: async () => ({ kind: "approve-once" }), +}); +``` + +- `{ mode: "inherit-parent", agentTypes }` — use this session's `model` for the listed built-in agent types. Throws if the session's `model` is not set. +- `{ mode: "fixed", model, agentTypes }` — use an explicit model (and optional `reasoningEffort`) for the listed built-in agent types. +- Omit `subagentModel` entirely to preserve the runtime's existing model-selection behavior for built-in sub-agents, unchanged. + +There is intentionally no wildcard for "all built-in sub-agents": the runtime's `agent_type` values are open-ended strings that aren't enumerated by the wire protocol, so name the agent types you want to affect. This option does not affect custom agents (use their own `model`/`reasoningEffort` fields instead) and does not change the parent session's model. + +> [!NOTE] +> This SDK-side wrapper is currently available for the Node.js SDK only. The underlying `session.tools.updateSubagentSettings` RPC is generated for every language binding, so the same convenience wrapper can be added to the other SDKs following this pattern; see the CHANGELOG for details. ## Per-agent skills diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9b853aa59..c1546f0bc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1512,6 +1512,58 @@ export class CopilotClient { } } + /** + * Applies {@link SessionConfigBase.subagentModel}, if configured, by + * translating it into a call to the runtime's existing (experimental) + * `session.tools.updateSubagentSettings` RPC. No call is made when the + * policy is omitted, preserving the runtime's default behavior exactly. + */ + private async applySubagentModelPolicy( + session: CopilotSession, + config: SessionConfigBase + ): Promise { + const policy = config.subagentModel; + if (policy === undefined) { + return; + } + if (policy.agentTypes.length === 0) { + throw new Error("subagentModel.agentTypes must include at least one agent type"); + } + + let model: string; + if (policy.mode === "inherit-parent") { + if (!config.model) { + throw new Error( + 'subagentModel: mode "inherit-parent" requires config.model to be set' + ); + } + model = config.model; + } else { + model = policy.model; + } + + const agents: Record = {}; + for (const agentType of policy.agentTypes) { + agents[agentType] = { + model, + ...(policy.reasoningEffort !== undefined + ? { effortLevel: policy.reasoningEffort } + : {}), + }; + } + + try { + await session.rpc.tools.updateSubagentSettings({ subagents: { agents } }); + } catch (e) { + try { + await session.disconnect(); + } catch { + // Swallow: original error is the one the caller needs. + } + throw e; + } + } + async createSession(config: SessionConfig): Promise { if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) { throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); @@ -1759,6 +1811,7 @@ export class CopilotClient { session.setCapabilities(capabilities); await this.updateSessionOptionsForMode(session, config); + await this.applySubagentModelPolicy(session, config); this.commitGitHubTokenProvider(returnedSessionId, gitHubTokenProviderRegistrationId); } catch (e) { if (registeredId !== undefined) { @@ -2035,6 +2088,7 @@ export class CopilotClient { } await this.updateSessionOptionsForMode(session, config); + await this.applySubagentModelPolicy(session, config); this.commitGitHubTokenProvider(sessionId, gitHubTokenProviderRegistrationId); } catch (e) { this.sessions.delete(sessionId); diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 616e15a46..972845bad 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1886,6 +1886,35 @@ export interface DefaultAgentConfig { excludedTools?: string[]; } +/** + * Model policy for the runtime's built-in sub-agents (spawned by the + * built-in `task` tool, e.g. `explore`, `general-purpose`). + * + * - `{ mode: "inherit-parent" }`: use this session's {@link SessionConfigBase.model} + * for the listed {@link agentTypes}. Requires `model` to be set on the session; + * throws if it is omitted. + * - `{ mode: "fixed", model }`: use an explicit model for the listed {@link agentTypes}. + * - Omitted entirely (the default): preserve the runtime's existing + * model-selection behavior for built-in sub-agents. + */ +export type SubagentModelPolicy = + | { + mode: "inherit-parent"; + /** Built-in sub-agent types to apply this policy to (e.g. `["explore", "general-purpose"]`). */ + agentTypes: string[]; + /** Reasoning effort override forwarded alongside the inherited model, when supported. */ + reasoningEffort?: ReasoningEffort; + } + | { + mode: "fixed"; + /** Model identifier to use for the listed built-in sub-agent types. */ + model: string; + /** Built-in sub-agent types to apply this policy to (e.g. `["explore", "general-purpose"]`). */ + agentTypes: string[]; + /** Reasoning effort override for the fixed model, when supported. */ + reasoningEffort?: ReasoningEffort; + }; + /** * Configuration for infinite sessions with automatic context compaction and workspace persistence. * When enabled, sessions automatically manage context window limits through background compaction @@ -2425,6 +2454,29 @@ export interface SessionConfigBase { */ excludedBuiltinAgents?: string[]; + /** + * Model policy applied to built-in sub-agents (e.g. `explore`, + * `general-purpose`) that the runtime's built-in `task` tool spawns. + * + * This does not affect custom agents, which already accept their own + * {@link CustomAgentConfig.model}. It also does not change the parent + * session's model. + * + * Implemented on top of the runtime's existing (experimental) subagent + * settings mechanism: after the session is created, the SDK calls + * `session.rpc.tools.updateSubagentSettings` for each agent type listed + * in {@link SubagentModelPolicy.agentTypes}. There is currently no + * wildcard to target "all built-in sub-agents" — the runtime's + * `agent_type` values are open-ended and not enumerated by the protocol, + * so callers must name the built-in agent types they want to affect + * (for example `["explore", "general-purpose"]`). + * + * When omitted, no settings call is made and the runtime's existing + * model-selection behavior for built-in sub-agents is preserved exactly + * as before. + */ + subagentModel?: SubagentModelPolicy; + /** * Built-in skill names to include in the session. In `mode: "empty"`, * omitting this option excludes all runtime-bundled skills; specifying names diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3ffda2fa7..cbd430ada 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -439,6 +439,127 @@ describe("CopilotClient", () => { ); }); + it("applies inherit-parent subagentModel by forwarding the session model to updateSubagentSettings", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + return { sessionId: params.sessionId, workspacePath: "/workspace" }; + } + if (method === "session.tools.updateSubagentSettings") { + return {}; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + sessionId: "create-with-subagent-model-policy", + model: "claude-sonnet-4.5", + subagentModel: { mode: "inherit-parent", agentTypes: ["explore", "general-purpose"] }, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith("session.tools.updateSubagentSettings", { + sessionId: "create-with-subagent-model-policy", + subagents: { + agents: { + explore: { model: "claude-sonnet-4.5" }, + "general-purpose": { model: "claude-sonnet-4.5" }, + }, + }, + }); + }); + + it("applies fixed subagentModel with an explicit model and reasoning effort", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + return { sessionId: params.sessionId, workspacePath: "/workspace" }; + } + if (method === "session.tools.updateSubagentSettings") { + return {}; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + sessionId: "create-with-fixed-subagent-model-policy", + subagentModel: { + mode: "fixed", + model: "claude-haiku-4.5", + agentTypes: ["explore"], + reasoningEffort: "low", + }, + onPermissionRequest: approveAll, + }); + + expect(spy).toHaveBeenCalledWith("session.tools.updateSubagentSettings", { + sessionId: "create-with-fixed-subagent-model-policy", + subagents: { + agents: { + explore: { model: "claude-haiku-4.5", effortLevel: "low" }, + }, + }, + }); + }); + + it("omits any updateSubagentSettings call when subagentModel is not configured", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") { + return { sessionId: params.sessionId, workspacePath: "/workspace" }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + sessionId: "create-without-subagent-model-policy", + onPermissionRequest: approveAll, + }); + + expect(spy).not.toHaveBeenCalledWith( + "session.tools.updateSubagentSettings", + expect.anything() + ); + }); + + it("rejects inherit-parent subagentModel when the session model is not set", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string, params: any) => { + if (method === "session.create") { + return { sessionId: params.sessionId, workspacePath: "/workspace" }; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + + await expect( + client.createSession({ + sessionId: "create-with-invalid-subagent-model-policy", + subagentModel: { mode: "inherit-parent", agentTypes: ["explore"] }, + onPermissionRequest: approveAll, + }) + ).rejects.toThrow(/inherit-parent.*requires config\.model/); + }); + it("registers MCP OAuth interest after cloud create only when an auth handler is configured", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 85137e0ff..e0ad20e93 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -549,6 +549,34 @@ describe("Session Configuration", async () => { await session1.disconnect(); }); + it("should apply an inherit-parent subagentModel policy on create without error", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + subagentModel: { + mode: "inherit-parent", + agentTypes: ["explore", "general-purpose"], + }, + }); + + await session.disconnect(); + }); + + it("should apply a fixed subagentModel policy on resume without error", async () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const session2 = await client.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + subagentModel: { + mode: "fixed", + model: "claude-haiku-4.5", + agentTypes: ["explore"], + }, + }); + + await session2.disconnect(); + await session1.disconnect(); + }); + it("should enable citations for Anthropic file attachments on create", async () => { const handler = new RecordingRequestHandler(); const citationClient = new CopilotClient({