fix(agents): allow hyphenated subagent task names

Allow `sessions_spawn.taskName` to accept lowercase hyphenated task slugs while keeping the existing underscore support and invalid-name rejection. Update the tool schema, system prompt wording, docs, focused tests, and generated prompt snapshots so the user/model-facing contract matches the validator.

Verification:
- `pnpm prompt:snapshots:check`
- `node scripts/run-vitest.mjs src/agents/tools/sessions-spawn-tool.test.ts src/agents/system-prompt.test.ts`
- Real behavior proof gate: https://github.com/openclaw/openclaw/actions/runs/26628449324/job/78470916945
- PR CI: https://github.com/openclaw/openclaw/actions/runs/26628441940, with failures matching current `main` at https://github.com/openclaw/openclaw/actions/runs/26628128225

Co-authored-by: chenhaoqiang <chenhaoqiang@xiaomi.com>
Co-authored-by: Lanzhi <lizhan3@xiaomi.com>
This commit is contained in:
兰之
2026-05-29 17:10:12 +08:00
committed by GitHub
parent 30c1ca5c7b
commit 6950e85605
12 changed files with 50 additions and 28 deletions

View File

@@ -178,7 +178,7 @@ Per-agent overrides use `agents.list[].subagents.delegationMode`.
The task description for the sub-agent.
</ParamField>
<ParamField path="taskName" type="string">
Optional stable handle for identifying a specific child in later status output. Must match `[a-z][a-z0-9_]{0,63}` and cannot be reserved targets such as `last` or `all`.
Optional stable handle for identifying a specific child in later status output. Must match `[a-z][a-z0-9_-]{0,63}` and cannot be reserved targets such as `last` or `all`.
</ParamField>
<ParamField path="label" type="string">
Optional human-readable label.

View File

