ravensight-playtest 0.2.7 → 0.2.9
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 +53 -1
- package/package.json +1 -1
- package/src/cli.js +1 -0
- package/src/commands/run.js +100 -9
- package/src/commands/upload.js +10 -0
- package/src/run/aggregate.js +8 -2
- package/src/run/args.js +2 -1
- package/src/run/drivers/godot.js +5 -0
- package/src/run/drivers/stdio.js +1 -0
- package/src/run/drivers/web.js +322 -19
- package/src/run/drivers/webdriver.js +1 -0
- package/src/run/dropped.js +294 -0
- package/src/run/heartbeat.js +8 -1
- package/src/run/personaLoop.js +113 -34
- package/src/run/placeholders.js +186 -0
- package/src/run/report.js +93 -2
- package/src/run/resume.js +25 -1
- package/src/run/state.js +4 -1
- package/src/run/synthetic.js +2 -0
- package/src/run/tools.js +10 -4
- package/src/upload/index.js +9 -1
- package/src/upload/queue.js +13 -5
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sha256File } from '../fsutil.js';
|
|
4
|
+
import { planRunDir } from '../upload/index.js';
|
|
5
|
+
import { record, STATUS } from '../upload/queue.js';
|
|
6
|
+
import { TRANSCRIPT_PATH, VIDEO_PATHS } from '../upload/allowlist.js';
|
|
7
|
+
import { RUN_FILES } from './paths.js';
|
|
8
|
+
import { fillPlaceholders } from './placeholders.js';
|
|
9
|
+
import { writeReportFiles } from './report.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Findings the server dropped, marked in the local report so it agrees with
|
|
13
|
+
* the dashboard.
|
|
14
|
+
*
|
|
15
|
+
* At `complete` the server keeps a finding only when at least one of its
|
|
16
|
+
* evidence refs resolves to a file the run actually uploaded, and answers
|
|
17
|
+
* with how many it dropped, not which. Without this step the CLI said "the
|
|
18
|
+
* server dropped 1 finding" while report.md and report.json still listed it,
|
|
19
|
+
* major severity and all, and the dashboard showed one fewer.
|
|
20
|
+
*
|
|
21
|
+
* The CLI uploaded the files itself, so it can apply the server's rule to
|
|
22
|
+
* the same list and name them. When its count matches the server's, each
|
|
23
|
+
* one is marked `dropped: true` in report.json and moved in report.md to a
|
|
24
|
+
* section of its own: kept for the developer to read, out of the top issues,
|
|
25
|
+
* the aggregate and the synthetic telemetry. When the counts disagree it
|
|
26
|
+
* does not guess which; it says how many the server dropped and points at
|
|
27
|
+
* the dashboard.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The section dropped findings are moved to. */
|
|
31
|
+
export const DROPPED_HEADING = '## Dropped by the server (no usable evidence)';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The uploaded path, or candidate paths, an evidence entry needs. The same
|
|
35
|
+
* mapping as the server's `manifestPathForEvidence`.
|
|
36
|
+
*
|
|
37
|
+
* @param {{type?: string, ref?: string}} evidence
|
|
38
|
+
* @returns {string|Array<string>|null}
|
|
39
|
+
*/
|
|
40
|
+
export function evidencePath(evidence) {
|
|
41
|
+
if (!evidence || typeof evidence.type !== 'string') return null;
|
|
42
|
+
const ref = typeof evidence.ref === 'string' ? evidence.ref : '';
|
|
43
|
+
switch (evidence.type) {
|
|
44
|
+
case 'screenshot':
|
|
45
|
+
return ref || null;
|
|
46
|
+
case 'video_ts':
|
|
47
|
+
return [...VIDEO_PATHS];
|
|
48
|
+
case 'transcript_step':
|
|
49
|
+
return TRANSCRIPT_PATH;
|
|
50
|
+
case 'file_line':
|
|
51
|
+
return ref ? ref.split(':')[0] : null;
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The findings none of whose evidence resolves to an uploaded file.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object} report
|
|
61
|
+
* @param {Iterable<string>} uploaded the run relative paths that were uploaded
|
|
62
|
+
* @returns {Array<Object>}
|
|
63
|
+
*/
|
|
64
|
+
export function unbackedFindings(report, uploaded) {
|
|
65
|
+
const paths = new Set(uploaded);
|
|
66
|
+
const findings = Array.isArray(report && report.findings) ? report.findings : [];
|
|
67
|
+
return findings.filter(finding => {
|
|
68
|
+
if (!finding || finding.dropped === true) return false;
|
|
69
|
+
const evidence = Array.isArray(finding.evidence) ? finding.evidence : [];
|
|
70
|
+
return !evidence.some(entry => {
|
|
71
|
+
const wanted = evidencePath(entry);
|
|
72
|
+
if (wanted === null) return false;
|
|
73
|
+
return (Array.isArray(wanted) ? wanted : [wanted]).some(candidate => paths.has(candidate));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* report.json with the named findings marked.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} report
|
|
82
|
+
* @param {Set<string>} ids
|
|
83
|
+
* @returns {Object}
|
|
84
|
+
*/
|
|
85
|
+
export function markDroppedFindings(report, ids) {
|
|
86
|
+
return {
|
|
87
|
+
...report,
|
|
88
|
+
findings: (report.findings || []).map(finding => (finding && ids.has(finding.id) ? { ...finding, dropped: true } : finding))
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SECTION = /^## /;
|
|
93
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* report.md with the dropped findings out of the summary and in a section of
|
|
97
|
+
* their own.
|
|
98
|
+
*
|
|
99
|
+
* A list item that names a dropped finding id and no kept one is removed
|
|
100
|
+
* wherever it is, which is how "Top issues" names them (`F003 ...`). A
|
|
101
|
+
* section left with nothing under it says so. Then the dropped findings are
|
|
102
|
+
* listed under {@link DROPPED_HEADING}, replacing one written before, so a
|
|
103
|
+
* second reconcile does not stack them.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} markdown
|
|
106
|
+
* @param {Array<Object>} dropped the dropped findings, from report.json
|
|
107
|
+
* @param {Array<Object>} kept the findings the server kept
|
|
108
|
+
* @param {{unidentified?: number}} [opts] a count the CLI could not attribute
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } = {}) {
|
|
112
|
+
const lines = withoutDroppedSection(String(markdown || '').replace(/\s+$/, '')).split('\n');
|
|
113
|
+
const droppedIds = dropped.map(finding => String(finding.id)).filter(Boolean);
|
|
114
|
+
const keptIds = kept.map(finding => String(finding.id)).filter(Boolean);
|
|
115
|
+
const names = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(id)}(?![0-9])`).test(line);
|
|
116
|
+
|
|
117
|
+
const out = [];
|
|
118
|
+
let section = null;
|
|
119
|
+
let sectionRemoved = 0;
|
|
120
|
+
let sectionKept = 0;
|
|
121
|
+
const closeSection = () => {
|
|
122
|
+
if (section !== null && sectionRemoved > 0 && sectionKept === 0) {
|
|
123
|
+
// Trailing blank lines stay after the note, as they were after the list.
|
|
124
|
+
let blanks = 0;
|
|
125
|
+
while (out.length > 0 && out[out.length - 1].trim() === '') {
|
|
126
|
+
out.pop();
|
|
127
|
+
blanks += 1;
|
|
128
|
+
}
|
|
129
|
+
out.push('- Nothing here that the server kept. See the dropped findings below.');
|
|
130
|
+
for (let i = 0; i < blanks; i += 1) out.push('');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
if (SECTION.test(line)) {
|
|
135
|
+
closeSection();
|
|
136
|
+
section = line;
|
|
137
|
+
sectionRemoved = 0;
|
|
138
|
+
sectionKept = 0;
|
|
139
|
+
out.push(line);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (LIST_ITEM.test(line) && droppedIds.some(id => names(line, id)) && !keptIds.some(id => names(line, id))) {
|
|
143
|
+
sectionRemoved += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (LIST_ITEM.test(line)) sectionKept += 1;
|
|
147
|
+
out.push(line);
|
|
148
|
+
}
|
|
149
|
+
closeSection();
|
|
150
|
+
|
|
151
|
+
const tail = ['', DROPPED_HEADING];
|
|
152
|
+
if (dropped.length > 0) {
|
|
153
|
+
tail.push(
|
|
154
|
+
'The server keeps a finding only when at least one of its evidence references points at a file this run uploaded. These did not, so the dashboard does not show them and they are not counted. They are kept here so you can judge them yourself.',
|
|
155
|
+
''
|
|
156
|
+
);
|
|
157
|
+
for (const finding of dropped) {
|
|
158
|
+
const evidence = (Array.isArray(finding.evidence) ? finding.evidence : [])
|
|
159
|
+
.map(entry => `${entry && entry.type} ${entry && entry.ref}`)
|
|
160
|
+
.join(', ') || 'none';
|
|
161
|
+
// The title is the model's prose, copied here; fill any slot it carries first.
|
|
162
|
+
tail.push(`- ${finding.id} (${finding.severity}, ${finding.category}): ${fillPlaceholders(String(finding.title || ''))}. Evidence cited: ${evidence}.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (unidentified > 0) {
|
|
166
|
+
tail.push(
|
|
167
|
+
`The server dropped ${unidentified} finding(s) with no usable evidence, and this report could not tell which, so every finding above is still listed and counted here. The dashboard shows the findings the server kept.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return `${out.join('\n')}\n${tail.join('\n')}\n`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Remove a dropped section written by an earlier reconcile.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} markdown
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
function withoutDroppedSection(markdown) {
|
|
180
|
+
const lines = markdown.split('\n');
|
|
181
|
+
const start = lines.findIndex(line => line.trim() === DROPPED_HEADING);
|
|
182
|
+
if (start === -1) return markdown;
|
|
183
|
+
let end = lines.findIndex((line, index) => index > start && SECTION.test(line));
|
|
184
|
+
if (end === -1) end = lines.length;
|
|
185
|
+
return [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\s+$/, '');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function escapeRegExp(value) {
|
|
189
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark the findings the server dropped in a run directory's report files.
|
|
194
|
+
*
|
|
195
|
+
* The server's own list (`dropped_finding_ids`) is used when it sends one.
|
|
196
|
+
* Without it the CLI applies the server's evidence rule to its upload plan,
|
|
197
|
+
* and marks only when that count matches the server's. Findings marked by an
|
|
198
|
+
* earlier reconcile count towards the server's number, so running it twice
|
|
199
|
+
* changes nothing.
|
|
200
|
+
*
|
|
201
|
+
* Rewriting the two files changes their sha256, which would make `upload
|
|
202
|
+
* <job>` send them again over the validated artifacts. So each new hash is
|
|
203
|
+
* recorded in the job's upload journal as a local edit, which the journal
|
|
204
|
+
* treats as already delivered.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object} args
|
|
207
|
+
* @param {string} args.directory the run directory
|
|
208
|
+
* @param {number} args.droppedCount `dropped_findings` from the complete answer
|
|
209
|
+
* @param {Array<string>} [args.droppedIds] `dropped_finding_ids` from the complete answer, when the server sends it
|
|
210
|
+
* @param {{uploadVideo?: boolean, uploadTranscript?: boolean}} [args.options] what was uploaded
|
|
211
|
+
* @param {{jobId: string, runId: string, repoRoot?: string}} [args.journal] where to record the local edit
|
|
212
|
+
* @param {(dir: string, opts: Object) => Promise<{files: Array<{path: string}>}>} [args.plan] test seam
|
|
213
|
+
* @returns {Promise<{report: Object, markdown: string, identified: boolean, dropped: Array<string>, source: string}|null>}
|
|
214
|
+
* null when there was nothing to mark or no report on disk
|
|
215
|
+
*/
|
|
216
|
+
export async function reconcileDroppedFindings({ directory, droppedCount, droppedIds = null, options = {}, journal = null, plan = planRunDir }) {
|
|
217
|
+
const serverIds = Array.isArray(droppedIds) ? droppedIds.map(String).filter(Boolean) : null;
|
|
218
|
+
const count = serverIds && serverIds.length > 0 ? serverIds.length : Number(droppedCount) || 0;
|
|
219
|
+
if (count <= 0) return null;
|
|
220
|
+
let report;
|
|
221
|
+
let markdown;
|
|
222
|
+
try {
|
|
223
|
+
report = JSON.parse(await readFile(path.join(directory, RUN_FILES.reportJson), 'utf8'));
|
|
224
|
+
markdown = await readFile(path.join(directory, RUN_FILES.reportMd), 'utf8');
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
if (!report || !Array.isArray(report.findings)) return null;
|
|
229
|
+
|
|
230
|
+
const already = report.findings.filter(finding => finding && finding.dropped === true).map(finding => finding.id);
|
|
231
|
+
let ids;
|
|
232
|
+
let identified;
|
|
233
|
+
let source;
|
|
234
|
+
if (serverIds && serverIds.length > 0) {
|
|
235
|
+
const present = new Set(report.findings.filter(Boolean).map(finding => finding.id));
|
|
236
|
+
ids = new Set(serverIds.filter(id => present.has(id)));
|
|
237
|
+
identified = ids.size === serverIds.length;
|
|
238
|
+
source = 'server';
|
|
239
|
+
} else {
|
|
240
|
+
const planned = await plan(directory, { video: Boolean(options.uploadVideo), transcript: Boolean(options.uploadTranscript) });
|
|
241
|
+
const unbacked = unbackedFindings(report, planned.files.map(file => file.path)).map(finding => finding.id);
|
|
242
|
+
identified = already.length + unbacked.length === count;
|
|
243
|
+
ids = new Set(identified ? [...already, ...unbacked] : already);
|
|
244
|
+
source = 'plan';
|
|
245
|
+
}
|
|
246
|
+
const marked = markDroppedFindings(report, ids);
|
|
247
|
+
const dropped = marked.findings.filter(finding => finding && finding.dropped === true);
|
|
248
|
+
const kept = marked.findings.filter(finding => !(finding && finding.dropped === true));
|
|
249
|
+
const rewritten = rewriteMarkdown(markdown, dropped, kept, { unidentified: identified ? 0 : count - dropped.length });
|
|
250
|
+
await writeReportFiles({ runDirectory: directory, markdown: rewritten, report: marked });
|
|
251
|
+
await recordLocalEdits(directory, journal);
|
|
252
|
+
return { report: marked, markdown: rewritten, identified, dropped: [...ids], source };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Record the rewritten report files' hashes as local edits, so the upload
|
|
257
|
+
* journal treats them as delivered rather than owed.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} directory
|
|
260
|
+
* @param {{jobId: string, runId: string, repoRoot?: string}|null} journal
|
|
261
|
+
*/
|
|
262
|
+
async function recordLocalEdits(directory, journal) {
|
|
263
|
+
const where = journal && journal.jobId && journal.repoRoot ? journal : journalFromDirectory(directory) || (journal && journal.jobId ? journal : null);
|
|
264
|
+
if (!where) return;
|
|
265
|
+
for (const name of [RUN_FILES.reportMd, RUN_FILES.reportJson]) {
|
|
266
|
+
const file = path.join(directory, name);
|
|
267
|
+
const info = await stat(file);
|
|
268
|
+
await record(where.jobId, {
|
|
269
|
+
kind: 'run',
|
|
270
|
+
run_id: where.runId,
|
|
271
|
+
path: name,
|
|
272
|
+
sha256: await sha256File(file),
|
|
273
|
+
size: info.size,
|
|
274
|
+
status: STATUS.LOCAL_EDIT,
|
|
275
|
+
detail: 'dropped findings marked after complete; the delivered copy stays the record'
|
|
276
|
+
}, where.repoRoot ? { repoRoot: where.repoRoot } : {});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The job, run and repository a run directory belongs to, from its own path
|
|
282
|
+
* (`<repo>/.ravensight/jobs/<jobId>/runs/<runId>`), or null for any other.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} directory
|
|
285
|
+
* @returns {{jobId: string, runId: string, repoRoot: string}|null}
|
|
286
|
+
*/
|
|
287
|
+
export function journalFromDirectory(directory) {
|
|
288
|
+
const parts = path.resolve(directory).split(path.sep);
|
|
289
|
+
const n = parts.length;
|
|
290
|
+
if (n < 6 || parts[n - 2] !== 'runs' || parts[n - 4] !== 'jobs' || parts[n - 5] !== '.ravensight') return null;
|
|
291
|
+
return { jobId: parts[n - 3], runId: parts[n - 1], repoRoot: parts.slice(0, n - 5).join(path.sep) || path.sep };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default { reconcileDroppedFindings, unbackedFindings, markDroppedFindings, rewriteMarkdown, evidencePath, DROPPED_HEADING };
|
package/src/run/heartbeat.js
CHANGED
|
@@ -58,6 +58,11 @@ export function createHeartbeat({
|
|
|
58
58
|
let canceled = false;
|
|
59
59
|
let failures = 0;
|
|
60
60
|
let inFlight = false;
|
|
61
|
+
// Set by stop(). A beat already on the wire when the run ends must not
|
|
62
|
+
// write its progress after the run's final state: in 0.2.8 one landed
|
|
63
|
+
// after the outcome and put a finished run back to `playing` in the
|
|
64
|
+
// journal.
|
|
65
|
+
let stopped = false;
|
|
61
66
|
// The progress the loop last reported. The interval beat carries it too,
|
|
62
67
|
// rather than resetting the server's and the journal's idea of the
|
|
63
68
|
// checkpoint to zero every five seconds.
|
|
@@ -78,7 +83,7 @@ export function createHeartbeat({
|
|
|
78
83
|
try {
|
|
79
84
|
const answer = await api.runs.heartbeat(gameId, jobId, runId, { heartbeat: true, ...progress });
|
|
80
85
|
failures = 0;
|
|
81
|
-
if (onBeat) await onBeat({ ...progress });
|
|
86
|
+
if (onBeat && !stopped) await onBeat({ ...progress });
|
|
82
87
|
if (answer && answer.run && answer.run.usage && onUsage) onUsage(answer.run.usage);
|
|
83
88
|
if (answer && answer.cancel_requested && !canceled) {
|
|
84
89
|
canceled = true;
|
|
@@ -111,6 +116,7 @@ export function createHeartbeat({
|
|
|
111
116
|
},
|
|
112
117
|
|
|
113
118
|
start() {
|
|
119
|
+
stopped = false;
|
|
114
120
|
if (timer) return this;
|
|
115
121
|
timer = setIntervalImpl(() => {
|
|
116
122
|
beat().catch(() => {});
|
|
@@ -121,6 +127,7 @@ export function createHeartbeat({
|
|
|
121
127
|
},
|
|
122
128
|
|
|
123
129
|
stop() {
|
|
130
|
+
stopped = true;
|
|
124
131
|
if (timer) clearIntervalImpl(timer);
|
|
125
132
|
timer = null;
|
|
126
133
|
return this;
|
package/src/run/personaLoop.js
CHANGED
|
@@ -2,15 +2,15 @@ import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
5
|
-
import { observationHash } from './drivers/driver.js';
|
|
6
5
|
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
7
6
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
8
7
|
import { RUN_FILES } from './paths.js';
|
|
9
|
-
import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
8
|
+
import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
10
9
|
import { assertNoSecrets } from './secretScan.js';
|
|
11
10
|
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
12
11
|
import { createTranscript } from './transcript.js';
|
|
13
12
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
13
|
+
import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* One persona's playthrough: PLAYING then REPORTING (spec 03).
|
|
@@ -387,7 +387,7 @@ async function playLoop({
|
|
|
387
387
|
latestObservation = first.text;
|
|
388
388
|
visualObservation = Boolean(first.screenshotBase64);
|
|
389
389
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
390
|
-
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
|
|
390
|
+
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
|
|
391
391
|
messages.push({
|
|
392
392
|
role: 'user',
|
|
393
393
|
content: [
|
|
@@ -502,7 +502,7 @@ async function playLoop({
|
|
|
502
502
|
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
503
503
|
},
|
|
504
504
|
onScreenshot: shot => {
|
|
505
|
-
screenshots.push({ ...shot, order: nextOrder() });
|
|
505
|
+
screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
|
|
506
506
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
|
|
507
507
|
}
|
|
508
508
|
});
|
|
@@ -551,7 +551,7 @@ async function playLoop({
|
|
|
551
551
|
// with the persona's view of the screen in front of it. A 4xx on a
|
|
552
552
|
// resource is a `network` note, which the report step reads as context
|
|
553
553
|
// rather than as a bug (a missing favicon is not a broken game).
|
|
554
|
-
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
554
|
+
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
|
|
555
555
|
for (const entry of consoleErrors) {
|
|
556
556
|
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
557
557
|
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
|
|
@@ -591,7 +591,7 @@ async function playLoop({
|
|
|
591
591
|
quitDetail = `the action budget of ${maxActions} ran out`;
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
const final = await safeScreenshot(driver, 'final', screenshots, transcript);
|
|
594
|
+
const final = await safeScreenshot(driver, 'final', screenshots, transcript, observations.length);
|
|
595
595
|
return {
|
|
596
596
|
actionsTaken: actions,
|
|
597
597
|
turns,
|
|
@@ -650,11 +650,15 @@ async function reportPhase({
|
|
|
650
650
|
observationLines(observations),
|
|
651
651
|
'',
|
|
652
652
|
'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
|
|
653
|
+
'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw.',
|
|
653
654
|
'',
|
|
654
|
-
'
|
|
655
|
+
'Every evidence entry is an object with exactly two keys, "type" and "ref", and nothing else. A screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}, never "path" or "file".',
|
|
656
|
+
'',
|
|
657
|
+
'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
|
|
658
|
+
'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
|
|
655
659
|
'',
|
|
656
660
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
657
|
-
screenshots.length > 0 ? screenshots.map(
|
|
661
|
+
screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
|
|
658
662
|
'',
|
|
659
663
|
`report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
|
|
660
664
|
'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
|
|
@@ -665,12 +669,20 @@ async function reportPhase({
|
|
|
665
669
|
let report = null;
|
|
666
670
|
let markdown = null;
|
|
667
671
|
let lint = [];
|
|
668
|
-
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
672
|
+
// The newest report.json the model wrote, across turns: a repair turn that
|
|
673
|
+
// rewrites only report.md keeps the report.json it already wrote.
|
|
674
|
+
let jsonText = null;
|
|
675
|
+
let repaired = false;
|
|
676
|
+
let continued = false;
|
|
677
|
+
|
|
678
|
+
// One repair at most when the schema or the evidence refs refuse what was
|
|
679
|
+
// written: each turn is a metered question, and the dogfood gate asks for
|
|
680
|
+
// "first or the repair attempt", not an open ended loop. A turn that wrote
|
|
681
|
+
// report.md and stopped before report.json is not a refused report, so it
|
|
682
|
+
// gets one plain "now write report.json" that does not spend the repair.
|
|
683
|
+
// In 0.2.8 it did, and the report.json that came back had its five
|
|
684
|
+
// screenshot refs under "path" with no turn left to fix them.
|
|
685
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
674
686
|
const answer = await callStepWithRetry({
|
|
675
687
|
client,
|
|
676
688
|
jobId,
|
|
@@ -693,12 +705,13 @@ async function reportPhase({
|
|
|
693
705
|
files.set(name, String(toolUse.input.content || ''));
|
|
694
706
|
}
|
|
695
707
|
if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
|
|
708
|
+
if (files.has(RUN_FILES.reportJson)) jsonText = files.get(RUN_FILES.reportJson);
|
|
696
709
|
|
|
697
710
|
let parsed = null;
|
|
698
711
|
let parseError = null;
|
|
699
|
-
if (
|
|
712
|
+
if (jsonText !== null) {
|
|
700
713
|
try {
|
|
701
|
-
parsed = JSON.parse(
|
|
714
|
+
parsed = JSON.parse(jsonText);
|
|
702
715
|
} catch (error) {
|
|
703
716
|
parseError = String(error && error.message);
|
|
704
717
|
}
|
|
@@ -718,7 +731,13 @@ async function reportPhase({
|
|
|
718
731
|
quitReason: play.quitReason,
|
|
719
732
|
quitDetail: play.quitDetail
|
|
720
733
|
};
|
|
721
|
-
|
|
734
|
+
// An evidence path under the wrong key is moved to `ref` before anything
|
|
735
|
+
// judges the report; see normaliseEvidence.
|
|
736
|
+
const normalised = parsed ? normaliseEvidence(parsed, runDirectory) : null;
|
|
737
|
+
if (normalised && normalised.changed > 0) {
|
|
738
|
+
transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
|
|
739
|
+
}
|
|
740
|
+
const candidate = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
|
|
722
741
|
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
723
742
|
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
724
743
|
// The server lints the same four sections at `complete` and records the
|
|
@@ -740,31 +759,50 @@ async function reportPhase({
|
|
|
740
759
|
...evidenceProblems,
|
|
741
760
|
...missingSections.map(section => `report.md has no "${section}" section`)
|
|
742
761
|
];
|
|
743
|
-
|
|
762
|
+
|
|
763
|
+
// Every tool_use the next request echoes back needs its tool_result, or
|
|
764
|
+
// the API refuses the request. A write cut off at the token limit is
|
|
765
|
+
// malformed: it was not saved, and it is answered so.
|
|
766
|
+
const toolResults = answer.toolUses.map(toolUse => (toolUse.malformed
|
|
767
|
+
? { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: [{ type: 'text', text: CUT_OFF_WRITE }] }
|
|
768
|
+
: { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }));
|
|
769
|
+
|
|
770
|
+
if (jsonText === null && files.has(RUN_FILES.reportMd) && !continued) {
|
|
771
|
+
continued = true;
|
|
772
|
+
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
773
|
+
messages.push({ role: 'user', content: toolResults.concat([{ type: 'text', text: CONTINUE_WITH_JSON }]) });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (repaired) {
|
|
744
778
|
if (!candidate) throw new Error(`the report step did not produce a valid report.json: ${lint.join('; ')}`);
|
|
745
|
-
//
|
|
779
|
+
// Still imperfect after the repair: keep it, record the lint, and let
|
|
746
780
|
// the server have the final word. It validates the same schema and
|
|
747
781
|
// drops unevidenced findings itself, so this is a report with known
|
|
748
|
-
// problems rather than no report at all.
|
|
782
|
+
// problems rather than no report at all. Every problem on its own line,
|
|
783
|
+
// before anything is uploaded, so a refusal at complete is never the
|
|
784
|
+
// first the developer hears of it.
|
|
749
785
|
report = candidate;
|
|
750
|
-
log(`report.json still has problems after one repair
|
|
786
|
+
log(`report.json still has problems after one repair (${lint.length}); the server will refuse a report that fails its schema:\n${lint.map(line => ` - ${line}`).join('\n')}`);
|
|
751
787
|
break;
|
|
752
788
|
}
|
|
753
789
|
|
|
790
|
+
repaired = true;
|
|
754
791
|
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
755
792
|
messages.push({
|
|
756
793
|
role: 'user',
|
|
757
|
-
content:
|
|
758
|
-
.filter(toolUse => !toolUse.malformed)
|
|
759
|
-
.map(toolUse => ({ type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }))
|
|
760
|
-
.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
|
|
794
|
+
content: toolResults.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
|
|
761
795
|
});
|
|
762
796
|
}
|
|
763
797
|
|
|
764
798
|
if (!markdown) {
|
|
765
799
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
766
800
|
}
|
|
767
|
-
|
|
801
|
+
const generatedAt = new Date().toISOString();
|
|
802
|
+
const facts = reportFacts({ persona, driverName, target, play });
|
|
803
|
+
markdown = stampGeneratedAt(markdown, generatedAt, facts);
|
|
804
|
+
// The same slots in the prose the dashboard shows from report.json.
|
|
805
|
+
report = fillReportPlaceholders(report, { generatedAt, ...facts });
|
|
768
806
|
|
|
769
807
|
await writeReportFiles({ runDirectory, markdown, report });
|
|
770
808
|
return { report, markdown, lint };
|
|
@@ -895,10 +933,10 @@ function redactToolInput(toolUse) {
|
|
|
895
933
|
return rest;
|
|
896
934
|
}
|
|
897
935
|
|
|
898
|
-
async function safeScreenshot(driver, name, screenshots, transcript) {
|
|
936
|
+
async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
|
|
899
937
|
try {
|
|
900
938
|
const shot = await driver.screenshot(name);
|
|
901
|
-
screenshots.push({ ...shot, reason: name, order: nextOrder() });
|
|
939
|
+
screenshots.push({ ...shot, reason: name, ...(Number.isInteger(observation) ? { observation } : {}), order: nextOrder() });
|
|
902
940
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
|
|
903
941
|
return shot;
|
|
904
942
|
} catch (error) {
|
|
@@ -1083,14 +1121,14 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
|
|
|
1083
1121
|
play,
|
|
1084
1122
|
reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
|
|
1085
1123
|
});
|
|
1086
|
-
const stamped = stampGeneratedAt(markdown, new Date().toISOString());
|
|
1124
|
+
const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1087
1125
|
await writeReportFiles({ runDirectory, markdown: stamped, report });
|
|
1088
1126
|
outcome.report = report;
|
|
1089
1127
|
outcome.reportMarkdown = stamped;
|
|
1090
1128
|
}
|
|
1091
1129
|
|
|
1092
1130
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1093
|
-
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString());
|
|
1131
|
+
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1094
1132
|
assertNoSecrets('report.md', markdown);
|
|
1095
1133
|
await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
|
|
1096
1134
|
}
|
|
@@ -1116,6 +1154,28 @@ export function noteLine(note, i) {
|
|
|
1116
1154
|
return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
|
|
1117
1155
|
}
|
|
1118
1156
|
|
|
1157
|
+
/** The tool_result for a write_file call that was cut off before its input was complete. */
|
|
1158
|
+
export const CUT_OFF_WRITE = 'That write_file call was cut off before it finished, so nothing was saved. Write that file again, shorter if it has to be.';
|
|
1159
|
+
|
|
1160
|
+
/** The one follow-up a report turn gets for writing report.md and stopping. */
|
|
1161
|
+
export const CONTINUE_WITH_JSON = 'report.md is saved. Now write report.json with write_file. Every evidence entry is exactly {"type": ..., "ref": ...}; a screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}.';
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* A screenshot as one line of the report prompt: its path, the observation
|
|
1165
|
+
* it followed (what a blocker or major has to be checked against) and why
|
|
1166
|
+
* it was taken.
|
|
1167
|
+
*
|
|
1168
|
+
* @param {Object} shot
|
|
1169
|
+
* @returns {string}
|
|
1170
|
+
*/
|
|
1171
|
+
export function screenshotLine(shot) {
|
|
1172
|
+
const tags = [
|
|
1173
|
+
Number.isInteger(shot.observation) && shot.observation > 0 ? `after observation ${shot.observation}` : null,
|
|
1174
|
+
shot.reason ? String(shot.reason) : null
|
|
1175
|
+
].filter(Boolean);
|
|
1176
|
+
return `- ${shot.relativePath}${tags.length > 0 ? ` (${tags.join('; ')})` : ''}`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1119
1179
|
/** The most observations listed in the report prompt; the newest are kept. */
|
|
1120
1180
|
export const MAX_OBSERVATIONS_IN_REPORT = 200;
|
|
1121
1181
|
|
|
@@ -1216,13 +1276,32 @@ export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] }
|
|
|
1216
1276
|
* @param {string} generatedAt an ISO timestamp
|
|
1217
1277
|
* @returns {string}
|
|
1218
1278
|
*/
|
|
1219
|
-
export function stampGeneratedAt(markdown, generatedAt) {
|
|
1279
|
+
export function stampGeneratedAt(markdown, generatedAt, facts = {}) {
|
|
1220
1280
|
const line = `**Generated at:** ${generatedAt}`;
|
|
1221
1281
|
const lines = String(markdown || '').split('\n').filter(entry => !/^\*\*Generated at:\*\* /.test(entry));
|
|
1222
1282
|
const heading = lines.findIndex(entry => /^# /.test(entry));
|
|
1223
|
-
if (heading === -1)
|
|
1224
|
-
lines.splice(heading + 1, 0, line);
|
|
1225
|
-
|
|
1283
|
+
if (heading === -1) lines.unshift(line, '');
|
|
1284
|
+
else lines.splice(heading + 1, 0, line);
|
|
1285
|
+
// The template's own slots (`**Date:** <date>`) are filled from what the
|
|
1286
|
+
// runner knows, because the prompt forbids the model a calendar date and a
|
|
1287
|
+
// model that obeys both writes the placeholder itself.
|
|
1288
|
+
return fillPlaceholders(lines.join('\n'), { generatedAt, ...facts });
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* What the runner knows about a run, for the template slots a report left
|
|
1293
|
+
* unfilled.
|
|
1294
|
+
*
|
|
1295
|
+
* @returns {{driver: string, target: string, persona: string, actionsTaken: number, quitReason: string}}
|
|
1296
|
+
*/
|
|
1297
|
+
function reportFacts({ persona, driverName, target, play }) {
|
|
1298
|
+
return {
|
|
1299
|
+
driver: driverName,
|
|
1300
|
+
target,
|
|
1301
|
+
persona: persona ? persona.name || persona.slug : undefined,
|
|
1302
|
+
actionsTaken: play ? play.actionsTaken : undefined,
|
|
1303
|
+
quitReason: play ? play.quitReason : undefined
|
|
1304
|
+
};
|
|
1226
1305
|
}
|
|
1227
1306
|
|
|
1228
1307
|
/**
|