feat: add thinking override to sessions_spawn

This commit is contained in:
Peter Steinberger
2026-01-18 00:14:02 +00:00
parent f8052be369
commit 1bf3861ca4
5 changed files with 91 additions and 1 deletions

View File

@@ -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`)

View File

@@ -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");
});

View File

@@ -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();

View File

@@ -266,7 +266,7 @@
"sessions_spawn": {
"emoji": "🧑‍🔧",
"title": "Sub-agent",
"detailKeys": ["label", "agentId", "runTimeoutSeconds", "cleanup"]
"detailKeys": ["label", "agentId", "thinking", "runTimeoutSeconds", "cleanup"]
},
"session_status": {
"emoji": "📊",

View File

@@ -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,