testronaut 1.2.3 β 1.2.4
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/bin/cli.js +74 -6
- package/core/agent.js +41 -2
- package/core/turnLoop.js +35 -4
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -51,7 +51,7 @@ import { ensureBrowsers } from '../tools/playwrightSetup.js';
|
|
|
51
51
|
// Keep PW browsers inside the project to avoid global cache skew
|
|
52
52
|
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
const TMP_DIR = path.resolve('./missions/tmp');
|
|
55
55
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
56
56
|
|
|
57
57
|
// const apiBase = `http://localhost:3002` //
|
|
@@ -60,6 +60,52 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
|
|
|
60
60
|
|
|
61
61
|
const args = process.argv.slice(2);
|
|
62
62
|
|
|
63
|
+
function readJsonlSteps(p) {
|
|
64
|
+
if (!p || !fs.existsSync(p)) return null;
|
|
65
|
+
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
66
|
+
const parsed = [];
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
try { parsed.push(JSON.parse(line)); } catch { /* ignore bad lines */ }
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Merge duplicate turns without losing information.
|
|
75
|
+
* Strategy:
|
|
76
|
+
* - Keep the latest **non-empty** version for a given turn if both exist
|
|
77
|
+
* - If both are non-empty, keep the later one (last write wins)
|
|
78
|
+
* - Preserve original order by line index as a tiebreaker
|
|
79
|
+
*/
|
|
80
|
+
function mergeDuplicateTurns(steps) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const byTurn = new Map(); // turn -> index in out
|
|
83
|
+
for (const s of steps) {
|
|
84
|
+
const turn = Number.isFinite(s.turn) ? s.turn : out.length;
|
|
85
|
+
const hasEvents = Array.isArray(s.events) && s.events.length > 0;
|
|
86
|
+
if (!byTurn.has(turn)) {
|
|
87
|
+
out.push(s);
|
|
88
|
+
byTurn.set(turn, out.length - 1);
|
|
89
|
+
} else {
|
|
90
|
+
const idx = byTurn.get(turn);
|
|
91
|
+
const existing = out[idx];
|
|
92
|
+
const existingHasEvents = Array.isArray(existing.events) && existing.events.length > 0;
|
|
93
|
+
// Prefer the one with events; if both have events, prefer the newer (s)
|
|
94
|
+
if (!existingHasEvents && hasEvents) {
|
|
95
|
+
out[idx] = s;
|
|
96
|
+
} else if (existingHasEvents && hasEvents) {
|
|
97
|
+
out[idx] = s;
|
|
98
|
+
} else {
|
|
99
|
+
// both empty or both sparse β keep latest
|
|
100
|
+
out[idx] = s;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// stable sort by `turn` ascending, then by original order via array index already preserved
|
|
105
|
+
out.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0));
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
63
109
|
// Look for --model=<id> or --model <id>
|
|
64
110
|
let modelOverride;
|
|
65
111
|
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
@@ -236,11 +282,22 @@ const endTime = new Date();
|
|
|
236
282
|
|
|
237
283
|
const flatMissions = allResults.flatMap(entry => {
|
|
238
284
|
const result = entry.result;
|
|
239
|
-
const missions = Array.isArray(result) ? result : [result];
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
285
|
+
const missions = Array.isArray(result) ? result : [result];
|
|
286
|
+
|
|
287
|
+
return missions.map(m => {
|
|
288
|
+
let steps = m.steps;
|
|
289
|
+
if (m.stepFile && fs.existsSync(m.stepFile)) {
|
|
290
|
+
const fromJsonl = readJsonlSteps(m.stepFile);
|
|
291
|
+
if (fromJsonl && fromJsonl.length) {
|
|
292
|
+
steps = mergeDuplicateTurns(fromJsonl);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
...m,
|
|
297
|
+
steps,
|
|
298
|
+
file: entry.file,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
244
301
|
});
|
|
245
302
|
|
|
246
303
|
// Read provider/model from config (allow env override for model)
|
|
@@ -775,3 +832,14 @@ async function serveLatestReport() {
|
|
|
775
832
|
// Best-effort auto-open
|
|
776
833
|
await openInBrowser(reportUrl).catch(() => {});
|
|
777
834
|
}
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
|
|
838
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
839
|
+
console.log('π§Ή Cleaned up temporary files.');
|
|
840
|
+
} else {
|
|
841
|
+
console.log('β οΈ Skipped tmp cleanup (TN_KEEP_TMP set).');
|
|
842
|
+
}
|
|
843
|
+
} catch (err) {
|
|
844
|
+
console.warn(`β οΈ Could not remove tmp folder: ${err.message}`);
|
|
845
|
+
}
|
package/core/agent.js
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
import 'dotenv/config';
|
|
26
26
|
import { ChromeBrowser } from '../tools/chromeBrowser.js';
|
|
27
27
|
import { turnLoop } from './turnLoop.js';
|
|
28
|
+
import fs from 'fs';
|
|
29
|
+
import path from 'path';
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Execute goals with a browser agent.
|
|
@@ -41,9 +43,18 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
|
41
43
|
|
|
42
44
|
try {
|
|
43
45
|
const missionResults = [];
|
|
46
|
+
const tmpDir = path.resolve(process.cwd(), 'missions/tmp');
|
|
47
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
44
48
|
|
|
45
49
|
for (const goal of goals) {
|
|
46
50
|
const steps = [];
|
|
51
|
+
// Unique-ish JSONL file for this missionβs steps
|
|
52
|
+
const stepFile = path.join(
|
|
53
|
+
tmpDir,
|
|
54
|
+
`${missionName.replace(/[^\w.-]+/g, '_')}_${Date.now()}_steps.jsonl`
|
|
55
|
+
);
|
|
56
|
+
// Start clean
|
|
57
|
+
fs.writeFileSync(stepFile, '');
|
|
47
58
|
|
|
48
59
|
// Ensure user message is a string (functions/objects β toString fallback)
|
|
49
60
|
const userContent =
|
|
@@ -78,16 +89,44 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
|
78
89
|
0, // currentTurn
|
|
79
90
|
0, // retryCount
|
|
80
91
|
null, // currentStep
|
|
81
|
-
{
|
|
92
|
+
{
|
|
93
|
+
steps,
|
|
94
|
+
missionName,
|
|
95
|
+
onStep: (s) => {
|
|
96
|
+
// Append each step as a JSON line
|
|
97
|
+
try {
|
|
98
|
+
fs.appendFileSync(stepFile, JSON.stringify(s) + '\n');
|
|
99
|
+
// Keep memory in check: retain only the last ~20 steps in RAM
|
|
100
|
+
if (steps.length > 20) steps.splice(0, steps.length - 20);
|
|
101
|
+
} catch {} // best-effort; donβt crash the agent
|
|
102
|
+
}
|
|
103
|
+
}
|
|
82
104
|
);
|
|
83
105
|
|
|
106
|
+
function dedupeSteps(steps) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const s of steps) {
|
|
110
|
+
const first = s?.events?.[0] || '';
|
|
111
|
+
const key = `${s.turn}::${s.summary}::${first}`;
|
|
112
|
+
if (!seen.has(key)) {
|
|
113
|
+
seen.add(key);
|
|
114
|
+
out.push(s);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const compact = dedupeSteps(steps);
|
|
121
|
+
|
|
84
122
|
// Snapshot steps (avoid retaining references to mutable arrays)
|
|
85
123
|
missionResults.push({
|
|
86
124
|
missionName,
|
|
87
125
|
submissionType: goal.submissionType || goal.label,
|
|
88
126
|
submissionName: goal.submissionName || null,
|
|
89
127
|
status: result?.success ? 'passed' : 'failed',
|
|
90
|
-
steps: JSON.parse(JSON.stringify(
|
|
128
|
+
steps: JSON.parse(JSON.stringify(compact)), // last 20 (by memory design)
|
|
129
|
+
stepFile, // full history is in this JSONL (one step per line)
|
|
91
130
|
startTime: Date.now() - 1, // TODO: wire actual timings if needed
|
|
92
131
|
endTime: Date.now(),
|
|
93
132
|
});
|
package/core/turnLoop.js
CHANGED
|
@@ -60,7 +60,7 @@ const llm = getLLM(PROVIDER_ID);
|
|
|
60
60
|
let totalTokensUsed = 0;
|
|
61
61
|
let turnTimestamps = [];
|
|
62
62
|
let shouldBackoff;
|
|
63
|
-
let steps = [];
|
|
63
|
+
// let steps = [];
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* Utility: format byte counts into human-readable strings.
|
|
@@ -148,6 +148,15 @@ export const turnLoop = async (
|
|
|
148
148
|
const { steps = [], missionName } = ctx;
|
|
149
149
|
let agentMemory = { lastMenuExpanded: false };
|
|
150
150
|
|
|
151
|
+
// Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
|
|
152
|
+
// Idempotent recorder β prevents accidental double-push
|
|
153
|
+
const recordStep = (step) => {
|
|
154
|
+
if (!step || step.__recorded) return;
|
|
155
|
+
step.__recorded = true;
|
|
156
|
+
try { steps.push(step); } catch {}
|
|
157
|
+
try { ctx?.onStep?.(step); } catch {}
|
|
158
|
+
};
|
|
159
|
+
|
|
151
160
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
152
161
|
// STEP 1: Begin reasoning cycle
|
|
153
162
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -163,7 +172,13 @@ export const turnLoop = async (
|
|
|
163
172
|
// Adaptive cooldown if nearing provider rate limit
|
|
164
173
|
({ totalTokensUsed, turnTimestamps, shouldBackoff } =
|
|
165
174
|
await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
|
|
166
|
-
if (shouldBackoff)
|
|
175
|
+
if (shouldBackoff) {
|
|
176
|
+
// step.result = 'π‘ In Progress';
|
|
177
|
+
recordStep(step); // β
write the partial step before sleeping
|
|
178
|
+
turn -= 1; // retry same turn index
|
|
179
|
+
continue;
|
|
180
|
+
// return await turnLoop(browser, messages, maxTurns, turn, currentStep);
|
|
181
|
+
}
|
|
167
182
|
|
|
168
183
|
// Verify message structure integrity before requesting next model step
|
|
169
184
|
if (!validateAndInsertMissingToolResponses(messages, { insertPlaceholders: true })) {
|
|
@@ -171,6 +186,7 @@ export const turnLoop = async (
|
|
|
171
186
|
step.events.push('β Tool call structure invalid and no placeholders inserted.');
|
|
172
187
|
step.result = 'β Failure';
|
|
173
188
|
steps.push(step);
|
|
189
|
+
recordStep(step);
|
|
174
190
|
return steps;
|
|
175
191
|
}
|
|
176
192
|
|
|
@@ -191,6 +207,7 @@ export const turnLoop = async (
|
|
|
191
207
|
step.events.push('π Detected assistant tool calls without matching tool responses');
|
|
192
208
|
step.result = 'β Failure';
|
|
193
209
|
steps.push(step);
|
|
210
|
+
recordStep(step);
|
|
194
211
|
return steps;
|
|
195
212
|
}
|
|
196
213
|
|
|
@@ -212,6 +229,9 @@ export const turnLoop = async (
|
|
|
212
229
|
try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
|
|
213
230
|
const delay = Math.min(60000, 2 ** retryCount * 2000);
|
|
214
231
|
console.warn(`β οΈ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
|
|
232
|
+
step.events.push(`β³ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
|
|
233
|
+
// step.result = 'π‘ In Progress';
|
|
234
|
+
recordStep(step); // β
persist this step before sleeping
|
|
215
235
|
await wait(delay);
|
|
216
236
|
|
|
217
237
|
if (retryCount >= 5) {
|
|
@@ -219,14 +239,20 @@ export const turnLoop = async (
|
|
|
219
239
|
step.events.push('β Too many retries. Exiting.');
|
|
220
240
|
step.result = 'β Failure';
|
|
221
241
|
steps.push(step);
|
|
242
|
+
recordStep(step);
|
|
222
243
|
return steps;
|
|
223
244
|
}
|
|
224
|
-
return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
245
|
+
// return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
246
|
+
// reattempt same turn after delay; keep same loop, bump retryCount
|
|
247
|
+
retryCount += 1;
|
|
248
|
+
turn -= 1;
|
|
249
|
+
continue;
|
|
225
250
|
} else if (err.status === 400) {
|
|
226
251
|
console.error('β Bad request:', err.message);
|
|
227
252
|
step.events.push(`β Bad request: ${err.message}`);
|
|
228
253
|
step.result = 'β Failure';
|
|
229
254
|
steps.push(step);
|
|
255
|
+
recordStep(step);
|
|
230
256
|
return steps;
|
|
231
257
|
} else {
|
|
232
258
|
throw err;
|
|
@@ -296,7 +322,7 @@ export const turnLoop = async (
|
|
|
296
322
|
} catch { /* non-JSON results ignored */ }
|
|
297
323
|
|
|
298
324
|
console.log(`[tool ] β ${fnName} result:`, errorMessage ? 'β Failed' : 'β
Success');
|
|
299
|
-
const truncated = result.length >
|
|
325
|
+
const truncated = result.length > 1000 ? result.slice(0, 1000) + 'β¦' : result;
|
|
300
326
|
step.events.push(`[tool ] β ${fnName} result: ${errorMessage ? 'β Failed' : 'β
Success'}`);
|
|
301
327
|
step.events.push(`[tool ] β ${truncated}`);
|
|
302
328
|
|
|
@@ -340,6 +366,7 @@ export const turnLoop = async (
|
|
|
340
366
|
messages.push(msg, ...toolResponses);
|
|
341
367
|
step.result = 'β
Passed';
|
|
342
368
|
steps.push(step);
|
|
369
|
+
recordStep(step);
|
|
343
370
|
continue;
|
|
344
371
|
}
|
|
345
372
|
|
|
@@ -351,6 +378,7 @@ export const turnLoop = async (
|
|
|
351
378
|
step.events.push(finalResponse.finalMessage);
|
|
352
379
|
step.result = finalResponse.success ? 'β
Mission Success' : 'β Mission Failure';
|
|
353
380
|
steps.push(step);
|
|
381
|
+
recordStep(step);
|
|
354
382
|
return { success: finalResponse.finalMessage, steps };
|
|
355
383
|
}
|
|
356
384
|
|
|
@@ -362,5 +390,8 @@ export const turnLoop = async (
|
|
|
362
390
|
});
|
|
363
391
|
console.log(`[auto] β Injected DOM for next reasoning step`);
|
|
364
392
|
step.events.push(`[auto] β Injected DOM for next reasoning step`);
|
|
393
|
+
// This is a meaningful turn even without tool calls β record it.
|
|
394
|
+
step.result = step.result || 'π‘ In Progress';
|
|
395
|
+
recordStep(step);
|
|
365
396
|
}
|
|
366
397
|
};
|