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
90 changes: 90 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
Options as ClaudeQueryOptions,
PermissionMode,
PermissionResult,
PreToolUseHookInput,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
Expand All @@ -30,6 +31,7 @@ import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Random from "effect/Random";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -3996,6 +3998,94 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("a hook fired inside a subagent refines its seeded effort", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const nextTaskEvent = adapter.streamEvents.pipe(
Stream.filter((event) => event.type.startsWith("task.")),
Stream.runHead,
Effect.map(Option.getOrUndefined),
);

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
modelSelection: createModelSelection(
ProviderInstanceId.make("claudeAgent"),
SYNTHETIC_CLAUDE_CAPABLE_MODEL,
[{ id: "effort", value: "max" }],
),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId: session.threadId,
input: "spawn an agent",
attachments: [],
});

harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-defined",
description: "Agent D",
subagent_type: "researcher",
task_type: "local_agent",
tool_use_id: "toolu_agent_defined",
uuid: "task-defined-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);
const started = yield* nextTaskEvent;
assert.equal(started?.type, "task.started");
if (started?.type === "task.started") {
assert.equal(started.payload.effort, "max");
}

// The subagent's definition sets its own effort, which the SDK only
// reports on hooks fired from inside it.
const preToolUse =
harness.getLastCreateQueryInput()?.options.hooks?.PreToolUse?.[0]?.hooks[0];
assert.equal(typeof preToolUse, "function");
if (!preToolUse) {
return;
}
const hookInput: PreToolUseHookInput = {
hook_event_name: "PreToolUse",
session_id: "sdk-session",
transcript_path: "/tmp/transcript.jsonl",
cwd: "/tmp",
agent_id: "task-defined",
agent_type: "researcher",
effort: { level: "high" },
tool_name: "Bash",
tool_input: { command: "pwd" },
tool_use_id: "toolu_subagent_bash",
};
yield* Effect.promise(() =>
preToolUse(hookInput, "toolu_subagent_bash", { signal: new AbortController().signal }),
);

harness.query.emit({
type: "system",
subtype: "task_progress",
task_id: "task-defined",
description: "Agent D",
usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 },
uuid: "task-defined-progress-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);

const progress = yield* nextTaskEvent;
assert.equal(progress?.type, "task.progress");
if (progress?.type === "task.progress") {
assert.equal(progress.payload.effort, "high");
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
22 changes: 20 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import * as NodeUtil from "node:util";
import {
type CanUseTool,
type HookCallback,
query,
getSessionMessages,
forkSession,
Expand Down Expand Up @@ -377,6 +378,8 @@ interface ClaudeTaskAgentState {
/** Seeded from the launching tool's input; refined by the subagent's own
* assistant snapshots (authoritative API model). */
model: string | undefined;
/** Seeded like `model`; refined by hooks fired inside the subagent, which
* report the effort it actually runs at (e.g. its agent definition's). */
effort: string | undefined;
}

Expand Down Expand Up @@ -417,7 +420,7 @@ interface ClaudeSessionContext {
readonly basePermissionMode: PermissionMode | undefined;
currentApiModelId: string | undefined;
/** Effective effort for the session's turns; subagents without an explicit
* effort override inherit this. */
* effort override seed their label from this. */
currentEffort: string | undefined;
resumeSessionId: string | undefined;
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
Expand Down Expand Up @@ -3724,7 +3727,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
context.turnState.hasSubagents = true;
}
// Model/effort: the Agent tool's input carries explicit overrides;
// absent ones inherit the session's selection (SDK behavior).
// absent ones seed from the session's selection. An agent definition's
// own effort only surfaces later, through `recordSubagentEffort`.
// Subagent assistant snapshots refine model with the authoritative API
// id: one that already arrived is buffered and outranks the seed here,
// later ones refine the record in place. AgentInput.effort may be a
Expand Down Expand Up @@ -4825,6 +4829,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (

const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
// Hooks are the only place the SDK reports the effort a subagent runs
// at; `agent_id` is the subagent's task id. Later task.* rows carry it.
const recordSubagentEffort: HookCallback = async (hookInput) => {
const agent = hookInput.agent_id ? taskAgents.get(hookInput.agent_id) : undefined;
const effort = trimmedString(hookInput.effort?.level);
if (agent && effort) {
agent.effort = effort;
}
return {};
};
const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
request,
callbackOptions,
Expand Down Expand Up @@ -4945,6 +4959,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(newSessionId ? { sessionId: newSessionId } : {}),
includePartialMessages: true,
canUseTool,
hooks: {
PreToolUse: [{ hooks: [recordSubagentEffort] }],
SubagentStop: [{ hooks: [recordSubagentEffort] }],
},
onUserDialog,
supportedDialogKinds: ["resume_return"],
env: McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession),
Expand Down
Loading