Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ca.js
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,7 @@ export default {
'toolDisplayName.TeamCreate': 'Crea equip',
'toolDisplayName.TeamDelete': 'Suprimeix equip',
'toolDisplayName.TeamPlanApproval': "Aprovació del pla d'equip",
'toolDisplayName.RequestShutdown': 'RequestShutdown',
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
'toolDisplayName.SendMessage': 'Envia missatge',
'toolDisplayName.StructuredOutput': 'Sortida estructurada',
'toolDisplayName.Monitor': 'Monitor',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ export default {
'toolDisplayName.TeamDelete': '刪除團隊',
'toolDisplayName.TeamPlanApproval': '團隊計畫審批',
'toolDisplayName.SendMessage': '傳送訊息',
'toolDisplayName.RequestShutdown': '請求下線',
'toolDisplayName.StructuredOutput': '結構化輸出',
'toolDisplayName.Monitor': '監控',
'toolDisplayName.NotebookEdit': '編輯 Notebook',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export default {
'toolDisplayName.TeamDelete': '删除团队',
'toolDisplayName.TeamPlanApproval': '团队计划审批',
'toolDisplayName.SendMessage': '发送消息',
'toolDisplayName.RequestShutdown': '请求下线',
'toolDisplayName.StructuredOutput': '结构化输出',
'toolDisplayName.Monitor': '监控',
'toolDisplayName.NotebookEdit': '编辑 Notebook',
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7733,6 +7733,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 {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8750,6 +8750,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) {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
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);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/skills/bundled/coordinate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>`. 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.
Expand All @@ -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.

Expand Down
152 changes: 152 additions & 0 deletions packages/core/src/tools/request-shutdown.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
} | 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<string, unknown>;
};
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();
});
});
Loading
Loading