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 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/cliTests/cli.helpers.test.js
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 if both exist
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 byTurn = new Map(); // turn -> index in out
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
- if (!byTurn.has(turn)) {
95
+ const key = `${turn}::${attempt}`;
96
+
97
+ if (!byKey.has(key)) {
87
98
  out.push(s);
88
- byTurn.set(turn, out.length - 1);
99
+ byKey.set(key, out.length - 1);
89
100
  } else {
90
- const idx = byTurn.get(turn);
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` ascending, then by original order via array index already preserved
105
- out.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0));
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
- const messages = [
66
- {
67
- role: 'system',
68
- content: `
69
- You are an autonomous web agent. Use function calls to complete the user's goal.
70
- If you are unsure of the selectors for inputs or buttons, call 'get_dom' to retrieve page HTML,
71
- analyze it, then make your best guess based on labels, names, types, and placeholder values.
72
- If you want to click a button labeled "Sign out", prefer:
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
- click_text({ text: "Sign out" })
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
- Do NOT use CSS selectors like 'button:contains("Sign out")' — they are invalid.
98
+ You have two Ground Control tools:
77
99
 
78
- After completing the goal, respond with a final plain-text message starting with SUCCESS or FAILURE.
79
- `.trim(),
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 seen = new Set();
108
- const out = [];
109
- for (const s of steps) {
110
- const first = s?.events?.[0] || '';
111
- const key = `${s.turn}::${s.summary}::${first}`;
112
- if (!seen.has(key)) {
113
- seen.add(key);
114
- out.push(s);
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
- return out;
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
- // let steps = [];
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 step = { turn, events: [], result: '🟡 In Progress', missionName };
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 { /* non-JSON results ignored */ }
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
- // Push corresponding tool message back to LLM
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: result,
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
- step.result = '✅ Passed';
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
- return { success: finalResponse.finalMessage, steps };
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.2.5",
3
+ "version": "1.3.1",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"