Add browser workflow reliability primitives and guardrails

This commit is contained in:
William Valentin
2026-02-26 14:06:46 -08:00
parent 3cc9e16ef5
commit 7c904ef0fd
7 changed files with 1185 additions and 272 deletions
+10 -1
View File
@@ -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.
+21
View File
@@ -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);
}); });
}); });
+7
View File
@@ -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
View File
@@ -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})`);
+157 -3
View File
@@ -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');
}); });
+702 -22
View File
@@ -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
View File
@@ -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'],