Add browser workflow reliability primitives and guardrails
This commit is contained in:
+10
-1
@@ -238,7 +238,8 @@ models:
|
|||||||
# default_namespace: default
|
# default_namespace: default
|
||||||
# allowed_namespaces: [] # Empty = allow any namespace; set to restrict access.
|
# allowed_namespaces: [] # Empty = allow any namespace; set to restrict access.
|
||||||
|
|
||||||
# Optional: Browser automation tools (browser.navigate/screenshot/click/type/content/eval/evaluate)
|
# Optional: Browser automation tools
|
||||||
|
# (browser.navigate/screenshot/click/type/content/wait_for/assert/extract/checkpoint.save/checkpoint.resume/eval/evaluate)
|
||||||
# Requires a local Chrome/Chromium install or a remote CDP endpoint.
|
# Requires a local Chrome/Chromium install or a remote CDP endpoint.
|
||||||
# browser:
|
# browser:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
@@ -247,6 +248,14 @@ models:
|
|||||||
# headless: true
|
# headless: true
|
||||||
# max_pages: 5
|
# max_pages: 5
|
||||||
# default_timeout: 30000
|
# default_timeout: 30000
|
||||||
|
# # Guardrails:
|
||||||
|
# # allowed_domains: ["*.example.com"]
|
||||||
|
# # high_risk_domains: ["bank.example.com"]
|
||||||
|
# # require_confirmation_for_high_risk: true
|
||||||
|
# # max_workflow_steps: 120
|
||||||
|
# # default_retry_attempts: 1
|
||||||
|
# # max_retry_attempts: 5
|
||||||
|
# # retry_delay_ms: 250
|
||||||
#
|
#
|
||||||
# Tool policy reminder:
|
# Tool policy reminder:
|
||||||
# - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools.
|
# - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools.
|
||||||
|
|||||||
@@ -243,6 +243,13 @@ describe('configSchema — browser', () => {
|
|||||||
expect(result.browser.headless).toBe(true);
|
expect(result.browser.headless).toBe(true);
|
||||||
expect(result.browser.max_pages).toBe(5);
|
expect(result.browser.max_pages).toBe(5);
|
||||||
expect(result.browser.default_timeout).toBe(30000);
|
expect(result.browser.default_timeout).toBe(30000);
|
||||||
|
expect(result.browser.allowed_domains).toEqual([]);
|
||||||
|
expect(result.browser.high_risk_domains).toEqual([]);
|
||||||
|
expect(result.browser.require_confirmation_for_high_risk).toBe(true);
|
||||||
|
expect(result.browser.max_workflow_steps).toBe(120);
|
||||||
|
expect(result.browser.default_retry_attempts).toBe(1);
|
||||||
|
expect(result.browser.max_retry_attempts).toBe(5);
|
||||||
|
expect(result.browser.retry_delay_ms).toBe(250);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts explicit browser config', () => {
|
it('accepts explicit browser config', () => {
|
||||||
@@ -254,6 +261,13 @@ describe('configSchema — browser', () => {
|
|||||||
headless: false,
|
headless: false,
|
||||||
max_pages: 3,
|
max_pages: 3,
|
||||||
default_timeout: 45000,
|
default_timeout: 45000,
|
||||||
|
allowed_domains: ['example.com', '*.trusted.test'],
|
||||||
|
high_risk_domains: ['bank.example.com'],
|
||||||
|
require_confirmation_for_high_risk: true,
|
||||||
|
max_workflow_steps: 40,
|
||||||
|
default_retry_attempts: 2,
|
||||||
|
max_retry_attempts: 6,
|
||||||
|
retry_delay_ms: 500,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,6 +276,13 @@ describe('configSchema — browser', () => {
|
|||||||
expect(result.browser.headless).toBe(false);
|
expect(result.browser.headless).toBe(false);
|
||||||
expect(result.browser.max_pages).toBe(3);
|
expect(result.browser.max_pages).toBe(3);
|
||||||
expect(result.browser.default_timeout).toBe(45000);
|
expect(result.browser.default_timeout).toBe(45000);
|
||||||
|
expect(result.browser.allowed_domains).toEqual(['example.com', '*.trusted.test']);
|
||||||
|
expect(result.browser.high_risk_domains).toEqual(['bank.example.com']);
|
||||||
|
expect(result.browser.require_confirmation_for_high_risk).toBe(true);
|
||||||
|
expect(result.browser.max_workflow_steps).toBe(40);
|
||||||
|
expect(result.browser.default_retry_attempts).toBe(2);
|
||||||
|
expect(result.browser.max_retry_attempts).toBe(6);
|
||||||
|
expect(result.browser.retry_delay_ms).toBe(500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -781,6 +781,13 @@ const browserSchema = z.object({
|
|||||||
headless: z.boolean().default(true),
|
headless: z.boolean().default(true),
|
||||||
max_pages: z.number().min(1).max(20).default(5),
|
max_pages: z.number().min(1).max(20).default(5),
|
||||||
default_timeout: z.number().min(1000).max(120000).default(30000),
|
default_timeout: z.number().min(1000).max(120000).default(30000),
|
||||||
|
allowed_domains: z.array(z.string().min(1)).default([]),
|
||||||
|
high_risk_domains: z.array(z.string().min(1)).default([]),
|
||||||
|
require_confirmation_for_high_risk: z.boolean().default(true),
|
||||||
|
max_workflow_steps: z.number().int().min(1).max(1000).default(120),
|
||||||
|
default_retry_attempts: z.number().int().min(1).max(10).default(1),
|
||||||
|
max_retry_attempts: z.number().int().min(1).max(20).default(5),
|
||||||
|
retry_delay_ms: z.number().int().min(0).max(10000).default(250),
|
||||||
}).default({});
|
}).default({});
|
||||||
|
|
||||||
const processSchema = z.object({
|
const processSchema = z.object({
|
||||||
|
|||||||
+23
-2
@@ -67,7 +67,20 @@ export function initTools(deps: ToolsDeps): ToolsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize browser manager and register browser tools (if enabled)
|
// Initialize browser manager and register browser tools (if enabled)
|
||||||
const browserToolNames = ['browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'];
|
const browserToolNames = [
|
||||||
|
'browser.navigate',
|
||||||
|
'browser.screenshot',
|
||||||
|
'browser.click',
|
||||||
|
'browser.type',
|
||||||
|
'browser.content',
|
||||||
|
'browser.wait_for',
|
||||||
|
'browser.assert',
|
||||||
|
'browser.extract',
|
||||||
|
'browser.checkpoint.save',
|
||||||
|
'browser.checkpoint.resume',
|
||||||
|
'browser.eval',
|
||||||
|
'browser.evaluate',
|
||||||
|
];
|
||||||
let browserManager: BrowserManager | undefined;
|
let browserManager: BrowserManager | undefined;
|
||||||
if (config.browser?.enabled) {
|
if (config.browser?.enabled) {
|
||||||
const manager = new BrowserManager({
|
const manager = new BrowserManager({
|
||||||
@@ -79,7 +92,15 @@ export function initTools(deps: ToolsDeps): ToolsResult {
|
|||||||
});
|
});
|
||||||
browserManager = manager;
|
browserManager = manager;
|
||||||
|
|
||||||
for (const tool of createBrowserTools(manager)) {
|
for (const tool of createBrowserTools(manager, {
|
||||||
|
allowedDomains: config.browser.allowed_domains,
|
||||||
|
highRiskDomains: config.browser.high_risk_domains,
|
||||||
|
requireHighRiskConfirmation: config.browser.require_confirmation_for_high_risk,
|
||||||
|
maxWorkflowSteps: config.browser.max_workflow_steps,
|
||||||
|
defaultRetryAttempts: config.browser.default_retry_attempts,
|
||||||
|
maxRetryAttempts: config.browser.max_retry_attempts,
|
||||||
|
retryDelayMs: config.browser.retry_delay_ms,
|
||||||
|
})) {
|
||||||
toolRegistry.register(tool);
|
toolRegistry.register(tool);
|
||||||
}
|
}
|
||||||
console.log(`Browser tools enabled (headless=${config.browser.headless})`);
|
console.log(`Browser tools enabled (headless=${config.browser.headless})`);
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ const mockUrl = vi.fn().mockReturnValue('https://example.com');
|
|||||||
const mockClick = vi.fn().mockResolvedValue(undefined);
|
const mockClick = vi.fn().mockResolvedValue(undefined);
|
||||||
const mockType = vi.fn().mockResolvedValue(undefined);
|
const mockType = vi.fn().mockResolvedValue(undefined);
|
||||||
const mock$eval = vi.fn().mockResolvedValue('Page content here');
|
const mock$eval = vi.fn().mockResolvedValue('Page content here');
|
||||||
|
const mock$$eval = vi.fn().mockResolvedValue(['Row 1', 'Row 2']);
|
||||||
const mockEvaluate = vi.fn().mockResolvedValue({ result: 42 });
|
const mockEvaluate = vi.fn().mockResolvedValue({ result: 42 });
|
||||||
|
const mockWaitForSelector = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockWaitForFunction = vi.fn().mockResolvedValue(undefined);
|
||||||
const mockScreenshot = vi.fn().mockResolvedValue('base64data');
|
const mockScreenshot = vi.fn().mockResolvedValue('base64data');
|
||||||
const mock$ = vi.fn().mockResolvedValue({ screenshot: vi.fn().mockResolvedValue('element-base64') });
|
const mock$ = vi.fn().mockResolvedValue({ screenshot: vi.fn().mockResolvedValue('element-base64') });
|
||||||
const mockKeyboard = { press: vi.fn().mockResolvedValue(undefined) };
|
const mockKeyboard = { press: vi.fn().mockResolvedValue(undefined) };
|
||||||
@@ -20,7 +23,10 @@ const mockPage = {
|
|||||||
click: mockClick,
|
click: mockClick,
|
||||||
type: mockType,
|
type: mockType,
|
||||||
$eval: mock$eval,
|
$eval: mock$eval,
|
||||||
|
$$eval: mock$$eval,
|
||||||
evaluate: mockEvaluate,
|
evaluate: mockEvaluate,
|
||||||
|
waitForSelector: mockWaitForSelector,
|
||||||
|
waitForFunction: mockWaitForFunction,
|
||||||
screenshot: mockScreenshot,
|
screenshot: mockScreenshot,
|
||||||
$: mock$,
|
$: mock$,
|
||||||
keyboard: mockKeyboard,
|
keyboard: mockKeyboard,
|
||||||
@@ -44,6 +50,7 @@ describe('Browser tools', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockUrl.mockReturnValue('https://example.com');
|
||||||
tools = createBrowserTools(mockManager);
|
tools = createBrowserTools(mockManager);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,9 +61,14 @@ describe('Browser tools', () => {
|
|||||||
expect(names).toContain('browser.click');
|
expect(names).toContain('browser.click');
|
||||||
expect(names).toContain('browser.type');
|
expect(names).toContain('browser.type');
|
||||||
expect(names).toContain('browser.content');
|
expect(names).toContain('browser.content');
|
||||||
|
expect(names).toContain('browser.wait_for');
|
||||||
|
expect(names).toContain('browser.assert');
|
||||||
|
expect(names).toContain('browser.extract');
|
||||||
|
expect(names).toContain('browser.checkpoint.save');
|
||||||
|
expect(names).toContain('browser.checkpoint.resume');
|
||||||
expect(names).toContain('browser.eval');
|
expect(names).toContain('browser.eval');
|
||||||
expect(names).toContain('browser.evaluate');
|
expect(names).toContain('browser.evaluate');
|
||||||
expect(names).toHaveLength(7);
|
expect(names).toHaveLength(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('browser.navigate navigates to URL', async () => {
|
it('browser.navigate navigates to URL', async () => {
|
||||||
@@ -73,6 +85,17 @@ describe('Browser tools', () => {
|
|||||||
expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'networkidle0' });
|
expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'networkidle0' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('browser.navigate retries on transient errors', async () => {
|
||||||
|
mockGoto.mockRejectedValueOnce(new Error('temporary down'));
|
||||||
|
const tool = getTool('browser.navigate');
|
||||||
|
const result = await tool.execute({
|
||||||
|
url: 'https://example.com',
|
||||||
|
retry: { attempts: 2, delay_ms: 0 },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockGoto).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('browser.screenshot takes page screenshot', async () => {
|
it('browser.screenshot takes page screenshot', async () => {
|
||||||
const tool = getTool('browser.screenshot');
|
const tool = getTool('browser.screenshot');
|
||||||
const result = await tool.execute({});
|
const result = await tool.execute({});
|
||||||
@@ -103,6 +126,17 @@ describe('Browser tools', () => {
|
|||||||
expect(mockClick).toHaveBeenCalledWith('#submit');
|
expect(mockClick).toHaveBeenCalledWith('#submit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('browser.click retries on transient failures', async () => {
|
||||||
|
mockClick.mockRejectedValueOnce(new Error('click miss'));
|
||||||
|
const tool = getTool('browser.click');
|
||||||
|
const result = await tool.execute({
|
||||||
|
selector: '#submit',
|
||||||
|
retry: { attempts: 2, delay_ms: 0 },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockClick).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('browser.type types into element', async () => {
|
it('browser.type types into element', async () => {
|
||||||
const tool = getTool('browser.type');
|
const tool = getTool('browser.type');
|
||||||
const result = await tool.execute({ selector: '#search', text: 'hello' });
|
const result = await tool.execute({ selector: '#search', text: 'hello' });
|
||||||
@@ -133,6 +167,75 @@ describe('Browser tools', () => {
|
|||||||
expect(mock$eval).toHaveBeenCalledWith('#main', expect.any(Function));
|
expect(mock$eval).toHaveBeenCalledWith('#main', expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('browser.wait_for waits on selector and text', async () => {
|
||||||
|
const tool = getTool('browser.wait_for');
|
||||||
|
const result = await tool.execute({
|
||||||
|
selector: '#loaded',
|
||||||
|
text: 'Ready',
|
||||||
|
timeout_ms: 5000,
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockWaitForSelector).toHaveBeenCalledWith('#loaded', { timeout: 5000, visible: false });
|
||||||
|
expect(mockWaitForFunction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser.assert validates selector/text/url conditions', async () => {
|
||||||
|
const tool = getTool('browser.assert');
|
||||||
|
const result = await tool.execute({
|
||||||
|
selector: '#main',
|
||||||
|
exists: true,
|
||||||
|
text: 'Page content here',
|
||||||
|
url_includes: 'example.com',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser.assert fails when conditions are not met', async () => {
|
||||||
|
mockUrl.mockReturnValue('https://example.com/path');
|
||||||
|
mock$eval.mockResolvedValueOnce('different content');
|
||||||
|
mock$.mockResolvedValueOnce(null);
|
||||||
|
const tool = getTool('browser.assert');
|
||||||
|
const result = await tool.execute({
|
||||||
|
selector: '#missing',
|
||||||
|
exists: true,
|
||||||
|
text: 'not present',
|
||||||
|
url_includes: 'nope',
|
||||||
|
retry: { attempts: 1 },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('failed after retries');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser.extract returns a single value by selector', async () => {
|
||||||
|
mock$eval.mockResolvedValueOnce('Primary value');
|
||||||
|
const tool = getTool('browser.extract');
|
||||||
|
const result = await tool.execute({ selector: '#value' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toContain('"value": "Primary value"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser.extract returns array values when all=true', async () => {
|
||||||
|
mock$$eval.mockResolvedValueOnce(['A', 'B', 'C']);
|
||||||
|
const tool = getTool('browser.extract');
|
||||||
|
const result = await tool.execute({ selector: '.row', all: true });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toContain('"count": 3');
|
||||||
|
expect(result.output).toContain('"values"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser.checkpoint.save and resume navigates to saved url', async () => {
|
||||||
|
const saveTool = getTool('browser.checkpoint.save');
|
||||||
|
const resumeTool = getTool('browser.checkpoint.resume');
|
||||||
|
|
||||||
|
const saved = await saveTool.execute({ checkpoint_id: 'cp-1' });
|
||||||
|
expect(saved.success).toBe(true);
|
||||||
|
expect(saved.output).toContain('cp-1');
|
||||||
|
|
||||||
|
const resumed = await resumeTool.execute({ checkpoint_id: 'cp-1' });
|
||||||
|
expect(resumed.success).toBe(true);
|
||||||
|
expect(mockGoto).toHaveBeenCalledWith('https://example.com', { waitUntil: 'domcontentloaded' });
|
||||||
|
});
|
||||||
|
|
||||||
it('browser.eval evaluates JS', async () => {
|
it('browser.eval evaluates JS', async () => {
|
||||||
const tool = getTool('browser.eval');
|
const tool = getTool('browser.eval');
|
||||||
const result = await tool.execute({ expression: '1 + 1' });
|
const result = await tool.execute({ expression: '1 + 1' });
|
||||||
@@ -155,10 +258,61 @@ describe('Browser tools', () => {
|
|||||||
expect(result.output).toContain('42');
|
expect(result.output).toContain('42');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enforces allowed domain guardrail for navigation', async () => {
|
||||||
|
const restrictedTools = createBrowserTools(mockManager, {
|
||||||
|
allowedDomains: ['example.com'],
|
||||||
|
});
|
||||||
|
const navigate = restrictedTools.find((tool) => tool.name === 'browser.navigate');
|
||||||
|
if (!navigate) {
|
||||||
|
throw new Error('missing navigate tool');
|
||||||
|
}
|
||||||
|
const result = await navigate.execute({ url: 'https://blocked.test' });
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('allowed_domains');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires explicit high-risk confirmation for configured domains', async () => {
|
||||||
|
const guardedTools = createBrowserTools(mockManager, {
|
||||||
|
highRiskDomains: ['bank.example.com'],
|
||||||
|
requireHighRiskConfirmation: true,
|
||||||
|
});
|
||||||
|
const navigate = guardedTools.find((tool) => tool.name === 'browser.navigate');
|
||||||
|
if (!navigate) {
|
||||||
|
throw new Error('missing navigate tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
const denied = await navigate.execute({ url: 'https://bank.example.com' });
|
||||||
|
expect(denied.success).toBe(false);
|
||||||
|
expect(denied.error).toContain('confirm_high_risk=true');
|
||||||
|
|
||||||
|
const allowed = await navigate.execute({
|
||||||
|
url: 'https://bank.example.com',
|
||||||
|
confirm_high_risk: true,
|
||||||
|
});
|
||||||
|
expect(allowed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces workflow step budget', async () => {
|
||||||
|
const budgetedTools = createBrowserTools(mockManager, {
|
||||||
|
maxWorkflowSteps: 1,
|
||||||
|
});
|
||||||
|
const navigate = budgetedTools.find((tool) => tool.name === 'browser.navigate');
|
||||||
|
const click = budgetedTools.find((tool) => tool.name === 'browser.click');
|
||||||
|
if (!navigate || !click) {
|
||||||
|
throw new Error('missing browser tools');
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await navigate.execute({ url: 'https://example.com' });
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
const second = await click.execute({ selector: '#submit' });
|
||||||
|
expect(second.success).toBe(false);
|
||||||
|
expect(second.error).toContain('budget exhausted');
|
||||||
|
});
|
||||||
|
|
||||||
it('handles navigation errors gracefully', async () => {
|
it('handles navigation errors gracefully', async () => {
|
||||||
mockGoto.mockRejectedValueOnce(new Error('Navigation failed'));
|
mockGoto.mockRejectedValueOnce(new Error('Navigation failed'));
|
||||||
const tool = getTool('browser.navigate');
|
const tool = getTool('browser.navigate');
|
||||||
const result = await tool.execute({ url: 'https://broken.example.com' });
|
const result = await tool.execute({ url: 'https://broken.example.com', retry: { attempts: 1 } });
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('Navigation failed');
|
expect(result.error).toContain('Navigation failed');
|
||||||
});
|
});
|
||||||
@@ -166,7 +320,7 @@ describe('Browser tools', () => {
|
|||||||
it('handles click errors gracefully', async () => {
|
it('handles click errors gracefully', async () => {
|
||||||
mockClick.mockRejectedValueOnce(new Error('Element not found'));
|
mockClick.mockRejectedValueOnce(new Error('Element not found'));
|
||||||
const tool = getTool('browser.click');
|
const tool = getTool('browser.click');
|
||||||
const result = await tool.execute({ selector: '#missing' });
|
const result = await tool.execute({ selector: '#missing', retry: { attempts: 1 } });
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('Element not found');
|
expect(result.error).toContain('Element not found');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
import type { Tool, ToolExecutionContext, ToolResult } from '../../types.js';
|
import type { Tool, ToolExecutionContext, ToolResult } from '../../types.js';
|
||||||
import type { BrowserManager } from './manager.js';
|
import type { BrowserManager } from './manager.js';
|
||||||
|
|
||||||
|
interface BrowserRetryConfig {
|
||||||
|
attempts?: number;
|
||||||
|
delay_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserToolsOptions {
|
||||||
|
allowedDomains?: string[];
|
||||||
|
highRiskDomains?: string[];
|
||||||
|
requireHighRiskConfirmation?: boolean;
|
||||||
|
maxWorkflowSteps?: number;
|
||||||
|
defaultRetryAttempts?: number;
|
||||||
|
maxRetryAttempts?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserCheckpoint {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
stepsUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetryOptions {
|
||||||
|
defaultRetryAttempts: number;
|
||||||
|
maxRetryAttempts: number;
|
||||||
|
retryDelayMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
function abortError(): string {
|
function abortError(): string {
|
||||||
return 'Operation aborted';
|
return 'Operation aborted';
|
||||||
}
|
}
|
||||||
@@ -55,20 +84,180 @@ async function withAbort<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDomainPattern(rawPattern: string): string {
|
||||||
|
const trimmed = rawPattern.trim().toLowerCase();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (trimmed === '*') {
|
||||||
|
return '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = trimmed;
|
||||||
|
if (candidate.includes('://')) {
|
||||||
|
try {
|
||||||
|
candidate = new URL(candidate).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidate = candidate.split('/')[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.startsWith('.')) {
|
||||||
|
candidate = candidate.slice(1);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainMatches(hostname: string, pattern: string): boolean {
|
||||||
|
if (pattern === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pattern.startsWith('*.')) {
|
||||||
|
const base = pattern.slice(2);
|
||||||
|
return hostname === base || hostname.endsWith(`.${base}`);
|
||||||
|
}
|
||||||
|
return hostname === pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHostname(rawUrl: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(rawUrl).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid URL: ${rawUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRetryConfig(
|
||||||
|
retry: BrowserRetryConfig | undefined,
|
||||||
|
defaults: RetryOptions,
|
||||||
|
): { attempts: number; delayMs: number } {
|
||||||
|
const attemptsInput = retry?.attempts ?? defaults.defaultRetryAttempts;
|
||||||
|
const attemptsRaw = Number.isFinite(attemptsInput) ? Number(attemptsInput) : defaults.defaultRetryAttempts;
|
||||||
|
const delayInput = retry?.delay_ms ?? defaults.retryDelayMs;
|
||||||
|
const delayRaw = Number.isFinite(delayInput) ? Number(delayInput) : defaults.retryDelayMs;
|
||||||
|
const attempts = Math.max(1, Math.min(defaults.maxRetryAttempts, Math.floor(attemptsRaw)));
|
||||||
|
const delayMs = Math.max(0, Math.floor(delayRaw));
|
||||||
|
return { attempts, delayMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (ms <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await withAbort(new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
}), signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithRetry<T>(
|
||||||
|
label: string,
|
||||||
|
operation: (attempt: number) => Promise<T>,
|
||||||
|
retry: BrowserRetryConfig | undefined,
|
||||||
|
defaults: RetryOptions,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
const { attempts, delayMs } = normalizeRetryConfig(retry, defaults);
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
try {
|
||||||
|
return await operation(attempt);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
lastError = error;
|
||||||
|
if (attempt >= attempts) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleepWithAbort(delayMs, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||||
|
throw new Error(`${label} failed after retries: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Create all browser tools bound to a BrowserManager instance. */
|
/** Create all browser tools bound to a BrowserManager instance. */
|
||||||
export function createBrowserTools(manager: BrowserManager): Tool[] {
|
export function createBrowserTools(manager: BrowserManager, options: BrowserToolsOptions = {}): Tool[] {
|
||||||
|
const allowedDomains = (options.allowedDomains ?? []).map(normalizeDomainPattern).filter(Boolean);
|
||||||
|
const highRiskDomains = (options.highRiskDomains ?? []).map(normalizeDomainPattern).filter(Boolean);
|
||||||
|
const requireHighRiskConfirmation = options.requireHighRiskConfirmation ?? true;
|
||||||
|
const maxWorkflowSteps = options.maxWorkflowSteps;
|
||||||
|
const retryOptions: RetryOptions = {
|
||||||
|
defaultRetryAttempts: options.defaultRetryAttempts ?? 1,
|
||||||
|
maxRetryAttempts: options.maxRetryAttempts ?? 5,
|
||||||
|
retryDelayMs: options.retryDelayMs ?? 250,
|
||||||
|
};
|
||||||
|
let stepsUsed = 0;
|
||||||
|
const checkpoints = new Map<string, BrowserCheckpoint>();
|
||||||
|
let latestCheckpointId: string | undefined;
|
||||||
|
|
||||||
|
function consumeWorkflowStep(action: string): void {
|
||||||
|
if (typeof maxWorkflowSteps !== 'number') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stepsUsed >= maxWorkflowSteps) {
|
||||||
|
throw new Error(`Browser workflow step budget exhausted (${stepsUsed}/${maxWorkflowSteps}) before ${action}`);
|
||||||
|
}
|
||||||
|
stepsUsed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDomainAllowed(rawUrl: string): void {
|
||||||
|
if (allowedDomains.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hostname = extractHostname(rawUrl);
|
||||||
|
if (!allowedDomains.some((pattern) => domainMatches(hostname, pattern))) {
|
||||||
|
throw new Error(`Navigation to '${hostname}' denied by browser.allowed_domains`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHighRiskConfirmed(rawUrl: string, confirmHighRisk?: boolean): void {
|
||||||
|
if (!requireHighRiskConfirmation || highRiskDomains.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hostname = extractHostname(rawUrl);
|
||||||
|
const isHighRisk = highRiskDomains.some((pattern) => domainMatches(hostname, pattern));
|
||||||
|
if (isHighRisk && !confirmHighRisk) {
|
||||||
|
throw new Error(
|
||||||
|
`Domain '${hostname}' is marked high-risk. Re-run with confirm_high_risk=true after explicit user confirmation.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCurrentPageDomainAllowed(rawUrl: string): void {
|
||||||
|
if (allowedDomains.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hostname = extractHostname(rawUrl);
|
||||||
|
if (!allowedDomains.some((pattern) => domainMatches(hostname, pattern))) {
|
||||||
|
throw new Error(`Current page '${hostname}' is outside browser.allowed_domains`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
createBrowserNavigateTool(manager),
|
createBrowserNavigateTool(manager),
|
||||||
createBrowserScreenshotTool(manager),
|
createBrowserScreenshotTool(manager),
|
||||||
createBrowserClickTool(manager),
|
createBrowserClickTool(manager),
|
||||||
createBrowserTypeTool(manager),
|
createBrowserTypeTool(manager),
|
||||||
createBrowserContentTool(manager),
|
createBrowserContentTool(manager),
|
||||||
|
createBrowserWaitForTool(manager),
|
||||||
|
createBrowserAssertTool(manager),
|
||||||
|
createBrowserExtractTool(manager),
|
||||||
|
createBrowserCheckpointSaveTool(manager),
|
||||||
|
createBrowserCheckpointResumeTool(manager),
|
||||||
createBrowserEvalTool(manager),
|
createBrowserEvalTool(manager),
|
||||||
createBrowserEvaluateTool(manager),
|
createBrowserEvaluateTool(manager),
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
function createBrowserNavigateTool(manager: BrowserManager): Tool {
|
function createBrowserNavigateTool(browserManager: BrowserManager): Tool {
|
||||||
return {
|
return {
|
||||||
name: 'browser.navigate',
|
name: 'browser.navigate',
|
||||||
description: 'Navigate to a URL in the browser. Returns the page title and URL after navigation.',
|
description: 'Navigate to a URL in the browser. Returns the page title and URL after navigation.',
|
||||||
@@ -80,16 +269,34 @@ function createBrowserNavigateTool(manager: BrowserManager): Tool {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'When to consider navigation complete: load, domcontentloaded, networkidle0, networkidle2 (default: domcontentloaded)',
|
description: 'When to consider navigation complete: load, domcontentloaded, networkidle0, networkidle2 (default: domcontentloaded)',
|
||||||
},
|
},
|
||||||
|
confirm_high_risk: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Required when navigating to a configured browser.high_risk_domains hostname.',
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' },
|
||||||
|
delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['url'],
|
required: ['url'],
|
||||||
},
|
},
|
||||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
const args = rawArgs as { url: string; waitUntil?: string };
|
const args = rawArgs as { url: string; waitUntil?: string; confirm_high_risk?: boolean; retry?: BrowserRetryConfig };
|
||||||
try {
|
try {
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
const page = await manager.getPage();
|
const page = await browserManager.getPage();
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
|
ensureDomainAllowed(args.url);
|
||||||
|
ensureHighRiskConfirmed(args.url, args.confirm_high_risk);
|
||||||
const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
|
const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
|
||||||
|
|
||||||
|
await runWithRetry(
|
||||||
|
'browser.navigate',
|
||||||
|
async () => {
|
||||||
|
consumeWorkflowStep('browser.navigate');
|
||||||
await withAbort(
|
await withAbort(
|
||||||
page.goto(args.url, { waitUntil }),
|
page.goto(args.url, { waitUntil }),
|
||||||
context?.signal,
|
context?.signal,
|
||||||
@@ -99,12 +306,18 @@ function createBrowserNavigateTool(manager: BrowserManager): Tool {
|
|||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
args.retry,
|
||||||
|
retryOptions,
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
|
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
const title = await page.title();
|
const title = await page.title();
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: `Navigated to: ${currentUrl}\nTitle: ${title}`,
|
output: `Navigated to: ${currentUrl}\nTitle: ${title}\nWorkflow steps used: ${stepsUsed}`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) {
|
if (isAbortError(error)) {
|
||||||
@@ -120,7 +333,7 @@ function createBrowserNavigateTool(manager: BrowserManager): Tool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserScreenshotTool(manager: BrowserManager): Tool {
|
function createBrowserScreenshotTool(browserManager: BrowserManager): Tool {
|
||||||
return {
|
return {
|
||||||
name: 'browser.screenshot',
|
name: 'browser.screenshot',
|
||||||
description: 'Take a screenshot of the current page. Returns the screenshot as a base64-encoded PNG.',
|
description: 'Take a screenshot of the current page. Returns the screenshot as a base64-encoded PNG.',
|
||||||
@@ -135,9 +348,11 @@ function createBrowserScreenshotTool(manager: BrowserManager): Tool {
|
|||||||
const args = rawArgs as { fullPage?: boolean; selector?: string };
|
const args = rawArgs as { fullPage?: boolean; selector?: string };
|
||||||
try {
|
try {
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
const page = await manager.getPage();
|
const page = await browserManager.getPage();
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
|
||||||
|
consumeWorkflowStep('browser.screenshot');
|
||||||
let screenshotData: string;
|
let screenshotData: string;
|
||||||
if (args.selector) {
|
if (args.selector) {
|
||||||
const element = await page.$(args.selector);
|
const element = await page.$(args.selector);
|
||||||
@@ -170,7 +385,7 @@ function createBrowserScreenshotTool(manager: BrowserManager): Tool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserClickTool(manager: BrowserManager): Tool {
|
function createBrowserClickTool(browserManager: BrowserManager): Tool {
|
||||||
return {
|
return {
|
||||||
name: 'browser.click',
|
name: 'browser.click',
|
||||||
description: 'Click an element on the page identified by CSS selector.',
|
description: 'Click an element on the page identified by CSS selector.',
|
||||||
@@ -178,16 +393,33 @@ function createBrowserClickTool(manager: BrowserManager): Tool {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
selector: { type: 'string', description: 'CSS selector of the element to click' },
|
selector: { type: 'string', description: 'CSS selector of the element to click' },
|
||||||
|
retry: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' },
|
||||||
|
delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['selector'],
|
required: ['selector'],
|
||||||
},
|
},
|
||||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
const args = rawArgs as { selector: string };
|
const args = rawArgs as { selector: string; retry?: BrowserRetryConfig };
|
||||||
try {
|
try {
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
const page = await manager.getPage();
|
const page = await browserManager.getPage();
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
await runWithRetry(
|
||||||
|
'browser.click',
|
||||||
|
async () => {
|
||||||
|
consumeWorkflowStep('browser.click');
|
||||||
await withAbort(page.click(args.selector), context?.signal);
|
await withAbort(page.click(args.selector), context?.signal);
|
||||||
|
},
|
||||||
|
args.retry,
|
||||||
|
retryOptions,
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
return { success: true, output: `Clicked element: ${args.selector}` };
|
return { success: true, output: `Clicked element: ${args.selector}` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) {
|
if (isAbortError(error)) {
|
||||||
@@ -203,7 +435,7 @@ function createBrowserClickTool(manager: BrowserManager): Tool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserTypeTool(manager: BrowserManager): Tool {
|
function createBrowserTypeTool(browserManager: BrowserManager): Tool {
|
||||||
return {
|
return {
|
||||||
name: 'browser.type',
|
name: 'browser.type',
|
||||||
description: 'Type text into an input element on the page.',
|
description: 'Type text into an input element on the page.',
|
||||||
@@ -213,20 +445,37 @@ function createBrowserTypeTool(manager: BrowserManager): Tool {
|
|||||||
selector: { type: 'string', description: 'CSS selector of the input element' },
|
selector: { type: 'string', description: 'CSS selector of the input element' },
|
||||||
text: { type: 'string', description: 'Text to type' },
|
text: { type: 'string', description: 'Text to type' },
|
||||||
clear: { type: 'boolean', description: 'Clear the field before typing (default: false)' },
|
clear: { type: 'boolean', description: 'Clear the field before typing (default: false)' },
|
||||||
|
retry: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' },
|
||||||
|
delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['selector', 'text'],
|
required: ['selector', 'text'],
|
||||||
},
|
},
|
||||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
const args = rawArgs as { selector: string; text: string; clear?: boolean };
|
const args = rawArgs as { selector: string; text: string; clear?: boolean; retry?: BrowserRetryConfig };
|
||||||
try {
|
try {
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
const page = await manager.getPage();
|
const page = await browserManager.getPage();
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
await runWithRetry(
|
||||||
|
'browser.type',
|
||||||
|
async () => {
|
||||||
|
consumeWorkflowStep('browser.type');
|
||||||
if (args.clear) {
|
if (args.clear) {
|
||||||
await withAbort(page.click(args.selector, { count: 3 }), context?.signal); // Select all
|
await withAbort(page.click(args.selector, { count: 3 }), context?.signal); // Select all
|
||||||
await withAbort(page.keyboard.press('Backspace'), context?.signal);
|
await withAbort(page.keyboard.press('Backspace'), context?.signal);
|
||||||
}
|
}
|
||||||
await withAbort(page.type(args.selector, args.text), context?.signal);
|
await withAbort(page.type(args.selector, args.text), context?.signal);
|
||||||
|
},
|
||||||
|
args.retry,
|
||||||
|
retryOptions,
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
return { success: true, output: `Typed "${args.text}" into ${args.selector}` };
|
return { success: true, output: `Typed "${args.text}" into ${args.selector}` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) {
|
if (isAbortError(error)) {
|
||||||
@@ -242,7 +491,7 @@ function createBrowserTypeTool(manager: BrowserManager): Tool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserContentTool(manager: BrowserManager): Tool {
|
function createBrowserContentTool(browserManager: BrowserManager): Tool {
|
||||||
return {
|
return {
|
||||||
name: 'browser.content',
|
name: 'browser.content',
|
||||||
description: 'Get the text content of the page or a specific element. Returns extracted text (not raw HTML).',
|
description: 'Get the text content of the page or a specific element. Returns extracted text (not raw HTML).',
|
||||||
@@ -257,8 +506,10 @@ function createBrowserContentTool(manager: BrowserManager): Tool {
|
|||||||
const args = rawArgs as { selector?: string; maxLength?: number };
|
const args = rawArgs as { selector?: string; maxLength?: number };
|
||||||
try {
|
try {
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
const page = await manager.getPage();
|
const page = await browserManager.getPage();
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
consumeWorkflowStep('browser.content');
|
||||||
const selector = args.selector ?? 'body';
|
const selector = args.selector ?? 'body';
|
||||||
const maxLength = args.maxLength ?? 10000;
|
const maxLength = args.maxLength ?? 10000;
|
||||||
|
|
||||||
@@ -294,23 +545,449 @@ function createBrowserContentTool(manager: BrowserManager): Tool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserEvalTool(manager: BrowserManager): Tool {
|
function createBrowserWaitForTool(browserManager: BrowserManager): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser.wait_for',
|
||||||
|
description: 'Wait for selector/text conditions to become true before continuing a workflow step.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
selector: { type: 'string', description: 'CSS selector to wait for' },
|
||||||
|
text: { type: 'string', description: 'Text that must appear in the page (or selected element)' },
|
||||||
|
visible: { type: 'boolean', description: 'When waiting for selector, require it to be visible (default: false)' },
|
||||||
|
timeout_ms: { type: 'number', description: 'Timeout for each wait attempt (default: page timeout)' },
|
||||||
|
retry: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' },
|
||||||
|
delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
|
const args = rawArgs as {
|
||||||
|
selector?: string;
|
||||||
|
text?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
timeout_ms?: number;
|
||||||
|
retry?: BrowserRetryConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!args.selector && !args.text) {
|
||||||
|
return { success: false, output: '', error: 'browser.wait_for requires selector and/or text' };
|
||||||
|
}
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
const page = await browserManager.getPage();
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
const timeoutMs = args.timeout_ms;
|
||||||
|
|
||||||
|
await runWithRetry(
|
||||||
|
'browser.wait_for',
|
||||||
|
async () => {
|
||||||
|
consumeWorkflowStep('browser.wait_for');
|
||||||
|
if (args.selector) {
|
||||||
|
await withAbort(
|
||||||
|
page.waitForSelector(args.selector, {
|
||||||
|
timeout: timeoutMs,
|
||||||
|
visible: args.visible ?? false,
|
||||||
|
}),
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.text) {
|
||||||
|
await withAbort(page.waitForFunction(
|
||||||
|
(needle: string, selector: string | null) => {
|
||||||
|
const doc = (globalThis as {
|
||||||
|
document?: {
|
||||||
|
querySelector?: (value: string) => unknown;
|
||||||
|
body?: unknown;
|
||||||
|
};
|
||||||
|
}).document;
|
||||||
|
const root = selector ? doc?.querySelector?.(selector) : doc?.body;
|
||||||
|
if (!root) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const container = root as { innerText?: string; textContent?: string | null };
|
||||||
|
const textValue = container.innerText ?? container.textContent ?? '';
|
||||||
|
return textValue.includes(needle);
|
||||||
|
},
|
||||||
|
{ timeout: timeoutMs },
|
||||||
|
args.text,
|
||||||
|
args.selector ?? null,
|
||||||
|
), context?.signal);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
args.retry,
|
||||||
|
retryOptions,
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: `wait_for satisfied${args.selector ? ` selector=${args.selector}` : ''}${args.text ? ` text="${args.text}"` : ''}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
return { success: false, output: '', error: abortError() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrowserAssertTool(browserManager: BrowserManager): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser.assert',
|
||||||
|
description: 'Assert page conditions (selector presence, text match, URL fragment) with deterministic failures.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
selector: { type: 'string', description: 'Optional selector for existence/text assertions' },
|
||||||
|
exists: { type: 'boolean', description: 'Expected selector existence (default: true when selector provided)' },
|
||||||
|
text: { type: 'string', description: 'Text that must be present in page or selector scope' },
|
||||||
|
url_includes: { type: 'string', description: 'Substring that must exist in current URL' },
|
||||||
|
retry: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' },
|
||||||
|
delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
|
const args = rawArgs as {
|
||||||
|
selector?: string;
|
||||||
|
exists?: boolean;
|
||||||
|
text?: string;
|
||||||
|
url_includes?: string;
|
||||||
|
retry?: BrowserRetryConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasAssertion = Boolean(args.selector || args.text || args.url_includes);
|
||||||
|
if (!hasAssertion) {
|
||||||
|
return { success: false, output: '', error: 'browser.assert requires at least one assertion field' };
|
||||||
|
}
|
||||||
|
if (typeof args.exists === 'boolean' && !args.selector) {
|
||||||
|
return { success: false, output: '', error: 'browser.assert exists requires selector' };
|
||||||
|
}
|
||||||
|
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
const page = await browserManager.getPage();
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
|
||||||
|
await runWithRetry(
|
||||||
|
'browser.assert',
|
||||||
|
async () => {
|
||||||
|
consumeWorkflowStep('browser.assert');
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
if (args.url_includes && !page.url().includes(args.url_includes)) {
|
||||||
|
failures.push(`url_includes failed (url=${page.url()}, expected fragment=${args.url_includes})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.selector) {
|
||||||
|
const exists = Boolean(await page.$(args.selector));
|
||||||
|
const expected = args.exists ?? true;
|
||||||
|
if (exists !== expected) {
|
||||||
|
failures.push(`selector existence failed (${args.selector}, expected=${expected}, actual=${exists})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.text) {
|
||||||
|
const scopeSelector = args.selector ?? 'body';
|
||||||
|
const textValue = await page.$eval(scopeSelector, (el) => {
|
||||||
|
const htmlEl = el as unknown as { innerText?: string; textContent?: string | null };
|
||||||
|
return htmlEl.innerText || htmlEl.textContent || '';
|
||||||
|
});
|
||||||
|
if (!textValue.includes(args.text)) {
|
||||||
|
failures.push(`text assertion failed (missing "${args.text}" in ${scopeSelector})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new Error(failures.join('; '));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
args.retry,
|
||||||
|
retryOptions,
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, output: 'Assertions passed' };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
return { success: false, output: '', error: abortError() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrowserExtractTool(browserManager: BrowserManager): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser.extract',
|
||||||
|
description: 'Extract structured values from elements by selector and attribute/text.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
selector: { type: 'string', description: 'Selector to extract from' },
|
||||||
|
attribute: { type: 'string', description: 'Attribute name to extract (default: text)' },
|
||||||
|
all: { type: 'boolean', description: 'Extract from all matched elements (default: false)' },
|
||||||
|
max_length: { type: 'number', description: 'Max characters per extracted value (default: 10000)' },
|
||||||
|
retry: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' },
|
||||||
|
delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['selector'],
|
||||||
|
},
|
||||||
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
|
const args = rawArgs as {
|
||||||
|
selector: string;
|
||||||
|
attribute?: string;
|
||||||
|
all?: boolean;
|
||||||
|
max_length?: number;
|
||||||
|
retry?: BrowserRetryConfig;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
const page = await browserManager.getPage();
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
const attribute = args.attribute?.trim() || 'text';
|
||||||
|
const maxLength = args.max_length ?? 10000;
|
||||||
|
|
||||||
|
const value = await runWithRetry(
|
||||||
|
'browser.extract',
|
||||||
|
async () => {
|
||||||
|
consumeWorkflowStep('browser.extract');
|
||||||
|
if (args.all) {
|
||||||
|
return page.$$eval(
|
||||||
|
args.selector,
|
||||||
|
(elements, attr) => elements.map((el) => {
|
||||||
|
if (attr === 'text') {
|
||||||
|
const htmlEl = el as unknown as { innerText?: string; textContent?: string | null };
|
||||||
|
return htmlEl.innerText || htmlEl.textContent || '';
|
||||||
|
}
|
||||||
|
return el.getAttribute(attr) ?? '';
|
||||||
|
}),
|
||||||
|
attribute,
|
||||||
|
) as Promise<string[]>;
|
||||||
|
}
|
||||||
|
return page.$eval(
|
||||||
|
args.selector,
|
||||||
|
(el, attr) => {
|
||||||
|
if (attr === 'text') {
|
||||||
|
const htmlEl = el as unknown as { innerText?: string; textContent?: string | null };
|
||||||
|
return htmlEl.innerText || htmlEl.textContent || '';
|
||||||
|
}
|
||||||
|
return el.getAttribute(attr) ?? '';
|
||||||
|
},
|
||||||
|
attribute,
|
||||||
|
) as Promise<string>;
|
||||||
|
},
|
||||||
|
args.retry,
|
||||||
|
retryOptions,
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const truncated = value.map((item) => item.length > maxLength ? `${item.slice(0, maxLength)}…` : item);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: JSON.stringify({
|
||||||
|
selector: args.selector,
|
||||||
|
attribute,
|
||||||
|
count: truncated.length,
|
||||||
|
values: truncated,
|
||||||
|
}, null, 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: JSON.stringify({
|
||||||
|
selector: args.selector,
|
||||||
|
attribute,
|
||||||
|
value: truncated,
|
||||||
|
}, null, 2),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
return { success: false, output: '', error: abortError() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrowserCheckpointSaveTool(browserManager: BrowserManager): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser.checkpoint.save',
|
||||||
|
description: 'Save the current page URL/title as a named browser workflow checkpoint.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
checkpoint_id: { type: 'string', description: 'Optional explicit checkpoint id' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
|
const args = rawArgs as { checkpoint_id?: string };
|
||||||
|
try {
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
const page = await browserManager.getPage();
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
consumeWorkflowStep('browser.checkpoint.save');
|
||||||
|
const id = args.checkpoint_id?.trim() || `cp-${Date.now()}`;
|
||||||
|
const checkpoint: BrowserCheckpoint = {
|
||||||
|
id,
|
||||||
|
url: page.url(),
|
||||||
|
title: await page.title(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
stepsUsed,
|
||||||
|
};
|
||||||
|
checkpoints.set(id, checkpoint);
|
||||||
|
latestCheckpointId = id;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: `Saved checkpoint '${id}' at ${checkpoint.url} (steps=${checkpoint.stepsUsed})`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
return { success: false, output: '', error: abortError() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrowserCheckpointResumeTool(browserManager: BrowserManager): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser.checkpoint.resume',
|
||||||
|
description: 'Resume a saved browser workflow checkpoint by navigating back to its stored URL.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
checkpoint_id: { type: 'string', description: 'Checkpoint id to resume. Defaults to the latest saved checkpoint.' },
|
||||||
|
waitUntil: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'When to consider navigation complete: load, domcontentloaded, networkidle0, networkidle2 (default: domcontentloaded)',
|
||||||
|
},
|
||||||
|
confirm_high_risk: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Required when resuming a high-risk domain checkpoint.',
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
attempts: { type: 'number', description: 'Retry attempts (bounded by browser.max_retry_attempts)' },
|
||||||
|
delay_ms: { type: 'number', description: 'Delay between retries in milliseconds' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||||
|
const args = rawArgs as {
|
||||||
|
checkpoint_id?: string;
|
||||||
|
waitUntil?: string;
|
||||||
|
confirm_high_risk?: boolean;
|
||||||
|
retry?: BrowserRetryConfig;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
throwIfAborted(context?.signal);
|
||||||
|
const checkpointId = args.checkpoint_id?.trim() || latestCheckpointId;
|
||||||
|
if (!checkpointId) {
|
||||||
|
return { success: false, output: '', error: 'No checkpoint available to resume' };
|
||||||
|
}
|
||||||
|
const checkpoint = checkpoints.get(checkpointId);
|
||||||
|
if (!checkpoint) {
|
||||||
|
return { success: false, output: '', error: `Unknown checkpoint: ${checkpointId}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDomainAllowed(checkpoint.url);
|
||||||
|
ensureHighRiskConfirmed(checkpoint.url, args.confirm_high_risk);
|
||||||
|
|
||||||
|
const page = await browserManager.getPage();
|
||||||
|
const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
|
||||||
|
await runWithRetry(
|
||||||
|
'browser.checkpoint.resume',
|
||||||
|
async () => {
|
||||||
|
consumeWorkflowStep('browser.checkpoint.resume');
|
||||||
|
await withAbort(page.goto(checkpoint.url, { waitUntil }), context?.signal);
|
||||||
|
},
|
||||||
|
args.retry,
|
||||||
|
retryOptions,
|
||||||
|
context?.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentUrl = page.url();
|
||||||
|
const title = await page.title();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: `Resumed checkpoint '${checkpointId}': ${currentUrl}\nTitle: ${title}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
return { success: false, output: '', error: abortError() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrowserEvalTool(browserManager: BrowserManager): Tool {
|
||||||
return createBrowserEvalLikeTool(
|
return createBrowserEvalLikeTool(
|
||||||
manager,
|
browserManager,
|
||||||
'browser.eval',
|
'browser.eval',
|
||||||
'Evaluate JavaScript in the browser page context. Returns the result as a string.',
|
'Evaluate JavaScript in the browser page context. Returns the result as a string.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserEvaluateTool(manager: BrowserManager): Tool {
|
function createBrowserEvaluateTool(browserManager: BrowserManager): Tool {
|
||||||
return createBrowserEvalLikeTool(
|
return createBrowserEvalLikeTool(
|
||||||
manager,
|
browserManager,
|
||||||
'browser.evaluate',
|
'browser.evaluate',
|
||||||
'Alias of browser.eval for compatibility. Evaluates JavaScript in the browser page context.',
|
'Alias of browser.eval for compatibility. Evaluates JavaScript in the browser page context.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBrowserEvalLikeTool(manager: BrowserManager, name: 'browser.eval' | 'browser.evaluate', description: string): Tool {
|
function createBrowserEvalLikeTool(browserManager: BrowserManager, name: 'browser.eval' | 'browser.evaluate', description: string): Tool {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -325,8 +1002,10 @@ function createBrowserEvalLikeTool(manager: BrowserManager, name: 'browser.eval'
|
|||||||
const args = rawArgs as { expression: string };
|
const args = rawArgs as { expression: string };
|
||||||
try {
|
try {
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
const page = await manager.getPage();
|
const page = await browserManager.getPage();
|
||||||
throwIfAborted(context?.signal);
|
throwIfAborted(context?.signal);
|
||||||
|
ensureCurrentPageDomainAllowed(page.url());
|
||||||
|
consumeWorkflowStep(name);
|
||||||
// Use evaluate with a function that evaluates the expression string
|
// Use evaluate with a function that evaluates the expression string
|
||||||
const result = await withAbort(page.evaluate((expr: string) => {
|
const result = await withAbort(page.evaluate((expr: string) => {
|
||||||
// eslint-disable-next-line no-eval
|
// eslint-disable-next-line no-eval
|
||||||
@@ -347,3 +1026,4 @@ function createBrowserEvalLikeTool(manager: BrowserManager, name: 'browser.eval'
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+22
-1
@@ -108,6 +108,11 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
'browser.click',
|
'browser.click',
|
||||||
'browser.type',
|
'browser.type',
|
||||||
'browser.content',
|
'browser.content',
|
||||||
|
'browser.wait_for',
|
||||||
|
'browser.assert',
|
||||||
|
'browser.extract',
|
||||||
|
'browser.checkpoint.save',
|
||||||
|
'browser.checkpoint.resume',
|
||||||
'browser.eval',
|
'browser.eval',
|
||||||
'browser.evaluate',
|
'browser.evaluate',
|
||||||
'agent.delegate',
|
'agent.delegate',
|
||||||
@@ -129,7 +134,23 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
export const TOOL_GROUPS: Record<string, string[]> = {
|
export const TOOL_GROUPS: Record<string, string[]> = {
|
||||||
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'],
|
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'],
|
||||||
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'],
|
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'],
|
||||||
'group:web': ['web.fetch', 'web.search', 'web.search.news', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'],
|
'group:web': [
|
||||||
|
'web.fetch',
|
||||||
|
'web.search',
|
||||||
|
'web.search.news',
|
||||||
|
'browser.navigate',
|
||||||
|
'browser.screenshot',
|
||||||
|
'browser.click',
|
||||||
|
'browser.type',
|
||||||
|
'browser.content',
|
||||||
|
'browser.wait_for',
|
||||||
|
'browser.assert',
|
||||||
|
'browser.extract',
|
||||||
|
'browser.checkpoint.save',
|
||||||
|
'browser.checkpoint.resume',
|
||||||
|
'browser.eval',
|
||||||
|
'browser.evaluate',
|
||||||
|
],
|
||||||
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
||||||
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read', 'gmail.filter.create'],
|
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read', 'gmail.filter.create'],
|
||||||
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],
|
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],
|
||||||
|
|||||||
Reference in New Issue
Block a user