diff --git a/CHANGELOG.md b/CHANGELOG.md index d17c12761..c5f0e860d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026.1.12-2 + +### Changes +- Subagents: add config to set default sub-agent model (`agents.defaults.subagents.model` + per-agent override); still overridden by `sessions_spawn.model`. + +## 2026.1.12-1 + +### Changes +- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers). +- Onboarding: normalize API key inputs (strip `export KEY=...` wrappers) so shell-style entries paste cleanly. + ## 2026.1.12 ### Highlights diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f56339647..322647bd7 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1276,6 +1276,7 @@ Example: }, maxConcurrent: 3, subagents: { + model: "minimax/MiniMax-M2.1", maxConcurrent: 1, archiveAfterMinutes: 60 }, @@ -1478,6 +1479,7 @@ Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias). Legacy: `tools.bash` is still accepted as an alias. `agents.defaults.subagents` configures sub-agent defaults: +- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. - `maxConcurrent`: max concurrent sub-agent runs (default 1) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index e39d20ab7..ce0cbb15f 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -20,6 +20,7 @@ Primary goals: Use `sessions_spawn`: - Starts a sub-agent run (`deliver: false`, global lane: `subagent`) - Then runs an announce step and posts the announce reply to the requester chat provider +- Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. Tool params: - `task` (required) diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts index d8c99cbfc..405ad64bc 100644 --- a/src/agents/clawdbot-tools.subagents.test.ts +++ b/src/agents/clawdbot-tools.subagents.test.ts @@ -687,6 +687,91 @@ describe("subagents", () => { }); }); + it("sessions_spawn applies default subagent model from defaults config", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + configOverride = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.1" } } }, + }; + 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 === "sessions.patch") { + return { ok: true }; + } + if (request.method === "agent") { + return { runId: "run-default-model", status: "accepted" }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "agent:main:main", + agentProvider: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call-default-model", { + task: "do thing", + }); + expect(result.details).toMatchObject({ + status: "accepted", + modelApplied: true, + }); + + const patchCall = calls.find((call) => call.method === "sessions.patch"); + expect(patchCall?.params).toMatchObject({ + model: "minimax/MiniMax-M2.1", + }); + }); + + it("sessions_spawn prefers per-agent subagent model over defaults", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + configOverride = { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { subagents: { model: "minimax/MiniMax-M2.1" } }, + list: [{ id: "research", subagents: { model: "opencode/claude" } }], + }, + }; + 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 === "sessions.patch") { + return { ok: true }; + } + if (request.method === "agent") { + return { runId: "run-agent-model", status: "accepted" }; + } + return {}; + }); + + const tool = createClawdbotTools({ + agentSessionKey: "agent:research:main", + agentProvider: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) throw new Error("missing sessions_spawn tool"); + + const result = await tool.execute("call-agent-model", { + task: "do thing", + }); + expect(result.details).toMatchObject({ + status: "accepted", + modelApplied: true, + }); + + const patchCall = calls.find((call) => call.method === "sessions.patch"); + expect(patchCall?.params).toMatchObject({ + model: "opencode/claude", + }); + }); + it("sessions_spawn skips invalid model overrides and continues", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 1a0eb5565..b0abee9f0 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -35,6 +35,17 @@ const SessionsSpawnToolSchema = Type.Object({ ), }); +function normalizeModelSelection(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (!value || typeof value !== "object") return undefined; + const primary = (value as { primary?: unknown }).primary; + if (typeof primary === "string" && primary.trim()) return primary.trim(); + return undefined; +} + export function createSessionsSpawnTool(opts?: { agentSessionKey?: string; agentProvider?: GatewayMessageProvider; @@ -51,7 +62,7 @@ export function createSessionsSpawnTool(opts?: { const task = readStringParam(params, "task", { required: true }); const label = typeof params.label === "string" ? params.label.trim() : ""; const requestedAgentId = readStringParam(params, "agentId"); - const model = readStringParam(params, "model"); + const modelOverride = readStringParam(params, "model"); const cleanup = params.cleanup === "keep" || params.cleanup === "delete" ? (params.cleanup as "keep" | "delete") @@ -129,11 +140,16 @@ export function createSessionsSpawnTool(opts?: { } const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; const shouldPatchSpawnedBy = opts?.sandboxed === true; - if (model) { + const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); + const resolvedModel = + normalizeModelSelection(modelOverride) ?? + normalizeModelSelection(targetAgentConfig?.subagents?.model) ?? + normalizeModelSelection(cfg.agents?.defaults?.subagents?.model); + if (resolvedModel) { try { await callGateway({ method: "sessions.patch", - params: { key: childSessionKey, model }, + params: { key: childSessionKey, model: resolvedModel }, timeoutMs: 10_000, }); modelApplied = true; @@ -218,7 +234,7 @@ export function createSessionsSpawnTool(opts?: { status: "accepted", childSessionKey, runId: childRunId, - modelApplied: model ? modelApplied : undefined, + modelApplied: resolvedModel ? modelApplied : undefined, warning: modelWarning, }); }, diff --git a/src/config/types.ts b/src/config/types.ts index 1c723122e..d1d5e6af6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1090,6 +1090,8 @@ export type ToolsConfig = { }; /** Sub-agent tool policy defaults (deny wins). */ subagents?: { + /** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */ + model?: string | { primary?: string; fallbacks?: string[] }; tools?: { allow?: string[]; deny?: string[]; @@ -1119,6 +1121,8 @@ export type AgentConfig = { subagents?: { /** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */ allowAgents?: string[]; + /** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */ + model?: string | { primary?: string; fallbacks?: string[] }; }; sandbox?: { mode?: "off" | "non-main" | "all"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index af8fd547a..5cf9662bd 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -924,6 +924,15 @@ const AgentEntrySchema = z.object({ subagents: z .object({ allowAgents: z.array(z.string()).optional(), + model: z + .union([ + z.string(), + z.object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }), + ]) + .optional(), }) .optional(), sandbox: AgentSandboxSchema, @@ -1227,6 +1236,15 @@ const AgentDefaultsSchema = z .object({ maxConcurrent: z.number().int().positive().optional(), archiveAfterMinutes: z.number().int().positive().optional(), + model: z + .union([ + z.string(), + z.object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }), + ]) + .optional(), }) .optional(), sandbox: z