Skip to content

Commit 7e3450c

Browse files
committed
fix(benchmark): harden llmCall for local models and improve JSON parsing
- Auto-detect mode: default to 'llm' when no VLM URL is set - Convert tool_calls/tool messages to plain text for llama-server compat - Smart max_tokens: use max_completion_tokens for cloud, max_tokens for local - Expand stripThink to handle Qwen3.5 plain-text reasoning blocks - Harden JSON parser: clean ellipsis, placeholder tags, trailing commas - Always exit 0 in skill mode (results reported via JSON events)
1 parent eb9dfbe commit 7e3450c

1 file changed

Lines changed: 51 additions & 9 deletions

File tree

skills/analysis/home-security-benchmark/scripts/run-benchmark.cjs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ const VLM_URL = process.env.AEGIS_VLM_URL || getArg('vlm', '');
8585
const RESULTS_DIR = getArg('out', path.join(os.homedir(), '.aegis-ai', 'benchmarks'));
8686
const IS_SKILL_MODE = !!process.env.AEGIS_SKILL_ID;
8787
const NO_OPEN = args.includes('--no-open') || skillParams.noOpen || false;
88-
const TEST_MODE = skillParams.mode || 'full';
88+
// Auto-detect mode: if no VLM URL, default to 'llm' (skip VLM image-analysis tests)
89+
const TEST_MODE = skillParams.mode || (VLM_URL ? 'full' : 'llm');
8990
const IDLE_TIMEOUT_MS = 30000; // Streaming idle timeout — resets on each received token
9091
const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures');
9192

@@ -190,18 +191,42 @@ async function llmCall(messages, opts = {}) {
190191

191192
// Sanitize messages for llama-server compatibility:
192193
// - Replace null content with empty string (llama-server rejects null)
193-
messages = messages.map(m => ({
194-
...m,
195-
...(m.content === null && { content: '' }),
196-
}));
194+
// - Convert tool_calls assistant messages to plain text (llama-server
195+
// doesn't support OpenAI tool_calls format in conversation history)
196+
// - Convert tool result messages to user messages
197+
messages = messages.map(m => {
198+
if (m.role === 'assistant' && m.tool_calls) {
199+
// Convert tool call to text representation
200+
const callDesc = m.tool_calls.map(tc =>
201+
`[Calling ${tc.function.name}(${tc.function.arguments})]`
202+
).join('\n');
203+
return { role: 'assistant', content: callDesc };
204+
}
205+
if (m.role === 'tool') {
206+
// Convert tool result to user message
207+
return { role: 'user', content: `[Tool result]: ${m.content}` };
208+
}
209+
return {
210+
...m,
211+
...(m.content === null && { content: '' }),
212+
};
213+
});
214+
215+
// Determine the correct max-tokens parameter name:
216+
// - OpenAI cloud (GPT-5.4+): requires 'max_completion_tokens', rejects 'max_tokens'
217+
// - Local llama-server: requires 'max_tokens', may not understand 'max_completion_tokens'
218+
const isCloudApi = !opts.vlm && (LLM_API_TYPE === 'openai' || LLM_BASE_URL.includes('openai.com') || LLM_BASE_URL.includes('api.anthropic'));
219+
const maxTokensParam = opts.maxTokens
220+
? (isCloudApi ? { max_completion_tokens: opts.maxTokens } : { max_tokens: opts.maxTokens })
221+
: {};
197222

198223
// Build request params
199224
const params = {
200225
messages,
201226
stream: true,
202227
...(model && { model }),
203228
...(opts.temperature !== undefined && { temperature: opts.temperature }),
204-
...(opts.maxTokens && { max_tokens: opts.maxTokens }),
229+
...maxTokensParam,
205230
...(opts.expectJSON && opts.temperature === undefined && { temperature: 0.7 }),
206231
...(opts.expectJSON && { top_p: 0.8 }),
207232
...(opts.tools && { tools: opts.tools }),
@@ -353,7 +378,12 @@ async function llmCall(messages, opts = {}) {
353378
}
354379

355380
function stripThink(text) {
356-
return text.replace(/<think>[\s\S]*?<\/think>\s*/gi, '').trim();
381+
// Strip standard <think>...</think> tags
382+
let cleaned = text.replace(/<think>[\s\S]*?<\/think>\s*/gi, '').trim();
383+
// Strip Qwen3.5 'Thinking Process:' blocks (outputs plain text reasoning
384+
// instead of <think> tags when enable_thinking is active)
385+
cleaned = cleaned.replace(/^Thinking Process[:\s]*[\s\S]*?(?=\n\s*[{\[]|\n```|$)/i, '').trim();
386+
return cleaned;
357387
}
358388

359389
function parseJSON(text) {
@@ -364,7 +394,7 @@ function parseJSON(text) {
364394
jsonStr = codeBlock[1];
365395
} else {
366396
// Find first { or [ and extract balanced JSON
367-
const startIdx = cleaned.search(/[{[]/);
397+
const startIdx = cleaned.search(/[{\[]/);
368398
if (startIdx >= 0) {
369399
const opener = cleaned[startIdx];
370400
const closer = opener === '{' ? '}' : ']';
@@ -383,6 +413,15 @@ function parseJSON(text) {
383413
}
384414
}
385415
}
416+
// Clean common local model artifacts before parsing:
417+
// - Replace literal "..." or "…" placeholders in arrays/values
418+
// - Replace <indices> placeholder tags
419+
jsonStr = jsonStr
420+
.replace(/,\s*\.{3,}\s*(?=[\]},])/g, '') // trailing ..., before ] } or ,
421+
.replace(/\.{3,}/g, '"..."') // standalone ... → string
422+
.replace(//g, '"..."') // ellipsis char
423+
.replace(/<[a-z_]+>/gi, '"placeholder"') // <indices> etc.
424+
.replace(/,\s*([}\]])/g, '$1'); // trailing commas
386425
return JSON.parse(jsonStr.trim());
387426
}
388427

@@ -2090,7 +2129,10 @@ async function main() {
20902129
});
20912130

20922131
log('');
2093-
process.exit(failed > 0 ? 1 : 0);
2132+
// When running as Aegis skill, always exit 0 — test results are reported
2133+
// via JSON events (pass/fail is a result, not an error). Exit 1 only for
2134+
// standalone CLI usage where CI/CD pipelines expect non-zero on failures.
2135+
process.exit(IS_SKILL_MODE ? 0 : (failed > 0 ? 1 : 0));
20942136
}
20952137

20962138
// Run when executed directly — supports both plain Node and Electron spawn.

0 commit comments

Comments
 (0)