Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions docs/users/features/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ Subagents are configured using Markdown files with YAML frontmatter. This format
name: agent-name
description: Brief description of when and how to use this agent
model: inherit # Optional: inherit or model-id
tools:
- tool1
- tool2
- tool3 # Optional
approvalMode: auto-edit # Optional: default, plan, auto-edit, yolo
tools: # Optional: allowlist of tools
- tool1
- tool2
---

System prompt content goes here.
Expand All @@ -118,6 +118,38 @@ Use the optional `model` frontmatter field to control which model a subagent use
- `glm-5`: Use that model ID with the main conversation's auth type
- `openai:gpt-4o`: Use a different provider (resolves credentials from env vars)

#### Permission Mode

Use the optional `approvalMode` frontmatter field to control how a subagent's tool calls are approved. Valid values:

- `default`: Tools require interactive approval (same as the main session default)
- `plan`: Analyze-only mode — the agent plans but does not execute changes
- `auto-edit`: Tools are auto-approved without prompting (recommended for most agents)
- `yolo`: All tools auto-approved, including potentially destructive ones

If you omit this field, the subagent's permission mode is determined automatically:

- If the parent session is in **yolo** or **auto-edit** mode, the subagent inherits that mode. A permissive parent stays permissive.
- If the parent session is in **plan** mode, the subagent stays in plan mode. An analyze-only session cannot mutate files through a delegated agent.
- If the parent session is in **default** mode (in a trusted folder), the subagent gets **auto-edit** so it can work autonomously.

When you do set `approvalMode`, the parent's permissive modes still take priority. For example, if the parent is in yolo mode, a subagent with `approvalMode: plan` will still run in yolo mode.

```
---
name: cautious-reviewer
description: Reviews code without making changes
approvalMode: plan
tools:
- read_file
- grep_search
- glob
---

You are a code reviewer. Analyze the code and report findings.
Do not modify any files.
```

#### Example Usage

