explorbot 0.2.5 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +17 -4
- package/dist/boat/prima/src/cli.js +7 -0
- package/dist/boat/prima/src/prima.js +18 -4
- package/dist/models.json +4 -4
- package/dist/package.json +6 -2
- package/dist/src/action-result.d.ts +13 -0
- package/dist/src/action-result.js +46 -15
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +48 -17
- package/dist/src/ai/captain/web-mode.js +1 -2
- package/dist/src/ai/captain.d.ts +20 -0
- package/dist/src/ai/captain.js +10 -1
- package/dist/src/ai/conversation.d.ts +1 -0
- package/dist/src/ai/conversation.js +3 -0
- package/dist/src/ai/driller.js +6 -2
- package/dist/src/ai/fisherman-tools.d.ts +40 -1
- package/dist/src/ai/fisherman-tools.js +39 -0
- package/dist/src/ai/fisherman.js +3 -2
- package/dist/src/ai/navigator.d.ts +2 -1
- package/dist/src/ai/navigator.js +5 -9
- package/dist/src/ai/pilot.js +51 -29
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.d.ts +3 -0
- package/dist/src/ai/provider.js +80 -17
- package/dist/src/ai/researcher/cache.d.ts +8 -3
- package/dist/src/ai/researcher/cache.js +13 -8
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
- package/dist/src/ai/researcher.js +4 -3
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +34 -26
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +79 -56
- package/dist/src/commands/explore-command.js +22 -17
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.d.ts +2 -1
- package/dist/src/config.js +3 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +1 -1
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/test-plan.d.ts +8 -0
- package/dist/src/test-plan.js +11 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +32 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +31 -2
- package/docs/basics/getting-started.md +33 -10
- package/docs/basics/providers.md +6 -4
- package/docs/contributing/npm-package.md +73 -4
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +51 -17
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/conversation.ts +3 -0
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +3 -2
- package/src/ai/navigator.ts +6 -10
- package/src/ai/pilot.ts +54 -32
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +111 -41
- package/src/ai/researcher/cache.ts +17 -9
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +1 -5
- package/src/ai/tester.ts +32 -27
- package/src/ai/tools.ts +84 -60
- package/src/commands/explore-command.ts +17 -14
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +4 -2
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +1 -1
- package/src/playwright-recorder.ts +6 -11
- package/src/test-plan.ts +18 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
package/src/ai/navigator.ts
CHANGED
|
@@ -213,7 +213,7 @@ class Navigator implements Agent {
|
|
|
213
213
|
return reasons.join('; ') || null;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
|
|
216
|
+
async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; experience?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
|
|
217
217
|
if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
218
218
|
|
|
219
219
|
this.lastFailureReason = null;
|
|
@@ -226,7 +226,7 @@ class Navigator implements Agent {
|
|
|
226
226
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
227
227
|
|
|
228
228
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
229
|
-
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
|
|
229
|
+
conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
|
|
230
230
|
|
|
231
231
|
let stopReason: string | null = null;
|
|
232
232
|
const tools = {
|
|
@@ -369,14 +369,10 @@ class Navigator implements Agent {
|
|
|
369
369
|
return resolved;
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
private async buildResolutionPrompt(message: string, actionResult: ActionResult): Promise<string> {
|
|
373
|
-
let experience = '';
|
|
374
|
-
if (!actionResult.isInsideIframe) {
|
|
375
|
-
|
|
376
|
-
if (successful.length > 0) {
|
|
377
|
-
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
|
|
378
|
-
experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
|
|
379
|
-
}
|
|
372
|
+
private async buildResolutionPrompt(message: string, actionResult: ActionResult, injectedExperience?: string): Promise<string> {
|
|
373
|
+
let experience = injectedExperience || '';
|
|
374
|
+
if (!experience && !actionResult.isInsideIframe) {
|
|
375
|
+
experience = this.experienceTracker.renderExperienceFor(actionResult);
|
|
380
376
|
}
|
|
381
377
|
|
|
382
378
|
return dedent`
|
package/src/ai/pilot.ts
CHANGED
|
@@ -25,8 +25,11 @@ import { capabilityGroundingRule, dataProtectionRules } from './rules.ts';
|
|
|
25
25
|
import { isInteractive } from './task-agent.ts';
|
|
26
26
|
import { withdrawVisionTools } from './tools.ts';
|
|
27
27
|
|
|
28
|
-
const CHECK_TOOLS = ['verify', 'see', 'research'
|
|
28
|
+
const CHECK_TOOLS = ['verify', 'see', 'research'];
|
|
29
|
+
const EVIDENCE_TOOLS = ['verify', 'see'];
|
|
29
30
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
31
|
+
const PILOT_MESSAGE_LIMIT = 2;
|
|
32
|
+
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
30
33
|
|
|
31
34
|
export class Pilot implements Agent {
|
|
32
35
|
emoji = '🧭';
|
|
@@ -324,6 +327,10 @@ export class Pilot implements Agent {
|
|
|
324
327
|
overrides the others — weigh them together. Tester's record() notes are the LEAST reliable; always
|
|
325
328
|
cross-check against actual actions and state. Visual screenshot analysis is strong for UI state
|
|
326
329
|
(active tabs, visible counts, colors).
|
|
330
|
+
Judge every check by WHAT IT ESTABLISHES, never by the fact that it ran. A check that executed
|
|
331
|
+
successfully is failure evidence when its content negates the scenario goal — the goal's object
|
|
332
|
+
absent, the action not performed, the interaction impossible. "The check passed" and "the goal was
|
|
333
|
+
met" are different claims.
|
|
327
334
|
If the final page clearly shows an equivalent success state in a different UI form, do not fail only
|
|
328
335
|
because one narrow assertion targeted a specific badge, count, toast, or wording that the product
|
|
329
336
|
represents differently.
|
|
@@ -465,9 +472,13 @@ export class Pilot implements Agent {
|
|
|
465
472
|
the elements needed for the scenario. The page summary does not list every element.
|
|
466
473
|
Prefer interacting with the current page over navigating away.
|
|
467
474
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
475
|
+
Tester never sees <experience> — a recorded recipe reaches it only when you open one.
|
|
476
|
+
The entries listed are what was recorded on the page you are on now; recipes for the
|
|
477
|
+
pages this test moves to are listed when it gets there. Open the ones whose titles fit a
|
|
478
|
+
step taken from here, and say so in the plan when none of them fit.
|
|
479
|
+
Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
|
|
480
|
+
automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
|
|
481
|
+
anywhere your scenario diverges from it.
|
|
471
482
|
|
|
472
483
|
Be concise and specific. Tester will follow your plan.
|
|
473
484
|
`,
|
|
@@ -513,9 +524,11 @@ export class Pilot implements Agent {
|
|
|
513
524
|
${this.formatExpectations(task)}
|
|
514
525
|
|
|
515
526
|
First: evaluate whether this navigation makes sense for the scenario goal. If the page is unrelated, instruct Tester to back() or reset(). Then plan next steps.
|
|
527
|
+
|
|
528
|
+
Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
|
|
516
529
|
`,
|
|
517
530
|
'pilot.reviewNewPage',
|
|
518
|
-
{ task }
|
|
531
|
+
{ tools: true, maxToolRoundtrips: 2, task }
|
|
519
532
|
);
|
|
520
533
|
}
|
|
521
534
|
|
|
@@ -549,6 +562,8 @@ export class Pilot implements Agent {
|
|
|
549
562
|
</recent_actions>
|
|
550
563
|
|
|
551
564
|
What should Tester do next?
|
|
565
|
+
|
|
566
|
+
Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
|
|
552
567
|
`,
|
|
553
568
|
'pilot.analyze',
|
|
554
569
|
{ tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task }
|
|
@@ -564,8 +579,8 @@ export class Pilot implements Agent {
|
|
|
564
579
|
}
|
|
565
580
|
|
|
566
581
|
async settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]> {
|
|
567
|
-
let image:
|
|
568
|
-
if (finalState?.screenshot && this.provider.hasVision()) image =
|
|
582
|
+
let image: Buffer | null = null;
|
|
583
|
+
if (finalState?.screenshot && this.provider.hasVision()) image = finalState.screenshot;
|
|
569
584
|
|
|
570
585
|
const decided = (text: string): 'passed' | 'failed' => {
|
|
571
586
|
if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
|
|
@@ -665,10 +680,9 @@ export class Pilot implements Agent {
|
|
|
665
680
|
|
|
666
681
|
let finalUserText = userText;
|
|
667
682
|
if (opts.tools) {
|
|
683
|
+
this.conversation!.cleanupTag('experience', '...cleaned experience index...');
|
|
668
684
|
const tocBlock = this.getExperienceToc();
|
|
669
|
-
if (tocBlock) {
|
|
670
|
-
finalUserText = `${tocBlock}\n\n${userText}`;
|
|
671
|
-
}
|
|
685
|
+
if (tocBlock) finalUserText = `${tocBlock}\n\n${userText}`;
|
|
672
686
|
}
|
|
673
687
|
this.conversation!.addUserText(finalUserText);
|
|
674
688
|
|
|
@@ -682,8 +696,9 @@ export class Pilot implements Agent {
|
|
|
682
696
|
telemetry: { functionId },
|
|
683
697
|
});
|
|
684
698
|
const text = result?.response?.text || '';
|
|
685
|
-
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
|
|
699
|
+
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => ({ url: e.output.url, content: e.output.content }));
|
|
686
700
|
if (learned.length === 0) return text;
|
|
701
|
+
opts.task.applyExperience(learned);
|
|
687
702
|
return dedent`
|
|
688
703
|
${text}
|
|
689
704
|
|
|
@@ -691,7 +706,7 @@ export class Pilot implements Agent {
|
|
|
691
706
|
Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
|
|
692
707
|
Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
|
|
693
708
|
|
|
694
|
-
${learned.join('\n\n')}
|
|
709
|
+
${learned.map((recipe) => recipe.content).join('\n\n')}
|
|
695
710
|
</applied_experience>
|
|
696
711
|
`;
|
|
697
712
|
}
|
|
@@ -718,6 +733,7 @@ export class Pilot implements Agent {
|
|
|
718
733
|
}
|
|
719
734
|
|
|
720
735
|
private buildPreconditionTool(task: Test) {
|
|
736
|
+
const unavailable = 'Data was not created and cannot be created automatically. Do not call precondition again for this test — continue with what the page already shows.';
|
|
721
737
|
return {
|
|
722
738
|
precondition: tool({
|
|
723
739
|
description: 'Create fresh disposable data that the test will act on (edit, delete, filter). Describe WHAT to create, not what exists. Do NOT request users. Examples: "1 post", "1 comment", "1 label named Bug".',
|
|
@@ -732,7 +748,7 @@ export class Pilot implements Agent {
|
|
|
732
748
|
if (!this.fisherman || !this.fisherman.isAvailable()) {
|
|
733
749
|
const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
|
|
734
750
|
if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
735
|
-
return { noted: true, prepared: false, reason:
|
|
751
|
+
return { noted: true, prepared: false, reason: unavailable };
|
|
736
752
|
}
|
|
737
753
|
|
|
738
754
|
const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
|
|
@@ -741,7 +757,7 @@ export class Pilot implements Agent {
|
|
|
741
757
|
if (result.summary) tag('warning').log(`Precondition failed: ${result.summary}`);
|
|
742
758
|
const skipReason = await this.checkDataAvailability(task, description, result.summary);
|
|
743
759
|
if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
744
|
-
return { noted: true, prepared: false, reason: result.summary };
|
|
760
|
+
return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
|
|
745
761
|
}
|
|
746
762
|
|
|
747
763
|
const items = result.created.map((c) => {
|
|
@@ -778,7 +794,7 @@ export class Pilot implements Agent {
|
|
|
778
794
|
Reply with YES or NO on the first line, then a one-sentence reason on the second line.
|
|
779
795
|
`;
|
|
780
796
|
|
|
781
|
-
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
|
|
797
|
+
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
|
|
782
798
|
if (!answer) return null;
|
|
783
799
|
|
|
784
800
|
const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
|
|
@@ -870,15 +886,6 @@ export class Pilot implements Agent {
|
|
|
870
886
|
private async fetchRequestedContext(text: string, currentState: ActionResult): Promise<string> {
|
|
871
887
|
const parts: string[] = [];
|
|
872
888
|
|
|
873
|
-
if (text.includes('ATTACH_HTML')) {
|
|
874
|
-
const html = await currentState.simplifiedHtml();
|
|
875
|
-
parts.push(dedent`
|
|
876
|
-
<page_html>
|
|
877
|
-
${html}
|
|
878
|
-
</page_html>
|
|
879
|
-
`);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
889
|
if (text.includes('ATTACH_ARIA')) {
|
|
883
890
|
parts.push(dedent`
|
|
884
891
|
<page_aria>
|
|
@@ -993,20 +1000,20 @@ export class Pilot implements Agent {
|
|
|
993
1000
|
|
|
994
1001
|
private hasSuccessfulCheckEvidence(currentState: ActionResult, testerConversation: Conversation): boolean {
|
|
995
1002
|
if (Object.values(currentState.verifications ?? {}).some(Boolean)) return true;
|
|
996
|
-
return testerConversation.getToolExecutions().some((t) =>
|
|
1003
|
+
return testerConversation.getToolExecutions().some((t) => EVIDENCE_TOOLS.includes(t.toolName) && t.wasSuccessful);
|
|
997
1004
|
}
|
|
998
1005
|
|
|
999
1006
|
private formatSuccessfulAssertions(currentState: ActionResult, testerConversation: Conversation): string {
|
|
1000
1007
|
const lines: string[] = [];
|
|
1001
1008
|
for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
|
|
1002
|
-
if (passed) lines.push(`
|
|
1009
|
+
if (passed) lines.push(`state verification (passed): ${assertion}`);
|
|
1003
1010
|
}
|
|
1004
1011
|
|
|
1005
1012
|
for (const exec of testerConversation.getToolExecutions()) {
|
|
1006
|
-
if (!
|
|
1013
|
+
if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful) continue;
|
|
1007
1014
|
const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
|
|
1008
1015
|
const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
|
|
1009
|
-
lines.push(`
|
|
1016
|
+
lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
|
|
1010
1017
|
}
|
|
1011
1018
|
|
|
1012
1019
|
return [...new Set(lines)].join('\n');
|
|
@@ -1048,6 +1055,21 @@ export class Pilot implements Agent {
|
|
|
1048
1055
|
const ariaDiff = t.output?.pageDiff?.ariaChanges;
|
|
1049
1056
|
if (ariaDiff) line += `\n ${ariaDiff}`;
|
|
1050
1057
|
|
|
1058
|
+
if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
|
|
1059
|
+
|
|
1060
|
+
const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r: any) => r.status >= 400);
|
|
1061
|
+
if (failedRequests.length > 0) {
|
|
1062
|
+
line += `\n requests: ${failedRequests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
|
|
1066
|
+
if (messages.length > 0) {
|
|
1067
|
+
line += `\n messages: ${messages.map((m: string) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1071
|
+
if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1072
|
+
|
|
1051
1073
|
return line;
|
|
1052
1074
|
})
|
|
1053
1075
|
.join('\n\n');
|
|
@@ -1116,11 +1138,11 @@ export class Pilot implements Agent {
|
|
|
1116
1138
|
role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
|
|
1117
1139
|
XPath into NEXT instruction.
|
|
1118
1140
|
|
|
1119
|
-
To request more context, mention
|
|
1141
|
+
To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
|
|
1120
1142
|
|
|
1121
|
-
Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck,
|
|
1122
|
-
back, getVisitedStates, reset, stop, finish, record.
|
|
1123
|
-
Use tool names exactly as listed. Do not invent combined names
|
|
1143
|
+
Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
|
|
1144
|
+
visualClick, back, getVisitedStates, reset, stop, finish, record.
|
|
1145
|
+
Use tool names exactly as listed. Do not invent combined names or aliases.
|
|
1124
1146
|
|
|
1125
1147
|
${capabilityGroundingRule}
|
|
1126
1148
|
|
|
@@ -4,7 +4,7 @@ import { normalizeUrl } from '../../state-manager.ts';
|
|
|
4
4
|
import type { StateManager } from '../../state-manager.ts';
|
|
5
5
|
import type { Plan } from '../../test-plan.ts';
|
|
6
6
|
import { tag } from '../../utils/logger.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
8
8
|
import type { Provider } from '../provider.ts';
|
|
9
9
|
import type { Constructor } from '../researcher/mixin.ts';
|
|
10
10
|
|
|
@@ -39,18 +39,7 @@ function buildKey(url: string, feature?: string): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function isTemplateMatch(urlA: string, urlB: string): boolean {
|
|
42
|
-
|
|
43
|
-
const partsB = normalizeUrl(urlB).split('/');
|
|
44
|
-
if (partsA.length !== partsB.length) return false;
|
|
45
|
-
|
|
46
|
-
let diffCount = 0;
|
|
47
|
-
for (let i = 0; i < partsA.length; i++) {
|
|
48
|
-
if (partsA[i] === partsB[i]) continue;
|
|
49
|
-
diffCount++;
|
|
50
|
-
if (diffCount > 1) return false;
|
|
51
|
-
if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i])) return false;
|
|
52
|
-
}
|
|
53
|
-
return diffCount === 1;
|
|
42
|
+
return isSamePageFamily(urlA, urlB);
|
|
54
43
|
}
|
|
55
44
|
|
|
56
45
|
export function getPlannedByStateHash(hash: string): PlanRecord | null {
|
package/src/ai/planner.ts
CHANGED
|
@@ -149,7 +149,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
149
149
|
|
|
150
150
|
const actionResult = ActionResult.fromState(state);
|
|
151
151
|
const combinedHtml = await actionResult.combinedHtml();
|
|
152
|
-
const similarHash = await findSimilarStateHash(combinedHtml);
|
|
152
|
+
const similarHash = await findSimilarStateHash(combinedHtml, state.url);
|
|
153
153
|
if (similarHash) {
|
|
154
154
|
const planned = getPlannedByStateHash(similarHash);
|
|
155
155
|
if (planned) {
|
package/src/ai/provider.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
2
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
-
import {
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
|
|
5
6
|
import type { ModelMessage } from 'ai';
|
|
7
|
+
import { z } from 'zod';
|
|
6
8
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
7
9
|
import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
|
|
8
10
|
import { executionController } from '../execution-controller.ts';
|
|
@@ -11,7 +13,7 @@ import { Stats } from '../stats.ts';
|
|
|
11
13
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
14
|
import { type RetryOptions, withRetry } from '../utils/retry.js';
|
|
13
15
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
14
|
-
import { Conversation, toToolExecution } from './conversation.js';
|
|
16
|
+
import { Conversation, NARRATION_TOOL, toToolExecution } from './conversation.js';
|
|
15
17
|
|
|
16
18
|
const debugLog = createDebug('explorbot:provider');
|
|
17
19
|
const promptLog = createDebug('explorbot:provider:out');
|
|
@@ -20,6 +22,20 @@ const responseLog = createDebug('explorbot:provider:in');
|
|
|
20
22
|
class AiError extends Error {}
|
|
21
23
|
export class ContextLengthError extends Error {}
|
|
22
24
|
|
|
25
|
+
const DEFAULT_PARALLEL_REQUESTS = 4;
|
|
26
|
+
|
|
27
|
+
const modelSlotContext = new AsyncLocalStorage<boolean>();
|
|
28
|
+
|
|
29
|
+
const HARMONY_CHANNELS = ['commentary', 'analysis', 'final'];
|
|
30
|
+
|
|
31
|
+
function createHarmonyChannelFallbackTool() {
|
|
32
|
+
return tool({
|
|
33
|
+
description: 'Internal compatibility fallback for model channel output. Do not call directly.',
|
|
34
|
+
inputSchema: z.record(z.string(), z.any()),
|
|
35
|
+
execute: async () => ({ message: 'Noted. Continue with your next action.' }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
let telemetryRegistered = false;
|
|
24
40
|
|
|
25
41
|
const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
|
|
@@ -79,6 +95,8 @@ export class Provider {
|
|
|
79
95
|
};
|
|
80
96
|
|
|
81
97
|
lastConversation: Conversation | null = null;
|
|
98
|
+
private activeModelCalls = 0;
|
|
99
|
+
private modelCallWaiters: (() => void)[] = [];
|
|
82
100
|
|
|
83
101
|
constructor(config: AIConfig) {
|
|
84
102
|
if (!config?.model) {
|
|
@@ -154,10 +172,28 @@ export class Provider {
|
|
|
154
172
|
private getRetryOptions(options: any = {}): RetryOptions {
|
|
155
173
|
return {
|
|
156
174
|
...this.defaultRetryOptions,
|
|
157
|
-
maxAttempts: options.maxRetries || this.defaultRetryOptions.maxAttempts,
|
|
175
|
+
maxAttempts: options.maxRetries || this.config.retryAttempts || this.defaultRetryOptions.maxAttempts,
|
|
176
|
+
baseDelay: this.config.retryDelay || this.defaultRetryOptions.baseDelay,
|
|
158
177
|
};
|
|
159
178
|
}
|
|
160
179
|
|
|
180
|
+
private async withModelRequestSlot<T>(fn: () => Promise<T>): Promise<T> {
|
|
181
|
+
if (modelSlotContext.getStore()) return fn();
|
|
182
|
+
const limit = Math.max(1, this.config.maxParallelRequests ?? DEFAULT_PARALLEL_REQUESTS);
|
|
183
|
+
if (this.activeModelCalls >= limit || this.modelCallWaiters.length > 0) {
|
|
184
|
+
await new Promise<void>((resolve) => this.modelCallWaiters.push(resolve));
|
|
185
|
+
} else {
|
|
186
|
+
this.activeModelCalls++;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return await modelSlotContext.run(true, fn);
|
|
190
|
+
} finally {
|
|
191
|
+
const next = this.modelCallWaiters.shift();
|
|
192
|
+
if (next) next();
|
|
193
|
+
else this.activeModelCalls--;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
161
197
|
private mergeProviderOptions(config: Record<string, any>, agentName?: string): Record<string, any> {
|
|
162
198
|
if (!agentName) return config;
|
|
163
199
|
const agentOptions = this.getProviderOptionsForAgent(agentName);
|
|
@@ -302,7 +338,7 @@ export class Provider {
|
|
|
302
338
|
const toolResults = response.toolResults || [];
|
|
303
339
|
|
|
304
340
|
const resultsById = new Map(toolResults.map((r: any) => [r.toolCallId, r]));
|
|
305
|
-
const toolExecutions = toolCalls.map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
341
|
+
const toolExecutions = toolCalls.filter((call: any) => call.toolName !== NARRATION_TOOL).map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
306
342
|
|
|
307
343
|
return { conversation, response, toolExecutions };
|
|
308
344
|
}
|
|
@@ -316,21 +352,23 @@ export class Provider {
|
|
|
316
352
|
|
|
317
353
|
promptLog(messages[messages.length - 1].content);
|
|
318
354
|
try {
|
|
319
|
-
const response = await
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
355
|
+
const response = await this.withModelRequestSlot(() =>
|
|
356
|
+
withRetry(async () => {
|
|
357
|
+
const result = await generateText({ messages, ...config });
|
|
358
|
+
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
359
|
+
if (!result.text) {
|
|
360
|
+
debugLog(result);
|
|
361
|
+
if (result.finishReason === 'length') {
|
|
362
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
363
|
+
}
|
|
364
|
+
throw new Error('No response text from AI');
|
|
365
|
+
}
|
|
324
366
|
if (result.finishReason === 'length') {
|
|
325
|
-
|
|
367
|
+
debugLog('finishReason=length, response may be truncated');
|
|
326
368
|
}
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
debugLog('finishReason=length, response may be truncated');
|
|
331
|
-
}
|
|
332
|
-
return result;
|
|
333
|
-
}, this.getRetryOptions(options));
|
|
369
|
+
return result;
|
|
370
|
+
}, this.getRetryOptions(options))
|
|
371
|
+
);
|
|
334
372
|
|
|
335
373
|
clearActivity();
|
|
336
374
|
responseLog(response.text);
|
|
@@ -356,7 +394,8 @@ export class Provider {
|
|
|
356
394
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
357
395
|
promptLog(`Using model: ${modelName}`);
|
|
358
396
|
|
|
359
|
-
const
|
|
397
|
+
const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
398
|
+
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
360
399
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
361
400
|
promptLog('Available tools:', toolNames);
|
|
362
401
|
promptLog(messages[messages.length - 1].content);
|
|
@@ -365,17 +404,19 @@ export class Provider {
|
|
|
365
404
|
const extraStop = options.stopWhen;
|
|
366
405
|
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
367
406
|
if (extraStop) stopConditions.push(extraStop);
|
|
368
|
-
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
|
|
407
|
+
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
369
408
|
try {
|
|
370
|
-
const response = await
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
409
|
+
const response = await this.withModelRequestSlot(() =>
|
|
410
|
+
withRetry(async () => {
|
|
411
|
+
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
|
|
412
|
+
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
413
|
+
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
414
|
+
if (!result.text && !hasToolCall && result.finishReason === 'length') {
|
|
415
|
+
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
}, this.getRetryOptions(options))
|
|
419
|
+
);
|
|
379
420
|
|
|
380
421
|
clearActivity();
|
|
381
422
|
|
|
@@ -418,9 +459,11 @@ export class Provider {
|
|
|
418
459
|
|
|
419
460
|
try {
|
|
420
461
|
promptLog(messages[messages.length - 1].content);
|
|
421
|
-
const response = await
|
|
422
|
-
|
|
423
|
-
|
|
462
|
+
const response = await this.withModelRequestSlot(() =>
|
|
463
|
+
withRetry(async () => {
|
|
464
|
+
return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
|
|
465
|
+
}, this.getRetryOptions(options))
|
|
466
|
+
);
|
|
424
467
|
|
|
425
468
|
clearActivity();
|
|
426
469
|
responseLog(response.object);
|
|
@@ -572,8 +615,6 @@ export class Provider {
|
|
|
572
615
|
|
|
573
616
|
setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
|
|
574
617
|
|
|
575
|
-
const imageData = `data:image/png;base64,${image.toString()}`;
|
|
576
|
-
|
|
577
618
|
const messages: ModelMessage[] = [
|
|
578
619
|
{
|
|
579
620
|
role: 'user',
|
|
@@ -585,7 +626,7 @@ export class Provider {
|
|
|
585
626
|
{
|
|
586
627
|
type: 'file',
|
|
587
628
|
mediaType: 'image/png',
|
|
588
|
-
data:
|
|
629
|
+
data: image,
|
|
589
630
|
},
|
|
590
631
|
],
|
|
591
632
|
},
|
|
@@ -602,12 +643,14 @@ export class Provider {
|
|
|
602
643
|
|
|
603
644
|
try {
|
|
604
645
|
promptLog(`Processing image with prompt: ${prompt}`);
|
|
605
|
-
const response = await
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
646
|
+
const response = await this.withModelRequestSlot(() =>
|
|
647
|
+
withRetry(async () => {
|
|
648
|
+
return await generateText({
|
|
649
|
+
messages,
|
|
650
|
+
...config,
|
|
651
|
+
});
|
|
652
|
+
}, this.getRetryOptions())
|
|
653
|
+
);
|
|
611
654
|
|
|
612
655
|
clearActivity();
|
|
613
656
|
responseLog(response.text);
|
|
@@ -623,8 +666,35 @@ export class Provider {
|
|
|
623
666
|
}
|
|
624
667
|
|
|
625
668
|
hasVision(): boolean {
|
|
626
|
-
return this.config.visionModel !== undefined;
|
|
669
|
+
return this.config.visionModel !== undefined && !Stats.visionDisabled;
|
|
627
670
|
}
|
|
628
671
|
}
|
|
629
672
|
|
|
673
|
+
function repairToolCall(options: ToolCallRepairOptions): any | null {
|
|
674
|
+
if (options.toolCall.toolName.includes('<|channel|>')) return repairChannelMarker(options);
|
|
675
|
+
return repairHarmonyChannel(options);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
679
|
+
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
680
|
+
if (markerIndex <= 0) return null;
|
|
681
|
+
const toolName = toolCall.toolName.slice(0, markerIndex);
|
|
682
|
+
if (!tools[toolName]) return null;
|
|
683
|
+
tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
|
|
684
|
+
return { ...toolCall, toolName };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function repairHarmonyChannel({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
688
|
+
if (!HARMONY_CHANNELS.includes(toolCall.toolName)) return null;
|
|
689
|
+
if (!tools.commentary) return null;
|
|
690
|
+
let input = toolCall.input;
|
|
691
|
+
if (typeof input !== 'string' || !input.trim().startsWith('{')) {
|
|
692
|
+
input = JSON.stringify({ content: typeof input === 'string' ? input : JSON.stringify(input ?? null) });
|
|
693
|
+
}
|
|
694
|
+
tag('warning').log(`Repaired tool name '${toolCall.toolName}' → 'commentary'`);
|
|
695
|
+
return { ...toolCall, toolName: NARRATION_TOOL, input };
|
|
696
|
+
}
|
|
697
|
+
|
|
630
698
|
export { AiError, Provider as AIProvider };
|
|
699
|
+
|
|
700
|
+
type ToolCallRepairOptions = { toolCall: any; tools: any };
|
|
@@ -60,7 +60,8 @@ export function getPreviousResearch(hash: string): string {
|
|
|
60
60
|
return readFileSync(researchFile, 'utf8');
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export function saveResearch(
|
|
63
|
+
export function saveResearch(state: ResearchState, text: string, combinedHtml?: string): string {
|
|
64
|
+
const { hash, url } = state;
|
|
64
65
|
const researchDir = outputPath('research');
|
|
65
66
|
const researchFile = join(researchDir, `${hash}.md`);
|
|
66
67
|
if (!existsSync(researchDir)) mkdirSync(researchDir, { recursive: true });
|
|
@@ -74,14 +75,16 @@ export function saveResearch(hash: string, text: string, combinedHtml?: string):
|
|
|
74
75
|
if (!existsSync(statesDir)) mkdirSync(statesDir, { recursive: true });
|
|
75
76
|
const fingerprint = computeHtmlFingerprint(combinedHtml);
|
|
76
77
|
const fingerprintFile = join(statesDir, `${hash}.fingerprint`);
|
|
77
|
-
|
|
78
|
+
const record: FingerprintRecord = { entries: fingerprint };
|
|
79
|
+
if (url) record.url = url;
|
|
80
|
+
writeFileSync(fingerprintFile, JSON.stringify(record));
|
|
78
81
|
debugLog(`Fingerprint saved to ${fingerprintFile}`);
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
return researchFile;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
function findSimilarMatch(combinedHtml: string): Promise<
|
|
87
|
+
function findSimilarMatch(combinedHtml: string, url?: string): Promise<FingerprintMatch | null> {
|
|
85
88
|
const statesDir = getStatesDir();
|
|
86
89
|
if (!existsSync(statesDir)) return Promise.resolve(null);
|
|
87
90
|
|
|
@@ -93,7 +96,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
93
96
|
resolve(null);
|
|
94
97
|
}, FINGERPRINT_WORKER_TIMEOUT_MS);
|
|
95
98
|
|
|
96
|
-
worker.on('message', (data: { matchHash: string | null; similarity: number }) => {
|
|
99
|
+
worker.on('message', (data: { matchHash: string | null; similarity: number; url?: string }) => {
|
|
97
100
|
clearTimeout(timeout);
|
|
98
101
|
const { matchHash, similarity } = data;
|
|
99
102
|
if (!matchHash) {
|
|
@@ -102,7 +105,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
|
|
105
|
-
resolve({ hash: matchHash, similarity });
|
|
108
|
+
resolve({ hash: matchHash, similarity, url: data.url });
|
|
106
109
|
});
|
|
107
110
|
|
|
108
111
|
worker.postMessage({
|
|
@@ -110,17 +113,22 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
110
113
|
statesDir,
|
|
111
114
|
maxAgeMs: FINGERPRINT_MAX_AGE_MS,
|
|
112
115
|
threshold: SIMILARITY_THRESHOLD,
|
|
116
|
+
url,
|
|
113
117
|
});
|
|
114
118
|
});
|
|
115
119
|
}
|
|
116
120
|
|
|
117
|
-
export async function findSimilarResearch(combinedHtml: string): Promise<string | null> {
|
|
118
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
121
|
+
export async function findSimilarResearch(combinedHtml: string, url?: string): Promise<string | null> {
|
|
122
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
119
123
|
if (!match) return null;
|
|
120
124
|
return getCachedResearch(match.hash) || null;
|
|
121
125
|
}
|
|
122
126
|
|
|
123
|
-
export async function findSimilarStateHash(combinedHtml: string): Promise<string | null> {
|
|
124
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
127
|
+
export async function findSimilarStateHash(combinedHtml: string, url?: string): Promise<string | null> {
|
|
128
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
125
129
|
return match?.hash || null;
|
|
126
130
|
}
|
|
131
|
+
|
|
132
|
+
type FingerprintRecord = { entries: string[]; url?: string };
|
|
133
|
+
type FingerprintMatch = { hash: string; similarity: number; url?: string };
|
|
134
|
+
type ResearchState = { hash: string; url?: string };
|
|
@@ -128,7 +128,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
128
128
|
updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
saveResearch(pageStateHash, updated);
|
|
131
|
+
saveResearch({ hash: pageStateHash }, updated);
|
|
132
132
|
tag('substep').log(`Overlay research appended: ${focusArea.name}`);
|
|
133
133
|
return sectionMarkdown;
|
|
134
134
|
}
|