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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// tests/toolsTests/domControl.test.js
|
|
2
|
+
/**
|
|
3
|
+
* domControl.test.js
|
|
4
|
+
* -------------------
|
|
5
|
+
* Purpose:
|
|
6
|
+
* Unit tests for DOM normalization helpers in domControl.js.
|
|
7
|
+
* Ensures we preserve semantic structure/text while stripping noise
|
|
8
|
+
* and collapsing long/repetitive content.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import * as cheerio from 'cheerio';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
removeNonContentTags,
|
|
16
|
+
collapseLongContent,
|
|
17
|
+
chunkBySection,
|
|
18
|
+
reduceRepeatedDivs,
|
|
19
|
+
removeObfuscatedClassNames,
|
|
20
|
+
removeAutoGeneratedClasses,
|
|
21
|
+
minifyDomWhitespace,
|
|
22
|
+
} from '../../tools/domControl.js'; // ← adjust path as needed
|
|
23
|
+
|
|
24
|
+
describe('domControl helpers', () => {
|
|
25
|
+
describe('removeNonContentTags', () => {
|
|
26
|
+
it('removes non-content tags and non-whitelisted attributes', async () => {
|
|
27
|
+
const html = `
|
|
28
|
+
<html>
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<script>console.log('hi');</script>
|
|
32
|
+
<style>.foo { color: red; }</style>
|
|
33
|
+
<link rel="stylesheet" href="style.css" />
|
|
34
|
+
</head>
|
|
35
|
+
<body data-theme="dark">
|
|
36
|
+
<!-- comment -->
|
|
37
|
+
<div id="main" data-testid="main" onclick="alert('x')">
|
|
38
|
+
<iframe src="frame.html"></iframe>
|
|
39
|
+
<canvas></canvas>
|
|
40
|
+
<svg></svg>
|
|
41
|
+
<a href="/home" rel="noopener" target="_blank">Home</a>
|
|
42
|
+
<input id="email" name="email" type="email" data-tracking="abc" />
|
|
43
|
+
</div>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
46
|
+
`;
|
|
47
|
+
const $ = cheerio.load(html);
|
|
48
|
+
|
|
49
|
+
await removeNonContentTags($);
|
|
50
|
+
const result = $.html();
|
|
51
|
+
|
|
52
|
+
// Non-content tags removed
|
|
53
|
+
expect(result).not.toContain('<script');
|
|
54
|
+
expect(result).not.toContain('<style');
|
|
55
|
+
expect(result).not.toContain('<meta');
|
|
56
|
+
expect(result).not.toContain('<link');
|
|
57
|
+
expect(result).not.toContain('<iframe');
|
|
58
|
+
expect(result).not.toContain('<canvas');
|
|
59
|
+
expect(result).not.toContain('<svg');
|
|
60
|
+
expect(result).not.toContain('<!-- comment -->');
|
|
61
|
+
|
|
62
|
+
// Body-level non-whitelisted attribute stripped
|
|
63
|
+
expect($('body').attr('data-theme')).toBeUndefined();
|
|
64
|
+
|
|
65
|
+
// Whitelisted attrs preserved
|
|
66
|
+
const main = $('#main');
|
|
67
|
+
expect(main.attr('id')).toBe('main');
|
|
68
|
+
expect(main.attr('data-testid')).toBeUndefined();
|
|
69
|
+
expect(main.attr('onclick')).toBeUndefined();
|
|
70
|
+
|
|
71
|
+
const input = $('#email');
|
|
72
|
+
expect(input.attr('id')).toBe('email');
|
|
73
|
+
expect(input.attr('name')).toBe('email');
|
|
74
|
+
expect(input.attr('type')).toBe('email');
|
|
75
|
+
expect(input.attr('data-tracking')).toBeUndefined();
|
|
76
|
+
|
|
77
|
+
const link = $('a[href="/home"]');
|
|
78
|
+
expect(link.attr('href')).toBe('/home');
|
|
79
|
+
// rel/target should be stripped (not whitelisted)
|
|
80
|
+
expect(link.attr('rel')).toBeUndefined();
|
|
81
|
+
expect(link.attr('target')).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('collapseLongContent', () => {
|
|
86
|
+
it('collapses long lists and appends a truncated marker', async () => {
|
|
87
|
+
const html = `
|
|
88
|
+
<ul id="list">
|
|
89
|
+
<li>Item 1</li>
|
|
90
|
+
<li>Item 2</li>
|
|
91
|
+
<li>Item 3</li>
|
|
92
|
+
<li>Item 4</li>
|
|
93
|
+
</ul>
|
|
94
|
+
<div id="cards">
|
|
95
|
+
<div class="card">A</div>
|
|
96
|
+
<div class="card">B</div>
|
|
97
|
+
<div class="card">C</div>
|
|
98
|
+
<div class="card">D</div>
|
|
99
|
+
</div>
|
|
100
|
+
`;
|
|
101
|
+
const $ = cheerio.load(html);
|
|
102
|
+
|
|
103
|
+
await collapseLongContent($, 2);
|
|
104
|
+
|
|
105
|
+
const listItems = $('#list').children('li');
|
|
106
|
+
expect(listItems.length).toBe(3); // 2 kept + 1 truncated
|
|
107
|
+
expect(listItems.eq(0).text()).toBe('Item 1');
|
|
108
|
+
expect(listItems.eq(1).text()).toBe('Item 2');
|
|
109
|
+
expect(listItems.eq(2).attr('data-collapsed')).toBe('true');
|
|
110
|
+
|
|
111
|
+
const cardChildren = $('#cards').children();
|
|
112
|
+
expect(cardChildren.length).toBe(3); // 2 cards + truncated div
|
|
113
|
+
expect(cardChildren.eq(0).hasClass('card')).toBe(true);
|
|
114
|
+
expect(cardChildren.eq(1).hasClass('card')).toBe(true);
|
|
115
|
+
expect(cardChildren.eq(2).attr('data-collapsed')).toBe('true');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('chunkBySection', () => {
|
|
120
|
+
it('returns section HTML for header/nav/main/footer/aside/body', async () => {
|
|
121
|
+
const html = `
|
|
122
|
+
<html>
|
|
123
|
+
<body>
|
|
124
|
+
<header><h1>Title</h1></header>
|
|
125
|
+
<nav><a href="/a">A</a></nav>
|
|
126
|
+
<main><p>Content</p></main>
|
|
127
|
+
<aside><p>Side</p></aside>
|
|
128
|
+
<footer><p>Foot</p></footer>
|
|
129
|
+
</body>
|
|
130
|
+
</html>
|
|
131
|
+
`;
|
|
132
|
+
const $ = cheerio.load(html);
|
|
133
|
+
|
|
134
|
+
const chunks = await chunkBySection($);
|
|
135
|
+
|
|
136
|
+
expect(chunks.header).toContain('Title');
|
|
137
|
+
expect(chunks.nav).toContain('href="/a"');
|
|
138
|
+
expect(chunks.main).toContain('Content');
|
|
139
|
+
expect(chunks.aside).toContain('Side');
|
|
140
|
+
expect(chunks.footer).toContain('Foot');
|
|
141
|
+
expect(chunks.body).toContain('<header>');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('honors the focus list and only returns requested keys', async () => {
|
|
145
|
+
const html = `
|
|
146
|
+
<body>
|
|
147
|
+
<header>H</header>
|
|
148
|
+
<main>M</main>
|
|
149
|
+
<footer>F</footer>
|
|
150
|
+
</body>
|
|
151
|
+
`;
|
|
152
|
+
const $ = cheerio.load(html);
|
|
153
|
+
|
|
154
|
+
const chunks = await chunkBySection($, ['main', 'footer']);
|
|
155
|
+
|
|
156
|
+
expect(Object.keys(chunks).sort()).toEqual(['footer', 'main']);
|
|
157
|
+
expect(chunks.main).toContain('M');
|
|
158
|
+
expect(chunks.footer).toContain('F');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('reduceRepeatedDivs', () => {
|
|
163
|
+
it('reduces repeated divs by class and appends a collapsed marker', () => {
|
|
164
|
+
const html = `
|
|
165
|
+
<div id="root">
|
|
166
|
+
<div class="card">1</div>
|
|
167
|
+
<div class="card">2</div>
|
|
168
|
+
<div class="card">3</div>
|
|
169
|
+
<div class="card">4</div>
|
|
170
|
+
<div class="other">X</div>
|
|
171
|
+
</div>
|
|
172
|
+
`;
|
|
173
|
+
const $ = cheerio.load(html);
|
|
174
|
+
|
|
175
|
+
reduceRepeatedDivs($, 2);
|
|
176
|
+
|
|
177
|
+
const rootChildren = $('#root').children('div');
|
|
178
|
+
const cardDivs = $('#root').children('div.card');
|
|
179
|
+
const collapsedMarkers = $('#root').children('div[data-collapsed="true"]');
|
|
180
|
+
|
|
181
|
+
// Only 2 card divs should remain
|
|
182
|
+
expect(cardDivs.length).toBe(2);
|
|
183
|
+
expect(cardDivs.eq(0).text()).toBe('1');
|
|
184
|
+
expect(cardDivs.eq(1).text()).toBe('2');
|
|
185
|
+
|
|
186
|
+
// One collapsed marker added for the "card" class
|
|
187
|
+
expect(collapsedMarkers.length).toBe(1);
|
|
188
|
+
expect(collapsedMarkers.attr('data-class')).toBe('card');
|
|
189
|
+
|
|
190
|
+
// "other" div should still be present
|
|
191
|
+
const other = $('#root').children('div.other');
|
|
192
|
+
expect(other.length).toBe(1);
|
|
193
|
+
expect(other.text().trim()).toBe('X');
|
|
194
|
+
|
|
195
|
+
// Total children: 2 cards + 1 other + 1 marker
|
|
196
|
+
expect(rootChildren.length).toBe(4);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('removeObfuscatedClassNames', () => {
|
|
201
|
+
it('removes short and hashed-looking class names', () => {
|
|
202
|
+
const html = `
|
|
203
|
+
<div id="foo" class="btn a abcd12 AbCdE1 custom-class"></div>
|
|
204
|
+
`;
|
|
205
|
+
const $ = cheerio.load(html);
|
|
206
|
+
|
|
207
|
+
removeObfuscatedClassNames($);
|
|
208
|
+
|
|
209
|
+
const classAttr = $('#foo').attr('class') || '';
|
|
210
|
+
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
211
|
+
|
|
212
|
+
// 'btn' + 'custom-class' should remain, short "a" and hashed ones removed
|
|
213
|
+
expect(classes).toContain('btn');
|
|
214
|
+
expect(classes).toContain('custom-class');
|
|
215
|
+
expect(classes).not.toContain('a');
|
|
216
|
+
expect(classes).not.toContain('abcd12');
|
|
217
|
+
expect(classes).not.toContain('AbCdE1');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('removes class attribute entirely if nothing remains', () => {
|
|
221
|
+
const html = `<div id="bar" class="a abcd12 AbCdE1"></div>`;
|
|
222
|
+
const $ = cheerio.load(html);
|
|
223
|
+
|
|
224
|
+
removeObfuscatedClassNames($);
|
|
225
|
+
|
|
226
|
+
expect($('#bar').attr('class')).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('removeAutoGeneratedClasses', () => {
|
|
231
|
+
it('removes known utility classes while keeping semantic ones', () => {
|
|
232
|
+
const html = `
|
|
233
|
+
<button
|
|
234
|
+
id="btn"
|
|
235
|
+
class="text-sm bg-red-500 hover:bg-red-600 flex items-center btn-primary custom-class"
|
|
236
|
+
>
|
|
237
|
+
Click
|
|
238
|
+
</button>
|
|
239
|
+
`;
|
|
240
|
+
const $ = cheerio.load(html);
|
|
241
|
+
|
|
242
|
+
removeAutoGeneratedClasses($);
|
|
243
|
+
|
|
244
|
+
const classAttr = $('#btn').attr('class') || '';
|
|
245
|
+
const classes = classAttr.split(/\s+/).filter(Boolean);
|
|
246
|
+
|
|
247
|
+
// Utility classes should be gone
|
|
248
|
+
expect(classes).not.toContain('text-sm');
|
|
249
|
+
expect(classes).not.toContain('bg-red-500');
|
|
250
|
+
expect(classes).not.toContain('hover:bg-red-600');
|
|
251
|
+
expect(classes).not.toContain('flex');
|
|
252
|
+
expect(classes).not.toContain('items-center');
|
|
253
|
+
|
|
254
|
+
// Semantic classes should remain
|
|
255
|
+
expect(classes).toContain('btn-primary');
|
|
256
|
+
expect(classes).toContain('custom-class');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('removes class attribute entirely if all classes are utilities', () => {
|
|
260
|
+
const html = `<div id="box" class="flex items-center text-sm"></div>`;
|
|
261
|
+
const $ = cheerio.load(html);
|
|
262
|
+
|
|
263
|
+
removeAutoGeneratedClasses($);
|
|
264
|
+
|
|
265
|
+
expect($('#box').attr('class')).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('minifyDomWhitespace', () => {
|
|
270
|
+
it('collapses whitespace in text nodes while preserving words', () => {
|
|
271
|
+
const html = `
|
|
272
|
+
<div id="content">
|
|
273
|
+
Hello
|
|
274
|
+
world,
|
|
275
|
+
this is
|
|
276
|
+
a test.
|
|
277
|
+
</div>
|
|
278
|
+
`;
|
|
279
|
+
const $ = cheerio.load(html);
|
|
280
|
+
|
|
281
|
+
minifyDomWhitespace($);
|
|
282
|
+
|
|
283
|
+
const text = $('#content').text();
|
|
284
|
+
// We only care that internal runs are collapsed and words are separated
|
|
285
|
+
expect(text).toContain('Hello world, this is a test.');
|
|
286
|
+
// No stray newlines or multiple spaces
|
|
287
|
+
expect(/\s{2,}/.test(text)).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('preserves whitespace inside pre/code/textarea', () => {
|
|
291
|
+
const html = `
|
|
292
|
+
<pre id="code-block">
|
|
293
|
+
line 1
|
|
294
|
+
line 2 with spaces
|
|
295
|
+
</pre>
|
|
296
|
+
<textarea id="ta">
|
|
297
|
+
line A
|
|
298
|
+
line B
|
|
299
|
+
</textarea>
|
|
300
|
+
`;
|
|
301
|
+
const $ = cheerio.load(html);
|
|
302
|
+
|
|
303
|
+
const beforePre = $('#code-block').text();
|
|
304
|
+
const beforeTa = $('#ta').text();
|
|
305
|
+
|
|
306
|
+
minifyDomWhitespace($);
|
|
307
|
+
|
|
308
|
+
const afterPre = $('#code-block').text();
|
|
309
|
+
const afterTa = $('#ta').text();
|
|
310
|
+
|
|
311
|
+
expect(afterPre).toBe(beforePre);
|
|
312
|
+
expect(afterTa).toBe(beforeTa);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('keeps at least one space for pure-whitespace text nodes', () => {
|
|
316
|
+
const html = `<p>Foo <span>bar</span> baz</p>`;
|
|
317
|
+
const $ = cheerio.load(html);
|
|
318
|
+
|
|
319
|
+
minifyDomWhitespace($);
|
|
320
|
+
|
|
321
|
+
const text = $('p').text();
|
|
322
|
+
// We don’t want "Foobarbaz"
|
|
323
|
+
expect(text.includes('Foo')).toBe(true);
|
|
324
|
+
expect(text.includes('bar')).toBe(true);
|
|
325
|
+
expect(text.includes('baz')).toBe(true);
|
|
326
|
+
expect(text).toMatch(/Foo.*bar.*baz/);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
});
|
package/tools/chromeBrowser.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
removeAutoGeneratedClasses,
|
|
18
18
|
removeNonContentTags,
|
|
19
19
|
removeObfuscatedClassNames,
|
|
20
|
+
minifyDomWhitespace
|
|
20
21
|
} from './domControl.js';
|
|
21
22
|
import fs from 'fs';
|
|
22
23
|
import path from 'path';
|
|
@@ -870,6 +871,9 @@ export class ChromeBrowser {
|
|
|
870
871
|
// $ = await removeAutoGeneratedClasses($);
|
|
871
872
|
}
|
|
872
873
|
|
|
874
|
+
// Minify whitespace *after* structural cleanup
|
|
875
|
+
$ = minifyDomWhitespace($);
|
|
876
|
+
|
|
873
877
|
// (Optional: If you'd like to use `focus`, you could filter here by selectors/keywords.)
|
|
874
878
|
|
|
875
879
|
// --- 5) Return trimmed HTML ---
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Tools whose *content* tends to be extremely large (DOM, etc.).
|
|
2
|
+
// We keep their existence in context, but can safely stub older payloads.
|
|
3
|
+
const HEAVY_SEMANTIC_TOOLS = new Set([
|
|
4
|
+
'get_dom',
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Safely prune/sanitize older heavy tool messages in the conversation.
|
|
9
|
+
*
|
|
10
|
+
* Goals:
|
|
11
|
+
* - Keep recent DOM snapshots intact (last few).
|
|
12
|
+
* - For older ones, keep the tool message but replace content with a stub.
|
|
13
|
+
*
|
|
14
|
+
* This preserves conversational structure while shrinking token load.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array} messages - conversation history (mutated in-place).
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {number} opts.keepRecentPerTool - how many latest tool results to keep verbatim
|
|
19
|
+
*/
|
|
20
|
+
export function sanitizeHeavyToolHistory(messages, { keepRecentPerTool = 2 } = {}) {
|
|
21
|
+
// Track latest indices per tool name
|
|
22
|
+
const indicesByTool = new Map();
|
|
23
|
+
|
|
24
|
+
messages.forEach((m, idx) => {
|
|
25
|
+
if (m.role === 'tool' && m.name && HEAVY_SEMANTIC_TOOLS.has(m.name)) {
|
|
26
|
+
if (!indicesByTool.has(m.name)) indicesByTool.set(m.name, []);
|
|
27
|
+
indicesByTool.get(m.name).push(idx);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// For each tool, stub all but the last `keepRecentPerTool`
|
|
32
|
+
for (const [toolName, indices] of indicesByTool.entries()) {
|
|
33
|
+
const toStub = indices.slice(0, Math.max(0, indices.length - keepRecentPerTool));
|
|
34
|
+
for (const i of toStub) {
|
|
35
|
+
const msg = messages[i];
|
|
36
|
+
msg.content = `[${toolName} output omitted for brevity – older snapshot]`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prune overall conversation context to keep it within a bounded window.
|
|
44
|
+
*
|
|
45
|
+
* Strategy:
|
|
46
|
+
* - Always keep all system messages.
|
|
47
|
+
* - Start from the end and keep the last `maxNonSystemMessages` non-system messages.
|
|
48
|
+
* - Ensure protocol correctness:
|
|
49
|
+
* * If a tool message is kept, its parent assistant with matching tool_call_id
|
|
50
|
+
* must also be present in the kept window. Otherwise, drop that tool message.
|
|
51
|
+
*
|
|
52
|
+
* This avoids "tool message without preceding tool_calls" API errors.
|
|
53
|
+
*
|
|
54
|
+
* @param {Array} messages - conversation history
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {number} opts.maxNonSystemMessages
|
|
57
|
+
* @returns {Array} pruned messages (new array)
|
|
58
|
+
*/
|
|
59
|
+
export function pruneConversationContext(messages, { maxNonSystemMessages = 40 } = {}) {
|
|
60
|
+
const systemMessages = messages.filter(m => m.role === 'system');
|
|
61
|
+
const nonSystem = messages.filter(m => m.role !== 'system');
|
|
62
|
+
|
|
63
|
+
// Take last N non-system messages (raw window)
|
|
64
|
+
const tail = nonSystem.slice(-maxNonSystemMessages);
|
|
65
|
+
|
|
66
|
+
// Pass 1: build map of tool_call_id -> index of assistant in tail
|
|
67
|
+
const assistantToolParents = new Map(); // tool_call_id -> index in tail
|
|
68
|
+
tail.forEach((m, idx) => {
|
|
69
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
|
|
70
|
+
for (const tc of m.tool_calls) {
|
|
71
|
+
if (tc && tc.id) {
|
|
72
|
+
assistantToolParents.set(tc.id, idx);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Pass 2: filter out tool messages that have no parent assistant in the tail
|
|
79
|
+
const cleanedTail = [];
|
|
80
|
+
for (const m of tail) {
|
|
81
|
+
if (m.role === 'tool') {
|
|
82
|
+
const toolId = m.tool_call_id;
|
|
83
|
+
if (!toolId) {
|
|
84
|
+
// Defensive: if there's no tool_call_id at all, drop it.
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!assistantToolParents.has(toolId)) {
|
|
88
|
+
// Parent assistant with this tool_call_id not present in tail,
|
|
89
|
+
// so keeping this tool message would violate the protocol. Drop it.
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
cleanedTail.push(m);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return [...systemMessages, ...cleanedTail];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ---------------- Ground Control state helpers ----------------
|
|
100
|
+
* These helpers back the "set_ground_control_state" and "record_mission_telemetry"
|
|
101
|
+
* tools. They are designed to:
|
|
102
|
+
* - Keep durable mission facts (URL, login state, constraints) outside
|
|
103
|
+
* the pruned chat window.
|
|
104
|
+
* - Accumulate a lightweight telemetry trail (breadcrumbs, assertions,
|
|
105
|
+
* issues, and notes) that can be surfaced in reports.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a normalized, empty Ground Control state object.
|
|
110
|
+
* Safe to reuse per mission and to serialize into reports.
|
|
111
|
+
*
|
|
112
|
+
* @returns {object} groundControl
|
|
113
|
+
*/
|
|
114
|
+
export function createEmptyGroundControl() {
|
|
115
|
+
return {
|
|
116
|
+
app: {
|
|
117
|
+
baseUrl: null,
|
|
118
|
+
currentUrl: null,
|
|
119
|
+
routeRole: null, // e.g. "login", "chat", "dashboard"
|
|
120
|
+
},
|
|
121
|
+
session: {
|
|
122
|
+
loggedIn: null, // true/false/null (unknown)
|
|
123
|
+
userLabel: null, // "Demo user", "Shane Fast", etc.
|
|
124
|
+
tenant: null, // workspace / tenant label if applicable
|
|
125
|
+
},
|
|
126
|
+
navigation: {
|
|
127
|
+
currentLabel: null, // human label for where we are, e.g. "Main chat page"
|
|
128
|
+
},
|
|
129
|
+
constraints: {
|
|
130
|
+
stayWithinBaseUrl: null, // if true, avoid leaving app.baseUrl unless told to
|
|
131
|
+
},
|
|
132
|
+
telemetry: [], // array of { kind, text, status, ts, turn? }
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Apply a partial update from the `set_ground_control_state` tool to the
|
|
138
|
+
* existing Ground Control state.
|
|
139
|
+
*
|
|
140
|
+
* - Mutates the provided `groundControl` in-place for simplicity.
|
|
141
|
+
* - Only merges known sections/keys; unknown keys are ignored defensively.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} groundControl - existing state (will be mutated)
|
|
144
|
+
* @param {object} payload - tool arguments from set_ground_control_state
|
|
145
|
+
* @returns {object} the same groundControl reference (for chaining)
|
|
146
|
+
*/
|
|
147
|
+
export function applyGroundControlUpdate(groundControl, payload = {}) {
|
|
148
|
+
if (!groundControl) {
|
|
149
|
+
throw new Error('applyGroundControlUpdate called without an existing groundControl state');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 🔐 Ensure sub-objects exist so we can safely assign into them
|
|
153
|
+
groundControl.app = groundControl.app || {};
|
|
154
|
+
groundControl.session = groundControl.session || {};
|
|
155
|
+
groundControl.navigation = groundControl.navigation || {};
|
|
156
|
+
groundControl.constraints = groundControl.constraints || {};
|
|
157
|
+
|
|
158
|
+
const { app, session, navigation, constraints } = payload;
|
|
159
|
+
|
|
160
|
+
if (app && typeof app === 'object') {
|
|
161
|
+
groundControl.app.baseUrl = app.baseUrl ?? groundControl.app.baseUrl;
|
|
162
|
+
groundControl.app.currentUrl = app.currentUrl ?? groundControl.app.currentUrl;
|
|
163
|
+
groundControl.app.routeRole = app.routeRole ?? groundControl.app.routeRole;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (session && typeof session === 'object') {
|
|
167
|
+
if (typeof session.loggedIn === 'boolean' || session.loggedIn === null) {
|
|
168
|
+
groundControl.session.loggedIn = session.loggedIn;
|
|
169
|
+
}
|
|
170
|
+
if (typeof session.userLabel === 'string') {
|
|
171
|
+
groundControl.session.userLabel = session.userLabel;
|
|
172
|
+
}
|
|
173
|
+
if (typeof session.tenant === 'string') {
|
|
174
|
+
groundControl.session.tenant = session.tenant;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (navigation && typeof navigation === 'object') {
|
|
179
|
+
if (typeof navigation.currentLabel === 'string') {
|
|
180
|
+
groundControl.navigation.currentLabel = navigation.currentLabel;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (constraints && typeof constraints === 'object') {
|
|
185
|
+
if (typeof constraints.stayWithinBaseUrl === 'boolean' || constraints.stayWithinBaseUrl === null) {
|
|
186
|
+
groundControl.constraints.stayWithinBaseUrl = constraints.stayWithinBaseUrl;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return groundControl;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Append a telemetry entry to Ground Control from the `record_mission_telemetry` tool.
|
|
195
|
+
*
|
|
196
|
+
* Each entry is small and durable, and can be surfaced in final mission reports.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} groundControl - existing state (mutated in-place)
|
|
199
|
+
* @param {object} entry - { kind, text, status? } from tool args
|
|
200
|
+
* @param {object} meta - optional metadata (e.g., { turn })
|
|
201
|
+
* @returns {object} the appended telemetry object
|
|
202
|
+
*/
|
|
203
|
+
export function recordGroundTelemetry(groundControl, entry, meta = {}) {
|
|
204
|
+
if (!groundControl) {
|
|
205
|
+
throw new Error('recordGroundTelemetry called without an existing groundControl state');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!groundControl.telemetry) {
|
|
209
|
+
groundControl.telemetry = [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const { kind, text, status } = entry || {};
|
|
213
|
+
if (!kind || !text) {
|
|
214
|
+
// Defensive: only record well-formed entries
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const telemetry = {
|
|
219
|
+
kind, // 'breadcrumb' | 'assertion' | 'issue' | 'note'
|
|
220
|
+
text,
|
|
221
|
+
status: status || 'n/a', // 'passed' | 'failed' | 'n/a'
|
|
222
|
+
ts: new Date().toISOString(),
|
|
223
|
+
...meta, // e.g., { turn: 3 }
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
groundControl.telemetry.push(telemetry);
|
|
227
|
+
return telemetry;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build a compact, model-friendly snapshot of Ground Control for use in prompts.
|
|
232
|
+
*
|
|
233
|
+
* Goals:
|
|
234
|
+
* - Keep *critical* durable facts (URL, login state, rough route/area)
|
|
235
|
+
* available between missions without dragging the full state into context.
|
|
236
|
+
* - Emit a very small object that can be safely JSON.stringified and injected
|
|
237
|
+
* into a system message or "Ground Control" note.
|
|
238
|
+
* - Return `null` when there is no meaningful signal yet, so callers can skip
|
|
239
|
+
* adding noise to the prompt.
|
|
240
|
+
*
|
|
241
|
+
* Shape of the snapshot:
|
|
242
|
+
* {
|
|
243
|
+
* app: {
|
|
244
|
+
* baseUrl: string | null,
|
|
245
|
+
* currentUrl: string | null,
|
|
246
|
+
* },
|
|
247
|
+
* session: {
|
|
248
|
+
* loggedIn: boolean, // coerced with `!!` (null/undefined → false)
|
|
249
|
+
* userLabel: string | null,
|
|
250
|
+
* },
|
|
251
|
+
* navigation: {
|
|
252
|
+
* routeRole: string | null, // high-level role like "login", "chat", etc.
|
|
253
|
+
* currentLabel: string | null, // human label like "Main chat page"
|
|
254
|
+
* },
|
|
255
|
+
* telemetryLines: string[] // up to 5 last telemetry entries, pre-formatted
|
|
256
|
+
* }
|
|
257
|
+
*
|
|
258
|
+
* @param {object} gc - Full Ground Control state as maintained by the tools.
|
|
259
|
+
* @returns {object|null} A compact snapshot or `null` if there is no signal.
|
|
260
|
+
*/
|
|
261
|
+
export function summarizeGroundControlForPrompt(gc = {}) {
|
|
262
|
+
if (!gc || typeof gc !== 'object') return null;
|
|
263
|
+
|
|
264
|
+
const app = gc.app || {};
|
|
265
|
+
const session = gc.session || {};
|
|
266
|
+
const navigation = gc.navigation || {};
|
|
267
|
+
const telemetry = Array.isArray(gc.telemetry) ? gc.telemetry : [];
|
|
268
|
+
|
|
269
|
+
const routeRole = typeof navigation.routeRole === 'string'
|
|
270
|
+
? navigation.routeRole
|
|
271
|
+
: (typeof app.routeRole === 'string' ? app.routeRole : null);
|
|
272
|
+
const currentLabel = typeof navigation.currentLabel === 'string' ? navigation.currentLabel : null;
|
|
273
|
+
|
|
274
|
+
// Only emit snapshot if we have at least *some* meaningful info
|
|
275
|
+
const hasSignal =
|
|
276
|
+
app.baseUrl ||
|
|
277
|
+
app.currentUrl ||
|
|
278
|
+
typeof session.loggedIn === 'boolean' ||
|
|
279
|
+
routeRole ||
|
|
280
|
+
currentLabel ||
|
|
281
|
+
telemetry.length > 0;
|
|
282
|
+
|
|
283
|
+
if (!hasSignal) return null;
|
|
284
|
+
|
|
285
|
+
const telemetryLines = telemetry
|
|
286
|
+
.slice(-5) // last 5 breadcrumbs max
|
|
287
|
+
.map(t => {
|
|
288
|
+
const statusSuffix = t.status && t.status !== 'n/a' ? ` (${t.status})` : '';
|
|
289
|
+
return `- [${t.kind}]${statusSuffix} ${t.text}`;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
app: {
|
|
294
|
+
baseUrl: app.baseUrl || null,
|
|
295
|
+
currentUrl: app.currentUrl || null,
|
|
296
|
+
},
|
|
297
|
+
session: {
|
|
298
|
+
loggedIn: !!session.loggedIn,
|
|
299
|
+
userLabel: session.userLabel || null,
|
|
300
|
+
},
|
|
301
|
+
navigation: {
|
|
302
|
+
routeRole,
|
|
303
|
+
currentLabel,
|
|
304
|
+
},
|
|
305
|
+
telemetryLines,
|
|
306
|
+
};
|
|
307
|
+
}
|