ravensight-playtest 0.7.0 → 0.7.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 +61 -8
- package/package.json +3 -3
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +42 -9
- package/src/commands/brief.js +1 -1
- package/src/commands/build.js +23 -1
- package/src/commands/check.js +5 -1
- package/src/commands/fakeCore.js +5 -1
- package/src/commands/fix.js +2 -0
- package/src/commands/hosted.js +144 -2
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +86 -5
- package/src/hosted/buildStatus.js +35 -0
- package/src/hosted/buildUpload.js +24 -2
- package/src/hosted/recordedRun.js +135 -3
- package/src/hosted/status.js +108 -0
- package/src/run/aggregate.js +69 -10
- package/src/run/budgetReserve.js +102 -0
- package/src/run/context.js +122 -3
- package/src/run/drivers/web.js +342 -126
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/model.js +18 -1
- package/src/run/openPr.js +8 -1
- package/src/run/personaLoop.js +333 -76
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/tools.js +31 -3
- package/src/run/usage.js +54 -0
- package/src/ui/index.js +21 -6
package/src/run/report.js
CHANGED
|
@@ -57,6 +57,60 @@ export function lintMarkdownSections(markdown) {
|
|
|
57
57
|
return REQUIRED_MARKDOWN_SECTIONS.filter(section => !text.includes(section));
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** The tool-call tags a model has been seen to leak into a written file. */
|
|
61
|
+
const TOOL_TAG = '(?:parameter|invoke|content|function_calls|function_results|tool_use|tool_call|antml:[\\w-]+)';
|
|
62
|
+
|
|
63
|
+
/** A line made only of tags: closers of any name, or tool-call openers. */
|
|
64
|
+
const MARKUP_LINE = new RegExp(`^(?:\\s*(?:<\\/[A-Za-z][\\w:.-]*\\s*>|<${TOOL_TAG}\\b[^>]*>))+\\s*$`);
|
|
65
|
+
|
|
66
|
+
/** Tool-call closers glued to the end of the last line of real text. */
|
|
67
|
+
const MARKUP_SUFFIX = new RegExp(`(?:\\s*<\\/${TOOL_TAG}\\s*>)+\\s*$`);
|
|
68
|
+
|
|
69
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Takes tool-call markup off the end of a file the report step wrote. A real
|
|
73
|
+
* report.md ended "</content>\n</invoke>" because the model closed its tool
|
|
74
|
+
* call inside the file's content, and the runner appended its own sections
|
|
75
|
+
* after the stray tags.
|
|
76
|
+
*
|
|
77
|
+
* Only the end of the text is touched, and only outside a code fence: HTML a
|
|
78
|
+
* report quotes in backticks or in a fenced block is kept.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} text
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function stripTrailingToolMarkup(text) {
|
|
84
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
85
|
+
const lines = text.split('\n');
|
|
86
|
+
const insideFence = count => lines.slice(0, count).filter(line => FENCE.test(line)).length % 2 === 1;
|
|
87
|
+
let end = lines.length;
|
|
88
|
+
let changed = false;
|
|
89
|
+
while (end > 0) {
|
|
90
|
+
const line = lines[end - 1];
|
|
91
|
+
if (line.trim() === '') {
|
|
92
|
+
end -= 1;
|
|
93
|
+
} else if (MARKUP_LINE.test(line) && !insideFence(end - 1)) {
|
|
94
|
+
end -= 1;
|
|
95
|
+
changed = true;
|
|
96
|
+
} else {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const kept = lines.slice(0, end);
|
|
101
|
+
if (kept.length > 0) {
|
|
102
|
+
const index = kept.length - 1;
|
|
103
|
+
const last = kept[index];
|
|
104
|
+
const backticks = (last.match(/`/g) || []).length;
|
|
105
|
+
if (MARKUP_SUFFIX.test(last) && backticks % 2 === 0 && !FENCE.test(last) && !insideFence(index)) {
|
|
106
|
+
kept[index] = last.replace(MARKUP_SUFFIX, '');
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!changed) return text;
|
|
111
|
+
return `${kept.join('\n').replace(/\s+$/, '')}\n`;
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
export class ReportInvalidError extends Error {
|
|
61
115
|
/**
|
|
62
116
|
* @param {Array<{instancePath: string, message: string}>} details
|
package/src/run/tools.js
CHANGED
|
@@ -335,6 +335,19 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
|
|
|
335
335
|
: action.ref && staleRefs
|
|
336
336
|
? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
|
|
337
337
|
: await driver.act(action);
|
|
338
|
+
// The runner refused to leave the build. The persona is told so in plain
|
|
339
|
+
// text and plays on: it is a step that changed nothing, never an error and
|
|
340
|
+
// never a run failure. No observation either, because nothing moved.
|
|
341
|
+
if (outcome.refused === 'off_site') {
|
|
342
|
+
return {
|
|
343
|
+
result: toolResult(toolUse.id, offSiteRefusalText(outcome.host)),
|
|
344
|
+
observation: null,
|
|
345
|
+
spentAction: ACTION_TOOLS.includes(toolUse.name),
|
|
346
|
+
changed: false,
|
|
347
|
+
error: null,
|
|
348
|
+
refused: 'off_site'
|
|
349
|
+
};
|
|
350
|
+
}
|
|
338
351
|
const observation = await driver.observe();
|
|
339
352
|
const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
|
|
340
353
|
const prefix = outcome.ok
|
|
@@ -350,11 +363,25 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
|
|
|
350
363
|
};
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
/**
|
|
367
|
+
* What the persona is told when the runner refuses to leave the build.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} [host]
|
|
370
|
+
* @returns {string}
|
|
371
|
+
*/
|
|
372
|
+
export function offSiteRefusalText(host) {
|
|
373
|
+
return `Refused: that link leaves the build under test (${host || 'another site'}). Stay on this page and use its controls.`;
|
|
374
|
+
}
|
|
375
|
+
|
|
353
376
|
/**
|
|
354
377
|
* A tool_result whose content is the new observation, with the screenshot
|
|
355
378
|
* attached as an image when the driver decided the text was too thin to
|
|
356
379
|
* play from. Images are pruned from history by the caller: the act step
|
|
357
|
-
* accepts a very small number of image blocks per request.
|
|
380
|
+
* accepts a very small number of image blocks per request. When the screenshot
|
|
381
|
+
* rides along, a failed action is NOT flagged `is_error`: the Messages API
|
|
382
|
+
* refuses an `is_error` result that carries an image, and the image is the
|
|
383
|
+
* persona's only view of a canvas game. The "That did not work" prefix
|
|
384
|
+
* already says what the flag would.
|
|
358
385
|
*
|
|
359
386
|
* @param {string} id
|
|
360
387
|
* @param {Object} observation
|
|
@@ -382,7 +409,8 @@ export function observationResult(id, observation, prefix = '', isError = false)
|
|
|
382
409
|
}
|
|
383
410
|
});
|
|
384
411
|
}
|
|
385
|
-
|
|
412
|
+
const withImage = content.length > 1;
|
|
413
|
+
return { type: 'tool_result', tool_use_id: id, content, ...(isError && !withImage ? { is_error: true } : {}) };
|
|
386
414
|
}
|
|
387
415
|
|
|
388
416
|
/**
|
|
@@ -429,4 +457,4 @@ function message(error) {
|
|
|
429
457
|
return String(error && error.message ? error.message : error);
|
|
430
458
|
}
|
|
431
459
|
|
|
432
|
-
export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult };
|
|
460
|
+
export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult, offSiteRefusalText };
|
package/src/run/usage.js
CHANGED
|
@@ -32,6 +32,14 @@ export function createUsageLedger() {
|
|
|
32
32
|
let modelMs = 0;
|
|
33
33
|
let serverTotalUsd = null;
|
|
34
34
|
let serverCalls = null;
|
|
35
|
+
// The run's tokens in input-token units (see weightedUnits), and the last
|
|
36
|
+
// server spend that covered every call this ledger had recorded when it
|
|
37
|
+
// arrived. Together they say what one unit costs on this run, which is how
|
|
38
|
+
// the persona loop keeps enough budget back for the report without ever
|
|
39
|
+
// holding a price: ./budgetReserve.js.
|
|
40
|
+
let units = 0;
|
|
41
|
+
let lastCallUnits = 0;
|
|
42
|
+
let sample = null;
|
|
35
43
|
|
|
36
44
|
return {
|
|
37
45
|
/**
|
|
@@ -50,6 +58,8 @@ export function createUsageLedger() {
|
|
|
50
58
|
entry.cache_read_tokens += Number(usage.cache_read_input_tokens || 0);
|
|
51
59
|
entry.output_tokens += Number(usage.output_tokens || 0);
|
|
52
60
|
byModel.set(key, entry);
|
|
61
|
+
lastCallUnits = weightedUnits(usage);
|
|
62
|
+
units += lastCallUnits;
|
|
53
63
|
},
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -61,6 +71,31 @@ export function createUsageLedger() {
|
|
|
61
71
|
if (!runUsage) return;
|
|
62
72
|
if (Number.isFinite(Number(runUsage.total_usd))) serverTotalUsd = Number(runUsage.total_usd);
|
|
63
73
|
if (Number.isFinite(Number(runUsage.calls))) serverCalls = Number(runUsage.calls);
|
|
74
|
+
// A figure that has not caught up with every call recorded here would
|
|
75
|
+
// make a unit look cheaper than it is, so only one that covers them all
|
|
76
|
+
// (the server may count more, which only makes a unit look dearer) is
|
|
77
|
+
// kept as the price sample.
|
|
78
|
+
const covers = !Number.isFinite(Number(runUsage.calls)) || Number(runUsage.calls) >= calls;
|
|
79
|
+
if (serverTotalUsd !== null && serverTotalUsd > 0 && units > 0 && covers) sample = { usd: serverTotalUsd, units };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** The weighted units of the last call recorded, 0 before the first. */
|
|
83
|
+
get lastCallUnits() {
|
|
84
|
+
return lastCallUnits;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What the run has spent and what one weighted unit costs on it, or null
|
|
89
|
+
* before the server has priced a call this ledger recorded. The spend is
|
|
90
|
+
* the server's figure plus the calls made since, priced at the sample's
|
|
91
|
+
* rate, so it never reads lower than the server's own.
|
|
92
|
+
*
|
|
93
|
+
* @returns {{usd: number, usdPerUnit: number}|null}
|
|
94
|
+
*/
|
|
95
|
+
spendEstimate() {
|
|
96
|
+
if (!sample) return null;
|
|
97
|
+
const usdPerUnit = sample.usd / sample.units;
|
|
98
|
+
return { usd: Math.max(serverTotalUsd || 0, sample.usd + usdPerUnit * Math.max(0, units - sample.units)), usdPerUnit };
|
|
64
99
|
},
|
|
65
100
|
|
|
66
101
|
get modelCalls() {
|
|
@@ -114,6 +149,25 @@ export function createUsageLedger() {
|
|
|
114
149
|
};
|
|
115
150
|
}
|
|
116
151
|
|
|
152
|
+
/** Input-token units per cache write, cache read and output token. */
|
|
153
|
+
export const CACHE_WRITE_WEIGHT = 1.25;
|
|
154
|
+
export const CACHE_READ_WEIGHT = 0.1;
|
|
155
|
+
export const OUTPUT_WEIGHT = 5;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One call's tokens in input-token units: what the server's price for the call
|
|
159
|
+
* is proportional to on every tier, since each tier prices a cache write at
|
|
160
|
+
* 1.25 input tokens, a cache read at 0.1 and an output token at 5. No price,
|
|
161
|
+
* only the shape of one.
|
|
162
|
+
*
|
|
163
|
+
* @param {Object} usage
|
|
164
|
+
* @returns {number}
|
|
165
|
+
*/
|
|
166
|
+
export function weightedUnits(usage = {}) {
|
|
167
|
+
const n = key => Math.max(0, Number(usage && usage[key]) || 0);
|
|
168
|
+
return n('input_tokens') + n('cache_creation_input_tokens') * CACHE_WRITE_WEIGHT + n('cache_read_input_tokens') * CACHE_READ_WEIGHT + n('output_tokens') * OUTPUT_WEIGHT;
|
|
169
|
+
}
|
|
170
|
+
|
|
117
171
|
/**
|
|
118
172
|
* @param {string} runDirectory
|
|
119
173
|
* @param {ReturnType<typeof createUsageLedger>} ledger
|
package/src/ui/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stableStringify } from '../fsutil.js';
|
|
3
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Terminal output, in one place.
|
|
@@ -183,22 +184,36 @@ export function spinner(label) {
|
|
|
183
184
|
};
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
/** The one line printed when a question needs a terminal and there is none. */
|
|
188
|
+
export const NO_TERMINAL_MESSAGE = 'No terminal to confirm in: pass --yes to accept the shown price and hold.';
|
|
189
|
+
|
|
190
|
+
function noTerminal(message = NO_TERMINAL_MESSAGE) {
|
|
191
|
+
const error = new CliError(message, ExitCode.FAILURE);
|
|
192
|
+
error.code = 'no_terminal';
|
|
193
|
+
return error;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** True for the error confirm() throws when there is no terminal to ask in. */
|
|
197
|
+
export const isNoTerminal = error => error instanceof CliError && error.code === 'no_terminal';
|
|
198
|
+
|
|
186
199
|
/**
|
|
187
200
|
* Ask a yes or no question.
|
|
188
201
|
*
|
|
189
|
-
* `--yes` skips it
|
|
190
|
-
* answer would hang a CI job forever,
|
|
191
|
-
*
|
|
192
|
-
*
|
|
202
|
+
* `--yes` skips it. A non interactive stdin is an error, not an answer: a
|
|
203
|
+
* prompt nobody can answer would hang a CI job forever, and piped text such as
|
|
204
|
+
* `echo y |` is never taken as consent, because the question is usually
|
|
205
|
+
* "shall I spend your money". Quietly declining instead left people wondering
|
|
206
|
+
* why nothing happened, so the error says what to do.
|
|
193
207
|
*
|
|
194
208
|
* @param {string} question
|
|
195
|
-
* @param {{yes?: boolean, input?: object, output?: object}} [options]
|
|
209
|
+
* @param {{yes?: boolean, input?: object, output?: object, noTerminalMessage?: string}} [options]
|
|
210
|
+
* noTerminalMessage replaces the line for a question that is not about money.
|
|
196
211
|
* @returns {Promise<boolean>}
|
|
197
212
|
*/
|
|
198
213
|
export async function confirm(question, options = {}) {
|
|
199
214
|
if (options.yes) return true;
|
|
200
215
|
const input = options.input || process.stdin;
|
|
201
|
-
if (!input.isTTY && !options.input)
|
|
216
|
+
if (!input.isTTY && !options.input) throw noTerminal(options.noTerminalMessage);
|
|
202
217
|
|
|
203
218
|
const rl = createInterface({ input, output: options.output || stream });
|
|
204
219
|
try {
|