mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 17:49:06 +00:00
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:
parent
2bfd20ebee
commit
637b8b70cf
7 changed files with 45 additions and 604 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue