fix(core): ignore agent names without active teams (#5115)

This commit is contained in:
Yufeng He 2026-06-15 02:39:32 +08:00 committed by GitHub
parent 75e8f259c4
commit 4748705bc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 32 deletions

View file

@ -338,6 +338,35 @@ describe('AgentTool', () => {
]);
});
it('does not expose teammate name when teams are disabled', () => {
const schema = agentTool.schema;
const parameters = schema.parametersJsonSchema as {
properties: {
name?: unknown;
};
};
expect(parameters.properties.name).toBeUndefined();
});
it('exposes teammate name when teams are enabled', async () => {
vi.mocked(config.isAgentTeamEnabled).mockReturnValue(true);
const teamAgentTool = new AgentTool(config);
await vi.runAllTimersAsync();
const schema = teamAgentTool.schema;
const parameters = schema.parametersJsonSchema as {
properties: {
name?: {
description?: string;
};
};
};
expect(parameters.properties.name?.description).toContain('active team');
});
it('should generate schema without enum when no subagents available', async () => {
vi.mocked(mockSubagentManager.listSubagents).mockResolvedValue([]);
@ -519,8 +548,10 @@ describe('AgentTool', () => {
});
describe('team routing', () => {
it('rejects `name` when no team is active', async () => {
it('falls back to one-shot when `name` is supplied without a team', async () => {
vi.mocked(config.getTeamManager).mockReturnValue(null);
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(null);
const invocation = agentTool.build({
description: 'Spawn helper',
prompt: 'Do work',
@ -528,11 +559,11 @@ describe('AgentTool', () => {
name: 'helper',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('no active team');
expect(result.llmContent).toContain('"helper"');
// Subagent must NOT have been spawned as a one-shot fallback.
expect(mockSubagentManager.loadSubagent).not.toHaveBeenCalled();
expect(result.llmContent).not.toContain('no active team');
expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith(
'file-search',
);
});
});

View file

@ -203,6 +203,13 @@ export interface AgentParams {
const debugLogger = createDebugLogger('AGENT');
const TEAM_AGENT_NAME_PROPERTY = {
type: 'string',
description:
'When provided, spawn as a named teammate via the active team ' +
'instead of a one-shot subagent. Requires an active team context.',
};
/**
* Maps ApprovalMode to PermissionMode for hook events.
*/
@ -540,12 +547,9 @@ export class AgentTool extends BaseDeclarativeTool<AgentParams, ToolResult> {
description:
'Set to true to run this agent in the background. You will be notified when it completes.',
},
name: {
type: 'string',
description:
'When provided, spawn as a named teammate via the active team ' +
'instead of a one-shot subagent. Requires an active team context.',
},
...(config.isAgentTeamEnabled()
? { name: TEAM_AGENT_NAME_PROPERTY }
: {}),
isolation: {
type: 'string',
enum: ['worktree'],
@ -725,6 +729,7 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent
subagent_type?: {
enum?: string[];
};
name?: typeof TEAM_AGENT_NAME_PROPERTY;
};
};
if (schema.properties && schema.properties.subagent_type) {
@ -734,6 +739,13 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent
delete schema.properties.subagent_type.enum;
}
}
if (schema.properties) {
if (this.config.isAgentTeamEnabled()) {
schema.properties.name = TEAM_AGENT_NAME_PROPERTY;
} else {
delete schema.properties.name;
}
}
}
override validateToolParams(params: AgentParams): string | null {
@ -1630,29 +1642,17 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<ToolResult> {
// ─── Team routing ────────────────────────────────────
// When a team is active AND the caller passed an explicit
// `name`, route through TeamManager as a named teammate.
// Without a name we fall through to the regular one-shot
// subagent flow — the schema says "When provided, spawn as
// a named teammate," so the absence of `name` means the
// caller wants a one-shot, not a teammate.
// A name only means "spawn a teammate" while a team is active. Older
// prompts may still pass it without a team; treat that as a normal
// one-shot agent instead of failing the whole task.
if (this.params.name && !isTeammate()) {
// The schema for `name` says it requires an active team,
// so reject the call up front instead of silently launching
// a different kind of agent (one-shot subagent) that
// ignores the supplied name.
if (!this.config.getTeamManager()) {
const msg =
`Cannot spawn teammate "${this.params.name}": no active team. ` +
`Use team_create to start a team first, or omit "name" to ` +
`launch a one-shot subagent.`;
return {
llmContent: msg,
returnDisplay: msg,
error: { message: msg },
};
debugLogger.debug(
`[AgentTool] Ignoring teammate name "${this.params.name}" because no team is active.`,
);
} else {
return this.executeTeammate(this.params.name, signal, updateOutput);
}
return this.executeTeammate(this.params.name, signal, updateOutput);
}
// ── Isolation state hoisted to the outermost scope ────────────