Skip to content
Merged
168 changes: 165 additions & 3 deletions packages/core/src/agents/runtime/agent-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
ContentGenerator,
ContentGeneratorConfig,
} from '../../core/contentGenerator.js';
import { ToolNames } from '../../tools/tool-names.js';

describe('AgentCore.runInAgentFrames', () => {
// The deferred-approval `respond` callback that AgentCore hands to the
Expand Down Expand Up @@ -307,13 +308,18 @@ describe('AgentCore.prepareTools', () => {
): {
core: AgentCore;
getFunctionDeclarationsSpy: ReturnType<typeof vi.fn>;
getFunctionDeclarationsFilteredSpy: ReturnType<typeof vi.fn>;
} {
const getFunctionDeclarationsSpy = vi.fn().mockReturnValue(fnDeclarations);
const getFunctionDeclarationsFilteredSpy = vi.fn((names: string[]) =>
fnDeclarations.filter((d) => d.name && names.includes(d.name)),
);
const config = {
getDebugLogger: vi.fn().mockReturnValue({ debug: vi.fn() }),
getToolRegistry: vi.fn().mockReturnValue({
warmAll: vi.fn().mockResolvedValue(undefined),
getFunctionDeclarations: getFunctionDeclarationsSpy,
getFunctionDeclarationsFiltered: vi.fn().mockReturnValue([]),
getFunctionDeclarationsFiltered: getFunctionDeclarationsFilteredSpy,
}),
} as unknown as Config;

Expand All @@ -325,7 +331,11 @@ describe('AgentCore.prepareTools', () => {
{ max_turns: 1 },
toolConfig,
);
return { core, getFunctionDeclarationsSpy };
return {
core,
getFunctionDeclarationsSpy,
getFunctionDeclarationsFilteredSpy,
};
}

it('wildcard tools:["*"] inherits deferred tools (passes includeDeferred: true)', async () => {
Expand Down Expand Up @@ -358,17 +368,26 @@ describe('AgentCore.prepareTools', () => {
it('absent toolConfig also inherits deferred tools (default = wildcard)', async () => {
const fnDecls: FunctionDeclaration[] = [
{ name: 'lsp', description: 'language server' } as FunctionDeclaration,
{
name: ToolNames.ENTER_PLAN_MODE,
description: 'enter plan mode',
} as FunctionDeclaration,
{
name: ToolNames.EXIT_PLAN_MODE,
description: 'exit plan mode',
} as FunctionDeclaration,
];
const { core, getFunctionDeclarationsSpy } = buildAgentForTools(
undefined,
fnDecls,
);

await core.prepareTools();
const tools = await core.prepareTools();

expect(getFunctionDeclarationsSpy).toHaveBeenCalledWith({
includeDeferred: true,
});
expect(tools.map((t) => t.name)).toEqual(['lsp']);
});

it('explicit tools list does NOT use the wildcard inherit path', async () => {
Expand All @@ -385,4 +404,147 @@ describe('AgentCore.prepareTools', () => {

expect(getFunctionDeclarationsSpy).not.toHaveBeenCalled();
});

it('excludes plan lifecycle tools from wildcard/default subagent tools', async () => {
const fnDecls: FunctionDeclaration[] = [
{ name: 'core_tool', description: 'core' } as FunctionDeclaration,
{
name: ToolNames.ENTER_PLAN_MODE,
description: 'enter plan mode',
} as FunctionDeclaration,
{
name: ToolNames.EXIT_PLAN_MODE,
description: 'exit plan mode',
} as FunctionDeclaration,
];
const { core } = buildAgentForTools({ tools: ['*'] }, fnDecls);

const tools = await core.prepareTools();

expect(tools.map((t) => t.name)).toEqual(['core_tool']);
});

it('does not re-enable plan lifecycle tools via explicit tool names', async () => {
const fnDecls: FunctionDeclaration[] = [
{ name: ToolNames.READ_FILE, description: 'read' } as FunctionDeclaration,
{
name: ToolNames.ENTER_PLAN_MODE,
description: 'enter plan mode',
} as FunctionDeclaration,
{
name: ToolNames.EXIT_PLAN_MODE,
description: 'exit plan mode',
} as FunctionDeclaration,
];
const { core, getFunctionDeclarationsFilteredSpy } = buildAgentForTools(
{
tools: [
ToolNames.READ_FILE,
ToolNames.ENTER_PLAN_MODE,
ToolNames.EXIT_PLAN_MODE,
],
},
fnDecls,
);

const tools = await core.prepareTools();

expect(getFunctionDeclarationsFilteredSpy).toHaveBeenCalledWith([
ToolNames.READ_FILE,
]);
expect(tools.map((t) => t.name)).toEqual([ToolNames.READ_FILE]);
});

it('filters inline declarations using the full subagent exclusion floor', async () => {
const inlineSafe = {
name: 'inline_safe',
description: 'safe inline tool',
} as FunctionDeclaration;
const { core } = buildAgentForTools(
{
tools: [
{ name: ToolNames.SEND_MESSAGE } as FunctionDeclaration,
{ name: ToolNames.TASK_UPDATE } as FunctionDeclaration,
{ name: ToolNames.ENTER_PLAN_MODE } as FunctionDeclaration,
{ name: ToolNames.EXIT_PLAN_MODE } as FunctionDeclaration,
inlineSafe,
],
},
[],
);

const tools = await core.prepareTools();

expect(tools).toEqual([inlineSafe]);
});

it('keeps teammate coordination tools but excludes plan lifecycle tools', async () => {
const fnDecls: FunctionDeclaration[] = [
{
name: ToolNames.SEND_MESSAGE,
description: 'send message',
} as FunctionDeclaration,
{
name: ToolNames.TASK_UPDATE,
description: 'task update',
} as FunctionDeclaration,
{
name: ToolNames.ENTER_PLAN_MODE,
description: 'enter plan mode',
} as FunctionDeclaration,
{
name: ToolNames.EXIT_PLAN_MODE,
description: 'exit plan mode',
} as FunctionDeclaration,
];
const { core } = buildAgentForTools({ tools: ['*'] }, fnDecls);

let tools: FunctionDeclaration[] = [];
await runWithTeammateIdentity(
{
agentId: 'agent@test',
agentName: 'agent',
teamName: 'test',
isTeamLead: false,
},
async () => {
tools = await core.prepareTools();
},
);

expect(tools.map((t) => t.name)).toEqual([
ToolNames.SEND_MESSAGE,
ToolNames.TASK_UPDATE,
]);
});

it.each([ToolNames.ENTER_PLAN_MODE, ToolNames.EXIT_PLAN_MODE])(
'returns a dedicated message when filtered %s is called directly',
async (toolName) => {
const { core } = buildAgentForTools(undefined, []);

const result = await runWithAgentContext('test-subagent', () =>
core.runInAgentFrames(() =>
core.processFunctionCalls(
[
{
name: toolName,
args: { plan: 'Plan from filtered tool' },
id: 'call-1',
},
],
new AbortController(),
'prompt-filtered-plan-tool',
1,
[{ name: ToolNames.READ_FILE } as FunctionDeclaration],
),
),
);

const response = result.messages[0]?.parts?.[0]?.functionResponse
?.response as { error?: string } | undefined;
expect(response?.error).toContain('not available inside subagents');
expect(response?.error).toContain('return your plan');
},
);
});
20 changes: 14 additions & 6 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ import {
runWithTeammateIdentity,
} from '../team/identity.js';
import type { TeammateIdentity } from '../team/types.js';
import {
getSubagentPlanToolUnavailableMessage,
isPlanLifecycleToolUnavailableInSubagent,
SUBAGENT_PLAN_LIFECYCLE_TOOLS,
} from './subagent-plan-tool-policy.js';

/**
* Result of a single reasoning loop invocation.
Expand All @@ -109,6 +114,8 @@ import type { TeammateIdentity } from '../team/types.js';
* non-team Agent subagent has no teammate identity, so isTeammate()
* returns false and these tools would treat it as the leader — letting
* it delete or rewrite the active team.
* - Plan lifecycle tools are owned by the caller/main session. A subagent
* should return its plan to the caller instead of entering or exiting mode.
*/
export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = new Set([
ToolNames.AGENT,
Expand All @@ -122,6 +129,7 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = new Set([
ToolNames.TASK_CREATE,
ToolNames.TASK_UPDATE,
ToolNames.TASK_LIST,
...SUBAGENT_PLAN_LIFECYCLE_TOOLS,
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
// Worktree management belongs to the parent session — a subagent must
// never enter or exit the user's worktree state independently.
ToolNames.ENTER_WORKTREE,
Expand All @@ -136,6 +144,7 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = new Set([
* Tools excluded from teammates. Teammates need send_message and the
* task_* coordination tools to do their job, but they must not be able
* to create or destroy the team itself — only the leader can do that.
* Plan lifecycle tools remain caller-owned for teammates too.
*/
const EXCLUDED_TOOLS_FOR_TEAMMATES: ReadonlySet<string> = new Set([
ToolNames.AGENT,
Expand All @@ -145,6 +154,7 @@ const EXCLUDED_TOOLS_FOR_TEAMMATES: ReadonlySet<string> = new Set([
ToolNames.TASK_STOP,
ToolNames.TEAM_CREATE,
ToolNames.TEAM_DELETE,
...SUBAGENT_PLAN_LIFECYCLE_TOOLS,
// Worktree management belongs to the parent session.
ToolNames.ENTER_WORKTREE,
ToolNames.EXIT_WORKTREE,
Expand Down Expand Up @@ -469,10 +479,6 @@ export class AgentCore {
const excludedFromSubagents = isTeammate()
? EXCLUDED_TOOLS_FOR_TEAMMATES
: EXCLUDED_TOOLS_FOR_SUBAGENTS;
// When a subagent has an explicit tools list (not wildcard), only the
// recursive-spawn guard (AgentTool) is enforced.
const recursionGuardOnly = new Set<string>([ToolNames.AGENT]);

if (this.toolConfig) {
const asStrings = this.toolConfig.tools.filter(
(t): t is string => typeof t === 'string',
Expand Down Expand Up @@ -509,7 +515,7 @@ export class AgentCore {
// Also filter inline FunctionDeclaration[] passed directly in toolConfig.
toolsList.push(
...onlyInlineDecls.filter(
(d) => !(d.name && recursionGuardOnly.has(d.name)),
(d) => !(d.name && excludedFromSubagents.has(d.name)),
),
);
} else {
Expand Down Expand Up @@ -1205,7 +1211,9 @@ export class AgentCore {
const args = (fc.args ?? {}) as Record<string, unknown>;

if (!allowedToolNames.has(fc.name)) {
const errorMessage = `Tool "${toolName}" not found. Tools must use the exact names provided.`;
const errorMessage = isPlanLifecycleToolUnavailableInSubagent(toolName)
? getSubagentPlanToolUnavailableMessage(toolName)
: `Tool "${toolName}" not found. Tools must use the exact names provided.`;
const functionResponsePart = {
functionResponse: {
id: callId,
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/agents/runtime/subagent-plan-tool-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { ToolNames } from '../../tools/tool-names.js';
import { isTeammate } from '../team/identity.js';
import { getCurrentAgentId } from './agent-context.js';

export const SUBAGENT_PLAN_LIFECYCLE_TOOLS: ReadonlySet<string> = new Set([
ToolNames.ENTER_PLAN_MODE,
ToolNames.EXIT_PLAN_MODE,
]);

export function isSubagentLikeExecutionContext(): boolean {
return getCurrentAgentId() !== null || isTeammate();
}

export function isSubagentPlanLifecycleTool(toolName: string): boolean {
return SUBAGENT_PLAN_LIFECYCLE_TOOLS.has(toolName);
}

export function isPlanLifecycleToolUnavailableInSubagent(
toolName: string,
): boolean {
return (
isSubagentLikeExecutionContext() && isSubagentPlanLifecycleTool(toolName)
);
}

export function getSubagentPlanToolUnavailableMessage(
toolName: string,
): string {
return `${toolName} is not available inside subagents or team agents. Plan mode is owned by the caller/main session; return your plan, findings, or constraints to the caller in your normal response instead of entering or exiting plan mode.`;
}
29 changes: 22 additions & 7 deletions packages/core/src/agents/runtime/workflow-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,13 +1156,14 @@ describe('createProductionDispatch', () => {
});
});

// T11: disallow SendMessage / ExitPlanMode to mirror upstream Tg8.
it('disallows SendMessage and ExitPlanMode for workflow subagents', async () => {
// T11: disallow SendMessage plus plan lifecycle tools to mirror upstream Tg8.
it('disallows SendMessage and plan lifecycle tools for workflow subagents', async () => {
const dispatch = createProductionDispatch(fakeConfig());
await dispatch('hello', { label: 'h1' });
expect(created[0]!.toolConfig?.tools).toEqual(['*']);
expect(created[0]!.toolConfig?.disallowedTools).toEqual([
'send_message',
'enter_plan_mode',
'exit_plan_mode',
]);
});
Expand Down Expand Up @@ -1932,9 +1933,13 @@ describe('WorkflowOrchestrator P3 — agentType / model / isolation / schema', (
expect(result).toBe('explore-output');
expect(calls).toHaveLength(1);
expect(calls[0].config.name).toBe('Explore');
// Workflow floor [SendMessage, ExitPlanMode] must be unioned in.
// Workflow floor [SendMessage, EnterPlanMode, ExitPlanMode] must be unioned in.
expect(calls[0].config.disallowedTools).toEqual(
expect.arrayContaining(['send_message', 'exit_plan_mode']),
expect.arrayContaining([
'send_message',
'enter_plan_mode',
'exit_plan_mode',
]),
);
});

Expand Down Expand Up @@ -2008,9 +2013,14 @@ describe('WorkflowOrchestrator P3 — agentType / model / isolation / schema', (
const dispatch = createProductionDispatch(config);
await dispatch('hi', { agentType: 'Permissive' });
const disallowed = calls[0].config.disallowedTools ?? [];
// Union: Foo (from agentType) + send_message + exit_plan_mode (floor).
// Union: Foo (from agentType) + send_message + plan lifecycle floor.
expect(disallowed).toEqual(
expect.arrayContaining(['Foo', 'send_message', 'exit_plan_mode']),
expect.arrayContaining([
'Foo',
'send_message',
'enter_plan_mode',
'exit_plan_mode',
]),
);
});

Expand Down Expand Up @@ -2404,7 +2414,12 @@ describe('WorkflowOrchestrator P3 — agentType / model / isolation / schema', (
});
const disallowed = calls[0].config.disallowedTools ?? [];
expect(disallowed).toEqual(
expect.arrayContaining(['Foo', 'send_message', 'exit_plan_mode']),
expect.arrayContaining([
'Foo',
'send_message',
'enter_plan_mode',
'exit_plan_mode',
]),
);
});

Expand Down
Loading
Loading