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.
Files changed (89) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/conversation.d.ts +1 -0
  17. package/dist/src/ai/conversation.js +3 -0
  18. package/dist/src/ai/driller.js +6 -2
  19. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  20. package/dist/src/ai/fisherman-tools.js +39 -0
  21. package/dist/src/ai/fisherman.js +3 -2
  22. package/dist/src/ai/navigator.d.ts +2 -1
  23. package/dist/src/ai/navigator.js +5 -9
  24. package/dist/src/ai/pilot.js +51 -29
  25. package/dist/src/ai/planner/subpages.js +2 -16
  26. package/dist/src/ai/planner.js +1 -1
  27. package/dist/src/ai/provider.d.ts +3 -0
  28. package/dist/src/ai/provider.js +80 -17
  29. package/dist/src/ai/researcher/cache.d.ts +8 -3
  30. package/dist/src/ai/researcher/cache.js +13 -8
  31. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  32. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  33. package/dist/src/ai/researcher.js +4 -3
  34. package/dist/src/ai/rules.js +1 -5
  35. package/dist/src/ai/tester.d.ts +1 -1
  36. package/dist/src/ai/tester.js +34 -26
  37. package/dist/src/ai/tools.d.ts +8 -5
  38. package/dist/src/ai/tools.js +79 -56
  39. package/dist/src/commands/explore-command.js +22 -17
  40. package/dist/src/commands/init-command.js +13 -20
  41. package/dist/src/config.d.ts +2 -1
  42. package/dist/src/config.js +3 -1
  43. package/dist/src/experience-tracker.d.ts +2 -0
  44. package/dist/src/experience-tracker.js +12 -0
  45. package/dist/src/explorbot.js +1 -1
  46. package/dist/src/playwright-recorder.js +6 -12
  47. package/dist/src/test-plan.d.ts +8 -0
  48. package/dist/src/test-plan.js +11 -0
  49. package/dist/src/utils/html-diff.d.ts +5 -0
  50. package/dist/src/utils/html-diff.js +65 -6
  51. package/dist/src/utils/strings.d.ts +2 -0
  52. package/dist/src/utils/strings.js +32 -0
  53. package/dist/src/utils/url-matcher.d.ts +1 -0
  54. package/dist/src/utils/url-matcher.js +31 -2
  55. package/docs/basics/getting-started.md +33 -10
  56. package/docs/basics/providers.md +6 -4
  57. package/docs/contributing/npm-package.md +73 -4
  58. package/models.json +4 -4
  59. package/package.json +6 -2
  60. package/src/action-result.ts +61 -16
  61. package/src/action.ts +51 -17
  62. package/src/ai/captain/web-mode.ts +1 -2
  63. package/src/ai/captain.ts +9 -1
  64. package/src/ai/conversation.ts +3 -0
  65. package/src/ai/driller.ts +6 -2
  66. package/src/ai/fisherman-tools.ts +35 -0
  67. package/src/ai/fisherman.ts +3 -2
  68. package/src/ai/navigator.ts +6 -10
  69. package/src/ai/pilot.ts +54 -32
  70. package/src/ai/planner/subpages.ts +2 -13
  71. package/src/ai/planner.ts +1 -1
  72. package/src/ai/provider.ts +111 -41
  73. package/src/ai/researcher/cache.ts +17 -9
  74. package/src/ai/researcher/deep-analysis.ts +1 -1
  75. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  76. package/src/ai/researcher.ts +4 -3
  77. package/src/ai/rules.ts +1 -5
  78. package/src/ai/tester.ts +32 -27
  79. package/src/ai/tools.ts +84 -60
  80. package/src/commands/explore-command.ts +17 -14
  81. package/src/commands/init-command.ts +14 -20
  82. package/src/config.ts +4 -2
  83. package/src/experience-tracker.ts +13 -0
  84. package/src/explorbot.ts +1 -1
  85. package/src/playwright-recorder.ts +6 -11
  86. package/src/test-plan.ts +18 -0
  87. package/src/utils/html-diff.ts +72 -7
  88. package/src/utils/strings.ts +36 -0
  89. package/src/utils/url-matcher.ts +27 -2
@@ -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
- const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
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', 'context'];
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
- If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
469
- raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
470
- steps 1–3, then…") and call out anywhere your scenario diverges from it.
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: string | null = null;
568
- if (finalState?.screenshot && this.provider.hasVision()) image = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
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: 'Fisherman not available' };
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) => CHECK_TOOLS.includes(t.toolName) && t.wasSuccessful);
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(`PASS state verification: ${assertion}`);
1009
+ if (passed) lines.push(`state verification (passed): ${assertion}`);
1003
1010
  }
1004
1011
 
1005
1012
  for (const exec of testerConversation.getToolExecutions()) {
1006
- if (!CHECK_TOOLS.includes(exec.toolName) || !exec.wasSuccessful) continue;
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(`PASS ${exec.toolName}: ${description}${result ? ` -> ${result}` : ''}`);
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 ATTACH_HTML, ATTACH_ARIA, or ATTACH_UI_MAP — only when recent actions show failures.
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, visualClick,
1122
- back, getVisitedStates, reset, stop, finish, record.
1123
- Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
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 { isDynamicSegment } from '../../utils/url-matcher.ts';
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
- const partsA = normalizeUrl(urlA).split('/');
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) {
@@ -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 { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
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 withRetry(async () => {
320
- const result = await generateText({ messages, ...config });
321
- this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
322
- if (!result.text) {
323
- debugLog(result);
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
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
367
+ debugLog('finishReason=length, response may be truncated');
326
368
  }
327
- throw new Error('No response text from AI');
328
- }
329
- if (result.finishReason === 'length') {
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 toolNames = Object.keys(tools || {});
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 withRetry(async () => {
371
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
372
- this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
373
- const hasToolCall = (result.toolCalls?.length || 0) > 0;
374
- if (!result.text && !hasToolCall && result.finishReason === 'length') {
375
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
376
- }
377
- return result;
378
- }, this.getRetryOptions(options));
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 withRetry(async () => {
422
- return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
423
- }, this.getRetryOptions(options));
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: imageData,
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 withRetry(async () => {
606
- return await generateText({
607
- messages,
608
- ...config,
609
- });
610
- }, this.getRetryOptions());
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(hash: string, text: string, combinedHtml?: string): string {
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
- writeFileSync(fingerprintFile, fingerprint.join('\n'));
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<{ hash: string; similarity: number } | null> {
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
  }