diff --git a/docs/providers.md b/docs/providers.md index 2c954657b7..4858ec0537 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -41,7 +41,9 @@ or without a control. differs from what it was given, including a setting nobody asked for (a native default such as Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the daemon stores it before the agent is published. A reopened agent resumes that state, so a - native default the provider later changes does not follow. + native default the provider later changes does not follow. `options` on the snapshot is what + the session opened with, and afterwards it moves only through an apply or an `options_changed`. + Editing a provider default in `config.json` affects agents the next time they open. - **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the `config.json` defaults. A removal arrives as the default the key falls back to, or `null` when there is none; the provider returns the setting to its own default if it can, or throws to diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3278cbb631..a9236dee0f 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12939,6 +12939,22 @@ describe("agent options", () => { expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true }); }); + test("editing a provider default does not change the options of a loaded agent", async () => { + const { manager, client, agentId } = await createOptionsAgent({ fast_mode: true }); + expect(manager.getAgent(agentId)?.effectiveOptions).toEqual({ + service_tier: "default", + fast_mode: true, + }); + + // The user edits the default in config.json while the agent is running. + client.effectiveOptions = (own) => ({ service_tier: "fast", ...own }); + await manager.markAgentUnread(agentId); + + const agent = manager.getAgent(agentId); + expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true }); + expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true }); + }); + test("a provider that rejects an option leaves the stored options unchanged", async () => { const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false }); client.session!.rejectWith = new Error("fast mode unavailable"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 7a76b552b0..71fe269e47 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2284,6 +2284,7 @@ export class AgentManager { async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise { const agent = this.requireAgent(agentId); let ended: ProviderOptionsPatch = {}; + let resolvedForApply: ProviderOptions = {}; if (Object.keys(patch).length > 0) { // A provider that cannot apply options to a running agent must not store them either. if (!agent.session.applyOptions) { @@ -2297,6 +2298,7 @@ export class AgentManager { } const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null)); const resolved = this.effectiveOptionsFor(agent.provider, set); + resolvedForApply = resolved; ended = (await agent.session.applyOptions( Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null])), @@ -2305,6 +2307,7 @@ export class AgentManager { await this.drainSessionEvents(agentId); } this.storeOptionsPatch(agent, { ...patch, ...ended }); + this.updateEffectiveOptions(agent, { ...patch, ...ended }, resolvedForApply); this.touchUpdatedAt(agent); this.emitState(agent); } @@ -2322,6 +2325,24 @@ export class AgentManager { agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined; } + /** + * What the running session has: the options it opened with, moved only by an apply or a change + * the provider reports. A removal shows the default it fell back to, as the provider was told. + */ + private updateEffectiveOptions( + agent: ManagedAgent, + patch: ProviderOptionsPatch, + fallbacks: ProviderOptions = {}, + ): void { + const moved = Object.fromEntries( + Object.entries(patch).map(([key, value]) => [key, value ?? fallbacks[key] ?? null]), + ); + agent.effectiveOptions = applyProviderOptionsPatch({ + options: agent.effectiveOptions, + patch: moved, + }); + } + private effectiveOptionsFor( provider: AgentProvider, own: ProviderOptions | undefined, @@ -4068,6 +4089,8 @@ export class AgentManager { session, capabilities: session.capabilities, config, + // What the session opened with. Provider defaults edited later reach it when it next opens. + effectiveOptions: this.effectiveOptionsFor(config.provider, config.providerOptions), runtimeInfo: undefined, lifecycle: "initializing", createdAt: options?.createdAt ?? now, @@ -4726,6 +4749,7 @@ export class AgentManager { }): void { const { agent, patch, flags } = params; this.storeOptionsPatch(agent, patch); + this.updateEffectiveOptions(agent, patch); flags.shouldDispatchEvent = false; this.emitState(agent); } @@ -5285,7 +5309,6 @@ export class AgentManager { if ("session" in agent && agent.session?.controls) { agent.controls = agent.session.controls; } - agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions); } private checkAndSetAttention(agent: ManagedAgent): void {