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/dist/src/ai/pilot.js
CHANGED
|
@@ -13,8 +13,11 @@ import { truncateJson } from "../utils/strings.js";
|
|
|
13
13
|
import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
|
|
14
14
|
import { isInteractive } from "./task-agent.js";
|
|
15
15
|
import { withdrawVisionTools } from "./tools.js";
|
|
16
|
-
const CHECK_TOOLS = ['verify', 'see', 'research'
|
|
16
|
+
const CHECK_TOOLS = ['verify', 'see', 'research'];
|
|
17
|
+
const EVIDENCE_TOOLS = ['verify', 'see'];
|
|
17
18
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
19
|
+
const PILOT_MESSAGE_LIMIT = 2;
|
|
20
|
+
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
18
21
|
export class Pilot {
|
|
19
22
|
emoji = '🧭';
|
|
20
23
|
provider;
|
|
@@ -278,6 +281,10 @@ export class Pilot {
|
|
|
278
281
|
overrides the others — weigh them together. Tester's record() notes are the LEAST reliable; always
|
|
279
282
|
cross-check against actual actions and state. Visual screenshot analysis is strong for UI state
|
|
280
283
|
(active tabs, visible counts, colors).
|
|
284
|
+
Judge every check by WHAT IT ESTABLISHES, never by the fact that it ran. A check that executed
|
|
285
|
+
successfully is failure evidence when its content negates the scenario goal — the goal's object
|
|
286
|
+
absent, the action not performed, the interaction impossible. "The check passed" and "the goal was
|
|
287
|
+
met" are different claims.
|
|
281
288
|
If the final page clearly shows an equivalent success state in a different UI form, do not fail only
|
|
282
289
|
because one narrow assertion targeted a specific badge, count, toast, or wording that the product
|
|
283
290
|
represents differently.
|
|
@@ -411,9 +418,13 @@ export class Pilot {
|
|
|
411
418
|
the elements needed for the scenario. The page summary does not list every element.
|
|
412
419
|
Prefer interacting with the current page over navigating away.
|
|
413
420
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
421
|
+
Tester never sees <experience> — a recorded recipe reaches it only when you open one.
|
|
422
|
+
The entries listed are what was recorded on the page you are on now; recipes for the
|
|
423
|
+
pages this test moves to are listed when it gets there. Open the ones whose titles fit a
|
|
424
|
+
step taken from here, and say so in the plan when none of them fit.
|
|
425
|
+
Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
|
|
426
|
+
automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
|
|
427
|
+
anywhere your scenario diverges from it.
|
|
417
428
|
|
|
418
429
|
Be concise and specific. Tester will follow your plan.
|
|
419
430
|
`, 'pilot.planTest', { tools: true, maxToolRoundtrips: 3, task });
|
|
@@ -452,7 +463,9 @@ export class Pilot {
|
|
|
452
463
|
${this.formatExpectations(task)}
|
|
453
464
|
|
|
454
465
|
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.
|
|
455
|
-
|
|
466
|
+
|
|
467
|
+
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.
|
|
468
|
+
`, 'pilot.reviewNewPage', { tools: true, maxToolRoundtrips: 2, task });
|
|
456
469
|
}
|
|
457
470
|
async analyzeProgress(task, currentState, testerConversation) {
|
|
458
471
|
tag('substep').log('Pilot analyzing progress...');
|
|
@@ -479,6 +492,8 @@ export class Pilot {
|
|
|
479
492
|
</recent_actions>
|
|
480
493
|
|
|
481
494
|
What should Tester do next?
|
|
495
|
+
|
|
496
|
+
Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
|
|
482
497
|
`, 'pilot.analyze', { tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task });
|
|
483
498
|
const contextToAttach = await this.fetchRequestedContext(text, currentState);
|
|
484
499
|
if (contextToAttach) {
|
|
@@ -489,7 +504,7 @@ export class Pilot {
|
|
|
489
504
|
async settleExpectations(task, finalState) {
|
|
490
505
|
let image = null;
|
|
491
506
|
if (finalState?.screenshot && this.provider.hasVision())
|
|
492
|
-
image =
|
|
507
|
+
image = finalState.screenshot;
|
|
493
508
|
const decided = (text) => {
|
|
494
509
|
if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text))
|
|
495
510
|
return 'passed';
|
|
@@ -580,10 +595,10 @@ export class Pilot {
|
|
|
580
595
|
debugLog(`sendToPilot: ${functionId}, tools: ${!!opts.tools}, roundtrips: ${opts.maxToolRoundtrips ?? 0}`);
|
|
581
596
|
let finalUserText = userText;
|
|
582
597
|
if (opts.tools) {
|
|
598
|
+
this.conversation.cleanupTag('experience', '...cleaned experience index...');
|
|
583
599
|
const tocBlock = this.getExperienceToc();
|
|
584
|
-
if (tocBlock)
|
|
600
|
+
if (tocBlock)
|
|
585
601
|
finalUserText = `${tocBlock}\n\n${userText}`;
|
|
586
|
-
}
|
|
587
602
|
}
|
|
588
603
|
this.conversation.addUserText(finalUserText);
|
|
589
604
|
const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
|
|
@@ -595,9 +610,10 @@ export class Pilot {
|
|
|
595
610
|
telemetry: { functionId },
|
|
596
611
|
});
|
|
597
612
|
const text = result?.response?.text || '';
|
|
598
|
-
const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
|
|
613
|
+
const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => ({ url: e.output.url, content: e.output.content }));
|
|
599
614
|
if (learned.length === 0)
|
|
600
615
|
return text;
|
|
616
|
+
opts.task.applyExperience(learned);
|
|
601
617
|
return dedent `
|
|
602
618
|
${text}
|
|
603
619
|
|
|
@@ -605,7 +621,7 @@ export class Pilot {
|
|
|
605
621
|
Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
|
|
606
622
|
Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
|
|
607
623
|
|
|
608
|
-
${learned.join('\n\n')}
|
|
624
|
+
${learned.map((recipe) => recipe.content).join('\n\n')}
|
|
609
625
|
</applied_experience>
|
|
610
626
|
`;
|
|
611
627
|
}
|
|
@@ -638,6 +654,7 @@ export class Pilot {
|
|
|
638
654
|
return planning;
|
|
639
655
|
}
|
|
640
656
|
buildPreconditionTool(task) {
|
|
657
|
+
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.';
|
|
641
658
|
return {
|
|
642
659
|
precondition: tool({
|
|
643
660
|
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".',
|
|
@@ -652,7 +669,7 @@ export class Pilot {
|
|
|
652
669
|
const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
|
|
653
670
|
if (skipReason)
|
|
654
671
|
return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
655
|
-
return { noted: true, prepared: false, reason:
|
|
672
|
+
return { noted: true, prepared: false, reason: unavailable };
|
|
656
673
|
}
|
|
657
674
|
const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
|
|
658
675
|
if (!result.success || result.created.length === 0) {
|
|
@@ -661,7 +678,7 @@ export class Pilot {
|
|
|
661
678
|
const skipReason = await this.checkDataAvailability(task, description, result.summary);
|
|
662
679
|
if (skipReason)
|
|
663
680
|
return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
664
|
-
return { noted: true, prepared: false, reason: result.summary };
|
|
681
|
+
return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
|
|
665
682
|
}
|
|
666
683
|
const items = result.created.map((c) => {
|
|
667
684
|
const parts = [c.type];
|
|
@@ -696,7 +713,7 @@ export class Pilot {
|
|
|
696
713
|
|
|
697
714
|
Reply with YES or NO on the first line, then a one-sentence reason on the second line.
|
|
698
715
|
`;
|
|
699
|
-
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
|
|
716
|
+
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
|
|
700
717
|
if (!answer)
|
|
701
718
|
return null;
|
|
702
719
|
const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
|
|
@@ -779,14 +796,6 @@ export class Pilot {
|
|
|
779
796
|
}
|
|
780
797
|
async fetchRequestedContext(text, currentState) {
|
|
781
798
|
const parts = [];
|
|
782
|
-
if (text.includes('ATTACH_HTML')) {
|
|
783
|
-
const html = await currentState.simplifiedHtml();
|
|
784
|
-
parts.push(dedent `
|
|
785
|
-
<page_html>
|
|
786
|
-
${html}
|
|
787
|
-
</page_html>
|
|
788
|
-
`);
|
|
789
|
-
}
|
|
790
799
|
if (text.includes('ATTACH_ARIA')) {
|
|
791
800
|
parts.push(dedent `
|
|
792
801
|
<page_aria>
|
|
@@ -893,20 +902,20 @@ export class Pilot {
|
|
|
893
902
|
hasSuccessfulCheckEvidence(currentState, testerConversation) {
|
|
894
903
|
if (Object.values(currentState.verifications ?? {}).some(Boolean))
|
|
895
904
|
return true;
|
|
896
|
-
return testerConversation.getToolExecutions().some((t) =>
|
|
905
|
+
return testerConversation.getToolExecutions().some((t) => EVIDENCE_TOOLS.includes(t.toolName) && t.wasSuccessful);
|
|
897
906
|
}
|
|
898
907
|
formatSuccessfulAssertions(currentState, testerConversation) {
|
|
899
908
|
const lines = [];
|
|
900
909
|
for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
|
|
901
910
|
if (passed)
|
|
902
|
-
lines.push(`
|
|
911
|
+
lines.push(`state verification (passed): ${assertion}`);
|
|
903
912
|
}
|
|
904
913
|
for (const exec of testerConversation.getToolExecutions()) {
|
|
905
|
-
if (!
|
|
914
|
+
if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
|
|
906
915
|
continue;
|
|
907
916
|
const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
|
|
908
917
|
const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
|
|
909
|
-
lines.push(`
|
|
918
|
+
lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
|
|
910
919
|
}
|
|
911
920
|
return [...new Set(lines)].join('\n');
|
|
912
921
|
}
|
|
@@ -942,6 +951,19 @@ export class Pilot {
|
|
|
942
951
|
const ariaDiff = t.output?.pageDiff?.ariaChanges;
|
|
943
952
|
if (ariaDiff)
|
|
944
953
|
line += `\n ${ariaDiff}`;
|
|
954
|
+
if (t.output?.pageDiff?.urlChanged)
|
|
955
|
+
line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
|
|
956
|
+
const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r) => r.status >= 400);
|
|
957
|
+
if (failedRequests.length > 0) {
|
|
958
|
+
line += `\n requests: ${failedRequests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
|
|
959
|
+
}
|
|
960
|
+
const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
|
|
961
|
+
if (messages.length > 0) {
|
|
962
|
+
line += `\n messages: ${messages.map((m) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
|
|
963
|
+
}
|
|
964
|
+
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
965
|
+
if (consoleError)
|
|
966
|
+
line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
945
967
|
return line;
|
|
946
968
|
})
|
|
947
969
|
.join('\n\n');
|
|
@@ -1007,11 +1029,11 @@ export class Pilot {
|
|
|
1007
1029
|
role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
|
|
1008
1030
|
XPath into NEXT instruction.
|
|
1009
1031
|
|
|
1010
|
-
To request more context, mention
|
|
1032
|
+
To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
|
|
1011
1033
|
|
|
1012
|
-
Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck,
|
|
1013
|
-
back, getVisitedStates, reset, stop, finish, record.
|
|
1014
|
-
Use tool names exactly as listed. Do not invent combined names
|
|
1034
|
+
Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
|
|
1035
|
+
visualClick, back, getVisitedStates, reset, stop, finish, record.
|
|
1036
|
+
Use tool names exactly as listed. Do not invent combined names or aliases.
|
|
1015
1037
|
|
|
1016
1038
|
${capabilityGroundingRule}
|
|
1017
1039
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { normalizeUrl } from "../../state-manager.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isSamePageFamily } from "../../utils/url-matcher.js";
|
|
5
5
|
const planRegistry = new Map();
|
|
6
6
|
export function registerPlan(url, plan, feature, stateHash) {
|
|
7
7
|
const key = buildKey(url, feature);
|
|
@@ -28,21 +28,7 @@ function buildKey(url, feature) {
|
|
|
28
28
|
return normalized;
|
|
29
29
|
}
|
|
30
30
|
export function isTemplateMatch(urlA, urlB) {
|
|
31
|
-
|
|
32
|
-
const partsB = normalizeUrl(urlB).split('/');
|
|
33
|
-
if (partsA.length !== partsB.length)
|
|
34
|
-
return false;
|
|
35
|
-
let diffCount = 0;
|
|
36
|
-
for (let i = 0; i < partsA.length; i++) {
|
|
37
|
-
if (partsA[i] === partsB[i])
|
|
38
|
-
continue;
|
|
39
|
-
diffCount++;
|
|
40
|
-
if (diffCount > 1)
|
|
41
|
-
return false;
|
|
42
|
-
if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i]))
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
return diffCount === 1;
|
|
31
|
+
return isSamePageFamily(urlA, urlB);
|
|
46
32
|
}
|
|
47
33
|
export function getPlannedByStateHash(hash) {
|
|
48
34
|
for (const record of planRegistry.values()) {
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -127,7 +127,7 @@ export class Planner extends PlannerBase {
|
|
|
127
127
|
}
|
|
128
128
|
const actionResult = ActionResult.fromState(state);
|
|
129
129
|
const combinedHtml = await actionResult.combinedHtml();
|
|
130
|
-
const similarHash = await findSimilarStateHash(combinedHtml);
|
|
130
|
+
const similarHash = await findSimilarStateHash(combinedHtml, state.url);
|
|
131
131
|
if (similarHash) {
|
|
132
132
|
const planned = getPlannedByStateHash(similarHash);
|
|
133
133
|
if (planned) {
|
|
@@ -13,6 +13,8 @@ export declare class Provider {
|
|
|
13
13
|
otelSdk: NodeSDK | null;
|
|
14
14
|
defaultRetryOptions: RetryOptions;
|
|
15
15
|
lastConversation: Conversation | null;
|
|
16
|
+
activeModelCalls: number;
|
|
17
|
+
modelCallWaiters: (() => void)[];
|
|
16
18
|
constructor(config: AIConfig);
|
|
17
19
|
validateConnection(): Promise<void>;
|
|
18
20
|
getModelForAgent(agentName?: string): any;
|
|
@@ -23,6 +25,7 @@ export declare class Provider {
|
|
|
23
25
|
getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined;
|
|
24
26
|
getReasoningForAgent(agentName?: string): string | undefined;
|
|
25
27
|
getRetryOptions(options?: any): RetryOptions;
|
|
28
|
+
withModelRequestSlot<T>(fn: () => Promise<T>): Promise<T>;
|
|
26
29
|
mergeProviderOptions(config: Record<string, any>, agentName?: string): Record<string, any>;
|
|
27
30
|
finalizeConfig(config: Record<string, any>, options: any, telemetry: any): void;
|
|
28
31
|
buildGenerateConfig(defaults: Record<string, any>, overrides: Record<string, any>, options: any): Record<string, any>;
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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';
|
|
6
|
+
import { z } from 'zod';
|
|
5
7
|
import { clearActivity, setActivity } from "../activity.js";
|
|
6
8
|
import { configuredModels, modelName as getModelName } from '../config.js';
|
|
7
9
|
import { executionController } from "../execution-controller.js";
|
|
@@ -10,7 +12,7 @@ import { Stats } from "../stats.js";
|
|
|
10
12
|
import { createDebug, tag } from '../utils/logger.js';
|
|
11
13
|
import { withRetry } from '../utils/retry.js';
|
|
12
14
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
13
|
-
import { Conversation, toToolExecution } from './conversation.js';
|
|
15
|
+
import { Conversation, NARRATION_TOOL, toToolExecution } from './conversation.js';
|
|
14
16
|
const debugLog = createDebug('explorbot:provider');
|
|
15
17
|
const promptLog = createDebug('explorbot:provider:out');
|
|
16
18
|
const responseLog = createDebug('explorbot:provider:in');
|
|
@@ -18,6 +20,16 @@ class AiError extends Error {
|
|
|
18
20
|
}
|
|
19
21
|
export class ContextLengthError extends Error {
|
|
20
22
|
}
|
|
23
|
+
const DEFAULT_PARALLEL_REQUESTS = 4;
|
|
24
|
+
const modelSlotContext = new AsyncLocalStorage();
|
|
25
|
+
const HARMONY_CHANNELS = ['commentary', 'analysis', 'final'];
|
|
26
|
+
function createHarmonyChannelFallbackTool() {
|
|
27
|
+
return tool({
|
|
28
|
+
description: 'Internal compatibility fallback for model channel output. Do not call directly.',
|
|
29
|
+
inputSchema: z.record(z.string(), z.any()),
|
|
30
|
+
execute: async () => ({ message: 'Noted. Continue with your next action.' }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
21
33
|
let telemetryRegistered = false;
|
|
22
34
|
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'];
|
|
23
35
|
function extractCachedTokens(usage) {
|
|
@@ -73,6 +85,8 @@ export class Provider {
|
|
|
73
85
|
},
|
|
74
86
|
};
|
|
75
87
|
lastConversation = null;
|
|
88
|
+
activeModelCalls = 0;
|
|
89
|
+
modelCallWaiters = [];
|
|
76
90
|
constructor(config) {
|
|
77
91
|
if (!config?.model) {
|
|
78
92
|
throw new AiError('AI model is not configured. Set ai.model in your config file.');
|
|
@@ -140,9 +154,31 @@ export class Provider {
|
|
|
140
154
|
getRetryOptions(options = {}) {
|
|
141
155
|
return {
|
|
142
156
|
...this.defaultRetryOptions,
|
|
143
|
-
maxAttempts: options.maxRetries || this.defaultRetryOptions.maxAttempts,
|
|
157
|
+
maxAttempts: options.maxRetries || this.config.retryAttempts || this.defaultRetryOptions.maxAttempts,
|
|
158
|
+
baseDelay: this.config.retryDelay || this.defaultRetryOptions.baseDelay,
|
|
144
159
|
};
|
|
145
160
|
}
|
|
161
|
+
async withModelRequestSlot(fn) {
|
|
162
|
+
if (modelSlotContext.getStore())
|
|
163
|
+
return fn();
|
|
164
|
+
const limit = Math.max(1, this.config.maxParallelRequests ?? DEFAULT_PARALLEL_REQUESTS);
|
|
165
|
+
if (this.activeModelCalls >= limit || this.modelCallWaiters.length > 0) {
|
|
166
|
+
await new Promise((resolve) => this.modelCallWaiters.push(resolve));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
this.activeModelCalls++;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return await modelSlotContext.run(true, fn);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
const next = this.modelCallWaiters.shift();
|
|
176
|
+
if (next)
|
|
177
|
+
next();
|
|
178
|
+
else
|
|
179
|
+
this.activeModelCalls--;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
146
182
|
mergeProviderOptions(config, agentName) {
|
|
147
183
|
if (!agentName)
|
|
148
184
|
return config;
|
|
@@ -269,7 +305,7 @@ export class Provider {
|
|
|
269
305
|
const toolCalls = response.toolCalls || [];
|
|
270
306
|
const toolResults = response.toolResults || [];
|
|
271
307
|
const resultsById = new Map(toolResults.map((r) => [r.toolCallId, r]));
|
|
272
|
-
const toolExecutions = toolCalls.map((call) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
308
|
+
const toolExecutions = toolCalls.filter((call) => call.toolName !== NARRATION_TOOL).map((call) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
273
309
|
return { conversation, response, toolExecutions };
|
|
274
310
|
}
|
|
275
311
|
async chat(messages, model, options = {}) {
|
|
@@ -279,7 +315,7 @@ export class Provider {
|
|
|
279
315
|
const config = this.buildGenerateConfig({ maxOutputTokens: 16384 }, { model, abortSignal: executionController.getAbortSignal() }, options);
|
|
280
316
|
promptLog(messages[messages.length - 1].content);
|
|
281
317
|
try {
|
|
282
|
-
const response = await withRetry(async () => {
|
|
318
|
+
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
283
319
|
const result = await generateText({ messages, ...config });
|
|
284
320
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
285
321
|
if (!result.text) {
|
|
@@ -293,7 +329,7 @@ export class Provider {
|
|
|
293
329
|
debugLog('finishReason=length, response may be truncated');
|
|
294
330
|
}
|
|
295
331
|
return result;
|
|
296
|
-
}, this.getRetryOptions(options));
|
|
332
|
+
}, this.getRetryOptions(options)));
|
|
297
333
|
clearActivity();
|
|
298
334
|
responseLog(response.text);
|
|
299
335
|
return response;
|
|
@@ -318,7 +354,8 @@ export class Provider {
|
|
|
318
354
|
const modelName = getModelName(model);
|
|
319
355
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
320
356
|
promptLog(`Using model: ${modelName}`);
|
|
321
|
-
const
|
|
357
|
+
const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
358
|
+
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
322
359
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
323
360
|
promptLog('Available tools:', toolNames);
|
|
324
361
|
promptLog(messages[messages.length - 1].content);
|
|
@@ -327,9 +364,9 @@ export class Provider {
|
|
|
327
364
|
const stopConditions = [isStepCount(maxRoundtrips)];
|
|
328
365
|
if (extraStop)
|
|
329
366
|
stopConditions.push(extraStop);
|
|
330
|
-
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
|
|
367
|
+
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
331
368
|
try {
|
|
332
|
-
const response = await withRetry(async () => {
|
|
369
|
+
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
333
370
|
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000));
|
|
334
371
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
335
372
|
const hasToolCall = (result.toolCalls?.length || 0) > 0;
|
|
@@ -337,7 +374,7 @@ export class Provider {
|
|
|
337
374
|
throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
|
|
338
375
|
}
|
|
339
376
|
return result;
|
|
340
|
-
}, this.getRetryOptions(options));
|
|
377
|
+
}, this.getRetryOptions(options)));
|
|
341
378
|
clearActivity();
|
|
342
379
|
// Log tool usage summary
|
|
343
380
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
@@ -376,9 +413,9 @@ export class Provider {
|
|
|
376
413
|
const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
|
|
377
414
|
try {
|
|
378
415
|
promptLog(messages[messages.length - 1].content);
|
|
379
|
-
const response = await withRetry(async () => {
|
|
416
|
+
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
380
417
|
return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000));
|
|
381
|
-
}, this.getRetryOptions(options));
|
|
418
|
+
}, this.getRetryOptions(options)));
|
|
382
419
|
clearActivity();
|
|
383
420
|
responseLog(response.object);
|
|
384
421
|
this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
|
|
@@ -512,7 +549,6 @@ export class Provider {
|
|
|
512
549
|
throw new Error('Vision model not configured. Please set ai.visionModel in your config.');
|
|
513
550
|
}
|
|
514
551
|
setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
|
|
515
|
-
const imageData = `data:image/png;base64,${image.toString()}`;
|
|
516
552
|
const messages = [
|
|
517
553
|
{
|
|
518
554
|
role: 'user',
|
|
@@ -524,7 +560,7 @@ export class Provider {
|
|
|
524
560
|
{
|
|
525
561
|
type: 'file',
|
|
526
562
|
mediaType: 'image/png',
|
|
527
|
-
data:
|
|
563
|
+
data: image,
|
|
528
564
|
},
|
|
529
565
|
],
|
|
530
566
|
},
|
|
@@ -540,12 +576,12 @@ export class Provider {
|
|
|
540
576
|
config.telemetry = telemetry;
|
|
541
577
|
try {
|
|
542
578
|
promptLog(`Processing image with prompt: ${prompt}`);
|
|
543
|
-
const response = await withRetry(async () => {
|
|
579
|
+
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
544
580
|
return await generateText({
|
|
545
581
|
messages,
|
|
546
582
|
...config,
|
|
547
583
|
});
|
|
548
|
-
}, this.getRetryOptions());
|
|
584
|
+
}, this.getRetryOptions()));
|
|
549
585
|
clearActivity();
|
|
550
586
|
responseLog(response.text);
|
|
551
587
|
this.recordUsage('vision', getModelName(this.config.visionModel), response.usage);
|
|
@@ -559,7 +595,34 @@ export class Provider {
|
|
|
559
595
|
}
|
|
560
596
|
}
|
|
561
597
|
hasVision() {
|
|
562
|
-
return this.config.visionModel !== undefined;
|
|
598
|
+
return this.config.visionModel !== undefined && !Stats.visionDisabled;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function repairToolCall(options) {
|
|
602
|
+
if (options.toolCall.toolName.includes('<|channel|>'))
|
|
603
|
+
return repairChannelMarker(options);
|
|
604
|
+
return repairHarmonyChannel(options);
|
|
605
|
+
}
|
|
606
|
+
function repairChannelMarker({ toolCall, tools }) {
|
|
607
|
+
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
608
|
+
if (markerIndex <= 0)
|
|
609
|
+
return null;
|
|
610
|
+
const toolName = toolCall.toolName.slice(0, markerIndex);
|
|
611
|
+
if (!tools[toolName])
|
|
612
|
+
return null;
|
|
613
|
+
tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
|
|
614
|
+
return { ...toolCall, toolName };
|
|
615
|
+
}
|
|
616
|
+
function repairHarmonyChannel({ toolCall, tools }) {
|
|
617
|
+
if (!HARMONY_CHANNELS.includes(toolCall.toolName))
|
|
618
|
+
return null;
|
|
619
|
+
if (!tools.commentary)
|
|
620
|
+
return null;
|
|
621
|
+
let input = toolCall.input;
|
|
622
|
+
if (typeof input !== 'string' || !input.trim().startsWith('{')) {
|
|
623
|
+
input = JSON.stringify({ content: typeof input === 'string' ? input : JSON.stringify(input ?? null) });
|
|
563
624
|
}
|
|
625
|
+
tag('warning').log(`Repaired tool name '${toolCall.toolName}' → 'commentary'`);
|
|
626
|
+
return { ...toolCall, toolName: NARRATION_TOOL, input };
|
|
564
627
|
}
|
|
565
628
|
export { AiError, Provider as AIProvider };
|
|
@@ -3,6 +3,11 @@ export declare function reportResearch(hash: string, text: string): void;
|
|
|
3
3
|
export declare function clearResearchCache(): void;
|
|
4
4
|
export declare function getCachedResearch(hash: string): string;
|
|
5
5
|
export declare function getPreviousResearch(hash: string): string;
|
|
6
|
-
export declare function saveResearch(
|
|
7
|
-
export declare function findSimilarResearch(combinedHtml: string): Promise<string | null>;
|
|
8
|
-
export declare function findSimilarStateHash(combinedHtml: string): Promise<string | null>;
|
|
6
|
+
export declare function saveResearch(state: ResearchState, text: string, combinedHtml?: string): string;
|
|
7
|
+
export declare function findSimilarResearch(combinedHtml: string, url?: string): Promise<string | null>;
|
|
8
|
+
export declare function findSimilarStateHash(combinedHtml: string, url?: string): Promise<string | null>;
|
|
9
|
+
type ResearchState = {
|
|
10
|
+
hash: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
@@ -55,7 +55,8 @@ export function getPreviousResearch(hash) {
|
|
|
55
55
|
return '';
|
|
56
56
|
return readFileSync(researchFile, 'utf8');
|
|
57
57
|
}
|
|
58
|
-
export function saveResearch(
|
|
58
|
+
export function saveResearch(state, text, combinedHtml) {
|
|
59
|
+
const { hash, url } = state;
|
|
59
60
|
const researchDir = outputPath('research');
|
|
60
61
|
const researchFile = join(researchDir, `${hash}.md`);
|
|
61
62
|
if (!existsSync(researchDir))
|
|
@@ -70,12 +71,15 @@ export function saveResearch(hash, text, combinedHtml) {
|
|
|
70
71
|
mkdirSync(statesDir, { recursive: true });
|
|
71
72
|
const fingerprint = computeHtmlFingerprint(combinedHtml);
|
|
72
73
|
const fingerprintFile = join(statesDir, `${hash}.fingerprint`);
|
|
73
|
-
|
|
74
|
+
const record = { entries: fingerprint };
|
|
75
|
+
if (url)
|
|
76
|
+
record.url = url;
|
|
77
|
+
writeFileSync(fingerprintFile, JSON.stringify(record));
|
|
74
78
|
debugLog(`Fingerprint saved to ${fingerprintFile}`);
|
|
75
79
|
}
|
|
76
80
|
return researchFile;
|
|
77
81
|
}
|
|
78
|
-
function findSimilarMatch(combinedHtml) {
|
|
82
|
+
function findSimilarMatch(combinedHtml, url) {
|
|
79
83
|
const statesDir = getStatesDir();
|
|
80
84
|
if (!existsSync(statesDir))
|
|
81
85
|
return Promise.resolve(null);
|
|
@@ -93,23 +97,24 @@ function findSimilarMatch(combinedHtml) {
|
|
|
93
97
|
return;
|
|
94
98
|
}
|
|
95
99
|
debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
|
|
96
|
-
resolve({ hash: matchHash, similarity });
|
|
100
|
+
resolve({ hash: matchHash, similarity, url: data.url });
|
|
97
101
|
});
|
|
98
102
|
worker.postMessage({
|
|
99
103
|
html: combinedHtml,
|
|
100
104
|
statesDir,
|
|
101
105
|
maxAgeMs: FINGERPRINT_MAX_AGE_MS,
|
|
102
106
|
threshold: SIMILARITY_THRESHOLD,
|
|
107
|
+
url,
|
|
103
108
|
});
|
|
104
109
|
});
|
|
105
110
|
}
|
|
106
|
-
export async function findSimilarResearch(combinedHtml) {
|
|
107
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
111
|
+
export async function findSimilarResearch(combinedHtml, url) {
|
|
112
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
108
113
|
if (!match)
|
|
109
114
|
return null;
|
|
110
115
|
return getCachedResearch(match.hash) || null;
|
|
111
116
|
}
|
|
112
|
-
export async function findSimilarStateHash(combinedHtml) {
|
|
113
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
117
|
+
export async function findSimilarStateHash(combinedHtml, url) {
|
|
118
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
114
119
|
return match?.hash || null;
|
|
115
120
|
}
|
|
@@ -96,7 +96,7 @@ export function WithDeepAnalysis(Base) {
|
|
|
96
96
|
else {
|
|
97
97
|
updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
|
|
98
98
|
}
|
|
99
|
-
saveResearch(pageStateHash, updated);
|
|
99
|
+
saveResearch({ hash: pageStateHash }, updated);
|
|
100
100
|
tag('substep').log(`Overlay research appended: ${focusArea.name}`);
|
|
101
101
|
return sectionMarkdown;
|
|
102
102
|
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
4
|
import { computeHtmlFingerprint } from "../../utils/html-diff.js";
|
|
5
|
+
import { isSamePageFamily } from "../../utils/url-matcher.js";
|
|
5
6
|
function diceSimilarity(a, b) {
|
|
6
7
|
let intersection = 0;
|
|
7
8
|
for (const item of a) {
|
|
@@ -14,7 +15,7 @@ function diceSimilarity(a, b) {
|
|
|
14
15
|
return Math.round(((2 * intersection) / total) * 100);
|
|
15
16
|
}
|
|
16
17
|
parentPort.on('message', (data) => {
|
|
17
|
-
const { html, statesDir, maxAgeMs, threshold } = data;
|
|
18
|
+
const { html, statesDir, maxAgeMs, threshold, url } = data;
|
|
18
19
|
if (!existsSync(statesDir)) {
|
|
19
20
|
parentPort.postMessage({ matchHash: null, similarity: 0 });
|
|
20
21
|
return;
|
|
@@ -28,19 +29,35 @@ parentPort.on('message', (data) => {
|
|
|
28
29
|
const files = readdirSync(statesDir).filter((f) => f.endsWith('.fingerprint'));
|
|
29
30
|
let bestHash = null;
|
|
30
31
|
let bestSimilarity = 0;
|
|
32
|
+
let bestUrl;
|
|
31
33
|
for (const file of files) {
|
|
32
34
|
const filePath = join(statesDir, file);
|
|
33
35
|
const mtime = statSync(filePath).mtimeMs;
|
|
34
36
|
if (now - mtime > maxAgeMs)
|
|
35
37
|
continue;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
+
const record = readFingerprint(filePath);
|
|
39
|
+
if (url && record.url && !isSamePageFamily(url, record.url))
|
|
40
|
+
continue;
|
|
41
|
+
const storedFingerprint = new Set(record.entries);
|
|
38
42
|
const similarity = diceSimilarity(currentFingerprint, storedFingerprint);
|
|
39
43
|
if (similarity > bestSimilarity) {
|
|
40
44
|
bestSimilarity = similarity;
|
|
41
45
|
bestHash = file.replace('.fingerprint', '');
|
|
46
|
+
bestUrl = record.url;
|
|
42
47
|
}
|
|
43
48
|
}
|
|
44
49
|
const matched = bestSimilarity >= threshold;
|
|
45
|
-
parentPort.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity });
|
|
50
|
+
parentPort.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
|
|
46
51
|
});
|
|
52
|
+
function readFingerprint(filePath) {
|
|
53
|
+
const content = readFileSync(filePath, 'utf8');
|
|
54
|
+
try {
|
|
55
|
+
const record = JSON.parse(content);
|
|
56
|
+
if (Array.isArray(record.entries))
|
|
57
|
+
return record;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { entries: content.split('\n').filter(Boolean) };
|
|
61
|
+
}
|
|
62
|
+
return { entries: content.split('\n').filter(Boolean) };
|
|
63
|
+
}
|
|
@@ -72,6 +72,7 @@ export class Researcher extends ResearcherBase {
|
|
|
72
72
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
73
73
|
this.actionResult = ActionResult.fromState(state);
|
|
74
74
|
const stateHash = state.hash || this.actionResult.getStateHash();
|
|
75
|
+
const researchState = { ...state, hash: stateHash };
|
|
75
76
|
if (!force && stateHash) {
|
|
76
77
|
const cached = getCachedResearch(stateHash);
|
|
77
78
|
if (cached) {
|
|
@@ -105,11 +106,11 @@ export class Researcher extends ResearcherBase {
|
|
|
105
106
|
debugLog('Researching web page:', this.actionResult.url);
|
|
106
107
|
const combinedHtml = await this.actionResult.combinedHtml();
|
|
107
108
|
if (!deep && !force) {
|
|
108
|
-
const similar = await findSimilarResearch(combinedHtml);
|
|
109
|
+
const similar = await findSimilarResearch(combinedHtml, state.url);
|
|
109
110
|
if (similar) {
|
|
110
111
|
tag('operation').log('Similar research found, reusing cached result');
|
|
111
112
|
if (stateHash)
|
|
112
|
-
saveResearch(
|
|
113
|
+
saveResearch(researchState, similar, combinedHtml);
|
|
113
114
|
tag('multiline').log(formatResearchSummary(similar));
|
|
114
115
|
tag('success').log('Research complete (reused)');
|
|
115
116
|
await this.hooksRunner.runAfterHook('researcher', state.url);
|
|
@@ -238,7 +239,7 @@ export class Researcher extends ResearcherBase {
|
|
|
238
239
|
result.cleanup();
|
|
239
240
|
let researchFile = null;
|
|
240
241
|
if (stateHash) {
|
|
241
|
-
researchFile = saveResearch(
|
|
242
|
+
researchFile = saveResearch(researchState, result.text, combinedHtml);
|
|
242
243
|
}
|
|
243
244
|
const summaryText = mdq(result.text).query('section2(/^summary/)').query('paragraph[0]').text().trim();
|
|
244
245
|
const summaryLine = summaryText.split('\n')[0]?.trim().slice(0, 200);
|