diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 99bb95d83c65..c2f8fca0614e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -476,6 +476,130 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("passes the configured advisor model to Claude", () => { + const harness = makeHarness({ claudeConfig: { advisorModel: "opus" } }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const options = harness.getLastCreateQueryInput()?.options; + assert.deepEqual(options?.settings, { advisorModel: "opus" }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("omits the advisor model when the setting is empty", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const options = harness.getLastCreateQueryInput()?.options; + assert.equal(options?.settings, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("completes the advisor item when the advisor result block arrives", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-0", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "server_tool_use", + id: "srvtoolu-advisor-1", + name: "advisor", + input: {}, + }, + }, + } as unknown as SDKMessage); + + // The advisor result lands on its own block index and names the tool it + // closes, so completion cannot key off the stream index. + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-1", + uuid: "stream-1", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "advisor_tool_result", + tool_use_id: "srvtoolu-advisor-1", + content: { + type: "advisor_redacted_result", + encrypted_content: "opaque", + }, + }, + }, + } as unknown as SDKMessage); + + // No result message: the consult must close on its own result block. The + // end-of-turn sweep that completes abandoned tool items never runs here, + // so this fiber only joins if the advisor block did the work. + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const advisorStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(advisorStarted?.type, "item.started"); + if (advisorStarted?.type === "item.started") { + assert.equal(String(advisorStarted.itemId), "srvtoolu-advisor-1"); + assert.equal(advisorStarted.payload.title, "Advisor consult"); + assert.equal(advisorStarted.payload.detail, undefined); + } + + const advisorCompletions = runtimeEvents.filter( + (event) => event.type === "item.completed" && String(event.itemId) === "srvtoolu-advisor-1", + ); + assert.equal(advisorCompletions.length, 1); + const advisorCompleted = advisorCompletions[0]; + assert.equal(advisorCompleted?.type, "item.completed"); + if (advisorCompleted?.type === "item.completed") { + assert.equal(advisorCompleted.payload.status, "completed"); + assert.equal(advisorCompleted.payload.title, "Advisor consult"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5848bcc457de..1eb02df386b2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1228,6 +1228,24 @@ function summarizeToolRequest(toolName: string, input: Record): return `${toolName}: ${serialized.slice(0, 397)}...`; } +/** + * The advisor is a server-side tool: Claude consults a second model mid-turn. + * It arrives as a `server_tool_use` block named `advisor` and its result comes + * back in the assistant stream, never as a `tool_result` user message. + */ +function isAdvisorToolName(toolName: string): boolean { + return toolName.toLowerCase() === "advisor"; +} + +/** The advisor result block, which closes the advisor tool item it names. */ +function isAdvisorResultBlock(block: unknown): block is { readonly tool_use_id?: unknown } { + return ( + typeof block === "object" && + block !== null && + (block as { readonly type?: unknown }).type === "advisor_tool_result" + ); +} + function titleForTool(itemType: CanonicalItemType): string { switch (itemType) { case "command_execution": @@ -2471,11 +2489,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Drop only the subagent's narration (text/thinking); tool_use blocks // and their input_json_delta frames must flow so attributed tool items // keep their inputs (review finding: dropping deltas emptied inputs). + // The advisor result block flows for the same reason: it is what closes + // the subagent's advisor tool item. const dropStart = event.type === "content_block_start" && event.content_block.type !== "tool_use" && event.content_block.type !== "server_tool_use" && - event.content_block.type !== "mcp_tool_use"; + event.content_block.type !== "mcp_tool_use" && + !isAdvisorResultBlock(event.content_block); const dropDelta = event.type === "content_block_delta" && (event.delta.type === "text_delta" || event.delta.type === "thinking_delta"); @@ -2668,6 +2689,59 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); return; } + // The advisor's result closes the tool block that started it. It never + // arrives as a `tool_result` user message, so without this the item + // spins until the turn's end-of-result sweep completes it. + if (isAdvisorResultBlock(block)) { + const advisorToolUseId = + typeof block.tool_use_id === "string" ? block.tool_use_id : undefined; + const advisorEntry = advisorToolUseId + ? Array.from(context.inFlightTools.entries()).find( + ([, inFlight]) => inFlight.itemId === advisorToolUseId, + ) + : undefined; + if (!advisorEntry) { + return; + } + const [advisorIndex, advisorTool] = advisorEntry; + context.inFlightTools.delete(advisorIndex); + const advisorStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: advisorStamp.eventId, + provider: PROVIDER, + createdAt: advisorStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + itemId: asRuntimeItemId(advisorTool.itemId), + payload: { + itemType: advisorTool.itemType, + status: "completed", + title: advisorTool.title, + ...(advisorTool.detail ? { detail: advisorTool.detail } : {}), + ...(advisorTool.agentId ? { agentId: advisorTool.agentId } : {}), + ...(advisorTool.parentToolUseId + ? { parentToolUseId: advisorTool.parentToolUseId } + : {}), + // The advisor's advice is returned encrypted, so there is no result + // text to carry: the item records that the consult happened. + data: { + toolName: advisorTool.toolName, + input: advisorTool.input, + }, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: advisorTool.itemId, + }), + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_start", + payload: message, + }, + }); + return; + } + if ( block.type !== "tool_use" && block.type !== "server_tool_use" && @@ -2683,7 +2757,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : {}; const itemType = classifyToolItemType(toolName, toolInput); const itemId = block.id; - const detail = summarizeToolRequest(toolName, toolInput); + // The advisor takes no arguments, so the generic input summary reads + // "advisor: {}". Its title carries the meaning instead. + const isAdvisor = isAdvisorToolName(toolName); + const detail = isAdvisor ? undefined : summarizeToolRequest(toolName, toolInput); const inputFingerprint = Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined; @@ -2699,8 +2776,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( itemId, itemType, toolName, - title: titleForTool(itemType), - detail, + title: isAdvisor ? "Advisor consult" : titleForTool(itemType), + ...(detail ? { detail } : {}), input: toolInput, partialInputJson: "", ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}), @@ -4357,6 +4434,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(claudeSettings.autoCompactWindow ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) } : {}), + // Settings key, not the `--advisor` flag: the flag aborts session start + // when the advisor cannot advise the thread model, while this path logs + // and runs the turn without an advisor. + ...(claudeSettings.advisorModel ? { advisorModel: claudeSettings.advisorModel } : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); // The attachments dir grant lets the agent Read/copy pasted images at diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index d750789fde02..c6431605a7f7 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -111,6 +111,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => customModels: [], launchArgs: "", autoCompactWindow: "", + advisorModel: "", ...overrides, }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 82ed8525b9b5..bcbb68638e6e 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -234,6 +234,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: ["claude-custom"], launchArgs: "", autoCompactWindow: "", + advisorModel: "", }); assert.deepEqual( next.textGenerationModelSelection, @@ -899,6 +900,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: [], launchArgs: "", autoCompactWindow: "", + advisorModel: "", }); assert.deepEqual(next.providers.opencode, { // OpenCode is disabled by default; this update only touches paths. diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 7dc13fa4f25c..593685e5e2ec 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -67,6 +67,7 @@ describe("ProviderSettingsForm helpers", () => { expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([ "binaryPath", "homePath", + "advisorModel", "autoCompactWindow", "launchArgs", ]); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 498fa802474d..8629d02d4cbd 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -46,6 +46,26 @@ offers to compact the conversation before you continue. You can also select **Co from the context meter. On every client, you can enter `/compact` in the message composer, and Claude can show its own resume prompt when you continue an old session. +## Let Claude Ask A Second Model For Advice + +Claude can consult a stronger model during a turn. The second model sees the work so far and gives +guidance before Claude continues. + +In Settings, open your Claude provider and set **Advisor model** to `fable`, `opus`, or `sonnet`. +Leave the field empty to keep the advisor setting from your Claude Code configuration. + +The advisor must be at least as capable as the model of the thread. For example, `sonnet` cannot +advise an Opus thread. If the two models do not match, Claude Code runs the turn without an +advisor. + +`fable` bills to usage credits. You must set up usage credits for your account before the advisor +can use `fable`. Run `/model fable` in an interactive Claude Code session to review and enable it. +Until you do this, T3 Code runs the turn without an advisor and shows no message. + +Each consult shows in the timeline as **Advisor consult**. The advice itself is encrypted, so the +item shows that the consult happened and no text. The advisor model uses its own tokens, which are +additional to the tokens of the thread model. + ## Where Claude Skills Are Loaded T3 Code looks for Claude skills in the Claude config directory's `skills` folder and diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5d4c1573ef7e..96701273355a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -50,6 +50,22 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClaudeSettings advisor model", () => { + it("inherits Claude's own advisor setting when none is configured", () => { + expect(decodeClaudeSettings({}).advisorModel).toBe(""); + }); + + it("keeps a configured advisor alias", () => { + expect(decodeClaudeSettings({ advisorModel: "opus" }).advisorModel).toBe("opus"); + }); + + it("accepts an advisor model at the settings patch boundary", () => { + expect( + decodeServerSettingsPatch({ providers: { claudeAgent: { advisorModel: "fable" } } }), + ).toBeDefined(); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fef3b2992f07..6ce48e4cce91 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -528,9 +528,21 @@ export const ClaudeSettings = makeProviderSettingsSchema( }, }), ), + advisorModel: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Advisor model", + description: + "Let Claude consult a second model during a turn. Use fable, opus, or sonnet. The advisor must be at least as capable as the thread model. Leave empty to use Claude's own setting.", + providerSettingsForm: { + placeholder: "e.g. opus", + clearWhenEmpty: "omit", + }, + }), + ), }, { - order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"], + order: ["binaryPath", "homePath", "advisorModel", "autoCompactWindow", "launchArgs"], }, ); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -1050,6 +1062,7 @@ const ClaudeSettingsPatch = Schema.Struct({ autoCompactWindow: Schema.optionalKey( TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)), ), + advisorModel: Schema.optionalKey(TrimmedString), }); const CursorSettingsPatch = Schema.Struct({