feat: subagent model defaults
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# 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
|
## 2026.1.12
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
@@ -1276,6 +1276,7 @@ Example:
|
|||||||
},
|
},
|
||||||
maxConcurrent: 3,
|
maxConcurrent: 3,
|
||||||
subagents: {
|
subagents: {
|
||||||
|
model: "minimax/MiniMax-M2.1",
|
||||||
maxConcurrent: 1,
|
maxConcurrent: 1,
|
||||||
archiveAfterMinutes: 60
|
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.
|
Legacy: `tools.bash` is still accepted as an alias.
|
||||||
|
|
||||||
`agents.defaults.subagents` configures sub-agent defaults:
|
`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)
|
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||||
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
- `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)
|
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Primary goals:
|
|||||||
Use `sessions_spawn`:
|
Use `sessions_spawn`:
|
||||||
- Starts a sub-agent run (`deliver: false`, global lane: `subagent`)
|
- 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
|
- 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:
|
Tool params:
|
||||||
- `task` (required)
|
- `task` (required)
|
||||||
|
|||||||
@@ -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 () => {
|
it("sessions_spawn skips invalid model overrides and continues", async () => {
|
||||||
resetSubagentRegistryForTests();
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
|
|||||||
@@ -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?: {
|
export function createSessionsSpawnTool(opts?: {
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentProvider?: GatewayMessageProvider;
|
agentProvider?: GatewayMessageProvider;
|
||||||
@@ -51,7 +62,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
const task = readStringParam(params, "task", { required: true });
|
const task = readStringParam(params, "task", { required: true });
|
||||||
const label = typeof params.label === "string" ? params.label.trim() : "";
|
const label = typeof params.label === "string" ? params.label.trim() : "";
|
||||||
const requestedAgentId = readStringParam(params, "agentId");
|
const requestedAgentId = readStringParam(params, "agentId");
|
||||||
const model = readStringParam(params, "model");
|
const modelOverride = readStringParam(params, "model");
|
||||||
const cleanup =
|
const cleanup =
|
||||||
params.cleanup === "keep" || params.cleanup === "delete"
|
params.cleanup === "keep" || params.cleanup === "delete"
|
||||||
? (params.cleanup as "keep" | "delete")
|
? (params.cleanup as "keep" | "delete")
|
||||||
@@ -129,11 +140,16 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
}
|
}
|
||||||
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
|
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
|
||||||
const shouldPatchSpawnedBy = opts?.sandboxed === true;
|
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 {
|
try {
|
||||||
await callGateway({
|
await callGateway({
|
||||||
method: "sessions.patch",
|
method: "sessions.patch",
|
||||||
params: { key: childSessionKey, model },
|
params: { key: childSessionKey, model: resolvedModel },
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 10_000,
|
||||||
});
|
});
|
||||||
modelApplied = true;
|
modelApplied = true;
|
||||||
@@ -218,7 +234,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
status: "accepted",
|
status: "accepted",
|
||||||
childSessionKey,
|
childSessionKey,
|
||||||
runId: childRunId,
|
runId: childRunId,
|
||||||
modelApplied: model ? modelApplied : undefined,
|
modelApplied: resolvedModel ? modelApplied : undefined,
|
||||||
warning: modelWarning,
|
warning: modelWarning,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1090,6 +1090,8 @@ export type ToolsConfig = {
|
|||||||
};
|
};
|
||||||
/** Sub-agent tool policy defaults (deny wins). */
|
/** Sub-agent tool policy defaults (deny wins). */
|
||||||
subagents?: {
|
subagents?: {
|
||||||
|
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */
|
||||||
|
model?: string | { primary?: string; fallbacks?: string[] };
|
||||||
tools?: {
|
tools?: {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
deny?: string[];
|
deny?: string[];
|
||||||
@@ -1119,6 +1121,8 @@ export type AgentConfig = {
|
|||||||
subagents?: {
|
subagents?: {
|
||||||
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
|
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
|
||||||
allowAgents?: string[];
|
allowAgents?: string[];
|
||||||
|
/** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */
|
||||||
|
model?: string | { primary?: string; fallbacks?: string[] };
|
||||||
};
|
};
|
||||||
sandbox?: {
|
sandbox?: {
|
||||||
mode?: "off" | "non-main" | "all";
|
mode?: "off" | "non-main" | "all";
|
||||||
|
|||||||
@@ -924,6 +924,15 @@ const AgentEntrySchema = z.object({
|
|||||||
subagents: z
|
subagents: z
|
||||||
.object({
|
.object({
|
||||||
allowAgents: z.array(z.string()).optional(),
|
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(),
|
.optional(),
|
||||||
sandbox: AgentSandboxSchema,
|
sandbox: AgentSandboxSchema,
|
||||||
@@ -1227,6 +1236,15 @@ const AgentDefaultsSchema = z
|
|||||||
.object({
|
.object({
|
||||||
maxConcurrent: z.number().int().positive().optional(),
|
maxConcurrent: z.number().int().positive().optional(),
|
||||||
archiveAfterMinutes: 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(),
|
.optional(),
|
||||||
sandbox: z
|
sandbox: z
|
||||||
|
|||||||
Reference in New Issue
Block a user