mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 04:35:29 +02:00
feat(qwen): add qwen-adapt-agents.js to normalize agent frontmatter
--target qwen installs all 68 agent definitions verbatim, so they carry Claude Code tool names (Read, Write, Edit, Bash, Grep, Glob, WebSearch) and color: teal. Qwen Code uses different tool ids, and its palette is red/blue/green/yellow/ purple/orange/pink/cyan/auto, so an unlisted value is dropped at load time with a SUBAGENT_MANAGER warning. ECC already solves this for Gemini via scripts/gemini-adapt-agents.js; this adds the Qwen counterpart plus a test mirroring the Gemini one. The canonical agents/ directory is untouched -- each harness adapts its own installed copy. Deliberate differences from the Gemini adapter, each a real Qwen behaviour: Edit -> edit (Gemini maps to replace) WebSearch -> dropped (Qwen has web_fetch only, no web search) mcp__server__tool -> unchanged (Gemini lowercases to mcp_server_tool) color: -> remapped (Gemini removes it; teal becomes cyan) tools: output -> comma scalar (the form Qwen's own examples use) model: is left alone, matching the Gemini adapter. 58 files say sonnet, 6 haiku, 4 opus and none are Qwen model ids, but Qwen logs no warning for them, so whether they are ignored or resolved at dispatch time is unverified and changing 68 files on an assumption seemed worse than flagging it as a follow-up. Tested: 10/10 new cases pass and gemini-adapt-agents.test.js still passes 4/4. Run against a copy of all 68 real definitions: 68 updated, 0 bodies changed, 0 invalid frontmatters, second run reports 0 updated. The result was installed into ~/.qwen/agents and a headless Qwen session loaded all 68 with no errors and no color warnings.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { normalizeAgentTools } = require('./lib/agent-tools');
|
||||
|
||||
// Qwen Code is a Gemini CLI descendant, so most tool ids match Gemini's. Three
|
||||
// entries deliberately differ from gemini-adapt-agents.js:
|
||||
//
|
||||
// Edit -> edit (Gemini uses `replace`)
|
||||
// WebSearch -> dropped (Gemini has `google_web_search`; Qwen has no
|
||||
// web search tool, only `web_fetch`)
|
||||
// mcp__* -> unchanged (Gemini lowercases to mcp_server_tool; Qwen
|
||||
// keeps the double-underscore form)
|
||||
const TOOL_NAME_MAP = new Map([
|
||||
['Read', 'read_file'],
|
||||
['ReadFile', 'read_file'],
|
||||
['NotebookRead', 'read_file'],
|
||||
['Write', 'write_file'],
|
||||
['Edit', 'edit'],
|
||||
['MultiEdit', 'edit'],
|
||||
['Bash', 'run_shell_command'],
|
||||
['Grep', 'grep_search'],
|
||||
['Glob', 'glob'],
|
||||
['WebFetch', 'web_fetch'],
|
||||
['TodoWrite', 'todo_write'],
|
||||
['Task', 'agent'],
|
||||
]);
|
||||
|
||||
// Tools with no Qwen Code equivalent. Silently keeping them would leave an agent
|
||||
// definition advertising a capability it cannot use.
|
||||
const TOOL_NAME_DROP = new Set(['WebSearch']);
|
||||
|
||||
// Qwen Code supports `color:`, but only this palette. An unlisted value is
|
||||
// dropped at load time with a SUBAGENT_MANAGER warning, so remap where the
|
||||
// intent is clear and drop the rest rather than shipping a field that warns.
|
||||
const VALID_COLORS = new Set([
|
||||
'red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan', 'auto',
|
||||
]);
|
||||
const COLOR_MAP = new Map([['teal', 'cyan']]);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Adapt ECC agent frontmatter for Qwen Code.',
|
||||
'',
|
||||
'Usage:',
|
||||
' node scripts/qwen-adapt-agents.js [agents-dir]',
|
||||
'',
|
||||
"Defaults to .qwen/agents under the current working directory.",
|
||||
'Rewrites tools: to Qwen Code tool ids, drops tools Qwen Code does not have,',
|
||||
'and remaps color: values outside the Qwen Code palette.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
if (argv.includes('--help') || argv.includes('-h')) {
|
||||
return { help: true };
|
||||
}
|
||||
|
||||
const positional = argv.filter(arg => !arg.startsWith('-'));
|
||||
if (positional.length > 1) {
|
||||
throw new Error('Expected at most one agents directory argument');
|
||||
}
|
||||
|
||||
return {
|
||||
help: false,
|
||||
agentsDir: path.resolve(positional[0] || path.join(process.cwd(), '.qwen', 'agents')),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
throw new Error(`Agents directory not found: ${dirPath}`);
|
||||
}
|
||||
|
||||
if (!fs.statSync(dirPath).isDirectory()) {
|
||||
throw new Error(`Expected a directory: ${dirPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseToolList(line) {
|
||||
const match = line.match(/^\s*tools\s*:\s*(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAgentTools(match[1]);
|
||||
}
|
||||
|
||||
function adaptToolName(toolName) {
|
||||
const mapped = TOOL_NAME_MAP.get(toolName);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (TOOL_NAME_DROP.has(toolName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Qwen Code keeps the mcp__server__tool shape, so MCP entries pass through.
|
||||
return toolName;
|
||||
}
|
||||
|
||||
function formatToolLine(tools) {
|
||||
// Comma-separated scalar rather than the JSON flow sequence
|
||||
// gemini-adapt-agents.js emits: Qwen Code parses both, and the scalar form is
|
||||
// what its own agent examples use.
|
||||
return `tools: ${tools.join(', ')}`;
|
||||
}
|
||||
|
||||
function adaptColor(line) {
|
||||
const match = line.match(/^(\s*)color\s*:\s*(.+?)\s*$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = match[2].replace(/^["']|["']$/g, '');
|
||||
if (VALID_COLORS.has(value)) {
|
||||
return { line, changed: false };
|
||||
}
|
||||
|
||||
const remapped = COLOR_MAP.get(value);
|
||||
if (remapped) {
|
||||
return { line: `${match[1]}color: ${remapped}`, changed: true };
|
||||
}
|
||||
|
||||
return { line: null, changed: true };
|
||||
}
|
||||
|
||||
function adaptFrontmatter(text) {
|
||||
const match = text.match(/^---\n([\s\S]*?)\n---(\n|$)/);
|
||||
if (!match) {
|
||||
return { text, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const updatedLines = [];
|
||||
|
||||
for (const line of match[1].split('\n')) {
|
||||
const color = adaptColor(line);
|
||||
if (color) {
|
||||
if (color.changed) {
|
||||
changed = true;
|
||||
}
|
||||
if (color.line !== null) {
|
||||
updatedLines.push(color.line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const tools = parseToolList(line);
|
||||
if (tools) {
|
||||
const adaptedTools = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const tool of tools.map(adaptToolName)) {
|
||||
if (!tool || seen.has(tool)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(tool);
|
||||
adaptedTools.push(tool);
|
||||
}
|
||||
|
||||
const updatedLine = formatToolLine(adaptedTools);
|
||||
if (updatedLine !== line) {
|
||||
changed = true;
|
||||
}
|
||||
updatedLines.push(updatedLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedLines.push(line);
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { text, changed: false };
|
||||
}
|
||||
|
||||
return {
|
||||
text: `---\n${updatedLines.join('\n')}\n---${match[2]}${text.slice(match[0].length)}`,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function adaptAgents(dirPath) {
|
||||
ensureDirectory(dirPath);
|
||||
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(dirPath, entry.name);
|
||||
const original = fs.readFileSync(filePath, 'utf8');
|
||||
const adapted = adaptFrontmatter(original);
|
||||
|
||||
if (adapted.changed) {
|
||||
fs.writeFileSync(filePath, adapted.text);
|
||||
updated += 1;
|
||||
} else {
|
||||
unchanged += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, unchanged };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const result = adaptAgents(options.agentsDir);
|
||||
console.log(`Updated ${result.updated} agent file(s); ${result.unchanged} already compatible`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Tests for scripts/qwen-adapt-agents.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'qwen-adapt-agents.js');
|
||||
|
||||
function run(args = [], options = {}) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: options.cwd,
|
||||
timeout: 10000,
|
||||
});
|
||||
return { code: 0, stdout, stderr: '' };
|
||||
} catch (error) {
|
||||
return {
|
||||
code: error.status || 1,
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-qwen-adapt-'));
|
||||
}
|
||||
|
||||
function cleanupTempDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function writeAgent(dirPath, name, body) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, name), body);
|
||||
}
|
||||
|
||||
function readAgent(dirPath, name) {
|
||||
return fs.readFileSync(path.join(dirPath, name), 'utf8');
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing qwen-adapt-agents.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('shows help with an explicit help flag', () => {
|
||||
const result = run(['--help']);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Adapt ECC agent frontmatter for Qwen Code'));
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('maps Claude Code tool names to Qwen Code ids and drops WebSearch', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'gan-planner.md',
|
||||
[
|
||||
'---',
|
||||
'name: gan-planner',
|
||||
'description: Planner agent',
|
||||
'tools: [Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch]',
|
||||
'model: opus',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Updated 1 agent file(s)'));
|
||||
|
||||
const updated = readAgent(agentsDir, 'gan-planner.md');
|
||||
// Edit maps to `edit`, not Gemini's `replace`.
|
||||
assert.ok(updated.includes('tools: read_file, write_file, edit, run_shell_command, grep_search, glob, web_fetch'));
|
||||
// Qwen Code has no web search tool, so the entry is dropped rather than
|
||||
// left advertising a capability the agent cannot use.
|
||||
assert.ok(!updated.includes('WebSearch'));
|
||||
assert.ok(!updated.includes('web_search'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves the mcp__server__tool shape', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'docs-lookup.md',
|
||||
[
|
||||
'---',
|
||||
'name: docs-lookup',
|
||||
'description: Documentation lookup agent',
|
||||
'tools: Read, Grep, mcp__context7__resolve-library-id, mcp__context7__query-docs',
|
||||
'model: sonnet',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const updated = readAgent(agentsDir, 'docs-lookup.md');
|
||||
// Unlike Gemini, which lowercases these to mcp_context7_resolve_library_id,
|
||||
// Qwen Code keeps the double-underscore form.
|
||||
assert.ok(updated.includes('tools: read_file, grep_search, mcp__context7__resolve-library-id, mcp__context7__query-docs'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deduplicates MultiEdit and Edit onto a single edit entry', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'code-simplifier.md',
|
||||
[
|
||||
'---',
|
||||
'name: code-simplifier',
|
||||
'description: Refactoring agent',
|
||||
'tools: Read, Edit, MultiEdit, Write',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const updated = readAgent(agentsDir, 'code-simplifier.md');
|
||||
assert.ok(updated.includes('tools: read_file, edit, write_file'));
|
||||
assert.strictEqual(updated.split('edit').length - 1, 1);
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('remaps an out-of-palette color instead of dropping it', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'harness-optimizer.md',
|
||||
[
|
||||
'---',
|
||||
'name: harness-optimizer',
|
||||
'description: Harness tuning agent',
|
||||
'tools: Read, Grep',
|
||||
'color: teal',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
// Qwen Code supports color:, but only red/blue/green/yellow/purple/orange/
|
||||
// pink/cyan/auto. An unlisted value is dropped at load time with a
|
||||
// warning, so teal is remapped to its nearest valid equivalent.
|
||||
const updated = readAgent(agentsDir, 'harness-optimizer.md');
|
||||
assert.ok(updated.includes('color: cyan'));
|
||||
assert.ok(!updated.includes('color: teal'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('leaves an already-compatible agent untouched', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'security-reviewer.md',
|
||||
[
|
||||
'---',
|
||||
'name: security-reviewer',
|
||||
'description: Security agent',
|
||||
'tools: read_file, grep_search',
|
||||
'color: red',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const before = readAgent(agentsDir, 'security-reviewer.md');
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Updated 0 agent file(s)'));
|
||||
assert.ok(result.stdout.includes('1 already compatible'));
|
||||
// A color inside the palette and Qwen-native tool ids need no rewrite.
|
||||
assert.strictEqual(readAgent(agentsDir, 'security-reviewer.md'), before);
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('leaves the agent body untouched', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
const body = ['', 'Read the codebase, then Write a summary.', 'Use Bash only when needed.'].join('\n');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'code-explorer.md',
|
||||
['---', 'name: code-explorer', 'tools: Read, Bash', '---', body].join('\n')
|
||||
);
|
||||
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const updated = readAgent(agentsDir, 'code-explorer.md');
|
||||
// Prose that names Claude tools must survive: only frontmatter is in scope.
|
||||
assert.ok(updated.endsWith(body));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('is idempotent on a second run', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'architect.md',
|
||||
['---', 'name: architect', 'tools: Read, Grep, Glob', 'color: teal', '---', '', 'Body'].join('\n')
|
||||
);
|
||||
|
||||
const first = run([agentsDir]);
|
||||
assert.strictEqual(first.code, 0, first.stderr);
|
||||
assert.ok(first.stdout.includes('Updated 1 agent file(s)'));
|
||||
const afterFirst = readAgent(agentsDir, 'architect.md');
|
||||
|
||||
const second = run([agentsDir]);
|
||||
assert.strictEqual(second.code, 0, second.stderr);
|
||||
assert.ok(second.stdout.includes('Updated 0 agent file(s)'));
|
||||
assert.ok(second.stdout.includes('1 already compatible'));
|
||||
assert.strictEqual(readAgent(agentsDir, 'architect.md'), afterFirst);
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('defaults to the cwd .qwen/agents directory', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.qwen', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'planner.md',
|
||||
['---', 'name: planner', 'tools: ["Read", "Grep", "Glob"]', '---', '', 'Body'].join('\n')
|
||||
);
|
||||
|
||||
const result = run([], { cwd: tempDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(readAgent(agentsDir, 'planner.md').includes('tools: read_file, grep_search, glob'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails with a clear error when the agents directory is missing', () => {
|
||||
const tempDir = createTempDir();
|
||||
|
||||
try {
|
||||
const result = run([path.join(tempDir, 'nope')]);
|
||||
assert.strictEqual(result.code, 1);
|
||||
assert.ok(result.stderr.includes('Agents directory not found'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user