Skip to content
Merged
191 changes: 188 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 @@ -306,14 +307,21 @@ describe('AgentCore.prepareTools', () => {
fnDeclarations: FunctionDeclaration[],
): {
core: AgentCore;
debugSpy: ReturnType<typeof vi.fn>;
getFunctionDeclarationsSpy: ReturnType<typeof vi.fn>;
getFunctionDeclarationsFilteredSpy: ReturnType<typeof vi.fn>;
} {
const debugSpy = 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: debugSpy }),
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 +333,12 @@ describe('AgentCore.prepareTools', () => {
{ max_turns: 1 },
toolConfig,
);
return { core, getFunctionDeclarationsSpy };
return {
core,
debugSpy,
getFunctionDeclarationsSpy,
getFunctionDeclarationsFilteredSpy,
};
}

it('wildcard tools:["*"] inherits deferred tools (passes includeDeferred: true)', async () => {
Expand Down Expand Up @@ -358,17 +371,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 +407,167 @@ 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, debugSpy, 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]);
expect(debugSpy).toHaveBeenCalledWith(
`[prepareTools] Filtered "${ToolNames.ENTER_PLAN_MODE}" from explicit subagent tool list`,
);
expect(debugSpy).toHaveBeenCalledWith(
`[prepareTools] Filtered "${ToolNames.EXIT_PLAN_MODE}" from explicit subagent tool list`,
);
});

it('filters inline declarations using the full subagent exclusion floor', async () => {
const inlineSafe = {
name: 'inline_safe',
description: 'safe inline tool',
} as FunctionDeclaration;
const { core, debugSpy } = 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]);
expect(debugSpy).toHaveBeenCalledWith(
`[prepareTools] Filtered inline declaration "${ToolNames.SEND_MESSAGE}" from subagent tool list`,
);
expect(debugSpy).toHaveBeenCalledWith(
`[prepareTools] Filtered inline declaration "${ToolNames.TASK_UPDATE}" from subagent tool list`,
);
expect(debugSpy).toHaveBeenCalledWith(
`[prepareTools] Filtered inline declaration "${ToolNames.ENTER_PLAN_MODE}" from subagent tool list`,
);
expect(debugSpy).toHaveBeenCalledWith(
`[prepareTools] Filtered inline declaration "${ToolNames.EXIT_PLAN_MODE}" from subagent tool list`,
);
});

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');
expect(response?.error).not.toContain('not found');
},
);
});
47 changes: 36 additions & 11 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 @@ -500,17 +506,34 @@ export class AgentCore {
// the recursion guard). This prevents control-plane tools
// (CRON_CREATE, TASK_STOP, SEND_MESSAGE, etc.) from leaking into
// explicitly-configured subagents that happen to list them.
const allowedNames = asStrings.filter((name) => {
if (excludedFromSubagents.has(name)) {
this.runtimeContext
.getDebugLogger()
?.debug(
`[prepareTools] Filtered "${name}" from explicit subagent tool list`,
);
return false;
}
return true;
});
toolsList.push(
...toolRegistry.getFunctionDeclarationsFiltered(
asStrings.filter((name) => !excludedFromSubagents.has(name)),
),
...toolRegistry.getFunctionDeclarationsFiltered(allowedNames),
);
}
// Also filter inline FunctionDeclaration[] passed directly in toolConfig.
toolsList.push(
...onlyInlineDecls.filter(
(d) => !(d.name && recursionGuardOnly.has(d.name)),
),
...onlyInlineDecls.filter((d) => {
if (d.name && excludedFromSubagents.has(d.name)) {
this.runtimeContext
.getDebugLogger()
?.debug(
`[prepareTools] Filtered inline declaration "${d.name}" from subagent tool list`,
);
return false;
}
return true;
}),
);
} else {
// Inherit all available tools by default when not specified — see the
Expand Down Expand Up @@ -1205,7 +1228,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
Loading
Loading