openvibers/openvibe.codes Commits

Keep the coding agent's request prefix stable for provider caching (#351)

OP OpenVibers committed 3 days ago · 52381fb2bf · 2 files, +76 −0

The system message never carried the task, and the tools are now offered
in a fixed name order, so the model, system message and tool schemas a
provider caches (its prompt prefix) are byte-identical between two tasks
and only the user message differs.

Adds test/harness-prompt-prefix.test.js: two runs with different prompts
send the same serialized prefix, and the tool schemas are in name order.

Changes

ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex b1df86c..5b55661 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -202,7 +202,10 @@ function apiAgent(spec) {
const key = spec.keyEnv ? env[spec.keyEnv] : (t.apiKey || '');
const model = t.model || spec.model;
const level = LEVELS[t.permission] || 1;
++ // Tools in a fixed name order, and the system message above never carries the task: the request prefix a
++ // provider caches (system + tools) is byte-identical from one task to the next, and only the prompt differs.
const tools = Object.entries(TOOL_DEFS).filter(([, d]) => LEVELS[d.level] <= level)
++ .sort(([a], [b]) => a.localeCompare(b))
.map(([name, d]) => ({ type: 'function', function: { name, description: d.description, parameters: d.parameters } }));
const sessionId = t.resume || `api_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const startedAt = Date.now();
ddiff --git a/test/harness-prompt-prefix.test.js b/test/harness-prompt-prefix.test.js
nnew file mode 100644
iindex 0000000..bb0ba2d
---- /dev/null
++++ b/test/harness-prompt-prefix.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * The prompt prefix is byte-identical between two different tasks (stage S3, cache-stable prompts): the system message
++ * and the tool schemas never carry the task, tools come in a fixed name order, and only the user message differs.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { createHarness } = require('../harness');
++const { createStore } = require('../harness/sessions');
++const { SYSTEM } = require('../harness/adapters/api');
++const { check, done } = require('./helpers/boot');
++
++/** A chat-completions server that records each request and always answers with a plain reply. */
++async function mockApi() {
++ const requests = [];
++ const server = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (d) => { body += d; });
++ req.on('end', () => {
++ requests.push(JSON.parse(body || '{}'));
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'done' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } }));
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, requests, close: () => new Promise((r) => server.close(r)) };
++}
++
++const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };
++/** The stable prefix of one request as it is serialized: the model, the system message and the tools, in that order. */
++const prefixOf = (body) => JSON.stringify({ model: body.model, messages: [body.messages[0]], tools: body.tools, tool_choice: body.tool_choice });
++
++(async () => {
++ const api = await mockApi();
++ try {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-prefix-'));
++ const work = path.join(dir, 'work');
++ fs.mkdirSync(work, { recursive: true });
++ // An empty environment: the harness reads its base url and model from here, never from the machine's own.
++ const env = { PATH: '/nonexistent', OPENVIBE_CODES_HOME: path.join(dir, 'home'), OPENVIBE_CODES_BASE_URL: api.url, OPENVIBE_CODES_MODEL: 'tiny', OPENVIBE_CODES_API_KEY: 'k-123' };
++ const h = createHarness({ env, store: createStore({ env }) });
++
++ await check('the system message never carries the task or a date', async () => {
++ assert.ok(SYSTEM.trim().length > 100);
++ assert.ok(!/\d{4}-\d{2}-\d{2}/.test(SYSTEM), 'no date in the stable system message');
++ });
++
++ await check('two tasks send the same model, system message and tools; only the prompt differs', async () => {
++ await collect(h.run({ prompt: 'rename name to count', agent: 'openai-compatible', cwd: work, permission: 'edit', handoff: false }));
++ await collect(h.run({ prompt: 'a much longer second task about the same repository', agent: 'openai-compatible', cwd: work, permission: 'edit', handoff: false }));
++ assert.strictEqual(api.requests.length, 2);
++ const [a, b] = api.requests;
++ assert.strictEqual(prefixOf(a), prefixOf(b), 'the serialized prefix is byte-identical');
++ assert.strictEqual(a.messages[0].role, 'system');
++ assert.strictEqual(a.messages[1].role, 'user');
++ assert.strictEqual(a.messages[1].content, 'rename name to count');
++ assert.strictEqual(b.messages[1].content, 'a much longer second task about the same repository');
++ });
++
++ await check('tool schemas serialize in a fixed name order', async () => {
++ const names = api.requests[0].tools.map((t) => t.function.name);
++ assert.deepStrictEqual(names, [...names].sort(), 'tools are offered in name order');
++ assert.deepStrictEqual(names, ['edit_file', 'grep', 'list_dir', 'read_file', 'write_file']);
++ assert.strictEqual(JSON.stringify(api.requests[0].tools), JSON.stringify(api.requests[1].tools));
++ });
++ } finally {
++ await api.close();
++ done();
++ }
++})();