Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 4 additions & 29 deletions src/ai/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import dedent from 'dedent';
import { z } from 'zod';
import { ActionResult } from '../action-result.js';
import type Action from '../action.ts';
import { type ExperienceTracker, renderExperienceToc } from '../experience-tracker.js';
import type { ExperienceTracker } from '../experience-tracker.js';
import Explorer from '../explorer.ts';
import type { KnowledgeTracker } from '../knowledge-tracker.js';
import { normalizeUrl } from '../state-manager.js';
Expand Down Expand Up @@ -191,19 +191,9 @@ class Navigator implements Agent {
const action = opts?.action ?? this.explorer.createAction();
const expectedUrl = opts?.expectedUrl;

let knowledge = '';
const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
let experience = '';

const relevantKnowledge = this.knowledgeTracker.getRelevantKnowledge(actionResult);
if (relevantKnowledge.length > 0) {
const knowledgeContent = relevantKnowledge.map((k) => k.content).join('\n\n');
knowledge = `
<hint>
Here is relevant knowledge for this page:
${knowledgeContent}
</hint>`;
}

if (!actionResult.isInsideIframe) {
const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
if (successful.length > 0) {
Expand Down Expand Up @@ -630,26 +620,11 @@ class Navigator implements Agent {
return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
}

let knowledge = '';
const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
let experience = '';

const relevantKnowledge = this.knowledgeTracker.getRelevantKnowledge(actionResult);
if (relevantKnowledge.length > 0) {
const knowledgeContent = relevantKnowledge.map((k) => k.content).join('\n\n');
knowledge = `
<hint>
Here is relevant knowledge for this page:
${knowledgeContent}
</hint>`;
}

if (!actionResult.isInsideIframe) {
const toc = this.experienceTracker.getExperienceTableOfContents(actionResult);
if (toc.length > 0) {
const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections) for: ${actionResult.url}`);
experience = renderExperienceToc(toc);
}
experience = this.experienceTracker.renderExperienceTocFor(actionResult);
}

const priorVerifications = Object.entries(actionResult.verifications ?? {});
Expand Down
5 changes: 1 addition & 4 deletions src/ai/pilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import dedent from 'dedent';
import { z } from 'zod';
import { ActionResult } from '../action-result.ts';
import { ConfigParser } from '../config.ts';
import { renderExperienceToc } from '../experience-tracker.ts';
import type Explorer from '../explorer.ts';
import { type Test, TestResult } from '../test-plan.ts';
import { collectInteractiveNodes, detectFocusArea, extractFocusedElement } from '../utils/aria.ts';
Expand Down Expand Up @@ -599,9 +598,7 @@ export class Pilot implements Agent {
private getExperienceToc(): string {
const state = this.explorer.getStateManager().getCurrentState();
if (!state) return '';
const actionResult = ActionResult.fromState(state);
const toc = this.explorer.getStateManager().getExperienceTracker().getExperienceTableOfContents(actionResult);
return renderExperienceToc(toc);
return this.explorer.getStateManager().getExperienceTracker().renderExperienceTocFor(ActionResult.fromState(state));
}

private pickPlanningTools() {
Expand Down
20 changes: 2 additions & 18 deletions src/ai/researcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { diffAriaSnapshots } from '../utils/aria.ts';
import { ErrorPageError, detectPageCondition } from '../utils/error-page.ts';
import { HooksRunner } from '../utils/hooks-runner.ts';
import { isBodyEmpty } from '../utils/html.ts';
import { createDebug, pluralize, tag } from '../utils/logger.js';
import { createDebug, tag } from '../utils/logger.js';
import { mdq } from '../utils/markdown-query.ts';
import { RulesLoader } from '../utils/rules-loader.ts';
import type { Agent } from './agent.js';
Expand Down Expand Up @@ -469,23 +469,7 @@ export class Researcher extends ResearcherBase implements Agent {
if (!this.actionResult) throw new Error('actionResult is not set');

const html = await this.actionResult.combinedHtml();
const knowledgeFiles = this.stateManager.getRelevantKnowledge();

let knowledge = '';
if (knowledgeFiles.length > 0) {
const knowledgeContent = knowledgeFiles
.map((k) => k.content)
.filter((k) => !!k)
.join('\n\n');

tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')} for: ${this.actionResult.url}`);
knowledge = `
<hint>
Here is relevant knowledge for this page:

${knowledgeContent}
</hint>`;
}
const knowledge = this.explorer.getKnowledgeTracker().renderRelevantKnowledge(this.actionResult);

const ariaSnapshot = this.actionResult.getCompactARIA();

Expand Down
33 changes: 3 additions & 30 deletions src/ai/task-agent.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import dedent from 'dedent';
import type { ActionResult } from '../action-result.js';
import { type ExperienceTracker, renderExperienceToc } from '../experience-tracker.js';
import type { ExperienceTracker } from '../experience-tracker.js';
import type { KnowledgeTracker } from '../knowledge-tracker.js';
import { createDebug, pluralize, tag } from '../utils/logger.js';

const debugLog = createDebug('explorbot:task-agent');
import { Historian } from './historian.js';
import type { Navigator } from './navigator.js';
import type { Provider } from './provider.js';
Expand Down Expand Up @@ -35,34 +31,11 @@ export abstract class TaskAgent {
protected abstract getProvider(): Provider;

protected getKnowledge(actionResult: ActionResult): string {
const knowledgeFiles = this.getKnowledgeTracker().getRelevantKnowledge(actionResult);

if (knowledgeFiles.length === 0) return '';

const knowledgeContent = knowledgeFiles
.map((k) => k.content)
.filter((k) => !!k)
.join('\n\n');

tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
return dedent`
<knowledge>
Here is relevant knowledge for this page:

${knowledgeContent}
</knowledge>
`;
return this.getKnowledgeTracker().renderRelevantKnowledge(actionResult);
}

protected getExperience(actionResult: ActionResult): string {
const tracker = this.getExperienceTracker();
const toc = tracker.getExperienceTableOfContents(actionResult);
if (toc.length === 0) return '';

const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
return renderExperienceToc(toc);
return this.getExperienceTracker().renderExperienceTocFor(actionResult);
}

setHistorian(historian: Historian): void {
Expand Down
12 changes: 11 additions & 1 deletion src/experience-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ActionResult } from './action-result.js';
import { ConfigParser } from './config.js';
import { KnowledgeTracker } from './knowledge-tracker.js';
import type { WebPageState } from './state-manager.js';
import { createDebug, tag } from './utils/logger.js';
import { createDebug, pluralize, tag } from './utils/logger.js';
import { mdq } from './utils/markdown-query.js';
import { redactSecrets } from './utils/secrets.js';
import { isNonReusableCode } from './utils/step-analyzer.ts';
Expand Down Expand Up @@ -309,6 +309,16 @@ export class ExperienceTracker {
return this.buildToc(sorted);
}

renderExperienceTocFor(state: ActionResult): string {
const toc = this.getExperienceTableOfContents(state);
if (toc.length === 0) return '';

const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
return renderExperienceToc(toc);
}

getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: { includeDescendantExperience?: boolean }): { title: string; url: string; content: string } | null {
const toc = this.getExperienceTableOfContents(state, options);
const entry = toc.find((e) => e.fileTag === fileTag);
Expand Down
24 changes: 22 additions & 2 deletions src/knowledge-tracker.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import dedent from 'dedent';
import matter from 'gray-matter';
import { ActionResult } from './action-result.js';
import { ConfigParser } from './config.js';
import { getCliName } from './utils/cli-name.ts';
import { createDebug } from './utils/logger.js';
import { createDebug, pluralize, tag } from './utils/logger.js';
import { isSecretName, registerSecret } from './utils/secrets.js';

const debugLog = createDebug('explorbot:knowledge-tracker');
Expand Down Expand Up @@ -79,6 +80,25 @@ export class KnowledgeTracker {
});
}

renderRelevantKnowledge(state: ActionResult): string {
const knowledgeFiles = this.getRelevantKnowledge(state);
if (knowledgeFiles.length === 0) return '';

const knowledgeContent = knowledgeFiles
.map((k) => k.content)
.filter((k) => !!k)
.join('\n\n');

tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
return dedent`
<knowledge>
Here is relevant knowledge for this page:

${knowledgeContent}
</knowledge>
`;
}

addKnowledge(urlPattern: string, description: string): { filename: string; filePath: string; isNewFile: boolean } {
const configParser = ConfigParser.getInstance();
const config = configParser.getConfig();
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/researcher-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('Researcher with real browser + aimock', () => {
};
return {
getStateManager: () => mockStateManager,
getKnowledgeTracker: () => ({ getRelevantKnowledge: () => [] }),
getKnowledgeTracker: () => ({ getRelevantKnowledge: () => [], renderRelevantKnowledge: () => '' }),
getConfig: () => ConfigParser.getInstance().getConfig(),
visit: async () => {},
annotateElements: async () => (await annotatePageElements(page)).elements,
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/researcher-sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function createMockExplorer(configOverrides: Record<string, unknown> = {}, playw
};
return {
getStateManager: () => stateManager,
getKnowledgeTracker: () => ({ getRelevantKnowledge: () => [] }),
getKnowledgeTracker: () => ({ getRelevantKnowledge: () => [], renderRelevantKnowledge: () => '' }),
getConfig: () => config,
visit: async () => {},
annotateElements: async () => [],
Expand Down
1 change: 1 addition & 0 deletions tests/integration/researcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function createMockExplorer(state = fakeState) {
};
const mockKnowledgeTracker = {
getRelevantKnowledge: () => [],
renderRelevantKnowledge: () => '',
};
const mockStateManager = {
getCurrentState: () => state,
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/experience-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ describe('ExperienceTracker', () => {
});
});

describe('renderExperienceTocFor', () => {
it('returns empty string when no experience matches the page', () => {
const state = new ActionResult({ url: 'https://example.com/empty', html: '<html></html>', title: 'Empty' });
expect(experienceTracker.renderExperienceTocFor(state)).toBe('');
});

it('renders the experience TOC for a page with recorded experience', () => {
const actionResult = new ActionResult({
html: '<html><body>Dashboard</body></html>',
url: 'https://example.com/dashboard',
title: 'Dashboard',
});
experienceTracker.writeAction(actionResult, { title: 'Navigate to dashboard', code: 'I.click("Dashboard")' });

const rendered = experienceTracker.renderExperienceTocFor(actionResult);

expect(rendered).toContain('<experience>');
expect(rendered).toContain('ACTION: navigate to dashboard');
});
});

describe('writeAction', () => {
it('should save action to experience file', () => {
const actionResult = new ActionResult({
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/knowledge-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { existsSync, rmSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import matter from 'gray-matter';
import { ActionResult } from '../../src/action-result.js';
import { ConfigParser } from '../../src/config';
import { KnowledgeTracker } from '../../src/knowledge-tracker';
import { clearRegisteredSecrets, redactSecrets } from '../../src/utils/secrets';
Expand Down Expand Up @@ -35,6 +36,26 @@ describe('KnowledgeTracker', () => {
writeFileSync(`${knowledgeDir}/${filename}`, fileContent, 'utf8');
}

describe('renderRelevantKnowledge', () => {
it('returns empty string when no knowledge matches the page', () => {
const tracker = new KnowledgeTracker();
const state = new ActionResult({ url: '/nothing-here', html: '<html></html>' });
expect(tracker.renderRelevantKnowledge(state)).toBe('');
});

it('renders a tagged knowledge block for a matching page', () => {
writeKnowledgeFile('login.md', '/login', 'Use admin credentials');
const tracker = new KnowledgeTracker();
const state = new ActionResult({ url: '/login', html: '<html></html>' });

const rendered = tracker.renderRelevantKnowledge(state);

expect(rendered).toContain('<knowledge>');
expect(rendered).toContain('Use admin credentials');
expect(rendered).toContain('</knowledge>');
});
});

describe('interpolateVars', () => {
it('should replace ${env.VAR} with environment variable value', () => {
process.env.TEST_LOGIN = 'admin@example.com';
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/tester-error-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ describe('Tester error page handling', () => {
clearHistory: () => {},
getExperienceTracker: () => ({
getExperienceTableOfContents: () => [],
renderExperienceTocFor: () => '',
}),
}),
getKnowledgeTracker: () => ({
getRelevantKnowledge: () => [],
renderRelevantKnowledge: () => '',
}),
getRequestStore: () => null,
playwrightHelper: {
Expand Down Expand Up @@ -101,10 +103,12 @@ describe('Tester error page handling', () => {
clearHistory: () => {},
getExperienceTracker: () => ({
getExperienceTableOfContents: () => [],
renderExperienceTocFor: () => '',
}),
}),
getKnowledgeTracker: () => ({
getRelevantKnowledge: () => [],
renderRelevantKnowledge: () => '',
}),
getRequestStore: () => null,
hasOtherTabs: () => false,
Expand Down
22 changes: 14 additions & 8 deletions tests/unit/tester-focus-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import { ActionResult } from '../../src/action-result.ts';
import { Tester } from '../../src/ai/tester.ts';
import { renderExperienceToc } from '../../src/experience-tracker.ts';
import { Test } from '../../src/test-plan.ts';

function buildTester(): Tester {
Expand All @@ -10,10 +11,12 @@ function buildTester(): Tester {
getCurrentState: () => null,
getExperienceTracker: () => ({
getExperienceTableOfContents: () => [],
renderExperienceTocFor: () => '',
}),
}),
getKnowledgeTracker: () => ({
getRelevantKnowledge: () => [],
renderRelevantKnowledge: () => '',
}),
getCurrentIframeInfo: () => null,
hasOtherTabs: () => false,
Expand All @@ -32,22 +35,25 @@ function buildTester(): Tester {
}

function buildTesterWithExperience(): Tester {
const experienceToc = [
{
fileTag: 'A',
fileHash: 'abc123',
url: '/page',
sections: [{ index: 1, level: 2, title: 'FLOW: create item' }],
},
];
const explorer: any = {
getConfig: () => ({ files: {} }),
getStateManager: () => ({
getExperienceTracker: () => ({
getExperienceTableOfContents: () => [
{
fileTag: 'A',
fileHash: 'abc123',
url: '/page',
sections: [{ index: 1, level: 2, title: 'FLOW: create item' }],
},
],
getExperienceTableOfContents: () => experienceToc,
renderExperienceTocFor: () => renderExperienceToc(experienceToc),
}),
}),
getKnowledgeTracker: () => ({
getRelevantKnowledge: () => [],
renderRelevantKnowledge: () => '',
}),
};
const provider: any = {};
Expand Down
Loading