@@ -1,6 +1,6 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
const SUBAGENT_TASK_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
const SUBAGENT_TASK_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
const RESERVED_SUBAGENT_TASK_NAMES = new Set(["all", "last"]);
type NormalizeSubagentTaskNameResult =
@@ -14,7 +14,7 @@ export function normalizeSubagentTaskName(value: unknown): NormalizeSubagentTask
}
if (!SUBAGENT_TASK_NAME_RE.test(taskName)) {
return {
error: `Invalid taskName "${taskName}". Use 1-64 chars matching [a-z][a-z0-9_]*.`,
error: `Invalid taskName "${taskName}". Use 1-64 chars matching [a-z][a-z0-9_-]*.`,
};
}
if (RESERVED_SUBAGENT_TASK_NAMES.has(taskName)) {

View File

@@ -866,6 +866,7 @@ describe("buildAgentSystemPrompt", () => {
"Anything requiring more work than a direct reply should go through `sessions_spawn`",
);
expect(preferPrompt).toContain("objective, expected output, relevant files/inputs");
expect(preferPrompt).toContain("keep it lowercase with underscores or hyphens");
expect(preferPrompt).toContain("Treat child outputs as reports/evidence");
expect(preferPrompt).toContain(
"Use `subagents(action=list)` only when explicitly asked for sub-agent status",

View File

@@ -96,7 +96,7 @@ function buildSubagentDelegationPreferenceSection(params: {
"- Anything requiring more work than a direct reply should go through `sessions_spawn`; avoid doing expensive tool calls yourself.",
"- Delegate file/code inspection, shell commands, web/browser use, long reads, debugging, coding, multi-step analysis, comparisons, non-trivial summarization, and background waiting.",
"- Before spawning, decide what stays local and what is delegated. Give each child a clear objective, expected output, relevant files/inputs, write scope, verification ask, and whether it blocks your final answer.",
'- Set `taskName` when you will need a stable handle later; keep it lowercase with underscores. Omit `context` for isolated children; set `context:"fork"` only when current transcript details matter.',
'- Set `taskName` when you will need a stable handle later; keep it lowercase with underscores or hyphens. Omit `context` for isolated children; set `context:"fork"` only when current transcript details matter.',
params.hasSessionsYield
? "- After spawning required work, call `sessions_yield` if you need completion events before answering. Do not poll for completion."
: "- After spawning, do not poll for completion. Child completion is push-based and returns as a runtime event; synthesize that result for the user.",

View File

@@ -355,7 +355,7 @@ describe("sessions_spawn tool", () => {
const result = await tool.execute("call-task-name", {
task: "review subagent handling",
taskName: "review_subagents",
taskName: "review-subagents",
});
expectDetailFields(result.details, {
@@ -364,24 +364,45 @@ describe("sessions_spawn tool", () => {
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.task).toBe("review subagent handling");
expect(spawnArgs.taskName).toBe("review_subagents");
expect(spawnArgs.taskName).toBe("review-subagents");
});
it("rejects invalid taskName before spawning", async () => {
it("accepts underscore taskName aliases", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
});
const result = await tool.execute("call-bad-task-name", {
const result = await tool.execute("call-underscore-task-name", {
task: "review subagent handling",
taskName: "Bad-Name",
taskName: "review_subagents",
});
expectDetailFields(result.details, { status: "error" });
expect(JSON.stringify(result.details)).toContain("Invalid taskName");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
expectDetailFields(result.details, {
status: "accepted",
childSessionKey: "agent:main:subagent:1",
});
const spawnArgs = mockCallArg(hoisted.spawnSubagentDirectMock, 0, 0, "spawnSubagentDirect");
expect(spawnArgs.taskName).toBe("review_subagents");
});
it.each(["Bad-Name", "code review", "-bad"])(
"rejects invalid taskName %s before spawning",
async (taskName) => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
});
const result = await tool.execute("call-bad-task-name", {
task: "review subagent handling",
taskName,
});
expectDetailFields(result.details, { status: "error" });
expect(JSON.stringify(result.details)).toContain("Invalid taskName");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
},
);
it.each(["last", "all"])("rejects reserved taskName %s before spawning", async (taskName) => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",

View File

@@ -159,7 +159,7 @@ function createSessionsSpawnToolSchema(params: {
taskName: Type.Optional(
Type.String({
description:
"Stable alias for later targeting; lowercase letters/digits/underscores, starts letter.",
"Stable alias for later targeting; lowercase letters/digits/underscores/hyphens, starts letter.",
}),
),
label: Type.Optional(Type.String()),

View File

@@ -1064,7 +1064,7 @@
"type": "string"
},
"taskName": {
"description": "Stable alias for later targeting; lowercase letters/digits/underscores, starts letter.",
"description": "Stable alias for later targeting; lowercase letters/digits/underscores/hyphens, starts letter.",
"type": "string"
},
"thinking": {

View File

@@ -1100,7 +1100,7 @@
"type": "string"
},
"taskName": {
"description": "Stable alias for later targeting; lowercase letters/digits/underscores, starts letter.",
"description": "Stable alias for later targeting; lowercase letters/digits/underscores/hyphens, starts letter.",
"type": "string"
},
"thinking": {

View File

@@ -1064,7 +1064,7 @@
"type": "string"
},
"taskName": {
"description": "Stable alias for later targeting; lowercase letters/digits/underscores, starts letter.",
"description": "Stable alias for later targeting; lowercase letters/digits/underscores/hyphens, starts letter.",
"type": "string"
},
"thinking": {

View File

@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 41138,
"roughTokens": 10285
"chars": 41146,
"roughTokens": 10287
},
"openClawDeveloperInstructions": {
"chars": 2988,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6925
},
"totalWithDynamicToolsJson": {
"chars": 68840,
"roughTokens": 17210
"chars": 68848,
"roughTokens": 17212
},
"userInputText": {
"chars": 1629,

View File

@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 40859,
"roughTokens": 10215
"chars": 40867,
"roughTokens": 10217
},
"openClawDeveloperInstructions": {
"chars": 1964,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6544
},
"totalWithDynamicToolsJson": {
"chars": 67037,
"roughTokens": 16760
"chars": 67045,
"roughTokens": 16762
},
"userInputText": {
"chars": 1129,

View File

@@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 41954,
"roughTokens": 10489
"chars": 41962,
"roughTokens": 10491
},
"openClawDeveloperInstructions": {
"chars": 1983,
@@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6780
},
"totalWithDynamicToolsJson": {
"chars": 69075,
"roughTokens": 17269
"chars": 69083,
"roughTokens": 17271
},
"userInputText": {
"chars": 1367,