Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
454 changes: 454 additions & 0 deletions docs/declarative-agents-port.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions docs/users/features/sub-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,45 @@ disallowedTools:
---
```

#### Claude Code Compatibility Fields

Qwen Code accepts the Claude Code 2.1.168 frontmatter fields below so you
can drop a CC agent file into `.qwen/agents/` and have the supported fields
parse identically. Optional fields with invalid values are silently dropped
at parse time rather than rejected — the same lenient posture CC uses.

| Field | Type | Notes |
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `permissionMode` | enum string | `acceptEdits`, `auto`, `bypassPermissions`, `default`, `dontAsk`, `plan`. Mapped to `approvalMode` at parse time; when both are set, the explicit `approvalMode` wins. |
| `maxTurns` | positive integer | Caps the agent's turn budget. Wired into `runConfig.max_turns` at runtime; when both are set, the top-level field wins. |
| `color` | enum string | Display color. Allowlist: `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan` (mirrors CC's `_Y`). The legacy qwen sentinel `auto` is also preserved for backward compatibility. Other values are silently dropped on parse. |

Example:

```
---
name: rigorous-reviewer
description: Deep code review with a turn cap
permissionMode: plan
maxTurns: 50
color: cyan
tools:
- read_file
- grep_search
- glob
---

You are a code reviewer. Analyze the code thoroughly and report findings
ordered by severity.
```

The remaining CC frontmatter fields — `effort`, `skills`, `initialPrompt`,
`memory`, `isolation`, `mcpServers`, `hooks` — are documented in the
declarative-agent design doc and land in follow-up PRs once the prerequisite
infrastructure exists (`effort` needs a model-layer parameter; `memory`
needs a scoped memory subsystem; `mcpServers` / `hooks` need a nested-aware
YAML parser; `--agent` CLI flag enables `initialPrompt`; etc.).

#### Example Usage

```
Expand Down
134 changes: 134 additions & 0 deletions packages/core/src/subagents/agent-frontmatter-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import {
PERMISSION_MODE_VALUES,
COLOR_VALUES,
claudePermissionModeToApprovalMode,
parseMaxTurns,
isPermissionMode,
isColor,
} from './agent-frontmatter-schema.js';

