explorbot 0.3.0 → 0.3.2

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 (43) hide show
  1. package/bin/explorbot-cli.ts +3 -3
  2. package/dist/bin/explorbot-cli.js +3 -2
  3. package/dist/package.json +1 -1
  4. package/dist/src/action-result.d.ts +3 -0
  5. package/dist/src/action-result.js +3 -0
  6. package/dist/src/action.d.ts +1 -0
  7. package/dist/src/action.js +12 -1
  8. package/dist/src/ai/conversation.d.ts +1 -0
  9. package/dist/src/ai/conversation.js +3 -0
  10. package/dist/src/ai/driller.js +3 -1
  11. package/dist/src/ai/fisherman.js +1 -1
  12. package/dist/src/ai/pilot.js +14 -9
  13. package/dist/src/ai/provider.d.ts +5 -0
  14. package/dist/src/ai/provider.js +84 -19
  15. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  16. package/dist/src/ai/tester.js +22 -6
  17. package/dist/src/commands/exit-command.js +1 -1
  18. package/dist/src/commands/explore-command.js +22 -17
  19. package/dist/src/config.d.ts +2 -1
  20. package/dist/src/explorbot.js +1 -0
  21. package/dist/src/state-manager.d.ts +2 -0
  22. package/dist/src/state-manager.js +3 -3
  23. package/dist/src/utils/html.d.ts +2 -1
  24. package/dist/src/utils/html.js +10 -4
  25. package/dist/src/utils/overlay.d.ts +24 -0
  26. package/dist/src/utils/overlay.js +43 -0
  27. package/package.json +1 -1
  28. package/src/action-result.ts +5 -0
  29. package/src/action.ts +15 -1
  30. package/src/ai/conversation.ts +3 -0
  31. package/src/ai/driller.ts +3 -1
  32. package/src/ai/fisherman.ts +1 -1
  33. package/src/ai/pilot.ts +15 -10
  34. package/src/ai/provider.ts +115 -43
  35. package/src/ai/researcher/deep-analysis.ts +2 -2
  36. package/src/ai/tester.ts +20 -7
  37. package/src/commands/exit-command.ts +1 -1
  38. package/src/commands/explore-command.ts +17 -14
  39. package/src/config.ts +2 -1
  40. package/src/explorbot.ts +1 -0
  41. package/src/state-manager.ts +4 -3
  42. package/src/utils/html.ts +13 -4
  43. package/src/utils/overlay.ts +51 -0
@@ -9,6 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
+ import { flushTelemetry } from '../src/ai/provider.js';
12
13
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
14
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
14
15
  import { remote } from '../src/remote.js';
