diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index c1d4ce4d9..d17191960 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -27,6 +27,7 @@ Tool params: - `label?` (optional) - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result) +- `thinking?` (optional; overrides thinking level for the sub-agent run) - `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) - `cleanup?` (`delete|keep`, default `keep`) diff --git a/src/agents/clawdbot-tools.sessions.test.ts b/src/agents/clawdbot-tools.sessions.test.ts index d795f463b..3f465a867 100644 --- a/src/agents/clawdbot-tools.sessions.test.ts +++ b/src/agents/clawdbot-tools.sessions.test.ts @@ -54,6 +54,7 @@ describe("sessions tools", () => { expect(schemaProp("sessions_list", "activeMinutes").type).toBe("number"); expect(schemaProp("sessions_list", "messageLimit").type).toBe("number"); expect(schemaProp("sessions_send", "timeoutSeconds").type).toBe("number"); + expect(schemaProp("sessions_spawn", "thinking").type).toBe("string"); expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number"); expect(schemaProp("sessions_spawn", "timeoutSeconds").type).toBe("number"); }); diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts index abc420f7e..2eea23bf0 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts @@ -92,6 +92,68 @@ describe("clawdbot-tools: subagents", () => { model: "claude-haiku-4-5", }); }); + + it("sessions_spawn forwards thinking overrides to the agent run", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "agent") { + return { runId: "run-thinking", status: "accepted" }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "discord:group:req", + agentChannel: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call-thinking", { + task: "do thing", + thinking: "high", + }); + expect(result.details).toMatchObject({ + status: "accepted", + }); + + const agentCall = calls.find((call) => call.method === "agent"); + expect(agentCall?.params).toMatchObject({ + thinking: "high", + }); + }); + + it("sessions_spawn rejects invalid thinking levels", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const calls: Array<{ method?: string }> = []; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + calls.push(request); + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "discord:group:req", + agentChannel: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call-thinking-invalid", { + task: "do thing", + thinking: "banana", + }); + expect(result.details).toMatchObject({ + status: "error", + }); + expect(String(result.details?.error)).toMatch(/Invalid thinking level/i); + expect(calls).toHaveLength(0); + }); it("sessions_spawn applies default subagent model from defaults config", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 79358640e..603c51e3b 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -266,7 +266,7 @@ "sessions_spawn": { "emoji": "🧑‍🔧", "title": "Sub-agent", - "detailKeys": ["label", "agentId", "runTimeoutSeconds", "cleanup"] + "detailKeys": ["label", "agentId", "thinking", "runTimeoutSeconds", "cleanup"] }, "session_status": { "emoji": "📊", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index e743c07d1..e9b8e0764 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; +import { formatThinkingLevels, normalizeThinkLevel } from "../../auto-reply/thinking.js"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { @@ -29,12 +30,22 @@ const SessionsSpawnToolSchema = Type.Object({ label: Type.Optional(Type.String()), agentId: Type.Optional(Type.String()), model: Type.Optional(Type.String()), + thinking: Type.Optional(Type.String()), runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), // Back-compat alias. Prefer runTimeoutSeconds. timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), cleanup: optionalStringEnum(["delete", "keep"] as const), }); +function splitModelRef(ref?: string) { + if (!ref) return { provider: undefined, model: undefined }; + const trimmed = ref.trim(); + if (!trimmed) return { provider: undefined, model: undefined }; + const [provider, model] = trimmed.split("/", 2); + if (model) return { provider, model }; + return { provider: undefined, model: trimmed }; +} + function normalizeModelSelection(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -64,6 +75,7 @@ export function createSessionsSpawnTool(opts?: { const label = typeof params.label === "string" ? params.label.trim() : ""; const requestedAgentId = readStringParam(params, "agentId"); const modelOverride = readStringParam(params, "model"); + const thinkingOverrideRaw = readStringParam(params, "thinking"); const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? (params.cleanup as "keep" | "delete") @@ -143,6 +155,19 @@ export function createSessionsSpawnTool(opts?: { normalizeModelSelection(modelOverride) ?? normalizeModelSelection(targetAgentConfig?.subagents?.model) ?? normalizeModelSelection(cfg.agents?.defaults?.subagents?.model); + let thinkingOverride: string | undefined; + if (thinkingOverrideRaw) { + const normalized = normalizeThinkLevel(thinkingOverrideRaw); + if (!normalized) { + const { provider, model } = splitModelRef(resolvedModel); + const hint = formatThinkingLevels(provider, model); + return jsonResult({ + status: "error", + error: `Invalid thinking level "${thinkingOverrideRaw}". Use one of: ${hint}.`, + }); + } + thinkingOverride = normalized; + } if (resolvedModel) { try { await callGateway({ @@ -187,6 +212,7 @@ export function createSessionsSpawnTool(opts?: { deliver: false, lane: AGENT_LANE_SUBAGENT, extraSystemPrompt: childSystemPrompt, + thinking: thinkingOverride, timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, label: label || undefined, spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,