describe('agent-frontmatter-schema', () => {
describe('enum constants — Claude Code 2.1.168 parity', () => {
it('PERMISSION_MODE_VALUES matches DL7 $E / kc constant exactly', () => {
expect([...PERMISSION_MODE_VALUES]).toEqual([
'acceptEdits',
'auto',
'bypassPermissions',
'default',
'dontAsk',
'plan',
]);
});

it('COLOR_VALUES matches CC _Y allowlist exactly', () => {
expect([...COLOR_VALUES]).toEqual([
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
]);
});
});

describe('claudePermissionModeToApprovalMode bridge', () => {
it('maps all 6 CC permissionMode values', () => {
expect(claudePermissionModeToApprovalMode('default')).toBe('default');
expect(claudePermissionModeToApprovalMode('plan')).toBe('plan');
expect(claudePermissionModeToApprovalMode('acceptEdits')).toBe(
'auto-edit',
);
expect(claudePermissionModeToApprovalMode('auto')).toBe('auto-edit');
expect(claudePermissionModeToApprovalMode('bypassPermissions')).toBe(
'yolo',
);
expect(claudePermissionModeToApprovalMode('dontAsk')).toBe('default');
});

it('returns undefined for unknown permissionMode', () => {
expect(claudePermissionModeToApprovalMode('not-a-mode')).toBeUndefined();
expect(claudePermissionModeToApprovalMode('')).toBeUndefined();
expect(claudePermissionModeToApprovalMode(undefined)).toBeUndefined();
});

it('does not walk the prototype chain for `__proto__` / `constructor`', () => {
// Implemented with `Map.get`, not a plain object lookup, so prototype
// keys cannot return Object.prototype / Function constructor.
expect(claudePermissionModeToApprovalMode('__proto__')).toBeUndefined();
expect(claudePermissionModeToApprovalMode('constructor')).toBeUndefined();
expect(
claudePermissionModeToApprovalMode('hasOwnProperty'),
).toBeUndefined();
expect(claudePermissionModeToApprovalMode('toString')).toBeUndefined();
});

it('preserves restrictive intent of dontAsk by mapping to default', () => {
// dontAsk in CC denies any tool call that would prompt the user.
// We map to `default` (which also requires approval) rather than
// `auto-edit` (which auto-approves). This preserves the restrictive
// intent.
expect(claudePermissionModeToApprovalMode('dontAsk')).toBe('default');
});
});

describe('parseMaxTurns — DL7 number-or-numeric-string lenience', () => {
it('accepts positive integer number', () => {
expect(parseMaxTurns(50)).toBe(50);
});

it('accepts positive integer string', () => {
expect(parseMaxTurns('50')).toBe(50);
});

it('returns undefined for zero or negative numbers', () => {
expect(parseMaxTurns(0)).toBeUndefined();
expect(parseMaxTurns(-1)).toBeUndefined();
});

it('returns undefined for non-integer numbers', () => {
expect(parseMaxTurns(5.5)).toBeUndefined();
});

it('returns undefined for non-numeric strings', () => {
expect(parseMaxTurns('many')).toBeUndefined();
expect(parseMaxTurns('')).toBeUndefined();
});

it('returns undefined for null / undefined / non-numeric types', () => {
expect(parseMaxTurns(undefined)).toBeUndefined();
expect(parseMaxTurns(null)).toBeUndefined();
expect(parseMaxTurns(true)).toBeUndefined();
expect(parseMaxTurns({})).toBeUndefined();
});
});

describe('type guards', () => {
it('isPermissionMode — accepts every PERMISSION_MODE_VALUES, rejects others', () => {
for (const v of PERMISSION_MODE_VALUES) {
expect(isPermissionMode(v)).toBe(true);
}
expect(isPermissionMode('not-a-mode')).toBe(false);
expect(isPermissionMode('')).toBe(false);
expect(isPermissionMode(undefined)).toBe(false);
});

it('isColor — accepts every COLOR_VALUES, rejects others (CC silently drops)', () => {
for (const v of COLOR_VALUES) {
expect(isColor(v)).toBe(true);
}
expect(isColor('magenta')).toBe(false);
expect(isColor('white')).toBe(false);
expect(isColor(undefined)).toBe(false);
});
});
});
119 changes: 119 additions & 0 deletions packages/core/src/subagents/agent-frontmatter-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

/**
* @fileoverview Declarative-agent frontmatter schema constants and parsers.
*
* Mirrors Claude Code 2.1.168's `.claude/agents/<name>.md` schema verbatim so
* a user can drop a Claude Code agent file into `.qwen/agents/` and have it
* parse identically. The internal verification source (DL7 / Ig5 / GN / kc /
* P37 / _Y) is documented in `docs/declarative-agents-port.md`.
*
* Parsing follows DL7's "lenient" posture: invalid optional fields are dropped
* to undefined rather than thrown — the caller layer is responsible for
* deciding whether a dropped field surfaces a warning. This intentionally
* differs from the strict throw-on-invalid posture used for `approvalMode`
* elsewhere in the loader, because that field predates this port and changing
* its semantics would break existing `.qwen/agents/*.md` files.
*/

/** Permission mode enum (DL7 `$E` / `kc` constant). */
export const PERMISSION_MODE_VALUES = [
'acceptEdits',
'auto',
'bypassPermissions',
'default',
'dontAsk',
'plan',
] as const;
export type PermissionModeValue = (typeof PERMISSION_MODE_VALUES)[number];

/** Color allowlist (DL7 `_Y` constant). Values outside this list are silently dropped. */
export const COLOR_VALUES = [
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
] as const;
export type ColorValue = (typeof COLOR_VALUES)[number];