@@ -103,9 +104,7 @@ async function startTUI(explorBot: ExplorBot): Promise<void> {
103
104
  async function showStatsAndExit(code: number): Promise<never> {
104
105
  if (remote.isAttached()) {
105
106
  await remote.close(code);
106
- process.exit(code);
107
- }
108
- if (Stats.hasActivity()) {
107
+ } else if (Stats.hasActivity()) {
109
108
  await new Promise<void>((resolve) => {
110
109
  const { unmount } = render(
111
110
  React.createElement(StatusPane, {
@@ -121,6 +120,7 @@ async function showStatsAndExit(code: number): Promise<never> {
121
120
  );
122
121
  });
123
122
  }
123
+ await flushTelemetry();
124
124
  process.exit(code);
125
125
  }
126
126
 
@@ -9,6 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
+ import { flushTelemetry } from '../src/ai/provider.js';
12
13
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
14
  import { ExplorBot } from '../src/explorbot.js';
14
15
  import { remote } from '../src/remote.js';
@@ -78,9 +79,8 @@ async function startTUI(explorBot) {
78
79
  async function showStatsAndExit(code) {
79
80
  if (remote.isAttached()) {
80
81
  await remote.close(code);
81
- process.exit(code);
82
82
  }
83
- if (Stats.hasActivity()) {
83
+ else if (Stats.hasActivity()) {
84
84
  await new Promise((resolve) => {
85
85
  const { unmount } = render(React.createElement(StatusPane, {
86
86
  onComplete: () => {
@@ -93,6 +93,7 @@ async function showStatsAndExit(code) {
93
93
  });
94
94
  });
95
95
  }
96
+ await flushTelemetry();
96
97
  process.exit(code);
97
98
  }
98
99
  addCommonOptions(program.command('start [path]').description('Start web exploration')).action(async (startPath, options) => {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -2,6 +2,7 @@ import { type HtmlConfig } from './config.js';
2
2
  import type { Link, WebPageState } from './state-manager.js';
3
3
  import { TTLCache } from './utils/cache.js';
4
4
  import { type HtmlDiffPart, type HtmlDiffResult } from './utils/html-diff.js';
5
+ import { Overlay } from './utils/overlay.js';
5
6
  interface ActionResultData extends WebPageState {
6
7
  html?: string;
7
8
  fullUrl?: string | undefined;
@@ -28,6 +29,7 @@ interface ActionResultData extends WebPageState {
28
29
  focusedElement?: FocusedElement | null;
29
30
  iframeURL?: string;
30
31
  links?: Link[];
32
+ overlayHtml?: string;
31
33
  }
32
34
  export interface PageDiff {
33
35
  urlChanged: boolean;
@@ -81,6 +83,7 @@ export declare class ActionResult implements ActionResultData {
81
83
  notes: string[];
82
84
  links: Link[];
83
85
  verifications?: Record<string, boolean>;
86
+ overlay: Overlay;
84
87
  constructor(data: ActionResultData);
85
88
  get hash(): string;
86
89
  get html(): string;
@@ -5,6 +5,7 @@ import { TTLCache } from "./utils/cache.js";
5
5
  import { htmlDiff, liveRegionMessages } from "./utils/html-diff.js";
6
6
  import { extractHeadings, extractLinks, extractTargetedHtml, htmlCombinedSnapshot, htmlMinimalUISnapshot, htmlTextSnapshot, minifyHtml } from "./utils/html.js";
7
7
  import { createDebug } from "./utils/logger.js";
8
+ import { Overlay } from "./utils/overlay.js";
8
9
  import { slugify } from "./utils/strings.js";
9
10
  import { extractStatePath, matchesUrl } from "./utils/url-matcher.js";
10
11
  const debugLog = createDebug('explorbot:state');
@@ -37,6 +38,7 @@ export class ActionResult {
37
38
  notes = [];
38
39
  links = [];
39
40
  verifications;
41
+ overlay = new Overlay();
40
42
  constructor(data) {
41
43
  this.id = data.id;
42
44
  this.timestamp = data.timestamp ?? new Date();
@@ -77,6 +79,7 @@ export class ActionResult {
77
79
  if (data.ariaSnapshot !== undefined) {
78
80
  this._ariaSnapshot = data.ariaSnapshot;
79
81
  }
82
+ this.overlay = Overlay.resolve(data);
80
83
  if (!this.fullUrl && this.url) {
81
84
  this.fullUrl = this.url;
82
85
  }
@@ -32,6 +32,7 @@ declare class Action {
32
32
  includeScreenshot?: boolean;
33
33
  codeBlock?: string;
34
34
  }): Promise<ActionResult>;
35
+ captureOverlayHtml(): Promise<string>;
35
36
  captureMainDocumentStatus(): Promise<number | undefined>;
36
37
  captureResponses(): () => void;
37
38
  recordNetworkCall(request: any, status: number): void;
@@ -8,8 +8,9 @@ import { clearActivity, setActivity } from "./activity.js";
8
8
  import { ConfigParser, outputPath } from './config.js';
9
9
  import { Observability } from "./observability.js";
10
10
  import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
11
- import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
11
+ import { captureHtmlForSnapshot, getVisibleOverlayHtmlExtractorSource, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
12
12
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
13
+ import { Overlay } from './utils/overlay.js';
13
14
  import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
14
15
  import { safeFilename } from "./utils/strings.js";
15
16
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from "./utils/web-sandbox.js";
@@ -131,10 +132,13 @@ class Action {
131
132
  let ariaSnapshot = null;
132
133
  let ariaSnapshotFile = undefined;
133
134
  let focusedElement = null;
135
+ let overlayHtml = '';
134
136
  try {
135
137
  const page = this.playwrightHelper.page;
136
138
  ariaSnapshot = await page.locator('body').ariaSnapshot();
137
139
  focusedElement = await page.evaluate(readFocusedElement);
140
+ if (!frame)
141
+ overlayHtml = await this.captureOverlayHtml();
138
142
  }
139
143
  catch (err) {
140
144
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
@@ -161,6 +165,7 @@ class Action {
161
165
  ariaSnapshot,
162
166
  ariaSnapshotFile,
163
167
  focusedElement,
168
+ overlayHtml: overlayHtml || undefined,
164
169
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
165
170
  });
166
171
  this.stateManager.updateState(result, codeBlock);
@@ -175,6 +180,12 @@ class Action {
175
180
  return new ActionResult({ url, error: msg });
176
181
  }
177
182
  }
183
+ async captureOverlayHtml() {
184
+ return this.playwrightHelper.page.evaluate(({ extractorSource, config }) => {
185
+ const extract = new Function(`return ${extractorSource}`)();
186
+ return extract(config);
187
+ }, { extractorSource: getVisibleOverlayHtmlExtractorSource(), config: Overlay.captureConfig() });
188
+ }
178
189
  async captureMainDocumentStatus() {
179
190
  if (this.mainDocumentStatus)
180
191
  return this.mainDocumentStatus;
@@ -7,6 +7,7 @@ export interface ToolExecution {
7
7
  }
8
8
  export declare function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution;
9
9
  export declare function toolExecutionLabel(input: Record<string, any> | undefined): string;
10
+ export declare const NARRATION_TOOL = "commentary";
10
11
  export declare class Conversation {
11
12
  messages: ModelMessage[];
12
13
  model: any;
@@ -7,6 +7,7 @@ export function toToolExecution(toolName, input, rawOutput) {
7
7
  export function toolExecutionLabel(input) {
8
8
  return input?.explanation || input?.assertion || input?.reason || input?.request || '';
9
9
  }
10
+ export const NARRATION_TOOL = 'commentary';
10
11
  const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
11
12
  const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
12
13
  export class Conversation {
@@ -203,6 +204,8 @@ export class Conversation {
203
204
  for (const part of message.content) {
204
205
  if (part.type !== 'tool-result')
205
206
  continue;
207
+ if (part.toolName === NARRATION_TOOL)
208
+ continue;
206
209
  executions.push(toToolExecution(part.toolName, toolCalls.get(part.toolCallId) || {}, part.output));
207
210
  }
208
211
  }
@@ -9,6 +9,7 @@ import { collectInteractiveNodes } from "../utils/aria.js";
9
9
  import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, getComponentScopeHtmlExtractorSource, getVisibleOverlayHtmlExtractorSource, inferHtmlRole, } from "../utils/html.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  import { loop, pause } from "../utils/loop.js";
12
+ import { OVERLAY_SELECTORS } from "../utils/overlay.js";
12
13
  import { annotatePageElements } from "../utils/web-annotate.js";
13
14
  import { eidxInContainer } from "../utils/web-eidx.js";
14
15
  import { WebElement } from "../utils/web-element.js";
@@ -578,7 +579,8 @@ export class Driller extends TaskAgent {
578
579
  config: {
579
580
  interactiveContentSelector: HTML_SELECTORS.interactiveContent,
580
581
  limits: HTML_EXTRACTION_LIMITS,
581
- overlaySelectors: HTML_SELECTORS.semanticOverlays,
582
+ overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
583
+ overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
582
584
  visibilityLimits: HTML_VISIBILITY_LIMITS,
583
585
  },
584
586
  }));
@@ -158,7 +158,7 @@ export class Fisherman {
158
158
 
159
159
  AVAILABLE TOOLS:
160
160
  ${toolNames.join(', ')}.
161
- Use tool names exactly as listed. Do not invent aliases, combined names, or names with channel markers such as "commentary".
161
+ Use tool names exactly as listed. Do not invent aliases or combined names.
162
162
  Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
163
163
 
164
164
  WORKFLOW:
@@ -5,7 +5,7 @@ import { ActionResult } from "../action-result.js";
5
5
  import { ConfigParser } from "../config.js";
6
6
  import { Stats } from "../stats.js";
7
7
  import { TestResult } from "../test-plan.js";
8
- import { collectInteractiveNodes, detectFocusArea } from "../utils/aria.js";
8
+ import { collectInteractiveNodes } from "../utils/aria.js";
9
9
  import { ErrorPageError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  const debugLog = createDebug('explorbot:pilot');
@@ -13,7 +13,8 @@ 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'];
18
19
  const PILOT_MESSAGE_LIMIT = 2;
19
20
  const PILOT_MESSAGE_MAX_LENGTH = 160;
@@ -280,6 +281,10 @@ export class Pilot {
280
281
  overrides the others — weigh them together. Tester's record() notes are the LEAST reliable; always
281
282
  cross-check against actual actions and state. Visual screenshot analysis is strong for UI state
282
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.
283
288
  If the final page clearly shows an equivalent success state in a different UI form, do not fail only
284
289
  because one narrow assertion targeted a specific badge, count, toast, or wording that the product
285
290
  represents differently.
@@ -499,7 +504,7 @@ export class Pilot {
499
504
  async settleExpectations(task, finalState) {
500
505
  let image = null;
501
506
  if (finalState?.screenshot && this.provider.hasVision())
502
- image = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
507
+ image = finalState.screenshot;
503
508
  const decided = (text) => {
504
509
  if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text))
505
510
  return 'passed';
@@ -733,7 +738,7 @@ export class Pilot {
733
738
  lines.push(`h2: ${state.h2 || ''}`);
734
739
  lines.push(`h3: ${state.h3 || ''}`);
735
740
  lines.push(`h4: ${state.h4 || ''}`);
736
- const focusArea = detectFocusArea(state.ariaSnapshot);
741
+ const focusArea = state.overlay;
737
742
  if (focusArea.detected) {
738
743
  lines.push(`modal: ${focusArea.name || focusArea.type}`);
739
744
  }
@@ -897,20 +902,20 @@ export class Pilot {
897
902
  hasSuccessfulCheckEvidence(currentState, testerConversation) {
898
903
  if (Object.values(currentState.verifications ?? {}).some(Boolean))
899
904
  return true;
900
- 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);
901
906
  }
902
907
  formatSuccessfulAssertions(currentState, testerConversation) {
903
908
  const lines = [];
904
909
  for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
905
910
  if (passed)
906
- lines.push(`PASS state verification: ${assertion}`);
911
+ lines.push(`state verification (passed): ${assertion}`);
907
912
  }
908
913
  for (const exec of testerConversation.getToolExecutions()) {
909
- if (!CHECK_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
914
+ if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
910
915
  continue;
911
916
  const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
912
917
  const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
913
- lines.push(`PASS ${exec.toolName}: ${description}${result ? ` -> ${result}` : ''}`);
918
+ lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
914
919
  }
915
920
  return [...new Set(lines)].join('\n');
916
921
  }
@@ -1028,7 +1033,7 @@ export class Pilot {
1028
1033
 
1029
1034
  Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1030
1035
  visualClick, back, getVisitedStates, reset, stop, finish, record.
1031
- Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
1036
+ Use tool names exactly as listed. Do not invent combined names or aliases.
1032
1037
 
1033
1038
  ${capabilityGroundingRule}
1034
1039
 
@@ -7,14 +7,18 @@ declare class AiError extends Error {
7
7
  }
8
8
  export declare class ContextLengthError extends Error {
9
9
  }
10
+ export declare function flushTelemetry(): Promise<void>;
10
11
  export declare class Provider {
11
12
  config: AIConfig;
12
13
  telemetryEnabled: boolean;
13
14
  otelSdk: NodeSDK | null;
14
15
  defaultRetryOptions: RetryOptions;
15
16
  lastConversation: Conversation | null;
17
+ activeModelCalls: number;
18
+ modelCallWaiters: (() => void)[];
16
19
  constructor(config: AIConfig);
17
20
  validateConnection(): Promise<void>;
21
+ stop(): Promise<void>;
18
22
  getModelForAgent(agentName?: string): any;
19
23
  getAgenticModel(agentName?: string): any;
20
24
  getVisionModel(): any;
@@ -23,6 +27,7 @@ export declare class Provider {
23
27
  getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined;
24
28
  getReasoningForAgent(agentName?: string): string | undefined;
25
29
  getRetryOptions(options?: any): RetryOptions;
30
+ withModelRequestSlot<T>(fn: () => Promise<T>): Promise<T>;
26
31
  mergeProviderOptions(config: Record<string, any>, agentName?: string): Record<string, any>;
27
32
  finalizeConfig(config: Record<string, any>, options: any, telemetry: any): void;
28
33
  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,7 +20,26 @@ 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;
34
+ let beforeExitFlushHooked = false;
35
+ let activeOtelSdk = null;
36
+ export async function flushTelemetry() {
37
+ const sdk = activeOtelSdk;
38
+ activeOtelSdk = null;
39
+ if (!sdk)
40
+ return;
41
+ await sdk.shutdown().catch((error) => debugLog(`Telemetry flush failed: ${error instanceof Error ? error.message : error}`));
42
+ }
22
43
  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
44
  function extractCachedTokens(usage) {
24
45
  if (!usage)
@@ -73,6 +94,8 @@ export class Provider {
73
94
  },
74
95
  };
75
96
  lastConversation = null;
97
+ activeModelCalls = 0;
98
+ modelCallWaiters = [];
76
99
  constructor(config) {
77
100
  if (!config?.model) {
78
101
  throw new AiError('AI model is not configured. Set ai.model in your config file.');
@@ -91,6 +114,9 @@ export class Provider {
91
114
  throw new AiError(`AI connection failed: ${error.message}`);
92
115
  }
93
116
  }
117
+ async stop() {
118
+ await flushTelemetry();
119
+ }
94
120
  getModelForAgent(agentName) {
95
121
  if (!agentName) {
96
122
  return this.config.model;
@@ -140,9 +166,31 @@ export class Provider {
140
166
  getRetryOptions(options = {}) {
141
167
  return {
142
168
  ...this.defaultRetryOptions,
143
- maxAttempts: options.maxRetries || this.defaultRetryOptions.maxAttempts,
169
+ maxAttempts: options.maxRetries || this.config.retryAttempts || this.defaultRetryOptions.maxAttempts,
170
+ baseDelay: this.config.retryDelay || this.defaultRetryOptions.baseDelay,
144
171
  };
145
172
  }
173
+ async withModelRequestSlot(fn) {
174
+ if (modelSlotContext.getStore())
175
+ return fn();
176
+ const limit = Math.max(1, this.config.maxParallelRequests ?? DEFAULT_PARALLEL_REQUESTS);
177
+ if (this.activeModelCalls >= limit || this.modelCallWaiters.length > 0) {
178
+ await new Promise((resolve) => this.modelCallWaiters.push(resolve));
179
+ }
180
+ else {
181
+ this.activeModelCalls++;
182
+ }
183
+ try {
184
+ return await modelSlotContext.run(true, fn);
185
+ }
186
+ finally {
187
+ const next = this.modelCallWaiters.shift();
188
+ if (next)
189
+ next();
190
+ else
191
+ this.activeModelCalls--;
192
+ }
193
+ }
146
194
  mergeProviderOptions(config, agentName) {
147
195
  if (!agentName)
148
196
  return config;
@@ -215,7 +263,12 @@ export class Provider {
215
263
  spanProcessors: [processor],
216
264
  instrumentations: [],
217
265
  });
266
+ activeOtelSdk = this.otelSdk;
218
267
  void this.otelSdk.start();
268
+ if (!beforeExitFlushHooked) {
269
+ process.on('beforeExit', () => void flushTelemetry());
270
+ beforeExitFlushHooked = true;
271
+ }
219
272
  if (!telemetryRegistered) {
220
273
  registerTelemetry(new OpenTelemetry());
221
274
  telemetryRegistered = true;
@@ -257,7 +310,7 @@ export class Provider {
257
310
  }
258
311
  async invokeConversation(conversation, tools, options = {}) {
259
312
  const response = tools ? await this.generateWithTools(conversation.messages, conversation.model, tools, options) : await this.chat(conversation.messages, conversation.model, options);
260
- const responseMessages = response.response?.messages || [];
313
+ const responseMessages = response.responseMessages || [];
261
314
  if (responseMessages.length > 0) {
262
315
  conversation.messages.push(...responseMessages);
263
316
  tag('debug').log('Added', responseMessages.length, 'messages from response');
@@ -269,7 +322,7 @@ export class Provider {
269
322
  const toolCalls = response.toolCalls || [];
270
323
  const toolResults = response.toolResults || [];
271
324
  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));
325
+ const toolExecutions = toolCalls.filter((call) => call.toolName !== NARRATION_TOOL).map((call) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
273
326
  return { conversation, response, toolExecutions };
274
327
  }
275
328
  async chat(messages, model, options = {}) {
@@ -279,7 +332,7 @@ export class Provider {
279
332
  const config = this.buildGenerateConfig({ maxOutputTokens: 16384 }, { model, abortSignal: executionController.getAbortSignal() }, options);
280
333
  promptLog(messages[messages.length - 1].content);
281
334
  try {
282
- const response = await withRetry(async () => {
335
+ const response = await this.withModelRequestSlot(() => withRetry(async () => {
283
336
  const result = await generateText({ messages, ...config });
284
337
  this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
285
338
  if (!result.text) {
@@ -293,7 +346,7 @@ export class Provider {
293
346
  debugLog('finishReason=length, response may be truncated');
294
347
  }
295
348
  return result;
296
- }, this.getRetryOptions(options));
349
+ }, this.getRetryOptions(options)));
297
350
  clearActivity();
298
351
  responseLog(response.text);
299
352
  return response;
@@ -318,7 +371,8 @@ export class Provider {
318
371
  const modelName = getModelName(model);
319
372
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
320
373
  promptLog(`Using model: ${modelName}`);
321
- const toolNames = Object.keys(tools || {});
374
+ const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
375
+ const toolNames = Object.keys(toolsWithCommentary || {});
322
376
  tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
323
377
  promptLog('Available tools:', toolNames);
324
378
  promptLog(messages[messages.length - 1].content);
@@ -327,9 +381,9 @@ export class Provider {
327
381
  const stopConditions = [isStepCount(maxRoundtrips)];
328
382
  if (extraStop)
329
383
  stopConditions.push(extraStop);
330
- const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
384
+ const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
331
385
  try {
332
- const response = await withRetry(async () => {
386
+ const response = await this.withModelRequestSlot(() => withRetry(async () => {
333
387
  const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000));
334
388
  this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
335
389
  const hasToolCall = (result.toolCalls?.length || 0) > 0;
@@ -337,7 +391,7 @@ export class Provider {
337
391
  throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
338
392
  }
339
393
  return result;
340
- }, this.getRetryOptions(options));
394
+ }, this.getRetryOptions(options)));
341
395
  clearActivity();
342
396
  // Log tool usage summary
343
397
  if (response.toolCalls && response.toolCalls.length > 0) {
@@ -376,9 +430,9 @@ export class Provider {
376
430
  const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
377
431
  try {
378
432
  promptLog(messages[messages.length - 1].content);
379
- const response = await withRetry(async () => {
433
+ const response = await this.withModelRequestSlot(() => withRetry(async () => {
380
434
  return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000));
381
- }, this.getRetryOptions(options));
435
+ }, this.getRetryOptions(options)));
382
436
  clearActivity();
383
437
  responseLog(response.object);
384
438
  this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
@@ -512,7 +566,6 @@ export class Provider {
512
566
  throw new Error('Vision model not configured. Please set ai.visionModel in your config.');
513
567
  }
514
568
  setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
515
- const imageData = `data:image/png;base64,${image.toString()}`;
516
569
  const messages = [
517
570
  {
518
571
  role: 'user',
@@ -524,7 +577,7 @@ export class Provider {
524
577
  {
525
578
  type: 'file',
526
579
  mediaType: 'image/png',
527
- data: imageData,
580
+ data: image,
528
581
  },
529
582
  ],
530
583
  },
@@ -540,12 +593,12 @@ export class Provider {
540
593
  config.telemetry = telemetry;
541
594
  try {
542
595
  promptLog(`Processing image with prompt: ${prompt}`);
543
- const response = await withRetry(async () => {
596
+ const response = await this.withModelRequestSlot(() => withRetry(async () => {
544
597
  return await generateText({
545
598
  messages,
546
599
  ...config,
547
600
  });
548
- }, this.getRetryOptions());
601
+ }, this.getRetryOptions()));
549
602
  clearActivity();
550
603
  responseLog(response.text);
551
604
  this.recordUsage('vision', getModelName(this.config.visionModel), response.usage);
@@ -559,13 +612,13 @@ export class Provider {
559
612
  }
560
613
  }
561
614
  hasVision() {
562
- return this.config.visionModel !== undefined;
615
+ return this.config.visionModel !== undefined && !Stats.visionDisabled;
563
616
  }
564
617
  }
565
618
  function repairToolCall(options) {
566
619
  if (options.toolCall.toolName.includes('<|channel|>'))
567
620
  return repairChannelMarker(options);
568
- return null;
621
+ return repairHarmonyChannel(options);
569
622
  }
570
623
  function repairChannelMarker({ toolCall, tools }) {
571
624
  const markerIndex = toolCall.toolName.indexOf('<|channel|>');
@@ -577,4 +630,16 @@ function repairChannelMarker({ toolCall, tools }) {
577
630
  tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
578
631
  return { ...toolCall, toolName };
579
632
  }
633
+ function repairHarmonyChannel({ toolCall, tools }) {
634
+ if (!HARMONY_CHANNELS.includes(toolCall.toolName))
635
+ return null;
636
+ if (!tools.commentary)
637
+ return null;
638
+ let input = toolCall.input;
639
+ if (typeof input !== 'string' || !input.trim().startsWith('{')) {
640
+ input = JSON.stringify({ content: typeof input === 'string' ? input : JSON.stringify(input ?? null) });
641
+ }
642
+ tag('warning').log(`Repaired tool name '${toolCall.toolName}' → 'commentary'`);
643
+ return { ...toolCall, toolName: NARRATION_TOOL, input };
644
+ }
580
645
  export { AiError, Provider as AIProvider };
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../../action-result.js';
3
3
  import { executionController } from "../../execution-controller.js";
4
- import { detectFocusArea, diffAriaSnapshots } from "../../utils/aria.js";
4
+ import { diffAriaSnapshots } from "../../utils/aria.js";
5
5
  import { extractCodeBlocks } from "../../utils/code-extractor.js";
6
6
  import { tag } from '../../utils/logger.js';
7
7
  import { mdq } from "../../utils/markdown-query.js";
@@ -60,7 +60,7 @@ export function WithDeepAnalysis(Base) {
60
60
  this._appendExtendedResearch(result, expandedSections, navigationLinks);
61
61
  }
62
62
  async researchOverlay(current, previous, pageStateHash) {
63
- const focusArea = detectFocusArea(current.ariaSnapshot);
63
+ const focusArea = current.overlay;
64
64
  if (!focusArea.detected || !focusArea.name)
65
65
  return null;
66
66
  if (focusArea.type !== 'dialog' && focusArea.type !== 'modal')