fix: use scalar Claude agent tools (#2583)

Normalize scalar Claude agent tool metadata across validators, adapters, dashboards, and generated surfaces with regression coverage.
This commit is contained in:
Affaan Mustafa 2026-07-26 03:20:15 -07:00 committed by GitHub
parent f3afd59045
commit 6a9f075cd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 515 additions and 87 deletions

View file

@ -19,24 +19,44 @@ function extractFrontmatter(content) {
const frontmatter = {};
const duplicates = [];
const sequenceFields = [];
let currentTopLevelKey = null;
const lines = match[1].split(/\r?\n/);
for (const line of lines) {
if (/^\s*-\s+/.test(line)) {
if (currentTopLevelKey) {
sequenceFields.push(currentTopLevelKey);
}
continue;
}
// Only top-level keys are unique. Indented YAML belongs to nested values.
if (/^\s/.test(line)) continue;
if (!line.trim() || line.trim().startsWith('#')) continue;
currentTopLevelKey = null;
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
currentTopLevelKey = key;
if (Object.prototype.hasOwnProperty.call(frontmatter, key)) {
duplicates.push(key);
}
frontmatter[key] = value;
if (value && '[!&*{|>'.includes(value[0])) {
sequenceFields.push(key);
}
}
}
Object.defineProperty(frontmatter, '__duplicates__', {
value: duplicates,
enumerable: false,
});
Object.defineProperty(frontmatter, '__sequenceFields__', {
value: sequenceFields,
enumerable: false,
});
return frontmatter;
}
@ -79,6 +99,11 @@ function validateAgents() {
}
}
if (frontmatter.__sequenceFields__.includes('tools')) {
console.error(`ERROR: ${file} - Agent tools must be a comma-separated scalar, not a YAML sequence`);
hasErrors = true;
}
// Validate model is a known value
if (frontmatter.model && !VALID_MODELS.includes(frontmatter.model)) {
console.error(`ERROR: ${file} - Invalid model '${frontmatter.model}'. Must be one of: ${VALID_MODELS.join(', ')}`);

View file

@ -12,6 +12,7 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const { normalizeAgentTools } = require('./lib/agent-tools');
function parsePort(v) {
const n = parseInt(String(v), 10);
@ -31,7 +32,11 @@ function readFrontmatter(p) {
const s = l.indexOf(':'); if (s <= 0) continue;
let k = l.slice(0, s).trim(), v = l.slice(s + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
if (v.startsWith('[') && v.endsWith(']')) { try { v = JSON.parse(v); } catch { v = v.slice(1, -1).split(',').map(x => x.trim().replace(/["']/g, '')); } }
if (k === 'tools') {
v = normalizeAgentTools(v);
} else if (v.startsWith('[') && v.endsWith(']')) {
try { v = JSON.parse(v); } catch { v = v.slice(1, -1).split(',').map(x => x.trim().replace(/["']/g, '')); }
}
fm[k] = v;
}
fm._body = c.replace(/^---[\s\S]*?---\n*/, '').trim();

View file

@ -3,6 +3,7 @@
const fs = require('fs');
const path = require('path');
const { normalizeAgentTools } = require('./lib/agent-tools');
const TOOL_NAME_MAP = new Map([
['Read', 'read_file'],
@ -53,25 +54,13 @@ function ensureDirectory(dirPath) {
}
}
function stripQuotes(value) {
return value.trim().replace(/^['"]|['"]$/g, '');
}
function parseToolList(line) {
const match = line.match(/^(\s*tools\s*:\s*)\[(.*)\]\s*$/);
const match = line.match(/^\s*tools\s*:\s*(.*)$/);
if (!match) {
return null;
}
const rawItems = match[2].trim();
if (!rawItems) {
return [];
}
return rawItems
.split(',')
.map(part => stripQuotes(part))
.filter(Boolean);
return normalizeAgentTools(match[1]);
}
function adaptToolName(toolName) {

View file

@ -2,6 +2,7 @@
const fs = require('fs');
const path = require('path');
const { normalizeAgentTools } = require('./agent-tools');
/**
* Parse YAML frontmatter from a markdown string.
@ -35,6 +36,10 @@ function parseFrontmatter(content) {
value = value.slice(1, -1);
}
if (key === 'tools') {
value = normalizeAgentTools(value);
}
frontmatter[key] = value;
}

View file

@ -0,0 +1,97 @@
'use strict';
function stripSurroundingQuotes(value) {
const trimmed = value.trim();
const quote = trimmed[0];
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function splitTopLevelToolList(value) {
const items = [];
const delimiters = [];
let quote = null;
let escaped = false;
let itemStart = 0;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (quote) {
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === quote) {
quote = null;
}
continue;
}
if (character === '"' || character === "'") {
quote = character;
continue;
}
if (character === '(' || character === '[' || character === '{') {
delimiters.push(character);
continue;
}
const expectedOpener = {
')': '(',
']': '[',
'}': '{',
}[character];
if (expectedOpener && delimiters.at(-1) === expectedOpener) {
delimiters.pop();
continue;
}
if (character === ',' && delimiters.length === 0) {
items.push(value.slice(itemStart, index));
itemStart = index + 1;
}
}
items.push(value.slice(itemStart));
return items;
}
/**
* Normalize Claude agent frontmatter tools to the array shape used internally.
*
* Claude Code expects tools to be a comma-separated scalar. Flow sequences are
* still accepted here so ECC can read legacy or harness-adapted agent files.
*/
function normalizeAgentTools(value) {
if (Array.isArray(value)) {
return value
.filter(item => typeof item === 'string')
.map(stripSurroundingQuotes)
.filter(Boolean);
}
if (typeof value !== 'string') {
return [];
}
const trimmed = value.trim();
const listValue = trimmed.startsWith('[') && trimmed.endsWith(']')
? trimmed.slice(1, -1)
: stripSurroundingQuotes(trimmed);
if (!listValue.trim()) {
return [];
}
return splitTopLevelToolList(listValue)
.map(stripSurroundingQuotes)
.filter(Boolean);
}
module.exports = {
normalizeAgentTools,
};