testronaut 1.0.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.
@@ -0,0 +1,184 @@
1
+ import { chromium } from 'playwright';
2
+ import * as cheerio from 'cheerio';
3
+ import {
4
+ chunkBySection,
5
+ reduceRepeatedDivs,
6
+ removeAutoGeneratedClasses,
7
+ removeNonContentTags,
8
+ removeObfuscatedClassNames,
9
+ } from './domControl.js';
10
+ import fs from 'fs';
11
+
12
+ export class ChromeBrowser {
13
+ constructor() {
14
+ this.browser = null;
15
+ this.page = null;
16
+ }
17
+
18
+ async start() {
19
+ this.browser = await chromium.launch({ headless: true });
20
+ const context = await this.browser.newContext();
21
+ this.page = await context.newPage();
22
+ }
23
+
24
+ async navigate({ url }) {
25
+ await this.page.goto(url, { timeout: 30_000 });
26
+ return `navigated to ${this.page.url()}`;
27
+ }
28
+
29
+ async fill({ selector, text }) {
30
+ await this.page.waitForSelector(selector, { timeout: 30_000 });
31
+ await this.page.fill(selector, text);
32
+ return `filled ${selector}`;
33
+ }
34
+
35
+ async click({ selector, waitFor = 'domcontentloaded', delayMs = 2000 }) {
36
+ await this.page.waitForSelector(selector, { timeout: 30_000 });
37
+
38
+ const [res] = await Promise.all([
39
+ this.page.waitForLoadState(waitFor, { timeout: 10_000 }).catch(() => null),
40
+ this.page.click(selector),
41
+ ]);
42
+
43
+ if (delayMs) {
44
+ await this.page.waitForTimeout(delayMs);
45
+ }
46
+
47
+ return `clicked ${selector} and waited ${delayMs}ms`;
48
+ }
49
+
50
+ async get_dom({ limit = 100000, exclude = true, focus = [] }, agentMemory = {}) {
51
+ let html;
52
+ try {
53
+ html = await this.page.content();
54
+ } catch (err) {
55
+ console.warn('āš ļø get_dom retrying due to navigation:', err.message);
56
+ // Wait for navigation to settle
57
+ await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
58
+ html = await this.page.content();
59
+ }
60
+ let $ = cheerio.load(html);
61
+
62
+ // Remove non-content tags
63
+ if (exclude) {
64
+ $ = await removeNonContentTags($);
65
+ // console.log('šŸ”§ Non-content tags removed from DOM.');
66
+ $ = await reduceRepeatedDivs($);
67
+ // console.log('šŸ”§ Repeated divs reduced in DOM.');
68
+ // $ = await removeObfuscatedClassNames($);
69
+ // console.log('šŸ”§ Obfuscated class names removed from DOM.')
70
+ // $ = await removeAutoGeneratedClasses($);
71
+ // console.log('šŸ”§ Auto-generated classes removed from DOM.');
72
+ }
73
+ // console.log('šŸ”§ DOM cleaned up:', $);
74
+ const finalHtml = $.html();
75
+ const trimmed = finalHtml.slice(0, limit);
76
+ return trimmed;
77
+ }
78
+
79
+ async check_text({ text }) {
80
+ const html = await this.page.content();
81
+ return html.includes(text) ? `FOUND: ${text}` : `NOT FOUND: ${text}`;
82
+ }
83
+
84
+ async click_text({ text }) {
85
+ console.log("attempting to click text with normal click:", text);
86
+
87
+ const elements = await this.page.$$('button, a, span, div, li, p'); // expand as needed
88
+ for (const el of elements) {
89
+ const innerText = await el.innerText().catch(() => '');
90
+ if (innerText.includes(text)) {
91
+ await el.scrollIntoViewIfNeeded().catch(() => {});
92
+ await el.click();
93
+ await this.page.screenshot({ path: `screenshot_after_click_${Date.now()}.png` });
94
+ await this.page.waitForTimeout(5000);
95
+ await this.page.screenshot({ path: `screenshot_after_waiting_click_${Date.now()}.png` });
96
+ return `clicked text "${text}"`;
97
+
98
+ }
99
+ }
100
+ throw new Error(`Element with text "${text}" not found`);
101
+ }
102
+
103
+ async click_text_xpath({ text, delayMs = 1000 }) {
104
+ console.log("attempting to click text using XPath:", text);
105
+ const candidates = await this.page.$x(`//*[normalize-space(text())="${text}"]`);
106
+ for (const el of candidates) {
107
+ const visible = await el.isVisible?.() ?? await this.page.evaluate(el => {
108
+ const rect = el.getBoundingClientRect();
109
+ return (
110
+ rect.width > 0 &&
111
+ rect.height > 0 &&
112
+ window.getComputedStyle(el).visibility !== 'hidden' &&
113
+ window.getComputedStyle(el).display !== 'none'
114
+ );
115
+ }, el);
116
+
117
+ if (visible) {
118
+ const box = await el.boundingBox();
119
+ console.log(`[click_text] → Clicking visible element with bounding box`, box);
120
+ await el.click();
121
+ await this.page.waitForTimeout(delayMs);
122
+ await this.page.screenshot({ path: `screenshot_after_click_${Date.now()}.png` });
123
+ return `clicked visible text "${text}"`;
124
+ }
125
+ }
126
+
127
+ throw new Error(`Text "${text}" not found or not visible`);
128
+ }
129
+
130
+
131
+ // async get_dom_focus_hint({ prompt }) {
132
+ // const lower = prompt.toLowerCase();
133
+ // const focus = [];
134
+
135
+ // if (lower.includes('logout') || lower.includes('sign out')) {
136
+ // focus.push('header', 'nav', 'footer');
137
+ // } else if (lower.includes('login') || lower.includes('sign in')) {
138
+ // focus.push('main', 'header');
139
+ // } else if (lower.includes('settings')) {
140
+ // focus.push('nav', 'aside', 'main');
141
+ // }
142
+
143
+ // return { focus };
144
+ // }
145
+
146
+ async expand_menu({ selector, delayMs = 1000 }) {
147
+ await this.page.waitForSelector(selector, { timeout: 30_000 });
148
+ await this.page.click(selector);
149
+ await this.page.waitForTimeout(delayMs);
150
+
151
+ const expandedDom = await this.page.content();
152
+ const filename = `expand_menu_dump_${Date.now()}.html`;
153
+ fs.writeFileSync(filename, expandedDom);
154
+ console.log(`[expand_menu] → Dumped DOM after expand click to ${filename}`);
155
+ await this.page.screenshot({ path: `screenshot_expand_menu_${Date.now()}.png` });
156
+
157
+ return `expanded menu using ${selector}`;
158
+ }
159
+
160
+
161
+
162
+ async close() {
163
+ await this.browser?.close();
164
+ }
165
+ }
166
+
167
+ export const CHROME_TOOL_MAP = {
168
+ navigate: (b, args) => b.navigate(args),
169
+ fill: (b, args) => b.fill(args),
170
+ click: (b, args) => b.click(args),
171
+ get_dom: (b, args, agentMemory) => b.get_dom(args, agentMemory),
172
+ check_text: (b, args) => b.check_text(args),
173
+ click_text: async (browser, args, agentMemory) => {
174
+ console.log(`[click_text] selecting click type: lastMenuExpanded=${agentMemory.lastMenuExpanded}`);
175
+ if (agentMemory.lastMenuExpanded) {
176
+ return await browser.click_text_xpath(args);
177
+ } else {
178
+ return await browser.click_text(args);
179
+ }
180
+ },
181
+
182
+ expand_menu: (b, args) => b.expand_menu(args),
183
+ // click_text: (b, args) => b.click_text(args),
184
+ };
@@ -0,0 +1,170 @@
1
+ export const removeNonContentTags = async ($) => {
2
+ $('script, style, meta, link, noscript, iframe, canvas, svg').remove();
3
+
4
+ $('*').each((_, el) => {
5
+ const $el = $(el);
6
+ const allowedAttrs = [
7
+ 'href', 'src', 'type', 'alt', 'title',
8
+ 'placeholder', 'name', 'id', 'class', 'aria-label', 'value'
9
+ ];
10
+
11
+ const attrs = el.attribs;
12
+ // const tagName = $el.get(0)?.tagName?.toLowerCase();
13
+ // const keepEmpty = ['input', 'button', 'textarea', 'select'];
14
+
15
+ // if (!keepEmpty.includes(tagName) && !$el.text().trim() && $el.children().length === 0) {
16
+ // $el.remove();
17
+ // }
18
+
19
+ for (const attr in attrs) {
20
+ if (!allowedAttrs.includes(attr)) {
21
+ $el.removeAttr(attr);
22
+ }
23
+ }
24
+ });
25
+
26
+ $('*')
27
+ .contents()
28
+ .each(function () {
29
+ if (this.type === 'comment') {
30
+ $(this).remove();
31
+ }
32
+ });
33
+
34
+ return $;
35
+ }
36
+
37
+ export const collapseLongContent = async ($, limit = 10) => {
38
+ $('ul, ol').each((_, el) => {
39
+ const $el = $(el);
40
+ const items = $el.children('li');
41
+
42
+ if (items.length > limit) {
43
+ items.slice(limit).remove();
44
+ $el.append('<li data-collapsed="true">[...truncated]</li>');
45
+ }
46
+ });
47
+
48
+ $('div, section').each((_, el) => {
49
+ const $el = $(el);
50
+ const children = $el.children();
51
+
52
+ if (children.length > limit) {
53
+ children.slice(limit).remove();
54
+ $el.append('<div data-collapsed="true">[...truncated]</div>');
55
+ }
56
+ });
57
+ }
58
+
59
+ export const chunkBySection = async ($, focus) => {
60
+ const chunks = {
61
+ header: $('header').html() || '',
62
+ nav: $('nav').html() || '',
63
+ main: $('main').html() || '',
64
+ footer: $('footer').html() || '',
65
+ aside: $('aside').html() || '',
66
+ body: $('body').html() || ''
67
+ };
68
+
69
+ if (Array.isArray(focus) && focus.length > 0) {
70
+ return Object.fromEntries(
71
+ Object.entries(chunks).filter(
72
+ ([k]) => focus.includes(k)
73
+ )
74
+ );
75
+ }
76
+
77
+ return chunks;
78
+
79
+ // let focusedChunks = Object.entries(chunks)
80
+ // .filter(([key]) => focus?.length === 0 || focus?.includes(key))
81
+ // .map(([, html]) => html)
82
+ // .join('\n');
83
+
84
+ // return focusedChunks;
85
+
86
+ }
87
+
88
+ export function reduceRepeatedDivs($, maxAllowed = 3) {
89
+ $('body, div').each((_, parentEl) => {
90
+ const $parent = $(parentEl);
91
+ const children = $parent.children('div');
92
+
93
+ // Group children divs by class name
94
+ const classGroups = {};
95
+ children.each((_, child) => {
96
+ const $child = $(child);
97
+ const className = $child.attr('class');
98
+ if (!className) return;
99
+
100
+ if (!classGroups[className]) {
101
+ classGroups[className] = [];
102
+ }
103
+ classGroups[className].push(child);
104
+ });
105
+
106
+ // Reduce each group to maxAllowed
107
+ for (const className in classGroups) {
108
+ const group = classGroups[className];
109
+ if (group.length > maxAllowed) {
110
+ // Remove excess nodes from the DOM
111
+ group.slice(maxAllowed).forEach(el => $(el).remove());
112
+ $parent.append(`<div data-collapsed="true" data-class="${className}">[...truncated]</div>`);
113
+ }
114
+ }
115
+ });
116
+ return $;
117
+ }
118
+
119
+ /**
120
+ * Remove obfuscated or hashed class names from a DOM string.
121
+ * Criteria: class names with high entropy (random letters/numbers), or very short length
122
+ */
123
+ export function removeObfuscatedClassNames($) {
124
+ $('[class]').each((_, el) => {
125
+ const $el = $(el);
126
+ const originalClasses = ($el.attr('class') || '').split(/\s+/);
127
+ const filtered = originalClasses.filter(cls => {
128
+ const isShort = cls.length <= 2;
129
+ const isHashed = /^[a-z0-9_-]{6,}$/.test(cls) && /[0-9]/.test(cls);
130
+ const isObviousHash = /^[A-Za-z0-9_-]{6,}$/.test(cls) && /[A-Z]/.test(cls) && /[0-9]/.test(cls);
131
+ return !(isShort || isHashed || isObviousHash);
132
+ });
133
+ if (filtered.length) {
134
+ $el.attr('class', filtered.join(' '));
135
+ } else {
136
+ $el.removeAttr('class');
137
+ }
138
+ });
139
+
140
+ return $;
141
+ }
142
+
143
+ /**
144
+ * Remove known auto-generated or utility classes (e.g., Tailwind/MUI/CSS-in-JS)
145
+ */
146
+ export function removeAutoGeneratedClasses($) {
147
+ // Regex patterns for utility classes, internal prefixes, and other noise
148
+ const utilityClassPatterns = [
149
+ /^text-/, /^bg-/, /^p[trblxy]?-\d+/, /^m[trblxy]?-\d+/, /^w-/, /^h-/, /^rounded/, /^border/, /^hover:/,
150
+ /^focus:/, /^group/, /^peer/, /^ring/, /^cl-/, /^md:/, /^sm:/, /^lg:/, /^xl:/, /^2xl:/, /^z-\d+/,
151
+ /^translate/, /^flex/, /^grid/, /^justify-/, /^items-/, /^gap-/, /^shadow/, /^overflow/, /^truncate/,
152
+ /^relative$/, /^absolute$/, /^inset/, /^top/, /^bottom/, /^left/, /^right/,
153
+ /^opacity-/, /^transition/, /^duration-/, /^ease-/, /^cursor-/, /^select-/, /^pointer-events-/,
154
+ ];
155
+
156
+ $('[class]').each((_, el) => {
157
+ const $el = $(el);
158
+ const classes = ($el.attr('class') || '').split(/\s+/);
159
+ const filtered = classes.filter(cls => {
160
+ return !utilityClassPatterns.some(pattern => pattern.test(cls));
161
+ });
162
+ if (filtered.length) {
163
+ $el.attr('class', filtered.join(' '));
164
+ } else {
165
+ $el.removeAttr('class');
166
+ }
167
+ });
168
+
169
+ return $;
170
+ }
@@ -0,0 +1,62 @@
1
+ import { encoding_for_model } from '@dqbd/tiktoken';
2
+ import { wait } from './turnLoopUtils.js';
3
+
4
+ const TOKEN_BACKOFF_MS = 60000;
5
+ const TOKEN_LIMIT_PER_MIN = 90000;
6
+
7
+ export const tokenEstimate = async (model, text) => {
8
+ const encoding = encoding_for_model(model);
9
+ const tokenCount = encoding.encode(text).length;
10
+ console.log(`🧠 Estimated token count: ${tokenCount}`);
11
+ return tokenCount;
12
+ }
13
+
14
+ export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps) => {
15
+ console.log("total tokens used: ", totalTokensUsed);
16
+ console.log("turnTimestamps: ", turnTimestamps);
17
+
18
+ if (totalTokensUsed > TOKEN_LIMIT_PER_MIN) {
19
+ const msToWait = await getDynamicBackoffMs(turnTimestamps);
20
+ console.warn(`āš ļø Token throttle risk → Waiting ${msToWait / 1000}s to cool off...`);
21
+ await wait(msToWait);
22
+ // totalTokensUsed = 0; // Reset token count after backoff
23
+ // turnTimestamps = []; // Reset timestamps after backoff
24
+ console.log('āœ… Backoff complete, resuming...');
25
+ // return true;
26
+ return { shouldBackoff: true, totalTokensUsed: 0, turnTimestamps: [] };
27
+ }
28
+ // return false;
29
+ return { shouldBackoff: false, totalTokensUsed, turnTimestamps };
30
+ }
31
+
32
+ export const recordTokenUsage = (turnTimestamps, tokensUsed) => {
33
+ const now = Date.now();
34
+ turnTimestamps.push([now, tokensUsed]);
35
+ };
36
+
37
+ export const pruneOldTokenUsage = (turnTimestamps, windowMs = 60000) => {
38
+ const cutoff = Date.now() - windowMs;
39
+ const recentEntries = turnTimestamps.filter(([timestamp]) => timestamp > cutoff);
40
+ const totalTokensUsed = recentEntries.reduce((acc, [, tokens]) => acc + tokens, 0);
41
+ return { turnTimestamps: recentEntries, totalTokensUsed };
42
+ };
43
+
44
+ const getDynamicBackoffMs = async (turnTimestamps, tokenLimit = TOKEN_LIMIT_PER_MIN) => {
45
+ const now = Date.now();
46
+ let runningTotal = 0;
47
+
48
+ // Sort oldest → newest to find the earliest point to fall below the limit
49
+ const sorted = [...turnTimestamps].sort((a, b) => a[0] - b[0]);
50
+
51
+ for (let i = 0; i < sorted.length; i++) {
52
+ runningTotal += sorted[i][1]; // add tokensUsed
53
+
54
+ if (runningTotal > tokenLimit) {
55
+ const [timestampOfExcess] = sorted[i];
56
+ const msUntilSafe = 60000 - (now - timestampOfExcess);
57
+ return Math.max(msUntilSafe, 1000); // wait at least 1s
58
+ }
59
+ }
60
+
61
+ return 0; // Safe to proceed
62
+ }
@@ -0,0 +1,162 @@
1
+ const toolsSchema = [
2
+ {
3
+ type: 'function',
4
+ function: {
5
+ name: 'navigate',
6
+ description: 'Navigate to a URL',
7
+ parameters: {
8
+ type: 'object',
9
+ properties: {
10
+ url: { type: 'string' },
11
+ },
12
+ required: ['url'],
13
+ },
14
+ },
15
+ },
16
+ {
17
+ type: 'function',
18
+ function: {
19
+ name: 'fill',
20
+ description: 'Fill in a field by CSS selector',
21
+ parameters: {
22
+ type: 'object',
23
+ properties: {
24
+ selector: { type: 'string' },
25
+ text: { type: 'string' },
26
+ },
27
+ required: ['selector', 'text'],
28
+ },
29
+ },
30
+ },
31
+ {
32
+ type: 'function',
33
+ function: {
34
+ name: 'click',
35
+ description: 'Click a button or link by CSS selector',
36
+ parameters: {
37
+ type: 'object',
38
+ properties: {
39
+ selector: { type: 'string' },
40
+ waitFor: {
41
+ type: 'string',
42
+ enum: ['load', 'domcontentloaded', 'networkidle'],
43
+ default: 'domcontentloaded',
44
+ },
45
+ delayMs: {
46
+ type: 'number',
47
+ default: 2000,
48
+ description: 'Milliseconds to wait after click',
49
+ },
50
+ },
51
+ required: ['selector'],
52
+ },
53
+ },
54
+ },
55
+ {
56
+ type: 'function',
57
+ function: {
58
+ name: 'get_dom',
59
+ description: 'Return trimmed HTML from the current page',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ limit: {
64
+ type: 'number',
65
+ default: 100000,
66
+ description: 'Max characters to return',
67
+ },
68
+ exclude: {
69
+ type: 'boolean',
70
+ default: true,
71
+ description: 'Whether to exclude noisy tags like script/style/etc.',
72
+ },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ {
78
+ type: 'function',
79
+ function: {
80
+ name: 'check_text',
81
+ description: 'Search page HTML for specific text',
82
+ parameters: {
83
+ type: 'object',
84
+ properties: {
85
+ text: { type: 'string' },
86
+ },
87
+ required: ['text'],
88
+ },
89
+ },
90
+ },
91
+ {
92
+ type: 'function',
93
+ function: {
94
+ name: 'expand_menu',
95
+ description: 'Click an element (like a hamburger menu or profile icon) to reveal a dropdown or hidden menu',
96
+ parameters: {
97
+ type: 'object',
98
+ properties: {
99
+ selector: { type: 'string' },
100
+ delayMs: {
101
+ type: 'number',
102
+ default: 1000,
103
+ description: 'Optional delay after expanding menu (in ms)',
104
+ }
105
+ },
106
+ required: ['selector'],
107
+ },
108
+ },
109
+ },
110
+ {
111
+ type: 'function',
112
+ function: {
113
+ name: 'click_text',
114
+ description: 'Clicks an element by visible text content, useful for buttons or links when selector is unknown',
115
+ parameters: {
116
+ type: 'object',
117
+ properties: {
118
+ text: {
119
+ type: 'string',
120
+ description: 'Visible text to search for and click on the page'
121
+ }
122
+ },
123
+ required: ['text']
124
+ }
125
+ }
126
+ },
127
+ // {
128
+ // type: 'function',
129
+ // function: {
130
+ // name: 'get_dom_focus_hint',
131
+ // description: 'Returns recommended DOM sections (e.g., "header", "main", "footer") to prioritize for the current task.',
132
+ // parameters: {
133
+ // type: 'object',
134
+ // properties: {
135
+ // prompt: {
136
+ // type: 'string',
137
+ // description: 'The current user instruction or task description'
138
+ // }
139
+ // },
140
+ // required: ['prompt']
141
+ // }
142
+ // }
143
+ // },
144
+ {
145
+ type: 'function',
146
+ function: {
147
+ name: 'get_dom_chunk',
148
+ description: 'Return a specific chunk of the DOM by index',
149
+ parameters: {
150
+ type: 'object',
151
+ properties: {
152
+ chunkIndex: { type: 'number' },
153
+ totalChunks: { type: 'number' }
154
+ },
155
+ required: ['chunkIndex', 'totalChunks']
156
+ }
157
+ }
158
+ }
159
+
160
+ ];
161
+
162
+ export default toolsSchema;
@@ -0,0 +1,63 @@
1
+ export const finalResponseHandler = (msg) => {
2
+ const final = msg.content?.trim().toLowerCase();
3
+ if (final?.startsWith('success')) {
4
+ console.log('\nā”ā” FINAL AGENT RESPONSE ━━━━━━━━━━━━━━━━━━━');
5
+ console.log(msg.content);
6
+ console.log('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
7
+ return true;
8
+ }
9
+ if (final?.startsWith('failure')) {
10
+ console.log('\nā”ā” FINAL AGENT RESPONSE ━━━━━━━━━━━━━━━━━━━');
11
+ console.log(msg.content);
12
+ console.log('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
13
+ return false;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export const wait = (ms) => new Promise(res => setTimeout(res, ms));
19
+
20
+ export function validateAndInsertMissingToolResponses(messages, options = {}) {
21
+ const { insertPlaceholders = true } = options;
22
+ const missingResponses = [];
23
+
24
+ for (let i = 0; i < messages.length; i++) {
25
+ const msg = messages[i];
26
+ if (msg.role === 'assistant' && msg.tool_calls?.length) {
27
+ for (const toolCall of msg.tool_calls) {
28
+ const expectedId = toolCall.id;
29
+
30
+ const hasResponse = messages.slice(i + 1).some(
31
+ (nextMsg) =>
32
+ nextMsg.role === 'tool' &&
33
+ nextMsg.tool_call_id === expectedId
34
+ );
35
+
36
+ if (!hasResponse) {
37
+ missingResponses.push(expectedId);
38
+
39
+ if (insertPlaceholders) {
40
+ const placeholder = {
41
+ role: 'tool',
42
+ tool_call_id: expectedId,
43
+ name: toolCall.function.name,
44
+ type: 'function',
45
+ content: `[auto-inserted placeholder] No result returned for ${toolCall.function.name}`,
46
+ };
47
+
48
+ // Insert directly after the assistant tool call
49
+ messages.splice(i + 1, 0, placeholder);
50
+ console.warn(`āš ļø Inserted placeholder for missing tool_call_id: ${expectedId}`);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ if (missingResponses.length > 0) {
58
+ console.warn(`āš ļø Missing tool responses for: ${missingResponses.join(', ')}`);
59
+ return insertPlaceholders;
60
+ }
61
+
62
+ return true;
63
+ }