testronaut 1.2.5 → 1.3.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 +74 -9
- package/core/agent.js +88 -25
- package/core/config.js +30 -0
- package/core/turnLoop.js +119 -20
- package/llm/gemini/geminiProvider.js +14 -1
- package/package.json +1 -1
- package/runner/testronaut.js +7 -2
- package/tests/binTests/cli.helpers.test.js +36 -0
- package/tests/coreTests/config.test.js +41 -0
- package/tests/coreTests/turnLoop.test.js +54 -2
- package/tests/llmTests/geminiProvider.test.js +29 -0
- package/tests/runnerTests/testronaut.test.js +10 -5
- package/tests/toolsTests/contextControl.test.js +595 -0
- package/tests/toolsTests/domControl.test.js +329 -0
- package/tests/toolsTests/generateHtmlReport.test.js +90 -0
- package/tools/chromeBrowser.js +4 -0
- package/tools/contextControl.js +307 -0
- package/tools/domControl.js +129 -3
- package/tools/generateHtmlReport.js +38 -4
- package/tools/tokenControl.js +13 -2
- package/tools/toolSchema.js +101 -0
- package/tools/turnLoopUtils.js +0 -1
package/bin/cli.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* Test strategy:
|
|
30
30
|
* To test pure helpers without running the whole CLI, we export a tiny test bundle:
|
|
31
31
|
* export const __test__ = { guessMimeType, safeJoin, findLatestReportPair, pkgManagerForCwd }
|
|
32
|
-
* See tests in tests/
|
|
32
|
+
* See tests in tests/toolsTests/cli.helpers.test.js
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import fs from 'fs';
|
|
@@ -60,6 +60,11 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
|
|
|
60
60
|
|
|
61
61
|
const args = process.argv.slice(2);
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Read a JSONL steps file into an array of objects.
|
|
65
|
+
* @param {string} p
|
|
66
|
+
* @returns {object[]|null}
|
|
67
|
+
*/
|
|
63
68
|
function readJsonlSteps(p) {
|
|
64
69
|
if (!p || !fs.existsSync(p)) return null;
|
|
65
70
|
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
@@ -73,21 +78,27 @@ function readJsonlSteps(p) {
|
|
|
73
78
|
/**
|
|
74
79
|
* Merge duplicate turns without losing information.
|
|
75
80
|
* Strategy:
|
|
76
|
-
* - Keep the latest **non-empty** version for a given turn
|
|
81
|
+
* - Keep the latest **non-empty** version for a given (turn,retry) pair
|
|
77
82
|
* - If both are non-empty, keep the later one (last write wins)
|
|
78
83
|
* - Preserve original order by line index as a tiebreaker
|
|
84
|
+
*
|
|
85
|
+
* @param {Array} steps
|
|
86
|
+
* @returns {Array}
|
|
79
87
|
*/
|
|
80
88
|
function mergeDuplicateTurns(steps) {
|
|
81
89
|
const out = [];
|
|
82
|
-
const
|
|
90
|
+
const byKey = new Map(); // composite key -> index in out
|
|
83
91
|
for (const s of steps) {
|
|
84
92
|
const turn = Number.isFinite(s.turn) ? s.turn : out.length;
|
|
93
|
+
const attempt = Number.isFinite(s.retryAttempt) ? s.retryAttempt : 1;
|
|
85
94
|
const hasEvents = Array.isArray(s.events) && s.events.length > 0;
|
|
86
|
-
|
|
95
|
+
const key = `${turn}::${attempt}`;
|
|
96
|
+
|
|
97
|
+
if (!byKey.has(key)) {
|
|
87
98
|
out.push(s);
|
|
88
|
-
|
|
99
|
+
byKey.set(key, out.length - 1);
|
|
89
100
|
} else {
|
|
90
|
-
const idx =
|
|
101
|
+
const idx = byKey.get(key);
|
|
91
102
|
const existing = out[idx];
|
|
92
103
|
const existingHasEvents = Array.isArray(existing.events) && existing.events.length > 0;
|
|
93
104
|
// Prefer the one with events; if both have events, prefer the newer (s)
|
|
@@ -101,11 +112,28 @@ function mergeDuplicateTurns(steps) {
|
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
|
-
// stable sort by `turn`
|
|
105
|
-
out.sort((a, b) =>
|
|
115
|
+
// stable sort by `turn` then `retryAttempt`, then by original order
|
|
116
|
+
out.sort((a, b) => {
|
|
117
|
+
const ta = a.turn ?? 0;
|
|
118
|
+
const tb = b.turn ?? 0;
|
|
119
|
+
if (ta !== tb) return ta - tb;
|
|
120
|
+
const ra = Number.isFinite(a.retryAttempt) ? a.retryAttempt : 1;
|
|
121
|
+
const rb = Number.isFinite(b.retryAttempt) ? b.retryAttempt : 1;
|
|
122
|
+
if (ra !== rb) return ra - rb;
|
|
123
|
+
return 0;
|
|
124
|
+
});
|
|
106
125
|
return out;
|
|
107
126
|
}
|
|
108
127
|
|
|
128
|
+
// Expose a small bundle for unit tests (helper-only; not the CLI flow)
|
|
129
|
+
export const __test__ = {
|
|
130
|
+
guessMimeType,
|
|
131
|
+
safeJoin,
|
|
132
|
+
findLatestReportPair,
|
|
133
|
+
mergeDuplicateTurns,
|
|
134
|
+
readJsonlSteps,
|
|
135
|
+
};
|
|
136
|
+
|
|
109
137
|
// Look for --model=<id> or --model <id>
|
|
110
138
|
let modelOverride;
|
|
111
139
|
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
@@ -149,6 +177,37 @@ if (turnsFlagIndex >= 0) {
|
|
|
149
177
|
args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
|
|
150
178
|
}
|
|
151
179
|
|
|
180
|
+
// Look for --retry_limit / --retry-limit
|
|
181
|
+
let retryOverride;
|
|
182
|
+
const retryFlagIndex = args.findIndex(a =>
|
|
183
|
+
a === '--retry_limit' ||
|
|
184
|
+
a.startsWith('--retry_limit=') ||
|
|
185
|
+
a === '--retry-limit' ||
|
|
186
|
+
a.startsWith('--retry-limit=')
|
|
187
|
+
);
|
|
188
|
+
if (retryFlagIndex >= 0) {
|
|
189
|
+
const rawArg = args[retryFlagIndex];
|
|
190
|
+
if (rawArg.includes('=')) {
|
|
191
|
+
retryOverride = rawArg.split('=')[1];
|
|
192
|
+
} else if (args[retryFlagIndex + 1]) {
|
|
193
|
+
retryOverride = args[retryFlagIndex + 1];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (retryOverride) {
|
|
197
|
+
const n = Number(retryOverride.trim());
|
|
198
|
+
if (Number.isFinite(n)) {
|
|
199
|
+
const clamped = Math.min(10, Math.max(1, n));
|
|
200
|
+
process.env.TESTRONAUT_RETRY_LIMIT = String(clamped);
|
|
201
|
+
console.log(`🔁 Retry limit override: ${process.env.TESTRONAUT_RETRY_LIMIT} (allowed 1-10)`);
|
|
202
|
+
} else {
|
|
203
|
+
console.warn(`⚠️ Invalid --retry_limit value "${retryOverride}". Ignoring.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Remove flag & value so they aren’t treated as filenames
|
|
208
|
+
args.splice(retryFlagIndex, retryOverride ? 2 : 1);
|
|
209
|
+
}
|
|
210
|
+
|
|
152
211
|
const missionsDir = path.resolve(process.cwd(), 'missions');
|
|
153
212
|
|
|
154
213
|
const allResults = [];
|
|
@@ -170,6 +229,7 @@ Options:
|
|
|
170
229
|
--init Scaffold project folders and a welcome mission
|
|
171
230
|
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
172
231
|
--help Show this help message
|
|
232
|
+
--retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
|
|
173
233
|
|
|
174
234
|
Examples:
|
|
175
235
|
npx testronaut
|
|
@@ -720,6 +780,11 @@ function safeJoin(root, relUrlPath) {
|
|
|
720
780
|
return resolved;
|
|
721
781
|
}
|
|
722
782
|
|
|
783
|
+
/**
|
|
784
|
+
* Best-effort cross-platform URL opener.
|
|
785
|
+
* @param {string} targetUrl
|
|
786
|
+
* @returns {Promise<void>}
|
|
787
|
+
*/
|
|
723
788
|
async function openInBrowser(targetUrl) {
|
|
724
789
|
const platform = process.platform;
|
|
725
790
|
const quoted = `"${targetUrl}"`;
|
|
@@ -848,4 +913,4 @@ try {
|
|
|
848
913
|
}
|
|
849
914
|
} catch (err) {
|
|
850
915
|
console.warn(`⚠️ Could not remove tmp folder: ${err.message}`);
|
|
851
|
-
}
|
|
916
|
+
}
|
package/core/agent.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
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
29
|
import fs from 'fs';
|
|
29
30
|
import path from 'path';
|
|
30
31
|
|
|
@@ -36,7 +37,7 @@ import path from 'path';
|
|
|
36
37
|
* @param {number} [maxTurns=20] - upper bound for turnLoop per goal
|
|
37
38
|
* @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
|
|
38
39
|
*/
|
|
39
|
-
export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
40
|
+
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit) {
|
|
40
41
|
const browser = new ChromeBrowser();
|
|
41
42
|
await browser.start();
|
|
42
43
|
let result;
|
|
@@ -46,8 +47,12 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
|
46
47
|
const tmpDir = path.resolve(process.cwd(), 'missions/tmp');
|
|
47
48
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
48
49
|
|
|
50
|
+
// 🔭 Shared Ground Control state across all goals/missions
|
|
51
|
+
const groundControl = createEmptyGroundControl();
|
|
52
|
+
|
|
49
53
|
for (const goal of goals) {
|
|
50
54
|
const steps = [];
|
|
55
|
+
const stepsArchive = [];
|
|
51
56
|
// Unique-ish JSONL file for this mission’s steps
|
|
52
57
|
const stepFile = path.join(
|
|
53
58
|
tmpDir,
|
|
@@ -62,22 +67,79 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
|
62
67
|
? goal.goal
|
|
63
68
|
: goal?.goal?.toString?.() ?? String(goal.goal);
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
// 🔭 Build a compact Ground Control snapshot for the prompt
|
|
71
|
+
const groundSummary = summarizeGroundControlForPrompt(groundControl);
|
|
72
|
+
let systemContent = `
|
|
73
|
+
You are an autonomous web agent piloting a browser for end-to-end testing.
|
|
74
|
+
|
|
75
|
+
Core behavior:
|
|
76
|
+
- Use function calls to complete the user's goal.
|
|
77
|
+
- If you are unsure of selectors for inputs or buttons, call 'get_dom' to retrieve page HTML,
|
|
78
|
+
analyze it, then choose selectors based on labels, names, types, and placeholder values.
|
|
79
|
+
- If you want to click a button labeled "Sign out", prefer:
|
|
80
|
+
|
|
81
|
+
click_text({ text: "Sign out" })
|
|
82
|
+
|
|
83
|
+
Do NOT use CSS selectors like 'button:contains("Sign out")' — they are invalid.
|
|
84
|
+
|
|
85
|
+
After completing the goal, respond with a final plain-text message starting with
|
|
86
|
+
either "SUCCESS:" or "FAILURE:" followed by a short explanation.
|
|
87
|
+
|
|
88
|
+
──── Ground Control (persistent mission state) ────
|
|
89
|
+
You also have special tools to manage a compact, non-pruned state called "Ground Control".
|
|
90
|
+
Ground Control tracks high-level truths about the app and session, such as:
|
|
73
91
|
|
|
74
|
-
|
|
92
|
+
- app: { baseUrl, currentUrl, routeRole } // where the app lives and where you are now
|
|
93
|
+
- session: { loggedIn, userLabel, tenant } // login state and identity
|
|
94
|
+
- navigation:{ currentLabel } // human label for the current view
|
|
95
|
+
- constraints:{ stayWithinBaseUrl } // domain / navigation constraints
|
|
96
|
+
- telemetry: [{ ts, kind, text, status, turn, extra }] // optional breadcrumbs
|
|
75
97
|
|
|
76
|
-
|
|
98
|
+
You have two Ground Control tools:
|
|
77
99
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
100
|
+
1) establish_ground_control
|
|
101
|
+
- Use this EARLY in a mission/phase once you understand key facts, e.g.:
|
|
102
|
+
- The base URL (e.g. "https://ult.ultimarii.app")
|
|
103
|
+
- The current URL and what the page represents (routeRole: "login", "chat", "dashboard", etc.)
|
|
104
|
+
- Whether you are logged in or not.
|
|
105
|
+
- Call it once per mission phase (premission / mission / postmission) when you first
|
|
106
|
+
have a clear picture of where you are and what the app state is.
|
|
107
|
+
- Provide as many known fields as you can, but do not guess wildly. Unknown fields can be omitted.
|
|
108
|
+
|
|
109
|
+
2) update_ground_control
|
|
110
|
+
- Use this when important truths change or become clearer, for example:
|
|
111
|
+
- URL or routeRole changes after navigation or login.
|
|
112
|
+
- Login state changes (loggedIn false → true or vice versa).
|
|
113
|
+
- You discover the displayed user name or tenant.
|
|
114
|
+
- You identify a new page role (e.g. "chat", "settings", "feedback-form").
|
|
115
|
+
- Only update fields that changed or that you now know with more confidence.
|
|
116
|
+
- Optionally add short telemetry breadcrumbs about major milestones,
|
|
117
|
+
e.g. "Logged in and reached chat workspace".
|
|
118
|
+
|
|
119
|
+
General rules:
|
|
120
|
+
- Keep Ground Control high-level and stable; do NOT spam small, noisy updates.
|
|
121
|
+
- Before leaving a page for a new flow, make sure Ground Control reflects the currentUrl
|
|
122
|
+
and routeRole so you can reason about where you are and where you’ve been.
|
|
123
|
+
- Respect constraints (e.g. stayWithinBaseUrl = true) when deciding whether it is safe to navigate.
|
|
124
|
+
|
|
125
|
+
Use Ground Control as your persistent mission memory about:
|
|
126
|
+
- Where the app lives (baseUrl),
|
|
127
|
+
- Where you are (currentUrl, routeRole),
|
|
128
|
+
- Who you are logged in as (session),
|
|
129
|
+
- Any constraints about staying on the correct site.
|
|
130
|
+
`.trim();
|
|
131
|
+
|
|
132
|
+
if (groundSummary) {
|
|
133
|
+
systemContent +=
|
|
134
|
+
'\n\n' +
|
|
135
|
+
`
|
|
136
|
+
──── Ground Control Snapshot ────
|
|
137
|
+
🛰️ ${JSON.stringify(groundSummary, null, 2)}
|
|
138
|
+
`.trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const messages = [
|
|
142
|
+
{ role: 'system', content: systemContent },
|
|
81
143
|
{ role: 'user', content: userContent },
|
|
82
144
|
];
|
|
83
145
|
|
|
@@ -91,33 +153,34 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
|
91
153
|
null, // currentStep
|
|
92
154
|
{
|
|
93
155
|
steps,
|
|
156
|
+
stepsArchive,
|
|
94
157
|
missionName,
|
|
158
|
+
retryLimit,
|
|
159
|
+
groundControl,
|
|
95
160
|
onStep: (s) => {
|
|
96
161
|
// Append each step as a JSON line
|
|
97
162
|
try {
|
|
98
163
|
fs.appendFileSync(stepFile, JSON.stringify(s) + '\n');
|
|
99
164
|
// Keep memory in check: retain only the last ~20 steps in RAM
|
|
100
165
|
if (steps.length > 20) steps.splice(0, steps.length - 20);
|
|
166
|
+
stepsArchive.push(s);
|
|
101
167
|
} catch {} // best-effort; don’t crash the agent
|
|
102
168
|
}
|
|
103
169
|
}
|
|
104
170
|
);
|
|
105
171
|
|
|
106
172
|
function dedupeSteps(steps) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const key = `${s.turn}::${s.summary}::${first}`;
|
|
112
|
-
if (!seen.has(key)) {
|
|
113
|
-
seen.add(key);
|
|
114
|
-
out.push(s);
|
|
173
|
+
const copy = [...steps];
|
|
174
|
+
copy.sort((a, b) => {
|
|
175
|
+
if (Number.isFinite(a?._seq) && Number.isFinite(b?._seq)) {
|
|
176
|
+
return a._seq - b._seq;
|
|
115
177
|
}
|
|
116
|
-
|
|
117
|
-
|
|
178
|
+
return (a?.turn ?? 0) - (b?.turn ?? 0);
|
|
179
|
+
});
|
|
180
|
+
return copy;
|
|
118
181
|
}
|
|
119
182
|
|
|
120
|
-
const compact = dedupeSteps(steps);
|
|
183
|
+
const compact = dedupeSteps(stepsArchive.length ? stepsArchive : steps);
|
|
121
184
|
|
|
122
185
|
// Snapshot steps (avoid retaining references to mutable arrays)
|
|
123
186
|
missionResults.push({
|
package/core/config.js
CHANGED
|
@@ -52,6 +52,36 @@ export function getMaxTurns(cfg, fallback = 20) {
|
|
|
52
52
|
return n;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Resolve retry limit per turn (attempts - 1) with clamping.
|
|
57
|
+
* Priority: env TESTRONAUT_RETRY_LIMIT → config.retryLimit → fallback
|
|
58
|
+
*
|
|
59
|
+
* @param {object} cfg
|
|
60
|
+
* @param {number} [fallback=2]
|
|
61
|
+
* @returns {{ value:number, source:'env'|'config'|'default', clamped:boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function getRetryLimit(cfg, fallback = 2) {
|
|
64
|
+
const clampRetry = (n) => {
|
|
65
|
+
const clamped = Math.min(10, Math.max(1, n));
|
|
66
|
+
return { value: clamped, clamped: clamped !== n };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const envVal = Number(process.env.TESTRONAUT_RETRY_LIMIT);
|
|
70
|
+
if (Number.isFinite(envVal)) {
|
|
71
|
+
const { value, clamped } = clampRetry(envVal);
|
|
72
|
+
return { value, source: 'env', clamped };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cfgVal = Number(cfg?.retryLimit);
|
|
76
|
+
if (Number.isFinite(cfgVal)) {
|
|
77
|
+
const { value, clamped } = clampRetry(cfgVal);
|
|
78
|
+
return { value, source: 'config', clamped };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { value, clamped } = clampRetry(fallback);
|
|
82
|
+
return { value, source: 'default', clamped };
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
/**
|
|
56
86
|
* Baseline limits for mission runs. Adjust here for global defaults.
|
|
57
87
|
*
|
package/core/turnLoop.js
CHANGED
|
@@ -47,6 +47,13 @@ import { resolveProviderModel } from '../llm/modelResolver.js';
|
|
|
47
47
|
import { getLLM } from '../llm/llmFactory.js';
|
|
48
48
|
import { summarizeTurnIntentFromMessage } from './turnIntent.js';
|
|
49
49
|
import { redactArgs } from './redaction.js';
|
|
50
|
+
import {
|
|
51
|
+
sanitizeHeavyToolHistory,
|
|
52
|
+
pruneConversationContext,
|
|
53
|
+
createEmptyGroundControl,
|
|
54
|
+
applyGroundControlUpdate,
|
|
55
|
+
recordGroundTelemetry,
|
|
56
|
+
} from '../tools/contextControl.js';
|
|
50
57
|
|
|
51
58
|
// ─────────────────────────────────────────────
|
|
52
59
|
// STEP 0: Resolve provider + model and initialize adapter
|
|
@@ -60,7 +67,28 @@ const llm = getLLM(PROVIDER_ID);
|
|
|
60
67
|
let totalTokensUsed = 0;
|
|
61
68
|
let turnTimestamps = [];
|
|
62
69
|
let shouldBackoff;
|
|
63
|
-
//
|
|
70
|
+
const DEFAULT_TURN_RETRY_LIMIT = 2; // number of retries (not counting initial attempt)
|
|
71
|
+
const TURN_RETRY_BASE_DELAY_MS = 500;
|
|
72
|
+
|
|
73
|
+
// Certain tools mutate the browser or produce side effects that are
|
|
74
|
+
// *useful for humans* (reports/screenshots), but do not carry semantic
|
|
75
|
+
// information that the LLM needs on subsequent turns.
|
|
76
|
+
// For these, we respond to the tool_call with a tiny stub ("OK"/error)
|
|
77
|
+
// instead of the full result payload to avoid bloating context.
|
|
78
|
+
//
|
|
79
|
+
// NOTE: We *still* log the full result in `step.events` and step metadata.
|
|
80
|
+
const FIRE_AND_FORGET_TOOLS = new Set([
|
|
81
|
+
'screenshot',
|
|
82
|
+
'click',
|
|
83
|
+
'click_text',
|
|
84
|
+
// 'expand_menu',
|
|
85
|
+
'fill',
|
|
86
|
+
'upload_file',
|
|
87
|
+
'download_file',
|
|
88
|
+
'click_and_follow_popup',
|
|
89
|
+
'switch_to_page',
|
|
90
|
+
'close_current_page',
|
|
91
|
+
]);
|
|
64
92
|
|
|
65
93
|
/**
|
|
66
94
|
* Utility: format byte counts into human-readable strings.
|
|
@@ -143,17 +171,27 @@ export const turnLoop = async (
|
|
|
143
171
|
currentTurn = 0,
|
|
144
172
|
retryCount = 0,
|
|
145
173
|
currentStep = {},
|
|
146
|
-
ctx = {} // { steps, missionName }
|
|
174
|
+
ctx = {} // { steps, missionName, groundControl }
|
|
147
175
|
) => {
|
|
148
|
-
const { steps = [], missionName } = ctx;
|
|
176
|
+
const { steps = [], missionName, groundControl = createEmptyGroundControl(), retryLimit: retryLimitRaw } = ctx;
|
|
177
|
+
const stepsArchive = ctx.stepsArchive || steps;
|
|
178
|
+
const retryLimitClamped = Math.min(10, Math.max(1, Number.isFinite(retryLimitRaw) ? retryLimitRaw : DEFAULT_TURN_RETRY_LIMIT)); // retries allowed (excludes initial)
|
|
179
|
+
const maxAttempts = retryLimitClamped + 1; // includes initial attempt
|
|
180
|
+
ctx.groundControl = groundControl;
|
|
149
181
|
let agentMemory = { lastMenuExpanded: false };
|
|
182
|
+
let turnRetries = 0;
|
|
183
|
+
let stepSeq = ctx._stepSeq || 0;
|
|
150
184
|
|
|
151
185
|
// Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
|
|
152
186
|
// Idempotent recorder – prevents accidental double-push
|
|
153
187
|
const recordStep = (step) => {
|
|
154
188
|
if (!step || step.__recorded) return;
|
|
189
|
+
step._seq = stepSeq++;
|
|
155
190
|
step.__recorded = true;
|
|
156
191
|
try { steps.push(step); } catch {}
|
|
192
|
+
if (stepsArchive && stepsArchive !== steps) {
|
|
193
|
+
try { stepsArchive.push(step); } catch {}
|
|
194
|
+
}
|
|
157
195
|
try { ctx?.onStep?.(step); } catch {}
|
|
158
196
|
};
|
|
159
197
|
|
|
@@ -163,7 +201,19 @@ export const turnLoop = async (
|
|
|
163
201
|
for (let turn = currentTurn; turn < maxTurns; turn++) {
|
|
164
202
|
console.log(`\n🔄 Turn ${turn + 1}/${maxTurns}`);
|
|
165
203
|
let response;
|
|
166
|
-
const
|
|
204
|
+
const attempt = turnRetries + 1;
|
|
205
|
+
const step = {
|
|
206
|
+
turn,
|
|
207
|
+
retryAttempt: attempt,
|
|
208
|
+
retryLimit: retryLimitClamped, // number of retries allowed (excludes initial)
|
|
209
|
+
events: [],
|
|
210
|
+
result: '🟡 In Progress',
|
|
211
|
+
missionName,
|
|
212
|
+
};
|
|
213
|
+
if (attempt > 1) {
|
|
214
|
+
const retryNumber = attempt - 1;
|
|
215
|
+
step.events.push(`🔁 Re-attempt ${retryNumber}/${retryLimitClamped} for turn`);
|
|
216
|
+
}
|
|
167
217
|
|
|
168
218
|
try {
|
|
169
219
|
// Refresh token usage window (rolling 60 seconds)
|
|
@@ -173,11 +223,9 @@ export const turnLoop = async (
|
|
|
173
223
|
({ totalTokensUsed, turnTimestamps, shouldBackoff } =
|
|
174
224
|
await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
|
|
175
225
|
if (shouldBackoff) {
|
|
176
|
-
// step.result = '🟡 In Progress';
|
|
177
226
|
recordStep(step); // ✅ write the partial step before sleeping
|
|
178
227
|
turn -= 1; // retry same turn index
|
|
179
228
|
continue;
|
|
180
|
-
// return await turnLoop(browser, messages, maxTurns, turn, currentStep);
|
|
181
229
|
}
|
|
182
230
|
|
|
183
231
|
// Verify message structure integrity before requesting next model step
|
|
@@ -186,7 +234,7 @@ export const turnLoop = async (
|
|
|
186
234
|
step.events.push('❌ Tool call structure invalid and no placeholders inserted.');
|
|
187
235
|
step.result = '❌ Failure';
|
|
188
236
|
recordStep(step);
|
|
189
|
-
return steps;
|
|
237
|
+
return { success: false, steps: stepsArchive };
|
|
190
238
|
}
|
|
191
239
|
|
|
192
240
|
// Detect orphaned assistant tool calls with no matching tool responses
|
|
@@ -206,9 +254,21 @@ export const turnLoop = async (
|
|
|
206
254
|
step.events.push('🛑 Detected assistant tool calls without matching tool responses');
|
|
207
255
|
step.result = '❌ Failure';
|
|
208
256
|
recordStep(step);
|
|
209
|
-
return steps;
|
|
257
|
+
return { success: false, steps: stepsArchive };
|
|
210
258
|
}
|
|
211
259
|
|
|
260
|
+
// Before calling the model, trim/sanitize the conversation context.
|
|
261
|
+
// 1) Replace older heavy tool payloads with small stubs.
|
|
262
|
+
sanitizeHeavyToolHistory(messages, { keepRecentPerTool: 2 });
|
|
263
|
+
|
|
264
|
+
// 2) Hard-cap total history length (system messages + last N others).
|
|
265
|
+
{
|
|
266
|
+
const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 40 });
|
|
267
|
+
messages.length = 0;
|
|
268
|
+
messages.push(...pruned);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
212
272
|
// ─────────────────────────────────────────────
|
|
213
273
|
// STEP 2: Request next reasoning turn from model
|
|
214
274
|
// ─────────────────────────────────────────────
|
|
@@ -228,7 +288,6 @@ export const turnLoop = async (
|
|
|
228
288
|
const delay = Math.min(60000, 2 ** retryCount * 2000);
|
|
229
289
|
console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
|
|
230
290
|
step.events.push(`⏳ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
|
|
231
|
-
// step.result = '🟡 In Progress';
|
|
232
291
|
recordStep(step); // ✅ persist this step before sleeping
|
|
233
292
|
await wait(delay);
|
|
234
293
|
|
|
@@ -237,10 +296,8 @@ export const turnLoop = async (
|
|
|
237
296
|
step.events.push('❌ Too many retries. Exiting.');
|
|
238
297
|
step.result = '❌ Failure';
|
|
239
298
|
recordStep(step);
|
|
240
|
-
return steps;
|
|
299
|
+
return { success: false, steps: stepsArchive };
|
|
241
300
|
}
|
|
242
|
-
// return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
243
|
-
// reattempt same turn after delay; keep same loop, bump retryCount
|
|
244
301
|
retryCount += 1;
|
|
245
302
|
turn -= 1;
|
|
246
303
|
continue;
|
|
@@ -249,7 +306,7 @@ export const turnLoop = async (
|
|
|
249
306
|
step.events.push(`❌ Bad request: ${err.message}`);
|
|
250
307
|
step.result = '❌ Failure';
|
|
251
308
|
recordStep(step);
|
|
252
|
-
return steps;
|
|
309
|
+
return { success: false, steps: stepsArchive };
|
|
253
310
|
} else {
|
|
254
311
|
throw err;
|
|
255
312
|
}
|
|
@@ -285,6 +342,7 @@ export const turnLoop = async (
|
|
|
285
342
|
if (msg.tool_calls?.length) {
|
|
286
343
|
console.log('Processing tool calls...');
|
|
287
344
|
const toolResponses = [];
|
|
345
|
+
let hadToolIssues = false;
|
|
288
346
|
|
|
289
347
|
for (const call of msg.tool_calls) {
|
|
290
348
|
const fnName = call.function.name;
|
|
@@ -301,9 +359,10 @@ export const turnLoop = async (
|
|
|
301
359
|
} catch (e) {
|
|
302
360
|
errorMessage = `ERROR: ${e.message}`;
|
|
303
361
|
result = errorMessage;
|
|
362
|
+
hadToolIssues = true;
|
|
304
363
|
}
|
|
305
364
|
|
|
306
|
-
// Capture and log any file upload/download events
|
|
365
|
+
// Capture and log any file upload/download events (for reports)
|
|
307
366
|
try {
|
|
308
367
|
const maybeJson = JSON.parse(result);
|
|
309
368
|
if (maybeJson && maybeJson._testronaut_file_event) {
|
|
@@ -315,14 +374,16 @@ export const turnLoop = async (
|
|
|
315
374
|
step.events.push(msgLine);
|
|
316
375
|
console.log(msgLine);
|
|
317
376
|
}
|
|
318
|
-
} catch {
|
|
377
|
+
} catch {
|
|
378
|
+
// non-JSON results ignored
|
|
379
|
+
}
|
|
319
380
|
|
|
320
381
|
console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : '✅ Success');
|
|
321
382
|
const truncated = result.length > 1000 ? result.slice(0, 1000) + '…' : result;
|
|
322
383
|
step.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : '✅ Success'}`);
|
|
323
384
|
step.events.push(`[tool ] ← ${truncated}`);
|
|
324
385
|
|
|
325
|
-
// Screenshot detection
|
|
386
|
+
// Screenshot detection (for report metadata only)
|
|
326
387
|
if (fnName === 'screenshot') {
|
|
327
388
|
const match = result.match(/screenshot.*?saved at: (.+\.png)/i);
|
|
328
389
|
if (match && match[1]) {
|
|
@@ -331,13 +392,33 @@ export const turnLoop = async (
|
|
|
331
392
|
}
|
|
332
393
|
}
|
|
333
394
|
|
|
334
|
-
|
|
395
|
+
if (fnName === 'set_ground_control_state') {
|
|
396
|
+
applyGroundControlUpdate(groundControl, args);
|
|
397
|
+
result = JSON.stringify({ ok: true, groundControl });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (fnName === 'record_mission_telemetry') {
|
|
401
|
+
const recorded = recordGroundTelemetry(groundControl, args, { turn });
|
|
402
|
+
result = JSON.stringify({ ok: true, recorded });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Decide what to send back to the LLM for this tool.
|
|
406
|
+
// - For "fire-and-forget" tools, send a tiny stub (OK/error) to avoid
|
|
407
|
+
// bloating context with large payloads (file JSON, etc.).
|
|
408
|
+
// - For semantic tools (get_dom, check_text, etc.), send full result.
|
|
409
|
+
let contentForModel;
|
|
410
|
+
if (FIRE_AND_FORGET_TOOLS.has(fnName)) {
|
|
411
|
+
contentForModel = errorMessage || 'OK';
|
|
412
|
+
} else {
|
|
413
|
+
contentForModel = result;
|
|
414
|
+
}
|
|
415
|
+
|
|
335
416
|
toolResponses.push({
|
|
336
417
|
role: 'tool',
|
|
337
418
|
tool_call_id: call.id,
|
|
338
419
|
name: fnName,
|
|
339
420
|
type: 'function',
|
|
340
|
-
content:
|
|
421
|
+
content: contentForModel,
|
|
341
422
|
});
|
|
342
423
|
|
|
343
424
|
// After interactive DOM actions, refresh model context
|
|
@@ -360,7 +441,21 @@ export const turnLoop = async (
|
|
|
360
441
|
|
|
361
442
|
// Merge new assistant + tool responses back into conversation
|
|
362
443
|
messages.push(msg, ...toolResponses);
|
|
363
|
-
|
|
444
|
+
if (hadToolIssues && turnRetries < retryLimitClamped) {
|
|
445
|
+
step.result = '⏳ Retrying turn';
|
|
446
|
+
const retryNumber = attempt - 1;
|
|
447
|
+
step.events.push(`🔁 Re-attempt ${retryNumber}/${retryLimitClamped} after tool issues`);
|
|
448
|
+
recordStep(step);
|
|
449
|
+
turnRetries += 1;
|
|
450
|
+
const delay = Math.min(TURN_RETRY_BASE_DELAY_MS * 2 ** (turnRetries - 1), 2000);
|
|
451
|
+
await wait(delay);
|
|
452
|
+
turn -= 1; // re-use the same turn index
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// reset retries after a clean turn or after exhausting retries
|
|
457
|
+
turnRetries = 0;
|
|
458
|
+
step.result = hadToolIssues ? '⚠️ Turn Issues' : '✅ Passed';
|
|
364
459
|
recordStep(step);
|
|
365
460
|
continue;
|
|
366
461
|
}
|
|
@@ -373,7 +468,8 @@ export const turnLoop = async (
|
|
|
373
468
|
step.events.push(finalResponse.finalMessage);
|
|
374
469
|
step.result = finalResponse.success ? '✅ Mission Success' : '❌ Mission Failure';
|
|
375
470
|
recordStep(step);
|
|
376
|
-
|
|
471
|
+
turnRetries = 0;
|
|
472
|
+
return { success: finalResponse.success, finalMessage: finalResponse.finalMessage, steps: stepsArchive };
|
|
377
473
|
}
|
|
378
474
|
|
|
379
475
|
// ─────────────────────────────────────────────
|
|
@@ -387,5 +483,8 @@ export const turnLoop = async (
|
|
|
387
483
|
// This is a meaningful turn even without tool calls — record it.
|
|
388
484
|
step.result = step.result || '🟡 In Progress';
|
|
389
485
|
recordStep(step);
|
|
486
|
+
turnRetries = 0;
|
|
390
487
|
}
|
|
488
|
+
|
|
489
|
+
return { success: false, steps: stepsArchive };
|
|
391
490
|
};
|
|
@@ -109,6 +109,19 @@ function safeJsonParse(s) {
|
|
|
109
109
|
try { return JSON.parse(s ?? '{}'); } catch { return null; }
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Gemini's functionDeclarations reject JSON Schema's `additionalProperties`; prune it recursively.
|
|
113
|
+
function stripAdditionalProperties(obj) {
|
|
114
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
115
|
+
if (Array.isArray(obj)) return obj.map(stripAdditionalProperties);
|
|
116
|
+
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
119
|
+
if (k === 'additionalProperties') continue; // Gemini function declarations do not support this key
|
|
120
|
+
out[k] = stripAdditionalProperties(v);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
/**
|
|
113
126
|
* Convert a Gemini candidate → OpenAI-like assistant message.
|
|
114
127
|
* - Collects text parts into `content`.
|
|
@@ -160,7 +173,7 @@ export class GeminiProvider {
|
|
|
160
173
|
functionDeclarations: tools.map(t => ({
|
|
161
174
|
name: t.function?.name ?? t.name,
|
|
162
175
|
description: t.description ?? '',
|
|
163
|
-
parameters: t.function?.parameters ?? t.parameters ?? {}, // JSON schema
|
|
176
|
+
parameters: stripAdditionalProperties(t.function?.parameters ?? t.parameters ?? {}), // JSON schema sans additionalProperties (unsupported by Gemini)
|
|
164
177
|
})),
|
|
165
178
|
}]
|
|
166
179
|
: undefined;
|