diff --git a/packages/askui-nodejs/src/execution/dsl.spec.ts b/packages/askui-nodejs/src/execution/dsl.spec.ts index a9a6fed43d..e95ed228e7 100644 --- a/packages/askui-nodejs/src/execution/dsl.spec.ts +++ b/packages/askui-nodejs/src/execution/dsl.spec.ts @@ -20,7 +20,7 @@ describe('DSL', () => { const testCommandSpy = jest.spyOn(underTest, 'fluentCommandExecutor'); await underTest.click().button().exec(); - expect(testCommandSpy).toHaveBeenCalledWith('Click on button', [], { aiElementNames: [], customElementsJson: [] }, false); + expect(testCommandSpy).toHaveBeenCalledWith('Click on button', [], { aiElementNames: [], customElementsJson: [] }, false, undefined); }); test('should call exec function with one custom element', async () => { @@ -52,6 +52,7 @@ describe('DSL', () => { ], }, false, + undefined, ); }); @@ -96,6 +97,7 @@ describe('DSL', () => { ], }, false, + undefined, ); }); @@ -129,6 +131,7 @@ describe('DSL', () => { ], }, false, + undefined, ); }); @@ -152,6 +155,7 @@ describe('DSL', () => { customElementsJson: [], }, false, + undefined, ); }); }); diff --git a/packages/askui-nodejs/src/execution/dsl.ts b/packages/askui-nodejs/src/execution/dsl.ts index 887167a792..ca8ce125c9 100644 --- a/packages/askui-nodejs/src/execution/dsl.ts +++ b/packages/askui-nodejs/src/execution/dsl.ts @@ -8,6 +8,7 @@ import { CustomElementJson } from '../core/model/custom-element-json'; import { DetectedElement } from '../core/model/annotation-result/detected-element'; import { ModelCompositionBranch } from './model-composition-branch'; +import { RetryStrategy } from './retry-strategies/retry-strategy'; function isStackTraceCodeline(line: string): boolean { return /[ \t]+at .+/.test(line); @@ -20,23 +21,18 @@ function splitStackTrace(stacktrace: string): { head: string[], codelines: strin return { head: errorStacktraceHead, codelines: errorStacktraceCodeLines }; } function rewriteStackTraceForError(error: Error, newStackTrace: string) { - const errorCopy = new Error(error.message); - - if (!error.stack) { - errorCopy.stack = newStackTrace; - return errorCopy; - } - const errorStacktraceSplit = splitStackTrace(error.stack); + const errorStacktraceSplit = splitStackTrace(error.stack ?? ''); const newStacktraceSplit = splitStackTrace(newStackTrace); - errorCopy.stack = [ + // eslint-disable-next-line no-param-reassign + error.stack = [ ...errorStacktraceSplit.head, ...newStacktraceSplit.codelines, ' ', ...errorStacktraceSplit.head, ...errorStacktraceSplit.codelines, ].join('\n'); - return errorCopy; + return error; } export enum Separators { @@ -62,6 +58,7 @@ export interface CommandExecutorContext { export interface ExecOptions { modelComposition?: ModelCompositionBranch[]; skipCache?: boolean; + retryStrategy?: RetryStrategy; } abstract class FluentBase { @@ -87,6 +84,7 @@ abstract class FluentBase { protected fluentCommandStringBuilder( modelComposition: ModelCompositionBranch[] = [], skipCache = false, + retryStrategy?: RetryStrategy, currentInstruction = '', paramsList: Map = new Map(), ): Promise { @@ -104,6 +102,7 @@ abstract class FluentBase { aiElementNames, }, skipCache, + retryStrategy, ); } if (!this.prev) { @@ -112,6 +111,7 @@ abstract class FluentBase { return this.prev.fluentCommandStringBuilder( modelComposition, skipCache, + retryStrategy, newCurrentInstruction, newParamsList, ); @@ -150,14 +150,14 @@ abstract class FluentBase { } export interface Executable { - exec(): Promise + exec(execOptions?: ExecOptions): Promise } export class Exec extends FluentBase implements Executable { exec(execOptions?: ExecOptions): Promise { const originStacktrace = { stack: '' }; Error.captureStackTrace(originStacktrace, this.exec); - return this.fluentCommandStringBuilder(execOptions?.modelComposition, execOptions?.skipCache).catch((err: Error) => Promise.reject(rewriteStackTraceForError(err, originStacktrace.stack))); + return this.fluentCommandStringBuilder(execOptions?.modelComposition, execOptions?.skipCache, execOptions?.retryStrategy).catch((err: Error) => Promise.reject(rewriteStackTraceForError(err, originStacktrace.stack))); } } @@ -1242,7 +1242,7 @@ export class FluentFiltersOrRelations extends FluentFilters { exec(execOptions?: ExecOptions): Promise { const originStacktrace = { stack: '' }; Error.captureStackTrace(originStacktrace, this.exec); - return this.fluentCommandStringBuilder(execOptions?.modelComposition, execOptions?.skipCache).catch((err: Error) => Promise.reject(rewriteStackTraceForError(err, originStacktrace.stack))); + return this.fluentCommandStringBuilder(execOptions?.modelComposition, execOptions?.skipCache, execOptions?.retryStrategy).catch((err: Error) => Promise.reject(rewriteStackTraceForError(err, originStacktrace.stack))); } } @@ -3147,6 +3147,7 @@ export abstract class FluentCommand extends FluentBase { modelComposition: ModelCompositionBranch[], context: CommandExecutorContext, skipCache : boolean, + retryStrategy?: RetryStrategy, ): Promise; } diff --git a/packages/askui-nodejs/src/execution/execution-runtime.ts b/packages/askui-nodejs/src/execution/execution-runtime.ts index 7b301ebc4d..1f8e3c29c5 100644 --- a/packages/askui-nodejs/src/execution/execution-runtime.ts +++ b/packages/askui-nodejs/src/execution/execution-runtime.ts @@ -57,11 +57,13 @@ export class ExecutionRuntime { instruction: Instruction, modelComposition: ModelCompositionBranch[], skipCache = false, + retryStrategy?: RetryStrategy, ): Promise { const controlCommand = await this.predictCommandWithRetry( instruction, modelComposition, skipCache, + retryStrategy, ); if (controlCommand.code === ControlCommandCode.OK) { return this.requestControl(controlCommand); @@ -114,15 +116,17 @@ export class ExecutionRuntime { instruction: Instruction, modelComposition: ModelCompositionBranch[], skipCache = false, + retryStrategy?: RetryStrategy, ): Promise { + const strategy = retryStrategy ?? this.retryStrategy; let command = await this.predictCommand(instruction, modelComposition, skipCache); /* eslint-disable no-await-in-loop */ - for (let k = 0; k < this.retryStrategy.retryCount; k += 1) { + for (let k = 0; k < strategy.retryCount; k += 1) { if (command.code === ControlCommandCode.OK) { return command; } - const msUntilRetry = this.retryStrategy.getDelay(k + 1); - logger.debug(`Wait ${msUntilRetry} and retry predicting command...`); + const msUntilRetry = strategy.getDelay(k + 1); + logger.debug(`Wait ${msUntilRetry} and retry ${k + 1}/${strategy.retryCount} predicting command...`); await delay(msUntilRetry); command = await this.predictCommand(instruction, modelComposition, skipCache, new ControlCommandError(command.actions?.[0]?.text ?? '')); } diff --git a/packages/askui-nodejs/src/execution/ui-control-client.ts b/packages/askui-nodejs/src/execution/ui-control-client.ts index 43057b9cd0..71dcf80eda 100644 --- a/packages/askui-nodejs/src/execution/ui-control-client.ts +++ b/packages/askui-nodejs/src/execution/ui-control-client.ts @@ -25,7 +25,8 @@ import { Instruction, StepReporter } from '../core/reporting'; import { AIElementCollection } from '../core/ai-element/ai-element-collection'; import { ModelCompositionBranch } from './model-composition-branch'; import { AIElementArgs } from '../core/ai-element/ai-elements-args'; -import { NoRetryStrategy } from './retry-strategies'; +import { NoRetryStrategy, RetryStrategy } from './retry-strategies'; +import { ControlCommandError } from './control-command-error'; import { AskUIAgent, AgentHistory, ActOptions } from '../core/models/anthropic'; import { AskUIGetAskUIElementTool, AskUIListAIElementTool } from '../core/models/anthropic/tools/askui-api-tools'; @@ -215,6 +216,7 @@ export class UiControlClient extends ApiCommands { modelComposition: ModelCompositionBranch[], context: CommandExecutorContext = { customElementsJson: [], aiElementNames: [] }, skipCache = false, + retryStrategy?: RetryStrategy, ): Promise { const aiElements = await this.getAIElementsByNames(context.aiElementNames); const instruction = await this.buildInstruction( @@ -227,7 +229,12 @@ export class UiControlClient extends ApiCommands { logger.debug(instruction); try { this.stepReporter.resetStep(instruction); - await this.executionRuntime.executeInstruction(instruction, modelComposition, skipCache); + await this.executionRuntime.executeInstruction( + instruction, + modelComposition, + skipCache, + retryStrategy, + ); await this.afterCommandExecution(instruction); return await Promise.resolve(); } catch (error) { @@ -523,17 +530,35 @@ export class UiControlClient extends ApiCommands { * @param {number} waitTime - Time in milliseconds */ async waitUntil(AskUICommand: Executable, maxTry = 5, waitTime = 2000) { + logger.debug(`waitUntil: Starting with maxTry=${maxTry}, waitTime=${waitTime}ms, retryStrategy=${this.executionRuntime.retryStrategy.constructor.name}`); + const userDefinedStrategy = this.executionRuntime.retryStrategy; - try { - this.executionRuntime.retryStrategy = new NoRetryStrategy(); - await AskUICommand.exec(); - this.executionRuntime.retryStrategy = userDefinedStrategy; - } catch (error) { - if (maxTry === 0) { + this.executionRuntime.retryStrategy = new NoRetryStrategy(); + + const attempt = async (retriesLeft: number): Promise => { + const attemptNumber = maxTry - retriesLeft; + logger.debug(`waitUntil: Attempt ${attemptNumber}/${maxTry} (${retriesLeft} retries remaining)`); + try { + await AskUICommand.exec(); + logger.debug(`waitUntil: Command succeeded on attempt ${attemptNumber}/${maxTry}`); + return; + } catch (error: unknown) { + if (error instanceof ControlCommandError && retriesLeft > 0) { + logger.debug(`waitUntil: ControlCommandError on attempt ${attemptNumber}/${maxTry}, waiting ${waitTime}ms before retry.`, error); + await this.waitFor(waitTime).exec(); + await attempt(retriesLeft - 1); + return; + } + const errorName = error instanceof Error ? error.name : 'Error'; + logger.debug(`waitUntil: ${errorName} on attempt ${attemptNumber}/${maxTry}, no retries remaining.`, error); throw error; } - await this.waitFor(waitTime).exec(); - await this.waitUntil(AskUICommand, maxTry - 1, waitTime); + }; + + try { + await attempt(maxTry - 1); + } finally { + this.executionRuntime.retryStrategy = userDefinedStrategy; } }