Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
89 changes: 85 additions & 4 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,24 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>):
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":
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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" &&
Expand All @@ -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;

Expand All @@ -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 } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisor title lost on input deltas

Medium Severity

The advisor start path sets title to Advisor consult and omits the empty input summary, but input_json_delta always recomputes title via titleForTool and detail via summarizeToolRequest. Those frames write the generic Tool call label and advisor: {} back onto the in-flight item, so item.completed publishes the label this change was meant to replace.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ef51c0d. Configure here.

input: toolInput,
partialInputJson: "",
...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}),
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings =>
customModels: [],
launchArgs: "",
autoCompactWindow: "",
advisorModel: "",
...overrides,
});

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
customModels: ["claude-custom"],
launchArgs: "",
autoCompactWindow: "",
advisorModel: "",
});
assert.deepEqual(
next.textGenerationModelSelection,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe("ProviderSettingsForm helpers", () => {
expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([
"binaryPath",
"homePath",
"advisorModel",
"autoCompactWindow",
"launchArgs",
]);
Expand Down
20 changes: 20 additions & 0 deletions docs/user/providers-claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
Loading