testronaut 1.9.3 β 1.10.0
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 +28 -3
- package/TODO.md +11 -0
- package/bin/cli.js +45 -13
- package/bin/init.js +10 -5
- package/bin/initHelpers.js +9 -3
- package/core/agent.js +75 -2
- package/core/config.js +16 -0
- package/core/launchProtocol.js +42 -0
- package/core/turnLoop.js +38 -20
- package/llm/anthropic/anthropicProvider.js +2 -0
- package/llm/gemini/geminiProvider.js +36 -8
- package/llm/openAI/openaiProvider.js +2 -0
- package/package.json +1 -1
- package/runner/testronaut.js +24 -8
- package/tests/binTests/cli.integration.test.js +26 -1
- package/tests/binTests/initHelpersTests/models.test.js +7 -1
- package/tests/coreTests/agent.test.js +61 -0
- package/tests/coreTests/config.test.js +10 -0
- package/tests/coreTests/launchProtocol.test.js +38 -0
- package/tests/coreTests/turnLoop.test.js +21 -0
- package/tests/llmTests/geminiProvider.test.js +89 -7
- package/tests/runnerTests/testronaut.test.js +33 -6
- package/tests/toolsTests/chromeBrowserProbe.test.js +38 -0
- package/tests/toolsTests/contextControl.test.js +33 -1
- package/tests/toolsTests/domControl.test.js +12 -0
- package/tests/toolsTests/toolSchema.test.js +8 -0
- package/tests/toolsTests/turnLoopUtils.test.js +16 -0
- package/tools/chromeBrowser.js +50 -5
- package/tools/contextControl.js +11 -1
- package/tools/domControl.js +20 -1
- package/tools/generateHtmlReport.js +1 -1
- package/tools/tokenControl.js +3 -3
- package/tools/toolSchema.js +4 -15
- package/tools/turnLoopUtils.js +4 -3
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ Choose your preferred LLM at init or via environment variables.
|
|
|
145
145
|
testronaut --init
|
|
146
146
|
|
|
147
147
|
# Or override anytime
|
|
148
|
-
TESTRONAUT_PROVIDER=gemini TESTRONAUT_MODEL=gemini-
|
|
148
|
+
TESTRONAUT_PROVIDER=gemini TESTRONAUT_MODEL=gemini-3.8-flash testronaut
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
### Token-rate override
|
|
@@ -172,7 +172,7 @@ Current supported providers:
|
|
|
172
172
|
| Provider | Example Models |
|
|
173
173
|
|-----------|----------------|
|
|
174
174
|
| **OpenAI** | gpt-5.6, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, and legacy GPT/o-series models |
|
|
175
|
-
| **Google Gemini** | gemini-
|
|
175
|
+
| **Google Gemini** | gemini-3.8-flash, gemini-3.7-flash, gemini-3.6-flash, gemini-3.5-flash-lite, gemini-3.1-pro-preview, Gemini 2.5 legacy models |
|
|
176
176
|
| **Anthropic Claude** | claude-sonnet-5, claude-opus-5, claude-haiku-4-5 |
|
|
177
177
|
|
|
178
178
|
The canonical Claude provider ID is `anthropic`; `claude` is also accepted as a CLI alias.
|
|
@@ -191,6 +191,31 @@ Run a specific mission:
|
|
|
191
191
|
testronaut login.mission.js
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
Reuse cookies, local storage, and IndexedDB authentication state across an ordered set of mission files:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
testronaut --session=shared-auth checkout.mission.js account.mission.js
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Each mission still receives a fresh browser context. The default
|
|
201
|
+
`--session=isolated` mode does not carry authentication forward. Shared-auth
|
|
202
|
+
suites can wrap a reusable login prerequisite in `launchProtocol()` so later
|
|
203
|
+
mission files probe the restored authenticated page without an LLM call:
|
|
204
|
+
|
|
205
|
+
```js
|
|
206
|
+
import { launchProtocol, runMissions } from 'testronaut';
|
|
207
|
+
|
|
208
|
+
const login = launchProtocol('Visit the app and log in.', {
|
|
209
|
+
id: 'authenticated:test-user',
|
|
210
|
+
probe: { url: process.env.URL, selector: '#dashboard' },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await runMissions({ preMission: login, mission: 'Test the dashboard.' }, 'dashboard');
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Unwrapped `preMission` values remain fully compatible and always run normally.
|
|
217
|
+
Place logout last in a shared-auth suite.
|
|
218
|
+
|
|
194
219
|
Preview or inspect without launching a browser:
|
|
195
220
|
```bash
|
|
196
221
|
testronaut list
|
|
@@ -371,7 +396,7 @@ Each includes:
|
|
|
371
396
|
- **LLMs** for reasoning, DOM parsing, and tool use
|
|
372
397
|
- **Token throttling** + adaptive cooldowns
|
|
373
398
|
- **Extensible architecture** for custom tools and workflows
|
|
374
|
-
- **DOM trimming controls** to cap list sizes (
|
|
399
|
+
- **DOM trimming controls** to cap list/table sizes (`TESTRONAUT_DOM_LIST_LIMIT`) and automatic post-action snapshots (`TESTRONAUT_AUTO_DOM_LIMIT` or `dom.automaticLimit`)
|
|
375
400
|
- **Resource guard** to ensure full list/table downloads (config `resourceGuard` or env `TESTRONAUT_RESOURCE_*`)
|
|
376
401
|
|
|
377
402
|
---
|
package/TODO.md
CHANGED
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
- [ ] Treat `--json` output as a stable automation contract with documented schemas and tests for every supported command.
|
|
10
10
|
- [ ] If authentication ever moves outside project config, continue reading existing `sessionToken` values and provide an opt-in migration path before deprecating legacy storage.
|
|
11
11
|
|
|
12
|
+
## Session and Reporting Follow-Ups
|
|
13
|
+
|
|
14
|
+
- [ ] Evaluate a true shared browser-context mode for suites that require session storage or in-memory application state; define page reset, crash recovery, and concurrency behavior first.
|
|
15
|
+
- [ ] Add optional shared-auth checkpoints/reset controls for suites that intentionally change users or tenants.
|
|
16
|
+
- [ ] Refresh short-lived Testronaut API session tokens during long runs once the API and credential-storage contract is defined.
|
|
17
|
+
- [ ] Store new CLI authentication outside project config, prefer environment credentials in CI, and migrate legacy project `sessionToken` values without breaking existing projects.
|
|
18
|
+
- [ ] Add structured post-run self-critique fields after the versioned report schema is available.
|
|
19
|
+
- [ ] Separate mission totals from phase totals and add an explicit `phase` field without losing compatibility with `submissionType`.
|
|
20
|
+
- [ ] Design an optional mission scope/side-effect policy that agents can enforce and reports can display.
|
|
21
|
+
- [ ] Evaluate semantic DOM diffs/chunks after configurable limits, selector scoping, and table trimming have production data.
|
|
22
|
+
|
|
12
23
|
## Automated MFA Follow-Ups
|
|
13
24
|
|
|
14
25
|
- [ ] Add an integration smoke test against a local or staging API fixture for `get_mfa_code`.
|
package/bin/cli.js
CHANGED
|
@@ -62,6 +62,8 @@ import { matchesTagFilter, normalizeTagMatch, normalizeTags } from '../core/tags
|
|
|
62
62
|
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
|
|
63
63
|
|
|
64
64
|
const TMP_DIR = path.resolve('./missions/tmp');
|
|
65
|
+
const runId = `run_${Date.now()}`;
|
|
66
|
+
const startTime = new Date();
|
|
65
67
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
66
68
|
const CLI_VERSION = JSON.parse(
|
|
67
69
|
fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')
|
|
@@ -89,6 +91,28 @@ const uploadScreenshots = !args.includes('--no-upload-screenshots');
|
|
|
89
91
|
args = args.filter(arg => arg !== '--no-upload-screenshots');
|
|
90
92
|
process.env.TESTRONAUT_SCREENSHOTS = screenshotsEnabled ? '1' : '0';
|
|
91
93
|
|
|
94
|
+
let sessionMode = 'isolated';
|
|
95
|
+
const sessionFlagIndex = args.findIndex(a => a === '--session' || a.startsWith('--session='));
|
|
96
|
+
if (sessionFlagIndex >= 0) {
|
|
97
|
+
const raw = args[sessionFlagIndex];
|
|
98
|
+
const inline = raw.includes('=');
|
|
99
|
+
const value = inline ? raw.slice(raw.indexOf('=') + 1) : args[sessionFlagIndex + 1];
|
|
100
|
+
if (!['isolated', 'shared-auth'].includes(value)) {
|
|
101
|
+
console.error('β --session must be "isolated" or "shared-auth".');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
sessionMode = value;
|
|
105
|
+
args.splice(sessionFlagIndex, inline ? 1 : 2);
|
|
106
|
+
}
|
|
107
|
+
if (sessionMode === 'shared-auth') {
|
|
108
|
+
const storageStatePath = path.join(TMP_DIR, `${runId}-storage-state.json`);
|
|
109
|
+
process.env.TESTRONAUT_STORAGE_STATE_PATH = storageStatePath;
|
|
110
|
+
process.once('exit', () => {
|
|
111
|
+
try { fs.unlinkSync(storageStatePath); } catch {}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
process.env.TESTRONAUT_SESSION_MODE = sessionMode;
|
|
115
|
+
|
|
92
116
|
// Detect how the CLI was invoked so help text matches the actual command
|
|
93
117
|
function detectCliName(npmCommand = process.env.npm_command, argv1 = process.argv[1]) {
|
|
94
118
|
// npx sets npm_command to 'exec'
|
|
@@ -486,7 +510,8 @@ __test__.parseTagArgs = parseTagArgs;
|
|
|
486
510
|
let modelOverride;
|
|
487
511
|
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
488
512
|
if (modelFlagIndex >= 0) {
|
|
489
|
-
|
|
513
|
+
const inline = args[modelFlagIndex].includes('=');
|
|
514
|
+
if (inline) {
|
|
490
515
|
modelOverride = args[modelFlagIndex].split('=')[1];
|
|
491
516
|
} else if (args[modelFlagIndex + 1]) {
|
|
492
517
|
modelOverride = args[modelFlagIndex + 1];
|
|
@@ -498,7 +523,7 @@ if (modelFlagIndex >= 0) {
|
|
|
498
523
|
}
|
|
499
524
|
|
|
500
525
|
// Remove flag & value from args so they don't look like mission filenames
|
|
501
|
-
args.splice(modelFlagIndex, modelOverride ? 2 : 1);
|
|
526
|
+
args.splice(modelFlagIndex, inline ? 1 : (modelOverride ? 2 : 1));
|
|
502
527
|
}
|
|
503
528
|
|
|
504
529
|
// Look for --provider=<id> or --provider <id>
|
|
@@ -601,7 +626,8 @@ if (debugFlagIndex >= 0) {
|
|
|
601
626
|
let turnsOverride;
|
|
602
627
|
const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
|
|
603
628
|
if (turnsFlagIndex >= 0) {
|
|
604
|
-
|
|
629
|
+
const inline = args[turnsFlagIndex].includes('=');
|
|
630
|
+
if (inline) {
|
|
605
631
|
turnsOverride = args[turnsFlagIndex].split('=')[1];
|
|
606
632
|
} else if (args[turnsFlagIndex + 1]) {
|
|
607
633
|
turnsOverride = args[turnsFlagIndex + 1];
|
|
@@ -618,7 +644,7 @@ if (turnsFlagIndex >= 0) {
|
|
|
618
644
|
}
|
|
619
645
|
|
|
620
646
|
// Remove flag & value so they arenβt treated as filenames
|
|
621
|
-
args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
|
|
647
|
+
args.splice(turnsFlagIndex, inline ? 1 : (turnsOverride ? 2 : 1));
|
|
622
648
|
}
|
|
623
649
|
|
|
624
650
|
// Look for --retry_limit / --retry-limit
|
|
@@ -631,7 +657,8 @@ const retryFlagIndex = args.findIndex(a =>
|
|
|
631
657
|
);
|
|
632
658
|
if (retryFlagIndex >= 0) {
|
|
633
659
|
const rawArg = args[retryFlagIndex];
|
|
634
|
-
|
|
660
|
+
const inline = rawArg.includes('=');
|
|
661
|
+
if (inline) {
|
|
635
662
|
retryOverride = rawArg.split('=')[1];
|
|
636
663
|
} else if (args[retryFlagIndex + 1]) {
|
|
637
664
|
retryOverride = args[retryFlagIndex + 1];
|
|
@@ -649,7 +676,7 @@ if (retryFlagIndex >= 0) {
|
|
|
649
676
|
}
|
|
650
677
|
|
|
651
678
|
// Remove flag & value so they arenβt treated as filenames
|
|
652
|
-
args.splice(retryFlagIndex, retryOverride ? 2 : 1);
|
|
679
|
+
args.splice(retryFlagIndex, inline ? 1 : (retryOverride ? 2 : 1));
|
|
653
680
|
}
|
|
654
681
|
|
|
655
682
|
// Look for --human-input / --no-human-input / --disable-human-input
|
|
@@ -697,7 +724,8 @@ const humanInputTimeoutFlagIndex = args.findIndex(a =>
|
|
|
697
724
|
);
|
|
698
725
|
if (humanInputTimeoutFlagIndex >= 0) {
|
|
699
726
|
const rawArg = args[humanInputTimeoutFlagIndex];
|
|
700
|
-
|
|
727
|
+
const inline = rawArg.includes('=');
|
|
728
|
+
if (inline) {
|
|
701
729
|
humanInputTimeoutOverride = rawArg.split('=')[1];
|
|
702
730
|
} else if (args[humanInputTimeoutFlagIndex + 1]) {
|
|
703
731
|
humanInputTimeoutOverride = args[humanInputTimeoutFlagIndex + 1];
|
|
@@ -713,12 +741,10 @@ if (humanInputTimeoutFlagIndex >= 0) {
|
|
|
713
741
|
}
|
|
714
742
|
}
|
|
715
743
|
|
|
716
|
-
args.splice(humanInputTimeoutFlagIndex, humanInputTimeoutOverride ? 2 : 1);
|
|
744
|
+
args.splice(humanInputTimeoutFlagIndex, inline ? 1 : (humanInputTimeoutOverride ? 2 : 1));
|
|
717
745
|
}
|
|
718
746
|
|
|
719
747
|
const allResults = [];
|
|
720
|
-
const runId = `run_${Date.now()}`;
|
|
721
|
-
const startTime = new Date();
|
|
722
748
|
|
|
723
749
|
const HELP_TEXT = `
|
|
724
750
|
π§βπ testronaut - Autonomous Agent Mission Runner
|
|
@@ -738,6 +764,8 @@ Options:
|
|
|
738
764
|
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
739
765
|
--debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
|
|
740
766
|
--provider=<id> Override LLM provider (openai, gemini, or anthropic)
|
|
767
|
+
--model=<id> Override the provider model for this run
|
|
768
|
+
--session=<mode> Browser auth reuse: isolated (default) or shared-auth
|
|
741
769
|
-o, --options key=value Set run options, such as mfa=github-test-mfa or inbox=github-staging
|
|
742
770
|
--dev Use the staging API base URL
|
|
743
771
|
--vercel-bypass=<secret> Send Vercel protection bypass header for protected deployments
|
|
@@ -849,13 +877,16 @@ if (args.length === 1 && args[0] === 'list') {
|
|
|
849
877
|
return;
|
|
850
878
|
}
|
|
851
879
|
const listedMissions = [];
|
|
880
|
+
const listTags = cliTags ?? normalizeTags(tagConfig?.tags, { allowUntagged: true });
|
|
881
|
+
const listTagMatch = normalizeTagMatch(cliTagMatch ?? tagConfig?.tagMatch);
|
|
852
882
|
if (!jsonOutput) console.log(`Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}:`);
|
|
853
883
|
for (const file of discoveredMissions) {
|
|
854
884
|
try {
|
|
855
885
|
const mission = await loadMissionModule(path.resolve(missionsRoot, file));
|
|
856
886
|
const tags = normalizeTags(mission.tags);
|
|
857
|
-
|
|
858
|
-
|
|
887
|
+
const selected = matchesTagFilter(tags, listTags, listTagMatch);
|
|
888
|
+
listedMissions.push({ file, tags, selected });
|
|
889
|
+
if (!jsonOutput) console.log(` ${selected ? 'β' : 'Β·'} ${file}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
|
|
859
890
|
} catch (error) {
|
|
860
891
|
console.log(` ${file} [could not load: ${error.message}]`);
|
|
861
892
|
process.exitCode = 1;
|
|
@@ -907,7 +938,7 @@ if (dryRun) {
|
|
|
907
938
|
invalid = true;
|
|
908
939
|
}
|
|
909
940
|
}
|
|
910
|
-
writeOutput({ dryRun: true, outputDir, provider: resolveProviderModel({ cwd: process.cwd() }), screenshots: screenshotsEnabled, missions });
|
|
941
|
+
writeOutput({ dryRun: true, outputDir, provider: resolveProviderModel({ cwd: process.cwd() }), session: { mode: sessionMode }, screenshots: screenshotsEnabled, missions });
|
|
911
942
|
if (invalid) process.exitCode = 1;
|
|
912
943
|
return;
|
|
913
944
|
}
|
|
@@ -1008,6 +1039,7 @@ const report = {
|
|
|
1008
1039
|
provider: llmProvider,
|
|
1009
1040
|
model: llmModel,
|
|
1010
1041
|
},
|
|
1042
|
+
session: { mode: sessionMode },
|
|
1011
1043
|
summary: {
|
|
1012
1044
|
totalMissions: flatMissions.length,
|
|
1013
1045
|
passed: flatMissions.filter(m => m.status === 'passed').length,
|
package/bin/init.js
CHANGED
|
@@ -122,12 +122,17 @@ export async function initializeTestronautProject() {
|
|
|
122
122
|
name: 'geminiModel',
|
|
123
123
|
message: 'Select a Google Gemini model:',
|
|
124
124
|
choices: [
|
|
125
|
-
{ title: 'Gemini
|
|
126
|
-
{ title: 'Gemini
|
|
127
|
-
{ title: 'Gemini
|
|
125
|
+
{ title: 'Gemini 3.8 Flash (latest stable, recommended)', value: 'gemini-3.8-flash' },
|
|
126
|
+
{ title: 'Gemini 3.7 Flash (previous stable)', value: 'gemini-3.7-flash' },
|
|
127
|
+
{ title: 'Gemini 3.6 Flash (stable, token-efficient)', value: 'gemini-3.6-flash' },
|
|
128
|
+
{ title: 'Gemini 3.5 Flash-Lite (fastest, cost-efficient)', value: 'gemini-3.5-flash-lite' },
|
|
129
|
+
{ title: 'Gemini 3.1 Pro Preview (complex reasoning)', value: 'gemini-3.1-pro-preview' },
|
|
130
|
+
{ title: 'Gemini 2.5 Pro (legacy)', value: 'gemini-2.5-pro' },
|
|
131
|
+
{ title: 'Gemini 2.5 Flash (legacy)', value: 'gemini-2.5-flash' },
|
|
132
|
+
{ title: 'Gemini 2.5 Flash-Lite (legacy)', value: 'gemini-2.5-flash-lite' },
|
|
128
133
|
],
|
|
129
|
-
// Keep prior selection if present;
|
|
130
|
-
initial: pickInitialIndex(models, config.model, 'gemini-
|
|
134
|
+
// Keep prior selection if present; otherwise use the latest stable model.
|
|
135
|
+
initial: pickInitialIndex(models, config.model, 'gemini-3.8-flash')
|
|
131
136
|
});
|
|
132
137
|
|
|
133
138
|
config.model = geminiModel;
|
package/bin/initHelpers.js
CHANGED
|
@@ -60,6 +60,7 @@ export function defaultConfig(rootBasename) {
|
|
|
60
60
|
addTags: [],
|
|
61
61
|
dom: {
|
|
62
62
|
listItemLimit: 3,
|
|
63
|
+
automaticLimit: 15000,
|
|
63
64
|
},
|
|
64
65
|
resourceGuard: {
|
|
65
66
|
enabled: true,
|
|
@@ -115,9 +116,14 @@ export function openAIModels() {
|
|
|
115
116
|
*/
|
|
116
117
|
export function geminiModels() {
|
|
117
118
|
return [
|
|
118
|
-
'gemini-
|
|
119
|
-
'gemini-
|
|
120
|
-
'gemini-
|
|
119
|
+
'gemini-3.8-flash', // latest stable agentic model
|
|
120
|
+
'gemini-3.7-flash', // previous stable agentic model
|
|
121
|
+
'gemini-3.6-flash', // stable, token-efficient
|
|
122
|
+
'gemini-3.5-flash-lite', // lightweight / high throughput
|
|
123
|
+
'gemini-3.1-pro-preview', // highest-quality preview
|
|
124
|
+
'gemini-2.5-pro', // legacy reasoning model
|
|
125
|
+
'gemini-2.5-flash', // legacy fast model
|
|
126
|
+
'gemini-2.5-flash-lite', // legacy lightweight model
|
|
121
127
|
];
|
|
122
128
|
}
|
|
123
129
|
|
package/core/agent.js
CHANGED
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
import 'dotenv/config';
|
|
26
26
|
import { ChromeBrowser } from '../tools/chromeBrowser.js';
|
|
27
27
|
import { turnLoop } from './turnLoop.js';
|
|
28
|
-
import { createEmptyGroundControl, summarizeGroundControlForPrompt } from '../tools/contextControl.js';
|
|
28
|
+
import { applyGroundControlUpdate, createEmptyGroundControl, summarizeGroundControlForPrompt } from '../tools/contextControl.js';
|
|
29
29
|
import fs from 'fs';
|
|
30
30
|
import path from 'path';
|
|
31
|
+
import { hasCompletedLaunchProtocol, markLaunchProtocolCompleted } from './launchProtocol.js';
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Execute goals with a browser agent.
|
|
@@ -36,7 +37,7 @@ import path from 'path';
|
|
|
36
37
|
* @param {string} missionName
|
|
37
38
|
* @param {number} [maxTurns=20] - upper bound for turnLoop per goal
|
|
38
39
|
* @param {number} [retryLimit]
|
|
39
|
-
* @param {{ domListLimit?: number|typeof Infinity, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] }, humanInput?: { enabled:boolean, timeoutSeconds:number } }} [opts]
|
|
40
|
+
* @param {{ domListLimit?: number|typeof Infinity, automaticDomLimit?: number, storageStatePath?: string, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] }, humanInput?: { enabled:boolean, timeoutSeconds:number } }} [opts]
|
|
40
41
|
* @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
|
|
41
42
|
*/
|
|
42
43
|
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, opts = {}) {
|
|
@@ -44,6 +45,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
|
|
|
44
45
|
domListLimit: opts.domListLimit,
|
|
45
46
|
debug: opts.debug,
|
|
46
47
|
resourceGuard: opts.resourceGuard,
|
|
48
|
+
storageStatePath: opts.storageStatePath,
|
|
47
49
|
});
|
|
48
50
|
await browser.start();
|
|
49
51
|
let result;
|
|
@@ -57,6 +59,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
|
|
|
57
59
|
const groundControl = createEmptyGroundControl();
|
|
58
60
|
|
|
59
61
|
for (const goal of goals) {
|
|
62
|
+
const telemetryStart = groundControl.telemetry.length;
|
|
60
63
|
const steps = [];
|
|
61
64
|
const stepsArchive = [];
|
|
62
65
|
// Unique-ish JSONL file for this missionβs steps
|
|
@@ -67,6 +70,69 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
|
|
|
67
70
|
// Start clean
|
|
68
71
|
fs.writeFileSync(stepFile, '');
|
|
69
72
|
|
|
73
|
+
let protocolResult = null;
|
|
74
|
+
const protocol = goal.launchProtocol;
|
|
75
|
+
const sharedAuth = process.env.TESTRONAUT_SESSION_MODE === 'shared-auth';
|
|
76
|
+
if (protocol && sharedAuth && hasCompletedLaunchProtocol(protocol.id)) {
|
|
77
|
+
let probeResult;
|
|
78
|
+
try {
|
|
79
|
+
probeResult = await browser.probe(protocol.probe);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
probeResult = { passed: false, reason: error?.message || String(error) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (probeResult.passed) {
|
|
85
|
+
const currentUrl = probeResult.url || protocol.probe.url || null;
|
|
86
|
+
applyGroundControlUpdate(groundControl, {
|
|
87
|
+
app: {
|
|
88
|
+
baseUrl: protocol.probe.url || currentUrl,
|
|
89
|
+
currentUrl,
|
|
90
|
+
routeRole: 'authenticated',
|
|
91
|
+
},
|
|
92
|
+
session: { loggedIn: true },
|
|
93
|
+
navigation: { currentLabel: `Launch protocol ${protocol.id} verified` },
|
|
94
|
+
constraints: { stayWithinBaseUrl: true },
|
|
95
|
+
});
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const step = {
|
|
98
|
+
turn: 0,
|
|
99
|
+
events: [`π‘ Launch protocol probe passed: ${protocol.id}`],
|
|
100
|
+
result: 'β
Mission Success',
|
|
101
|
+
summary: `Reused launch protocol ${protocol.id}`,
|
|
102
|
+
missionName,
|
|
103
|
+
tokensUsed: 0,
|
|
104
|
+
inputTokens: 0,
|
|
105
|
+
outputTokens: 0,
|
|
106
|
+
};
|
|
107
|
+
fs.appendFileSync(stepFile, JSON.stringify(step) + '\n');
|
|
108
|
+
missionResults.push({
|
|
109
|
+
missionName,
|
|
110
|
+
submissionType: goal.submissionType || goal.label,
|
|
111
|
+
submissionName: goal.submissionName || null,
|
|
112
|
+
status: 'passed',
|
|
113
|
+
steps: [step],
|
|
114
|
+
stepFile,
|
|
115
|
+
startTime: now,
|
|
116
|
+
endTime: now,
|
|
117
|
+
telemetry: [],
|
|
118
|
+
launchProtocol: { id: protocol.id, status: 'reused', probe: 'passed' },
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
protocolResult = {
|
|
123
|
+
id: protocol.id,
|
|
124
|
+
status: 'executed',
|
|
125
|
+
probe: 'failed',
|
|
126
|
+
reason: probeResult.reason,
|
|
127
|
+
};
|
|
128
|
+
} else if (protocol) {
|
|
129
|
+
protocolResult = {
|
|
130
|
+
id: protocol.id,
|
|
131
|
+
status: 'executed',
|
|
132
|
+
probe: sharedAuth ? 'not-needed' : 'disabled',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
70
136
|
// Ensure user message is a string (functions/objects β toString fallback)
|
|
71
137
|
const userContent =
|
|
72
138
|
typeof goal.goal === 'string'
|
|
@@ -197,6 +263,7 @@ Email invitation and magic links:
|
|
|
197
263
|
groundControl,
|
|
198
264
|
resourceGuard: opts.resourceGuard,
|
|
199
265
|
humanInput: opts.humanInput,
|
|
266
|
+
automaticDomLimit: opts.automaticDomLimit,
|
|
200
267
|
onStep: (s) => {
|
|
201
268
|
// Append each step as a JSON line
|
|
202
269
|
try {
|
|
@@ -232,8 +299,14 @@ Email invitation and magic links:
|
|
|
232
299
|
stepFile, // full history is in this JSONL (one step per line)
|
|
233
300
|
startTime: Date.now() - 1, // TODO: wire actual timings if needed
|
|
234
301
|
endTime: Date.now(),
|
|
302
|
+
telemetry: JSON.parse(JSON.stringify(groundControl.telemetry.slice(telemetryStart))),
|
|
303
|
+
...(protocolResult ? { launchProtocol: protocolResult } : {}),
|
|
235
304
|
});
|
|
236
305
|
|
|
306
|
+
if (protocol && result?.success) {
|
|
307
|
+
markLaunchProtocolCompleted(protocol.id);
|
|
308
|
+
}
|
|
309
|
+
|
|
237
310
|
if (!result?.success) {
|
|
238
311
|
console.log('π Agent stopped due to failed goal.\n');
|
|
239
312
|
return missionResults;
|
package/core/config.js
CHANGED
|
@@ -130,6 +130,22 @@ export function getDomListLimit(cfg, fallback = 3) {
|
|
|
130
130
|
return { ...fbParsed, source: 'default' };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/** Resolve the character limit for automatic DOM snapshots injected after actions. */
|
|
134
|
+
export function getAutomaticDomLimit(cfg, fallback = 15000) {
|
|
135
|
+
const parse = raw => {
|
|
136
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return null;
|
|
137
|
+
const value = Number(raw);
|
|
138
|
+
if (!Number.isFinite(value)) return null;
|
|
139
|
+
const clamped = Math.min(100000, Math.max(1000, Math.trunc(value)));
|
|
140
|
+
return { value: clamped, clamped: clamped !== value };
|
|
141
|
+
};
|
|
142
|
+
const env = parse(process.env.TESTRONAUT_AUTO_DOM_LIMIT);
|
|
143
|
+
if (env) return { ...env, source: 'env' };
|
|
144
|
+
const configured = parse(cfg?.dom?.automaticLimit ?? cfg?.automaticDomLimit);
|
|
145
|
+
if (configured) return { ...configured, source: 'config' };
|
|
146
|
+
return { ...parse(fallback), source: 'default' };
|
|
147
|
+
}
|
|
148
|
+
|
|
133
149
|
/**
|
|
134
150
|
* Resolve resource guard settings for generic list/table harvesting.
|
|
135
151
|
* - Patterns are used to detect resource anchors/data attributes.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const completedProtocols = new Set();
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Add optional shared-session reuse metadata to an ordinary mission goal.
|
|
5
|
+
* The wrapped goal remains string-coercible for backward compatibility.
|
|
6
|
+
*/
|
|
7
|
+
export function launchProtocol(goal, { id, probe } = {}) {
|
|
8
|
+
const protocolId = String(id || '').trim();
|
|
9
|
+
if (!protocolId) throw new Error('launchProtocol requires a non-empty id.');
|
|
10
|
+
if (!probe || (!probe.selector && !probe.text)) {
|
|
11
|
+
throw new Error('launchProtocol requires a probe selector or text value.');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return Object.freeze({
|
|
15
|
+
__testronautLaunchProtocol: true,
|
|
16
|
+
goal,
|
|
17
|
+
protocol: Object.freeze({
|
|
18
|
+
id: protocolId,
|
|
19
|
+
probe: Object.freeze({ ...probe }),
|
|
20
|
+
}),
|
|
21
|
+
toString() {
|
|
22
|
+
return typeof goal === 'string' ? goal : goal?.toString?.() ?? String(goal);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function unwrapLaunchProtocol(value) {
|
|
28
|
+
if (!value?.__testronautLaunchProtocol) return { goal: value, protocol: null };
|
|
29
|
+
return { goal: value.goal, protocol: value.protocol };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hasCompletedLaunchProtocol(id) {
|
|
33
|
+
return completedProtocols.has(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function markLaunchProtocolCompleted(id) {
|
|
37
|
+
completedProtocols.add(id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resetLaunchProtocols() {
|
|
41
|
+
completedProtocols.clear();
|
|
42
|
+
}
|
package/core/turnLoop.js
CHANGED
|
@@ -190,14 +190,15 @@ function formatBytes(n) {
|
|
|
190
190
|
* @param {object} agentMemory - Memory object storing recent agent state.
|
|
191
191
|
* @param {{skipIfLastTool?: string[]}} opts - Optional tool skip conditions.
|
|
192
192
|
*/
|
|
193
|
-
const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool } = {}) => {
|
|
193
|
+
const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool, limit = 15000 } = {}) => {
|
|
194
194
|
// Avoid redundant DOM pushes immediately following tools that already include DOM context.
|
|
195
195
|
if (skipIfLastTool && skipIfLastTool.includes(messages.at(-1)?.name)) {
|
|
196
196
|
console.log(`[skip] Skipping DOM push after redundant tool: ${messages.at(-1)?.name}`);
|
|
197
|
-
return;
|
|
197
|
+
return { injected: false, domLength: 0 };
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
const domCallId = `get_dom_${Date.now()}`;
|
|
201
|
+
const automaticDomLimit = limit;
|
|
201
202
|
|
|
202
203
|
// Inject a synthetic assistant tool call
|
|
203
204
|
messages.push({
|
|
@@ -208,13 +209,17 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
|
|
|
208
209
|
type: 'function',
|
|
209
210
|
function: {
|
|
210
211
|
name: 'get_dom',
|
|
211
|
-
arguments: JSON.stringify({ limit:
|
|
212
|
+
arguments: JSON.stringify({ limit: automaticDomLimit, exclude: true }),
|
|
212
213
|
},
|
|
213
214
|
},
|
|
214
215
|
],
|
|
215
216
|
});
|
|
216
217
|
|
|
217
|
-
const domHtml = await CHROME_TOOL_MAP.get_dom(
|
|
218
|
+
const domHtml = await CHROME_TOOL_MAP.get_dom(
|
|
219
|
+
browser,
|
|
220
|
+
{ limit: automaticDomLimit, exclude: true },
|
|
221
|
+
agentMemory
|
|
222
|
+
);
|
|
218
223
|
await tokenEstimate(MODEL_ID, domHtml);
|
|
219
224
|
|
|
220
225
|
// Push the corresponding tool response back into the conversation
|
|
@@ -225,6 +230,11 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
|
|
|
225
230
|
type: 'function',
|
|
226
231
|
content: typeof domHtml === 'string' ? domHtml : JSON.stringify(domHtml),
|
|
227
232
|
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
injected: true,
|
|
236
|
+
domLength: typeof domHtml === 'string' ? domHtml.length : JSON.stringify(domHtml).length,
|
|
237
|
+
};
|
|
228
238
|
};
|
|
229
239
|
|
|
230
240
|
/**
|
|
@@ -427,6 +437,8 @@ export const turnLoop = async (
|
|
|
427
437
|
const tokensUsed = usage.total_tokens || 0;
|
|
428
438
|
console.log(`π Token Usage This Turn β Total: ${tokensUsed}`);
|
|
429
439
|
step.tokensUsed = tokensUsed;
|
|
440
|
+
step.inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? usage.providerRaw?.input_tokens ?? usage.providerRaw?.prompt_tokens;
|
|
441
|
+
step.outputTokens = usage.output_tokens ?? usage.completion_tokens ?? usage.providerRaw?.output_tokens ?? usage.providerRaw?.completion_tokens;
|
|
430
442
|
recordTokenUsage(turnTimestamps, tokensUsed);
|
|
431
443
|
({ turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(turnTimestamps));
|
|
432
444
|
console.log(`π Running Total Tokens Used (Rolling 60s): ${totalTokensUsed}`);
|
|
@@ -449,13 +461,14 @@ export const turnLoop = async (
|
|
|
449
461
|
console.log('Processing tool calls...');
|
|
450
462
|
const toolResponses = [];
|
|
451
463
|
let hadToolIssues = false;
|
|
464
|
+
let domRefreshSource = null;
|
|
452
465
|
|
|
453
466
|
for (const call of msg.tool_calls) {
|
|
454
467
|
const fnName = call.function.name;
|
|
455
468
|
const args = JSON.parse(call.function.arguments || '{}');
|
|
456
469
|
const safeArgs = redactArgs(fnName, args);
|
|
457
470
|
console.log(`[model] β ${fnName}`, safeArgs);
|
|
458
|
-
step.events.push(`[model] β ${fnName} ${safeArgs}`);
|
|
471
|
+
step.events.push(`[model] β ${fnName} ${JSON.stringify(safeArgs)}`);
|
|
459
472
|
|
|
460
473
|
let result;
|
|
461
474
|
let errorMessage = null;
|
|
@@ -796,26 +809,30 @@ export const turnLoop = async (
|
|
|
796
809
|
content: contentForModel,
|
|
797
810
|
});
|
|
798
811
|
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
focus: [],
|
|
807
|
-
}, agentMemory);
|
|
808
|
-
await tokenEstimate(MODEL_ID, domHtml);
|
|
809
|
-
await pushDOMAssistant(browser, messages, agentMemory, {
|
|
810
|
-
skipIfLastTool: ['get_dom', 'check_text'],
|
|
811
|
-
});
|
|
812
|
-
console.log(`[auto] β DOM size after ${fnName}: ${domHtml.length} chars`);
|
|
813
|
-
step.events.push(`[auto] β DOM size after ${fnName}: ${domHtml.length} chars`);
|
|
812
|
+
// Defer automatic DOM injection until the originating assistant call
|
|
813
|
+
// and all of its function responses are in history. Gemini requires a
|
|
814
|
+
// model functionCall to immediately follow user content or a
|
|
815
|
+
// functionResponse; inserting it before `msg` would create adjacent,
|
|
816
|
+
// reversed model function-call turns.
|
|
817
|
+
if (!errorMessage && ['click_text', 'click', 'expand_menu'].includes(fnName)) {
|
|
818
|
+
domRefreshSource = fnName;
|
|
814
819
|
}
|
|
815
820
|
}
|
|
816
821
|
|
|
817
822
|
// Merge new assistant + tool responses back into conversation
|
|
818
823
|
messages.push(msg, ...toolResponses);
|
|
824
|
+
if (domRefreshSource && !hadToolIssues) {
|
|
825
|
+
console.log(`[auto] β Injecting DOM after ${domRefreshSource}...`);
|
|
826
|
+
step.events.push(`[auto] β Injecting DOM after ${domRefreshSource}...`);
|
|
827
|
+
const domRefresh = await pushDOMAssistant(browser, messages, agentMemory, {
|
|
828
|
+
skipIfLastTool: ['get_dom', 'check_text'],
|
|
829
|
+
limit: ctx.automaticDomLimit,
|
|
830
|
+
});
|
|
831
|
+
if (domRefresh?.injected) {
|
|
832
|
+
console.log(`[auto] β DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
|
|
833
|
+
step.events.push(`[auto] β DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
819
836
|
if (hadToolIssues && turnRetries < retryLimitClamped) {
|
|
820
837
|
step.result = 'β³ Retrying turn';
|
|
821
838
|
const retryNumber = attempt - 1;
|
|
@@ -870,6 +887,7 @@ export const turnLoop = async (
|
|
|
870
887
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
871
888
|
await pushDOMAssistant(browser, messages, agentMemory, {
|
|
872
889
|
skipIfLastTool: ['get_dom', 'check_text'],
|
|
890
|
+
limit: ctx.automaticDomLimit,
|
|
873
891
|
});
|
|
874
892
|
console.log(`[auto] β Injected DOM for next reasoning step`);
|
|
875
893
|
step.events.push(`[auto] β Injected DOM for next reasoning step`);
|
|
@@ -112,6 +112,8 @@ export class AnthropicProvider {
|
|
|
112
112
|
|
|
113
113
|
const usage = {
|
|
114
114
|
total_tokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
|
|
115
|
+
input_tokens: response.usage?.input_tokens,
|
|
116
|
+
output_tokens: response.usage?.output_tokens,
|
|
115
117
|
providerRaw: response.usage,
|
|
116
118
|
};
|
|
117
119
|
return { message: fromAnthropicMessage(response), usage };
|