/**
* Mapping from Claude Code permissionMode → qwen-code approvalMode.
*
* 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.
*
* Use `Map` instead of a plain `Record` so a caller passing `'__proto__'` or
* `'constructor'` cannot walk the prototype chain and get back a non-string
* value (e.g. `Object.prototype`).
*/
const PERMISSION_MODE_TO_APPROVAL_MODE = new Map<string, string>([
['default', 'default'],
['plan', 'plan'],
['acceptEdits', 'auto-edit'],
['auto', 'auto-edit'],
['bypassPermissions', 'yolo'],
['dontAsk', 'default'],
]);

/**
* Map a Claude Code `permissionMode` frontmatter value to a qwen-code
* `approvalMode` value. Returns `undefined` for unknown / falsy input.
*
* Disambiguated from `packages/core/src/tools/agent/agent.ts`'s internal
* `permissionModeToApprovalMode`, which maps the qwen `PermissionMode` enum
* to the qwen `ApprovalMode` enum (different domain entirely). Importing the
* wrong symbol via IDE auto-complete would silently return `undefined` for
* every qwen enum value, hence the longer name.
*/
export function claudePermissionModeToApprovalMode(
permissionMode: string | undefined,
): string | undefined {
if (!permissionMode) return undefined;
return PERMISSION_MODE_TO_APPROVAL_MODE.get(permissionMode);
}

/**
* Parse a maxTurns value. Accepts a positive integer number or numeric string.
* Returns `undefined` for anything else (matches DL7 `W46`).
*/
export function parseMaxTurns(value: unknown): number | undefined {
let candidate: number;
if (typeof value === 'number') {
candidate = value;
} else if (typeof value === 'string' && value.length > 0) {
candidate = Number(value);
if (Number.isNaN(candidate)) return undefined;
} else {
return undefined;
}
if (!Number.isFinite(candidate)) return undefined;
if (!Number.isInteger(candidate)) return undefined;
if (candidate <= 0) return undefined;
return candidate;
}

/** Type guard: value is a valid PERMISSION_MODE_VALUES literal. */
export function isPermissionMode(value: unknown): value is PermissionModeValue {
return (
typeof value === 'string' &&
(PERMISSION_MODE_VALUES as readonly string[]).includes(value)
);
}

/** Type guard: value is a valid COLOR_VALUES literal. */
export function isColor(value: unknown): value is ColorValue {
Comment thread
LaZzyMan marked this conversation as resolved.
return (
typeof value === 'string' &&
(COLOR_VALUES as readonly string[]).includes(value)
);
}
9 changes: 9 additions & 0 deletions packages/core/src/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,14 @@ export {
// Validation system
export { SubagentValidator } from './validation.js';

// NOTE: declarative-agent schema helpers (e.g.
// claudePermissionModeToApprovalMode, parseMaxTurns, isPermissionMode)
// live in `agent-frontmatter-schema.ts` and are intentionally NOT
// re-exported here — they are internal to the `SubagentManager` /
// `claude-converter` parse paths and locking their names in the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The comment says these helpers are "internal to the SubagentManager / claude-converter parse paths", but claude-converter.ts does not import anything from agent-frontmatter-schema.ts — it retains its own inline mapping table. The comment names a consumer that does not exist, which could mislead a future developer into thinking consolidation has already happened.

Suggested change
// `claude-converter` parse paths and locking their names in the
// NOTE: declarative-agent schema helpers (e.g.
// claudePermissionModeToApprovalMode, parseMaxTurns, isPermissionMode)
// live in `agent-frontmatter-schema.ts` and are intentionally NOT
// re-exported here — they are internal to the `SubagentManager`
// parse path and locking their names in the package's public API
// would constrain follow-up PRs (e.g. when `js-yaml` lands and the
// schema shape changes). Re-introduce specific exports here when a
// cross-package caller actually needs them.

— qwen3.7-max via Qwen Code /review

// package's public API would constrain follow-up PRs (e.g. when
// `js-yaml` lands and the schema shape changes). Re-introduce specific
// exports here when a cross-package caller actually needs them.

// Main management class
export { SubagentManager } from './subagent-manager.js';
Loading
Loading