```
Expand Down Expand Up @@ -501,6 +533,7 @@ Always follow these standards:
## Security Considerations

- **Tool Restrictions**: Subagents only have access to their configured tools
- **Permission Mode**: Subagents inherit their parent's permission mode by default. Plan-mode sessions cannot escalate to auto-edit through delegated agents. Privileged modes (auto-edit, yolo) are blocked in untrusted folders.
- **Sandboxing**: All tool execution follows the same security model as direct tool use
- **Audit Trail**: All Subagents actions are logged and visible in real-time
- **Access Control**: Project and user-level separation provides appropriate boundaries
Expand Down
20 changes: 17 additions & 3 deletions packages/core/src/extension/claude-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,24 @@ export function convertClaudeAgentConfig(
qwenAgent['model'] = claudeAgent.model;
}

// Preserve unsupported fields as-is for potential future compatibility
// These fields are not supported by Qwen Code SubagentConfig but we keep them
// Map Claude permission mode aliases to Qwen ApprovalMode values.
// Note: Claude's `dontAsk` denies any tool call that would prompt the user,
// making it restrictive. We map it to `default` (which also requires approval)
// rather than `auto-edit` (which auto-approves), preserving the restrictive
// intent. `bypassPermissions` is the Claude mode that auto-approves everything.
if (claudeAgent.permissionMode) {
qwenAgent['permissionMode'] = claudeAgent.permissionMode;
const claudeToQwenMode: Record<string, string> = {
default: 'default',
plan: 'plan',
acceptEdits: 'auto-edit',
dontAsk: 'default',
bypassPermissions: 'yolo',
auto: 'auto-edit',
};
const mapped =
claudeToQwenMode[claudeAgent.permissionMode] ??
claudeAgent.permissionMode;
qwenAgent['approvalMode'] = mapped;
}
if (claudeAgent.hooks) {
qwenAgent['hooks'] = claudeAgent.hooks;
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/subagents/subagent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
AgentHooks,
} from '../agents/runtime/agent-events.js';
import type { Config } from '../config/config.js';
import { APPROVAL_MODES } from '../config/config.js';
import {
type AuthType,
type ContentGenerator,
Expand Down Expand Up @@ -594,6 +595,13 @@ export class SubagentManager {
frontmatter['color'] = config.color;
}

if (
config.approvalMode &&
APPROVAL_MODES.includes(config.approvalMode as never)
) {
frontmatter['approvalMode'] = config.approvalMode;
}

// Serialize to YAML
const yamlContent = stringifyYaml(frontmatter, {
lineWidth: 0, // Disable line wrapping
Expand Down Expand Up @@ -1024,6 +1032,28 @@ function parseSubagentContent(
| Record<string, unknown>
| undefined;
const color = frontmatter['color'] as string | undefined;
const approvalModeRaw = frontmatter['approvalMode'];
if (
approvalModeRaw !== undefined &&
approvalModeRaw !== null &&
typeof approvalModeRaw !== 'string'
) {
throw new Error(
`Invalid "approvalMode" value: expected a string, got ${typeof approvalModeRaw}. Valid values: ${APPROVAL_MODES.join(', ')}`,
);
}
const approvalMode =
typeof approvalModeRaw === 'string' && approvalModeRaw !== ''
? approvalModeRaw
: undefined;
if (
approvalMode !== undefined &&
!APPROVAL_MODES.includes(approvalMode as never)
) {
throw new Error(
`Invalid "approvalMode" value "${approvalMode}". Valid values: ${APPROVAL_MODES.join(', ')}`,
);
}
const model =
modelRaw != null && modelRaw !== ''
? String(modelRaw)
Expand All @@ -1035,6 +1065,7 @@ function parseSubagentContent(
name,
description,
tools,
approvalMode,
systemPrompt: systemPrompt.trim(),
filePath,
model,
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/subagents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ export interface SubagentConfig {
*/
tools?: string[];

/**
* Optional permission mode for this subagent.
* Controls how tool calls are approved during execution.
* Valid values: 'default', 'plan', 'auto-edit', 'yolo'.
* If omitted, the resolved mode depends on the parent's mode
* (permissive parent modes win; otherwise defaults to 'auto-edit').
*/
approvalMode?: string;

/**
* System prompt content that defines the subagent's behavior.
* Supports ${variable} templating via ContextState.
Expand Down
95 changes: 89 additions & 6 deletions packages/core/src/tools/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { AgentTool, type AgentParams } from './agent.js';
import {
AgentTool,
type AgentParams,
resolveSubagentApprovalMode,
} from './agent.js';
import type { PartListUnion } from '@google/genai';
import type { ToolResultDisplay, AgentResultDisplay } from './tools.js';
import { ToolConfirmationOutcome } from './tools.js';
import type { Config } from '../config/config.js';
import { type Config, ApprovalMode } from '../config/config.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import { AgentTerminateMode } from '../agents/runtime/agent-types.js';
Expand Down Expand Up @@ -87,6 +91,8 @@ describe('AgentTool', () => {
getGeminiClient: vi.fn().mockReturnValue(undefined),
getHookSystem: vi.fn().mockReturnValue(undefined),
getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'),
getApprovalMode: vi.fn().mockReturnValue('default'),
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;

changeListeners = [];
Expand Down Expand Up @@ -392,7 +398,7 @@ describe('AgentTool', () => {
);
expect(mockSubagentManager.createAgentHeadless).toHaveBeenCalledWith(
mockSubagents[0],
config,
expect.any(Object), // config (may be approval-mode override)
expect.any(Object), // eventEmitter parameter
);
expect(mockAgent.execute).toHaveBeenCalledWith(
Expand Down Expand Up @@ -627,7 +633,7 @@ describe('AgentTool', () => {
expect(mockHookSystem.fireSubagentStartEvent).toHaveBeenCalledWith(
expect.stringContaining('file-search-'),
'file-search',
PermissionMode.Default,
PermissionMode.AutoEdit,
undefined,
);
});
Expand Down Expand Up @@ -809,7 +815,7 @@ describe('AgentTool', () => {
'/test/transcript',
'Task completed successfully',
false,
PermissionMode.Default,
PermissionMode.AutoEdit,
undefined,
);
});
Expand Down Expand Up @@ -854,7 +860,7 @@ describe('AgentTool', () => {
'/test/transcript',
'Task completed successfully',
true,
PermissionMode.Default,
PermissionMode.AutoEdit,
undefined,
);
});
Expand Down Expand Up @@ -1304,3 +1310,80 @@ describe('AgentTool', () => {
});
});
});

describe('resolveSubagentApprovalMode', () => {
it('should return yolo when parent is yolo, regardless of agent config', () => {
expect(resolveSubagentApprovalMode(ApprovalMode.YOLO, 'plan', true)).toBe(
PermissionMode.Yolo,
);
expect(
resolveSubagentApprovalMode(ApprovalMode.YOLO, undefined, false),
).toBe(PermissionMode.Yolo);
});

it('should return auto-edit when parent is auto-edit, regardless of agent config', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.AUTO_EDIT, 'plan', true),
).toBe(PermissionMode.AutoEdit);
expect(
resolveSubagentApprovalMode(ApprovalMode.AUTO_EDIT, 'default', false),
).toBe(PermissionMode.AutoEdit);
});

it('should respect agent-declared mode when parent is default and folder is trusted', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, 'plan', true),
).toBe(PermissionMode.Plan);
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, 'auto-edit', true),
).toBe(PermissionMode.AutoEdit);
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, 'yolo', true),
).toBe(PermissionMode.Yolo);
});

it('should block privileged agent-declared modes in untrusted folders', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, 'auto-edit', false),
).toBe(PermissionMode.Default);
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, 'yolo', false),
).toBe(PermissionMode.Default);
});

it('should allow non-privileged agent-declared modes in untrusted folders', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, 'plan', false),
).toBe(PermissionMode.Plan);
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, 'default', false),
).toBe(PermissionMode.Default);
});

it('should default to plan when parent is plan and no agent config', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.PLAN, undefined, true),
).toBe(PermissionMode.Plan);
expect(
resolveSubagentApprovalMode(ApprovalMode.PLAN, undefined, false),
).toBe(PermissionMode.Plan);
});

it('should allow agent-declared mode to override plan parent', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.PLAN, 'auto-edit', true),
).toBe(PermissionMode.AutoEdit);
});

it('should default to auto-edit when parent is default and folder is trusted', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, undefined, true),
).toBe(PermissionMode.AutoEdit);
});

it('should default to parent mode when parent is default and folder is untrusted', () => {
expect(
resolveSubagentApprovalMode(ApprovalMode.DEFAULT, undefined, false),
).toBe(PermissionMode.Default);
});
});
Loading
Loading