testronaut 1.0.27 → 1.1.1

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 CHANGED
@@ -26,6 +26,27 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
26
26
 
27
27
 
28
28
  const args = process.argv.slice(2);
29
+
30
+ // Look for --model=<id> or --model <id>
31
+ let modelOverride;
32
+ const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
33
+ if (modelFlagIndex >= 0) {
34
+ if (args[modelFlagIndex].includes('=')) {
35
+ modelOverride = args[modelFlagIndex].split('=')[1];
36
+ } else if (args[modelFlagIndex + 1]) {
37
+ modelOverride = args[modelFlagIndex + 1];
38
+ }
39
+
40
+ if (modelOverride) {
41
+ process.env.TESTRONAUT_MODEL = modelOverride.trim();
42
+ console.log(`🧠 Model override: ${process.env.TESTRONAUT_MODEL}`);
43
+ }
44
+
45
+ // Remove flag & value from args so they don't look like mission filenames
46
+ args.splice(modelFlagIndex, modelOverride ? 2 : 1);
47
+ }
48
+
49
+
29
50
  const missionsDir = path.resolve(process.cwd(), 'missions');
30
51
 
31
52
  const allResults = [];
@@ -165,10 +186,35 @@ const flatMissions = allResults.flatMap(entry => {
165
186
  }));
166
187
  });
167
188
 
