openvibers/openvibe.actor Commits

Keep the agent's prompt prefix stable for provider caching (#390)

OP OpenVibers committed 4 days ago · cfb27b4a34 · 2 files, +61 −1

The system prompt put today's date in its first line, so two tasks never
shared a prefix and a provider's prompt cache had nothing to reuse. The
rules now come first and the volatile tail (plan, ledger, demoted-tools
note) plus the date come last; the wording is unchanged.

Adds test/runtime-prompt-prefix.test.js: two assemblies with different
dates and plans share the whole rules prefix byte for byte, and the tool
definitions serialize identically on every assembly.

Changes

ddiff --git a/server/runtime/loop.js b/server/runtime/loop.js
iindex 31ae269..9521075 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -65,7 +65,10 @@ const MAX_PLAN_CHARS = 8000;
const today = (now) => new Date(now()).toISOString().slice(0, 10);
--const SYSTEM = ({ date, plan, maxCalls = 12, note = '', ledger = '' }) => `You are OpenVibe Actor's agent. You complete a person's task by acting through your tools, then answer them. Today is ${date}.
++// The rules come first and what changes between tasks (today's date, the plan, the ledger, a demoted-tools note)
++// comes last, so the prompt prefix a provider caches is byte-identical from one task to the next and a cache
++// breakpoint never has to cover a date. Nothing the model is told changes; only its place in the prompt does.
++const SYSTEM = ({ date, plan, maxCalls = 12, note = '', ledger = '' }) => `You are OpenVibe Actor's agent. You complete a person's task by acting through your tools, then answer them.
How the answer is grounded:
- Every number, price, score, standing, weather reading, count, date, name or current status comes from a tool result in this run. Cite it inline as [E1], [E2], … right after the fact it supports.
@@@ -91,6 +94,8 @@ Your plan:
${plan}
${ledger ? `\n${ledger}` : ''}${note ? `\n${note}` : ''}
++Today is ${date}.
++
Begin your answer with the answer itself, not with a statement about it.`;
/** Every artifact the run's tools produced, once each, in the order they were made. */
ddiff --git a/test/runtime-prompt-prefix.test.js b/test/runtime-prompt-prefix.test.js
nnew file mode 100644
iindex 0000000..2694c55
---- /dev/null
++++ b/test/runtime-prompt-prefix.test.js
@@@ -0,0 +1,55 @@
++'use strict';
++/**
++ * The prompt prefix stays byte-identical between tasks (stage S3, cache-stable prompts).
++ *
++ * - the system prompt keeps its rules first and today's date, the plan, the ledger and a demoted-tools note last,
++ * so two tasks share the whole stable prefix and a provider's cache breakpoint never covers a date;
++ * - the tool definitions serialize the same way on every assembly, so a cache is not lost to tool order.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const assert = require('assert');
++const { SYSTEM } = require('../server/runtime/loop');
++const { createRegistry } = require('../server/runtime/tools');
++
++const results = [];
++const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { results.push(e); console.log(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}`); } };
++
++function tempTools(files) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'actor-prefix-'));
++ for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body);
++ return dir;
++}
++const tool = (name) => `module.exports = { name: ${JSON.stringify(name)}, description: ${JSON.stringify(`The ${name} tool.`)}, input: { type: 'object', properties: { q: { type: 'string' } } }, scope: 'public', run: async () => ({ text: 'ok' }) };\n`;
++
++(async () => {
++ await ok('two tasks share the whole rules prefix; the date and the plan come after it', async () => {
++ const a = SYSTEM({ date: '2026-11-09', plan: '1. Look up the price' });
++ const b = SYSTEM({ date: '2027-01-01', plan: '1. Compare two offers', ledger: 'Progress: 1 of 2', note: 'Offered last: slow_tool.' });
++ const at = a.indexOf('Today is ');
++ const stableAt = a.indexOf('Your plan:');
++ assert.ok(at > 0, 'the date is in the prompt');
++ assert.ok(a.indexOf('How the answer is grounded:') < stableAt, 'the rules come before the plan');
++ assert.ok(stableAt > 1500, `the stable prefix is the whole rules block (${stableAt} bytes)`);
++ assert.strictEqual(a.slice(0, stableAt), b.slice(0, stableAt), 'the rules prefix is byte-identical between two tasks');
++ assert.ok(at > stableAt && b.indexOf('Today is ') > stableAt, 'the date comes after the rules');
++ assert.strictEqual(a.split('Today is ').length, 2, 'the date appears once');
++ assert.ok(b.indexOf('1. Compare two offers') < b.indexOf('Today is '), 'the plan stays before the date');
++ assert.ok(b.indexOf('Progress: 1 of 2') < b.indexOf('Today is '), 'the ledger stays before the date');
++ assert.ok(b.indexOf('Offered last: slow_tool.') < b.indexOf('Today is '), 'the demoted-tools note stays before the date');
++ });
++
++ await ok('tool definitions serialize identically on every assembly', async () => {
++ const dir = tempTools({ 'zebra.js': tool('zebra'), 'alpha.js': tool('alpha'), 'middle.js': tool('middle') });
++ const first = createRegistry({ dir, strict: true, log: { warn() {} } }).definitions({ requester: 'user:usr_1' });
++ const second = createRegistry({ dir, strict: true, log: { warn() {} } }).definitions({ requester: 'user:usr_1' });
++ assert.strictEqual(JSON.stringify(first), JSON.stringify(second));
++ assert.deepStrictEqual(first.map((d) => d.function.name), ['alpha', 'middle', 'zebra'], 'files load in a fixed order');
++ });
++
++ if (results.length) {
++ for (const e of results) console.error(e.stack || String(e));
++ process.exitCode = 1;
++ }
++})();