mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(agents): stop recommending unavailable session tools (#127648)
* fix(agents): stop recommending unavailable session tools * fix(agents): preserve authorized tool guidance placement * fix(agents): keep spawn recovery guidance executable
This commit is contained in:
committed by
GitHub
parent
eee905934a
commit
9c85319792
@@ -5,6 +5,12 @@ import { getPluginToolMeta, setPluginToolMeta } from "../plugins/tools.js";
|
||||
import { applyToolAvailabilityDescriptions } from "./agent-tools.deferred-followup.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { getChannelAgentToolMeta, setChannelAgentToolMeta } from "./channel-tool-metadata.js";
|
||||
import {
|
||||
describeSessionsSearchTool,
|
||||
describeSessionsSendTool,
|
||||
describeSessionsSpawnTool,
|
||||
} from "./tool-description-presets.js";
|
||||
import { createConversationsSendTool } from "./tools/conversation-tools.js";
|
||||
|
||||
function findToolDescription(toolName: string, includeCron: boolean) {
|
||||
const tools = applyToolAvailabilityDescriptions([
|
||||
@@ -46,25 +52,55 @@ describe("createOpenClawCodingTools availability guidance", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves ownership metadata when replacing process descriptions", () => {
|
||||
const processTool = {
|
||||
name: "process",
|
||||
description: "plugin process",
|
||||
} as AnyAgentTool;
|
||||
setPluginToolMeta(processTool, { pluginId: "example", optional: false });
|
||||
setChannelAgentToolMeta(processTool as never, { channelId: "example-channel" });
|
||||
it.each([
|
||||
{ name: "process", description: "plugin process", available: [] },
|
||||
{
|
||||
name: "sessions_send",
|
||||
description: describeSessionsSendTool(),
|
||||
available: ["conversations_list", "conversations_send"],
|
||||
},
|
||||
{
|
||||
name: "sessions_search",
|
||||
description: describeSessionsSearchTool(),
|
||||
available: ["sessions_history"],
|
||||
},
|
||||
{
|
||||
name: "sessions_spawn",
|
||||
description: describeSessionsSpawnTool(),
|
||||
available: ["agents_list"],
|
||||
},
|
||||
{
|
||||
name: "conversations_send",
|
||||
description: createConversationsSendTool().description,
|
||||
available: ["conversations_list"],
|
||||
},
|
||||
])(
|
||||
"preserves ownership metadata when replacing $name descriptions",
|
||||
({ name, description, available }) => {
|
||||
const originalTool = {
|
||||
name,
|
||||
description,
|
||||
} as AnyAgentTool;
|
||||
setPluginToolMeta(originalTool, { pluginId: "example", optional: false });
|
||||
setChannelAgentToolMeta(originalTool as never, { channelId: "example-channel" });
|
||||
|
||||
const [updated] = applyToolAvailabilityDescriptions([processTool]);
|
||||
const [updated] = applyToolAvailabilityDescriptions([
|
||||
originalTool,
|
||||
...available.map(
|
||||
(toolName) => ({ name: toolName, description: "available" }) as AnyAgentTool,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(updated).not.toBe(processTool);
|
||||
expect(getPluginToolMeta(expectDefined(updated, "updated test invariant"))).toEqual({
|
||||
pluginId: "example",
|
||||
optional: false,
|
||||
});
|
||||
expect(getChannelAgentToolMeta(updated as never)).toEqual({
|
||||
channelId: "example-channel",
|
||||
});
|
||||
});
|
||||
expect(updated).not.toBe(originalTool);
|
||||
expect(getPluginToolMeta(expectDefined(updated, "updated test invariant"))).toEqual({
|
||||
pluginId: "example",
|
||||
optional: false,
|
||||
});
|
||||
expect(getChannelAgentToolMeta(updated as never)).toEqual({
|
||||
channelId: "example-channel",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("mentions sessions_spawn only when it survives tool filtering", () => {
|
||||
const withoutSpawn = applyToolAvailabilityDescriptions([
|
||||
@@ -83,4 +119,153 @@ describe("createOpenClawCodingTools availability guidance", () => {
|
||||
expect(tool.description).toContain("sessions_spawn");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "sessions_send",
|
||||
description: describeSessionsSendTool(),
|
||||
unavailable: ["conversations_list", "conversations_send", "conversations_turn"],
|
||||
},
|
||||
{
|
||||
name: "sessions_search",
|
||||
description: describeSessionsSearchTool(),
|
||||
unavailable: ["sessions_history"],
|
||||
},
|
||||
{
|
||||
name: "sessions_spawn",
|
||||
description: describeSessionsSpawnTool({ swarmEnabled: true }),
|
||||
unavailable: ["agents_list", "agents_wait", "subagents", "sessions_history"],
|
||||
},
|
||||
{
|
||||
name: "conversations_send",
|
||||
description: createConversationsSendTool().description,
|
||||
unavailable: ["conversations_list"],
|
||||
},
|
||||
])(
|
||||
"does not advertise unavailable follow-up tools from $name",
|
||||
({ name, description, unavailable }) => {
|
||||
const [tool] = applyToolAvailabilityDescriptions([{ name, description } as AnyAgentTool]);
|
||||
|
||||
for (const unavailableTool of unavailable) {
|
||||
expect(tool?.description).not.toContain(unavailableTool);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ available: [], expected: [] },
|
||||
{ available: ["conversations_list"], expected: [] },
|
||||
{ available: ["conversations_send"], expected: [] },
|
||||
{
|
||||
available: ["conversations_list", "conversations_send"],
|
||||
expected: ["conversations_list", "conversations_send"],
|
||||
},
|
||||
{
|
||||
available: ["conversations_list", "conversations_turn"],
|
||||
expected: ["conversations_list", "conversations_turn"],
|
||||
},
|
||||
{
|
||||
available: ["conversations_list", "conversations_send", "conversations_turn"],
|
||||
expected: ["conversations_list", "conversations_send", "conversations_turn"],
|
||||
},
|
||||
])("describes only executable conversation routes: $available", ({ available, expected }) => {
|
||||
const [tool] = applyToolAvailabilityDescriptions([
|
||||
{ name: "sessions_send", description: describeSessionsSendTool() },
|
||||
...available.map((name) => ({ name, description: "available" })),
|
||||
] as AnyAgentTool[]);
|
||||
|
||||
for (const name of ["conversations_list", "conversations_send", "conversations_turn"]) {
|
||||
expect(tool?.description.includes(name)).toBe(expected.includes(name));
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the existing fully authorized session-send description byte for byte", () => {
|
||||
const [tool] = applyToolAvailabilityDescriptions([
|
||||
{ name: "sessions_send", description: describeSessionsSendTool() },
|
||||
{ name: "conversations_list", description: "list" },
|
||||
{ name: "conversations_send", description: "send" },
|
||||
{ name: "conversations_turn", description: "turn" },
|
||||
] as AnyAgentTool[]);
|
||||
|
||||
expect(tool?.description).toBe(
|
||||
[
|
||||
"Run a visible session on this Gateway by sessionKey/label, or a configured local agent by agentId; sessionKey wins redundant label.",
|
||||
"A session identifies model context, not an external address; its reply may still announce through established delivery context.",
|
||||
"For an exact external destination, use `conversations_list` plus `conversations_send`/`conversations_turn`.",
|
||||
'Thread chats rejected: target parent channel. Missing configured-agent main created. Waits for reply when available; status "no_reply" is terminal, so do not wait for an announcement.',
|
||||
"watch:true: notice arrives when others later change target session.",
|
||||
].join(" "),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps authorized history guidance and the prepared session URL", () => {
|
||||
const sessionLinkBase = "https://gateway.example/control";
|
||||
const [tool] = applyToolAvailabilityDescriptions([
|
||||
{
|
||||
name: "sessions_search",
|
||||
description: describeSessionsSearchTool({ sessionLinkBase }),
|
||||
},
|
||||
{ name: "sessions_history", description: "history" },
|
||||
] as AnyAgentTool[]);
|
||||
|
||||
expect(tool?.description).toContain("sessions_history");
|
||||
expect(tool?.description).toContain(`${sessionLinkBase}/chat/<agentId>`);
|
||||
expect(tool?.description.indexOf("Follow up with sessions_history")).toBeLessThan(
|
||||
tool?.description.indexOf("When pointing the user at a session") ?? Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
it("restores conversation lookup guidance only when lookup is authorized", () => {
|
||||
const [tool] = applyToolAvailabilityDescriptions([
|
||||
createConversationsSendTool(),
|
||||
{ name: "conversations_list", description: "lookup" } as AnyAgentTool,
|
||||
]);
|
||||
|
||||
expect(tool?.description).toBe(
|
||||
"Send directly through a conversationRef from conversations_list. This performs channel delivery; it does not run the local agent in the backing session.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only authorized spawn follow-ups without losing prepared runtime facts", () => {
|
||||
const [tool] = applyToolAvailabilityDescriptions([
|
||||
{
|
||||
name: "sessions_spawn",
|
||||
description: describeSessionsSpawnTool({
|
||||
acpAvailable: false,
|
||||
threadAvailable: true,
|
||||
sessionToolsVisibility: "self",
|
||||
swarmEnabled: true,
|
||||
}),
|
||||
},
|
||||
{ name: "agents_list", description: "agent lookup" },
|
||||
{ name: "sessions_history", description: "history" },
|
||||
] as AnyAgentTool[]);
|
||||
|
||||
expect(tool?.description).toContain("configured agent (see agents_list);");
|
||||
expect(tool?.description).toContain("sessions_history");
|
||||
expect(tool?.description).not.toContain("agents_wait");
|
||||
expect(tool?.description).not.toContain("subagents");
|
||||
expect(tool?.description).toContain("persistent/thread-bound");
|
||||
expect(tool?.description).toContain("(self: current session only)");
|
||||
expect(tool?.description).not.toContain('runtime="acp"');
|
||||
});
|
||||
|
||||
it("preserves original inline spawn guidance when every follow-up remains available", () => {
|
||||
const [tool] = applyToolAvailabilityDescriptions([
|
||||
{
|
||||
name: "sessions_spawn",
|
||||
description: describeSessionsSpawnTool({ acpAvailable: false, swarmEnabled: true }),
|
||||
},
|
||||
...["agents_list", "agents_wait", "subagents", "sessions_history"].map((name) => ({
|
||||
name,
|
||||
description: "available",
|
||||
})),
|
||||
] as AnyAgentTool[]);
|
||||
|
||||
expect(tool?.description).toContain("configured agent (see agents_list);");
|
||||
expect(tool?.description).toContain("`groupId` groups a batch; await with agents_wait.");
|
||||
expect(tool?.description).toContain(
|
||||
"No spawn for quick lookup/single read. Check spawns via `subagents`/`sessions_history`. After spawn,",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,14 +10,75 @@ function replaceDescription(tool: AnyAgentTool, description: string): AnyAgentTo
|
||||
return copyAgentToolMetadata(tool, updated);
|
||||
}
|
||||
|
||||
const SESSION_TOOL_FOLLOWUPS = [
|
||||
[
|
||||
"sessions_search",
|
||||
"sessions_history",
|
||||
"Search your own past sessions for matching user and assistant text.",
|
||||
"Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey, sessionId, and messageId for neighboring context.",
|
||||
],
|
||||
[
|
||||
"conversations_send",
|
||||
"conversations_list",
|
||||
"through a conversationRef.",
|
||||
"through a conversationRef from conversations_list.",
|
||||
],
|
||||
["sessions_spawn", "agents_list", "configured agent;", "configured agent (see agents_list);"],
|
||||
[
|
||||
"sessions_spawn",
|
||||
"agents_wait",
|
||||
"`groupId` groups a batch.",
|
||||
"`groupId` groups a batch; await with agents_wait.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
function describeAvailableSessionTool(
|
||||
tool: AnyAgentTool,
|
||||
availableTools: ReadonlySet<string>,
|
||||
): string {
|
||||
let description = tool.description;
|
||||
// Preserve byte-stable default prompt placement while gating every named sibling.
|
||||
for (const [sourceTool, requiredTool, original, expanded] of SESSION_TOOL_FOLLOWUPS) {
|
||||
if (sourceTool === tool.name && availableTools.has(requiredTool)) {
|
||||
description = description.replace(original, expanded);
|
||||
}
|
||||
}
|
||||
if (tool.name === "sessions_send") {
|
||||
const deliveryTools = ["conversations_send", "conversations_turn"].filter((name) =>
|
||||
availableTools.has(name),
|
||||
);
|
||||
if (availableTools.has("conversations_list") && deliveryTools.length > 0) {
|
||||
const guidance = `For an exact external destination, use \`conversations_list\` plus ${deliveryTools.map((name) => `\`${name}\``).join("/")}.`;
|
||||
description = description.replace(
|
||||
" Thread chats rejected:",
|
||||
` ${guidance} Thread chats rejected:`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (tool.name === "sessions_spawn") {
|
||||
const statusTools = ["subagents", "sessions_history"].filter((name) =>
|
||||
availableTools.has(name),
|
||||
);
|
||||
if (statusTools.length > 0) {
|
||||
const guidance = statusTools.map((name) => `\`${name}\``).join("/");
|
||||
description = description.replace(
|
||||
"No spawn for quick lookup/single read.",
|
||||
`No spawn for quick lookup/single read. Check spawns via ${guidance}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
/** Return tools with cross-tool guidance adjusted for the tools that survived filtering. */
|
||||
export function applyToolAvailabilityDescriptions(
|
||||
tools: AnyAgentTool[],
|
||||
params?: { agentId?: string },
|
||||
): AnyAgentTool[] {
|
||||
const availableTools = new Set(tools.map((tool) => tool.name));
|
||||
const hasCronTool = tools.some((tool) => isAutomationsToolName(tool.name));
|
||||
const hasProcessTool = tools.some((tool) => tool.name === "process");
|
||||
const hasSessionsSpawnTool = tools.some((tool) => tool.name === "sessions_spawn");
|
||||
const hasProcessTool = availableTools.has("process");
|
||||
const hasSessionsSpawnTool = availableTools.has("sessions_spawn");
|
||||
return tools.map((tool) => {
|
||||
if (tool.name === "exec") {
|
||||
return replaceDescription(
|
||||
@@ -34,6 +95,7 @@ export function applyToolAvailabilityDescriptions(
|
||||
if (tool.name === "agents_wait") {
|
||||
return replaceDescription(tool, describeAgentsWaitTool(hasSessionsSpawnTool));
|
||||
}
|
||||
return tool;
|
||||
const description = describeAvailableSessionTool(tool, availableTools);
|
||||
return description === tool.description ? tool : replaceDescription(tool, description);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ describe("subagent spawn allowlist + sandbox guards", () => {
|
||||
const result = await spawn({});
|
||||
expectStatus(result, "forbidden");
|
||||
expect(result.error ?? "").toContain("sessions_spawn requires explicit agentId");
|
||||
expect(result.error ?? "").not.toContain("agents_list");
|
||||
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -253,19 +254,16 @@ describe("subagent spawn allowlist + sandbox guards", () => {
|
||||
name: "rejects malformed agentId strings before any gateway work",
|
||||
agentId: "Agent not found: xyz",
|
||||
extraAgents: [{ id: "research" }],
|
||||
mentionsAgentsList: true,
|
||||
},
|
||||
{
|
||||
name: "rejects agentId containing path separators",
|
||||
agentId: "../../../etc/passwd",
|
||||
extraAgents: [],
|
||||
mentionsAgentsList: false,
|
||||
},
|
||||
{
|
||||
name: "rejects agentId exceeding 64 characters",
|
||||
agentId: "a".repeat(65),
|
||||
extraAgents: [],
|
||||
mentionsAgentsList: false,
|
||||
},
|
||||
])("$name", async (row) => {
|
||||
setConfig({
|
||||
@@ -276,9 +274,7 @@ describe("subagent spawn allowlist + sandbox guards", () => {
|
||||
const result = await spawn({ agentId: row.agentId });
|
||||
expectStatus(result, "error");
|
||||
expect(result.error ?? "").toContain("Invalid agentId");
|
||||
if (row.mentionsAgentsList) {
|
||||
expect(result.error ?? "").toContain("agents_list");
|
||||
}
|
||||
expect(result.error ?? "").not.toContain("agents_list");
|
||||
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ function buildThreadBindingUnavailableError(kind: SpawnBackendKind, mode: SpawnM
|
||||
return (
|
||||
'sessions_spawn(mode="session") is only available on channels that expose thread bindings (e.g. Discord threads, Slack threads, Telegram forum topics). ' +
|
||||
"This request is not running on a channel that can bind a subagent thread. " +
|
||||
'Use mode="run" for one-shot subagent work, or sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.'
|
||||
'Use mode="run" for one-shot subagent work.'
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -354,7 +354,7 @@ export function resolveSpawnAdmission(params: {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"sessions_spawn requires explicit agentId when requireAgentId is configured. Use agents_list to see allowed agent ids.",
|
||||
"sessions_spawn requires explicit agentId when requireAgentId is configured. Provide an allowed configured agentId.",
|
||||
};
|
||||
}
|
||||
const targetPolicy = resolveSubagentTargetPolicy({
|
||||
|
||||
@@ -91,7 +91,7 @@ export function resolveSubagentSpawnRequest(
|
||||
if (requestedAgentId && !isValidAgentId(requestedAgentId)) {
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
`Invalid agentId "${requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}. Use agents_list to discover valid targets.`,
|
||||
`Invalid agentId "${requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}.`,
|
||||
);
|
||||
}
|
||||
const requestThreadBinding = params.thread === true;
|
||||
@@ -109,7 +109,7 @@ export function resolveSubagentSpawnRequest(
|
||||
return rejectSubagentSpawnRequest(
|
||||
"error",
|
||||
'sessions_spawn(mode="session") requires thread=true so the subagent can stay bound to a channel thread. ' +
|
||||
'Retry with { mode: "session", thread: true } on a channel that supports threads, use mode="run" for one-shot work, or use sessions_send(sessionKey=...) to keep talking to a persistent session without thread binding.',
|
||||
'Retry with { mode: "session", thread: true } on a channel that supports threads, or use mode="run" for one-shot work.',
|
||||
);
|
||||
}
|
||||
const cleanup =
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => {
|
||||
if (result.status === "error") {
|
||||
expect(result.error).toContain("thread: true");
|
||||
expect(result.error).toContain('mode="run"');
|
||||
expect(result.error).toContain("sessions_send");
|
||||
expect(result.error).not.toContain("sessions_send");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => {
|
||||
if (result.status === "error") {
|
||||
expect(result.error).toContain("not running on a channel");
|
||||
expect(result.error).toContain('mode="run"');
|
||||
expect(result.error).toContain("sessions_send");
|
||||
expect(result.error).not.toContain("sessions_send");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,7 @@ describe('spawnSubagentDirect mode="session" with thread binding-capable channel
|
||||
if (result.status === "error") {
|
||||
expect(result.error).toContain("thread: true");
|
||||
expect(result.error).toContain('mode="run"');
|
||||
expect(result.error).toContain("sessions_send");
|
||||
expect(result.error).not.toContain("sessions_send");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,8 +26,7 @@ const SESSION_DESCRIPTIONS = [
|
||||
{
|
||||
tool: "sessions_search",
|
||||
describe: describeSessionsSearchTool,
|
||||
original:
|
||||
"Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey, sessionId, and messageId for neighboring context.",
|
||||
original: "Search your own past sessions for matching user and assistant text.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -48,7 +47,7 @@ describe("sessions_send tool description", () => {
|
||||
expect(SESSIONS_SEND_TOOL_DISPLAY_SUMMARY).toContain("same-Gateway");
|
||||
expect(describeSessionsSendTool()).toContain("on this Gateway");
|
||||
expect(describeSessionsSendTool()).toContain("not an external address");
|
||||
expect(describeSessionsSendTool()).toContain("`conversations_send`/`conversations_turn`");
|
||||
expect(describeSessionsSendTool()).not.toContain("conversations_");
|
||||
expect(describeSessionsSendTool()).toContain("reply may still announce");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,6 @@ export function describeSessionsHistoryTool(options?: SessionLinkDescriptionOpti
|
||||
export function describeSessionsSearchTool(options?: SessionLinkDescriptionOptions): string {
|
||||
return [
|
||||
"Search your own past sessions for matching user and assistant text.",
|
||||
"Follow up with sessions_history using a returned sessionKey, sessionId, and messageId for neighboring context.",
|
||||
...(options?.sessionLinkBase ? [describeSessionLinkRule(options.sessionLinkBase)] : []),
|
||||
].join(" ");
|
||||
}
|
||||
@@ -96,7 +95,6 @@ export function describeSessionsSendTool(): string {
|
||||
return [
|
||||
"Run a visible session on this Gateway by sessionKey/label, or a configured local agent by agentId; sessionKey wins redundant label.",
|
||||
"A session identifies model context, not an external address; its reply may still announce through established delivery context.",
|
||||
"For an exact external destination, use `conversations_list` plus `conversations_send`/`conversations_turn`.",
|
||||
'Thread chats rejected: target parent channel. Missing configured-agent main created. Waits for reply when available; status "no_reply" is terminal, so do not wait for an announcement.',
|
||||
"watch:true: notice arrives when others later change target session.",
|
||||
].join(" ");
|
||||
@@ -131,12 +129,12 @@ export function describeSessionsSpawnTool(options?: {
|
||||
options?.threadAvailable
|
||||
? '`mode="run"` one-shot; `mode="session"` persistent/thread-bound only on supporting requester channel.'
|
||||
: '`mode="run"` one-shot background.',
|
||||
"`agentId` targets a configured agent (see agents_list); `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require.",
|
||||
"`agentId` targets a configured agent; `model` overrides its model; `cleanup` delete|keep hidden child session; `sandbox` inherit|require.",
|
||||
'`visible=true`: durable visible session. Default for coding, multi-step work, or results user may revisit/steer/keep — not only when a thread is requested. Shows in web UI sidebar; works without UI: completion announces back, progress checkable. `category` explicitly groups it; omission or an empty string leaves it ungrouped. Subagent only; omit `mode` (no `mode="run"`), `thread`, `thinking`, `lightContext`, `attachments`, `attachAs`; inherits the caller tool-policy ceiling; may check out a git worktree via `worktree`/`worktreeName`/`worktreeBaseRef`. When its accepted result includes `sessionUrl`, channel acknowledgements put the session URL on the first line and `Owner: <label>` on the second line.',
|
||||
visibilityLine,
|
||||
...(options?.swarmEnabled
|
||||
? [
|
||||
"`collect=true` (swarm): parallel fan-out collector children; structured result per `outputSchema`; `groupId` groups a batch; await with agents_wait.",
|
||||
"`collect=true` (swarm): parallel fan-out collector children; structured result per `outputSchema`; `groupId` groups a batch.",
|
||||
]
|
||||
: []),
|
||||
"Inherits parent workspace. Native task arrives as first `[Subagent Task]`.",
|
||||
@@ -144,7 +142,7 @@ export function describeSessionsSpawnTool(options?: {
|
||||
? []
|
||||
: ['`runtime="acp"` ids: codex, claude, gemini, opencode, or configured ACP.']),
|
||||
'Native transcript needed: `context="fork"`; else omit/isolated.',
|
||||
"Hidden child: research, parallel/batch reads, throwaway side tasks. Coding, PRs, long builds, anything worth keeping: `visible=true`. No spawn for quick lookup/single read. Check spawns via `subagents`/`sessions_history`.",
|
||||
"Hidden child: research, parallel/batch reads, throwaway side tasks. Coding, PRs, long builds, anything worth keeping: `visible=true`. No spawn for quick lookup/single read.",
|
||||
completionGuidance,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export function createConversationsSendTool(
|
||||
name: "conversations_send",
|
||||
displaySummary: "Send to an exact external conversation.",
|
||||
description:
|
||||
"Send directly through a conversationRef from conversations_list. This performs channel delivery; it does not run the local agent in the backing session.",
|
||||
"Send directly through a conversationRef. This performs channel delivery; it does not run the local agent in the backing session.",
|
||||
parameters: ConversationsSendSchema,
|
||||
outputSchema: ConversationSendResultSchema,
|
||||
execute: async (toolCallId, args, signal) => {
|
||||
|
||||
@@ -387,8 +387,13 @@ describe("sessions_spawn tool", () => {
|
||||
requesterRunId: "parent-run",
|
||||
config: { tools: { swarm: true } },
|
||||
});
|
||||
const schema = tool.parameters as { properties?: Record<string, unknown> };
|
||||
const schema = tool.parameters as {
|
||||
properties?: Record<string, { description?: string } | undefined>;
|
||||
};
|
||||
expect(schema.properties?.collect).toBeDefined();
|
||||
expect(requireSchemaProperty(schema.properties, "collect").description).not.toContain(
|
||||
"agents_wait",
|
||||
);
|
||||
expect(schema.properties?.outputSchema).toBeDefined();
|
||||
expect(schema.properties?.fastMode).toBeDefined();
|
||||
expect(schema.properties?.groupId).toBeDefined();
|
||||
@@ -868,6 +873,45 @@ describe("sessions_spawn tool", () => {
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "malformed agent ID",
|
||||
agentId: "Agent not found: reviewer",
|
||||
requireAgentId: false,
|
||||
expected: "Invalid agentId",
|
||||
},
|
||||
{
|
||||
name: "missing required agent ID",
|
||||
agentId: undefined,
|
||||
requireAgentId: true,
|
||||
expected: "sessions_spawn requires agentId",
|
||||
},
|
||||
])("keeps visible $name recovery independent of filtered tools", async (testCase) => {
|
||||
const callGateway = vi.fn();
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { subagents: { requireAgentId: testCase.requireAgentId } },
|
||||
list: [{ id: "main" }],
|
||||
},
|
||||
},
|
||||
callGateway,
|
||||
countActiveRuns: () => 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("visible-invalid-agent", {
|
||||
task: "inspect issue",
|
||||
visible: true,
|
||||
...(testCase.agentId ? { agentId: testCase.agentId } : {}),
|
||||
});
|
||||
|
||||
const details = requireRecord(result.details, "visible spawn failure");
|
||||
expect(details.error).toContain(testCase.expected);
|
||||
expect(details.error).not.toContain("agents_list");
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cwd escape for sandboxed visible sessions", async () => {
|
||||
await withTestDir({ prefix: "openclaw-visible-sandbox-cwd-" }, async (dir) => {
|
||||
const callGateway = vi.fn();
|
||||
@@ -1679,6 +1723,18 @@ describe("sessions_spawn tool", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects channel-delivery parameters without recommending filtered tools", async () => {
|
||||
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" });
|
||||
|
||||
await expect(
|
||||
tool.execute("call-channel-delivery", { task: "do thing", channel: "example" }),
|
||||
).rejects.toThrow(
|
||||
'sessions_spawn does not support "channel"; remove channel-delivery parameters.',
|
||||
);
|
||||
|
||||
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes inherited workspaceDir from tool context, not from tool args", async () => {
|
||||
const tool = createSessionsSpawnTool({
|
||||
agentSessionKey: "agent:main:main",
|
||||
|
||||
@@ -204,7 +204,7 @@ function createSessionsSpawnToolSchema(params: {
|
||||
? {
|
||||
collect: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Swarm collector child for parallel fan-out; await via agents_wait.",
|
||||
description: "Swarm collector child for parallel fan-out.",
|
||||
}),
|
||||
),
|
||||
outputSchema: Type.Optional(
|
||||
@@ -370,7 +370,7 @@ export function createSessionsSpawnTool(
|
||||
);
|
||||
if (unsupportedParam) {
|
||||
throw new ToolInputError(
|
||||
`sessions_spawn does not support "${unsupportedParam}". Use "message" or "sessions_send" for channel delivery.`,
|
||||
`sessions_spawn does not support "${unsupportedParam}"; remove channel-delivery parameters.`,
|
||||
);
|
||||
}
|
||||
const unsupportedTimeoutParam = resolveSnakeCaseParamKey(params, "timeoutSeconds");
|
||||
|
||||
@@ -199,7 +199,7 @@ export async function maybeSpawnVisibleSession(params: {
|
||||
if (params.requestedAgentId && !isValidAgentId(params.requestedAgentId)) {
|
||||
return {
|
||||
status: "error",
|
||||
error: `Invalid agentId "${params.requestedAgentId}". Use agents_list.`,
|
||||
error: `Invalid agentId "${params.requestedAgentId}". Agent IDs must match [a-z0-9][a-z0-9_-]{0,63}.`,
|
||||
};
|
||||
}
|
||||
const requesterAgentId = resolveSessionAgentId({
|
||||
@@ -213,7 +213,7 @@ export async function maybeSpawnVisibleSession(params: {
|
||||
cfg.agents?.defaults?.subagents?.requireAgentId ??
|
||||
false;
|
||||
if (requireAgentId && !params.requestedAgentId) {
|
||||
return { status: "forbidden", error: "sessions_spawn requires agentId. Use agents_list." };
|
||||
return { status: "forbidden", error: "sessions_spawn requires agentId; use an allowed agent." };
|
||||
}
|
||||
const targetAgentId = params.requestedAgentId
|
||||
? normalizeAgentId(params.requestedAgentId)
|
||||
|
||||
Reference in New Issue
Block a user