189
+ // Read provider/model from config (allow env override for model)
190
+ const cfgPath = path.resolve(process.cwd(), 'testronaut-config.json');
191
+ let llmProvider = 'openai';
192
+ let llmModel = 'gpt-4o';
193
+
194
+ if (fs.existsSync(cfgPath)) {
195
+ try {
196
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
197
+ if (cfg?.provider) llmProvider = String(cfg.provider);
198
+ if (cfg?.model) llmModel = String(cfg.model);
199
+ } catch (e) {
200
+ console.warn('⚠️ Could not read testronaut-config.json:', e.message);
201
+ }
202
+ }
203
+
204
+ // Env override takes precedence for the model (matches runtime behavior)
205
+ if (process.env.TESTRONAUT_MODEL?.trim()) {
206
+ llmModel = process.env.TESTRONAUT_MODEL.trim();
207
+ }
208
+
209
+
168
210
  const report = {
169
211
  runId,
170
212
  startTime: startTime.toISOString(),
171
213
  endTime: endTime.toISOString(),
214
+ llm: {
215
+ provider: llmProvider,
216
+ model: llmModel,
217
+ },
172
218
  summary: {
173
219
  totalMissions: flatMissions.length,
174
220
  passed: flatMissions.filter(m => m.status === 'passed').length,
package/bin/init.js CHANGED
@@ -13,33 +13,76 @@ export async function initializeTestronautProject() {
13
13
  let config = {};
14
14
  if (fs.existsSync(configPath)) {
15
15
  config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
16
+
17
+ // --- Back-compat migration: old configs stored provider in `model`
18
+ if (!config.provider && config.model === 'openai') {
19
+ config.provider = 'openai';
20
+ // don't overwrite model yet; we'll re-ask below only if not initialized
21
+ }
16
22
  }
17
23
 
18
24
  // If not initialized before, walk through setup
19
25
  if (!config.initialized) {
20
- // Prompt for LLM
21
- const response = await prompts({
26
+ // 1) Provider
27
+ const { llmProvider } = await prompts({
22
28
  type: 'select',
23
29
  name: 'llmProvider',
24
30
  message: 'Which LLM provider would you like to use?',
25
31
  choices: [
26
- { title: 'OpenAI (GPT-4o)', value: 'openai' },
32
+ { title: 'OpenAI (GPT family)', value: 'openai' },
27
33
  // Add others like Anthropic, Mistral, etc. later
28
34
  ]
29
35
  });
30
36
 
31
- config.model = response.llmProvider;
37
+ config.provider = llmProvider; // store provider explicitly
38
+
39
+ // 2) If OpenAI, ask for model
40
+ const { openaiModel } = await prompts({
41
+ type: 'select',
42
+ name: 'openaiModel',
43
+ message: 'Select an OpenAI model for agentic workflows (function/tool calling):',
44
+ choices: [
45
+ { title: 'GPT-4.1 (general purpose)', value: 'gpt-4.1' },
46
+ { title: 'GPT-4.1 mini (faster, cheaper)', value: 'gpt-4.1-mini' },
47
+ { title: 'GPT-4o (multimodal, tool use)', value: 'gpt-4o' },
48
+ { title: 'GPT-4o mini (speed/cost optimized)', value: 'gpt-4o-mini' },
49
+ { title: 'o3 (reasoning w/ native tool use)', value: 'o3' },
50
+ { title: 'o4-mini (reasoning, cost-effective)', value: 'o4-mini' },
51
+
52
+ // 👇 GPT-5 family
53
+ { title: 'GPT-5 (latest reasoning model)', value: 'gpt-5' },
54
+ { title: 'GPT-5 mini (faster)', value: 'gpt-5-mini' },
55
+ { title: 'GPT-5 nano (lightweight)', value: 'gpt-5-nano' },
56
+ ],
57
+ initial: 1
58
+ });
59
+
60
+ config.model = openaiModel;
61
+
62
+ // Warn about GPT-5 availability
63
+ if (openaiModel.startsWith('gpt-5')) {
64
+ console.log(`
65
+ ⚠️ Warning: GPT-5 support may not be available for all users.
66
+ - Access depends on your OpenAI account/region
67
+ - Some capabilities may be gated or rate-limited
68
+ `);
69
+ }
70
+
71
+ // Default config fields
32
72
  config.initialized = true;
33
- config.outputDir = 'missions/mission_reports';
34
- config.projectName = path.basename(root);
35
- config.maxTurns = 20;
73
+ config.outputDir = config.outputDir || 'missions/mission_reports';
74
+ config.projectName = config.projectName || path.basename(root);
75
+ config.maxTurns = config.maxTurns ?? 20;
76
+
36
77
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
37
78
  console.log('📝 Created testronaut-config.json');
38
79
 
39
- if (response.llmProvider === 'openai') {
40
- // Display .env instructions
80
+ if (llmProvider === 'openai') {
81
+ // Display .env instructions for OpenAI
41
82
  if (!fs.existsSync(envPath)) {
42
- const exampleEnv = `# Add your OpenAI API key below\nOPENAI_API_KEY=sk-...\n`;
83
+ const exampleEnv = `# Add your OpenAI API key below
84
+ OPENAI_API_KEY=sk-...
85
+ `;
43
86
  fs.writeFileSync(envPath, exampleEnv);
44
87
  console.log('🔐 Created .env file');
45
88
  }
@@ -51,7 +94,7 @@ export async function initializeTestronautProject() {
51
94
 
52
95
  OPENAI_API_KEY=sk-...
53
96
 
54
- 📌 The key must have permissions to call the OpenAI Chat Completions API.
97
+ 📌 The key must have permissions to call the OpenAI API.
55
98
 
56
99
  ✅ You’re now ready to write missions in \`missions/\` and run your first mission using:
57
100
 
@@ -0,0 +1,79 @@
1
+ // core/redaction.js
2
+
3
+ function clone(obj) {
4
+ try { return structuredClone(obj); } catch { return JSON.parse(JSON.stringify(obj ?? {})); }
5
+ }
6
+
7
+ export function maskPreview(val, showLength = true) {
8
+ const s = String(val ?? '');
9
+ if (!s) return '••••••';
10
+ // Show length for debugging without leaking the value
11
+ return showLength ? `•••••• (${s.length})` : '••••••';
12
+ }
13
+
14
+ function hasPasswordHint(str = '') {
15
+ return /\b(pass(word)?|pwd|passwd)\b/i.test(String(str || ''));
16
+ }
17
+
18
+ function isPasswordCall(fnName, args = {}) {
19
+ if (!(fnName === 'type' || fnName === 'fill')) return false;
20
+ const { selector, label, placeholder, name, inputType, role, testId } = args;
21
+ return (
22
+ hasPasswordHint(selector) ||
23
+ hasPasswordHint(label) ||
24
+ hasPasswordHint(placeholder) ||
25
+ hasPasswordHint(name) ||
26
+ String(inputType || '').toLowerCase() === 'password' ||
27
+ hasPasswordHint(role) ||
28
+ hasPasswordHint(testId)
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Redact ONLY password values in tool args:
34
+ * - Applies to type/fill on password-looking fields
35
+ * - Also masks any arg property explicitly named password/pwd/passwd (shallow)
36
+ */
37
+ export function redactArgs(fnName, args = {}, { showLength = true } = {}) {
38
+ const out = clone(args);
39
+
40
+ // Mask direct props named password/pwd/passwd if present
41
+ for (const k of Object.keys(out || {})) {
42
+ if (hasPasswordHint(k)) {
43
+ out[k] = maskPreview(out[k], showLength);
44
+ }
45
+ }
46
+
47
+ // For typing into a password field, mask the value
48
+ if (isPasswordCall(fnName, args)) {
49
+ if ('text' in out) out.text = maskPreview(out.text, showLength);
50
+ if ('value' in out) out.value = maskPreview(out.value, showLength);
51
+ if ('input' in out) out.input = maskPreview(out.input, showLength);
52
+ if ('keys' in out) out.keys = maskPreview(out.keys, showLength);
53
+ }
54
+
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Redact password literals in freeform mission text.
60
+ * Heuristic: look for the word "password" then the next quoted string OR
61
+ * a token after "with|as|to|=" within ~80 chars.
62
+ */
63
+ export function redactPasswordInText(text = '', { showLength = true } = {}) {
64
+ let s = String(text ?? '');
65
+
66
+ // Case 1: password ... "VALUE"
67
+ s = s.replace(
68
+ /(\bpassword\b[^"'\\\n]{0,80}["'])([^"']+)(["'])/gi,
69
+ (_m, pre, pwd, post) => pre + maskPreview(pwd, showLength) + post
70
+ );
71
+
72
+ // Case 2: password ... (with|as|to|=) VALUE
73
+ s = s.replace(
74
+ /(\bpassword\b[^.\n]{0,80}?\b(with|as|to|=)\s*)([^\s"'`]+)/gi,
75
+ (_m, pre, _kw, pwd) => pre + maskPreview(pwd, showLength)
76
+ );
77
+
78
+ return s;
79
+ }
@@ -0,0 +1,64 @@
1
+ import { redactArgs } from './redaction.js';
2
+
3
+ export function truncateMiddle(str = '', max = 80) {
4
+ if (!str || str.length <= max) return str;
5
+ const half = Math.floor((max - 1) / 2);
6
+ return `${str.slice(0, half)}…${str.slice(-half)}`;
7
+ }
8
+
9
+ function pickArg(args, keys) {
10
+ for (const k of keys) {
11
+ if (args?.[k] != null && String(args[k]).trim() !== '') return String(args[k]);
12
+ }
13
+ return null;
14
+ }
15
+
16
+ export function summarizeToolCall(fnName, args = {}) {
17
+ const safeArgs = redactArgs(fnName, args);
18
+
19
+ const selector = pickArg(safeArgs, ['selector','xpath','css','role','testId']);
20
+ const text = pickArg(safeArgs, ['text','value','keys','input','query','label']);
21
+ const url = pickArg(safeArgs, ['url','href','to']);
22
+ const file = pickArg(safeArgs, ['filePath','fileName','downloadPath']);
23
+ const x = pickArg(safeArgs, ['x']);
24
+ const y = pickArg(safeArgs, ['y']);
25
+ const timeout = pickArg(safeArgs, ['timeoutMs','timeout']);
26
+
27
+ const sel = selector ? ` (${truncateMiddle(selector, 60)})` : '';
28
+ const val = text ? ` "${truncateMiddle(text, 60)}"` : '';
29
+ const tmo = timeout ? ` [≤${timeout}ms]` : '';
30
+
31
+ switch (fnName) {
32
+ case 'navigate': return `Navigate to ${truncateMiddle(url || '(no url)', 80)}`;
33
+ case 'click_text': return `Click text${val}`;
34
+ case 'click': return `Click element${sel}`;
35
+ case 'type':
36
+ case 'fill': return `Type${val}${selector ? ` into ${truncateMiddle(selector, 60)}` : ''}`;
37
+ case 'press': return `Press key${val || ''}${selector ? ` in ${truncateMiddle(selector, 60)}` : ''}`;
38
+ case 'hover': return `Hover over${sel || val || ''}`;
39
+ case 'wait_for_selector': return `Wait for${sel}${tmo}`;
40
+ case 'wait': return `Wait${tmo || ''}`;
41
+ case 'screenshot': return `Capture screenshot${selector ? ` of ${truncateMiddle(selector, 60)}` : ''}`;
42
+ case 'get_dom': return `Inspect DOM (focused extract)`;
43
+ case 'check_text': return `Check page for${val || ' specific text'}`;
44
+ case 'scroll': return `Scroll to coords (${x ?? '?'}, ${y ?? '?'})`;
45
+ case 'upload_file': return `Upload file ${truncateMiddle(file || '(unknown)', 60)}${selector ? ` via ${truncateMiddle(selector, 60)}` : ''}`;
46
+ case 'download_file': return `Download file to ${truncateMiddle(file || '(unknown)', 60)}`;
47
+ case 'expand_menu': return `Expand menu${sel || ''}`;
48
+ case 'set_viewport': return `Set viewport ${safeArgs?.width}×${safeArgs?.height}`;
49
+ default:
50
+ return `Run ${fnName} with ${truncateMiddle(JSON.stringify(safeArgs ?? {}), 80)}`;
51
+ }
52
+ }
53
+
54
+ export function summarizeTurnIntentFromMessage(msg) {
55
+ try {
56
+ if (msg?.tool_calls?.length) {
57
+ return msg.tool_calls
58
+ .map(c => summarizeToolCall(c.function?.name || 'unknown', JSON.parse(c.function?.arguments || '{}')))
59
+ .join(' → ');
60
+ }
61
+ if (msg?.content) return `Reasoning: ${truncateMiddle(msg.content, 120)}`;
62
+ } catch {}
63
+ return 'No intent detected';
64
+ }
package/core/turnLoop.js CHANGED
@@ -11,8 +11,17 @@ import {
11
11
  tokenEstimate,
12
12
  tokenUseCoolOff,
13
13
  recordTokenUsage,
14
- pruneOldTokenUsage
14
+ pruneOldTokenUsage,
15
+ getCurrentTokenLimit,
16
+ updateLimitsFromHeaders
15
17
  } from '../tools/tokenControl.js';
18
+ import { resolveModel } from '../openAI/modelResolver.js';
19
+ import { summarizeTurnIntentFromMessage } from './turnIntent.js';
20
+ import { redactArgs } from './redaction.js';
21
+
22
+
23
+ const MODEL_ID = resolveModel();
24
+ console.log(`🧠 Using OpenAI model: ${MODEL_ID}`);
16
25
 
17
26
  const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
18
27
 
@@ -52,7 +61,7 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
52
61
  });
53
62
 
54
63
  const domHtml = await CHROME_TOOL_MAP.get_dom(browser, { limit: 100000, exclude: true }, agentMemory);
55
- await tokenEstimate('gpt-4o', domHtml);
64
+ await tokenEstimate( MODEL_ID, domHtml);
56
65
  // fs.writeFileSync(`missions/mission_reports/debug-expanded-${Date.now()}.html`, domHtml);
57
66
 
58
67
  messages.push({
@@ -89,7 +98,7 @@ export const turnLoop = async (
89
98
  ({ turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(turnTimestamps));
90
99
 
91
100
  // Cool off check
92
- ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps));
101
+ ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
93
102
  if(shouldBackoff) return await turnLoop(browser, messages, maxTurns, turn, currentStep);
94
103
 
95
104
  //Tool Validator
@@ -123,12 +132,14 @@ export const turnLoop = async (
123
132
 
124
133
 
125
134
  response = await openai.chat.completions.create({
126
- model: 'gpt-4o',
135
+ model: MODEL_ID,
127
136
  messages,
128
137
  tools: toolsSchema,
129
138
  });
130
139
  } catch (err) {
131
140
  if (err.status === 429) {
141
+ // If headers include limits, learn them for this model
142
+ try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
132
143
  const delay = Math.min(60000, 2 ** retryCount * 2000);
133
144
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
134
145
  await wait(delay);
@@ -169,6 +180,11 @@ export const turnLoop = async (
169
180
 
170
181
  const msg = response.choices[0].message;
171
182
 
183
+ const planLine = summarizeTurnIntentFromMessage(msg);
184
+ currentStep.summary = planLine;
185
+ currentStep.events.unshift(`📝 Plan: ${planLine}`);
186
+ console.log(`📝 Plan: ${planLine}`);
187
+
172
188
  if (msg.tool_calls?.length) {
173
189
  console.log('Processing tool calls...');
174
190
  const toolResponses = [];
@@ -178,8 +194,9 @@ export const turnLoop = async (
178
194
  const fnName = call.function.name;
179
195
  lastToolName = fnName;
180
196
  const args = JSON.parse(call.function.arguments || '{}');
181
- console.log(`[model] → ${fnName}`, args);
182
- currentStep.events.push(`[model] → ${fnName} ${args}`);
197
+ const safeArgs = redactArgs(fnName, args);
198
+ console.log(`[model] → ${fnName}`, safeArgs);
199
+ currentStep.events.push(`[model] → ${fnName} ${safeArgs}`);
183
200
  // console.log('Calling tool:', fnName);
184
201
  let result;
185
202
  let errorMessage = null;
@@ -248,7 +265,7 @@ export const turnLoop = async (
248
265
  exclude: true,
249
266
  focus: [],
250
267
  }, agentMemory);
251
- await tokenEstimate('gpt-4o', domHtml);
268
+ await tokenEstimate(MODEL_ID, domHtml);
252
269
  await pushDOMAssistant(browser, messages, agentMemory, {
253
270
  skipIfLastTool: ['get_dom', 'check_text']
254
271
  });
@@ -0,0 +1,33 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+
4
+ // Resolve model from config (or ENV) with safe fallback
5
+ const root = process.cwd();
6
+ const configPath = path.join(root, 'testronaut-config.json');
7
+
8
+ export function resolveModel() {
9
+ // Highest priority: env override
10
+ if (process.env.TESTRONAUT_MODEL?.trim()) {
11
+ return process.env.TESTRONAUT_MODEL.trim();
12
+ }
13
+
14
+ try {
15
+ if (fs.existsSync(configPath)) {
16
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
17
+ // Only use the model if provider is openai (future-proofing for other providers)
18
+ if (cfg?.provider === 'openai' && typeof cfg?.model === 'string' && cfg.model.trim()) {
19
+ return cfg.model.trim();
20
+ }
21
+ // Back-compat: older configs may have stored 'openai' in model
22
+ if (!cfg?.provider && cfg?.model === 'openai') {
23
+ return 'gpt-4o';
24
+ }
25
+ }
26
+ } catch (e) {
27
+ console.warn('⚠️ Could not read testronaut-config.json; using default model. Error:', e.message);
28
+ }
29
+
30
+ return 'gpt-4o';
31
+ }
32
+
33
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.0.27",
3
+ "version": "1.1.1",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -1,37 +1,68 @@
1
1
  import { runAgent } from '../core/agent.js';
2
- import { runSuite } from './suiteRunner.js';
2
+ import { redactPasswordInText } from '../core/redaction.js';
3
+ // import { runSuite } from './suiteRunner.js';
3
4
 
4
- const objectives = [];
5
+ // const objectives = [];
5
6
 
6
- export function mission(title, fn) {
7
- console.log(`\n🚀 Mission: ${title}`);
8
- fn();
9
- }
7
+ // export function mission(title, fn) {
8
+ // console.log(`\n🚀 Mission: ${title}`);
9
+ // fn();
10
+ // }
11
+
12
+ // export function objective(desc, workflow) {
13
+ // objectives.push({ name: desc, workflow });
14
+ // }
15
+
16
+ // export async function launch(preMissionSetup) {
17
+ // await runSuite(objectives, preMissionSetup);
18
+ // }
10
19
 
11
- export function objective(desc, workflow) {
12
- objectives.push({ name: desc, workflow });
13
- }
14
20
 
15
- export async function launch(preMissionSetup) {
16
- await runSuite(objectives, preMissionSetup);
17
- }
18
21
 
19
22
  export async function runMissions({ preMission, mission, postMission }, missionName) {
20
23
  const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
21
-
22
- const pre = normalizeToArray(preMission);
24
+
25
+ const pre = normalizeToArray(preMission);
23
26
  const main = normalizeToArray(mission);
24
27
  const post = normalizeToArray(postMission);
25
28
 
26
29
  const goals = [];
27
30
 
31
+ const pickName = (kind, fn, i) => {
32
+ const baseFallback =
33
+ kind === 'premission' ? `pre-${i + 1}` :
34
+ kind === 'postmission' ? `post-${i + 1}` :
35
+ `mission-${i + 1}`;
36
+
37
+ // MAIN mission: prefer the missionName argument
38
+ if (kind === 'mission') {
39
+ if (missionName) {
40
+ // if multiple main items, disambiguate nicely
41
+ const suffix = main.length > 1 ? ` (${i + 1}/${main.length})` : '';
42
+ return `${missionName}${suffix}`;
43
+ }
44
+ // no missionName: if mission is a string, derive from its first non-empty line
45
+ if (typeof fn === 'string') {
46
+ const firstLine = (fn.trim().split('\n').find(Boolean) || fn.trim()).slice(0, 60);
47
+ return firstLine.length === 60 ? `${firstLine}…` : firstLine || baseFallback;
48
+ }
49
+ }
50
+
51
+ // PRE/POST (and fallback for mission): prefer author-provided labels on the function
52
+ const explicit =
53
+ fn?.submissionName || fn?.displayName || fn?.title || fn?.name;
54
+ if (explicit && String(explicit).trim()) return String(explicit).trim();
55
+
56
+ return baseFallback;
57
+ };
58
+
28
59
  if (pre.length) {
29
60
  goals.push(
30
61
  ...pre.map((fn, i) => ({
31
62
  goal: fn,
32
- label: 'pre-mission', // legacy label
33
- submissionType: 'premission', // normalized
34
- submissionName: fn?.name || `pre-${i + 1}`,
63
+ label: 'pre-mission', // legacy label (kept for logs)
64
+ submissionType: 'premission', // normalized
65
+ submissionName: pickName('premission', fn, i),
35
66
  }))
36
67
  );
37
68
  }
@@ -42,7 +73,7 @@ export async function runMissions({ preMission, mission, postMission }, missionN
42
73
  goal: fn,
43
74
  label: 'mission',
44
75
  submissionType: 'mission',
45
- submissionName: fn?.name || `mission-${i + 1}`,
76
+ submissionName: pickName('mission', fn, i),
46
77
  }))
47
78
  );
48
79
  }
@@ -53,28 +84,30 @@ export async function runMissions({ preMission, mission, postMission }, missionN
53
84
  goal: fn,
54
85
  label: 'post-mission',
55
86
  submissionType: 'postmission',
56
- submissionName: fn?.name || `post-${i + 1}`,
87
+ submissionName: pickName('postmission', fn, i),
57
88
  }))
58
89
  );
59
90
  }
60
91
 
61
92
  console.log(
62
93
  '\n🧭 Running mission flow:\n',
63
- goals.map(g => `${g.label}: ${g.goal?.name ?? g.goal}`)
94
+ goals.map(g => {
95
+ if (typeof g.goal === 'string') {
96
+ // redact only the password literal in the human-readable mission text
97
+ return `${g.label}: ${redactPasswordInText(g.goal)}`;
98
+ }
99
+ return `${g.label}: ${g.goal?.name || g.submissionName || g.goal}`;
100
+ })
64
101
  );
65
-
102
+
66
103
  const success = await runAgent(goals, missionName);
67
104
  if (!success) {
68
105
  console.log(`❌ Aborting after failed goal.`);
69
106
  return;
70
107
  }
71
- // console.log("success[0]: ", success[0]);
72
- // console.log("success[0].steps.length: ", success[0].steps.length);
73
- // console.log("success[0].steps[success[0].steps.length - 1].result: ", success[0].steps[success[0].steps.length - 1].result)
108
+
74
109
  const missionStatus = success[0].steps[success[0].steps.length - 1].result;
75
- // console.log("missionStatus: ", missionStatus)
76
110
  if (missionStatus.toLowerCase().includes('failure')) {
77
- // console.log("we detected the failure!")
78
111
  success[0].status = 'failed';
79
112
  }
80
113
  console.log('\n✅ Mission flow complete.');
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
 
5
5
  export function generateHtmlReport(report, outputPath) {
6
- const { runId, startTime, endTime, missions = [], summary = {} } = report;
6
+ const { runId, startTime, endTime, missions = [], summary = {}, llm = {} } = report;
7
7
  const durationSec =
8
8
  (startTime && endTime)
9
9
  ? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
@@ -13,6 +13,7 @@ export function generateHtmlReport(report, outputPath) {
13
13
  const esc = (s) => String(s ?? '')
14
14
  .replace(/&/g, '&amp;').replace(/</g, '&lt;')
15
15
  .replace(/>/g, '&gt;');
16
+ const escAttr = (s) => esc(s).replace(/"/g, '&quot;'); // for HTML attributes
16
17
  const badge = (status) =>
17
18
  status === 'passed' ? '✅ Passed' :
18
19
  status === 'failed' ? '❌ Failed' : (status || '—');
@@ -31,10 +32,18 @@ export function generateHtmlReport(report, outputPath) {
31
32
  ? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn ?? idx)}">`
32
33
  : '';
33
34
 
35
+ const plan = (typeof step.summary === 'string' && step.summary.trim())
36
+ ? step.summary.trim()
37
+ : '';
38
+ const planSpan = plan
39
+ ? `<span class="plan" title="${escAttr(plan)}">${esc(plan)}</span>`
40
+ : '';
41
+
34
42
  return `
35
43
  <details class="step" ${ok ? '' : 'open'}>
36
44
  <summary>
37
45
  <span class="turn">Turn ${esc((step.turn ?? idx) + 1)}</span>
46
+ ${planSpan}
38
47
  <span class="step-result ${ok ? 'ok' : 'bad'}">${esc(step.result || '—')}</span>
39
48
  <span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} / total: ${esc(step.totalTokensUsed ?? '—')}</span>
40
49
  </summary>
@@ -210,6 +219,10 @@ export function generateHtmlReport(report, outputPath) {
210
219
  .step summary:hover{ background: rgba(255,255,255,.08); }
211
220
 
212
221
  .turn{ font-weight:700; }
222
+ .plan{
223
+ flex:1; color: var(--text-muted); font-size:12px;
224
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
225
+ }
213
226
  .step-result.ok{ color: var(--ok); font-weight:700; }
214
227
  .step-result.bad{ color: var(--bad); font-weight:700; }
215
228
  .tokens{ color: var(--text-muted); font-size:12px; margin-left:auto; font-variant-numeric: tabular-nums; }
@@ -241,6 +254,7 @@ export function generateHtmlReport(report, outputPath) {
241
254
  <div><strong>Run ID:</strong> ${esc(runId ?? '—')}</div>
242
255
  <div><strong>Start:</strong> ${esc(startTime ?? '—')}</div>
243
256
  <div><strong>End:</strong> ${esc(endTime ?? '—')} • <strong>Duration:</strong> ${durationSec}s</div>
257
+ <div><strong>LLM:</strong> ${esc(llm.provider ?? '—')} • <strong>Model:</strong> ${esc(llm.model ?? '—')}</div>
244
258
  </div>
245
259
  </div>
246
260
 
@@ -248,6 +262,7 @@ export function generateHtmlReport(report, outputPath) {
248
262
  <div class="pill">Missions: ${esc(summary.totalMissions ?? totals.total)}</div>
249
263
  <div class="pill ok">Passed: ${esc(summary.passed ?? totals.passed)}</div>
250
264
  <div class="pill bad">Failed: ${esc(summary.failed ?? totals.failed)}</div>
265
+ <div class="pill">LLM: ${esc(llm.provider ?? '—')} • ${esc(llm.model ?? '')}</div>
251
266
  </div>
252
267
 
253
268
  <div class="container">
@@ -1,33 +1,164 @@
1
- import { encoding_for_model } from '@dqbd/tiktoken';
1
+ import { encoding_for_model, get_encoding } from '@dqbd/tiktoken';
2
2
  import { wait } from './turnLoopUtils.js';
3
3
 
4
- const TOKEN_BACKOFF_MS = 60000;
5
- const TOKEN_LIMIT_PER_MIN = 90000;
4
+ /**
5
+ * Dynamic token-per-minute limits by model family.
6
+ * - You can tweak these defaults anytime.
7
+ * - ENV override: TESTRONAUT_TOKENS_PER_MIN forces a single limit for everything.
8
+ * - At runtime, you can call `updateLimitsFromHeaders(model, headers)` after a 429
9
+ * to adopt server-advertised limits (if present).
10
+ */
11
+ const DEFAULT_LIMITS = [
12
+ // Newer families first (checked in order)
13
+ { test: /^gpt-5(-|$)/i, tpm: 90000 },
14
+ { test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
15
+ { test: /^gpt-5-nano(-|$)/i, tpm: 600000 },
16
+
17
+ { test: /^gpt-4o(-|$)/i, tpm: 450000 },
18
+ { test: /^gpt-4\.1(-|$)/i, tpm: 1000000 },
19
+
20
+ { test: /^o3(-|$)/i, tpm: 300000 },
21
+ { test: /^o4-mini(-|$)/i, tpm: 600000 },
22
+
23
+ // Older / fallback
24
+ { test: /^gpt-4(-|$)/i, tpm: 150000 },
25
+ { test: /^gpt-3\.5(-|$)/i, tpm: 600000 },
26
+
27
+ // Ultimate fallback for anything else
28
+ { test: /.*/, tpm: 150000 },
29
+ ];
30
+
31
+ // Live, mutable limits (can be updated by headers at runtime)
32
+ const liveLimits = new Map(); // modelId -> { tpm, source: 'default'|'env'|'header' }
33
+
34
+ // One-time warning tracking for tokenizer fallback
35
+ const warnedModels = new Set();
36
+
37
+ /* ---------------- Tokenizer helpers (same idea as before) ---------------- */
38
+
39
+ function getTokenizer(model) {
40
+ try {
41
+ return encoding_for_model(model);
42
+ } catch (_) {
43
+ const m = (model || '').toLowerCase();
44
+ const useO200k =
45
+ m.startsWith('gpt-5') ||
46
+ m.startsWith('gpt-4o') ||
47
+ m.startsWith('gpt-4.1') ||
48
+ m.startsWith('o') ||
49
+ m.includes('omni');
50
+
51
+ try {
52
+ return get_encoding(useO200k ? 'o200k_base' : 'cl100k_base');
53
+ } catch (e2) {
54
+ if (!warnedModels.has(model)) {
55
+ console.warn(`⚠️ Could not load tokenizer for "${model}". Will approximate by bytes/4.`, e2?.message || e2);
56
+ warnedModels.add(model);
57
+ }
58
+ return null;
59
+ }
60
+ }
61
+ }
6
62
 
7
63
  export const tokenEstimate = async (model, text) => {
8
- const encoding = encoding_for_model(model);
9
- const tokenCount = encoding.encode(text).length;
10
- console.log(`🧠 Estimated token count: ${tokenCount}`);
11
- return tokenCount;
64
+ const str = typeof text === 'string' ? text : JSON.stringify(text ?? '');
65
+ const enc = getTokenizer(model);
66
+
67
+ if (enc) {
68
+ try {
69
+ const tokenCount = enc.encode(str).length;
70
+ enc.free?.();
71
+ console.log(`🧠 Estimated token count (${model}): ${tokenCount}`);
72
+ return tokenCount;
73
+ } catch (e) {
74
+ enc.free?.();
75
+ if (!warnedModels.has(model)) {
76
+ console.warn(`⚠️ Tokenizer encode failed for "${model}". Falling back to bytes/4.`, e?.message || e);
77
+ warnedModels.add(model);
78
+ }
79
+ }
80
+ }
81
+
82
+ // Final fallback: rough heuristic (UTF-8 bytes / 4)
83
+ const bytes = Buffer.from(str, 'utf8').length;
84
+ const approx = Math.ceil(bytes / 4);
85
+ console.log(`🧠 Estimated token count (approx, ${model}): ${approx}`);
86
+ return approx;
87
+ };
88
+
89
+ /* ---------------- Dynamic limit resolution ---------------- */
90
+
91
+ function resolveDefaultLimitForModel(model) {
92
+ // Highest priority: ENV override (forces a single limit for all models)
93
+ const envTPM = process.env.TESTRONAUT_TOKENS_PER_MIN
94
+ ? Number(process.env.TESTRONAUT_TOKENS_PER_MIN)
95
+ : undefined;
96
+ if (envTPM && Number.isFinite(envTPM) && envTPM > 0) {
97
+ return { tpm: envTPM, source: 'env' };
98
+ }
99
+
100
+ // Otherwise find best-matching default
101
+ const hit = DEFAULT_LIMITS.find(entry => entry.test.test(model || ''));
102
+ return { tpm: hit?.tpm ?? 150000, source: 'default' };
103
+ }
104
+
105
+ /**
106
+ * Public: get the current token-per-minute limit for a model.
107
+ * Checks liveOverrides first (header-learned), then ENV, then defaults.
108
+ */
109
+ export function getCurrentTokenLimit(model) {
110
+ const m = (model || '').trim() || 'unknown';
111
+ const live = liveLimits.get(m);
112
+ if (live?.tpm) return live;
113
+
114
+ const resolved = resolveDefaultLimitForModel(m);
115
+ liveLimits.set(m, resolved);
116
+ return resolved;
12
117
  }
13
118
 
14
- export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps) => {
15
- // console.log("total tokens used: ", totalTokensUsed);
16
- // console.log("turnTimestamps: ", turnTimestamps);
17
-
18
- if (totalTokensUsed > TOKEN_LIMIT_PER_MIN) {
19
- const msToWait = await getDynamicBackoffMs(turnTimestamps);
20
- console.warn(`⚠️ Token throttle risk → Waiting ${msToWait / 1000}s to cool off...`);
21
- await wait(msToWait);
22
- // totalTokensUsed = 0; // Reset token count after backoff
23
- // turnTimestamps = []; // Reset timestamps after backoff
119
+ /**
120
+ * Public: update limits from HTTP response headers (e.g., after 429).
121
+ * Looks for typical headers like:
122
+ * - x-ratelimit-limit-tokens: "90000"
123
+ * - x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens, etc.
124
+ * If found, we store the new TPM for this model (source='header').
125
+ */
126
+ export function updateLimitsFromHeaders(model, headers = {}) {
127
+ if (!model) return;
128
+
129
+ // Normalize header keys to lowercase
130
+ const lower = {};
131
+ for (const k of Object.keys(headers || {})) lower[k.toLowerCase()] = headers[k];
132
+
133
+ // Common header names OpenAI/Azure may set (varies!)
134
+ const tokenCap =
135
+ Number(lower['x-ratelimit-limit-tokens']) ||
136
+ Number(lower['x-ratelimit-limit-tpm']) ||
137
+ Number(lower['x-ratelimit-limit-token']) ||
138
+ undefined;
139
+
140
+ if (tokenCap && Number.isFinite(tokenCap) && tokenCap > 0) {
141
+ const cur = getCurrentTokenLimit(model);
142
+ if (cur.tpm !== tokenCap || cur.source !== 'header') {
143
+ liveLimits.set(model, { tpm: tokenCap, source: 'header' });
144
+ console.log(`📏 Updated TPM for ${model}: ${tokenCap} (from headers)`);
145
+ }
146
+ }
147
+ }
148
+
149
+ /* ---------------- Cooloff / backoff logic ---------------- */
150
+
151
+ export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps, model) => {
152
+ const { tpm } = getCurrentTokenLimit(model);
153
+ if (totalTokensUsed > tpm) {
154
+ const msToWait = await getDynamicBackoffMs(turnTimestamps, tpm);
155
+ console.warn(`⚠️ Token throttle risk (${totalTokensUsed}/${tpm}) → Waiting ${Math.ceil((msToWait || 1000)/1000)}s...`);
156
+ await wait(msToWait || 1000);
24
157
  console.log('✅ Backoff complete, resuming...');
25
- // return true;
26
158
  return { shouldBackoff: true, totalTokensUsed: 0, turnTimestamps: [] };
27
159
  }
28
- // return false;
29
160
  return { shouldBackoff: false, totalTokensUsed, turnTimestamps };
30
- }
161
+ };
31
162
 
32
163
  export const recordTokenUsage = (turnTimestamps, tokensUsed) => {
33
164
  const now = Date.now();
@@ -41,22 +172,18 @@ export const pruneOldTokenUsage = (turnTimestamps, windowMs = 60000) => {
41
172
  return { turnTimestamps: recentEntries, totalTokensUsed };
42
173
  };
43
174
 
44
- const getDynamicBackoffMs = async (turnTimestamps, tokenLimit = TOKEN_LIMIT_PER_MIN) => {
175
+ const getDynamicBackoffMs = async (turnTimestamps, tokenLimit) => {
45
176
  const now = Date.now();
46
177
  let runningTotal = 0;
47
178
 
48
- // Sort oldest → newest to find the earliest point to fall below the limit
49
179
  const sorted = [...turnTimestamps].sort((a, b) => a[0] - b[0]);
50
-
51
180
  for (let i = 0; i < sorted.length; i++) {
52
- runningTotal += sorted[i][1]; // add tokensUsed
53
-
181
+ runningTotal += sorted[i][1];
54
182
  if (runningTotal > tokenLimit) {
55
183
  const [timestampOfExcess] = sorted[i];
56
184
  const msUntilSafe = 60000 - (now - timestampOfExcess);
57
- return Math.max(msUntilSafe, 1000); // wait at least 1s
185
+ return Math.max(msUntilSafe, 1000); // at least 1s
58
186
  }
59
187
  }
60
-
61
- return 0; // Safe to proceed
62
- }
188
+ return 0;
189
+ };