refactor(core): shrink declarative agents PR to vertically-sliced v1

Reduces this PR to ship only the fields that have an end-to-end runtime path
today: permissionMode (bridges to existing approvalMode), maxTurns (wires
into runConfig.max_turns), and a tightened color allowlist. Everything else
gets deferred to follow-up PRs that first land the prerequisite infra they
need.

Removed:
- effort (no model-layer effort param in qwen providers)
- mcpServers / hooks (need nested-aware YAML parser — yaml-parser.ts is
  hand-rolled and only handles 1 level)
- memory (qwen's auto-memory has no user/project/local scope distinction)
- isolation (workflow PR #4732 owns the runtime)
- initialPrompt (needs --agent CLI flag, no main-session-agent infra)
- skills (needs SkillManager consumption of config.skills)

This drops 7 fields from SubagentConfig + types.ts, deletes their parser /
serializer / test code, deletes the unused enum constants and helpers from
agent-frontmatter-schema.ts (EFFORT_VALUES, EFFORT_ALIASES, MEMORY_VALUES,
ISOLATION_VALUES, parseEffort, isMemory, isIsolation), and trims the user
docs to the supported field set with a pointer to the design doc for the
deferred fields.

Why ship the design doc alongside such a narrow v1: the full
reverse-engineering record in docs/declarative-agents-port.md (DL7/Ig5/GN/_Y
constants, error messages, schema parity decisions, coordination matrix with
workflow PR #4732) stays load-bearing for the follow-up PRs. A status table
at the top discloses what's deferred and why.

Net: -559 LOC across 7 files. Final PR is permissionMode bridge + maxTurns
wiring + color allowlist + design reference doc + the yaml-parser
nested-limitation pin tests added during round-3 review.

Refs #4821 #4721 #4732
This commit is contained in:
LaZzyMan 2026-06-08 17:42:28 +08:00
parent 2bfd20ebee
commit 637b8b70cf
7 changed files with 45 additions and 604 deletions

View file

@ -8,6 +8,32 @@ coordinating with the workflow port in issue [#4721][i4721] / PR [#4732][p4732].
[i4721]: https://github.com/QwenLM/qwen-code/issues/4721
[p4732]: https://github.com/QwenLM/qwen-code/pull/4732
## Implementation status (vertical-sliced)
PR [#4842][p4842] ships only the fields that have an end-to-end runtime path
today. The remaining fields are deferred to follow-up PRs that first add the
prerequisite infrastructure they need.
| Field | Status in this PR | Why deferred / prerequisite |
| ----------------- | ----------------- | --------------------------------------------------------------------------- |
| `permissionMode` | **shipped** | bridges to existing qwen `approvalMode` at parse time |
| `maxTurns` | **shipped** | wired into existing `runConfig.max_turns` runtime path |
| `color` allowlist | **shipped** | tightens existing field to CC's `_Y` set + `auto` legacy sentinel handling |
| `effort` | deferred | no model-layer `effort` parameter exists yet in qwen providers |
| `mcpServers` | deferred | needs nested-aware YAML parser (replace `yaml-parser.ts` with `js-yaml`) |
| `hooks` | deferred | needs nested-aware YAML parser (same as above) |
| `memory` | deferred | qwen's auto-memory has no `user`/`project`/`local` scope distinction yet |
| `isolation` | deferred | workflow PR #4732 owns the runtime; per-agent default lands when that lands |
| `initialPrompt` | deferred | requires `--agent` CLI flag (no main-session-agent infra in qwen) |
| `skills` | deferred | requires SkillManager consumption of `config.skills` |
The full reverse-engineering record below is retained as the design reference
for the follow-up PRs — schema constants, DL7/Ig5 semantics, error messages,
and the coordination matrix with workflow are still load-bearing for that
work even though the data carriers are not shipped today.
[p4842]: https://github.com/QwenLM/qwen-code/pull/4842
---
## Phase 0 — Boundaries

View file

@ -277,34 +277,25 @@ disallowedTools:
#### Claude Code Compatibility Fields
Qwen Code accepts the full set of frontmatter fields that Claude Code's
`.claude/agents/*.md` schema uses, so you can drop a Claude Code agent file
into `.qwen/agents/` and it will parse identically. Optional fields with
invalid values are silently dropped at parse time rather than rejected — the
same lenient posture Claude Code uses.
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` | string | Enum: `acceptEdits`, `auto`, `bypassPermissions`, `default`, `dontAsk`, `plan`. Mapped to `approvalMode` at parse time. When both are set, `approvalMode` wins. |
| `effort` | string \| number | Thinking effort: `low`, `medium`, `high`, `xhigh`, `max`, or a positive integer. The alias `med` resolves to `medium`. |
| `maxTurns` | number | Positive integer. Caps the agent's turn budget. Overrides the legacy nested `runConfig.max_turns` when both are present. |
| `skills` | string \| array | List of skill names. Accepts a comma-separated string (`"lint, format"`) or a YAML array. Carried through to the runtime; per-skill semantics land in a follow-up. |
| `initialPrompt` | string | Initial message auto-submitted when the agent runs as the main-session agent (e.g. via a future `--agent` flag). Has no effect when the agent runs as a subagent. |
| `memory` | string | Enum: `user`, `project`, `local`. Memory scope tag. Carried verbatim; runtime semantics land in a follow-up. |
| `isolation` | string | Currently only `worktree` is valid. Used as the per-agent default; when a workflow specifies its own isolation per-call, the workflow value wins. |
| `mcpServers` | array | Per-agent MCP-server overrides. **Limited support**: qwen-code's lightweight YAML parser only handles 1 level of nesting. Flat array-of-strings form (`mcpServers: [filesystem-server]`) reads cleanly; the nested CC form (`- name: { type: stdio, command: node }`) is garbled on read. `/agents` edits drop the field rather than risk corruption. Full nested support lands with `js-yaml`. |
| `hooks` | object | Per-agent hook overrides. **Limited support**: hooks require nested YAML throughout, which the local parser cannot represent. Drop-in CC `hooks:` blocks will be misread. Carried as a placeholder for forward compatibility; the field works correctly once `js-yaml` lands. |
| `color` | string | Display color. Allowlist: `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan` (mirrors Claude Code's `_Y`). The legacy qwen sentinel `auto` is also accepted for backward compatibility. Values outside both lists are silently dropped. |
| 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. Overrides the legacy nested `runConfig.max_turns` when both are present; the legacy nested value is pruned from the on-disk file on save to avoid two sources of truth. |
| `color` | enum string | Display color. Allowlist: `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan` (mirrors CC's `_Y`). The legacy qwen sentinel `auto` is accepted on read and normalised to undefined for round-trip parity with the existing CLI helpers. Other values are silently dropped on parse. |
Example combining several fields:
Example:
```
---
name: rigorous-reviewer
description: Deep code review with high thinking effort and a turn cap
effort: high
maxTurns: 50
description: Deep code review with a turn cap
permissionMode: plan
maxTurns: 50
color: cyan
tools:
- read_file
@ -316,6 +307,13 @@ 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
```

View file

@ -6,39 +6,18 @@
import { describe, it, expect } from 'vitest';
import {
EFFORT_VALUES,
EFFORT_ALIASES,
PERMISSION_MODE_VALUES,
MEMORY_VALUES,
ISOLATION_VALUES,
COLOR_VALUES,
claudePermissionModeToApprovalMode,
parseStringOrArray,
parseBackground,
parseMaxTurns,
parseEffort,
isPermissionMode,
isMemory,
isIsolation,
isColor,
} from './agent-frontmatter-schema.js';
describe('agent-frontmatter-schema', () => {
describe('enum constants — Claude Code 2.1.168 parity', () => {
it('EFFORT_VALUES matches DL7 GN constant exactly', () => {
expect([...EFFORT_VALUES]).toEqual([
'low',
'medium',
'high',
'xhigh',
'max',
]);
});
it('EFFORT_ALIASES maps `med` to `medium` per CC P37', () => {
expect(EFFORT_ALIASES).toEqual({ med: 'medium' });
});
it('PERMISSION_MODE_VALUES matches DL7 $E / kc constant exactly', () => {
expect([...PERMISSION_MODE_VALUES]).toEqual([
'acceptEdits',
@ -50,14 +29,6 @@ describe('agent-frontmatter-schema', () => {
]);
});
it('MEMORY_VALUES matches CC memory enum', () => {
expect([...MEMORY_VALUES]).toEqual(['user', 'project', 'local']);
});
it('ISOLATION_VALUES contains only "worktree" (not "none")', () => {
expect([...ISOLATION_VALUES]).toEqual(['worktree']);
});
it('COLOR_VALUES matches CC _Y allowlist exactly', () => {
expect([...COLOR_VALUES]).toEqual([
'red',
@ -195,61 +166,6 @@ describe('agent-frontmatter-schema', () => {
});
});
describe('parseEffort — enum + integer + alias resolution', () => {
it('accepts every EFFORT_VALUES literal', () => {
for (const v of EFFORT_VALUES) {
expect(parseEffort(v)).toBe(v);
}
});
it('normalises `med` alias to `medium`', () => {
expect(parseEffort('med')).toBe('medium');
});
it('accepts integer effort', () => {
expect(parseEffort(5)).toBe(5);
expect(parseEffort(100)).toBe(100);
});
it('accepts numeric string effort (CC DL7 parity)', () => {
// CC's effort parser falls back to parseInt for non-enum strings so a
// quoted YAML number round-trips like an unquoted one.
expect(parseEffort('5')).toBe(5);
expect(parseEffort('100')).toBe(100);
});
it('rejects floats and partial numeric strings', () => {
expect(parseEffort('5.5')).toBeUndefined();
expect(parseEffort('5abc')).toBeUndefined();
expect(parseEffort('1e2')).toBeUndefined();
});
it('rejects zero and negative integers (positive-integer per docs)', () => {
// Stay symmetric with parseMaxTurns (which already rejects <= 0) and
// match the user-facing doc claim that effort accepts a "positive integer".
expect(parseEffort(0)).toBeUndefined();
expect(parseEffort(-1)).toBeUndefined();
expect(parseEffort(-100)).toBeUndefined();
expect(parseEffort('0')).toBeUndefined();
expect(parseEffort('-5')).toBeUndefined();
});
it('returns undefined for invalid string', () => {
expect(parseEffort('extreme')).toBeUndefined();
expect(parseEffort('')).toBeUndefined();
});
it('returns undefined for non-integer numbers', () => {
expect(parseEffort(1.5)).toBeUndefined();
});
it('returns undefined for null / undefined / wrong types', () => {
expect(parseEffort(undefined)).toBeUndefined();
expect(parseEffort(null)).toBeUndefined();
expect(parseEffort(true)).toBeUndefined();
});
});
describe('type guards', () => {
it('isPermissionMode — accepts every PERMISSION_MODE_VALUES, rejects others', () => {
for (const v of PERMISSION_MODE_VALUES) {
@ -260,21 +176,6 @@ describe('agent-frontmatter-schema', () => {
expect(isPermissionMode(undefined)).toBe(false);
});
it('isMemory — accepts every MEMORY_VALUES, rejects others', () => {
for (const v of MEMORY_VALUES) {
expect(isMemory(v)).toBe(true);
}
expect(isMemory('cloud')).toBe(false);
expect(isMemory(undefined)).toBe(false);
});
it('isIsolation — accepts "worktree" only', () => {
expect(isIsolation('worktree')).toBe(true);
expect(isIsolation('none')).toBe(false);
expect(isIsolation('docker')).toBe(false);
expect(isIsolation(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);

View file

@ -20,15 +20,6 @@
* its semantics would break existing `.qwen/agents/*.md` files.
*/
/** Effort enum (DL7 `GN` constant). */
export const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
export type EffortValue = (typeof EFFORT_VALUES)[number];
/** Effort aliases (DL7 `P37` constant). */
export const EFFORT_ALIASES: Readonly<Record<string, EffortValue>> = {
med: 'medium',
};
/** Permission mode enum (DL7 `$E` / `kc` constant). */
export const PERMISSION_MODE_VALUES = [
'acceptEdits',
@ -40,18 +31,6 @@ export const PERMISSION_MODE_VALUES = [
] as const;
export type PermissionModeValue = (typeof PERMISSION_MODE_VALUES)[number];
/** Memory enum (CC parity). */
export const MEMORY_VALUES = ['user', 'project', 'local'] as const;
export type MemoryValue = (typeof MEMORY_VALUES)[number];
/**
* Isolation enum (CC parity). The CC binary also has a `["none","worktree"]`
* schema at strings:313284, but that belongs to background-session settings,
* NOT to the agent frontmatter verified during reverse engineering.
*/
export const ISOLATION_VALUES = ['worktree'] as const;
export type IsolationValue = (typeof ISOLATION_VALUES)[number];
/** Color allowlist (DL7 `_Y` constant). Values outside this list are silently dropped. */
export const COLOR_VALUES = [
'red',
@ -149,35 +128,6 @@ export function parseMaxTurns(value: unknown): number | undefined {
return candidate;
}
/**
* Parse an effort value. Accepts EFFORT_VALUES literals, the `med → medium`
* alias, or a positive integer. Returns `undefined` otherwise.
*/
export function parseEffort(value: unknown): EffortValue | number | undefined {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
const aliased = EFFORT_ALIASES[trimmed];
if (aliased) return aliased;
if ((EFFORT_VALUES as readonly string[]).includes(trimmed)) {
return trimmed as EffortValue;
}
// CC parity: DL7 falls back to parseInt for non-enum strings so that
// `effort: "5"` (quoted YAML number) round-trips like `effort: 5`.
const asInt = Number.parseInt(trimmed, 10);
if (!Number.isNaN(asInt) && String(asInt) === trimmed && asInt > 0) {
return asInt;
}
return undefined;
}
if (typeof value === 'number') {
if (!Number.isInteger(value)) return undefined;
if (value <= 0) return undefined;
return value;
}
return undefined;
}
/** Type guard: value is a valid PERMISSION_MODE_VALUES literal. */
export function isPermissionMode(value: unknown): value is PermissionModeValue {
return (
@ -186,22 +136,6 @@ export function isPermissionMode(value: unknown): value is PermissionModeValue {
);
}
/** Type guard: value is a valid MEMORY_VALUES literal. */
export function isMemory(value: unknown): value is MemoryValue {
return (
typeof value === 'string' &&
(MEMORY_VALUES as readonly string[]).includes(value)
);
}
/** Type guard: value is a valid ISOLATION_VALUES literal. */
export function isIsolation(value: unknown): value is IsolationValue {
return (
typeof value === 'string' &&
(ISOLATION_VALUES as readonly string[]).includes(value)
);
}
/** Type guard: value is a valid COLOR_VALUES literal. */
export function isColor(value: unknown): value is ColorValue {
return (

View file

@ -680,62 +680,6 @@ You are an agent.
expect(config.approvalMode).toBeUndefined();
});
it('should parse valid effort string', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
effort: 'high',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\neffort: high\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.effort).toBe('high');
});
it('should normalise effort alias med → medium', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
effort: 'med',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\neffort: med\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.effort).toBe('medium');
});
it('should accept integer effort', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
effort: 7,
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\neffort: 7\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.effort).toBe(7);
});
it('should drop invalid effort', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
effort: 'extreme',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\neffort: extreme\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.effort).toBeUndefined();
});
it('should parse maxTurns as number', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
@ -778,148 +722,6 @@ You are an agent.
expect(config.maxTurns).toBeUndefined();
});
it('should parse skills from comma-separated string', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
skills: 'lint, format',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nskills: "lint, format"\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.skills).toEqual(['lint', 'format']);
});
it('should parse skills from YAML array', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
skills: ['lint', 'format'],
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nskills:\n - lint\n - format\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.skills).toEqual(['lint', 'format']);
});
it('should parse initialPrompt verbatim', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
initialPrompt: 'Start by reading README.md',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\ninitialPrompt: Start by reading README.md\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.initialPrompt).toBe('Start by reading README.md');
});
it('should drop whitespace-only initialPrompt', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
initialPrompt: ' ',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\ninitialPrompt: " "\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.initialPrompt).toBeUndefined();
});
it('should parse valid memory value', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
memory: 'project',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nmemory: project\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.memory).toBe('project');
});
it('should drop invalid memory value', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
memory: 'cloud',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nmemory: cloud\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.memory).toBeUndefined();
});
it('should parse isolation: worktree', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
isolation: 'worktree',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nisolation: worktree\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.isolation).toBe('worktree');
});
it('should drop invalid isolation value', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
isolation: 'docker',
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nisolation: docker\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.isolation).toBeUndefined();
});
it('should carry mcpServers verbatim (loose validation)', () => {
const mcpServers = [{ filesystem: { type: 'stdio' } }];
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
mcpServers,
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nmcpServers: [...]\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.mcpServers).toEqual(mcpServers);
});
it('should carry hooks verbatim (loose validation)', () => {
const hooks = { PreToolUse: [] };
mockParseYaml.mockReturnValueOnce({
name: 'a',
description: 'd',
hooks,
});
const config = manager.parseSubagentContent(
'---\nname: a\ndescription: d\nhooks: {...}\n---\nx',
validConfig.filePath!,
'project',
);
expect(config.hooks).toEqual(hooks);
});
it('should preserve color from allowlist', () => {
mockParseYaml.mockReturnValueOnce({
name: 'a',
@ -1085,14 +887,6 @@ You are an agent.
expect(serialized).toContain('permissionMode: bypassPermissions');
});
it('should serialize effort when set', () => {
const serialized = manager.serializeSubagent({
...validConfig,
effort: 'high',
});
expect(serialized).toContain('effort: high');
});
it('should serialize maxTurns when set', () => {
const serialized = manager.serializeSubagent({
...validConfig,
@ -1101,49 +895,6 @@ You are an agent.
expect(serialized).toContain('maxTurns: 25');
});
it('should serialize skills array when non-empty', () => {
const serialized = manager.serializeSubagent({
...validConfig,
skills: ['lint', 'format'],
});
expect(serialized).toContain('skills:');
expect(serialized).toContain('- lint');
expect(serialized).toContain('- format');
});
it('should not serialize empty skills array', () => {
const serialized = manager.serializeSubagent({
...validConfig,
skills: [],
});
expect(serialized).not.toContain('skills:');
});
it('should serialize initialPrompt when set', () => {
const serialized = manager.serializeSubagent({
...validConfig,
initialPrompt: 'Begin reading README',
});
expect(serialized).toContain('initialPrompt:');
expect(serialized).toContain('Begin reading README');
});
it('should serialize memory when set', () => {
const serialized = manager.serializeSubagent({
...validConfig,
memory: 'project',
});
expect(serialized).toContain('memory: project');
});
it('should serialize isolation when set', () => {
const serialized = manager.serializeSubagent({
...validConfig,
isolation: 'worktree',
});
expect(serialized).toContain('isolation: worktree');
});
it('should prune legacy runConfig.max_turns when top-level maxTurns is set', () => {
// Avoid emitting two sources of truth: when the runtime already
// promotes maxTurns to the top level, the on-disk file should not
@ -1185,34 +936,7 @@ You are an agent.
it('should not include new fields when undefined', () => {
const serialized = manager.serializeSubagent(validConfig);
expect(serialized).not.toContain('permissionMode:');
expect(serialized).not.toContain('effort:');
expect(serialized).not.toContain('maxTurns:');
expect(serialized).not.toContain('skills:');
expect(serialized).not.toContain('initialPrompt:');
expect(serialized).not.toContain('memory:');
expect(serialized).not.toContain('isolation:');
expect(serialized).not.toContain('mcpServers:');
expect(serialized).not.toContain('hooks:');
});
it('should NOT serialize mcpServers (nested-object round-trip not safe yet)', () => {
// The local yaml-parser only formats one level of nesting and would
// mangle nested mcpServers into "[object Object]" on emit. We carry the
// field through in memory but drop it on serialize until a real YAML
// library is wired in.
const serialized = manager.serializeSubagent({
...validConfig,
mcpServers: [{ filesystem: { type: 'stdio', command: 'node' } }],
});
expect(serialized).not.toContain('mcpServers:');
});
it('should NOT serialize hooks (nested-object round-trip not safe yet)', () => {
const serialized = manager.serializeSubagent({
...validConfig,
hooks: { PreToolUse: [{ matcher: 'x' }] },
});
expect(serialized).not.toContain('hooks:');
});
});

View file

@ -49,13 +49,9 @@ import { BuiltinAgentRegistry } from './builtin-agents.js';
import {
COLOR_VALUES,
isColor,
isIsolation,
isMemory,
isPermissionMode,
parseBackground,
parseEffort,
parseMaxTurns,
parseStringOrArray,
claudePermissionModeToApprovalMode,
} from './agent-frontmatter-schema.js';
import { ToolDisplayNamesMigration } from '../tools/tool-names.js';
@ -638,38 +634,10 @@ export class SubagentManager {
frontmatter['permissionMode'] = config.permissionMode;
}
if (config.effort !== undefined) {
frontmatter['effort'] = config.effort;
}
if (config.maxTurns !== undefined) {
frontmatter['maxTurns'] = config.maxTurns;
}
if (config.skills && config.skills.length > 0) {
frontmatter['skills'] = config.skills;
}
if (config.initialPrompt) {
frontmatter['initialPrompt'] = config.initialPrompt;
}
if (config.memory) {
frontmatter['memory'] = config.memory;
}
if (config.isolation) {
frontmatter['isolation'] = config.isolation;
}
// NOTE: mcpServers and hooks are intentionally NOT serialised. They are
// carried verbatim from the file into in-memory config, but the local
// yaml-parser only formats one level of nesting and would mangle their
// shape on emit. Until a real YAML library is wired in, the
// `updateSubagent` write path will drop these fields rather than corrupt
// them. Users who edit them manually retain their values through reads;
// see docs/users/features/sub-agents.md for the limitation.
// Serialize to YAML
const yamlContent = stringifyYaml(frontmatter, {
lineWidth: 0, // Disable line wrapping
@ -1278,14 +1246,6 @@ function parseSubagentContent(
: undefined;
const effectiveApprovalMode = approvalMode ?? bridgedApprovalMode;
// effort: DL7 GN enum or integer; `med` alias normalises to `medium`.
const effort = parseEffort(frontmatter['effort']);
if (frontmatter['effort'] !== undefined && effort === undefined) {
debugLogger.warn(
`Agent file ${filePath} has invalid effort '${frontmatter['effort']}'. Dropping field.`,
);
}
// maxTurns: positive integer (or numeric string).
const maxTurns = parseMaxTurns(frontmatter['maxTurns']);
if (frontmatter['maxTurns'] !== undefined && maxTurns === undefined) {
@ -1294,43 +1254,6 @@ function parseSubagentContent(
);
}
// skills: comma-separated string or YAML array.
const skills = parseStringOrArray(frontmatter['skills']);
// initialPrompt: any non-empty-after-trim string.
const initialPromptRaw = frontmatter['initialPrompt'];
const initialPrompt =
typeof initialPromptRaw === 'string' && initialPromptRaw.trim().length > 0
? initialPromptRaw
: undefined;
// memory: CC enum.
const memoryRaw = frontmatter['memory'];
const memory = isMemory(memoryRaw) ? memoryRaw : undefined;
if (memoryRaw !== undefined && memoryRaw !== null && memory === undefined) {
debugLogger.warn(
`Agent file ${filePath} has invalid memory '${memoryRaw}'. Dropping field.`,
);
}
// isolation: CC enum (currently "worktree" only).
const isolationRaw = frontmatter['isolation'];
const isolation = isIsolation(isolationRaw) ? isolationRaw : undefined;
if (
isolationRaw !== undefined &&
isolationRaw !== null &&
isolation === undefined
) {
debugLogger.warn(
`Agent file ${filePath} has invalid isolation '${isolationRaw}'. Dropping field.`,
);
}
// mcpServers / hooks: loose validation per CC Ig5 (z.unknown()). Carried
// verbatim; runtime semantics deferred to follow-up PRs.
const mcpServers = frontmatter['mcpServers'];
const hooks = frontmatter['hooks'];
const config: SubagentConfig = {
name,
description,
@ -1345,14 +1268,7 @@ function parseSubagentContent(
level,
...(background ? { background } : {}),
...(permissionMode !== undefined ? { permissionMode } : {}),
...(effort !== undefined ? { effort } : {}),
...(maxTurns !== undefined ? { maxTurns } : {}),
...(skills !== undefined ? { skills } : {}),
...(initialPrompt !== undefined ? { initialPrompt } : {}),
...(memory !== undefined ? { memory } : {}),
...(isolation !== undefined ? { isolation } : {}),
...(mcpServers !== undefined ? { mcpServers } : {}),
...(hooks !== undefined ? { hooks } : {}),
};
// Validate the parsed configuration

View file

@ -119,13 +119,6 @@ export interface SubagentConfig {
*/
permissionMode?: string;
/**
* Optional thinking-effort hint. Either one of `low | medium | high | xhigh
* | max` (with `med` aliased to `medium`) or a positive integer. Maps to
* Claude Code's DL7 `effort` field.
*/
effort?: string | number;
/**
* Optional maximum number of turns before the agent halts. Positive integer.
* Top-level promotion of the legacy `runConfig.max_turns` field; when both
@ -133,57 +126,6 @@ export interface SubagentConfig {
*/
maxTurns?: number;
/**
* Optional list of skill names to expose to this agent. Matches CC `skills`
* frontmatter field.
*/
skills?: string[];
/**
* Optional initial prompt automatically submitted when the agent becomes
* the main-session agent (via `--agent` or settings). Has no effect when
* the agent runs as a subagent.
*/
initialPrompt?: string;
/**
* Optional memory binding. One of `user | project | local`. Carried verbatim
* for CC parity; runtime semantics are deferred to a follow-up PR.
*/
memory?: string;
/**
* Optional isolation mode. Currently the only valid value is `worktree`.
* Used as the per-agent default; per-call workflow `opts.isolation` (when
* present) overrides this. Carried verbatim for CC parity; runtime
* semantics are owned by the workflow port (#4721 / PR #4732).
*/
isolation?: string;
/**
* Optional MCP-server overrides. Carried verbatim for CC parity; runtime
* semantics are deferred to a follow-up PR.
*
* **Known limitation:** qwen-code's lightweight `yaml-parser.ts` only handles
* one level of YAML nesting. Flat forms array of server names, e.g.
* `mcpServers: [filesystem-server, redis-server]` round-trip cleanly.
* Nested CC forms (`mcpServers: - name: { type: stdio, command: node }`)
* are read with deeper keys collapsed or leaked. Full nested support lands
* when this codebase wires in `js-yaml`.
*/
mcpServers?: unknown;
/**
* Optional hooks. Carried verbatim for CC parity; runtime semantics are
* deferred to a follow-up PR.
*
* **Known limitation:** hooks fundamentally require nested YAML, which
* qwen-code's `yaml-parser.ts` cannot represent (see `mcpServers` above).
* In practice the field is shaped correctly only for trivial inputs; full
* support lands with `js-yaml`.
*/
hooks?: unknown;
/**
* Indicates whether this is a built-in agent.
* Built-in agents cannot be modified or deleted.