mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-15 04:32:23 +02:00
243 lines
15 KiB
JavaScript
243 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/** Real 7011 native workspace read/write/execute follow-ups in isolated temp roots. */
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
|
|
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
|
const base = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
|
const endpointId = process.env.ENDPOINT_ID || '1d1022ef';
|
|
const endpointUrl = process.env.ENDPOINT_URL || 'http://100.67.207.85:19184/v1/chat/completions';
|
|
const model = process.env.MODEL || 'odysseus-qwen3.5-tools-pre-heretic';
|
|
const owner = 'sft_alex_creator';
|
|
const reportPath = path.resolve(process.env.REPORT_PATH || path.join(root, `reports/native-workspace-followups-${new Date().toISOString().replace(/[:.]/g, '-')}.json`));
|
|
if (!reportPath.startsWith(path.join(root, 'reports') + path.sep) || fs.existsSync(reportPath)) throw Error('Report path must be new and under reports/');
|
|
const selected = new Set((process.env.CASES || '').split(',').map(value => value.trim()).filter(Boolean));
|
|
let cases = [
|
|
{
|
|
name: 'grep-missing-recover', tools: ['grep', 'grep', 'grep'],
|
|
fixture: ['search fixture.txt', 'Alpha\nalpha\nviolet-72\n'],
|
|
prompts: [
|
|
'Use grep to search /workspace/missing.txt for alpha. Report whether the search succeeded. Read only.',
|
|
'Sorry, I meant /workspace/search fixture.txt. Search for the same pattern, case-sensitive. Read only.',
|
|
'Now search that same file for the same pattern, but ignore case. Show both matching lines. Read only.',
|
|
],
|
|
expectedFailures: [true, false, false],
|
|
outputEvidence: [[], ['search fixture.txt:2:alpha'], ['search fixture.txt:1:Alpha', 'search fixture.txt:2:alpha']],
|
|
answerCheck: (index, text) => index !== 0 || (/not found|does not exist|doesn't exist|missing|failed/i.test(text) && !/no matches/i.test(text)),
|
|
validate: (index, args, workspace) => args.pattern === 'alpha'
|
|
&& (index === 0 ? ['/workspace/missing.txt', path.join(workspace, 'missing.txt')].includes(args.path)
|
|
: ['/workspace/search fixture.txt', path.join(workspace, 'search fixture.txt')].includes(args.path))
|
|
&& (index === 2 ? args.ignore_case === true : !args.ignore_case),
|
|
verifyTurn: workspace => fs.readFileSync(path.join(workspace, 'search fixture.txt'), 'utf8') === 'Alpha\nalpha\nviolet-72\n',
|
|
},
|
|
{
|
|
name: 'file-edit-undo', tools: ['edit_file', 'edit_file', 'read_file'],
|
|
fixture: ['edit fixture.txt', 'First: alpha\r\nSecond: alpha\r\nKeep: violet-72\r\n'],
|
|
prompts: [
|
|
'Use edit_file on /workspace/edit fixture.txt to replace only Second: alpha with Second: beta. Preserve everything else, including line endings.',
|
|
'Undo only that last change using edit_file on the same file. Preserve everything else.',
|
|
'Read that same file using read_file and report its contents. Read only.',
|
|
],
|
|
validate: (index, args, workspace) => ['/workspace/edit fixture.txt', path.join(workspace, 'edit fixture.txt')].includes(args.path) && (index === 2
|
|
|| (args.old_string === (index === 0 ? 'Second: alpha' : 'Second: beta')
|
|
&& args.new_string === (index === 0 ? 'Second: beta' : 'Second: alpha') && args.replace_all !== true)),
|
|
verifyTurn: (workspace, index) => fs.readFileSync(path.join(workspace, 'edit fixture.txt'), 'utf8')
|
|
=== `First: alpha\r\nSecond: ${index === 0 ? 'beta' : 'alpha'}\r\nKeep: violet-72\r\n`,
|
|
verify: workspace => fs.readFileSync(path.join(workspace, 'edit fixture.txt'), 'utf8')
|
|
=== 'First: alpha\r\nSecond: alpha\r\nKeep: violet-72\r\n',
|
|
},
|
|
{
|
|
name: 'glob-grep-read', tools: ['glob', 'grep', 'read_file'],
|
|
fixture: ['search fixture.txt', 'alpha\nbeta\nviolet-72\n'],
|
|
prompts: [
|
|
'Use glob to find the *.txt files in /workspace. Read only.',
|
|
'Use grep to find violet-72 in that file. Show the matching line. Read only.',
|
|
'Use read_file on that same file to show only its second line. Read only.',
|
|
],
|
|
expectedAnswers: ['search fixture.txt', 'violet-72', 'beta'],
|
|
validate: (index, args, workspace) => index === 0
|
|
? typeof args.pattern === 'string' && args.pattern.includes('*.txt')
|
|
: index === 1 ? args.pattern === 'violet-72'
|
|
: ['/workspace/search fixture.txt', path.join(workspace, 'search fixture.txt')].includes(args.path)
|
|
&& args.offset === 2 && args.limit === 1,
|
|
verifyTurn: workspace => fs.readFileSync(path.join(workspace, 'search fixture.txt'), 'utf8') === 'alpha\nbeta\nviolet-72\n',
|
|
},
|
|
{
|
|
name: 'two-output-followup', tools: ['python', 'read_file'],
|
|
expectedAnswers: [null, 'SECOND_OK'],
|
|
prompts: [
|
|
'Use python to create /workspace/first.v2.txt containing exactly FIRST_OK and /workspace/second.v2.txt containing exactly SECOND_OK. Do not add newlines.',
|
|
'Use read_file to read the second file you created and report its exact contents. Read only.',
|
|
],
|
|
validate: (index, args) => index === 0
|
|
? typeof args.code === 'string' && args.code.includes('first.v2.txt') && args.code.includes('second.v2.txt')
|
|
: args.path === '/workspace/second.v2.txt',
|
|
verify: workspace => fs.readFileSync(path.join(workspace, 'first.v2.txt'), 'utf8') === 'FIRST_OK'
|
|
&& fs.readFileSync(path.join(workspace, 'second.v2.txt'), 'utf8') === 'SECOND_OK',
|
|
},
|
|
{
|
|
name: 'workspace-to-list', tools: ['get_workspace', 'ls'],
|
|
prompts: [
|
|
'Use get_workspace to inspect the current confined workspace. Read only.',
|
|
'Now use ls to list that same workspace directory. Read only.',
|
|
],
|
|
validate: (index, args) => index === 0
|
|
? Object.keys(args).length === 0
|
|
: !args.path || ['/workspace', '.'].includes(args.path),
|
|
},
|
|
{
|
|
name: 'file-read-refine', tools: ['read_file', 'read_file'],
|
|
fixture: ['sample.txt', 'alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\n'],
|
|
prompts: [
|
|
'Use read_file to read the first three lines of /workspace/sample.txt. Read only.',
|
|
'Read that same file again starting at line 4, returning at most two lines. Read only.',
|
|
],
|
|
validate: (index, args) => args.path === '/workspace/sample.txt'
|
|
&& (index === 0 ? (args.offset == null || args.offset === 1) && args.limit === 3 : args.offset === 4 && args.limit === 2),
|
|
},
|
|
{
|
|
name: 'write-then-read', tools: ['write_file', 'read_file'],
|
|
prompts: [
|
|
'Use write_file to create /workspace/followup.txt containing exactly NATIVE_WRITE_OK followed by a newline.',
|
|
'Use read_file to read that same file and report its exact contents. Read only.',
|
|
],
|
|
validate: (index, args) => index === 0
|
|
? args.path === '/workspace/followup.txt' && args.content === 'NATIVE_WRITE_OK\n'
|
|
: args.path === '/workspace/followup.txt',
|
|
verify: workspace => fs.readFileSync(path.join(workspace, 'followup.txt'), 'utf8') === 'NATIVE_WRITE_OK\n',
|
|
},
|
|
{
|
|
name: 'python-then-read', tools: ['python', 'read_file'],
|
|
prompts: [
|
|
"Use python to write the exact text NATIVE_PYTHON_OK followed by a newline to /workspace/python-result.txt.",
|
|
'Use read_file to read that generated file and report its exact contents. Read only.',
|
|
],
|
|
validate: (index, args) => index === 0
|
|
? typeof args.code === 'string' && args.code.includes('python-result.txt') && args.code.includes('NATIVE_PYTHON_OK')
|
|
: args.path === '/workspace/python-result.txt',
|
|
verify: workspace => fs.readFileSync(path.join(workspace, 'python-result.txt'), 'utf8') === 'NATIVE_PYTHON_OK\n',
|
|
},
|
|
].filter(spec => !selected.size || selected.has(spec.name));
|
|
if (!cases.length) throw Error('No matching cases selected');
|
|
|
|
const auth = JSON.parse(fs.readFileSync('/home/pewds/odysseus-cookbook-fresh/data/sessions.json', 'utf8'));
|
|
const token = Object.entries(auth).find(([, value]) => value?.username === owner)?.[0];
|
|
if (!token) throw Error(`No active ${owner} session`);
|
|
const report = {
|
|
model, status: 'running', cases: [],
|
|
privacy: 'Only synthetic fixture names, tool names, argument keys, effect booleans, and contract checks retained; no model answers or file contents.',
|
|
};
|
|
const save = () => { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); };
|
|
const parseSSE = body => body.replace(/\r\n/g, '\n').split('\n\n').flatMap(frame => {
|
|
const raw = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
if (!raw || raw === '[DONE]') return [];
|
|
try { return [JSON.parse(raw)]; } catch { return [{ type: 'invalid_sse' }]; }
|
|
});
|
|
const parseArgs = event => { try { return JSON.parse(event?.command || '{}'); } catch { return {}; } };
|
|
const safeArgs = args => ({
|
|
...(typeof args.path === 'string' ? { path: args.path } : {}),
|
|
...(Number.isInteger(args.offset) ? { offset: args.offset } : {}),
|
|
...(Number.isInteger(args.limit) ? { limit: args.limit } : {}),
|
|
...(typeof args.content === 'string' ? {
|
|
content_length: args.content.length,
|
|
content_has_final_newline: args.content.endsWith('\n'),
|
|
} : {}),
|
|
...(typeof args.code === 'string' ? {
|
|
code_length: args.code.length,
|
|
code_mentions_target: args.code.includes('python-result.txt'),
|
|
code_mentions_marker: args.code.includes('NATIVE_PYTHON_OK'),
|
|
code_uses_chr_10: /chr\s*\(\s*10\s*\)/.test(args.code),
|
|
} : {}),
|
|
});
|
|
|
|
let browser, context;
|
|
try {
|
|
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server'] });
|
|
context = await browser.newContext({ serviceWorkers: 'block', extraHTTPHeaders: { 'Accept-Encoding': 'identity' } });
|
|
await context.addCookies([{ name: 'odysseus_session', value: token, url: base }]);
|
|
for (const spec of cases) {
|
|
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), `odysseus-${spec.name}-`));
|
|
if (spec.fixture) fs.writeFileSync(path.join(workspace, spec.fixture[0]), spec.fixture[1]);
|
|
const result = { name: spec.name, expected_tools: spec.tools, turns: [], cleanup: { session: false, workspace: false }, status: 'running' };
|
|
report.cases.push(result); save();
|
|
let session = '';
|
|
try {
|
|
const created = await context.request.post(`${base}/api/session`, { multipart: {
|
|
name: `[native-workspace-followup] ${spec.name}`, model, endpoint_id: endpointId,
|
|
endpoint_url: endpointUrl, skip_validation: 'true', rag: 'false', cwd: workspace,
|
|
}});
|
|
if (!created.ok()) throw Error(`Session create HTTP ${created.status()}`);
|
|
session = (await created.json()).id;
|
|
const runtime = JSON.stringify({
|
|
surface: 'odysseus-native', terminal_agent: true, unattended_mode: true,
|
|
input_files: spec.fixture ? [`/workspace/${spec.fixture[0]}`] : [],
|
|
});
|
|
for (let index = 0; index < spec.prompts.length; index++) {
|
|
const response = await context.request.post(`${base}/api/chat_stream`, { multipart: {
|
|
message: spec.prompts[index], session, mode: 'agent', agent_prompt_mode: 'auto',
|
|
selected_endpoint_id: endpointId, selected_endpoint_url: endpointUrl,
|
|
selected_model: model, cwd: workspace, workspace,
|
|
client_runtime_context: runtime,
|
|
}, timeout: 180000 });
|
|
const events = parseSSE(await response.text());
|
|
const contract = events.find(event => event.type === 'turn_contract') || {};
|
|
const starts = events.filter(event => event.type === 'tool_start');
|
|
const expected = spec.tools[index];
|
|
const matchingStarts = starts.filter(event => event.tool === expected);
|
|
const outputs = events.filter(event => event.type === 'tool_output' && event.tool === expected);
|
|
const successfulOutputs = outputs.filter(event => !event.error && (event.exit_code == null || event.exit_code === 0));
|
|
const args = parseArgs(matchingStarts[0]);
|
|
const final = events.filter(event => event.type === 'final_response')
|
|
.map(event => event.content || '').join('')
|
|
|| events.filter(event => typeof event.delta === 'string').map(event => event.delta).join('');
|
|
const checks = {
|
|
http_ok: response.ok(), clean_route: contract.selection_mode === 'clean_compact_v3_preview',
|
|
native_workspace: contract.native_workspace === true,
|
|
shell_family: (contract.active_capabilities || []).includes('shell_files')
|
|
|| (contract.native_workspace === true && (contract.offered || []).includes(expected)),
|
|
expected_offered: (contract.offered || []).includes(expected),
|
|
exactly_one_execution: starts.length === 1 && matchingStarts.length === 1,
|
|
argument_contract: matchingStarts.length === 1 && spec.validate(index, args, workspace),
|
|
expected_execution_outcome: spec.expectedFailures?.[index]
|
|
? outputs.length === 1 && (outputs[0].error || outputs[0].exit_code === 1)
|
|
: successfulOutputs.length === 1,
|
|
no_stream_error: !events.some(event => ['error', 'invalid_sse'].includes(event.type)),
|
|
expected_answer: !spec.expectedAnswers?.[index] || final.includes(spec.expectedAnswers[index]),
|
|
state_after_turn: !spec.verifyTurn || spec.verifyTurn(workspace, index),
|
|
output_evidence: !spec.outputEvidence || spec.outputEvidence[index].every(value => outputs.some(event => String(event.output || '').includes(value))),
|
|
answer_semantics: !spec.answerCheck || spec.answerCheck(index, final),
|
|
};
|
|
result.turns.push({
|
|
index, expected_tool: expected, offered: (contract.offered || []).slice().sort(),
|
|
tools: starts.map(event => event.tool), argument_keys: Object.keys(args).sort(),
|
|
calls: starts.map(event => ({ tool: event.tool, round: event.round, args: safeArgs(parseArgs(event)) })),
|
|
outputs: outputs.map(event => ({ round: event.round, error: event.error === true, exit_code: event.exit_code ?? null })),
|
|
checks, status: Object.values(checks).every(Boolean) ? 'passed' : 'failed',
|
|
});
|
|
save();
|
|
}
|
|
result.effect_verified = spec.verify ? spec.verify(workspace) : true;
|
|
result.status = result.effect_verified && result.turns.every(turn => turn.status === 'passed') ? 'passed' : 'failed';
|
|
} catch (error) {
|
|
result.error = String(error).split('\n')[0].slice(0, 400); result.status = 'failed';
|
|
} finally {
|
|
if (session) result.cleanup.session = (await context.request.delete(`${base}/api/session/${encodeURIComponent(session)}`)).ok();
|
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
result.cleanup.workspace = !fs.existsSync(workspace);
|
|
if (!result.cleanup.session || !result.cleanup.workspace) result.status = 'failed';
|
|
save();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
report.error = String(error).split('\n')[0].slice(0, 400);
|
|
} finally {
|
|
if (browser) await browser.close();
|
|
}
|
|
report.status = report.cases.length === cases.length && report.cases.every(item => item.status === 'passed') ? 'passed' : 'failed';
|
|
report.summary = { passed: report.cases.filter(item => item.status === 'passed').length, total: cases.length, turns: report.cases.reduce((sum, item) => sum + item.turns.length, 0) };
|
|
save();
|
|
console.log(JSON.stringify({ report: path.relative(root, reportPath), status: report.status, summary: report.summary, failures: report.cases.filter(item => item.status !== 'passed') }));
|
|
if (report.status !== 'passed') process.exitCode = 1;
|