diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 068c48f15f..aa4aa5fb22 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -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', + ); }); }); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 64a9914311..91fdaed881 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -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 { 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 { updateOutput?: (output: ToolResultDisplay) => void, ): Promise { // ─── 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 ────────────