testronaut 1.2.3 β 1.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 +1 -1
- package/bin/cli.js +88 -14
- package/bin/init.js +2 -1
- package/bin/initHelpers.js +1 -0
- package/core/agent.js +41 -2
- package/core/turnLoop.js +35 -10
- package/package.json +1 -1
- package/tools/tokenControl.js +1 -0
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ Current supported providers:
|
|
|
127
127
|
|
|
128
128
|
| Provider | Example Models |
|
|
129
129
|
|-----------|----------------|
|
|
130
|
-
| **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, etc. |
|
|
130
|
+
| **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, gpt-5.1, etc. |
|
|
131
131
|
| **Google Gemini** | gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-8b |
|
|
132
132
|
|
|
133
133
|
More providers coming soon (Anthropic, Mistral, etc.).
|
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)
|
|
@@ -753,16 +810,22 @@ async function serveLatestReport() {
|
|
|
753
810
|
await new Promise((resolve, reject) => {
|
|
754
811
|
server.once('error', reject);
|
|
755
812
|
server.listen(0, '127.0.0.1', resolve);
|
|
756
|
-
|
|
757
|
-
process.on('SIGINT', () => {
|
|
758
|
-
console.log('\nπ Shutting downβ¦');
|
|
759
|
-
server.close(() => {
|
|
760
|
-
resolve();
|
|
761
|
-
process.exit(0);
|
|
762
|
-
});
|
|
763
|
-
});
|
|
764
813
|
});
|
|
765
814
|
|
|
815
|
+
// Move signal handling to after server is listening
|
|
816
|
+
const shutdown = () => {
|
|
817
|
+
console.log('\nπ Shutting downβ¦');
|
|
818
|
+
try {
|
|
819
|
+
server.close(); // best-effort; donβt wait for it
|
|
820
|
+
} catch (_) {
|
|
821
|
+
// ignore
|
|
822
|
+
}
|
|
823
|
+
process.exit(0);
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
process.once('SIGINT', shutdown);
|
|
827
|
+
process.once('SIGTERM', shutdown);
|
|
828
|
+
|
|
766
829
|
const address = server.address();
|
|
767
830
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
768
831
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
@@ -775,3 +838,14 @@ async function serveLatestReport() {
|
|
|
775
838
|
// Best-effort auto-open
|
|
776
839
|
await openInBrowser(reportUrl).catch(() => {});
|
|
777
840
|
}
|
|
841
|
+
|
|
842
|
+
try {
|
|
843
|
+
if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
|
|
844
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
845
|
+
console.log('π§Ή Cleaned up temporary files.');
|
|
846
|
+
} else {
|
|
847
|
+
console.log('β οΈ Skipped tmp cleanup (TN_KEEP_TMP set).');
|
|
848
|
+
}
|
|
849
|
+
} catch (err) {
|
|
850
|
+
console.warn(`β οΈ Could not remove tmp folder: ${err.message}`);
|
|
851
|
+
}
|
package/bin/init.js
CHANGED
|
@@ -94,9 +94,10 @@ export async function initializeTestronautProject() {
|
|
|
94
94
|
{ title: 'o3 (reasoning w/ native tool use)', value: 'o3' },
|
|
95
95
|
{ title: 'o4-mini (reasoning, cost-effective)', value: 'o4-mini' },
|
|
96
96
|
// GPT-5 family (availability varies)
|
|
97
|
-
{ title: 'GPT-5 (
|
|
97
|
+
{ title: 'GPT-5 (previous reasoning model)', value: 'gpt-5' },
|
|
98
98
|
{ title: 'GPT-5 mini (faster)', value: 'gpt-5-mini' },
|
|
99
99
|
{ title: 'GPT-5 nano (lightweight)', value: 'gpt-5-nano' },
|
|
100
|
+
{ title: 'GPT-5.1 (latest reasoning model)', value: 'gpt-5.1' },
|
|
100
101
|
],
|
|
101
102
|
// Keep prior selection highlighted if present; fallback to 4.1-mini.
|
|
102
103
|
initial: pickInitialIndex(models, config.model, 'gpt-4.1-mini')
|
package/bin/initHelpers.js
CHANGED
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,14 +172,20 @@ 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 })) {
|
|
170
185
|
console.error('β Tool call structure invalid and no placeholders inserted.');
|
|
171
186
|
step.events.push('β Tool call structure invalid and no placeholders inserted.');
|
|
172
187
|
step.result = 'β Failure';
|
|
173
|
-
|
|
188
|
+
recordStep(step);
|
|
174
189
|
return steps;
|
|
175
190
|
}
|
|
176
191
|
|
|
@@ -190,7 +205,7 @@ export const turnLoop = async (
|
|
|
190
205
|
console.dir(unrespondedCalls, { depth: 5 });
|
|
191
206
|
step.events.push('π Detected assistant tool calls without matching tool responses');
|
|
192
207
|
step.result = 'β Failure';
|
|
193
|
-
|
|
208
|
+
recordStep(step);
|
|
194
209
|
return steps;
|
|
195
210
|
}
|
|
196
211
|
|
|
@@ -212,21 +227,28 @@ export const turnLoop = async (
|
|
|
212
227
|
try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
|
|
213
228
|
const delay = Math.min(60000, 2 ** retryCount * 2000);
|
|
214
229
|
console.warn(`β οΈ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
|
|
230
|
+
step.events.push(`β³ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
|
|
231
|
+
// step.result = 'π‘ In Progress';
|
|
232
|
+
recordStep(step); // β
persist this step before sleeping
|
|
215
233
|
await wait(delay);
|
|
216
234
|
|
|
217
235
|
if (retryCount >= 5) {
|
|
218
236
|
console.error('β Too many retries. Exiting.');
|
|
219
237
|
step.events.push('β Too many retries. Exiting.');
|
|
220
238
|
step.result = 'β Failure';
|
|
221
|
-
|
|
239
|
+
recordStep(step);
|
|
222
240
|
return steps;
|
|
223
241
|
}
|
|
224
|
-
return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
242
|
+
// return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
243
|
+
// reattempt same turn after delay; keep same loop, bump retryCount
|
|
244
|
+
retryCount += 1;
|
|
245
|
+
turn -= 1;
|
|
246
|
+
continue;
|
|
225
247
|
} else if (err.status === 400) {
|
|
226
248
|
console.error('β Bad request:', err.message);
|
|
227
249
|
step.events.push(`β Bad request: ${err.message}`);
|
|
228
250
|
step.result = 'β Failure';
|
|
229
|
-
|
|
251
|
+
recordStep(step);
|
|
230
252
|
return steps;
|
|
231
253
|
} else {
|
|
232
254
|
throw err;
|
|
@@ -296,7 +318,7 @@ export const turnLoop = async (
|
|
|
296
318
|
} catch { /* non-JSON results ignored */ }
|
|
297
319
|
|
|
298
320
|
console.log(`[tool ] β ${fnName} result:`, errorMessage ? 'β Failed' : 'β
Success');
|
|
299
|
-
const truncated = result.length >
|
|
321
|
+
const truncated = result.length > 1000 ? result.slice(0, 1000) + 'β¦' : result;
|
|
300
322
|
step.events.push(`[tool ] β ${fnName} result: ${errorMessage ? 'β Failed' : 'β
Success'}`);
|
|
301
323
|
step.events.push(`[tool ] β ${truncated}`);
|
|
302
324
|
|
|
@@ -339,7 +361,7 @@ export const turnLoop = async (
|
|
|
339
361
|
// Merge new assistant + tool responses back into conversation
|
|
340
362
|
messages.push(msg, ...toolResponses);
|
|
341
363
|
step.result = 'β
Passed';
|
|
342
|
-
|
|
364
|
+
recordStep(step);
|
|
343
365
|
continue;
|
|
344
366
|
}
|
|
345
367
|
|
|
@@ -350,7 +372,7 @@ export const turnLoop = async (
|
|
|
350
372
|
if (finalResponse !== null) {
|
|
351
373
|
step.events.push(finalResponse.finalMessage);
|
|
352
374
|
step.result = finalResponse.success ? 'β
Mission Success' : 'β Mission Failure';
|
|
353
|
-
|
|
375
|
+
recordStep(step);
|
|
354
376
|
return { success: finalResponse.finalMessage, steps };
|
|
355
377
|
}
|
|
356
378
|
|
|
@@ -362,5 +384,8 @@ export const turnLoop = async (
|
|
|
362
384
|
});
|
|
363
385
|
console.log(`[auto] β Injected DOM for next reasoning step`);
|
|
364
386
|
step.events.push(`[auto] β Injected DOM for next reasoning step`);
|
|
387
|
+
// This is a meaningful turn even without tool calls β record it.
|
|
388
|
+
step.result = step.result || 'π‘ In Progress';
|
|
389
|
+
recordStep(step);
|
|
365
390
|
}
|
|
366
391
|
};
|
package/package.json
CHANGED
package/tools/tokenControl.js
CHANGED
|
@@ -35,6 +35,7 @@ import { wait } from './turnLoopUtils.js';
|
|
|
35
35
|
*/
|
|
36
36
|
const DEFAULT_LIMITS = [
|
|
37
37
|
// ββ OpenAI (newer first) ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
38
|
+
{ test: /^gpt-5.1(-|$)/i, tpm: 120000 },
|
|
38
39
|
{ test: /^gpt-5(-|$)/i, tpm: 90000 },
|
|
39
40
|
{ test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
|
|
40
41
|
{ test: /^gpt-5-nano(-|$)/i, tpm: 600000 },
|