testronaut 1.3.1 → 1.3.2
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 +2 -0
- package/bin/cli.js +51 -8
- package/bin/init.js +2 -0
- package/bin/initHelpers.js +8 -0
- package/core/agent.js +10 -3
- package/core/config.js +91 -0
- package/core/missionDiscovery.js +55 -0
- package/core/turnLoop.js +118 -0
- package/package.json +3 -2
- package/runner/testronaut.js +66 -2
- package/tests/binTests/cli.helpers.test.js +9 -0
- package/tests/binTests/init.test.js +6 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +3 -0
- package/tests/coreTests/config.test.js +96 -0
- package/tests/coreTests/missionDiscovery.test.js +81 -0
- package/tests/coreTests/turnLoop.test.js +46 -1
- package/tests/runnerTests/testronaut.test.js +70 -5
- package/tests/toolsTests/domControl.test.js +35 -0
- package/tests/toolsTests/toolSchema.test.js +24 -0
- package/tools/chromeBrowser.js +211 -4
- package/tools/domControl.js +10 -3
- package/tools/toolSchema.js +43 -1
package/README.md
CHANGED
|
@@ -178,6 +178,8 @@ Each includes:
|
|
|
178
178
|
- **LLMs** for reasoning, DOM parsing, and tool use
|
|
179
179
|
- **Token throttling** + adaptive cooldowns
|
|
180
180
|
- **Extensible architecture** for custom tools and workflows
|
|
181
|
+
- **DOM trimming controls** to cap list sizes (env `TESTRONAUT_DOM_LIST_LIMIT` or config `dom.listItemLimit`; use `all` cautiously—it can spike token use)
|
|
182
|
+
- **Resource guard** to ensure full list/table downloads (config `resourceGuard` or env `TESTRONAUT_RESOURCE_*`)
|
|
181
183
|
|
|
182
184
|
---
|
|
183
185
|
|
package/bin/cli.js
CHANGED
|
@@ -47,6 +47,7 @@ import { promisify } from 'util';
|
|
|
47
47
|
const exec = promisify(execCmd);
|
|
48
48
|
import url from 'url';
|
|
49
49
|
import { ensureBrowsers } from '../tools/playwrightSetup.js';
|
|
50
|
+
import { discoverMissionFiles } from '../core/missionDiscovery.js';
|
|
50
51
|
|
|
51
52
|
// Keep PW browsers inside the project to avoid global cache skew
|
|
52
53
|
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
|
|
@@ -153,6 +154,48 @@ if (modelFlagIndex >= 0) {
|
|
|
153
154
|
args.splice(modelFlagIndex, modelOverride ? 2 : 1);
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// Look for --debug / --debug=<bool> / --no-debug
|
|
158
|
+
// Normalize CLI boolean strings into true/false/null for optional flags.
|
|
159
|
+
const parseBool = (v) => {
|
|
160
|
+
const lower = String(v ?? '').trim().toLowerCase();
|
|
161
|
+
if (!lower) return null;
|
|
162
|
+
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
|
|
163
|
+
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
|
|
164
|
+
return null;
|
|
165
|
+
};
|
|
166
|
+
__test__.parseBool = parseBool;
|
|
167
|
+
|
|
168
|
+
let debugOverride;
|
|
169
|
+
let debugConsumesNext = false;
|
|
170
|
+
const debugFlagIndex = args.findIndex(a =>
|
|
171
|
+
a === '--debug' ||
|
|
172
|
+
a.startsWith('--debug=') ||
|
|
173
|
+
a === '--no-debug'
|
|
174
|
+
);
|
|
175
|
+
if (debugFlagIndex >= 0) {
|
|
176
|
+
const rawArg = args[debugFlagIndex];
|
|
177
|
+
if (rawArg.includes('=')) {
|
|
178
|
+
debugOverride = parseBool(rawArg.split('=')[1]);
|
|
179
|
+
} else if (rawArg === '--no-debug') {
|
|
180
|
+
debugOverride = false;
|
|
181
|
+
} else if (args[debugFlagIndex + 1] && !args[debugFlagIndex + 1].startsWith('-')) {
|
|
182
|
+
debugOverride = parseBool(args[debugFlagIndex + 1]);
|
|
183
|
+
debugConsumesNext = true;
|
|
184
|
+
} else {
|
|
185
|
+
debugOverride = true; // bare --debug enables it
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (debugOverride !== null) {
|
|
189
|
+
process.env.TESTRONAUT_DEBUG = debugOverride ? '1' : '0';
|
|
190
|
+
console.log(`🛠️ Debug mode ${debugOverride ? 'enabled' : 'disabled'} (TESTRONAUT_DEBUG=${process.env.TESTRONAUT_DEBUG})`);
|
|
191
|
+
} else {
|
|
192
|
+
console.warn('⚠️ Invalid --debug value. Use true/false, 1/0, yes/no.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const consume = 1 + (debugConsumesNext && debugOverride !== null ? 1 : 0);
|
|
196
|
+
args.splice(debugFlagIndex, consume);
|
|
197
|
+
}
|
|
198
|
+
|
|
156
199
|
// Look for --turns=<n> or --turns <n>
|
|
157
200
|
let turnsOverride;
|
|
158
201
|
const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
|
|
@@ -208,8 +251,6 @@ if (retryFlagIndex >= 0) {
|
|
|
208
251
|
args.splice(retryFlagIndex, retryOverride ? 2 : 1);
|
|
209
252
|
}
|
|
210
253
|
|
|
211
|
-
const missionsDir = path.resolve(process.cwd(), 'missions');
|
|
212
|
-
|
|
213
254
|
const allResults = [];
|
|
214
255
|
const runId = `run_${Date.now()}`;
|
|
215
256
|
const startTime = new Date();
|
|
@@ -228,6 +269,7 @@ Usage:
|
|
|
228
269
|
Options:
|
|
229
270
|
--init Scaffold project folders and a welcome mission
|
|
230
271
|
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
272
|
+
--debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
|
|
231
273
|
--help Show this help message
|
|
232
274
|
--retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
|
|
233
275
|
|
|
@@ -302,14 +344,16 @@ if (args.includes('serve') || args.includes('view')) {
|
|
|
302
344
|
await new Promise(() => {}); // ✅ never resolves; Ctrl+C will terminate
|
|
303
345
|
}
|
|
304
346
|
|
|
305
|
-
|
|
306
|
-
|
|
347
|
+
const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
|
|
348
|
+
|
|
349
|
+
if (!fs.existsSync(missionsRoot)) {
|
|
350
|
+
console.error(`❌ Missions directory not found: ${path.relative(process.cwd(), missionsRoot)}`);
|
|
307
351
|
process.exit(1);
|
|
308
352
|
}
|
|
309
353
|
|
|
310
354
|
const runFile = async (filePath) => {
|
|
311
355
|
try {
|
|
312
|
-
const modulePath = path.resolve(
|
|
356
|
+
const modulePath = path.resolve(missionsRoot, filePath);
|
|
313
357
|
const missionsModule = await import(`file://${modulePath}`);
|
|
314
358
|
|
|
315
359
|
if (typeof missionsModule.executeMission === 'function') {
|
|
@@ -331,9 +375,8 @@ if (args.length > 0) {
|
|
|
331
375
|
await runFile(file);
|
|
332
376
|
}
|
|
333
377
|
} else {
|
|
334
|
-
// Run
|
|
335
|
-
const
|
|
336
|
-
for (const file of files) {
|
|
378
|
+
// Run missions discovered from config (or default behavior)
|
|
379
|
+
for (const file of discoveredMissions) {
|
|
337
380
|
await runFile(file);
|
|
338
381
|
}
|
|
339
382
|
}
|
package/bin/init.js
CHANGED
|
@@ -139,6 +139,8 @@ export async function initializeTestronautProject() {
|
|
|
139
139
|
config.outputDir = config.outputDir || defaults.outputDir;
|
|
140
140
|
config.projectName = config.projectName || defaults.projectName;
|
|
141
141
|
config.maxTurns = config.maxTurns ?? defaults.maxTurns;
|
|
142
|
+
config.dom = { ...defaults.dom, ...(config.dom || {}) };
|
|
143
|
+
config.resourceGuard = { ...defaults.resourceGuard, ...(config.resourceGuard || {}) };
|
|
142
144
|
|
|
143
145
|
// ─────────────────────────────────────────────
|
|
144
146
|
// STEP 4: Persist config and scaffold .env (first run only)
|
package/bin/initHelpers.js
CHANGED
|
@@ -52,6 +52,14 @@ export function defaultConfig(rootBasename) {
|
|
|
52
52
|
outputDir: 'missions/mission_reports',
|
|
53
53
|
projectName: rootBasename,
|
|
54
54
|
maxTurns: 20,
|
|
55
|
+
dom: {
|
|
56
|
+
listItemLimit: 3,
|
|
57
|
+
},
|
|
58
|
+
resourceGuard: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
hrefIncludes: ['/document/', '/file/', '/download', '/attachment/'],
|
|
61
|
+
dataTypes: ['document', 'file', 'item', 'row'],
|
|
62
|
+
},
|
|
55
63
|
};
|
|
56
64
|
}
|
|
57
65
|
|
package/core/agent.js
CHANGED
|
@@ -35,10 +35,16 @@ import path from 'path';
|
|
|
35
35
|
* @param {Array<{goal:any, submissionType?:string, submissionName?:string, label?:string}>} goals
|
|
36
36
|
* @param {string} missionName
|
|
37
37
|
* @param {number} [maxTurns=20] - upper bound for turnLoop per goal
|
|
38
|
+
* @param {number} [retryLimit]
|
|
39
|
+
* @param {{ domListLimit?: number|typeof Infinity, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] } }} [opts]
|
|
38
40
|
* @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
|
|
39
41
|
*/
|
|
40
|
-
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit) {
|
|
41
|
-
const browser = new ChromeBrowser(
|
|
42
|
+
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, opts = {}) {
|
|
43
|
+
const browser = new ChromeBrowser({
|
|
44
|
+
domListLimit: opts.domListLimit,
|
|
45
|
+
debug: opts.debug,
|
|
46
|
+
resourceGuard: opts.resourceGuard,
|
|
47
|
+
});
|
|
42
48
|
await browser.start();
|
|
43
49
|
let result;
|
|
44
50
|
|
|
@@ -155,8 +161,9 @@ Use Ground Control as your persistent mission memory about:
|
|
|
155
161
|
steps,
|
|
156
162
|
stepsArchive,
|
|
157
163
|
missionName,
|
|
158
|
-
retryLimit,
|
|
164
|
+
retryLimit,
|
|
159
165
|
groundControl,
|
|
166
|
+
resourceGuard: opts.resourceGuard,
|
|
160
167
|
onStep: (s) => {
|
|
161
168
|
// Append each step as a JSON line
|
|
162
169
|
try {
|
package/core/config.js
CHANGED
|
@@ -82,6 +82,97 @@ export function getRetryLimit(cfg, fallback = 2) {
|
|
|
82
82
|
return { value, source: 'default', clamped };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Resolve how many list-like items to keep in DOM snapshots.
|
|
87
|
+
* - Accepts numbers (clamped 0-100), or the strings "all"/"none".
|
|
88
|
+
* - Priority: env TESTRONAUT_DOM_LIST_LIMIT → config.dom.listItemLimit/listLimit/domListLimit → fallback.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} cfg
|
|
91
|
+
* @param {number} [fallback=3]
|
|
92
|
+
* @returns {{ value:number|typeof Infinity, mode:'number'|'all'|'none', source:'env'|'config'|'default', clamped:boolean }}
|
|
93
|
+
*/
|
|
94
|
+
export function getDomListLimit(cfg, fallback = 3) {
|
|
95
|
+
const clampList = (n) => {
|
|
96
|
+
const clamped = Math.min(100, Math.max(0, n));
|
|
97
|
+
return { value: clamped, clamped: clamped !== n };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const parseRaw = (raw) => {
|
|
101
|
+
if (raw === undefined || raw === null) return null;
|
|
102
|
+
|
|
103
|
+
if (typeof raw === 'string') {
|
|
104
|
+
const lower = raw.trim().toLowerCase();
|
|
105
|
+
if (!lower) return null;
|
|
106
|
+
if (lower === 'all') return { value: Infinity, mode: 'all', clamped: false };
|
|
107
|
+
if (lower === 'none') return { value: 0, mode: 'none', clamped: false };
|
|
108
|
+
const num = Number(lower);
|
|
109
|
+
if (!Number.isFinite(num)) return null;
|
|
110
|
+
const { value, clamped } = clampList(num);
|
|
111
|
+
return { value, mode: value === 0 ? 'none' : 'number', clamped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
115
|
+
const { value, clamped } = clampList(raw);
|
|
116
|
+
return { value, mode: value === 0 ? 'none' : 'number', clamped };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const envParsed = parseRaw(process.env.TESTRONAUT_DOM_LIST_LIMIT);
|
|
123
|
+
if (envParsed) return { ...envParsed, source: 'env' };
|
|
124
|
+
|
|
125
|
+
const cfgRaw = cfg?.dom?.listItemLimit ?? cfg?.dom?.listLimit ?? cfg?.domListLimit;
|
|
126
|
+
const cfgParsed = parseRaw(cfgRaw);
|
|
127
|
+
if (cfgParsed) return { ...cfgParsed, source: 'config' };
|
|
128
|
+
|
|
129
|
+
const fbParsed = parseRaw(fallback);
|
|
130
|
+
return { ...fbParsed, source: 'default' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve resource guard settings for generic list/table harvesting.
|
|
135
|
+
* - Patterns are used to detect resource anchors/data attributes.
|
|
136
|
+
* - Enabled can be toggled via config.resourceGuard.enabled or TESTRONAUT_RESOURCE_GUARD.
|
|
137
|
+
*/
|
|
138
|
+
export function getResourceGuardConfig(cfg) {
|
|
139
|
+
const parseBool = (raw) => {
|
|
140
|
+
if (raw === undefined || raw === null) return null;
|
|
141
|
+
const lower = String(raw).trim().toLowerCase();
|
|
142
|
+
if (['1', 'true', 'yes', 'on'].includes(lower)) return true;
|
|
143
|
+
if (['0', 'false', 'no', 'off'].includes(lower)) return false;
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const defaultHrefPatterns = ['/document/', '/file/', '/download', '/attachment/'];
|
|
148
|
+
const defaultDataTypes = ['document', 'file', 'item', 'row'];
|
|
149
|
+
|
|
150
|
+
const envEnabled = parseBool(process.env.TESTRONAUT_RESOURCE_GUARD);
|
|
151
|
+
const cfgEnabled = parseBool(cfg?.resourceGuard?.enabled);
|
|
152
|
+
const enabled = envEnabled ?? cfgEnabled ?? true;
|
|
153
|
+
|
|
154
|
+
const parseList = (raw) => {
|
|
155
|
+
if (Array.isArray(raw)) return raw.map(String).map(s => s.trim()).filter(Boolean);
|
|
156
|
+
if (typeof raw === 'string') {
|
|
157
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const hrefIncludes = parseList(process.env.TESTRONAUT_RESOURCE_HREF_PATTERNS)
|
|
163
|
+
|| [];
|
|
164
|
+
const dataTypes = parseList(process.env.TESTRONAUT_RESOURCE_DATA_TYPES)
|
|
165
|
+
|| [];
|
|
166
|
+
|
|
167
|
+
const cfgHref = parseList(cfg?.resourceGuard?.hrefIncludes);
|
|
168
|
+
const cfgTypes = parseList(cfg?.resourceGuard?.dataTypes);
|
|
169
|
+
|
|
170
|
+
const finalHref = (hrefIncludes.length ? hrefIncludes : cfgHref.length ? cfgHref : defaultHrefPatterns).map(s => s.toLowerCase());
|
|
171
|
+
const finalTypes = (dataTypes.length ? dataTypes : cfgTypes.length ? cfgTypes : defaultDataTypes).map(s => s.toLowerCase());
|
|
172
|
+
|
|
173
|
+
return { enabled, hrefIncludes: finalHref, dataTypes: finalTypes };
|
|
174
|
+
}
|
|
175
|
+
|
|
85
176
|
/**
|
|
86
177
|
* Baseline limits for mission runs. Adjust here for global defaults.
|
|
87
178
|
*
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_ROOT = 'missions';
|
|
7
|
+
const DEFAULT_INCLUDE = ['**/*.mission.js', '**/*.mission.ts'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discover mission files based on testronaut-config.json.
|
|
11
|
+
* - When no missions block exists, preserve legacy behavior:
|
|
12
|
+
* read the ./missions folder (non-recursive) for *.mission.js files.
|
|
13
|
+
* - When missions is defined, apply root/include/exclude globs relative to root.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} [opts.cwd=process.cwd()] - project root for config + resolution
|
|
17
|
+
* @returns {Promise<{ root:string, files:string[] }>} root is absolute; files are relative to root
|
|
18
|
+
*/
|
|
19
|
+
export async function discoverMissionFiles({ cwd = process.cwd() } = {}) {
|
|
20
|
+
const defaultRoot = path.resolve(cwd, DEFAULT_ROOT);
|
|
21
|
+
const cfg = await loadConfig(cwd);
|
|
22
|
+
const missionsCfg = cfg?.missions;
|
|
23
|
+
|
|
24
|
+
// Legacy behavior: no missions block → non-recursive *.mission.js in ./missions
|
|
25
|
+
if (!missionsCfg) {
|
|
26
|
+
if (!fs.existsSync(defaultRoot)) {
|
|
27
|
+
return { root: defaultRoot, files: [] };
|
|
28
|
+
}
|
|
29
|
+
const files = fs.readdirSync(defaultRoot)
|
|
30
|
+
.filter(f => f.endsWith('.mission.js'))
|
|
31
|
+
.sort();
|
|
32
|
+
return { root: defaultRoot, files };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const root = path.resolve(cwd, missionsCfg.root || DEFAULT_ROOT);
|
|
36
|
+
if (!fs.existsSync(root)) {
|
|
37
|
+
return { root, files: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const include = Array.isArray(missionsCfg.include) && missionsCfg.include.length
|
|
41
|
+
? missionsCfg.include
|
|
42
|
+
: DEFAULT_INCLUDE;
|
|
43
|
+
const exclude = Array.isArray(missionsCfg.exclude) ? missionsCfg.exclude : [];
|
|
44
|
+
|
|
45
|
+
const matches = await fg(include, {
|
|
46
|
+
cwd: root,
|
|
47
|
+
ignore: exclude,
|
|
48
|
+
onlyFiles: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const unique = Array.from(new Set(matches));
|
|
52
|
+
unique.sort();
|
|
53
|
+
|
|
54
|
+
return { root, files: unique };
|
|
55
|
+
}
|
package/core/turnLoop.js
CHANGED
|
@@ -63,6 +63,50 @@ console.log(`🧠 Using LLM → provider: ${PROVIDER_ID}, model: ${MODEL_ID}`);
|
|
|
63
63
|
|
|
64
64
|
const llm = getLLM(PROVIDER_ID);
|
|
65
65
|
|
|
66
|
+
// Track resource/download coverage across turns (guard against partial loops).
|
|
67
|
+
function ensureDocProgress(agentMemory, cfg) {
|
|
68
|
+
if (!cfg?.enabled) return null;
|
|
69
|
+
if (!agentMemory.docProgress) {
|
|
70
|
+
agentMemory.docProgress = {
|
|
71
|
+
items: [],
|
|
72
|
+
downloaded: new Set(),
|
|
73
|
+
lastSummary: '',
|
|
74
|
+
lastScriptCount: 0,
|
|
75
|
+
patterns: cfg,
|
|
76
|
+
};
|
|
77
|
+
} else {
|
|
78
|
+
agentMemory.docProgress.patterns = cfg;
|
|
79
|
+
}
|
|
80
|
+
return agentMemory.docProgress;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Parse injected <pre data-testronaut-doc-list> summary into structured items.
|
|
84
|
+
function parseDocListFromDom(html) {
|
|
85
|
+
try {
|
|
86
|
+
const match = html.match(/<pre[^>]*data-testronaut-doc-list[^>]*>([\s\S]*?)<\/pre>/i);
|
|
87
|
+
if (!match) return { items: [], scriptDocs: 0 };
|
|
88
|
+
const text = match[1];
|
|
89
|
+
const lines = text.split('\n').map(l => l.trim()).filter(Boolean).slice(1);
|
|
90
|
+
const items = lines.map(l => {
|
|
91
|
+
const m = l.match(/-\s*\[(.*?)\]\s*(.*?)\s+(\/document\/\d+[^\s]*)?/i);
|
|
92
|
+
return {
|
|
93
|
+
id: m?.[1] || '',
|
|
94
|
+
title: m?.[2] || l.replace(/^-/, '').trim(),
|
|
95
|
+
href: m?.[3] || '',
|
|
96
|
+
};
|
|
97
|
+
}).filter(it => it.id || it.title || it.href);
|
|
98
|
+
return { items, scriptDocs: items.length };
|
|
99
|
+
} catch {
|
|
100
|
+
return { items: [], scriptDocs: 0 };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extract numeric document IDs from common /document/<id> URLs.
|
|
105
|
+
function extractDocIdFromUrl(url = '') {
|
|
106
|
+
const m = url.match(/\/document\/(\d+)/i);
|
|
107
|
+
return m ? m[1] : '';
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
// Rolling token counters used for self-throttling
|
|
67
111
|
let totalTokensUsed = 0;
|
|
68
112
|
let turnTimestamps = [];
|
|
@@ -174,11 +218,17 @@ export const turnLoop = async (
|
|
|
174
218
|
ctx = {} // { steps, missionName, groundControl }
|
|
175
219
|
) => {
|
|
176
220
|
const { steps = [], missionName, groundControl = createEmptyGroundControl(), retryLimit: retryLimitRaw } = ctx;
|
|
221
|
+
const resourceGuardCfg = ctx.resourceGuard || {
|
|
222
|
+
enabled: true,
|
|
223
|
+
hrefIncludes: ['/document/', '/file/', '/download', '/attachment/'],
|
|
224
|
+
dataTypes: ['document', 'file', 'item', 'row'],
|
|
225
|
+
};
|
|
177
226
|
const stepsArchive = ctx.stepsArchive || steps;
|
|
178
227
|
const retryLimitClamped = Math.min(10, Math.max(1, Number.isFinite(retryLimitRaw) ? retryLimitRaw : DEFAULT_TURN_RETRY_LIMIT)); // retries allowed (excludes initial)
|
|
179
228
|
const maxAttempts = retryLimitClamped + 1; // includes initial attempt
|
|
180
229
|
ctx.groundControl = groundControl;
|
|
181
230
|
let agentMemory = { lastMenuExpanded: false };
|
|
231
|
+
ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
182
232
|
let turnRetries = 0;
|
|
183
233
|
let stepSeq = ctx._stepSeq || 0;
|
|
184
234
|
|
|
@@ -373,6 +423,20 @@ export const turnLoop = async (
|
|
|
373
423
|
: `📥 Downloaded "${maybeJson.fileName}" (${formatBytes(maybeJson.bytes)}) via ${maybeJson.mode}`;
|
|
374
424
|
step.events.push(msgLine);
|
|
375
425
|
console.log(msgLine);
|
|
426
|
+
|
|
427
|
+
if (resourceGuardCfg.enabled && (maybeJson._testronaut_file_event === 'download' || maybeJson._testronaut_file_event === 'upload')) {
|
|
428
|
+
const prog = ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
429
|
+
if (prog) {
|
|
430
|
+
const id = extractDocIdFromUrl(maybeJson.url || maybeJson.selector || maybeJson.fileName);
|
|
431
|
+
if (id) prog.downloaded.add(id);
|
|
432
|
+
// also try title match
|
|
433
|
+
if (maybeJson.fileName) {
|
|
434
|
+
const byTitle = prog.items.find(i => maybeJson.fileName.includes(i.title));
|
|
435
|
+
if (byTitle?.id) prog.downloaded.add(byTitle.id);
|
|
436
|
+
}
|
|
437
|
+
step.events.push(`📊 Resource progress: ${prog.downloaded.size}/${prog.items.length || prog.lastScriptCount}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
376
440
|
}
|
|
377
441
|
} catch {
|
|
378
442
|
// non-JSON results ignored
|
|
@@ -413,6 +477,35 @@ export const turnLoop = async (
|
|
|
413
477
|
contentForModel = result;
|
|
414
478
|
}
|
|
415
479
|
|
|
480
|
+
// Keep doc list progress updated when we fetch DOM
|
|
481
|
+
if (!errorMessage && resourceGuardCfg.enabled) {
|
|
482
|
+
const prog = ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
483
|
+
if (prog) {
|
|
484
|
+
if (fnName === 'get_dom') {
|
|
485
|
+
const { items, scriptDocs } = parseDocListFromDom(result);
|
|
486
|
+
if (items.length) {
|
|
487
|
+
prog.items = items;
|
|
488
|
+
prog.lastScriptCount = scriptDocs || items.length;
|
|
489
|
+
prog.lastSummary = `docs:${items.length}`;
|
|
490
|
+
step.events.push(`📊 Detected document list (${items.length} items)`);
|
|
491
|
+
}
|
|
492
|
+
} else if (fnName === 'list_local_files') {
|
|
493
|
+
try {
|
|
494
|
+
const parsed = JSON.parse(result);
|
|
495
|
+
const files = parsed?.files || [];
|
|
496
|
+
if (Array.isArray(files) && files.length) {
|
|
497
|
+
prog.items = files.map(f => ({ id: f, title: f, href: f }));
|
|
498
|
+
prog.lastScriptCount = files.length;
|
|
499
|
+
prog.lastSummary = `files:${files.length}`;
|
|
500
|
+
step.events.push(`📊 Detected local files (${files.length} items)`);
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
// ignore parse errors
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
416
509
|
toolResponses.push({
|
|
417
510
|
role: 'tool',
|
|
418
511
|
tool_call_id: call.id,
|
|
@@ -465,6 +558,24 @@ export const turnLoop = async (
|
|
|
465
558
|
// ─────────────────────────────────────────────
|
|
466
559
|
const finalResponse = finalResponseHandler(msg);
|
|
467
560
|
if (finalResponse !== null) {
|
|
561
|
+
const prog = ensureDocProgress(agentMemory, resourceGuardCfg);
|
|
562
|
+
if (prog?.items.length && prog.downloaded.size < prog.items.length) {
|
|
563
|
+
const remaining = prog.items
|
|
564
|
+
.filter(i => !prog.downloaded.has(i.id))
|
|
565
|
+
.map(i => i.title || i.id)
|
|
566
|
+
.slice(0, 10);
|
|
567
|
+
const remainText = remaining.length ? remaining.join('; ') : 'unknown items';
|
|
568
|
+
const guardMsg = `Auto-guard: downloaded ${prog.downloaded.size}/${prog.items.length}. Remaining: ${remainText}`;
|
|
569
|
+
console.log(`[guard] ${guardMsg}`);
|
|
570
|
+
step.events.push(guardMsg);
|
|
571
|
+
step.result = '🟡 In Progress';
|
|
572
|
+
recordStep(step);
|
|
573
|
+
// Nudge model to continue
|
|
574
|
+
messages.push({ role: 'assistant', content: guardMsg });
|
|
575
|
+
// Continue loop without exiting
|
|
576
|
+
turnRetries = 0;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
468
579
|
step.events.push(finalResponse.finalMessage);
|
|
469
580
|
step.result = finalResponse.success ? '✅ Mission Success' : '❌ Mission Failure';
|
|
470
581
|
recordStep(step);
|
|
@@ -488,3 +599,10 @@ export const turnLoop = async (
|
|
|
488
599
|
|
|
489
600
|
return { success: false, steps: stepsArchive };
|
|
490
601
|
};
|
|
602
|
+
|
|
603
|
+
// Expose small helper bundle for unit tests (no production use).
|
|
604
|
+
export const __docProgressInternals = {
|
|
605
|
+
ensureDocProgress,
|
|
606
|
+
parseDocListFromDom,
|
|
607
|
+
extractDocIdFromUrl,
|
|
608
|
+
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testronaut",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"main": "./runner/testronaut.js",
|
|
5
5
|
"bin": {
|
|
6
|
-
"testronaut": "
|
|
6
|
+
"testronaut": "bin/cli.js"
|
|
7
7
|
},
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./runner/testronaut.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"@google/generative-ai": "^0.24.1",
|
|
25
25
|
"cheerio": "^1.1.0",
|
|
26
26
|
"dotenv": "^17.2.0",
|
|
27
|
+
"fast-glob": "^3.3.3",
|
|
27
28
|
"inquirer": "^12.9.1",
|
|
28
29
|
"node-fetch": "^3.3.2",
|
|
29
30
|
"openai": "^5.10.2",
|
package/runner/testronaut.js
CHANGED
|
@@ -23,9 +23,20 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
import fs from 'fs';
|
|
27
|
+
import path from 'path';
|
|
26
28
|
import { runAgent } from '../core/agent.js';
|
|
27
29
|
import { redactPasswordInText } from '../core/redaction.js';
|
|
28
|
-
import { loadConfig, enforceTurnBudget, getRetryLimit } from '../core/config.js';
|
|
30
|
+
import { loadConfig, enforceTurnBudget, getRetryLimit, getDomListLimit, getResourceGuardConfig } from '../core/config.js';
|
|
31
|
+
|
|
32
|
+
// Check process env for debug toggles (shared helper for tests and CLI).
|
|
33
|
+
const isDebugEnabled = () => {
|
|
34
|
+
const raw = String(process.env.TESTRONAUT_DEBUG || '').trim().toLowerCase();
|
|
35
|
+
return ['1', 'true', 'yes', 'on'].includes(raw);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Presentable string for DOM list limit logging/debug output.
|
|
39
|
+
const formatListLimit = (v) => v === Infinity ? 'all' : v;
|
|
29
40
|
|
|
30
41
|
/**
|
|
31
42
|
* Run a mission flow.
|
|
@@ -40,12 +51,56 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
40
51
|
const maxTurns = effectiveMax;
|
|
41
52
|
const retryInfo = getRetryLimit(cfg);
|
|
42
53
|
const retryLimit = retryInfo.value;
|
|
54
|
+
const domListLimitInfo = getDomListLimit(cfg);
|
|
55
|
+
const resourceGuard = getResourceGuardConfig(cfg);
|
|
56
|
+
const debugEnabled = isDebugEnabled();
|
|
43
57
|
if (notes.length) {
|
|
44
58
|
console.warn(notes.join('\n'));
|
|
45
59
|
}
|
|
46
60
|
if (retryInfo.clamped) {
|
|
47
61
|
console.warn(`⚠️ Retry limit clamped to ${retryLimit} (allowed range 1-10).`);
|
|
48
62
|
}
|
|
63
|
+
if (domListLimitInfo?.clamped) {
|
|
64
|
+
console.warn(`⚠️ DOM list limit clamped to ${domListLimitInfo.value} (allowed 0-100 or "all"/"none").`);
|
|
65
|
+
}
|
|
66
|
+
if (domListLimitInfo?.mode === 'all') {
|
|
67
|
+
console.warn('⚠️ DOM list limit is set to "all". This can dramatically increase token usage and may break flows on heavy pages.');
|
|
68
|
+
}
|
|
69
|
+
if (debugEnabled) {
|
|
70
|
+
const cfgDomRaw = cfg?.dom?.listItemLimit ?? cfg?.dom?.listLimit ?? cfg?.domListLimit;
|
|
71
|
+
console.log('[debug] Debug mode enabled');
|
|
72
|
+
console.log('[debug] Turn limits', {
|
|
73
|
+
maxTurns,
|
|
74
|
+
softMax: limits.softMaxTurns,
|
|
75
|
+
hardMax: limits.hardMaxTurns,
|
|
76
|
+
hardMin: limits.hardMinTurns,
|
|
77
|
+
});
|
|
78
|
+
console.log('[debug] Retry limit', { retryLimit, source: retryInfo.source, clamped: retryInfo.clamped });
|
|
79
|
+
console.log('[debug] DOM list limit', {
|
|
80
|
+
env: process.env.TESTRONAUT_DOM_LIST_LIMIT ?? 'unset',
|
|
81
|
+
config: cfgDomRaw ?? 'unset',
|
|
82
|
+
resolved: formatListLimit(domListLimitInfo?.value),
|
|
83
|
+
mode: domListLimitInfo?.mode,
|
|
84
|
+
source: domListLimitInfo?.source,
|
|
85
|
+
clamped: domListLimitInfo?.clamped,
|
|
86
|
+
});
|
|
87
|
+
console.log('[debug] Resource guard', {
|
|
88
|
+
enabled: resourceGuard.enabled,
|
|
89
|
+
hrefIncludes: resourceGuard.hrefIncludes,
|
|
90
|
+
dataTypes: resourceGuard.dataTypes,
|
|
91
|
+
});
|
|
92
|
+
// Helpful for upload missions: show missions/files contents
|
|
93
|
+
const filesDir = path.resolve(process.cwd(), 'missions/files');
|
|
94
|
+
try {
|
|
95
|
+
const entries = fs.readdirSync(filesDir);
|
|
96
|
+
const pdfs = entries.filter(f => f.toLowerCase().endsWith('.pdf'));
|
|
97
|
+
console.log(`[debug] missions/files dir: ${filesDir}`);
|
|
98
|
+
console.log(`[debug] missions/files entries (${entries.length}):`, entries);
|
|
99
|
+
if (pdfs.length) console.log(`[debug] missions/files PDFs (${pdfs.length}):`, pdfs);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.log(`[debug] missions/files not readable: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
49
104
|
|
|
50
105
|
// 2) Normalize submissions
|
|
51
106
|
const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
|
|
@@ -128,7 +183,13 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
128
183
|
);
|
|
129
184
|
|
|
130
185
|
// 3) Execute
|
|
131
|
-
const success = await runAgent(
|
|
186
|
+
const success = await runAgent(
|
|
187
|
+
goals,
|
|
188
|
+
missionName,
|
|
189
|
+
maxTurns,
|
|
190
|
+
retryLimit,
|
|
191
|
+
{ domListLimit: domListLimitInfo?.value, debug: debugEnabled, resourceGuard }
|
|
192
|
+
);
|
|
132
193
|
if (!success) {
|
|
133
194
|
console.log(`❌ Aborting after failed goal.`);
|
|
134
195
|
return;
|
|
@@ -141,3 +202,6 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
141
202
|
console.log('\n✅ Mission flow complete.');
|
|
142
203
|
return success;
|
|
143
204
|
}
|
|
205
|
+
|
|
206
|
+
// Exposed for unit tests.
|
|
207
|
+
export const __test__ = { isDebugEnabled, formatListLimit };
|
|
@@ -33,4 +33,13 @@ describe('cli helpers', () => {
|
|
|
33
33
|
expect(latest?.htmlFile).toBe('run_200.html');
|
|
34
34
|
expect(latest?.jsonFile).toBe('run_200.json');
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it('parses booleans in a tolerant way', () => {
|
|
38
|
+
const { parseBool } = __test__;
|
|
39
|
+
expect(parseBool('true')).toBe(true);
|
|
40
|
+
expect(parseBool('0')).toBe(false);
|
|
41
|
+
expect(parseBool('off')).toBe(false);
|
|
42
|
+
expect(parseBool('')).toBeNull();
|
|
43
|
+
expect(parseBool('maybe')).toBeNull();
|
|
44
|
+
});
|
|
36
45
|
});
|
|
@@ -77,6 +77,8 @@ describe('initializeTestronautProject', () => {
|
|
|
77
77
|
expect(cfg.initialized).toBe(true);
|
|
78
78
|
expect(cfg.outputDir).toBe('missions/mission_reports');
|
|
79
79
|
expect(typeof cfg.maxTurns).toBe('number');
|
|
80
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
81
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
80
82
|
|
|
81
83
|
// .env
|
|
82
84
|
const envPath = path.join(temp, '.env');
|
|
@@ -108,6 +110,8 @@ describe('initializeTestronautProject', () => {
|
|
|
108
110
|
const cfg = readJson(path.join(temp, 'testronaut-config.json'));
|
|
109
111
|
expect(cfg.provider).toBe('gemini');
|
|
110
112
|
expect(cfg.model).toBe('gemini-2.5-flash');
|
|
113
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
114
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
111
115
|
|
|
112
116
|
const envTxt = fs.readFileSync(path.join(temp, '.env'), 'utf8');
|
|
113
117
|
expect(envTxt).toMatch(/GEMINI_API_KEY=AIza/);
|
|
@@ -185,5 +189,7 @@ describe('initializeTestronautProject', () => {
|
|
|
185
189
|
expect(cfg.projectName).toBe('my-project'); // preserved
|
|
186
190
|
expect(cfg.outputDir).toBe('missions/mission_reports'); // default filled in
|
|
187
191
|
expect(cfg.maxTurns).toBe(20); // default filled in
|
|
192
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
193
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
188
194
|
});
|
|
189
195
|
});
|
|
@@ -8,5 +8,8 @@ describe('defaultConfig', () => {
|
|
|
8
8
|
expect(cfg.outputDir).toBe('missions/mission_reports');
|
|
9
9
|
expect(cfg.projectName).toBe('my-app');
|
|
10
10
|
expect(cfg.maxTurns).toBe(20);
|
|
11
|
+
expect(cfg.dom?.listItemLimit).toBe(3);
|
|
12
|
+
expect(cfg.resourceGuard?.enabled).toBe(true);
|
|
13
|
+
expect(cfg.resourceGuard?.hrefIncludes).toContain('/document/');
|
|
11
14
|
});
|
|
12
15
|
});
|