testronaut 1.2.5 β†’ 1.3.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/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
 
@@ -46,6 +47,9 @@ 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 = [];
51
55
  // Unique-ish JSONL file for this mission’s steps
@@ -62,22 +66,79 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
62
66
  ? goal.goal
63
67
  : goal?.goal?.toString?.() ?? String(goal.goal);
64
68
 
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:
69
+ // πŸ”­ Build a compact Ground Control snapshot for the prompt
70
+ const groundSummary = summarizeGroundControlForPrompt(groundControl);
71
+ let systemContent = `
72
+ You are an autonomous web agent piloting a browser for end-to-end testing.
73
+
74
+ Core behavior:
75
+ - Use function calls to complete the user's goal.
76
+ - If you are unsure of selectors for inputs or buttons, call 'get_dom' to retrieve page HTML,
77
+ analyze it, then choose selectors based on labels, names, types, and placeholder values.
78
+ - If you want to click a button labeled "Sign out", prefer:
79
+
80
+ click_text({ text: "Sign out" })
81
+
82
+ Do NOT use CSS selectors like 'button:contains("Sign out")' β€” they are invalid.
83
+
84
+ After completing the goal, respond with a final plain-text message starting with
85
+ either "SUCCESS:" or "FAILURE:" followed by a short explanation.
86
+
87
+ ──── Ground Control (persistent mission state) ────
88
+ You also have special tools to manage a compact, non-pruned state called "Ground Control".
89
+ Ground Control tracks high-level truths about the app and session, such as:
73
90
 
74
- click_text({ text: "Sign out" })
91
+ - app: { baseUrl, currentUrl, routeRole } // where the app lives and where you are now
92
+ - session: { loggedIn, userLabel, tenant } // login state and identity
93
+ - navigation:{ currentLabel } // human label for the current view
94
+ - constraints:{ stayWithinBaseUrl } // domain / navigation constraints
95
+ - telemetry: [{ ts, kind, text, status, turn, extra }] // optional breadcrumbs
75
96
 
76
- Do NOT use CSS selectors like 'button:contains("Sign out")' β€” they are invalid.
97
+ You have two Ground Control tools:
77
98
 
78
- After completing the goal, respond with a final plain-text message starting with SUCCESS or FAILURE.
79
- `.trim(),
80
- },
99
+ 1) establish_ground_control
100
+ - Use this EARLY in a mission/phase once you understand key facts, e.g.:
101
+ - The base URL (e.g. "https://ult.ultimarii.app")
102
+ - The current URL and what the page represents (routeRole: "login", "chat", "dashboard", etc.)
103
+ - Whether you are logged in or not.
104
+ - Call it once per mission phase (premission / mission / postmission) when you first
105
+ have a clear picture of where you are and what the app state is.
106
+ - Provide as many known fields as you can, but do not guess wildly. Unknown fields can be omitted.
107
+
108
+ 2) update_ground_control
109
+ - Use this when important truths change or become clearer, for example:
110
+ - URL or routeRole changes after navigation or login.
111
+ - Login state changes (loggedIn false β†’ true or vice versa).
112
+ - You discover the displayed user name or tenant.
113
+ - You identify a new page role (e.g. "chat", "settings", "feedback-form").
114
+ - Only update fields that changed or that you now know with more confidence.
115
+ - Optionally add short telemetry breadcrumbs about major milestones,
116
+ e.g. "Logged in and reached chat workspace".
117
+
118
+ General rules:
119
+ - Keep Ground Control high-level and stable; do NOT spam small, noisy updates.
120
+ - Before leaving a page for a new flow, make sure Ground Control reflects the currentUrl
121
+ and routeRole so you can reason about where you are and where you’ve been.
122
+ - Respect constraints (e.g. stayWithinBaseUrl = true) when deciding whether it is safe to navigate.
123
+
124
+ Use Ground Control as your persistent mission memory about:
125
+ - Where the app lives (baseUrl),
126
+ - Where you are (currentUrl, routeRole),
127
+ - Who you are logged in as (session),
128
+ - Any constraints about staying on the correct site.
129
+ `.trim();
130
+
131
+ if (groundSummary) {
132
+ systemContent +=
133
+ '\n\n' +
134
+ `
135
+ ──── Ground Control Snapshot ────
136
+ πŸ›°οΈ ${JSON.stringify(groundSummary, null, 2)}
137
+ `.trim();
138
+ }
139
+
140
+ const messages = [
141
+ { role: 'system', content: systemContent },
81
142
  { role: 'user', content: userContent },
82
143
  ];
83
144
 
@@ -92,6 +153,7 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
92
153
  {
93
154
  steps,
94
155
  missionName,
156
+ groundControl,
95
157
  onStep: (s) => {
96
158
  // Append each step as a JSON line
97
159
  try {
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,26 @@ const llm = getLLM(PROVIDER_ID);
60
67
  let totalTokensUsed = 0;
61
68
  let turnTimestamps = [];
62
69
  let shouldBackoff;
63
- // let steps = [];
70
+
71
+ // Certain tools mutate the browser or produce side effects that are
72
+ // *useful for humans* (reports/screenshots), but do not carry semantic
73
+ // information that the LLM needs on subsequent turns.
74
+ // For these, we respond to the tool_call with a tiny stub ("OK"/error)
75
+ // instead of the full result payload to avoid bloating context.
76
+ //
77
+ // NOTE: We *still* log the full result in `step.events` and step metadata.
78
+ const FIRE_AND_FORGET_TOOLS = new Set([
79
+ 'screenshot',
80
+ 'click',
81
+ 'click_text',
82
+ // 'expand_menu',
83
+ 'fill',
84
+ 'upload_file',
85
+ 'download_file',
86
+ 'click_and_follow_popup',
87
+ 'switch_to_page',
88
+ 'close_current_page',
89
+ ]);
64
90
 
65
91
  /**
66
92
  * Utility: format byte counts into human-readable strings.
@@ -143,9 +169,10 @@ export const turnLoop = async (
143
169
  currentTurn = 0,
144
170
  retryCount = 0,
145
171
  currentStep = {},
146
- ctx = {} // { steps, missionName }
172
+ ctx = {} // { steps, missionName, groundControl }
147
173
  ) => {
148
- const { steps = [], missionName } = ctx;
174
+ const { steps = [], missionName, groundControl = createEmptyGroundControl() } = ctx;
175
+ ctx.groundControl = groundControl;
149
176
  let agentMemory = { lastMenuExpanded: false };
150
177
 
151
178
  // Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
@@ -173,11 +200,9 @@ export const turnLoop = async (
173
200
  ({ totalTokensUsed, turnTimestamps, shouldBackoff } =
174
201
  await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
175
202
  if (shouldBackoff) {
176
- // step.result = '🟑 In Progress';
177
203
  recordStep(step); // βœ… write the partial step before sleeping
178
204
  turn -= 1; // retry same turn index
179
205
  continue;
180
- // return await turnLoop(browser, messages, maxTurns, turn, currentStep);
181
206
  }
182
207
 
183
208
  // Verify message structure integrity before requesting next model step
@@ -209,6 +234,18 @@ export const turnLoop = async (
209
234
  return steps;
210
235
  }
211
236
 
237
+ // Before calling the model, trim/sanitize the conversation context.
238
+ // 1) Replace older heavy tool payloads with small stubs.
239
+ sanitizeHeavyToolHistory(messages, { keepRecentPerTool: 2 });
240
+
241
+ // 2) Hard-cap total history length (system messages + last N others).
242
+ {
243
+ const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 40 });
244
+ messages.length = 0;
245
+ messages.push(...pruned);
246
+ }
247
+
248
+
212
249
  // ─────────────────────────────────────────────
213
250
  // STEP 2: Request next reasoning turn from model
214
251
  // ─────────────────────────────────────────────
@@ -228,7 +265,6 @@ export const turnLoop = async (
228
265
  const delay = Math.min(60000, 2 ** retryCount * 2000);
229
266
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
230
267
  step.events.push(`⏳ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
