testronaut 1.2.4 β 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/README.md +1 -1
- package/bin/cli.js +14 -8
- package/bin/init.js +2 -1
- package/bin/initHelpers.js +1 -0
- package/core/agent.js +75 -13
- package/core/turnLoop.js +69 -19
- 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 +14 -2
- package/tools/toolSchema.js +101 -0
- package/tools/turnLoopUtils.js +0 -1
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ Current supported providers:
|
|
|
127
127
|
|
|
128
128
|
| Provider | Example Models |
|
|
129
129
|
|-----------|----------------|
|
|
130
|
-
| **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, etc. |
|
|
130
|
+
| **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, gpt-5.1, etc. |
|
|
131
131
|
| **Google Gemini** | gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-8b |
|
|
132
132
|
|
|
133
133
|
More providers coming soon (Anthropic, Mistral, etc.).
|
package/bin/cli.js
CHANGED
|
@@ -810,16 +810,22 @@ async function serveLatestReport() {
|
|
|
810
810
|
await new Promise((resolve, reject) => {
|
|
811
811
|
server.once('error', reject);
|
|
812
812
|
server.listen(0, '127.0.0.1', resolve);
|
|
813
|
-
|
|
814
|
-
process.on('SIGINT', () => {
|
|
815
|
-
console.log('\nπ Shutting downβ¦');
|
|
816
|
-
server.close(() => {
|
|
817
|
-
resolve();
|
|
818
|
-
process.exit(0);
|
|
819
|
-
});
|
|
820
|
-
});
|
|
821
813
|
});
|
|
822
814
|
|
|
815
|
+
// Move signal handling to after server is listening
|
|
816
|
+
const shutdown = () => {
|
|
817
|
+
console.log('\nπ Shutting downβ¦');
|
|
818
|
+
try {
|
|
819
|
+
server.close(); // best-effort; donβt wait for it
|
|
820
|
+
} catch (_) {
|
|
821
|
+
// ignore
|
|
822
|
+
}
|
|
823
|
+
process.exit(0);
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
process.once('SIGINT', shutdown);
|
|
827
|
+
process.once('SIGTERM', shutdown);
|
|
828
|
+
|
|
823
829
|
const address = server.address();
|
|
824
830
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
825
831
|
const baseUrl = `http://127.0.0.1:${port}`;
|
package/bin/init.js
CHANGED
|
@@ -94,9 +94,10 @@ export async function initializeTestronautProject() {
|
|
|
94
94
|
{ title: 'o3 (reasoning w/ native tool use)', value: 'o3' },
|
|
95
95
|
{ title: 'o4-mini (reasoning, cost-effective)', value: 'o4-mini' },
|
|
96
96
|
// GPT-5 family (availability varies)
|
|
97
|
-
{ title: 'GPT-5 (
|
|
97
|
+
{ title: 'GPT-5 (previous reasoning model)', value: 'gpt-5' },
|
|
98
98
|
{ title: 'GPT-5 mini (faster)', value: 'gpt-5-mini' },
|
|
99
99
|
{ title: 'GPT-5 nano (lightweight)', value: 'gpt-5-nano' },
|
|
100
|
+
{ title: 'GPT-5.1 (latest reasoning model)', value: 'gpt-5.1' },
|
|
100
101
|
],
|
|
101
102
|
// Keep prior selection highlighted if present; fallback to 4.1-mini.
|
|
102
103
|
initial: pickInitialIndex(models, config.model, 'gpt-4.1-mini')
|
package/bin/initHelpers.js
CHANGED
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
|
|
@@ -185,7 +210,6 @@ export const turnLoop = async (
|
|
|
185
210
|
console.error('β Tool call structure invalid and no placeholders inserted.');
|
|
186
211
|
step.events.push('β Tool call structure invalid and no placeholders inserted.');
|
|
187
212
|
step.result = 'β Failure';
|
|
188
|
-
steps.push(step);
|
|
189
213
|
recordStep(step);
|
|
190
214
|
return steps;
|
|
191
215
|
}
|
|
@@ -206,11 +230,22 @@ export const turnLoop = async (
|
|
|
206
230
|
console.dir(unrespondedCalls, { depth: 5 });
|
|
207
231
|
step.events.push('π Detected assistant tool calls without matching tool responses');
|
|
208
232
|
step.result = 'β Failure';
|
|
209
|
-
steps.push(step);
|
|
210
233
|
recordStep(step);
|
|
211
234
|
return steps;
|
|
212
235
|
}
|
|
213
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
|
+
|
|
214
249
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
215
250
|
// STEP 2: Request next reasoning turn from model
|
|
216
251
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -230,7 +265,6 @@ export const turnLoop = async (
|
|
|
230
265
|
const delay = Math.min(60000, 2 ** retryCount * 2000);
|
|
231
266
|
console.warn(`β οΈ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
|
|
232
267
|
step.events.push(`β³ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
|
|
233
|
-
// step.result = 'π‘ In Progress';
|
|
234
268
|
recordStep(step); // β
persist this step before sleeping
|
|
235
269
|
await wait(delay);
|
|
236
270
|
|
|
@@ -238,12 +272,9 @@ export const turnLoop = async (
|
|
|
238
272
|
console.error('β Too many retries. Exiting.');
|
|
239
273
|
step.events.push('β Too many retries. Exiting.');
|
|
240
274
|
step.result = 'β Failure';
|
|
241
|
-
steps.push(step);
|
|
242
275
|
recordStep(step);
|
|
243
276
|
return steps;
|
|
244
277
|
}
|
|
245
|
-
// return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
246
|
-
// reattempt same turn after delay; keep same loop, bump retryCount
|
|
247
278
|
retryCount += 1;
|
|
248
279
|
turn -= 1;
|
|
249
280
|
continue;
|
|
@@ -251,7 +282,6 @@ export const turnLoop = async (
|
|
|
251
282
|
console.error('β Bad request:', err.message);
|
|
252
283
|
step.events.push(`β Bad request: ${err.message}`);
|
|
253
284
|
step.result = 'β Failure';
|
|
254
|
-
steps.push(step);
|
|
255
285
|
recordStep(step);
|
|
256
286
|
return steps;
|
|
257
287
|
} else {
|
|
@@ -307,7 +337,7 @@ export const turnLoop = async (
|
|
|
307
337
|
result = errorMessage;
|
|
308
338
|
}
|
|
309
339
|
|
|
310
|
-
// Capture and log any file upload/download events
|
|
340
|
+
// Capture and log any file upload/download events (for reports)
|
|
311
341
|
try {
|
|
312
342
|
const maybeJson = JSON.parse(result);
|
|
313
343
|
if (maybeJson && maybeJson._testronaut_file_event) {
|
|
@@ -319,14 +349,16 @@ export const turnLoop = async (
|
|
|
319
349
|
step.events.push(msgLine);
|
|
320
350
|
console.log(msgLine);
|
|
321
351
|
}
|
|
322
|
-
} catch {
|
|
352
|
+
} catch {
|
|
353
|
+
// non-JSON results ignored
|
|
354
|
+
}
|
|
323
355
|
|
|
324
356
|
console.log(`[tool ] β ${fnName} result:`, errorMessage ? 'β Failed' : 'β
Success');
|
|
325
357
|
const truncated = result.length > 1000 ? result.slice(0, 1000) + 'β¦' : result;
|
|
326
358
|
step.events.push(`[tool ] β ${fnName} result: ${errorMessage ? 'β Failed' : 'β
Success'}`);
|
|
327
359
|
step.events.push(`[tool ] β ${truncated}`);
|
|
328
360
|
|
|
329
|
-
// Screenshot detection
|
|
361
|
+
// Screenshot detection (for report metadata only)
|
|
330
362
|
if (fnName === 'screenshot') {
|
|
331
363
|
const match = result.match(/screenshot.*?saved at: (.+\.png)/i);
|
|
332
364
|
if (match && match[1]) {
|
|
@@ -335,13 +367,33 @@ export const turnLoop = async (
|
|
|
335
367
|
}
|
|
336
368
|
}
|
|
337
369
|
|
|
338
|
-
|
|
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
|
+
|
|
339
391
|
toolResponses.push({
|
|
340
392
|
role: 'tool',
|
|
341
393
|
tool_call_id: call.id,
|
|
342
394
|
name: fnName,
|
|
343
395
|
type: 'function',
|
|
344
|
-
content:
|
|
396
|
+
content: contentForModel,
|
|
345
397
|
});
|
|
346
398
|
|
|
347
399
|
// After interactive DOM actions, refresh model context
|
|
@@ -365,7 +417,6 @@ export const turnLoop = async (
|
|
|
365
417
|
// Merge new assistant + tool responses back into conversation
|
|
366
418
|
messages.push(msg, ...toolResponses);
|
|
367
419
|
step.result = 'β
Passed';
|
|
368
|
-
steps.push(step);
|
|
369
420
|
recordStep(step);
|
|
370
421
|
continue;
|
|
371
422
|
}
|
|
@@ -377,7 +428,6 @@ export const turnLoop = async (
|
|
|
377
428
|
if (finalResponse !== null) {
|
|
378
429
|
step.events.push(finalResponse.finalMessage);
|
|
379
430
|
step.result = finalResponse.success ? 'β
Mission Success' : 'β Mission Failure';
|
|
380
|
-
steps.push(step);
|
|
381
431
|
recordStep(step);
|
|
382
432
|
return { success: finalResponse.finalMessage, steps };
|
|
383
433
|
}
|
|
@@ -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
|
|