diff --git a/.changeset/spawn-inherit-thinking-level.md b/.changeset/spawn-inherit-thinking-level.md new file mode 100644 index 0000000..0903d4d --- /dev/null +++ b/.changeset/spawn-inherit-thinking-level.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Sessions started via `spawn_session` and `spawn_subsession` now inherit the spawning session's thinking level instead of falling back to the pi default, clamped to the child model's capabilities. diff --git a/docs/config.html b/docs/config.html index 17314f4..3ba1ede 100644 --- a/docs/config.html +++ b/docs/config.html @@ -813,7 +813,8 @@ inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A #provider/model-id reference in the prompt (see Prompt completions) is how users ask for a specific model; agents - forward that reference as this parameter. + forward that reference as this parameter. The new session also inherits the dispatching session's + thinking level, clamped to its model's capabilities.

In Settings → Session daemon, these keys are saved on the selected machine. Restart the diff --git a/docs/config.md b/docs/config.md index e9d7fe8..b15c89b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -273,7 +273,7 @@ A completion notice wakes an idle parent or queues behind in-flight work. Each n `list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. Included output and transcripts follow a labeled marker and come last, after PI WEB guidance. -Both `spawn_session` and `spawn_subsession` accept an optional `model` parameter, given as an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`. When set, the new session starts on that model instead of inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A `#provider/model-id` reference in the prompt (see [Prompt completions](#prompt-completions)) is how users ask for a specific model; agents forward that reference as this parameter. +Both `spawn_session` and `spawn_subsession` accept an optional `model` parameter, given as an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`. When set, the new session starts on that model instead of inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A `#provider/model-id` reference in the prompt (see [Prompt completions](#prompt-completions)) is how users ask for a specific model; agents forward that reference as this parameter. The new session also inherits the dispatching session's thinking level, clamped to its model's capabilities. In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts index 439f724..47cfecb 100644 --- a/src/server/sessions/piSessionService.spawnSession.test.ts +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -61,6 +61,52 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("passes the dispatching session's thinking level to the spawned session's runtime", async () => { + const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); + let initialThinkingLevel: unknown; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialThinkingLevel = options.initialThinkingLevel; + return fake.runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime, + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", thinkingLevel: "high" }); + + expect(initialThinkingLevel).toBe("high"); + await service.dispose(); + }); + + it("leaves the spawned session's thinking level to pi defaults when the dispatcher has none", async () => { + const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); + let initialThinkingLevel: unknown = "unset"; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialThinkingLevel = options.initialThinkingLevel; + return fake.runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime, + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" }); + + expect(initialThinkingLevel).toBeUndefined(); + await service.dispose(); + }); + it("names the spawned session's model in the result", async () => { const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() }); const service = new PiSessionService(new CapturingSessionEventHub(), { diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index 4a43468..e28de31 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -64,17 +64,19 @@ describe("PiSessionService", () => { await service.dispose(); }); - it("uses the parent model and disables delegation before creating the tracked child runtime", async () => { + it("uses the parent model and thinking level and disables delegation before creating the tracked child runtime", async () => { const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); const model = testModel(); const initialModels: PiAgentSession["model"][] = []; + const initialThinkingLevels: unknown[] = []; const delegationCapabilities: boolean[] = []; const runtimes = [parent.runtime, child.runtime]; let index = 0; const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { await Promise.resolve(); initialModels.push(options.initialModel); + initialThinkingLevels.push(options.initialThinkingLevel); delegationCapabilities.push(options.delegationToolsEnabled); const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -91,9 +93,10 @@ describe("PiSessionService", () => { }); await service.start("/workspace"); - await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model }); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model, thinkingLevel: "max" }); expect(initialModels).toEqual([undefined, model]); + expect(initialThinkingLevels).toEqual([undefined, "max"]); expect(delegationCapabilities).toEqual([true, false]); await service.dispose(); }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index d85de27..5c93b3a 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -243,6 +243,11 @@ type SessionCreationProvenance = "tracked-subsession"; interface StartSessionOptions { parentSession?: string; initialModel?: AgentModel; + /** + * Thinking level for the brand new session; omit to resolve from settings + * and pi defaults. Pi clamps it to the initial model's capabilities. + */ + initialThinkingLevel?: ClientThinkingLevel; /** * Opaque label, echoed on this construction's startup progress so a browser * row with no session id yet can recognise its own. @@ -439,7 +444,7 @@ interface PendingSessionOpen { promise: Promise>; } -interface CreateSessionRuntimeOptions extends Pick { +interface CreateSessionRuntimeOptions extends Pick { notificationGeneration?: SessionNotificationGeneration; notifications?: "enabled" | "disabled"; /** @@ -607,11 +612,13 @@ interface CreateAgentRuntimeOptions { sessionManager: PiSessionManager; delegationToolsEnabled: boolean; initialModel?: AgentModel; + initialThinkingLevel?: ClientThinkingLevel; } type PiWebRuntimeFactoryOptions = Parameters[0] & { delegationToolsEnabled?: boolean; initialModel?: AgentModel; + initialThinkingLevel?: ClientThinkingLevel; }; type PiWebCreateAgentSessionRuntimeFactory = ( @@ -622,7 +629,7 @@ type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory, function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise { if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager"); - const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled); + const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled); return createAgentSessionRuntime(runtimeFactory, { cwd: options.cwd, agentDir: options.agentDir, @@ -633,20 +640,26 @@ function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntime function createRuntimeWithOneShotSessionOptions( createRuntime: PiWebCreateAgentSessionRuntimeFactory, initialModel: AgentModel | undefined, + initialThinkingLevel: ClientThinkingLevel | undefined, delegationToolsEnabled: boolean, ): CreateAgentSessionRuntimeFactory { // These inputs belong only to the session being opened. A later runtime - // replacement resolves its own model and delegation capability. + // replacement resolves its own model and delegation capability, and restores + // the thinking level from the existing session file. let pendingInitialModel = initialModel; + let pendingInitialThinkingLevel = initialThinkingLevel; let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled; return async (options) => { const model = pendingInitialModel; + const thinkingLevel = pendingInitialThinkingLevel; const toolsEnabled = pendingDelegationToolsEnabled; pendingInitialModel = undefined; + pendingInitialThinkingLevel = undefined; pendingDelegationToolsEnabled = undefined; return createRuntime({ ...options, ...(model === undefined ? {} : { initialModel: model }), + ...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }), ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }), }); }; @@ -678,7 +691,7 @@ function createDefaultRuntimeFactory( subsessions?: SubsessionToolDeps, askUser?: AskUserToolDeps, ): PiWebCreateAgentSessionRuntimeFactory { - return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { + return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => { const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime }); const resolvedDelegationToolsEnabled = delegationToolsEnabled ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); @@ -689,6 +702,7 @@ function createDefaultRuntimeFactory( customTools, ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }), ...(initialModel === undefined ? {} : { model: initialModel }), + ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }), }); return { ...result, services, diagnostics: services.diagnostics }; }; @@ -1170,6 +1184,7 @@ export class PiSessionService implements SessionRouteService { startupIntent: "create", ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }), ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), + ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }), ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }), }, ); @@ -1207,7 +1222,10 @@ export class PiSessionService implements SessionRouteService { const model = input.modelSpec === undefined ? input.model : await this.resolveSpawnModel(input.spawningSessionId, input.modelSpec); - const created = await this.start(decision.cwd, model === undefined ? {} : { initialModel: model }); + const created = await this.start(decision.cwd, { + ...(model === undefined ? {} : { initialModel: model }), + ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }), + }); const modelUsed = this.active.get(created.id)?.runtime.session.model; await this.prompt(created.id, input.prompt); this.logger.info( @@ -1239,6 +1257,7 @@ export class PiSessionService implements SessionRouteService { const created = await this.startSession(decision.cwd, { ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }), ...(model === undefined ? {} : { initialModel: model }), + ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }), creationProvenance: "tracked-subsession", }); const modelUsed = this.active.get(created.id)?.runtime.session.model; @@ -2864,6 +2883,7 @@ export class PiSessionService implements SessionRouteService { sessionManager, delegationToolsEnabled, ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), + ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }), }); const active: ActiveSession = { runtime, unsubscribe: noop }; let boundSession = runtime.session; diff --git a/src/server/sessions/spawnSessionTool.test.ts b/src/server/sessions/spawnSessionTool.test.ts index 58dc660..5f03ad4 100644 --- a/src/server/sessions/spawnSessionTool.test.ts +++ b/src/server/sessions/spawnSessionTool.test.ts @@ -4,21 +4,21 @@ import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js"; const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; -function ctxFor(sessionId: string, model?: unknown): ExtensionContext { +function ctxFor(sessionId: string, model?: unknown, thinkingLevel?: string): ExtensionContext { const sessionManager = { getSessionId: () => sessionId }; - // The spawn tool only reads sessionManager.getSessionId and model. + // The spawn tool only reads sessionManager.getSessionId, model, and thinkingLevel. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads. - return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext; + return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext; } describe("createSpawnSessionToolDefinition", () => { - it("passes the spawning identity, explicit cwd, dispatching model, and prompt to spawn callback", async () => { + it("passes the spawning identity, explicit cwd, dispatching model, thinking level, and prompt to spawn callback", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); - const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel)); + const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel, "high")); - expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel }); + expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel, thinkingLevel: "high" }); expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" }); expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-1 in /repos/a-feature." }); }); @@ -31,7 +31,7 @@ describe("createSpawnSessionToolDefinition", () => { expect(tool.description).not.toMatch(/subsession|child|parent/i); }); - it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => { + it("forwards omitted cwd as undefined and omits a missing dispatching model and thinking level", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); diff --git a/src/server/sessions/spawnSessionTool.ts b/src/server/sessions/spawnSessionTool.ts index 45c81e5..0c85e57 100644 --- a/src/server/sessions/spawnSessionTool.ts +++ b/src/server/sessions/spawnSessionTool.ts @@ -9,6 +9,7 @@ export interface SpawnSessionResult { } export type SpawnSessionModel = NonNullable; +export type SpawnSessionThinkingLevel = NonNullable; export interface SpawnSessionInvocation { spawningCwd: string; @@ -20,6 +21,8 @@ export interface SpawnSessionInvocation { model?: SpawnSessionModel; /** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */ modelSpec?: string; + /** Dispatching session's current thinking level, inherited by the spawned session (pi clamps it to the spawned model's capabilities). */ + thinkingLevel?: SpawnSessionThinkingLevel; } export interface SpawnSessionToolDeps { @@ -64,6 +67,7 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw cwd: params.cwd, ...(ctx.model === undefined ? {} : { model: ctx.model }), ...(params.model === undefined ? {} : { modelSpec: params.model }), + ...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }), }); const modelNote = result.model === undefined ? "" : ` using model ${result.model}`; return { diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index da1f221..0dec9ff 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -5,11 +5,11 @@ import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spaw const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; -function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext { +function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown, thinkingLevel?: string): ExtensionContext { const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile }; - // The subsession tools only read sessionManager.getSessionId/getSessionFile and model. + // The subsession tools only read sessionManager.getSessionId/getSessionFile, model, and thinkingLevel. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use. - return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext; + return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext; } function tools(deps: Partial) { @@ -48,7 +48,7 @@ describe("createSubsessionToolDefinitions", () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })); const { spawn: spawnTool } = tools({ spawn }); - const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel)); + const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel, "max")); expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", @@ -57,6 +57,7 @@ describe("createSubsessionToolDefinitions", () => { prompt: "do it", cwd: "/repos/a-feature", model: dispatchModel, + thinkingLevel: "max", }); expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" }); expect(firstText(result.content)).toContain("Started tracked subsession child-1"); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 173679b..4915d21 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -13,6 +13,7 @@ export interface SpawnSubsessionResult { } export type SpawnSubsessionModel = NonNullable; +export type SpawnSubsessionThinkingLevel = NonNullable; export interface SpawnSubsessionInvocation { /** cwd of the session that invoked the tool (used for project-scope checks). */ @@ -27,6 +28,8 @@ export interface SpawnSubsessionInvocation { model?: SpawnSubsessionModel; /** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */ modelSpec?: string; + /** Parent's current thinking level, inherited by the child session (pi clamps it to the child model's capabilities). */ + thinkingLevel?: SpawnSubsessionThinkingLevel; } export interface SubsessionSummary { @@ -205,6 +208,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse cwd: params.cwd, ...(ctx.model === undefined ? {} : { model: ctx.model }), ...(params.model === undefined ? {} : { modelSpec: params.model }), + ...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }), }); const modelNote = result.model === undefined ? "" : ` using model ${result.model}`; return {