explorbot 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -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
  }
@@ -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:
@@ -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';
@@ -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
 
@@ -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', experimental_repairToolCall: repairToolCall }, { 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,13 +595,13 @@ export class Provider {
559
595
  }
560
596
  }
561
597
  hasVision() {
562
- return this.config.visionModel !== undefined;
598
+ return this.config.visionModel !== undefined && !Stats.visionDisabled;
563
599
  }
564
600
  }
565
601
  function repairToolCall(options) {
566
602
  if (options.toolCall.toolName.includes('<|channel|>'))
567
603
  return repairChannelMarker(options);
568
- return null;
604
+ return repairHarmonyChannel(options);
569
605
  }
570
606
  function repairChannelMarker({ toolCall, tools }) {
571
607
  const markerIndex = toolCall.toolName.indexOf('<|channel|>');
@@ -577,4 +613,16 @@ function repairChannelMarker({ toolCall, tools }) {
577
613
  tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
578
614
  return { ...toolCall, toolName };
579
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) });
624
+ }
625
+ tag('warning').log(`Repaired tool name '${toolCall.toolName}' → 'commentary'`);
626
+ return { ...toolCall, toolName: NARRATION_TOOL, input };
627
+ }
580
628
  export { AiError, Provider as AIProvider };
@@ -6,6 +6,7 @@ import { z } from 'zod';
6
6
  import { ActionResult } from "../action-result.js";
7
7
  import { clearActivity, setActivity } from "../activity.js";
8
8
  import { Observability } from "../observability.js";
9
+ import { normalizeUrl } from "../state-manager.js";
9
10
  import { Stats } from "../stats.js";
10
11
  import { TestResult } from "../test-plan.js";
11
12
  import { detectFocusArea } from "../utils/aria.js";
@@ -78,7 +79,7 @@ export class Tester extends TaskAgent {
78
79
  }
79
80
  async test(task, opts = {}) {
80
81
  Stats.tests++;
81
- const state = this.stateManager.getCurrentState();
82
+ let state = this.stateManager.getCurrentState();
82
83
  if (!state)
83
84
  throw new Error('No state found');
84
85
  setActivity(`🧪 Testing: ${task.scenario}`, 'action');
@@ -97,7 +98,23 @@ export class Tester extends TaskAgent {
97
98
  const offFailedRequest = requestStore.onFailedRequest((r) => {
98
99
  task.addObservation(`Network error: ${r.method} ${r.path} → ${r.status}`);
99
100
  });
100
- const initialState = ActionResult.fromState(state);
101
+ let initialState = ActionResult.fromState(state);
102
+ const currentUrl = state.fullUrl || state.url;
103
+ let startOnCurrentPage = opts.startOnCurrentPage;
104
+ if (isErrorPage(initialState) && !startOnCurrentPage && task.startUrl && normalizeUrl(currentUrl) !== normalizeUrl(task.startUrl)) {
105
+ debugLog(`Recovering from error page at ${currentUrl} by navigating to ${task.startUrl}`);
106
+ try {
107
+ await this.explorer.visit(task.startUrl);
108
+ state = this.stateManager.getCurrentState();
109
+ if (!state)
110
+ throw new Error('No state found after navigating to test start URL');
111
+ initialState = ActionResult.fromState(state);
112
+ startOnCurrentPage = true;
113
+ }
114
+ catch (error) {
115
+ debugLog(`Could not recover from error page: ${compactErrorMessage(error)}`);
116
+ }
117
+ }
101
118
  if (isErrorPage(initialState)) {
102
119
  task.start();
103
120
  this.testRun = await this.explorer.beginTest(task);
@@ -122,7 +139,7 @@ export class Tester extends TaskAgent {
122
139
  startUrl: task.startUrl,
123
140
  expected: task.expected,
124
141
  },
125
- }, async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts));
142
+ }, async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, { ...opts, startOnCurrentPage }));
126
143
  }
