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 +75 -13
- package/core/turnLoop.js +69 -13
- package/llm/gemini/geminiProvider.js +14 -1
- package/package.json +1 -1
- package/tests/llmTests/geminiProvider.test.js +29 -0
- package/tests/toolsTests/contextControl.test.js +595 -0
- package/tests/toolsTests/domControl.test.js +329 -0
- package/tools/chromeBrowser.js +4 -0
- package/tools/contextControl.js +307 -0
- package/tools/domControl.js +129 -3
- package/tools/tokenControl.js +13 -2
- package/tools/toolSchema.js +101 -0
- package/tools/turnLoopUtils.js +0 -1
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
You have two Ground Control tools:
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
@@ -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
|
|