231
- // step.result = '🟑 In Progress';
232
268
  recordStep(step); // βœ… persist this step before sleeping
233
269
  await wait(delay);
234
270
 
@@ -239,8 +275,6 @@ export const turnLoop = async (
239
275
  recordStep(step);
240
276
  return steps;
241
277
  }
242
- // return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
243
- // reattempt same turn after delay; keep same loop, bump retryCount
244
278
  retryCount += 1;
245
279
  turn -= 1;
246
280
  continue;
@@ -303,7 +337,7 @@ export const turnLoop = async (
303
337
  result = errorMessage;
304
338
  }
305
339
 
306
- // Capture and log any file upload/download events
340
+ // Capture and log any file upload/download events (for reports)
307
341
  try {
308
342
  const maybeJson = JSON.parse(result);
309
343
  if (maybeJson && maybeJson._testronaut_file_event) {
@@ -315,14 +349,16 @@ export const turnLoop = async (
315
349
  step.events.push(msgLine);
316
350
  console.log(msgLine);
317
351
  }
318
- } catch { /* non-JSON results ignored */ }
352
+ } catch {
353
+ // non-JSON results ignored
354
+ }
319
355
 
320
356
  console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : 'βœ… Success');
321
357
  const truncated = result.length > 1000 ? result.slice(0, 1000) + '…' : result;
