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 +1 -1
- package/dist/src/ai/conversation.d.ts +1 -0
- package/dist/src/ai/conversation.js +3 -0
- package/dist/src/ai/fisherman.js +1 -1
- package/dist/src/ai/pilot.js +12 -7
- package/dist/src/ai/provider.d.ts +3 -0
- package/dist/src/ai/provider.js +66 -18
- package/dist/src/ai/tester.js +21 -4
- package/dist/src/commands/explore-command.js +22 -17
- package/dist/src/config.d.ts +2 -1
- package/package.json +1 -1
- package/src/ai/conversation.ts +3 -0
- package/src/ai/fisherman.ts +1 -1
- package/src/ai/pilot.ts +13 -8
- package/src/ai/provider.ts +96 -42
- package/src/ai/tester.ts +19 -5
- package/src/commands/explore-command.ts +17 -14
- package/src/config.ts +2 -1
package/dist/package.json
CHANGED
|
@@ -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
|
}
|
package/dist/src/ai/fisherman.js
CHANGED
|
@@ -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
|
|
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:
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -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'
|
|
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 =
|
|
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) =>
|
|
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(`
|
|
911
|
+
lines.push(`state verification (passed): ${assertion}`);
|
|
907
912
|
}
|
|
908
913
|
for (const exec of testerConversation.getToolExecutions()) {
|
|
909
|
-
if (!
|
|
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(`
|
|
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
|
|
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>;
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { OpenTelemetry } from '@ai-sdk/otel';
|
|
2
2
|
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
3
3
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
4
|
-
import {
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
|
|
6
|
+
import { z } from 'zod';
|
|
5
7
|
import { clearActivity, setActivity } from "../activity.js";
|
|
6
8
|
import { configuredModels, modelName as getModelName } from '../config.js';
|
|
7
9
|
import { executionController } from "../execution-controller.js";
|
|
@@ -10,7 +12,7 @@ import { Stats } from "../stats.js";
|
|
|
10
12
|
import { createDebug, tag } from '../utils/logger.js';
|
|
11
13
|
import { withRetry } from '../utils/retry.js';
|
|
12
14
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
13
|
-
import { Conversation, toToolExecution } from './conversation.js';
|
|
15
|
+
import { Conversation, NARRATION_TOOL, toToolExecution } from './conversation.js';
|
|
14
16
|
const debugLog = createDebug('explorbot:provider');
|
|
15
17
|
const promptLog = createDebug('explorbot:provider:out');
|
|
16
18
|
const responseLog = createDebug('explorbot:provider:in');
|
|
@@ -18,6 +20,16 @@ class AiError extends Error {
|
|
|
18
20
|
}
|
|
19
21
|
export class ContextLengthError extends Error {
|
|
20
22
|
}
|
|
23
|
+
const DEFAULT_PARALLEL_REQUESTS = 4;
|
|
24
|
+
const modelSlotContext = new AsyncLocalStorage();
|
|
25
|
+
const HARMONY_CHANNELS = ['commentary', 'analysis', 'final'];
|
|
26
|
+
function createHarmonyChannelFallbackTool() {
|
|
27
|
+
return tool({
|
|
28
|
+
description: 'Internal compatibility fallback for model channel output. Do not call directly.',
|
|
29
|
+
inputSchema: z.record(z.string(), z.any()),
|
|
30
|
+
execute: async () => ({ message: 'Noted. Continue with your next action.' }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
21
33
|
let telemetryRegistered = false;
|
|
22
34
|
const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
|
|
23
35
|
function extractCachedTokens(usage) {
|
|
@@ -73,6 +85,8 @@ export class Provider {
|
|
|
73
85
|
},
|
|
74
86
|
};
|
|
75
87
|
lastConversation = null;
|
|
88
|
+
activeModelCalls = 0;
|
|
89
|
+
modelCallWaiters = [];
|
|
76
90
|
constructor(config) {
|
|
77
91
|
if (!config?.model) {
|
|
78
92
|
throw new AiError('AI model is not configured. Set ai.model in your config file.');
|
|
@@ -140,9 +154,31 @@ export class Provider {
|
|
|
140
154
|
getRetryOptions(options = {}) {
|
|
141
155
|
return {
|
|
142
156
|
...this.defaultRetryOptions,
|
|
143
|
-
maxAttempts: options.maxRetries || this.defaultRetryOptions.maxAttempts,
|
|
157
|
+
maxAttempts: options.maxRetries || this.config.retryAttempts || this.defaultRetryOptions.maxAttempts,
|
|
158
|
+
baseDelay: this.config.retryDelay || this.defaultRetryOptions.baseDelay,
|
|
144
159
|
};
|
|
145
160
|
}
|
|
161
|
+
async withModelRequestSlot(fn) {
|
|
162
|
+
if (modelSlotContext.getStore())
|
|
163
|
+
return fn();
|
|
164
|
+
const limit = Math.max(1, this.config.maxParallelRequests ?? DEFAULT_PARALLEL_REQUESTS);
|
|
165
|
+
if (this.activeModelCalls >= limit || this.modelCallWaiters.length > 0) {
|
|
166
|
+
await new Promise((resolve) => this.modelCallWaiters.push(resolve));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
this.activeModelCalls++;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return await modelSlotContext.run(true, fn);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
const next = this.modelCallWaiters.shift();
|
|
176
|
+
if (next)
|
|
177
|
+
next();
|
|
178
|
+
else
|
|
179
|
+
this.activeModelCalls--;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
146
182
|
mergeProviderOptions(config, agentName) {
|
|
147
183
|
if (!agentName)
|
|
148
184
|
return config;
|
|
@@ -269,7 +305,7 @@ export class Provider {
|
|
|
269
305
|
const toolCalls = response.toolCalls || [];
|
|
270
306
|
const toolResults = response.toolResults || [];
|
|
271
307
|
const resultsById = new Map(toolResults.map((r) => [r.toolCallId, r]));
|
|
272
|
-
const toolExecutions = toolCalls.map((call) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
308
|
+
const toolExecutions = toolCalls.filter((call) => call.toolName !== NARRATION_TOOL).map((call) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
|
|
273
309
|
return { conversation, response, toolExecutions };
|
|
274
310
|
}
|
|
275
311
|
async chat(messages, model, options = {}) {
|
|
@@ -279,7 +315,7 @@ export class Provider {
|
|
|
279
315
|
const config = this.buildGenerateConfig({ maxOutputTokens: 16384 }, { model, abortSignal: executionController.getAbortSignal() }, options);
|
|
280
316
|
promptLog(messages[messages.length - 1].content);
|
|
281
317
|
try {
|
|
282
|
-
const response = await withRetry(async () => {
|
|
318
|
+
const response = await this.withModelRequestSlot(() => withRetry(async () => {
|
|
283
319
|
const result = await generateText({ messages, ...config });
|
|
284
320
|
this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
|
|
285
321
|
if (!result.text) {
|
|
@@ -293,7 +329,7 @@ export class Provider {
|
|
|
293
329
|
debugLog('finishReason=length, response may be truncated');
|
|
294
330
|
}
|
|
295
331
|
return result;
|
|
296
|
-
}, this.getRetryOptions(options));
|
|
332
|
+
}, this.getRetryOptions(options)));
|
|
297
333
|
clearActivity();
|
|
298
334
|
responseLog(response.text);
|
|
299
335
|
return response;
|
|
@@ -318,7 +354,8 @@ export class Provider {
|
|
|
318
354
|
const modelName = getModelName(model);
|
|
319
355
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
320
356
|
promptLog(`Using model: ${modelName}`);
|
|
321
|
-
const
|
|
357
|
+
const toolsWithCommentary = tools?.commentary ? tools : { ...tools, commentary: createHarmonyChannelFallbackTool() };
|
|
358
|
+
const toolNames = Object.keys(toolsWithCommentary || {});
|
|
322
359
|
tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
|
|
323
360
|
promptLog('Available tools:', toolNames);
|
|
324
361
|
promptLog(messages[messages.length - 1].content);
|
|
@@ -327,9 +364,9 @@ export class Provider {
|
|
|
327
364
|
const stopConditions = [isStepCount(maxRoundtrips)];
|
|
328
365
|
if (extraStop)
|
|
329
366
|
stopConditions.push(extraStop);
|
|
330
|
-
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', 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:
|
|
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
|
|
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 };
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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';
|
package/dist/src/config.d.ts
CHANGED
|
@@ -151,8 +151,9 @@ interface AIConfig {
|
|
|
151
151
|
vision?: boolean;
|
|
152
152
|
visionModel?: any;
|
|
153
153
|
agenticModel?: any;
|
|
154
|
-
|
|
154
|
+
retryAttempts?: number;
|
|
155
155
|
retryDelay?: number;
|
|
156
|
+
maxParallelRequests?: number;
|
|
156
157
|
agents?: AgentsConfig;
|
|
157
158
|
}
|
|
158
159
|
interface HtmlConfig {
|
package/package.json
CHANGED
package/src/ai/conversation.ts
CHANGED
|
@@ -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
|
}
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -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
|
|
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'
|
|
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:
|
|
578
|
-
if (finalState?.screenshot && this.provider.hasVision()) image =
|
|
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) =>
|
|
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(`
|
|
1009
|
+
if (passed) lines.push(`state verification (passed): ${assertion}`);
|
|
1005
1010
|
}
|
|
1006
1011
|
|
|
1007
1012
|
for (const exec of testerConversation.getToolExecutions()) {
|
|
1008
|
-
if (!
|
|
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(`
|
|
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
|
|
1145
|
+
Use tool names exactly as listed. Do not invent combined names or aliases.
|
|
1141
1146
|
|
|
1142
1147
|
${capabilityGroundingRule}
|
|
1143
1148
|
|
package/src/ai/provider.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
367
|
+
debugLog('finishReason=length, response may be truncated');
|
|
326
368
|
}
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
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
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
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:
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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