diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index 247d04525cd..ed7d9ef7260 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -5,6 +5,7 @@ export default { 'tool-use-summaries': 'Tool-Use Summaries', 'markdown-rendering': 'Markdown Rendering', 'sub-agents': 'SubAgents', + 'multi-agent-coordination': 'Multi-Agent Coordination', arena: 'Agent Arena', skills: 'Skills', memory: 'Memory', diff --git a/docs/users/features/arena.md b/docs/users/features/arena.md index 6f55aeae4b0..c8e3558182d 100644 --- a/docs/users/features/arena.md +++ b/docs/users/features/arena.md @@ -200,14 +200,14 @@ Agent Arena is experimental. Current limitations: ## Comparison with other multi-agent modes -Agent Arena is one of several planned multi-agent modes in Qwen Code. **Agent Team** and **Agent Swarm** are not yet implemented — the table below describes their intended design for reference. +Agent Arena and the experimental Agent Team runtime serve different multi-agent workflows. Agent Swarm remains a planned mode. -| | **Agent Arena** | **Agent Team** (planned) | **Agent Swarm** (planned) | +| | **Agent Arena** | **Agent Team** | **Agent Swarm** (planned) | | :---------------- | :----------------------------------------------------- | :------------------------------------------------- | :------------------------------------------------------- | | **Goal** | Competitive: Find the best solution to the _same_ task | Collaborative: Tackle _different_ aspects together | Batch parallel: Dynamically spawn workers for bulk tasks | | **Agents** | Pre-configured models compete independently | Teammates collaborate with assigned roles | Workers spawned on-the-fly, destroyed on completion | | **Communication** | No inter-agent communication | Direct peer-to-peer messaging | One-way: results aggregated by parent | -| **Isolation** | Full: separate Git worktrees | Independent sessions with shared task list | Lightweight ephemeral context per worker | +| **Isolation** | Full: separate Git worktrees | In-process teammates with a shared task list | Lightweight ephemeral context per worker | | **Output** | One selected solution applied to workspace | Synthesized results from multiple perspectives | Aggregated results from parallel processing | | **Best for** | Benchmarking, choosing between model approaches | Research, complex collaboration, cross-layer work | Batch operations, data processing, map-reduce tasks | @@ -216,4 +216,5 @@ Agent Arena is one of several planned multi-agent modes in Qwen Code. **Agent Te Explore related approaches for parallel and delegated work: - **Lightweight delegation**: [Subagents](./sub-agents.md) handle focused subtasks within your session — better when you don't need model comparison +- **Collaborative execution**: [Multi-Agent Coordination](./multi-agent-coordination.md) uses Agent Team for shared tasks and teammate messaging - **Manual parallel sessions**: Run multiple Qwen Code sessions yourself in separate terminals with [Git worktrees](https://git-scm.com/docs/git-worktree) for full manual control diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index f17bc7e60fe..41a5e44ea8d 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -139,12 +139,13 @@ Commands for managing AI tools and models. These commands invoke bundled skills that provide specialized workflows. -| Command | Description | Usage Examples | -| ------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------- | -| `/review` | Multi-agent code review (12 parallel agents at high effort) | `/review`, `/review 123`, `/review 123 --comment`, `/review --effort low` | -| `/loop` | Run a prompt on a recurring schedule | `/loop 5m check the build` | -| `/simplify` | Review recent changes and apply safe cleanup edits directly | `/simplify`, `/simplify focus on duplication` | -| `/qc-helper` | Answer questions about Qwen Code usage and configuration | `/qc-helper how do I configure MCP?` | +| Command | Description | Usage Examples | +| ------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `/review` | Multi-agent code review (12 parallel agents at high effort) | `/review`, `/review 123`, `/review 123 --comment`, `/review --effort low` | +| `/coordinate` | Coordinate read-only workers and one optional worktree writer | `/coordinate investigate and fix the authentication regression` | +| `/loop` | Run a prompt on a recurring schedule | `/loop 5m check the build` | +| `/simplify` | Review recent changes and apply safe cleanup edits directly | `/simplify`, `/simplify focus on duplication` | +| `/qc-helper` | Answer questions about Qwen Code usage and configuration | `/qc-helper how do I configure MCP?` | See [Code Review](./code-review.md) for full `/review` documentation. diff --git a/docs/users/features/multi-agent-coordination.md b/docs/users/features/multi-agent-coordination.md new file mode 100644 index 00000000000..aeef7c79ef2 --- /dev/null +++ b/docs/users/features/multi-agent-coordination.md @@ -0,0 +1,30 @@ +# Multi-Agent Coordination + +Qwen Code can coordinate several teammates with the experimental Agent Team runtime. Teammates receive separate tasks, share a task list, exchange messages, and appear in the existing Agent View tabs. `/coordinate` defaults investigation workers to an enforced read-only tool set and can place one writer in a leader-owned Git worktree. + +## Enable Agent Team + +Set `experimental.agentTeam` to `true` in Qwen Code settings and restart, or start Qwen Code with `QWEN_CODE_ENABLE_AGENT_TEAM=1`. + +## Run a coordinated task + +Use the bundled skill with a goal: + +```text +/coordinate investigate the authentication regression and propose the smallest fix +``` + +The leader creates a team, assigns up to three independent workstreams, and uses the existing team tools for messages and task state. Teammate conversations and approvals remain visible through the existing Agent View UI. Read-only teammates cannot execute shell commands or write files. If implementation is needed, the leader can create one Git worktree and pin one writer teammate to it; the leader remains the only merge authority for the current branch. + +If Agent Team is disabled, `/coordinate` can still use ordinary foreground agents for read-only parallel investigation. That fallback is delegation, not a collaborating team: the workers report only to the leader. + +## Choosing the right multi-agent mode + +| Mode | Use it for | Communication | Workspace behavior | +| ----------------------------- | --------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------- | +| `/coordinate` with Agent Team | Different workstreams contributing to one result | Shared tasks and teammate messages | Enforced read-only workers; optional single worktree writer | +| Subagents | Small delegated tasks | Worker reports to parent | Depends on the selected agent | +| Arena | Several models competing on the same task | Agents do not collaborate | Isolated worktrees; one winner is selected | +| Herdr | Coordinating different CLI products or remote terminal sessions | External terminal-level control | Managed outside Qwen Code | + +The current workflow deliberately reuses the in-process Agent Team runtime and Agent View UI. Teammates normally inherit the session model, although an agent definition can override it. Persistent independent PTY sessions, cross-vendor workers, and remote attach are separate product concerns and are not implemented by `/coordinate`. diff --git a/packages/core/src/agents/runtime/subagent-plan-tool-policy.ts b/packages/core/src/agents/runtime/subagent-plan-tool-policy.ts index 638643d1a53..87a6077b50d 100644 --- a/packages/core/src/agents/runtime/subagent-plan-tool-policy.ts +++ b/packages/core/src/agents/runtime/subagent-plan-tool-policy.ts @@ -16,16 +16,20 @@ export const SUBAGENT_PLAN_LIFECYCLE_TOOLS: ReadonlySet = new Set([ ToolNames.EXIT_PLAN_MODE, ]); -const PLAN_REQUIRED_TEAMMATE_PRE_APPROVAL_TOOLS: ReadonlySet = new Set([ - ToolNames.EXIT_PLAN_MODE, +export const READ_ONLY_INSPECTION_TOOLS: readonly string[] = [ ToolNames.READ_FILE, ToolNames.GREP, ToolNames.GLOB, ToolNames.LS, ToolNames.LSP, - ToolNames.TASK_LIST, ToolNames.TOOL_SEARCH, ToolNames.READ_MCP_RESOURCE, +]; + +const PLAN_REQUIRED_TEAMMATE_PRE_APPROVAL_TOOLS: ReadonlySet = new Set([ + ToolNames.EXIT_PLAN_MODE, + ToolNames.TASK_LIST, + ...READ_ONLY_INSPECTION_TOOLS, ]); const PRE_APPROVAL_TASK_CLAIM_KEYS: ReadonlySet = new Set([ diff --git a/packages/core/src/agents/team/TeamManager.ts b/packages/core/src/agents/team/TeamManager.ts index 365132176b2..644e0efd460 100644 --- a/packages/core/src/agents/team/TeamManager.ts +++ b/packages/core/src/agents/team/TeamManager.ts @@ -30,6 +30,7 @@ import { PermissionMode } from '../../hooks/types.js'; import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; import { AgentEventType } from '../runtime/agent-events.js'; import type { + AgentRoundTextEvent, AgentStatusChangeEvent, AgentToolCallEvent, AgentToolResultEvent, @@ -75,6 +76,8 @@ import { runWithTeammateIdentity } from './identity.js'; import type { SubagentManager } from '../../subagents/subagent-manager.js'; import type { ToolConfig } from '../runtime/agent-types.js'; import { runOutsideAgentContext } from '../runtime/agent-context.js'; +import { READ_ONLY_INSPECTION_TOOLS } from '../runtime/subagent-plan-tool-policy.js'; +import { ToolNames } from '../../tools/tool-names.js'; const debug = createDebugLogger('AGENTS_TEAM_MANAGER'); @@ -98,6 +101,8 @@ export interface TeammateSpawnConfig { cwd?: string; /** Start this teammate in plan mode and require leader plan approval. */ planModeRequired?: boolean; + /** Restrict this teammate to read-only inspection and team coordination. */ + readOnly?: boolean; } export interface TeamPlanApprovalRequest { @@ -188,9 +193,15 @@ export class TeamManager { * agentId so we can release each agent's listeners as soon as * it reaches a terminal status — not just at full team * cleanup. Otherwise long-running sessions accumulate dead - * listeners (4 per spawn) on shared emitters. */ + * listeners (5 per spawn) on shared emitters. */ private readonly eventBridgeCleanups = new Map void>(); + /** Last model-visible answer from each teammate's active turn. */ + private readonly pendingFinalReports = new Map(); + + /** Teammates that explicitly reported to the leader during this turn. */ + private readonly explicitLeaderReports = new Set(); + /** Unsubscribe from task update notifications. */ private taskUpdateUnsubscribe?: () => void; @@ -293,6 +304,7 @@ export class TeamManager { isActive: undefined, subscriptions: [], planModeRequired: config.planModeRequired || undefined, + readOnly: config.readOnly || undefined, mode: config.planModeRequired ? PermissionMode.Plan : undefined, }; @@ -321,6 +333,8 @@ export class TeamManager { const idx = this.teamFile.members.indexOf(member); if (idx !== -1) this.teamFile.members.splice(idx, 1); this.pendingMessages.delete(agentId); + this.pendingFinalReports.delete(agentId); + this.explicitLeaderReports.delete(agentId); this.lastActivityAt.delete(agentId); this.agentIdentities.delete(agentId); if (eventBridgeAttached) { @@ -394,12 +408,28 @@ export class TeamManager { } } + if (config.readOnly) { + const tools = [ + ...READ_ONLY_INSPECTION_TOOLS, + ToolNames.SEND_MESSAGE, + ToolNames.TASK_LIST, + ToolNames.TASK_UPDATE, + ]; + toolConfig = { + tools: [...tools], + executionAllowedTools: [...tools], + }; + } + // Build system prompt: subagent prompt (if any) or user prompt + team addendum. const addendum = buildTeammatePromptAddendum( name, this.teamFile.name, LEADER_NAME, - { planModeRequired: config.planModeRequired }, + { + planModeRequired: config.planModeRequired, + readOnly: config.readOnly, + }, ); const basePrompt = subagentPrompt ?? config.prompt; const systemPrompt = basePrompt @@ -506,6 +536,7 @@ export class TeamManager { message: string, from?: string, summary?: string, + automatic = false, ): Promise { // Messages addressed to the leader go to leader's mailbox. if ( @@ -525,7 +556,7 @@ export class TeamManager { ? findMemberByName(this.teamFile.members, from) : undefined; const shutdownResponse = - sender && this._shutdownPending.has(sender.name) + sender && !automatic && this._shutdownPending.has(sender.name) ? classifyShutdownResponse(message) : undefined; @@ -537,6 +568,9 @@ export class TeamManager { read: false, type: shutdownResponse, }); + if (sender && !automatic) { + this.explicitLeaderReports.add(sender.agentId); + } this.teamEventEmitter.emit(TeamEventType.MESSAGE_SENT, { from: from ?? 'unknown', to: LEADER_NAME, @@ -1360,6 +1394,8 @@ export class TeamManager { } this.pendingMessages.clear(); + this.pendingFinalReports.clear(); + this.explicitLeaderReports.clear(); this.lastActivityAt.clear(); this.agentIdentities.clear(); this.teamEventEmitter.removeAllListeners(); @@ -1403,6 +1439,11 @@ export class TeamManager { const onStatusChange = (event: AgentStatusChangeEvent) => { recordActivity(); + if (event.newStatus === AgentStatus.RUNNING) { + this.pendingFinalReports.delete(agentId); + this.explicitLeaderReports.delete(agentId); + } + this.teamEventEmitter.emit(TeamEventType.TEAMMATE_STATUS_CHANGE, { agentId, name: agentName, @@ -1412,6 +1453,25 @@ export class TeamManager { }); if (event.newStatus === AgentStatus.IDLE) { + const finalReport = this.pendingFinalReports.get(agentId); + const explicitlyReported = this.explicitLeaderReports.has(agentId); + this.pendingFinalReports.delete(agentId); + this.explicitLeaderReports.delete(agentId); + + if (!explicitlyReported && !event.roundCancelledByUser) { + this.fireAndForget( + `reportFinalAnswer(${agentId})`, + this.sendMessage( + LEADER_NAME, + finalReport ?? + `${agentName} completed a turn without a model-visible final answer. Check the shared task list or send a follow-up if more detail is needed.`, + agentName, + `${agentName} completed a turn`, + true, + ), + ); + } + this.teamEventEmitter.emit(TeamEventType.TEAMMATE_IDLE, { agentId, name: agentName, @@ -1463,6 +1523,8 @@ export class TeamManager { // lost — better to refuse the send (handled at sendMessage // by the missing entry) than accept it and drop it. this.pendingMessages.delete(agentId); + this.pendingFinalReports.delete(agentId); + this.explicitLeaderReports.delete(agentId); this.lastActivityAt.delete(agentId); this.agentIdentities.delete(agentId); this._shutdownPending.delete(agentName); @@ -1483,7 +1545,18 @@ export class TeamManager { recordActivity(); }; + const onRoundText = (event: AgentRoundTextEvent) => { + recordActivity(); + const text = event.text.trim(); + this.pendingFinalReports.delete(agentId); + if (text) { + this.pendingFinalReports.set(agentId, text); + this.explicitLeaderReports.delete(agentId); + } + }; + emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.on(AgentEventType.ROUND_TEXT, onRoundText); emitter.on(AgentEventType.TOOL_CALL, onToolCall); emitter.on(AgentEventType.TOOL_RESULT, onToolResult); @@ -1513,6 +1586,7 @@ export class TeamManager { // release this agent's listeners on terminal status. this.eventBridgeCleanups.set(agentId, () => { emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.off(AgentEventType.ROUND_TEXT, onRoundText); emitter.off(AgentEventType.TOOL_CALL, onToolCall); emitter.off(AgentEventType.TOOL_RESULT, onToolResult); emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); @@ -1671,6 +1745,8 @@ export class TeamManager { const agent = this.getAgentFromBackend(agentId); if (!agent) return; if (agent.getStatus() !== AgentStatus.IDLE) return; + if (findMemberByName(this.teamFile.members, agentName)?.readOnly) return; + if (this._shutdownPending.has(agentName)) return; const pendingTasks = pending ?? @@ -1683,6 +1759,7 @@ export class TeamManager { for (const task of pendingTasks) { if (task.owner) continue; if (task.blockedBy.length > 0) continue; + if (this._shutdownPending.has(agentName)) return; const claimed = await claimTask(this.teamFile.name, task.id, agentId, { checkAgentBusy: true, @@ -1736,10 +1813,10 @@ export class TeamManager { const agent = this.getAgentFromBackend(member.agentId); if (!agent) return false; if (agent.getStatus() !== AgentStatus.IDLE) return false; + if (member.readOnly) return false; // Don't auto-claim a task for a teammate the leader is shutting - // down — it would start work it's about to abandon. flushNextMessage - // gates its own auto-claim on the same set; this is the task-update - // -triggered path, which reaches tryAutoClaimTask directly. + // down — it would start work it's about to abandon. tryAutoClaimTask + // repeats this check after async task reads for both claim paths. if (this._shutdownPending.has(member.name)) return false; const queue = this.pendingMessages.get(member.agentId) ?? []; return queue.length === 0; diff --git a/packages/core/src/agents/team/promptAddendum.test.ts b/packages/core/src/agents/team/promptAddendum.test.ts index 660faff3ec0..86af8fdec53 100644 --- a/packages/core/src/agents/team/promptAddendum.test.ts +++ b/packages/core/src/agents/team/promptAddendum.test.ts @@ -24,4 +24,15 @@ describe('buildTeammatePromptAddendum', () => { expect(prompt).toContain('call exit_plan_mode'); expect(prompt).toContain('Do not use send_message for plan approval'); }); + + it('marks read-only tasks complete before the turn-ending report', () => { + const prompt = buildTeammatePromptAddendum('reader', 'team', 'leader', { + readOnly: true, + }); + + expect(prompt).toContain('MARK COMPLETE'); + expect(prompt.indexOf('MARK COMPLETE')).toBeLessThan( + prompt.indexOf('REPORT RESULTS'), + ); + }); }); diff --git a/packages/core/src/agents/team/promptAddendum.ts b/packages/core/src/agents/team/promptAddendum.ts index c133577b360..e88ae1ea645 100644 --- a/packages/core/src/agents/team/promptAddendum.ts +++ b/packages/core/src/agents/team/promptAddendum.ts @@ -23,8 +23,33 @@ export function buildTeammatePromptAddendum( teammateName: string, teamName: string, leaderName: string, - options: { planModeRequired?: boolean } = {}, + options: { planModeRequired?: boolean; readOnly?: boolean } = {}, ): string { + if (options.readOnly) { + return [ + `You are read-only agent "${teammateName}" in team "${teamName}".`, + `The team leader is "${leaderName}".`, + '', + 'CRITICAL RULES — you MUST follow these:', + '', + '1. CHECK TASKS FIRST: Call task_list to find pending tasks.', + ' Claim a task by calling task_update(taskId, status: "in_progress").', + '', + '2. INVESTIGATE ONLY: Inspect the checkout with the available read-only', + ' tools. You cannot run shell commands, edit files, persist memory,', + ' schedule work, or spawn agents.', + '', + '3. COLLABORATE: Use send_message for questions, conflicting evidence,', + ' and useful findings. Include the task id and concrete evidence.', + '', + '4. MARK COMPLETE: Call task_update(taskId, status: "completed").', + '', + '5. REPORT RESULTS: Return a concise final answer with findings,', + ' evidence, uncertainty, and recommended next action. The runtime', + ' forwards it to the leader automatically. This ends your turn.', + ].join('\n'); + } + if (options.planModeRequired) { return [ `You are agent "${teammateName}" in team "${teamName}".`, diff --git a/packages/core/src/agents/team/test-utils/coordination-harness.test.ts b/packages/core/src/agents/team/test-utils/coordination-harness.test.ts index 0ae0ec40345..d9e854e18a9 100644 --- a/packages/core/src/agents/team/test-utils/coordination-harness.test.ts +++ b/packages/core/src/agents/team/test-utils/coordination-harness.test.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { describe, it, expect, vi, afterEach } from 'vitest'; +import { AgentEventType } from '../../runtime/agent-events.js'; import { AgentStatus } from '../../runtime/agent-types.js'; import { TeamCoordinationHarness } from './coordination-harness.js'; import type { FakeAgent } from './fake-agent.js'; @@ -84,6 +85,132 @@ describe('TeamCoordinationHarness', () => { // ─── 1. Message routing ──────────────────────────────────── describe('message routing', () => { + it('notifies the leader when a teammate does not report explicitly', async () => { + const h = await createHarness(); + const worker = await h.spawnTeammate('worker', { + onMessage: (_message, agent) => { + agent.getEventEmitter().emit(AgentEventType.ROUND_TEXT, { + subagentId: agent.agentId, + round: 1, + text: 'final finding', + thoughtText: '', + timestamp: Date.now(), + }); + }, + }); + + await h.teamManager.sendMessage('worker', 'inspect', 'leader'); + await h.waitForStatus('worker', AgentStatus.IDLE); + + await vi.waitFor(async () => { + expect(await h.teamManager.getLeaderMessages()).toEqual([ + expect.objectContaining({ + from: 'worker', + text: 'final finding', + }), + ]); + }); + expect(worker.getReceivedMessages()).toHaveLength(1); + + worker.getEventEmitter().emit(AgentEventType.ROUND_TEXT, { + subagentId: worker.agentId, + round: 2, + text: 'follow-up finding', + thoughtText: '', + timestamp: Date.now(), + }); + worker.getEventEmitter().emit(AgentEventType.STATUS_CHANGE, { + agentId: worker.agentId, + previousStatus: AgentStatus.IDLE, + newStatus: AgentStatus.IDLE, + timestamp: Date.now(), + }); + + await vi.waitFor(async () => { + expect(await h.teamManager.getLeaderMessages()).toEqual([ + expect.objectContaining({ + from: 'worker', + text: 'follow-up finding', + }), + ]); + }); + + await h.spawnTeammate('silent-worker'); + await h.teamManager.sendMessage('silent-worker', 'inspect', 'leader'); + + await vi.waitFor(async () => { + expect(await h.teamManager.getLeaderMessages()).toEqual([ + expect.objectContaining({ + from: 'silent-worker', + text: expect.stringContaining( + 'completed a turn without a model-visible final answer', + ), + }), + ]); + }); + }); + + it('forwards final text after an interim leader message', async () => { + const h = await createHarness(); + await h.spawnTeammate('worker', { + onMessage: async (_message, agent) => { + await h.teamManager.sendMessage( + 'leader', + 'interim finding', + 'worker', + ); + agent.getEventEmitter().emit(AgentEventType.ROUND_TEXT, { + subagentId: agent.agentId, + round: 1, + text: 'final finding', + thoughtText: '', + timestamp: Date.now(), + }); + }, + }); + + await h.teamManager.sendMessage('worker', 'inspect', 'leader'); + + await vi.waitFor(async () => { + expect(await h.teamManager.getLeaderMessages()).toEqual([ + expect.objectContaining({ text: 'interim finding' }), + expect.objectContaining({ text: 'final finding' }), + ]); + }); + }); + + it('does not forward text from an earlier round when the final round is empty', async () => { + const h = await createHarness(); + await h.spawnTeammate('worker', { + onMessage: (_message, agent) => { + for (const [round, text] of [ + [1, 'interim narration'], + [2, ''], + ] as const) { + agent.getEventEmitter().emit(AgentEventType.ROUND_TEXT, { + subagentId: agent.agentId, + round, + text, + thoughtText: '', + timestamp: Date.now(), + }); + } + }, + }); + + await h.teamManager.sendMessage('worker', 'inspect', 'leader'); + + await vi.waitFor(async () => { + expect(await h.teamManager.getLeaderMessages()).toEqual([ + expect.objectContaining({ + text: expect.stringContaining( + 'completed a turn without a model-visible final answer', + ), + }), + ]); + }); + }); + it('sends message from leader to teammate', async () => { const h = await createHarness(); const worker = await h.spawnTeammate('worker'); @@ -176,6 +303,40 @@ describe('TeamCoordinationHarness', () => { expect(workerMsgs).toHaveLength(1); expectTeamMessage(workerMsgs[0], 'leader', 'work'); }); + + it('does not auto-claim while shutdown is pending', async () => { + const h = await createHarness(); + const worker = await h.spawnTeammate('worker'); + + h.teamManager.markShutdownRequested('worker'); + await createTask(h.teamName, { + subject: 'Do not claim', + description: 'Wait for another worker', + }); + + worker.setStatus(AgentStatus.RUNNING); + worker.setStatus(AgentStatus.IDLE); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(worker.getReceivedMessages()).toHaveLength(0); + }); + + it('does not auto-claim tasks for read-only teammates', async () => { + const h = await createHarness(); + await h.teamManager.spawnTeammate({ + name: 'reader', + cwd: h.tmpDir, + readOnly: true, + }); + + await createTask(h.teamName, { + subject: 'Writer task', + description: 'Must remain available for the writer', + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(h.getAgent('reader').getReceivedMessages()).toHaveLength(0); + }); }); // ─── 3. Message priority ─────────────────────────────────── @@ -264,6 +425,25 @@ describe('TeamCoordinationHarness', () => { expect(target.getStatus()).toBe(AgentStatus.CANCELLED); }); + it('does not treat an automatic final report as a shutdown response', async () => { + const h = await createHarness(); + const target = await h.spawnTeammate('target', { + onMessage: () => 'stay_running', + }); + target.goIdle(); + + await h.teamManager.requestShutdown('target'); + await h.teamManager.sendMessage( + 'leader', + 'shutdown_approved is handled by the coordinator.', + 'target', + undefined, + true, + ); + + expect(target.getStatus()).not.toBe(AgentStatus.CANCELLED); + }); + it('shutdown_rejected clears the pending flag and disarms the abort', async () => { const h = await createHarness(); const target = await h.spawnTeammate('target', { @@ -499,6 +679,26 @@ describe('TeamCoordinationHarness', () => { // ─── Spawn lifecycle ──────────────────────────────────────── describe('spawn cap', () => { + it('gives read-only teammates only inspection and coordination tools', async () => { + const h = await createHarness(); + await h.teamManager.spawnTeammate({ + name: 'reader', + cwd: h.tmpDir, + readOnly: true, + }); + + const member = h.teamManager.getTeamFile().members[0]!; + const toolConfig = h.backend.getSpawnConfig(member.agentId)?.inProcess + ?.runtimeConfig.toolConfig; + + expect(toolConfig?.tools).toEqual(toolConfig?.executionAllowedTools); + expect(toolConfig?.tools).toContain('read_file'); + expect(toolConfig?.tools).toContain('send_message'); + expect(toolConfig?.tools).not.toContain('run_shell_command'); + expect(toolConfig?.tools).not.toContain('save_memory'); + expect(toolConfig?.tools).not.toContain('create_sub_session'); + }); + it('concurrent spawns cannot exceed MAX_TEAMMATES', async () => { // Regression: the cap check was synchronous but the push to // `members` happened after `loadSubagent`/`convertToRuntimeConfig` diff --git a/packages/core/src/agents/team/types.ts b/packages/core/src/agents/team/types.ts index 70bdaf49941..85a6174b350 100644 --- a/packages/core/src/agents/team/types.ts +++ b/packages/core/src/agents/team/types.ts @@ -82,8 +82,6 @@ export interface TeamMember { cwd: string; /** Tmux pane ID (empty string for in-process). */ tmuxPaneId: string; - /** Git worktree path if isolated. */ - worktreePath?: string; /** Backend type used to spawn this member. */ backendType?: DisplayMode; /** false = idle, undefined/true = active. */ @@ -92,6 +90,8 @@ export interface TeamMember { subscriptions: string[]; /** Whether plan mode is required for this member. */ planModeRequired?: boolean; + /** Whether this member is restricted to read-only investigation. */ + readOnly?: boolean; /** Permission mode for this member. */ mode?: PermissionMode; /** Phase 2: member's session UUID. */ diff --git a/packages/core/src/skills/bundled/coordinate/SKILL.md b/packages/core/src/skills/bundled/coordinate/SKILL.md new file mode 100644 index 00000000000..c777f69487a --- /dev/null +++ b/packages/core/src/skills/bundled/coordinate/SKILL.md @@ -0,0 +1,52 @@ +--- +name: coordinate +description: Coordinate up to three Qwen Code teammates with enforced read-only workers, an optional worktree-pinned writer, shared tasks, peer messages, and existing Agent View tabs. Invoke explicitly with /coordinate. +argument-hint: '' +disable-model-invocation: true +--- + +# Coordinate Qwen Code Teammates + +Act as the team leader. Decompose the goal, keep task ownership clear, reconcile disagreements, and deliver the final result. + +## Build one bounded team + +When `team_create` is available: + +1. Create one team and one self-contained task per current investigation workstream. Do not queue an implementation task while read-only teammates are idle because tasks are auto-assigned. +2. Spawn one to three named investigation teammates with `read_only: true`. Do not pass `model`; use the session-default model unless the selected agent definition explicitly overrides it. +3. Assign tasks and let teammates collaborate through `send_message` and the shared task list. Send targeted follow-ups when evidence conflicts, a task needs clarification, or a result is incomplete. +4. Accept or reject each result based on its evidence. Reassign rejected work instead of silently using it. + +Read-only teammates have a positive execution allowlist. They cannot use shell, edit or write files, save memory, create schedules, invoke arbitrary deferred tools, or spawn agents. This is enforced by the runtime, not only by this prompt. + +## Pin the only writer to a worktree + +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. +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: `. 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. +6. Review and verify the worktree result. The leader alone decides whether and how to integrate it into the current branch. +7. Do not remove the worktree until accepted changes have been integrated or deliberately discarded. + +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. + +The existing Agent View tabs show teammate conversations, messages, status, and approvals. Do not create another roster, session manager, or terminal UI. + +If the Agent Team tools are unavailable, say that `experimental.agentTeam` must be enabled and Qwen Code restarted. Ordinary subagents are an acceptable fallback for parallel research, but describe them accurately: they report only to the leader and cannot collaborate as a team. + +## Keep coordination bounded + +- Use one teammate for a narrow task and no more than three for this workflow. +- Give every task an objective, scope, completion condition, and required evidence. +- Default every teammate to `read_only: true`; add one worktree writer only when implementation is required. +- Do not use Arena: it is for competing solutions to the same task, not collaboration on different tasks. +- Do not claim that in-process teammates are independent PTY sessions or heterogeneous CLIs. +- Finish implementation before running the smallest relevant verification once. + +Return the outcome, material evidence or disagreements, changes made by the leader, verification, and remaining risks. diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index da24b91b6d8..4355a43bae1 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -1109,6 +1109,16 @@ describe('AgentTool', () => { ).toBeNull(); }); + it('accepts worktree isolation with an empty name placeholder', () => { + expect( + agentTool.validateToolParams({ + ...validParams, + name: '', + isolation: 'worktree', + }), + ).toBeNull(); + }); + it('rejects isolation values other than "worktree"', () => { expect( agentTool.validateToolParams({ @@ -1333,6 +1343,31 @@ describe('AgentTool', () => { }), ).toBeNull(); }); + + it('accepts redundant isolation for a named worktree teammate', () => { + vi.mocked(config.getTeamManager).mockReturnValue({} as never); + + expect( + agentTool.validateToolParams({ + ...validParams, + name: 'writer', + working_dir: '.qwen/tmp/writer', + isolation: 'worktree', + }), + ).toBeNull(); + }); + + it('allows named isolation to fall back without an active team', () => { + vi.mocked(config.getTeamManager).mockReturnValue(null); + + expect( + agentTool.validateToolParams({ + ...validParams, + name: 'helper', + isolation: 'worktree', + }), + ).toBeNull(); + }); }); // Round-7 regression guard: agent isolation must refuse when the @@ -1495,6 +1530,29 @@ describe('AgentTool', () => { ); }); + it('passes enforced read-only mode through to TeamManager', async () => { + const spawnTeammate = vi.fn().mockResolvedValue(undefined); + vi.mocked(config.getTeamManager).mockReturnValue({ + spawnTeammate, + } as never); + + const invocation = agentTool.build({ + description: 'Inspect implementation', + prompt: 'Inspect the coordination boundary', + name: 'reader', + read_only: true, + }); + + await invocation.execute(new AbortController().signal); + + expect(spawnTeammate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'reader', + readOnly: true, + }), + ); + }); + it('blocks a model grade if a team becomes active after validation', async () => { const spawnTeammate = vi.fn().mockResolvedValue(undefined); vi.mocked(config.getTeamManager).mockReturnValue({ @@ -1517,6 +1575,29 @@ describe('AgentTool', () => { expect(spawnTeammate).not.toHaveBeenCalled(); }); + it('blocks isolation if a team becomes active after validation', async () => { + const spawnTeammate = vi.fn().mockResolvedValue(undefined); + vi.mocked(config.getTeamManager).mockReturnValue({ + spawnTeammate, + } as never); + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation({ + description: 'Change files', + prompt: 'Implement the fix', + subagent_type: 'file-search', + name: 'writer', + isolation: 'worktree', + }); + const result = await invocation.execute(new AbortController().signal); + + expect(partToString(result.llmContent)).toMatch( + /isolation.*named teammate/i, + ); + expect(spawnTeammate).not.toHaveBeenCalled(); + }); + it('rejects plan_mode_required direct execution from a subagent context', async () => { const spawnTeammate = vi.fn().mockResolvedValue(undefined); vi.mocked(config.getTeamManager).mockReturnValue({ @@ -1538,27 +1619,127 @@ describe('AgentTool', () => { expect(spawnTeammate).not.toHaveBeenCalled(); }); - it('rejects working_dir when a named teammate would spawn (worktree pin would be silently ignored)', async () => { + it('rejects read_only direct execution from a subagent context', async () => { const spawnTeammate = vi.fn().mockResolvedValue(undefined); vi.mocked(config.getTeamManager).mockReturnValue({ spawnTeammate, } as never); const invocation = agentTool.build({ - description: 'Review', - prompt: 'Review the diff', - subagent_type: 'file-search', - name: 'reviewer', - working_dir: '.qwen/tmp/review-pr-1', + description: 'Inspect implementation', + prompt: 'Inspect the coordination boundary', + name: 'reader', + read_only: true, }); - const result = await invocation.execute(new AbortController().signal); - - expect(partToString(result.llmContent)).toMatch( - /not supported for a named teammate/i, + const result = await runWithAgentContext('child-agent', () => + invocation.execute(new AbortController().signal), ); + + expect(result.llmContent).toContain('from the team leader'); expect(spawnTeammate).not.toHaveBeenCalled(); }); + + it('pins a named teammate to a validated caller-owned worktree', async () => { + const { GitWorktreeService } = await import( + '../../services/gitWorktreeService.js' + ); + const spies = [ + vi + .spyOn(GitWorktreeService.prototype, 'checkGitAvailable') + .mockResolvedValue({ available: true }), + vi + .spyOn(GitWorktreeService.prototype, 'isGitRepository') + .mockResolvedValue(true), + vi + .spyOn(GitWorktreeService.prototype, 'getRepoTopLevel') + .mockResolvedValue('/test/project'), + vi + .spyOn(GitWorktreeService.prototype, 'isRegisteredLinkedWorktree') + .mockResolvedValue(true), + vi + .spyOn(GitWorktreeService.prototype, 'getRegisteredWorktreeBranch') + .mockResolvedValue({ + branch: 'worktree-writer', + headCommit: 'abc123', + }), + ]; + try { + const spawnTeammate = vi.fn().mockResolvedValue(undefined); + vi.mocked(config.getTeamManager).mockReturnValue({ + spawnTeammate, + } as never); + + const invocation = agentTool.build({ + description: 'Review', + prompt: 'Review the diff', + subagent_type: 'file-search', + name: 'reviewer', + working_dir: '.qwen/tmp/review-pr-1', + isolation: 'worktree', + }); + + await invocation.execute(new AbortController().signal); + + expect(spawnTeammate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'reviewer', + cwd: '/test/project/.qwen/tmp/review-pr-1', + }), + ); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + + it('aborts a named teammate after working_dir validation', async () => { + const { GitWorktreeService } = await import( + '../../services/gitWorktreeService.js' + ); + const controller = new AbortController(); + const spies = [ + vi + .spyOn(GitWorktreeService.prototype, 'checkGitAvailable') + .mockResolvedValue({ available: true }), + vi + .spyOn(GitWorktreeService.prototype, 'isGitRepository') + .mockResolvedValue(true), + vi + .spyOn(GitWorktreeService.prototype, 'getRepoTopLevel') + .mockResolvedValue('/test/project'), + vi + .spyOn(GitWorktreeService.prototype, 'isRegisteredLinkedWorktree') + .mockResolvedValue(true), + vi + .spyOn(GitWorktreeService.prototype, 'getRegisteredWorktreeBranch') + .mockImplementation(async () => { + controller.abort(); + return { branch: 'writer', headCommit: 'abc123' }; + }), + ]; + try { + const spawnTeammate = vi.fn().mockResolvedValue(undefined); + vi.mocked(config.getTeamManager).mockReturnValue({ + spawnTeammate, + } as never); + + const invocation = agentTool.build({ + description: 'Write', + prompt: 'Make the change', + subagent_type: 'file-search', + name: 'writer', + working_dir: '.qwen/tmp/writer', + }); + const result = await invocation.execute(controller.signal); + + expect(spawnTeammate).not.toHaveBeenCalled(); + expect(partToString(result.llmContent)).toContain( + 'spawn aborted before "writer" was registered', + ); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); }); describe('nesting depth guard', () => { @@ -2373,6 +2554,54 @@ describe('AgentTool', () => { } }, 20000); + it('accepts a registered sibling worktree of this repo', async () => { + vi.useRealTimers(); + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-agent-wd-sibling-')), + ); + const repo = path.join(root, 'repo'); + const wt = path.join(root, 'review-pr-1'); + try { + fs.mkdirSync(repo); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: repo }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: repo }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repo, + }); + fs.writeFileSync(path.join(repo, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: repo }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: repo, + }); + execFileSync('git', ['worktree', 'add', '-b', 'review-pr-1', wt], { + cwd: repo, + }); + + vi.mocked(config.getProjectRoot).mockReturnValue(repo); + vi.mocked(config.getTargetDir).mockReturnValue(repo); + vi.mocked(config.getCwd).mockReturnValue(repo); + vi.mocked(config.getWorkingDir).mockReturnValue(repo); + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation({ + description: 'Review', + prompt: 'Review the diff', + subagent_type: 'file-search', + working_dir: wt, + }); + await invocation.execute(); + + const createCall = vi.mocked(mockSubagentManager.createAgentHeadless) + .mock.calls[0]; + expect((createCall[1] as Config).getProjectRoot()).toBe(wt); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + vi.useFakeTimers(); + } + }, 20000); + it('resolves a repo-relative working_dir against the parent cwd (the /review production form)', async () => { vi.useRealTimers(); const repo = fs.realpathSync( diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 95470a27948..851581a6e91 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -6,7 +6,6 @@ import { randomUUID } from 'node:crypto'; import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from '../tools.js'; import { ToolNames, ToolDisplayNames } from '../tool-names.js'; import { @@ -241,6 +240,8 @@ export interface AgentParams { name?: string; /** Start a named teammate in plan mode and require leader approval. */ plan_mode_required?: boolean; + /** Restrict a named teammate to read-only inspection and coordination tools. */ + read_only?: boolean; /** * When set to `'worktree'`, spins up a temporary git worktree under * `/.qwen/worktrees/agent-<7hex>` and instructs the agent to @@ -261,8 +262,8 @@ export interface AgentParams { * search tools resolve inside the worktree rather than the parent tree. * (This is a cwd pin, not a filesystem sandbox — absolute paths can still * reach outside, same as `isolation:'worktree'`.) Must resolve to a - * worktree registered against this repository, and must live inside it — - * pinning rebinds the child's workspace boundary. If `isolation` is also + * worktree registered against this repository. Pinning rebinds the child's + * workspace boundary. If `isolation` is also * provided, it is ignored and the caller-owned worktree is reused. */ working_dir?: string; @@ -283,16 +284,14 @@ function getForkProfileModeError(config: Config): string | undefined { /** * Resolves and validates an `AgentParams.working_dir`: an EXISTING, - * caller-owned git worktree that a sub-agent should be pinned to (e.g. the - * PR-review worktree `/review`'s `fetch-pr` provisions). Unlike + * caller-owned git worktree that a sub-agent or teammate should be pinned to + * (e.g. the PR-review worktree `/review`'s `fetch-pr` provisions). Unlike * `isolation:'worktree'`, the harness neither creates nor tears down this * directory — it only rebinds the child Config's cwd surfaces to it. * - * Two checks stop a bad path from aiming the sub-agent somewhere it should not - * be: + * Git's worktree registry stops a bad path from aiming the sub-agent somewhere + * it should not be: * - * - It must resolve INSIDE the repository (canonical comparison), because - * pinning rebinds the child's `WorkspaceContext` wholesale. * - It must be a REGISTERED linked worktree of this repository, enforced by * `isRegisteredLinkedWorktree`: git's own registry entry for the path must * point back at it, and it must not be the primary working tree. That @@ -341,25 +340,6 @@ async function resolveExternalWorktreeDir( const wtService = repoRoot === parentCwd ? probe : new GitWorktreeService(repoRoot); - // Containment. A registered worktree may live anywhere on disk, but pinning - // rebinds the child's WorkspaceContext wholesale, so a model-supplied path - // must not silently move the file tools' boundary outside the repository. - // (`isolation: 'worktree'` has this property implicitly — it always - // provisions under `/.qwen/worktrees/`.) Compare canonical - // paths so a symlink cannot straddle the boundary. - const realRepoRoot = await fs.realpath(repoRoot).catch(() => repoRoot); - const realResolved = await fs - .realpath(resolvedPath) - .catch(() => resolvedPath); - const relToRepo = path.relative(realRepoRoot, realResolved); - if (relToRepo.startsWith('..') || path.isAbsolute(relToRepo)) { - return { - error: - `working_dir "${resolvedPath}" resolves outside this repository ` + - `(${realRepoRoot}). Pass a worktree that lives inside the repository.`, - }; - } - // The single authoritative gate: the path must be a REGISTERED linked // worktree of this repository — git's own registry entry for it points back // at exactly this path, and it is not the primary working tree. That one @@ -409,6 +389,14 @@ const TEAM_AGENT_PLAN_REQUIRED_PROPERTY = { 'with a named teammate in an active team.', }; +const TEAM_AGENT_READ_ONLY_PROPERTY = { + type: 'boolean', + description: + 'When true, the named teammate can only inspect the checkout and use ' + + 'team coordination tools. Shell, file writes, memory, schedules, and ' + + 'nested agents are blocked by an execution allowlist.', +}; + /** * Maps ApprovalMode to PermissionMode for hook events. */ @@ -853,12 +841,13 @@ export class AgentTool extends BaseDeclarativeTool { type: 'boolean', default: true, description: - 'Defaults to true for top-level regular subagents. Set to false to run a regular agent in the foreground and return its result inline. Set to true for an interactive fork to receive its completion notification; headless forks always run in the background. Nested agents run in the foreground unless run_in_background is explicitly true, which is rejected because they cannot receive background completion notifications. Caller-owned working_dir launches default to foreground and cannot run in the background.', + 'Defaults to true for top-level regular subagents. Set to false to run a regular agent in the foreground and return its result inline. Set to true for an interactive fork to receive its completion notification; headless forks always run in the background. Nested agents run in the foreground unless run_in_background is explicitly true, which is rejected because they cannot receive background completion notifications. Unnamed caller-owned working_dir launches default to foreground. Named teammates may run in the background, but must be shut down before their caller-owned worktree is removed.', }, ...(config.isAgentTeamEnabled() ? { name: TEAM_AGENT_NAME_PROPERTY, plan_mode_required: TEAM_AGENT_PLAN_REQUIRED_PROPERTY, + read_only: TEAM_AGENT_READ_ONLY_PROPERTY, } : {}), isolation: { @@ -870,7 +859,7 @@ export class AgentTool extends BaseDeclarativeTool { working_dir: { type: 'string', description: - "Pin the sub-agent's working directory to an EXISTING git worktree of this repo (absolute path, or relative to the current directory). Unlike 'isolation', the worktree is NOT created or cleaned up — the caller owns its lifecycle. The sub-agent's cwd-relative file and shell operations resolve inside this directory, and search tools (grep, glob) default to it as their root. This is a cwd pin, not a filesystem sandbox — file, shell, and search tools can still be pointed outside via an explicit absolute path. Must be a worktree already registered against the current repository, and must live inside it. If both working_dir and isolation are provided, isolation is ignored and the caller-owned worktree is reused.", + "Pin a sub-agent or named teammate to an EXISTING, caller-owned git worktree of this repo (absolute path, or relative to the current directory). Unlike 'isolation', the worktree is NOT created or cleaned up by Agent. Relative file, shell, and search operations resolve inside it. This is a cwd pin, not a filesystem sandbox: explicit absolute paths can still reach outside. The path must be a registered linked worktree of this repository. If both working_dir and isolation are provided, isolation is ignored.", }, }, required: ['description', 'prompt'], @@ -940,7 +929,7 @@ export class AgentTool extends BaseDeclarativeTool { // feature is on; otherwise the model is steered toward a // `team_create` tool that isn't registered. const teamGuidance = this.config.isAgentTeamEnabled() - ? `**For tasks requiring multiple agents to coordinate, communicate, or work as a team**: Use ${ToolNames.TEAM_CREATE} first to create a team, then spawn teammates using the Agent tool with the \`name\` parameter (the active team is selected automatically). Teams enable message passing between agents, shared task lists, and coordinated workflows. If the user asks for agents to collaborate, review each other's work, or produce a consolidated result — create a team.` + ? `**For tasks requiring multiple agents to coordinate, communicate, or work as a team**: Use ${ToolNames.TEAM_CREATE} first to create a team, then spawn teammates using the Agent tool with explicit \`name\` and \`subagent_type\` parameters (the active team is selected automatically). Set \`read_only: true\` for investigation teammates. A single writer teammate may be pinned to a leader-owned Git worktree with \`working_dir\`; shut it down before removing that worktree. Teams enable message passing between agents, shared task lists, and coordinated workflows. If the user asks for agents to collaborate, review each other's work, or produce a consolidated result — create a team.` : ''; const baseDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. The Agent tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. @@ -974,7 +963,7 @@ Usage notes: - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent - If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. - If the user asks for agents "in parallel", group independent launches in a single message with multiple Agent tool use content blocks. Do not parallelize overlapping code changes. -- Top-level regular subagents run in the background by default. Set \`run_in_background: false\` when the current turn must wait for the result before continuing. Nested agent launches run in the foreground and return to their direct parent; an explicit \`run_in_background: true\` request is rejected because nested agents cannot receive background completion notifications. Caller-owned \`working_dir\` launches default to foreground and cannot run in the background. +- Top-level regular subagents run in the background by default. Set \`run_in_background: false\` when the current turn must wait for the result before continuing. Nested agent launches run in the foreground and return to their direct parent; an explicit \`run_in_background: true\` request is rejected because nested agents cannot receive background completion notifications. Unnamed caller-owned \`working_dir\` launches default to foreground and cannot run in the background; named teammates may use one, but must be shut down before it is removed. - You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result so you can review or merge them. ## When to fork @@ -1043,6 +1032,7 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent }; name?: typeof TEAM_AGENT_NAME_PROPERTY; plan_mode_required?: typeof TEAM_AGENT_PLAN_REQUIRED_PROPERTY; + read_only?: typeof TEAM_AGENT_READ_ONLY_PROPERTY; }; }; if (schema.properties) { @@ -1050,9 +1040,11 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent schema.properties.name = TEAM_AGENT_NAME_PROPERTY; schema.properties.plan_mode_required = TEAM_AGENT_PLAN_REQUIRED_PROPERTY; + schema.properties.read_only = TEAM_AGENT_READ_ONLY_PROPERTY; } else { delete schema.properties.name; delete schema.properties.plan_mode_required; + delete schema.properties.read_only; } const availableGrades = [ @@ -1220,6 +1212,15 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent ) { return 'Parameter "isolation" requires an explicit subagent_type (and cannot be "fork").'; } + if ( + params.name && + params.working_dir === undefined && + !isTeammate() && + isTopLevelSession() && + this.config.getTeamManager() + ) { + return 'Parameter "isolation" cannot be used for a named teammate. Create a leader-owned worktree first, then pass it with "working_dir".'; + } } if ( @@ -1237,12 +1238,9 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent ) { return 'Parameter "working_dir" must be a non-empty string when set.'; } - // A caller-owned worktree has no lifecycle coupling to a background - // agent: nothing stops the caller from removing the worktree while a - // detached agent is still running in it (ENOENT on its own cwd). The - // isolation:'worktree' path is safe in background because the tool owns - // and reaps the worktree; working_dir does not. - if (params.run_in_background === true) { + // Unnamed background agents have no lifecycle coupling to a + // caller-owned worktree. Named teammates are team-managed instead. + if (params.run_in_background === true && params.name === undefined) { return 'Parameters "working_dir" and "run_in_background" are incompatible: the caller owns the worktree lifecycle and could remove it while a background agent is still running.'; } // `working_dir` is the more specific workspace instruction. Some @@ -1280,6 +1278,27 @@ assistant: Uses the ${ToolNames.AGENT} tool to launch the test-runner agent } } + if (params.read_only !== undefined) { + if (typeof params.read_only !== 'boolean') { + return 'Parameter "read_only" must be a boolean when set.'; + } + if (params.read_only) { + if ( + !params.name || + typeof params.name !== 'string' || + params.name.trim() === '' + ) { + return 'Parameter "read_only" requires a named teammate via "name".'; + } + if (!this.config.getTeamManager()) { + return 'Parameter "read_only" requires an active team.'; + } + if (params.plan_mode_required === true) { + return 'Parameters "read_only" and "plan_mode_required" cannot be used together.'; + } + } + } + return null; } @@ -2301,6 +2320,30 @@ class AgentToolInvocation extends BaseToolInvocation { } } + if (this.params.read_only === true) { + if ( + !this.params.name || + this.params.name.trim() === '' || + isSubagentLikeExecutionContext() + ) { + const msg = + 'read_only can only be used when spawning a named teammate from the team leader.'; + return { + llmContent: msg, + returnDisplay: msg, + error: { message: msg }, + }; + } + if (!this.config.getTeamManager()) { + const msg = 'read_only requires an active team. Use TeamCreate first.'; + return { + llmContent: msg, + returnDisplay: msg, + error: { message: msg }, + }; + } + } + // ─── Team routing ──────────────────────────────────── // 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 @@ -2321,16 +2364,10 @@ class AgentToolInvocation extends BaseToolInvocation { 'Error: "model" is not supported for a named teammate.', 'model is incompatible with a named teammate', ); - } else if (this.params.working_dir !== undefined) { - // A teammate spawns via TeamManager with cwd = getCwd() and returns - // before the working_dir rebind below is reached, so the pin would be - // silently ignored and the teammate would run in the parent working - // tree. Refuse rather than give a false sense of isolation. (Same - // lifecycle rationale as run_in_background: a persistent teammate has - // no coupling to a caller-owned worktree.) + } else if (this.params.isolation !== undefined) { return this.buildSpawnBlockedResult( - 'Error: "working_dir" is not supported for a named teammate — a teammate runs in the parent working tree, so the worktree pin would be silently ignored. Drop "name" to pin a one-shot sub-agent to the worktree, or drop "working_dir".', - 'working_dir is incompatible with a named teammate', + 'Error: "isolation" cannot be used for a named teammate. Create a leader-owned worktree first, then pass it with "working_dir".', + 'isolation is incompatible with a named teammate', ); } else { return this.executeTeammate(this.params.name, signal, updateOutput); @@ -4315,18 +4352,44 @@ class AgentToolInvocation extends BaseToolInvocation { }); try { + let cwd = this.config.getCwd(); + if (this.params.working_dir) { + const resolved = await resolveExternalWorktreeDir( + this.config, + this.params.working_dir, + ); + if ('error' in resolved) { + return this.buildSpawnBlockedResult( + `Error: ${resolved.error}`, + 'Invalid teammate working_dir', + ); + } + cwd = resolved.path; + } + + if (signal?.aborted) { + return { + llmContent: `Teammate spawn aborted before "${name}" was registered.`, + returnDisplay: `Teammate spawn aborted.`, + error: { message: 'Aborted.' }, + }; + } + await teamManager.spawnTeammate({ name, prompt: this.params.prompt, agentType: this.params.subagent_type, - cwd: this.config.getCwd(), + cwd, planModeRequired: this.params.plan_mode_required === true, + readOnly: this.params.read_only === true, }); // Return immediately — teammate runs concurrently. const msg = `Teammate "${name}" is now running concurrently.` + ` Task: ${this.params.description}` + + (this.params.read_only ? '\nMode: enforced read-only.' : '') + + (this.params.working_dir ? `\nWorktree: ${cwd}` : '') + '\n\nYou will receive their messages as they' + ' arrive. Do NOT call task_list to check on' + ' them — teammates report results via' + diff --git a/packages/core/src/tools/team-create.ts b/packages/core/src/tools/team-create.ts index 09add56949d..7245e7ca6ee 100644 --- a/packages/core/src/tools/team-create.ts +++ b/packages/core/src/tools/team-create.ts @@ -202,7 +202,7 @@ When in doubt about whether a task warrants a team, prefer spawning a team. When spawning teammates via the Agent tool, choose the \`subagent_type\` based on what tools the agent needs for its task. Each agent type has a different set of available tools — match the agent to the work: -- **Read-only agents** (e.g., Explore, Plan) cannot edit or write files. Only assign them research, search, or planning tasks. Never assign them implementation work. +- **Enforced read-only teammates** use the Agent tool's read_only option. This replaces the selected agent's tool surface with inspection and team-coordination tools, so shell and file writes are unavailable. Use them for research, search, review, or planning tasks. - **Full-capability agents** (e.g., general-purpose) have access to all tools including file editing, writing, and bash. Use these for tasks that require making changes. - **Custom agents** defined in \`.qwen/agents/\` may have their own tool restrictions. Check their descriptions to understand what they can and cannot do. @@ -225,7 +225,7 @@ This creates: 1. **Create a team** with TeamCreate - this creates both the team and its task list 2. **Create tasks** using the Task tools (TaskCreate, TaskList, etc.) - they automatically use the team's task list -3. **Spawn teammates** using the Agent tool with the \`name\` parameter to create teammates that join the active team (max ${config.getAgentsSettings().team?.maxTeammates ?? MAX_TEAMMATES} teammates per team) +3. **Spawn teammates** using the Agent tool with explicit \`name\` and \`subagent_type\` parameters to create teammates that join the active team (max ${config.getAgentsSettings().team?.maxTeammates ?? MAX_TEAMMATES} teammates per team). Set \`read_only: true\` for investigation workers; pin a single writer to a leader-owned worktree with \`working_dir\` when code changes are required, then shut it down before removing that worktree. 4. **Assign tasks** using TaskUpdate with \`owner\` to give tasks to idle teammates 5. **Teammates work on assigned tasks** and mark them completed via TaskUpdate 6. **Teammates go idle between turns** - after each turn, teammates automatically go idle and send a notification. IMPORTANT: Be patient with idle teammates! Don't comment on their idleness until it actually impacts your work.