322
358
  step.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : 'βœ… Success'}`);
323
359
  step.events.push(`[tool ] ← ${truncated}`);
324
360
 
325
- // Screenshot detection
361
+ // Screenshot detection (for report metadata only)
326
362
  if (fnName === 'screenshot') {
327
363
  const match = result.match(/screenshot.*?saved at: (.+\.png)/i);
328
364
  if (match && match[1]) {
@@ -331,13 +367,33 @@ export const turnLoop = async (
331
367
  }
332
368
  }
333
369
 
334
- // Push corresponding tool message back to LLM
370
+ if (fnName === 'set_ground_control_state') {
371
+ applyGroundControlUpdate(groundControl, args);
372
+ result = JSON.stringify({ ok: true, groundControl });
373
+ }
374
+
375
+ if (fnName === 'record_mission_telemetry') {
376
+ const recorded = recordGroundTelemetry(groundControl, args, { turn });
377
+ result = JSON.stringify({ ok: true, recorded });
378
+ }
379
+
380
+ // Decide what to send back to the LLM for this tool.
381
+ // - For "fire-and-forget" tools, send a tiny stub (OK/error) to avoid
382
+ // bloating context with large payloads (file JSON, etc.).
383
+ // - For semantic tools (get_dom, check_text, etc.), send full result.
384
+ let contentForModel;
385
+ if (FIRE_AND_FORGET_TOOLS.has(fnName)) {
386
+ contentForModel = errorMessage || 'OK';
387
+ } else {
388
+ contentForModel = result;
389
+ }
390
+
335
391
  toolResponses.push({
336
392
  role: 'tool',
337
393
  tool_call_id: call.id,
338
394
  name: fnName,
339
395
  type: 'function',
340
- content: result,
396
+ content: contentForModel,
341
397
  });
342
398
 
343
399
  // After interactive DOM actions, refresh model context
@@ -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.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -77,6 +77,35 @@ describe('GeminiProvider', () => {
77
77
  expect(shared.getModelOpts.tools[0].functionDeclarations.map(d => d.name)).toEqual(['foo', 'bar']);
78
78
  });
79
79
 
80
+ it('strips additionalProperties from tool schemas (unsupported by Gemini)', async () => {
81
+ const prov = new GeminiProvider({ apiKey: 'gk-123' });
82
+ const tools = [
83
+ {
84
+ type: 'function',
85
+ function: {
86
+ name: 'set_ground_control_state',
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ app: {
91
+ type: 'object',
92
+ additionalProperties: false,
93
+ properties: { baseUrl: { type: 'string' } },
94
+ },
95
+ },
96
+ additionalProperties: false,
97
+ },
98
+ },
99
+ },
100
+ ];
101
+
102
+ await prov.chat({ model: 'gemini-2.5-flash', messages: [{ role: 'user', content: 'go' }], tools });
103
+
104
+ const decl = shared.getModelOpts.tools[0].functionDeclarations[0];
105
+ expect(decl.parameters.additionalProperties).toBeUndefined();
106
+ expect(decl.parameters.properties.app.additionalProperties).toBeUndefined();
107
+ });
108
+
80
109
  it('converts OpenAI-like messages β†’ Gemini contents (system prefix + tool calls + tool results)', async () => {
81
110
  const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
82
111