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
@@ -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', 'context'];
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
- If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
415
- raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
416
- steps 1–3, then…") and call out anywhere your scenario diverges from it.
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
- `, 'pilot.reviewNewPage', { task });
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 = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
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: 'Fisherman not available' };
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) => CHECK_TOOLS.includes(t.toolName) && t.wasSuccessful);
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(`PASS state verification: ${assertion}`);
911
+ lines.push(`state verification (passed): ${assertion}`);
903
912
  }
904
913
  for (const exec of testerConversation.getToolExecutions()) {
905
- if (!CHECK_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
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(`PASS ${exec.toolName}: ${description}${result ? ` -> ${result}` : ''}`);
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 ATTACH_HTML, ATTACH_ARIA, or ATTACH_UI_MAP — only when recent actions show failures.
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, visualClick,
1013
- back, getVisitedStates, reset, stop, finish, record.
1014
- Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
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 { isDynamicSegment } from "../../utils/url-matcher.js";
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
- const partsA = normalizeUrl(urlA).split('/');
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()) {
@@ -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>;
@@ -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 { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
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 toolNames = Object.keys(tools || {});
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: imageData,
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(hash: string, text: string, combinedHtml?: string): string;
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(hash, text, combinedHtml) {
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
- writeFileSync(fingerprintFile, fingerprint.join('\n'));
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 lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
37
- const storedFingerprint = new Set(lines);
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(stateHash, similar, combinedHtml);
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(stateHash, result.text, combinedHtml);
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);