127
144
  async runTestSession(task, initialState, conversation, handlers, opts) {
128
145
  const { offFailedRequest } = handlers;
@@ -421,7 +438,7 @@ export class Tester extends TaskAgent {
421
438
  <rules>
422
439
  Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
423
440
  Fall back to interact() when those fail, when the step needs a sequence of actions, or when your context is not enough to locate the element.
424
- Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
441
+ Use tool names exactly as listed in this prompt. Do not invent combined tool names or aliases.
425
442
  Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
426
443
  Do not do unsuccesful clicks again.
427
444
  Do not run same tool calls with same parameters again.
@@ -59,25 +59,30 @@ export class ExploreCommand extends BaseCommand {
59
59
  tag('warning').log(error.message);
60
60
  return;
61
61
  }
62
- if (cfg.enabled) {
63
- await this.runReuseMode(mainUrl, feature, cfg);
64
- }
65
- else {
66
- await this.runFreshMode(mainUrl, feature, cfg.styles);
67
- }
68
- const mainPlan = this.completedPlans[0];
69
- if (mainPlan)
70
- this.explorBot.setCurrentPlan(mainPlan);
71
- if (this.dryRun) {
62
+ try {
63
+ if (cfg.enabled) {
64
+ await this.runReuseMode(mainUrl, feature, cfg);
65
+ }
66
+ else {
67
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
68
+ }
69
+ const mainPlan = this.completedPlans[0];
70
+ if (mainPlan)
71
+ this.explorBot.setCurrentPlan(mainPlan);
72
+ if (this.dryRun) {
73
+ this.printResults();
74
+ return;
75
+ }
76
+ if (mainUrl)
77
+ await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
78
+ const savedPath = this.explorBot.savePlans(this.completedPlans);
72
79
  this.printResults();
73
- return;
80
+ this.printNextSteps(savedPath);
81
+ }
82
+ finally {
83
+ if (!this.dryRun)
84
+ await this.explorBot.printSessionAnalysis();
74
85
  }
75
- if (mainUrl)
76
- await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
77
- const savedPath = this.explorBot.savePlans(this.completedPlans);
78
- this.printResults();
79
- await this.explorBot.printSessionAnalysis();
80
- this.printNextSteps(savedPath);
81
86
  }
82
87
  originLabel(test) {
83
88
  return this.oldTestRefs.has(test) ? 'OLD' : 'NEW';
@@ -151,8 +151,9 @@ interface AIConfig {
151
151
  vision?: boolean;
152
152
  visionModel?: any;
153
153
  agenticModel?: any;
154
- maxAttempts?: number;
154
+ retryAttempts?: number;
155
155
  retryDelay?: number;
156
+ maxParallelRequests?: number;
156
157
  agents?: AgentsConfig;
157
158
  }
158
159
  interface HtmlConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -17,6 +17,8 @@ export function toolExecutionLabel(input: Record<string, any> | undefined): stri
17
17
  return input?.explanation || input?.assertion || input?.reason || input?.request || '';
18
18
  }
19
19
 
20
+ export const NARRATION_TOOL = 'commentary';
21
+
20
22
  const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
21
23
  const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
22
24
 
@@ -227,6 +229,7 @@ export class Conversation {
227
229
  if (!Array.isArray(message.content)) continue;
228
230
  for (const part of message.content) {
229
231
  if (part.type !== 'tool-result') continue;
232
+ if (part.toolName === NARRATION_TOOL) continue;
230
233
  executions.push(toToolExecution(part.toolName, toolCalls.get(part.toolCallId) || {}, part.output));
231
234
  }
232
235
  }
@@ -196,7 +196,7 @@ export class Fisherman implements Agent {
196
196
 
197
197
  AVAILABLE TOOLS:
198
198
  ${toolNames.join(', ')}.
199
- Use tool names exactly as listed. Do not invent aliases, combined names, or names with channel markers such as "commentary".
199
+ Use tool names exactly as listed. Do not invent aliases or combined names.
200
200
  Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
201
201
 
202
202
  WORKFLOW:
package/src/ai/pilot.ts CHANGED
@@ -25,7 +25,8 @@ 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'];
30
31
  const PILOT_MESSAGE_LIMIT = 2;
31
32
  const PILOT_MESSAGE_MAX_LENGTH = 160;
@@ -326,6 +327,10 @@ export class Pilot implements Agent {
326
327
  overrides the others — weigh them together. Tester's record() notes are the LEAST reliable; always
327
328
  cross-check against actual actions and state. Visual screenshot analysis is strong for UI state
328
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.
329
334
  If the final page clearly shows an equivalent success state in a different UI form, do not fail only
330
335
  because one narrow assertion targeted a specific badge, count, toast, or wording that the product
331
336
  represents differently.
@@ -574,8 +579,8 @@ export class Pilot implements Agent {
574
579
  }
575
580
 
576
581
  async settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]> {
577
- let image: string | null = null;
578
- 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;
579
584
 
580
585
  const decided = (text: string): 'passed' | 'failed' => {
581
586
  if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
@@ -995,20 +1000,20 @@ export class Pilot implements Agent {
995
1000
 
996
1001
  private hasSuccessfulCheckEvidence(currentState: ActionResult, testerConversation: Conversation): boolean {
997
1002
  if (Object.values(currentState.verifications ?? {}).some(Boolean)) return true;
998
- 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);
999
1004
  }
1000
1005
 
1001
1006
  private formatSuccessfulAssertions(currentState: ActionResult, testerConversation: Conversation): string {
1002
1007
  const lines: string[] = [];
1003
1008
  for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
1004
- if (passed) lines.push(`PASS state verification: ${assertion}`);
1009
+ if (passed) lines.push(`state verification (passed): ${assertion}`);
1005
1010
  }
1006
1011
 
1007
1012
  for (const exec of testerConversation.getToolExecutions()) {
1008
- if (!CHECK_TOOLS.includes(exec.toolName) || !exec.wasSuccessful) continue;
1013
+ if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful) continue;
1009
1014
  const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
1010
1015
  const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
1011
- lines.push(`PASS ${exec.toolName}: ${description}${result ? ` -> ${result}` : ''}`);
1016
+ lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
1012
1017
  }
1013
1018
 
1014
1019
  return [...new Set(lines)].join('\n');
@@ -1137,7 +1142,7 @@ export class Pilot implements Agent {
1137
1142
 
1138
1143
  Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1139
1144
  visualClick, back, getVisitedStates, reset, stop, finish, record.
1140
- Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
1145
+ Use tool names exactly as listed. Do not invent combined names or aliases.
1141
1146
 
1142
1147
  ${capabilityGroundingRule}
1143
1148
 
@@ -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', experimental_repairToolCall: repairToolCall }, { 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,13 +666,13 @@ 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
 
630
673
  function repairToolCall(options: ToolCallRepairOptions): any | null {
631
674
  if (options.toolCall.toolName.includes('<|channel|>')) return repairChannelMarker(options);
632
- return null;
675
+ return repairHarmonyChannel(options);
633
676
  }
634
677
 
635
678
  function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
@@ -641,6 +684,17 @@ function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any |
641
684
  return { ...toolCall, toolName };
642
685
  }
643
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
+
644
698
  export { AiError, Provider as AIProvider };
645
699
 
646
700
  type ToolCallRepairOptions = { toolCall: any; tools: any };
package/src/ai/tester.ts CHANGED
@@ -8,7 +8,7 @@ import { clearActivity, setActivity } from '../activity.ts';
8
8
  import type { RequestStore } from '../api/request-store.ts';
9
9
  import type { TestRun } from '../explorer.ts';
10
10
  import { Observability } from '../observability.ts';
11
- import type { StateTransition } from '../state-manager.ts';
11
+ import { type StateTransition, normalizeUrl } from '../state-manager.ts';
12
12
  import { Stats } from '../stats.ts';
13
13
  import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
14
14
  import { detectFocusArea } from '../utils/aria.ts';
@@ -100,7 +100,7 @@ export class Tester extends TaskAgent implements Agent {
100
100
 
101
101
  async test(task: Test, opts: TestOptions = {}): Promise<{ success: boolean }> {
102
102
  Stats.tests++;
103
- const state = this.stateManager.getCurrentState();
103
+ let state = this.stateManager.getCurrentState();
104
104
  if (!state) throw new Error('No state found');
105
105
 
106
106
  setActivity(`🧪 Testing: ${task.scenario}`, 'action');
@@ -122,7 +122,21 @@ export class Tester extends TaskAgent implements Agent {
122
122
  task.addObservation(`Network error: ${r.method} ${r.path} → ${r.status}`);
123
123
  });
124
124
 
125
- const initialState = ActionResult.fromState(state);
125
+ let initialState = ActionResult.fromState(state);
126
+ const currentUrl = state.fullUrl || state.url;
127
+ let startOnCurrentPage = opts.startOnCurrentPage;
128
+ if (isErrorPage(initialState) && !startOnCurrentPage && task.startUrl && normalizeUrl(currentUrl) !== normalizeUrl(task.startUrl)) {
129
+ debugLog(`Recovering from error page at ${currentUrl} by navigating to ${task.startUrl}`);
130
+ try {
131
+ await this.explorer.visit(task.startUrl);
132
+ state = this.stateManager.getCurrentState();
133
+ if (!state) throw new Error('No state found after navigating to test start URL');
134
+ initialState = ActionResult.fromState(state);
135
+ startOnCurrentPage = true;
136
+ } catch (error) {
137
+ debugLog(`Could not recover from error page: ${compactErrorMessage(error)}`);
138
+ }
139
+ }
126
140
  if (isErrorPage(initialState)) {
127
141
  task.start();
128
142
  this.testRun = await this.explorer.beginTest(task);
@@ -153,7 +167,7 @@ export class Tester extends TaskAgent implements Agent {
153
167
  expected: task.expected,
154
168
  },
155
169
  },
156
- async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts)
170
+ async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, { ...opts, startOnCurrentPage })
157
171
  );
158
172
  }
159
173
 
@@ -481,7 +495,7 @@ export class Tester extends TaskAgent implements Agent {
481
495
  <rules>
482
496
  Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
483
497
  Fall back to interact() when those fail, when the step needs a sequence of actions, or when your context is not enough to locate the element.
484
- Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
498
+ Use tool names exactly as listed in this prompt. Do not invent combined tool names or aliases.
485
499
  Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
486
500
  Do not do unsuccesful clicks again.
487
501
  Do not run same tool calls with same parameters again.
@@ -63,23 +63,26 @@ export class ExploreCommand extends BaseCommand {
63
63
  return;
64
64
  }
65
65
 
66
- if (cfg.enabled) {
67
- await this.runReuseMode(mainUrl, feature, cfg);
68
- } else {
69
- await this.runFreshMode(mainUrl, feature, cfg.styles);
70
- }
66
+ try {
67
+ if (cfg.enabled) {
68
+ await this.runReuseMode(mainUrl, feature, cfg);
69
+ } else {
70
+ await this.runFreshMode(mainUrl, feature, cfg.styles);
71
+ }
71
72
 
72
- const mainPlan = this.completedPlans[0];
73
- if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
74
- if (this.dryRun) {
73
+ const mainPlan = this.completedPlans[0];
74
+ if (mainPlan) this.explorBot.setCurrentPlan(mainPlan);
75
+ if (this.dryRun) {
76
+ this.printResults();
77
+ return;
78
+ }
79
+ if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
80
+ const savedPath = this.explorBot.savePlans(this.completedPlans);
75
81
  this.printResults();
76
- return;
82
+ this.printNextSteps(savedPath);
83
+ } finally {
84
+ if (!this.dryRun) await this.explorBot.printSessionAnalysis();
77
85
  }
78
- if (mainUrl) await this.explorBot.visit(mainUrl).catch((err) => tag('warning').log(`Could not return to ${mainUrl}: ${browserErrorMessage(err)}`));
79
- const savedPath = this.explorBot.savePlans(this.completedPlans);
80
- this.printResults();
81
- await this.explorBot.printSessionAnalysis();
82
- this.printNextSteps(savedPath);
83
86
  }
84
87
 
85
88
  private originLabel(test: Test): string {
package/src/config.ts CHANGED
@@ -175,8 +175,9 @@ interface AIConfig {
175
175
  vision?: boolean;
176
176
  visionModel?: any;
177
177
  agenticModel?: any;
178
- maxAttempts?: number;
178
+ retryAttempts?: number;
179
179
  retryDelay?: number;
180
+ maxParallelRequests?: number;
180
181
  agents?: AgentsConfig;
181
182
  }
182
183