From 0be66876929e4bb9fabdfff0836b5e060343a28e Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 6 Feb 2026 05:19:19 +0200 Subject: [PATCH 1/5] Add agent hooks and knowledge code execution - Add beforeHook/afterHook support for Navigator, Researcher, Tester, Captain agents - Hooks can execute Playwright or CodeceptJS code before/after agent runs - Support URL pattern matching for conditional hook execution - Add HooksRunner utility class for hook execution - Add `code` property to knowledge files for CodeceptJS execution on page visit - Document hooks system, knowledge automation, and HTML filtering Co-Authored-By: Claude Opus 4.5 --- docs/configuration.md | 7 ++ docs/hooks.md | 238 ++++++++++++++++++++++++++++++++++++++ docs/knowledge.md | 70 +++++++++++ docs/page-interaction.md | 45 ++++++- src/ai/captain.ts | 16 ++- src/ai/navigator.ts | 6 +- src/ai/researcher.ts | 7 +- src/ai/tester.ts | 9 ++ src/config.ts | 26 ++++- src/explorer.ts | 9 +- src/utils/hooks-runner.ts | 92 +++++++++++++++ 11 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 docs/hooks.md create mode 100644 src/utils/hooks-runner.ts diff --git a/docs/configuration.md b/docs/configuration.md index dc5ce5a..346ee0c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -163,6 +163,8 @@ agents: { model: 'gpt-oss-20b', // Override default model enabled: true, // Enable/disable agent systemPrompt: '...', // Append to system prompt + beforeHook: { /* ... */ }, // Run before agent executes + afterHook: { /* ... */ }, // Run after agent completes }, } ``` @@ -172,6 +174,10 @@ agents: { | `model` | `string` | Model to use for this agent (overrides default) | | `enabled` | `boolean` | Enable or disable the agent | | `systemPrompt` | `string` | Additional instructions appended to the agent's system prompt | +| `beforeHook` | `Hook \| HookPatternMap` | Code to run before agent execution | +| `afterHook` | `Hook \| HookPatternMap` | Code to run after agent execution | + +See [Agent Hooks](./hooks.md) for detailed hook configuration. ### Researcher Agent Options @@ -396,6 +402,7 @@ export default { - [AI Providers](./providers.md) - Provider setup examples - [Agents](./agents.md) - Agent descriptions and workflows +- [Agent Hooks](./hooks.md) - Custom code before/after agent execution - [Researcher Agent](./researcher.md) - Researcher configuration and usage - [Knowledge Files](./knowledge.md) - Domain knowledge format - [Observability](./observability.md) - Langfuse integration diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..21866b7 --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,238 @@ +# Agent Hooks + +Hooks allow you to execute custom code before or after specific agents run. They provide fine-grained control over page preparation and cleanup on a per-agent basis. + +> [!NOTE] +> For simple page automation (waiting, clicking cookie banners), prefer using [Knowledge Files](./knowledge.md) with `wait`, `waitForElement`, or `code` fields. Hooks are best when you need different behavior for different agents. + +## When to Use Hooks vs Knowledge + +| Use Case | Solution | +|----------|----------| +| Wait for element on all page visits | Knowledge: `waitForElement` | +| Dismiss cookie banner on page load | Knowledge: `code` | +| Wait for network idle only during research | Hook: `researcher.beforeHook` | +| Clean up test data after each test | Hook: `tester.afterHook` | +| Different waits for navigation vs testing | Hooks for each agent | + +## Configuration + +Hooks are configured per-agent in `explorbot.config.js`: + +```javascript +export default { + ai: { + provider: myProvider, + model: 'gpt-4o', + agents: { + navigator: { + beforeHook: { + type: 'playwright', + hook: async ({ page, url }) => { + await page.waitForLoadState('networkidle'); + } + } + }, + tester: { + afterHook: { + type: 'codeceptjs', + hook: async ({ I, url }) => { + await I.executeScript(() => localStorage.clear()); + } + } + } + } + } +} +``` + +## Hook Types + +### Playwright Hooks + +Direct access to Playwright page object: + +```javascript +beforeHook: { + type: 'playwright', + hook: async ({ page, url }) => { + await page.waitForLoadState('networkidle'); + await page.locator('.loading').waitFor({ state: 'hidden' }); + } +} +``` + +### CodeceptJS Hooks + +Use familiar CodeceptJS `I` actor: + +```javascript +beforeHook: { + type: 'codeceptjs', + hook: async ({ I, url }) => { + await I.waitForElement('.page-ready'); + await I.wait(1); + } +} +``` + +## URL Pattern Matching + +Apply different hooks based on URL patterns: + +```javascript +researcher: { + beforeHook: { + '/login': { + type: 'codeceptjs', + hook: async ({ I }) => await I.waitForElement('#login-form') + }, + '/admin/*': { + type: 'playwright', + hook: async ({ page }) => await page.waitForLoadState('networkidle') + }, + '/api/*': { + type: 'codeceptjs', + hook: async ({ I }) => await I.wait(2) + } + } +} +``` + +### Pattern Syntax + +| Pattern | Matches | +|---------|---------| +| `/login` | Exact path `/login` | +| `/admin/*` | `/admin` and any path starting with `/admin/` | +| `*` | All URLs (fallback) | +| `^/users/\d+$` | Regex: `/users/` followed by digits | +| `**/*.html` | Glob: any `.html` file | + +## Supported Agents + +| Agent | beforeHook | afterHook | Description | +|-------|------------|-----------|-------------| +| `navigator` | After navigation | After page capture | Browser navigation | +| `researcher` | After navigation | After research complete | Page analysis | +| `tester` | Before test loop | After test loop | Test execution | +| `captain` | Before handling command | After command complete | User commands | + +> [!WARNING] +> The `planner` agent does not support hooks as it doesn't interact with the browser. + +## Examples + +### Wait for SPA to Load + +```javascript +navigator: { + beforeHook: { + type: 'playwright', + hook: async ({ page }) => { + await page.waitForFunction(() => { + return window.__APP_READY__ === true; + }); + } + } +} +``` + +### Dismiss Modals Before Research + +```javascript +researcher: { + beforeHook: { + type: 'codeceptjs', + hook: async ({ I }) => { + const modalVisible = await I.grabNumberOfVisibleElements('.modal-overlay'); + if (modalVisible > 0) { + await I.click('.modal-close'); + await I.wait(0.5); + } + } + } +} +``` + +### Clean Up After Tests + +```javascript +tester: { + afterHook: { + type: 'playwright', + hook: async ({ page }) => { + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + } + } +} +``` + +### Different Behavior per URL + +```javascript +tester: { + beforeHook: { + '/checkout': { + type: 'codeceptjs', + hook: async ({ I }) => { + // Ensure cart has items before checkout tests + await I.executeScript(() => { + if (!localStorage.getItem('cart')) { + localStorage.setItem('cart', JSON.stringify([{ id: 1, qty: 1 }])); + } + }); + } + }, + '/admin/*': { + type: 'codeceptjs', + hook: async ({ I }) => { + // Ensure admin session + await I.waitForElement('.admin-header', 5); + } + } + } +} +``` + +## Error Handling + +Hook errors are logged but don't stop agent execution: + +```javascript +beforeHook: { + type: 'codeceptjs', + hook: async ({ I }) => { + try { + await I.waitForElement('.optional-banner', 2); + await I.click('.dismiss'); + } catch { + // Banner not present, continue + } + } +} +``` + +## Execution Flow + +``` +┌─────────────────────────────────────────────────────────┐ +│ Agent Execution │ +├─────────────────────────────────────────────────────────┤ +│ 1. Agent starts │ +│ 2. Navigate to URL (if applicable) │ +│ 3. ▶ beforeHook executes │ +│ 4. Agent performs main work │ +│ 5. ▶ afterHook executes │ +│ 6. Agent completes │ +└─────────────────────────────────────────────────────────┘ +``` + +## See Also + +- [Knowledge Files](./knowledge.md) - Page-level automation with `wait`, `waitForElement`, `code` +- [Configuration](./configuration.md) - Full configuration reference +- [Agents](./agents.md) - Agent descriptions and workflows diff --git a/docs/knowledge.md b/docs/knowledge.md index 7f18268..d759377 100644 --- a/docs/knowledge.md +++ b/docs/knowledge.md @@ -81,6 +81,70 @@ Notes: | `title` | Human-readable title (optional) | | Custom fields | Any additional metadata for agents | +## Page Automation + +Knowledge files can include automation commands that execute when navigating to matching pages. This is useful for handling loading states, cookie banners, or page-specific setup. + +### Available Fields + +| Field | Type | Description | +|-------|------|-------------| +| `wait` | `number` | Wait for specified seconds after page load | +| `waitForElement` | `string` | Wait for element to appear (CSS selector) | +| `code` | `string` | Execute CodeceptJS code after navigation | +| `statePush` | `boolean` | Use `history.pushState` instead of full navigation | + +### Wait for Page Load + +```markdown +--- +url: /dashboard +wait: 2 +waitForElement: '.dashboard-loaded' +--- + +Dashboard requires data to load before interaction. +``` + +### Execute Custom Code + +```markdown +--- +url: /app/* +code: | + I.waitForElement('.app-ready'); + I.click('.cookie-accept'); + I.wait(1); +--- + +App pages need cookie consent dismissed and loading complete. +``` + +### SPA Navigation + +For single-page apps where full page reload breaks state: + +```markdown +--- +url: /settings/* +statePush: true +--- + +Settings uses client-side routing. Use pushState to preserve app state. +``` + +> [!TIP] +> Use knowledge automation for page-specific behaviors. For agent-specific logic (like running code only during testing), use [Agent Hooks](./hooks.md) instead. + +### Execution Order + +When navigating to a page, automation executes in this order: + +1. Navigation (`I.amOnPage()` or `history.pushState`) +2. `wait` (if specified) +3. `waitForElement` (if specified) +4. `code` (if specified) + ## What to Document ### Authentication @@ -161,3 +225,9 @@ When an agent operates on a page, it receives relevant knowledge based on URL ma ``` Files are named based on URL pattern. Multiple entries for the same URL are appended to the same file. + +## See Also + +- [Agent Hooks](./hooks.md) - Per-agent custom code execution +- [Configuration](./configuration.md) - Full configuration reference +- [Page Interaction](./page-interaction.md) - How agents interact with pages diff --git a/docs/page-interaction.md b/docs/page-interaction.md index 6ec3864..e8893f0 100644 --- a/docs/page-interaction.md +++ b/docs/page-interaction.md @@ -40,6 +40,45 @@ Processed HTML provides detailed attributes: Used for: Precise locator construction, form field identification, state verification. +#### HTML Filtering + +Agents primarily use the `combined` HTML snapshot, which can be configured to exclude noisy elements: + +```javascript +// explorbot.config.js +html: { + combined: { + include: ['*'], + exclude: ['script', 'style', 'svg', '.cookie-banner', '.analytics-tracker'] + } +} +``` + +| Snapshot Type | Purpose | Config Key | +|---------------|---------|------------| +| `combined` | Main HTML for agents (interactive + semantic elements) | `html.combined` | +| `minimal` | Focused on interactive elements only | `html.minimal` | +| `text` | Text content extraction | `html.text` | + +> [!TIP] +> Filter out noisy elements like cookie banners, analytics widgets, and ads to reduce token usage and improve agent focus. + +**Common exclusions:** + +```javascript +html: { + combined: { + exclude: [ + 'script', 'style', 'svg', 'noscript', + '.cookie-consent', '.cookie-banner', + '.chat-widget', '.intercom-*', + '.analytics-*', '.tracking-*', + '[data-testid="ads"]' + ] + } +} +``` + ### Screenshots (Vision Model) Visual analysis adds spatial awareness: @@ -295,4 +334,8 @@ On subsequent runs, agents load relevant experience to: - Anticipate state changes - Apply learned recovery strategies -See [docs/knowledge.md](knowledge.md) for more on teaching Explorbot. +## See Also + +- [Knowledge Files](knowledge.md) - Teach Explorbot about your app +- [Agent Hooks](hooks.md) - Custom code before/after agent execution +- [Configuration](configuration.md) - Full configuration reference diff --git a/src/ai/captain.ts b/src/ai/captain.ts index ac97b6d..2cb016e 100644 --- a/src/ai/captain.ts +++ b/src/ai/captain.ts @@ -6,6 +6,7 @@ import { ExperienceTracker } from '../experience-tracker.js'; import type { ExplorBot } from '../explorbot.ts'; import type { WebPageState } from '../state-manager.ts'; import { Task, Test } from '../test-plan.ts'; +import { HooksRunner } from '../utils/hooks-runner.ts'; import { createDebug, tag } from '../utils/logger.js'; import { loop } from '../utils/loop.js'; import type { Agent } from './agent.js'; @@ -28,6 +29,7 @@ export class Captain extends TaskAgent implements Agent { private experienceTracker: ExperienceTracker; private awaitingSave = false; private pendingExperience: { state: ActionResult; intent: string; summary: string; code: string } | null = null; + private hooksRunner: HooksRunner | null = null; constructor(explorBot: ExplorBot) { super(); @@ -35,6 +37,14 @@ export class Captain extends TaskAgent implements Agent { this.experienceTracker = new ExperienceTracker(); } + private getHooksRunner(): HooksRunner { + if (!this.hooksRunner) { + const explorer = this.explorBot.getExplorer(); + this.hooksRunner = new HooksRunner(explorer, explorer.getConfig()); + } + return this.hooksRunner; + } + protected getNavigator(): Navigator { return this.explorBot.agentNavigator(); } @@ -352,7 +362,8 @@ export class Captain extends TaskAgent implements Agent { const pageContext = await this.getPageContext(); const planContext = this.planSummary(); - // Clean up old page context from previous inputs when continuing conversation + await this.getHooksRunner().runBeforeHook('captain', startUrl); + if (!options.reset && this.conversation) { conversation.cleanupTag('page_aria', '...cleaned...', 1); conversation.cleanupTag('page_html', '...cleaned...', 1); @@ -435,6 +446,9 @@ export class Captain extends TaskAgent implements Agent { } ); + const finalUrl = stateManager.getCurrentState()?.url || startUrl; + await this.getHooksRunner().runAfterHook('captain', finalUrl); + if (finalSummary) { tag('success').log(this.emoji, finalSummary); } else { diff --git a/src/ai/navigator.ts b/src/ai/navigator.ts index 0843ffd..9d26948 100644 --- a/src/ai/navigator.ts +++ b/src/ai/navigator.ts @@ -5,6 +5,7 @@ import Explorer from '../explorer.ts'; import { KnowledgeTracker } from '../knowledge-tracker.js'; import type { WebPageState } from '../state-manager.js'; import { extractCodeBlocks } from '../utils/code-extractor.js'; +import { HooksRunner } from '../utils/hooks-runner.ts'; import { createDebug, pluralize, tag } from '../utils/logger.js'; import { loop } from '../utils/loop.js'; import type { Agent } from './agent.js'; @@ -24,6 +25,7 @@ class Navigator implements Agent { private experienceTracker: ExperienceTracker; private currentAction: any = null; private currentUrl: string | null = null; + private hooksRunner: HooksRunner; private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5'); @@ -57,6 +59,7 @@ class Navigator implements Agent { this.experienceCompactor = experienceCompactor; this.knowledgeTracker = new KnowledgeTracker(); this.experienceTracker = experienceTracker || new ExperienceTracker(); + this.hooksRunner = new HooksRunner(explorer, explorer.getConfig()); } async visit(url: string): Promise { @@ -64,6 +67,7 @@ class Navigator implements Agent { const action = this.explorer.createAction(); await action.execute(`I.amOnPage('${url}')`); + await this.hooksRunner.runBeforeHook('navigator', url); await action.expect(`I.seeInCurrentUrl('${url}')`); if (action.lastError) { @@ -74,12 +78,12 @@ class Navigator implements Agent { But I got error: ${action.lastError?.message || 'Navigation failed'}. `.trim(); - // Store action and url for execution in resolveState this.currentAction = action; this.currentUrl = url; await this.resolveState(originalMessage, actionResult); } await action.caputrePageWithScreenshot(); + await this.hooksRunner.runAfterHook('navigator', url); } catch (error) { console.error(`Failed to visit page ${url}:`, error); throw error; diff --git a/src/ai/researcher.ts b/src/ai/researcher.ts index 962f69f..016f7da 100644 --- a/src/ai/researcher.ts +++ b/src/ai/researcher.ts @@ -10,11 +10,12 @@ import type Explorer from '../explorer.ts'; import { Observability } from '../observability.ts'; import type { StateManager } from '../state-manager.js'; import { WebPageState } from '../state-manager.js'; +import { collectInteractiveNodes, diffAriaSnapshots } from '../utils/aria.ts'; import { extractCodeBlocks } from '../utils/code-extractor.ts'; +import { HooksRunner } from '../utils/hooks-runner.ts'; import { type HtmlDiffResult, htmlDiff } from '../utils/html-diff.ts'; import { codeToMarkdown, isBodyEmpty } from '../utils/html.ts'; import { createDebug, pluralize, tag } from '../utils/logger.js'; -import { collectInteractiveNodes, diffAriaSnapshots } from '../utils/aria.ts'; import { loop } from '../utils/loop.ts'; import type { Agent } from './agent.js'; import type { Conversation } from './conversation.js'; @@ -46,12 +47,14 @@ export class Researcher implements Agent { private experienceTracker: ExperienceTracker; private hasScreenshotToAnalyze = false; private actionResult?: ActionResult; + private hooksRunner: HooksRunner; constructor(explorer: Explorer, provider: Provider) { this.explorer = explorer; this.provider = provider; this.stateManager = explorer.getStateManager(); this.experienceTracker = this.stateManager.getExperienceTracker(); + this.hooksRunner = new HooksRunner(explorer, explorer.getConfig()); } static getCachedResearch(state: WebPageState): string { @@ -107,6 +110,7 @@ export class Researcher implements Agent { const isOnCurrentState = this.actionResult!.getStateHash() === this.stateManager.getCurrentState()?.hash; await this.ensureNavigated(state.url, screenshot && this.provider.hasVision()); + await this.hooksRunner.runBeforeHook('researcher', state.url); debugLog('Researching web page:', this.actionResult!.url); @@ -156,6 +160,7 @@ export class Researcher implements Agent { tag('multiline').log(researchText); tag('success').log(`Research complete! ${researchText.length} characters`); + await this.hooksRunner.runAfterHook('researcher', state.url); return researchText; }); } diff --git a/src/ai/tester.ts b/src/ai/tester.ts index fc4d1a3..2f149f0 100644 --- a/src/ai/tester.ts +++ b/src/ai/tester.ts @@ -10,6 +10,7 @@ import type Explorer from '../explorer.ts'; import type { StateTransition, WebPageState } from '../state-manager.ts'; import { type Note, type Test, TestResult, type TestResultType } from '../test-plan.ts'; import { extractFocusedElement } from '../utils/aria.ts'; +import { HooksRunner } from '../utils/hooks-runner.ts'; import { codeToMarkdown } from '../utils/html.ts'; import { createDebug, tag } from '../utils/logger.ts'; import { loop } from '../utils/loop.ts'; @@ -39,6 +40,7 @@ export class Tester extends TaskAgent implements Agent { executionLogFile: string | null = null; private previousUrl: string | null = null; private previousStateHash: string | null = null; + private hooksRunner: HooksRunner; constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) { super(); @@ -47,6 +49,7 @@ export class Tester extends TaskAgent implements Agent { this.researcher = researcher; this.navigator = navigator; this.agentTools = agentTools; + this.hooksRunner = new HooksRunner(explorer, explorer.getConfig()); } protected getNavigator(): Navigator { @@ -103,6 +106,9 @@ export class Tester extends TaskAgent implements Agent { await this.explorer.visit(task.startUrl!); } + const currentUrl = this.explorer.getStateManager().getCurrentState()?.url || task.startUrl || ''; + await this.hooksRunner.runBeforeHook('tester', currentUrl); + const offStateChange = this.explorer.getStateManager().onStateChange((event: StateTransition) => { if (event.toState?.url === event.fromState?.url) return; task.addNote(`Navigated to ${event.toState?.url}`, TestResult.PASSED); @@ -227,6 +233,9 @@ export class Tester extends TaskAgent implements Agent { } ); + const finalUrl = this.explorer.getStateManager().getCurrentState()?.url || currentUrl; + await this.hooksRunner.runAfterHook('tester', finalUrl); + await this.finalReview(task); await this.getHistorian().saveSession(task, initialState, conversation); if (task.plan) { diff --git a/src/config.ts b/src/config.ts index e64cc74..7ee5e2c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,7 +29,29 @@ interface PlaywrightConfig { args?: string[]; } -interface AgentConfig { +type PlaywrightHookFn = (ctx: { page: any; url: string }) => Promise | void; +type CodeceptJSHookFn = (ctx: { I: any; url: string }) => Promise | void; + +interface PlaywrightHook { + type: 'playwright'; + hook: PlaywrightHookFn; +} + +interface CodeceptJSHook { + type: 'codeceptjs'; + hook: CodeceptJSHookFn; +} + +type Hook = PlaywrightHook | CodeceptJSHook; +type HookPatternMap = Record; +type HookConfig = Hook | HookPatternMap; + +interface HooksConfig { + beforeHook?: HookConfig; + afterHook?: HookConfig; +} + +interface AgentConfig extends HooksConfig { model?: string; enabled?: boolean; systemPrompt?: string; @@ -120,7 +142,7 @@ const config: ExplorbotConfig = { }, }; -export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, ResearcherAgentConfig }; +export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, ResearcherAgentConfig, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap }; export class ConfigParser { private static instance: ConfigParser; diff --git a/src/explorer.ts b/src/explorer.ts index d5f880d..fd07174 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -204,7 +204,7 @@ class Explorer { const currentState = this.stateManager.getCurrentState(); const actionResult = currentState ? ActionResult.fromState(currentState) : null; - const { statePush = false, wait, waitForElement } = this.knowledgeTracker.getStateParameters(actionResult!, ['statePush', 'wait', 'waitForElement']); + const { statePush = false, wait, waitForElement, code } = this.knowledgeTracker.getStateParameters(actionResult!, ['statePush', 'wait', 'waitForElement', 'code']); const action = this.createAction(); @@ -215,7 +215,7 @@ class Explorer { } if (wait !== undefined) { - console.log('Waiting for', wait); + debugLog('Waiting for', wait); await action.execute(`I.wait(${wait})`); } @@ -223,6 +223,11 @@ class Explorer { await action.execute(`I.waitForElement(${JSON.stringify(waitForElement)})`); } + if (code) { + debugLog('Executing knowledge code:', code); + await action.execute(code); + } + return action; } diff --git a/src/utils/hooks-runner.ts b/src/utils/hooks-runner.ts new file mode 100644 index 0000000..ae414db --- /dev/null +++ b/src/utils/hooks-runner.ts @@ -0,0 +1,92 @@ +import micromatch from 'micromatch'; +import type { ExplorbotConfig, Hook, HookConfig } from '../config.ts'; +import type Explorer from '../explorer.ts'; +import { createDebug } from './logger.ts'; + +const debugLog = createDebug('explorbot:hooks'); + +export class HooksRunner { + constructor( + private explorer: Explorer, + private config: ExplorbotConfig + ) {} + + async runBeforeHook(agentName: string, url: string): Promise { + await this.runHook(agentName, 'beforeHook', url); + } + + async runAfterHook(agentName: string, url: string): Promise { + await this.runHook(agentName, 'afterHook', url); + } + + private async runHook(agentName: string, hookType: 'beforeHook' | 'afterHook', url: string): Promise { + const agentConfig = this.config.ai?.agents?.[agentName as keyof typeof this.config.ai.agents]; + if (!agentConfig) return; + + const hookConfig = agentConfig[hookType]; + if (!hookConfig) return; + + const hook = this.findMatchingHook(hookConfig, url); + if (!hook) return; + + debugLog(`Running ${hookType} for ${agentName} at ${url}`); + await this.executeHook(hook, url); + } + + private findMatchingHook(config: HookConfig, url: string): Hook | null { + if (this.isSingleHook(config)) return config as Hook; + + const urlPath = this.extractPath(url); + for (const [pattern, hook] of Object.entries(config)) { + if (this.matchesPattern(pattern, urlPath)) return hook as Hook; + } + return null; + } + + private async executeHook(hook: Hook, url: string): Promise { + try { + if (hook.type === 'playwright') { + const page = this.explorer.playwrightHelper.page; + await hook.hook({ page, url }); + } else { + const I = this.explorer.actor; + await hook.hook({ I, url }); + } + } catch (error) { + debugLog(`Hook error: ${error}`); + } + } + + private isSingleHook(config: HookConfig): boolean { + return 'type' in config && 'hook' in config; + } + + private extractPath(url: string): string { + if (url.startsWith('/')) return url; + try { + return new URL(url).pathname; + } catch { + return url; + } + } + + private matchesPattern(pattern: string, path: string): boolean { + if (pattern === '*') return true; + if (pattern.toLowerCase() === path.toLowerCase()) return true; + + if (pattern.endsWith('/*')) { + const base = pattern.slice(0, -2); + if (path === base || path.startsWith(`${base}/`)) return true; + } + + if (pattern.startsWith('^')) { + try { + return new RegExp(pattern.slice(1)).test(path); + } catch { + return false; + } + } + + return micromatch.isMatch(path, pattern); + } +} From 5ddf8836f48b3e38b5062480ab45a2de1766b3a8 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 6 Feb 2026 05:27:49 +0200 Subject: [PATCH 2/5] Add CodeceptJS effects support in knowledge code - Import tryTo, retryTo, within, hopeThat from codeceptjs/lib/effects.js - Pass effects to code execution in action.execute() and action.expect() - Document effects usage in knowledge.md Co-Authored-By: Claude Opus 4.5 --- docs/knowledge.md | 27 +++++++++++++++++++++++++++ src/action.ts | 9 +++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/knowledge.md b/docs/knowledge.md index d759377..93a6c2d 100644 --- a/docs/knowledge.md +++ b/docs/knowledge.md @@ -120,6 +120,33 @@ code: | App pages need cookie consent dismissed and loading complete. ``` +### CodeceptJS Effects + +Knowledge code has access to CodeceptJS effects for error handling and retries: + +| Effect | Purpose | +|--------|---------| +| `tryTo(fn)` | Execute without failing - returns `true`/`false` | +| `retryTo(fn, maxTries, interval)` | Retry on failure with polling | +| `within(context, fn)` | Execute within a specific element context | +| `hopeThat(fn)` | Soft assertion - logs failure but continues | + +**Example with effects:** + +```markdown +--- +url: /dashboard +code: | + await tryTo(() => I.click('.cookie-dismiss')); + await retryTo(() => I.waitForElement('.data-loaded'), 5, 500); +--- + +Dashboard may show cookie banner. Data loads asynchronously. +``` + +> [!NOTE] +> Effects are async - use `await` when calling them in knowledge code. + ### SPA Navigation For single-page apps where full page reload breaks state: diff --git a/src/action.ts b/src/action.ts index 798852b..d6d9c1e 100644 --- a/src/action.ts +++ b/src/action.ts @@ -4,6 +4,7 @@ import { context, trace } from '@opentelemetry/api'; import { highlight } from 'cli-highlight'; import { container, recorder } from 'codeceptjs'; import * as codeceptjs from 'codeceptjs'; +import { tryTo, retryTo, within, hopeThat } from 'codeceptjs/lib/effects.js'; import dedent from 'dedent'; import { ActionResult } from './action-result.js'; import { clearActivity, setActivity } from './activity.ts'; @@ -188,8 +189,8 @@ class Action { try { debugLog('Executing action:', codeString); - const codeFunction = new Function('I', codeString); - codeFunction(this.actor); + const codeFunction = new Function('I', 'tryTo', 'retryTo', 'within', 'hopeThat', codeString); + codeFunction(this.actor, tryTo, retryTo, within, hopeThat); await recorder.add(() => sleep(this.config.action?.delay || 500)); // wait for the action to be executed await recorder.promise(); @@ -231,9 +232,9 @@ class Action { if (typeof codeOrFunction === 'function') { codeFunction = codeOrFunction; } else { - codeFunction = new Function('I', codeString); + codeFunction = new Function('I', 'tryTo', 'retryTo', 'within', 'hopeThat', codeString); } - codeFunction(this.actor); + codeFunction(this.actor, tryTo, retryTo, within, hopeThat); await recorder.promise(); debugLog('Expectation executed successfully'); From 5940c5f35e77cb5c6fcd6ec090a7a0a4ab5e54e6 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 6 Feb 2026 05:29:28 +0200 Subject: [PATCH 3/5] Update retryTo example to show action + wait pattern Co-Authored-By: Claude Opus 4.5 --- docs/knowledge.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/knowledge.md b/docs/knowledge.md index 93a6c2d..f7d7bb8 100644 --- a/docs/knowledge.md +++ b/docs/knowledge.md @@ -138,10 +138,13 @@ Knowledge code has access to CodeceptJS effects for error handling and retries: url: /dashboard code: | await tryTo(() => I.click('.cookie-dismiss')); - await retryTo(() => I.waitForElement('.data-loaded'), 5, 500); + await retryTo(() => { + I.click('Reload Data'); + I.waitForElement('.data-loaded'); + }, 5, 500); --- -Dashboard may show cookie banner. Data loads asynchronously. +Dashboard may show cookie banner. Data loads asynchronously - retry reload if needed. ``` > [!NOTE] From b814f81c8c506a4ad44c5b3d49a4465f6095afbe Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 6 Feb 2026 05:33:54 +0200 Subject: [PATCH 4/5] Remove hopeThat from effects documentation Co-Authored-By: Claude Opus 4.5 --- docs/knowledge.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/knowledge.md b/docs/knowledge.md index f7d7bb8..df8437f 100644 --- a/docs/knowledge.md +++ b/docs/knowledge.md @@ -129,7 +129,6 @@ Knowledge code has access to CodeceptJS effects for error handling and retries: | `tryTo(fn)` | Execute without failing - returns `true`/`false` | | `retryTo(fn, maxTries, interval)` | Retry on failure with polling | | `within(context, fn)` | Execute within a specific element context | -| `hopeThat(fn)` | Soft assertion - logs failure but continues | **Example with effects:** From 544ef0f5b750db75e2fd294a547970c7e8b83c6d Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sat, 7 Feb 2026 21:47:44 +0200 Subject: [PATCH 5/5] improved hook handling --- src/ai/captain.ts | 5 +++-- src/explorer.ts | 4 ++-- src/utils/hooks-runner.ts | 9 ++++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ai/captain.ts b/src/ai/captain.ts index 2cb016e..6a20cbf 100644 --- a/src/ai/captain.ts +++ b/src/ai/captain.ts @@ -359,11 +359,12 @@ export class Captain extends TaskAgent implements Agent { }; const tools = this.tools(task, onDone); - const pageContext = await this.getPageContext(); - const planContext = this.planSummary(); await this.getHooksRunner().runBeforeHook('captain', startUrl); + const pageContext = await this.getPageContext(); + const planContext = this.planSummary(); + if (!options.reset && this.conversation) { conversation.cleanupTag('page_aria', '...cleaned...', 1); conversation.cleanupTag('page_html', '...cleaned...', 1); diff --git a/src/explorer.ts b/src/explorer.ts index fd07174..e68f6c9 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -202,9 +202,9 @@ class Explorer { const serializedUrl = JSON.stringify(url); const currentState = this.stateManager.getCurrentState(); - const actionResult = currentState ? ActionResult.fromState(currentState) : null; + const actionResult = currentState ? ActionResult.fromState(currentState) : new ActionResult({ url }); - const { statePush = false, wait, waitForElement, code } = this.knowledgeTracker.getStateParameters(actionResult!, ['statePush', 'wait', 'waitForElement', 'code']); + const { statePush = false, wait, waitForElement, code } = this.knowledgeTracker.getStateParameters(actionResult, ['statePush', 'wait', 'waitForElement', 'code']); const action = this.createAction(); diff --git a/src/utils/hooks-runner.ts b/src/utils/hooks-runner.ts index ae414db..7029ebc 100644 --- a/src/utils/hooks-runner.ts +++ b/src/utils/hooks-runner.ts @@ -37,12 +37,19 @@ export class HooksRunner { if (this.isSingleHook(config)) return config as Hook; const urlPath = this.extractPath(url); - for (const [pattern, hook] of Object.entries(config)) { + const entries = Object.entries(config).sort(([a], [b]) => this.patternSpecificity(b) - this.patternSpecificity(a)); + for (const [pattern, hook] of entries) { if (this.matchesPattern(pattern, urlPath)) return hook as Hook; } return null; } + private patternSpecificity(pattern: string): number { + if (pattern === '*') return 0; + if (pattern.includes('*')) return pattern.length; + return pattern.length + 1000; + } + private async executeHook(hook: Hook, url: string): Promise { try { if (hook.type === 'playwright') {