diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 82548e7140a..4ccec20dd4b 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2422,6 +2422,7 @@ export default { 'toolDisplayName.TeamCreate': 'Crea equip', 'toolDisplayName.TeamDelete': 'Suprimeix equip', 'toolDisplayName.TeamPlanApproval': "Aprovació del pla d'equip", + 'toolDisplayName.RequestShutdown': "Sol·licita l'aturada", 'toolDisplayName.SendMessage': 'Envia missatge', 'toolDisplayName.StructuredOutput': 'Sortida estructurada', 'toolDisplayName.Monitor': 'Monitor', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 6961da35b42..7518b958e37 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -215,6 +215,7 @@ export default { 'toolDisplayName.TeamDelete': 'toolDisplayName.TeamDelete', 'toolDisplayName.TeamPlanApproval': 'toolDisplayName.TeamPlanApproval', 'toolDisplayName.SendMessage': 'toolDisplayName.SendMessage', + 'toolDisplayName.RequestShutdown': 'toolDisplayName.RequestShutdown', 'toolDisplayName.StructuredOutput': 'toolDisplayName.StructuredOutput', 'toolDisplayName.Monitor': 'toolDisplayName.Monitor', 'toolDisplayName.NotebookEdit': 'toolDisplayName.NotebookEdit', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index b7ed680f135..af313d0139f 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -206,6 +206,7 @@ export default { 'toolDisplayName.TeamDelete': '刪除團隊', 'toolDisplayName.TeamPlanApproval': '團隊計畫審批', 'toolDisplayName.SendMessage': '傳送訊息', + 'toolDisplayName.RequestShutdown': '請求下線', 'toolDisplayName.StructuredOutput': '結構化輸出', 'toolDisplayName.Monitor': '監控', 'toolDisplayName.NotebookEdit': '編輯 Notebook', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index b68776deb76..187bf7eb27e 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -207,6 +207,7 @@ export default { 'toolDisplayName.TeamDelete': '删除团队', 'toolDisplayName.TeamPlanApproval': '团队计划审批', 'toolDisplayName.SendMessage': '发送消息', + 'toolDisplayName.RequestShutdown': '请求下线', 'toolDisplayName.StructuredOutput': '结构化输出', 'toolDisplayName.Monitor': '监控', 'toolDisplayName.NotebookEdit': '编辑 Notebook', diff --git a/packages/core/src/agents/runtime/agent-core.test.ts b/packages/core/src/agents/runtime/agent-core.test.ts index 0c810ff0ebc..3232f7b0287 100644 --- a/packages/core/src/agents/runtime/agent-core.test.ts +++ b/packages/core/src/agents/runtime/agent-core.test.ts @@ -962,6 +962,7 @@ describe('extractParentToolNames', () => { functionDeclarations: [ { name: ToolNames.WORKFLOW }, { name: ToolNames.AGENT }, + { name: ToolNames.REQUEST_SHUTDOWN }, { name: ToolNames.READ_FILE }, ], }, @@ -970,6 +971,9 @@ describe('extractParentToolNames', () => { expect(names).toEqual([ToolNames.READ_FILE]); expect(names).not.toContain(ToolNames.WORKFLOW); expect(names).not.toContain(ToolNames.AGENT); + // Leader-only team control: a subagent must never impersonate the + // leader by requesting a teammate shutdown (#9401). + expect(names).not.toContain(ToolNames.REQUEST_SHUTDOWN); }); it('filters out empty and non-string declaration names', () => { diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 0ca4bb92bdb..06a05430c0e 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -190,6 +190,7 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet = new Set([ ToolNames.TEAM_CREATE, ToolNames.TEAM_DELETE, ToolNames.TEAM_PLAN_APPROVAL, + ToolNames.REQUEST_SHUTDOWN, ToolNames.TASK_CREATE, ToolNames.TASK_UPDATE, ToolNames.TASK_LIST, @@ -255,6 +256,7 @@ const EXCLUDED_TOOLS_FOR_TEAMMATES: ReadonlySet = new Set([ ToolNames.TEAM_CREATE, ToolNames.TEAM_DELETE, ToolNames.TEAM_PLAN_APPROVAL, + ToolNames.REQUEST_SHUTDOWN, ToolNames.TODO_WRITE, ...SUBAGENT_PLAN_LIFECYCLE_TOOLS, // Worktree management belongs to the parent session. diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index af74e3122fb..026e6fa0f23 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -7778,6 +7778,42 @@ describe('Server Config (config.ts)', () => { ]); }); + // The leader-only property of request_shutdown is "enforced by absence": + // a teammate's registry never contains it, so the call cannot be formed. + // That only holds if the skip actually fires, which nothing asserted. + it('registers request_shutdown for a leader but not for a subagent', async () => { + const config = new Config({ + ...baseParams, + agentTeamEnabled: true, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + + const leaderNames = (registerToolMock as Mock).mock.calls.map( + (call) => call[0], + ); + expect(leaderNames).toContain(ToolNames.REQUEST_SHUTDOWN); + + (registerToolMock as Mock).mockClear(); + await config.createToolRegistry(undefined, { + skipDiscovery: true, + forSubAgent: true, + }); + + const subagentNames = (registerToolMock as Mock).mock.calls.map( + (call) => call[0], + ); + expect(subagentNames).not.toContain(ToolNames.REQUEST_SHUTDOWN); + // The rest of the team surface still registers — only the leader-only + // control tool is withheld. + expect(subagentNames).toContain(ToolNames.SEND_MESSAGE); + }); + it('registers web_search when enabled with a usable env-declared backend', async () => { process.env['WEB_SEARCH_GATE_TEST_KEY'] = 'sk-test'; try { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 365d9de269a..8f7541b47b7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -8776,6 +8776,20 @@ export class Config { ); return new TeamPlanApprovalTool(this); }); + // Leader-only, enforced by absence. `requestShutdown` writes the target's + // mailbox entry as `from: LEADER_NAME`, so a teammate calling it would be + // impersonating the leader. Skipping registration in subagent-context + // registries means a teammate has no declaration for it and cannot emit + // the call — rather than emitting one and being rejected, which is how + // #9276 lost teammate reports when this was a `send_message` field. + if (!options?.forSubAgent) { + await registerLazy(ToolNames.REQUEST_SHUTDOWN, async () => { + const { RequestShutdownTool } = await import( + '../tools/request-shutdown.js' + ); + return new RequestShutdownTool(this); + }); + } await registerLazy(ToolNames.TASK_CREATE, async () => { const { TaskCreateTool } = await import('../tools/task-create.js'); return new TaskCreateTool(this); diff --git a/packages/core/src/skills/bundled/coordinate/SKILL.md b/packages/core/src/skills/bundled/coordinate/SKILL.md index c777f69487a..954f8324786 100644 --- a/packages/core/src/skills/bundled/coordinate/SKILL.md +++ b/packages/core/src/skills/bundled/coordinate/SKILL.md @@ -25,7 +25,7 @@ Read-only teammates have a positive execution allowlist. They cannot use shell, When the goal requires code changes: 1. Finish the parallel investigation first. -2. Send each investigation teammate a `shutdown_request`. Once shutdown is pending, they are excluded from automatic task assignment. +2. Call `request_shutdown` for each investigation teammate. Once shutdown is pending, they are excluded from automatic task assignment. 3. Call `enter_worktree` once and keep the returned path. 4. Create the implementation task, then spawn exactly one named writer with `subagent_type: "general-purpose"` and `working_dir: `. Do not set `read_only` for this teammate. 5. Give the writer the accepted investigation evidence and require all changes to stay inside the worktree. @@ -34,7 +34,7 @@ When the goal requires code changes: If the checkout is not a Git repository or worktree creation fails, keep all teammates read-only and let the leader make the final change in the current checkout. -After synthesis, send each still-active teammate a `shutdown_request`, then delete the team. +After synthesis, call `request_shutdown` for each still-active teammate, then delete the team. The existing Agent View tabs show teammate conversations, messages, status, and approvals. Do not create another roster, session manager, or terminal UI. diff --git a/packages/core/src/tools/request-shutdown.test.ts b/packages/core/src/tools/request-shutdown.test.ts new file mode 100644 index 00000000000..84c5b67651d --- /dev/null +++ b/packages/core/src/tools/request-shutdown.test.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RequestShutdownTool } from './request-shutdown.js'; +import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; +import type { ApprovalMode, Config } from '../config/config.js'; +import { runWithTeammateIdentity } from '../agents/team/identity.js'; +import { runWithAgentContext } from '../agents/runtime/agent-context.js'; + +const DEFAULT_MODE = 'default' as ApprovalMode; + +function makeTeamConfig(opts?: { + teamManager?: { + requestShutdown: (...args: unknown[]) => Promise; + } | null; + approvalMode?: ApprovalMode; +}) { + return { + getTeamManager: () => opts?.teamManager ?? null, + getBackgroundTaskRegistry: () => new BackgroundTaskRegistry(), + getApprovalMode: () => opts?.approvalMode ?? DEFAULT_MODE, + } as unknown as Config; +} + +describe('RequestShutdownTool', () => { + it('has the correct name', () => { + const tool = new RequestShutdownTool(makeTeamConfig()); + expect(tool.name).toBe('request_shutdown'); + }); + + it('routes to TeamManager.requestShutdown', async () => { + const requestShutdown = vi.fn().mockResolvedValue(undefined); + const tool = new RequestShutdownTool( + makeTeamConfig({ teamManager: { requestShutdown } }), + ); + + const invocation = tool.build({ to: 'bob' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('Shutdown'); + expect(result.llmContent).toContain('bob'); + expect(requestShutdown).toHaveBeenCalledWith('bob'); + }); + + it('carries no message field — it is a control action, not content', () => { + // The whole point of splitting this out of send_message (#9276): this tool + // cannot carry text, and send_message cannot carry control. Neither can + // impersonate the other. + const tool = new RequestShutdownTool(makeTeamConfig()); + const schema = tool.schema.parametersJsonSchema as { + properties: Record; + }; + expect(Object.keys(schema.properties)).toEqual(['to']); + }); + + it('refuses a teammate caller even if the tool reaches its registry', async () => { + // Registration already keeps this out of a subagent-context registry, so + // this path should be unreachable. Kept as defence in depth: reaching it + // would mean a registry was built without `forSubAgent`, and requestShutdown + // writes the mailbox entry as `from: LEADER_NAME`. + const requestShutdown = vi.fn().mockResolvedValue(undefined); + const tool = new RequestShutdownTool( + makeTeamConfig({ teamManager: { requestShutdown } }), + ); + + const invocation = tool.build({ to: 'bob' }); + const result = await runWithTeammateIdentity( + { + agentName: 'attacker', + teamName: 'team', + agentId: 'attacker@team', + isTeamLead: false, + }, + () => invocation.execute(new AbortController().signal), + ); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('Only the team leader'); + expect(requestShutdown).not.toHaveBeenCalled(); + }); + + it('errors without an active team', async () => { + const tool = new RequestShutdownTool(makeTeamConfig()); + const invocation = tool.build({ to: 'bob' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('No active team'); + }); + + it('surfaces an unknown teammate as a tool error', async () => { + const requestShutdown = vi + .fn() + .mockRejectedValue(new Error('Teammate "ghost" not found.')); + const tool = new RequestShutdownTool( + makeTeamConfig({ teamManager: { requestShutdown } }), + ); + + const invocation = tool.build({ to: 'ghost' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('not found'); + }); + + // Absence from a subagent registry only covers registries built with + // `forSubAgent`. The workflow fast path reuses the leader's own registry + // untouched, and that one does contain this tool — so the runtime check has + // to catch any subagent-like context, not just a teammate identity. + it('refuses a workflow subagent reusing the leader registry', async () => { + const requestShutdown = vi.fn().mockResolvedValue(undefined); + const tool = new RequestShutdownTool( + makeTeamConfig({ teamManager: { requestShutdown } }), + ); + const invocation = tool.build({ to: 'bob' }); + const result = await runWithAgentContext('wf-1', () => + invocation.execute(new AbortController().signal), + ); + expect(result.error).toBeDefined(); + expect(requestShutdown).not.toHaveBeenCalled(); + }); + + // Pinned because the value is deliberate: 'allow' would let AUTO + // short-circuit the classifier for an action that ends another agent's + // participation. + it('defaults to ask so AUTO still routes through the classifier', async () => { + const tool = new RequestShutdownTool(makeTeamConfig()); + const invocation = tool.build({ to: 'bob' }); + await expect(invocation.getDefaultPermission()).resolves.toBe('ask'); + }); + + // The schema accepts '' and ' ' — `type: string` plus `required` does not + // exclude them — so the guard is reachable. + it('rejects an empty or whitespace-only recipient', async () => { + const requestShutdown = vi.fn().mockResolvedValue(undefined); + const tool = new RequestShutdownTool( + makeTeamConfig({ teamManager: { requestShutdown } }), + ); + for (const to of ['', ' ']) { + const result = await tool + .build({ to }) + .execute(new AbortController().signal); + expect(result.error).toBeDefined(); + } + expect(requestShutdown).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/tools/request-shutdown.ts b/packages/core/src/tools/request-shutdown.ts new file mode 100644 index 00000000000..1bb38d61239 --- /dev/null +++ b/packages/core/src/tools/request-shutdown.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * request_shutdown tool - ask a teammate to wind down. + * + * Leader-only **by registration**: `createToolRegistry` skips this tool when + * building a subagent-context registry (`forSubAgent`), so a teammate's tool + * list does not contain it and a teammate cannot emit a call for it at all. + * + * This used to be a `type: 'shutdown_request'` discriminator on `send_message`. + * That shape was withdrawn because it made an illegal state *representable*: + * the field was visible to every caller, its description ("structured message + * type for control flow") reads as something a teammate writing a structured + * report should set, and a teammate that set it had its report rejected and + * discarded rather than delivered. Separating the tools makes the leader-only + * property structural instead of a runtime error — see #9276. + * + * The delivered mailbox entry is unchanged: `sendStructuredMessage` still + * writes `type: 'shutdown_request'` with `from: LEADER_NAME`. Only the tool + * surface moved; the wire format did not. + */ + +import type { Config } from '../config/config.js'; +import type { PermissionDecision } from '../permissions/types.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import { + getPlanRequiredTeammatePreApprovalMessage, + isPlanRequiredTeammateAwaitingApproval, + isSubagentLikeExecutionContext, +} from '../agents/runtime/subagent-plan-tool-policy.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolResult, +} from './tools.js'; + +export interface RequestShutdownParams { + /** Teammate name to wind down. Bare name, no `@`. */ + to: string; +} + +class RequestShutdownInvocation extends BaseToolInvocation< + RequestShutdownParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: RequestShutdownParams, + ) { + super(params); + } + + getDescription(): string { + return `Request shutdown for "${this.params.to}"`; + } + + /** + * Shutdown is cooperative — the teammate may reply `shutdown_rejected` — but + * it still ends another agent's participation and releases its task + * ownership. Keep the L4 default at 'ask' so AUTO routes through the + * classifier rather than short-circuiting, matching `send_message`. + */ + override async getDefaultPermission(): Promise { + return 'ask'; + } + + async execute(_signal: AbortSignal): Promise { + if (isPlanRequiredTeammateAwaitingApproval(this.config)) { + const msg = getPlanRequiredTeammatePreApprovalMessage( + ToolNames.REQUEST_SHUTDOWN, + ); + return { llmContent: msg, returnDisplay: msg, error: { message: msg } }; + } + + // Absence from a subagent registry is the primary guarantee, but it only + // covers registries that were *built* with `forSubAgent`. Some dispatch + // paths reuse the parent's registry untouched — `runSingleDispatch`'s + // workflow fast path hands the leader's own registry to a subagent — and + // that registry does contain this tool. So the runtime check has to cover + // every subagent-like context, not just a teammate identity. + // + // Fail closed rather than impersonate the leader: `requestShutdown` writes + // the mailbox entry as `from: LEADER_NAME` and arms shutdown_approved + // tracking for the target. + if (isSubagentLikeExecutionContext()) { + const msg = 'Only the team leader can request shutdowns.'; + return { llmContent: msg, returnDisplay: msg, error: { message: msg } }; + } + + const teamManager = this.config.getTeamManager(); + if (!teamManager) { + const msg = 'No active team. Create a team before requesting a shutdown.'; + return { llmContent: msg, returnDisplay: msg, error: { message: msg } }; + } + + const to = this.params.to?.trim(); + if (!to) { + const msg = 'Recipient "to" is required.'; + return { llmContent: msg, returnDisplay: msg, error: { message: msg } }; + } + + try { + await teamManager.requestShutdown(to); + const msg = `Shutdown requested for "${to}".`; + return { llmContent: msg, returnDisplay: msg }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { llmContent: msg, returnDisplay: msg, error: { message: msg } }; + } + } +} + +export class RequestShutdownTool extends BaseDeclarativeTool< + RequestShutdownParams, + ToolResult +> { + constructor(private readonly config: Config) { + super( + ToolNames.REQUEST_SHUTDOWN, + ToolDisplayNames.REQUEST_SHUTDOWN, + 'Ask a teammate to finish its current work and wind down. ' + + 'The teammate replies "shutdown_approved" or "shutdown_rejected: ". ' + + 'Once a shutdown is pending the teammate is excluded from automatic task assignment. ' + + 'Leader-only. To send a teammate ordinary text, use send_message instead.', + Kind.Other, + { + type: 'object', + properties: { + to: { + type: 'string', + description: 'Teammate name to wind down (bare name, no @).', + }, + }, + required: ['to'], + additionalProperties: false, + }, + true, // isOutputMarkdown + false, // canUpdateOutput + true, // shouldDefer — shutting a teammate down is infrequent + false, // alwaysLoad + 'shutdown teammate team stop wind down finish', + ); + } + + protected createInvocation( + params: RequestShutdownParams, + ): ToolInvocation { + return new RequestShutdownInvocation(this.config, params); + } + + /** + * The recipient is the whole action — forward it so the classifier can see + * which teammate is being wound down. + */ + override toAutoClassifierInput( + params: RequestShutdownParams, + ): Record { + return { to: params.to }; + } +} diff --git a/packages/core/src/tools/send-message.test.ts b/packages/core/src/tools/send-message.test.ts index 56a2a38445a..66daba3fda9 100644 --- a/packages/core/src/tools/send-message.test.ts +++ b/packages/core/src/tools/send-message.test.ts @@ -18,7 +18,6 @@ function makeTeamConfig(opts?: { teamManager?: { sendMessage: (...args: unknown[]) => Promise; broadcast: (...args: unknown[]) => Promise; - requestShutdown?: (...args: unknown[]) => Promise; } | null; approvalMode?: ApprovalMode; }) { @@ -101,62 +100,50 @@ describe('SendMessageTool — team mode', () => { expect(result.llmContent).toContain('No active team'); }); - it('routes shutdown_request via requestShutdown', async () => { - const requestShutdown = vi.fn().mockResolvedValue(undefined); - const tool = new SendMessageTool( - makeTeamConfig({ - teamManager: { - sendMessage: vi.fn(), - broadcast: vi.fn(), - requestShutdown, - }, - }), - ); - - const invocation = tool.build({ - to: 'bob', - message: 'Please shut down.', - type: 'shutdown_request', - }); - const result = await invocation.execute(new AbortController().signal); - expect(result.error).toBeUndefined(); - expect(result.llmContent).toContain('Shutdown'); - expect(result.llmContent).toContain('bob'); - expect(requestShutdown).toHaveBeenCalledWith('bob'); + // #9276: the tool used to carry an optional single-value enum + // `type: ['shutdown_request']` described as "structured message type for + // control flow". Models filled it while writing an ordinary report, the + // call was rejected leader-only, and the report content was discarded. + // The fix is that the field no longer exists — assert the *absence*, since + // a reworded description would still leave the state representable. + it('exposes no control discriminator on the schema', () => { + const tool = new SendMessageTool(makeTeamConfig()); + const schema = tool.schema.parametersJsonSchema as { + properties: Record; + }; + expect(Object.keys(schema.properties)).not.toContain('type'); + expect(JSON.stringify(schema)).not.toContain('shutdown_request'); }); - it('rejects shutdown_request from a teammate (leader-only)', async () => { - // A teammate calling shutdown_request would impersonate the - // leader, since requestShutdown writes the mailbox entry with - // `from: LEADER_NAME` and arms shutdown_approved tracking. - const requestShutdown = vi.fn().mockResolvedValue(undefined); + it("delivers a teammate's ordinary message to the leader", async () => { + const sendMessage = vi.fn().mockResolvedValue(undefined); const tool = new SendMessageTool( makeTeamConfig({ - teamManager: { - sendMessage: vi.fn(), - broadcast: vi.fn(), - requestShutdown, - }, + teamManager: { sendMessage, broadcast: vi.fn() }, }), ); const invocation = tool.build({ - to: 'bob', - message: 'Please shut down.', - type: 'shutdown_request', + to: 'leader', + message: 'Task completed and verified', }); const result = await runWithTeammateIdentity( { - agentName: 'attacker', + agentName: 'worker', teamName: 'team', - agentId: 'attacker@team', + agentId: 'worker@team', isTeamLead: false, }, () => invocation.execute(new AbortController().signal), ); - expect(result.error).toBeDefined(); - expect(result.llmContent).toContain('Only the team leader'); - expect(requestShutdown).not.toHaveBeenCalled(); + + expect(result.error).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + 'leader', + 'Task completed and verified', + 'worker', + undefined, + ); }); it('blocks plan-required teammates before leader approval', async () => { diff --git a/packages/core/src/tools/send-message.ts b/packages/core/src/tools/send-message.ts index be8efbe509b..b0b7a1933d0 100644 --- a/packages/core/src/tools/send-message.ts +++ b/packages/core/src/tools/send-message.ts @@ -9,8 +9,9 @@ * * Two routing modes: * - Team mode: `to` matches a teammate name (or "*" for broadcast). Messages - * route through TeamManager. Supports structured messages like - * `shutdown_request`. + * route through TeamManager as plain text. This tool carries content only; + * team control actions are separate tools (see `request-shutdown.ts`), so a + * teammate cannot express a control action at all — see #9276. * - Background-task mode: `task_id` matches an entry in the background task * registry. Running tasks receive the message at the next tool-round * boundary; paused recovered tasks are resumed first and take the message as @@ -21,7 +22,7 @@ import type { Config } from '../config/config.js'; import type { PermissionDecision } from '../permissions/types.js'; import { ToolErrorType } from './tool-error.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; -import { getAgentName, isTeammate } from '../agents/team/identity.js'; +import { getAgentName } from '../agents/team/identity.js'; import { LEADER_NAME } from '../agents/team/types.js'; import { getPlanRequiredTeammatePreApprovalMessage, @@ -44,8 +45,6 @@ export interface SendMessageParams { message: string; /** Optional 5-10 word summary for UI display (team mode). */ summary?: string; - /** Structured control message type (team mode). */ - type?: 'shutdown_request'; } class SendMessageInvocation extends BaseToolInvocation< @@ -241,26 +240,6 @@ class SendMessageInvocation extends BaseToolInvocation< } try { - // Structured control messages route through mailbox. - if (this.params.type === 'shutdown_request') { - // Only the leader can request shutdowns. A teammate - // calling this would be impersonating the leader, since - // requestShutdown writes the mailbox entry with - // `from: LEADER_NAME` and arms shutdown_approved tracking - // for the target. - if (isTeammate()) { - const msg = 'Only the team leader can request shutdowns.'; - return { - llmContent: msg, - returnDisplay: msg, - error: { message: msg }, - }; - } - await teamManager.requestShutdown(to); - const msg = `Shutdown requested for "${to}".`; - return { llmContent: msg, returnDisplay: msg }; - } - if (to === '*') { const sender = getAgentName() ?? LEADER_NAME; await teamManager.broadcast(this.params.message, sender); @@ -326,14 +305,6 @@ export class SendMessageTool extends BaseDeclarativeTool< type: 'string', description: 'Optional 5-10 word summary for UI display.', }, - type: { - type: 'string', - enum: ['shutdown_request'], - description: - 'Structured message type for control flow. ' + - 'When set, routes through the mailbox ' + - 'instead of plain text delivery.', - }, }, required: ['message'], additionalProperties: false, @@ -356,8 +327,7 @@ export class SendMessageTool extends BaseDeclarativeTool< * Forward the routing fields and the message verbatim to the classifier — * `to`/`task_id` identify the privileged sink and the `message` itself is * the new instruction the recipient will execute, so the classifier needs - * the full text to evaluate the action's safety. `type` surfaces control - * messages (e.g. shutdown_request). + * the full text to evaluate the action's safety. */ override toAutoClassifierInput( params: SendMessageParams, @@ -366,7 +336,6 @@ export class SendMessageTool extends BaseDeclarativeTool< to: params.to, task_id: params.task_id, message: params.message, - type: params.type, }; } } diff --git a/packages/core/src/tools/team-create.ts b/packages/core/src/tools/team-create.ts index 2c57ce2be64..bff98129a55 100644 --- a/packages/core/src/tools/team-create.ts +++ b/packages/core/src/tools/team-create.ts @@ -229,7 +229,7 @@ This creates: 4. **Assign tasks** using TaskUpdate with \`owner\` to give tasks to idle teammates 5. **Teammates work on assigned tasks** and mark them completed via TaskUpdate 6. **Teammates go idle between turns** - after each turn, teammates automatically go idle and send a notification. IMPORTANT: Be patient with idle teammates! Don't comment on their idleness until it actually impacts your work. -7. **Shutdown your team** - when the task is completed, gracefully shut down your teammates via SendMessage with \`type: "shutdown_request"\` (a top-level parameter alongside \`to\` and \`message\`). +7. **Shutdown your team** - when the task is completed, gracefully shut down each teammate with ${ToolNames.REQUEST_SHUTDOWN} (\`to\`: the teammate name). ${ToolNames.SEND_MESSAGE} carries ordinary text only. ## Task Ownership diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index df17f0956ac..1be239ee781 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -50,6 +50,7 @@ export const ToolNames = { TEAM_CREATE: 'team_create', TEAM_DELETE: 'team_delete', TEAM_PLAN_APPROVAL: 'team_plan_approval', + REQUEST_SHUTDOWN: 'request_shutdown', SEND_MESSAGE: 'send_message', STRUCTURED_OUTPUT: 'structured_output', MONITOR: 'monitor', @@ -110,6 +111,7 @@ export const ToolDisplayNames = { TEAM_CREATE: 'TeamCreate', TEAM_DELETE: 'TeamDelete', TEAM_PLAN_APPROVAL: 'TeamPlanApproval', + REQUEST_SHUTDOWN: 'RequestShutdown', SEND_MESSAGE: 'SendMessage', STRUCTURED_OUTPUT: 'StructuredOutput', MONITOR: 'Monitor', diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index d1b97e20752..15038647464 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -55,6 +55,7 @@ export const TOOL_DISPLAY_NAMES: Record = { team_create: 'TeamCreate', team_delete: 'TeamDelete', team_plan_approval: 'TeamPlanApproval', + request_shutdown: 'RequestShutdown', workflow: 'Workflow', artifact: 'Artifact', record_artifact: 'RecordArtifact', diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index d8ab9370533..a03b927e5a8 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -3133,6 +3133,7 @@ const ZH: Messages = { 'toolName.team_create': '创建团队', 'toolName.team_delete': '删除团队', 'toolName.send_message': '发送消息', + 'toolName.request_shutdown': '请求下线', 'toolName.list_agents': '列出 Agent', 'toolName.structured_output': '结构化输出', 'toolName.monitor': '监控',