ravensight-playtest 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -6
- package/docs/ADAPTERS.md +77 -0
- package/package.json +2 -1
- package/src/cli.js +12 -6
- package/src/commands/check.js +81 -0
- package/src/commands/fix.js +8 -4
- package/src/commands/init.js +29 -4
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +81 -26
- package/src/run/actionSelector.js +47 -0
- package/src/run/aggregate.js +10 -5
- package/src/run/args.js +19 -3
- package/src/run/context.js +12 -1
- package/src/run/drivers/driver.js +6 -3
- package/src/run/drivers/index.js +6 -6
- package/src/run/drivers/stdio.js +111 -0
- package/src/run/drivers/web.js +153 -7
- package/src/run/drivers/webdriver.js +67 -0
- package/src/run/fixLoop.js +52 -3
- package/src/run/personaLoop.js +204 -16
- package/src/run/resume.js +1 -0
- package/src/run/tools.js +19 -3
- package/src/states.js +4 -1
- package/src/ui/index.js +28 -0
package/src/run/drivers/web.js
CHANGED
|
@@ -2,7 +2,6 @@ import { mkdir, rename, stat, readdir } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
DriverError,
|
|
5
|
-
OBSERVATION_MAX_CHARS,
|
|
6
5
|
THIN_OBSERVATION_CHARS,
|
|
7
6
|
observationHash,
|
|
8
7
|
screenshotName,
|
|
@@ -36,6 +35,87 @@ export const MAX_WAIT_MS = 10000;
|
|
|
36
35
|
/** How long a single Playwright call may take before it is an error, not a hang. */
|
|
37
36
|
export const ACTION_TIMEOUT_MS = 15000;
|
|
38
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The web observation ceiling. An aria snapshot is re-sent on every turn the
|
|
40
|
+
* observation stays in context, and the run's model budget is fixed, so a busy
|
|
41
|
+
* page at the shared 12,000 character ceiling spends the budget on markup the
|
|
42
|
+
* persona never needed. Compaction (below) usually lands well under this.
|
|
43
|
+
*/
|
|
44
|
+
export const WEB_OBSERVATION_MAX_CHARS = 4000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Full observations kept in context for a web run. The newest screen is what
|
|
48
|
+
* the persona acts on; one more is enough to compare against, and every older
|
|
49
|
+
* one is a stub.
|
|
50
|
+
*/
|
|
51
|
+
export const WEB_KEEP_OBSERVATIONS = 2;
|
|
52
|
+
|
|
53
|
+
/** Roles a player can act on. Kept even with no name, since they are still clickable. */
|
|
54
|
+
const INTERACTIVE_ROLES = new Set([
|
|
55
|
+
'button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'combobox', 'listbox', 'option',
|
|
56
|
+
'slider', 'spinbutton', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'treeitem',
|
|
57
|
+
'canvas'
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Keep the nodes a player could act on or read, and drop the structure in
|
|
62
|
+
* between. An aria snapshot prints every wrapper (`- generic:`, `- list:`, a
|
|
63
|
+
* nameless `- img`) and the attribute lines under links (`- /url: ...`), none
|
|
64
|
+
* of which tell a persona anything, and all of which cost tokens every turn.
|
|
65
|
+
*
|
|
66
|
+
* Indentation is dropped too: the persona targets nodes by role and name, not
|
|
67
|
+
* by depth. Text that is not an aria snapshot (the innerText fallback for a
|
|
68
|
+
* thin page) is returned unchanged.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} text
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function compactAriaSnapshot(text) {
|
|
74
|
+
const value = String(text == null ? '' : text);
|
|
75
|
+
const lines = value.split('\n');
|
|
76
|
+
if (!lines.some(line => /^\s*- /.test(line))) return value;
|
|
77
|
+
const kept = [];
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed.startsWith('- ')) continue;
|
|
81
|
+
const body = unquoteKey(trimmed.slice(2));
|
|
82
|
+
if (body.startsWith('/')) continue;
|
|
83
|
+
const role = (body.match(/^([a-z]+)/) || [])[1] || '';
|
|
84
|
+
const named = /^[a-z]+ "[^"]*"/.test(body);
|
|
85
|
+
const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
|
|
86
|
+
if (INTERACTIVE_ROLES.has(role) || named || inlineText) kept.push(`- ${body}`);
|
|
87
|
+
}
|
|
88
|
+
return kept.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Playwright writes a node key as a single quoted YAML scalar when the name
|
|
93
|
+
* contains `: `, ` #`, a brace or a backtick: `- 'button "Player #1"'`. Strip
|
|
94
|
+
* that outer pair (a doubled `''` inside is one quote) so the node is
|
|
95
|
+
* classified and shown like any other, keeping whatever followed the closing
|
|
96
|
+
* quote (a `:` for a node with children, or `: text`).
|
|
97
|
+
*
|
|
98
|
+
* @param {string} body the line after its `- `
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function unquoteKey(body) {
|
|
102
|
+
if (!body.startsWith("'")) return body;
|
|
103
|
+
let out = '';
|
|
104
|
+
for (let i = 1; i < body.length; i += 1) {
|
|
105
|
+
if (body[i] === "'") {
|
|
106
|
+
if (body[i + 1] === "'") {
|
|
107
|
+
out += "'";
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
return out + body.slice(i + 1);
|
|
112
|
+
}
|
|
113
|
+
out += body[i];
|
|
114
|
+
}
|
|
115
|
+
// No closing quote: not a quoted key after all.
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
118
|
+
|
|
39
119
|
const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
|
|
40
120
|
|
|
41
121
|
/**
|
|
@@ -127,12 +207,33 @@ export function resolveLocator(page, target) {
|
|
|
127
207
|
return page.getByText(value, { exact: false }).first();
|
|
128
208
|
}
|
|
129
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Hand the run's playtest identity to the page. Runs in the browser, before
|
|
212
|
+
* any of the game's own scripts, so the Ravensight JS SDK finds it at
|
|
213
|
+
* construction. Serialised by Playwright, so it must not close over anything.
|
|
214
|
+
*
|
|
215
|
+
* @param {{token: string, runId: string, jobId: string, persona: string}} identity
|
|
216
|
+
*/
|
|
217
|
+
function injectPlaytestIdentity(identity) {
|
|
218
|
+
window.__RAVENSIGHT_PLAYTEST__ = identity;
|
|
219
|
+
try {
|
|
220
|
+
localStorage.setItem('ravensight.playtest', JSON.stringify(identity));
|
|
221
|
+
} catch {
|
|
222
|
+
// Storage can be refused (a sandboxed frame, a privacy mode). The window
|
|
223
|
+
// global is the first thing the SDK reads, so this is only the fallback.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
130
227
|
/**
|
|
131
228
|
* @param {Object} opts
|
|
132
229
|
* @param {(specifier: string) => Promise<any>} [opts.importer] test seam
|
|
230
|
+
* @param {boolean} [opts.headless]
|
|
231
|
+
* @param {{token?: string, runId?: string, jobId?: string, persona?: string}} [opts.playtest]
|
|
232
|
+
* the run's playtest identity, handed to the page so the game's own SDK
|
|
233
|
+
* session is marked synthetic
|
|
133
234
|
* @returns {Object} a driver
|
|
134
235
|
*/
|
|
135
|
-
export function createWebDriver({ importer, headless = true } = {}) {
|
|
236
|
+
export function createWebDriver({ importer, headless = true, playtest = null } = {}) {
|
|
136
237
|
let browser = null;
|
|
137
238
|
let context = null;
|
|
138
239
|
let page = null;
|
|
@@ -142,6 +243,7 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
142
243
|
let step = 0;
|
|
143
244
|
let shots = 0;
|
|
144
245
|
let lastHash = null;
|
|
246
|
+
let lastObservedHash = null;
|
|
145
247
|
const consoleBuffer = [];
|
|
146
248
|
|
|
147
249
|
/** @returns {any} */
|
|
@@ -150,9 +252,15 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
150
252
|
return page;
|
|
151
253
|
}
|
|
152
254
|
|
|
255
|
+
/**
|
|
256
|
+
* @returns {Promise<{text: string, aria: boolean}>} the screen text, and
|
|
257
|
+
* whether it is the aria snapshot (compacted) or the innerText fallback
|
|
258
|
+
* (never compacted: a bullet list in the page is not snapshot structure)
|
|
259
|
+
*/
|
|
153
260
|
async function snapshotText() {
|
|
154
261
|
const current = requirePage();
|
|
155
262
|
let text = '';
|
|
263
|
+
let aria = true;
|
|
156
264
|
const body = current.locator('body');
|
|
157
265
|
if (typeof body.ariaSnapshot === 'function') {
|
|
158
266
|
try {
|
|
@@ -167,17 +275,31 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
167
275
|
const el = globalThis.document && globalThis.document.body;
|
|
168
276
|
return el ? el.innerText : '';
|
|
169
277
|
});
|
|
170
|
-
if (String(inner).trim().length > text.trim().length)
|
|
278
|
+
if (String(inner).trim().length > text.trim().length) {
|
|
279
|
+
text = String(inner);
|
|
280
|
+
aria = false;
|
|
281
|
+
}
|
|
171
282
|
} catch {
|
|
172
283
|
// A page mid-navigation has no body to read. The thin observation
|
|
173
284
|
// is the honest answer, and the screenshot fallback below covers it.
|
|
174
285
|
}
|
|
175
286
|
}
|
|
176
|
-
return text;
|
|
287
|
+
return { text, aria };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* What the model is shown for a raw snapshot. `observe` and `act` both use
|
|
292
|
+
* this, so the two hashes compare like with like.
|
|
293
|
+
* @param {{text: string, aria: boolean}} snap
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
function shownText(snap) {
|
|
297
|
+
return truncateObservation(snap.aria ? compactAriaSnapshot(snap.text) : snap.text, WEB_OBSERVATION_MAX_CHARS);
|
|
177
298
|
}
|
|
178
299
|
|
|
179
300
|
return {
|
|
180
301
|
name: 'playwright_web',
|
|
302
|
+
keepObservations: WEB_KEEP_OBSERVATIONS,
|
|
181
303
|
|
|
182
304
|
/**
|
|
183
305
|
* @param {Object} opts
|
|
@@ -200,6 +322,18 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
200
322
|
viewport,
|
|
201
323
|
...(videoRequested ? { recordVideo: { dir: runDir, size: viewport } } : {})
|
|
202
324
|
});
|
|
325
|
+
// The playtest identity goes in as an init script, not as
|
|
326
|
+
// `extraHTTPHeaders`: a context header rides on every request the page
|
|
327
|
+
// makes, including to the game's own API, and the token is only for
|
|
328
|
+
// Ravensight. The SDK reads it from the page and sends it itself.
|
|
329
|
+
if (playtest && playtest.token) {
|
|
330
|
+
await context.addInitScript(injectPlaytestIdentity, {
|
|
331
|
+
token: playtest.token,
|
|
332
|
+
runId: playtest.runId || null,
|
|
333
|
+
jobId: playtest.jobId || null,
|
|
334
|
+
persona: playtest.persona || null
|
|
335
|
+
});
|
|
336
|
+
}
|
|
203
337
|
page = await context.newPage();
|
|
204
338
|
page.on('console', message => {
|
|
205
339
|
if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
|
|
@@ -219,10 +353,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
219
353
|
const current = requirePage();
|
|
220
354
|
const started = Date.now();
|
|
221
355
|
step += 1;
|
|
222
|
-
const
|
|
356
|
+
const snap = await snapshotText();
|
|
357
|
+
const text = snap.text;
|
|
223
358
|
const observation = {
|
|
224
359
|
step,
|
|
225
|
-
text:
|
|
360
|
+
text: shownText(snap),
|
|
226
361
|
url: current.url(),
|
|
227
362
|
elapsedMs: Date.now() - started
|
|
228
363
|
};
|
|
@@ -244,6 +379,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
244
379
|
// point would otherwise read as changed to `act` and identical to
|
|
245
380
|
// anything reading the observation.
|
|
246
381
|
lastHash = observationHash(observation);
|
|
382
|
+
// The same screen twice is sent as a marker rather than in full
|
|
383
|
+
// (./tools.js), which is most of what a stuck persona would otherwise
|
|
384
|
+
// pay for.
|
|
385
|
+
if (lastHash === lastObservedHash) observation.unchanged = true;
|
|
386
|
+
lastObservedHash = lastHash;
|
|
247
387
|
return observation;
|
|
248
388
|
},
|
|
249
389
|
|
|
@@ -293,7 +433,13 @@ export function createWebDriver({ importer, headless = true } = {}) {
|
|
|
293
433
|
try {
|
|
294
434
|
// The same truncation observe() applies, so the two hashes are
|
|
295
435
|
// comparable. Without it a long screen is always "changed".
|
|
296
|
-
|
|
436
|
+
const snap = await snapshotText();
|
|
437
|
+
const after = { text: shownText(snap) };
|
|
438
|
+
if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
|
|
439
|
+
const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
440
|
+
after.screenshotBase64 = frame.toString('base64');
|
|
441
|
+
}
|
|
442
|
+
changed = observationHash(after) !== before;
|
|
297
443
|
} catch {
|
|
298
444
|
changed = true;
|
|
299
445
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Native desktop and mobile adapters use the W3C session/actions protocol.
|
|
2
|
+
// The developer supplies a local Appium/WebDriver server and a reviewed config;
|
|
3
|
+
// game observations cannot choose servers, capabilities, executables or scripts.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { DriverError, observationHash, screenshotName, truncateObservation } from './driver.js';
|
|
7
|
+
|
|
8
|
+
export const WEBDRIVER_NAMES = ['desktop_driver', 'unity_driver', 'unreal_driver', 'android_driver', 'ios_driver'];
|
|
9
|
+
const KEYS = { Enter:'\uE007', Escape:'\uE00C', Space:' ', Tab:'\uE004', Backspace:'\uE003', ArrowUp:'\uE013', ArrowDown:'\uE015', ArrowLeft:'\uE012', ArrowRight:'\uE014' };
|
|
10
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
export async function readDriverConfig(file) {
|
|
12
|
+
const stat=await fs.stat(file); if(stat.size>64000) throw new DriverError('Driver configuration exceeds 64 KB.',{fatal:true});
|
|
13
|
+
const config=JSON.parse(await fs.readFile(file,'utf8'));
|
|
14
|
+
const url=new URL(config.endpoint);
|
|
15
|
+
if(!['http:','https:'].includes(url.protocol)||!['localhost','127.0.0.1','[::1]'].includes(url.hostname)||url.username||url.password||url.search||url.hash) throw new DriverError('The device server must use a loopback URL without credentials.',{fatal:true});
|
|
16
|
+
if(!config.capabilities||Array.isArray(config.capabilities)||typeof config.capabilities!=='object'||typeof config.capabilities.platformName!=='string') throw new DriverError('Supply W3C capabilities with platformName.',{fatal:true});
|
|
17
|
+
return {...config,endpoint:url.href.replace(/\/$/,'')};
|
|
18
|
+
}
|
|
19
|
+
export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=globalThis.fetch,sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}) {
|
|
20
|
+
let config,session,runDirectory,step=0,shots=0,lastHash=null;
|
|
21
|
+
async function request(method,route,body) {
|
|
22
|
+
const response=await fetchImpl(config.endpoint+route,{method,redirect:'error',signal:AbortSignal.timeout(30000),headers:{'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{})});
|
|
23
|
+
if(response.status===204) return null;
|
|
24
|
+
if(!response.body) throw new DriverError('Device response was empty.');
|
|
25
|
+
const reader=response.body.getReader();const chunks=[];let size=0;
|
|
26
|
+
try {for(;;){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>MAX_BYTES){await reader.cancel();throw new DriverError('Device response exceeded the limit.');}chunks.push(Buffer.from(part.value));}}finally{reader.releaseLock();}
|
|
27
|
+
const data=JSON.parse(Buffer.concat(chunks).toString());
|
|
28
|
+
if(!response.ok||data.value?.error)throw new DriverError('Device command failed: '+String(data.value?.error||response.status));
|
|
29
|
+
return data.value;
|
|
30
|
+
}
|
|
31
|
+
const route=tail=>{if(!session)throw new DriverError('No device session is active.',{fatal:true});return '/session/'+encodeURIComponent(session)+tail;};
|
|
32
|
+
async function frame(){const data=await request('GET',route('/screenshot'));if(typeof data!=='string'||!data.startsWith('iVBORw0KGgo'))throw new DriverError('Device screenshot is not a PNG.');return data;}
|
|
33
|
+
async function observe(){const [source,image]=await Promise.all([request('GET',route('/source')),frame()]);const observation={step:++step,text:truncateObservation(typeof source==='string'?source:JSON.stringify(source)),screenshotBase64:image,screenshotMediaType:'image/png',elapsedMs:0};lastHash=observationHash(observation);return observation;}
|
|
34
|
+
async function actions(type,values,pointerType='mouse'){await request('POST',route('/actions'),{actions:[{type,id:'ravensight',...(type==='pointer'?{parameters:{pointerType}}:{}),actions:values}]});}
|
|
35
|
+
const driver={name,supportedActions:['click','type','key','wait','swipe'],
|
|
36
|
+
async launch({target,runDir}){
|
|
37
|
+
config=await readDriverConfig(target);runDirectory=runDir;
|
|
38
|
+
const platform=config.capabilities.platformName.toLowerCase();
|
|
39
|
+
if(name==='android_driver'&&platform!=='android'||name==='ios_driver'&&platform!=='ios')throw new DriverError('The device platform does not match the selected adapter.',{fatal:true});
|
|
40
|
+
const answer=await request('POST','/session',{capabilities:{alwaysMatch:config.capabilities,firstMatch:[{}]}});
|
|
41
|
+
if(!answer||typeof answer.sessionId!=='string')throw new DriverError('The device server did not create a session.',{fatal:true});
|
|
42
|
+
session=answer.sessionId;await fs.mkdir(path.join(runDir,'screenshots'),{recursive:true});
|
|
43
|
+
},observe,
|
|
44
|
+
async act(action){
|
|
45
|
+
const before=lastHash;const touch=['android_driver','ios_driver'].includes(name)?'touch':'mouse';
|
|
46
|
+
const point=value=>{if(!value||!Number.isFinite(value.x)||!Number.isFinite(value.y)||value.x<0||value.y<0||value.x>32768||value.y>32768)throw new DriverError('Supply bounded screenshot coordinates.');return {x:Math.round(value.x),y:Math.round(value.y)};};
|
|
47
|
+
try {
|
|
48
|
+
if(action.kind==='wait')await sleep(Math.min(5000,Math.max(0,Number(action.ms)||0)));
|
|
49
|
+
else if(action.kind==='click'){
|
|
50
|
+
const p=point(action.target);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...p},{type:'pointerDown',button:0},{type:'pointerUp',button:0}],touch);
|
|
51
|
+
}else if(action.kind==='swipe'){
|
|
52
|
+
const from=point(action.from),to=point(action.to);await actions('pointer',[{type:'pointerMove',duration:0,origin:'viewport',...from},{type:'pointerDown',button:0},{type:'pointerMove',duration:Math.min(2000,Math.max(100,Number(action.durationMs)||500)),origin:'viewport',...to},{type:'pointerUp',button:0}],touch);
|
|
53
|
+
}else if(action.kind==='key'||action.kind==='type'){
|
|
54
|
+
const chars=action.kind==='type'?Array.from(String(action.text||'').slice(0,2000)):[KEYS[action.key]||action.key];
|
|
55
|
+
if(chars.some(key=>typeof key!=='string'||Array.from(key).length!==1))throw new DriverError('Unsupported device key.');
|
|
56
|
+
if(action.submit)chars.push(KEYS.Enter);
|
|
57
|
+
await actions('key',chars.flatMap(value=>[{type:'keyDown',value},{type:'pause',duration:Math.min(2000,Math.max(0,Number(action.durationMs)||0))},{type:'keyUp',value}]));
|
|
58
|
+
}else throw new DriverError('This device adapter does not support that action.');
|
|
59
|
+
return {ok:true,changed:observationHash(await observe())!==before};
|
|
60
|
+
}catch(error){return {ok:false,changed:false,error:error instanceof DriverError?error.message:'Device input failed.'};}
|
|
61
|
+
finally {if(session)await request('DELETE',route('/actions')).catch(()=>{});}
|
|
62
|
+
},
|
|
63
|
+
async screenshot(reason){const relativePath=path.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
|
|
64
|
+
async consoleErrors(){return [];},
|
|
65
|
+
async stop(){if(session){const current=route('');session=null;await request('DELETE',current);}return {videoPath:null};}
|
|
66
|
+
};return driver;
|
|
67
|
+
}
|
package/src/run/fixLoop.js
CHANGED
|
@@ -51,6 +51,55 @@ export const PLAN_MAX_TURNS = 6;
|
|
|
51
51
|
*/
|
|
52
52
|
export const NUDGE_EVERY = 8;
|
|
53
53
|
|
|
54
|
+
/** One breakpoint, frozen so no caller can hand a mutated one to the next turn. */
|
|
55
|
+
const CACHE_BREAKPOINT = Object.freeze({ type: 'ephemeral' });
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Move a cache breakpoint onto the newest turn of a conversation.
|
|
59
|
+
*
|
|
60
|
+
* `fixSystem` already marks the end of the system prompt, but a breakpoint
|
|
61
|
+
* there only ever caches the skill and the repository facts. The tokens on
|
|
62
|
+
* this path are in `messages`: a `read_file` result is a whole source file,
|
|
63
|
+
* and the apply loop accumulates dozens of them over up to sixty turns, every
|
|
64
|
+
* one of which re-sends the whole conversation. Without a breakpoint in
|
|
65
|
+
* `messages` that conversation is processed at full price on every turn, which
|
|
66
|
+
* is quadratic in the number of turns and is where `fix.apply`'s bill comes
|
|
67
|
+
* from.
|
|
68
|
+
*
|
|
69
|
+
* The prefix written on turn N is exactly the prefix read on turn N+1, because
|
|
70
|
+
* these two loops only ever APPEND to `messages`. Nothing rewrites an earlier
|
|
71
|
+
* turn, so the bytes ahead of the marker are identical from one call to the
|
|
72
|
+
* next. (`personaLoop` deliberately does not do this: `prepareMessages`
|
|
73
|
+
* rewrites older observations into stubs as a run goes on, so its prefix is not
|
|
74
|
+
* stable and a breakpoint in its messages would pay the write premium instead
|
|
75
|
+
* of earning a read.)
|
|
76
|
+
*
|
|
77
|
+
* Applied to a COPY, leaving the stored conversation unmarked, for two
|
|
78
|
+
* reasons. A request may carry at most four breakpoints, so a marker left
|
|
79
|
+
* behind on each turn would be refused by the fifth. And the bytes this call
|
|
80
|
+
* sends for an earlier turn have to be the bytes the last call sent, which a
|
|
81
|
+
* marker written into the history would change. A marker that moves is not an
|
|
82
|
+
* invalidator; one that accumulates is.
|
|
83
|
+
*
|
|
84
|
+
* No breakpoint can leak anything across runs or across customers. A cache
|
|
85
|
+
* read requires the caller to present the identical prefix bytes, so the only
|
|
86
|
+
* thing an entry can ever serve back is a prompt whoever asked for it already
|
|
87
|
+
* held. The proxy passes `messages` through by reference, so nothing about
|
|
88
|
+
* this needs the server's field allowlist widened.
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<Object>} messages the conversation so far, unmarked
|
|
91
|
+
* @returns {Array<Object>} the conversation to send, with one moving marker
|
|
92
|
+
*/
|
|
93
|
+
export function withRollingCacheBreakpoint(messages) {
|
|
94
|
+
const last = messages.at(-1);
|
|
95
|
+
if (!last || !Array.isArray(last.content) || last.content.length === 0) return messages;
|
|
96
|
+
const block = last.content.at(-1);
|
|
97
|
+
if (!block || typeof block !== 'object') return messages;
|
|
98
|
+
const content = [...last.content];
|
|
99
|
+
content[content.length - 1] = { ...block, cache_control: CACHE_BREAKPOINT };
|
|
100
|
+
return [...messages.slice(0, -1), { ...last, content }];
|
|
101
|
+
}
|
|
102
|
+
|
|
54
103
|
/** The text of a tool result, whichever of the two content shapes it uses. */
|
|
55
104
|
function resultText(result) {
|
|
56
105
|
if (!result) return '';
|
|
@@ -213,7 +262,7 @@ export async function planFix({
|
|
|
213
262
|
runId,
|
|
214
263
|
step: STEPS.fixPlan,
|
|
215
264
|
system,
|
|
216
|
-
messages,
|
|
265
|
+
messages: withRollingCacheBreakpoint(messages),
|
|
217
266
|
tools: repo ? repo.tools : undefined,
|
|
218
267
|
maxTokens: PLAN_MAX_TOKENS
|
|
219
268
|
});
|
|
@@ -337,7 +386,7 @@ export async function applyFix({
|
|
|
337
386
|
runId,
|
|
338
387
|
step: STEPS.fixApply,
|
|
339
388
|
system,
|
|
340
|
-
messages,
|
|
389
|
+
messages: withRollingCacheBreakpoint(messages),
|
|
341
390
|
tools: repo.tools,
|
|
342
391
|
maxTokens: APPLY_MAX_TOKENS
|
|
343
392
|
});
|
|
@@ -486,4 +535,4 @@ export function clamp(value, limit) {
|
|
|
486
535
|
return text.length <= limit ? text : text.slice(0, limit);
|
|
487
536
|
}
|
|
488
537
|
|
|
489
|
-
export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan };
|
|
538
|
+
export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan, withRollingCacheBreakpoint };
|