diff --git a/apps/report/src/components/detail-panel/index.tsx b/apps/report/src/components/detail-panel/index.tsx index 48bf6198ec..45bc6fc42a 100644 --- a/apps/report/src/components/detail-panel/index.tsx +++ b/apps/report/src/components/detail-panel/index.tsx @@ -16,6 +16,10 @@ import type { IExecutionDump, } from '@midscene/core'; import { executionToMarkdown, getTaskSearchArea } from '@midscene/core'; +import { + buildDeepAssertScreenshots, + isCurrentScreenshotFallback, +} from '@midscene/core/agent'; import { Blackboard, Player, @@ -253,7 +257,13 @@ const DetailPanel = ({ activeTask.uiContext?.screenshot?.capturedAt, ); - contextLocatorView = activeTask.uiContext ? ( + const evidenceScreenshots = buildDeepAssertScreenshots(activeTask); + const allowCurrentScreenshot = + evidenceScreenshots === undefined || + isCurrentScreenshotFallback(activeTask); + + contextLocatorView = + allowCurrentScreenshot && activeTask.uiContext ? ( @@ -268,7 +278,16 @@ const DetailPanel = ({ ) : null; - if (activeTask.recorder?.length) { + if (evidenceScreenshots && evidenceScreenshots.length > 0) { + for (const [index, item] of evidenceScreenshots.entries()) { + screenshotItems.push({ + timestamp: item.screenshotTimestamp ?? index, + screenshotTimestamp: item.screenshotTimestamp, + screenshot: item.screenshot, + timing: item.timing, + }); + } + } else if (evidenceScreenshots === undefined && activeTask.recorder?.length) { for (const item of activeTask.recorder) { const screenshot = item.screenshot?.base64; if (screenshot) { diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 4f4c80b323..ec49d3e87a 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -125,6 +125,8 @@ export type AiActOptions = { deepLocate?: boolean; abortSignal?: AbortSignal; context?: string; + AfterActPictures?: number; + Interval?: number; }; type AiActInternalOptions = AiActOptions & { @@ -400,6 +402,7 @@ export class Agent waitAfterAction: this.opts.waitAfterAction, useDeviceTime: this.opts.useDeviceTime, actionSpace: this.fullActionSpace, + getAssertionExecutions: () => this.dump.executions, hooks: { onSnapshotChange: async (runner) => { const executionDump = runner.dump(); @@ -1234,6 +1237,10 @@ export class Agent deepLocate, abortSignal, internalReportDisplay, + { + AfterActPictures: opt?.AfterActPictures, + Interval: opt?.Interval, + }, ); // update cache @@ -1399,7 +1406,7 @@ export class Agent async aiAssert( assertion: TUserPrompt, - msg?: string, + msg?: string | AssertOptions, opt?: AssertOptions, ): Promise { return this.createInsight().aiAssert(assertion, msg, opt); diff --git a/packages/core/src/agent/assertion-evidence.ts b/packages/core/src/agent/assertion-evidence.ts new file mode 100644 index 0000000000..db753311bb --- /dev/null +++ b/packages/core/src/agent/assertion-evidence.ts @@ -0,0 +1,560 @@ +import type { + AssertionBoundary, + AssertionEvaluationContext, + AssertionEvidenceImage, + ExecutionDump, + ExecutionTask, + IExecutionDump, +} from '@/types'; + +export const DEFAULT_AFTER_FRAMES = 1; +export const DEFAULT_FRAME_INTERVAL_MS = 50; +export const DEFAULT_BEFORE_EXECUTIONS = 1; +export const DEFAULT_BEFORE_TASKS = 1; +export const DEFAULT_MAX_PICTURES = 2; + +export const ANALYSIS_SECTIONS = [ + '当前界面判断', + '关联 task 分析', + '截图证据分析', + '最终结论', +] as const; + +const ACTION_EVIDENCE_PHASE_RE = /(before-calling|after-calling-(\d+))$/; + +const STATUS_TEXT: Record = { + finished: '已完成', + failed: '失败', + running: '执行中', + pending: '待执行', +}; + +export type ActionEvidenceOptions = { + AfterActPictures: number; + Interval: number; +}; + +export type NormalizedAssertEvidenceOptions = { + deepAssert: boolean; + AssertionContextBoundary: AssertionBoundary; + BeforeExecutions: number; + BeforeTasks: number; + MaxPictures: number; +}; + +export type AssertCallArgs = { + message?: string; + options?: { + keepRawResponse?: boolean; + context?: string; + abortSignal?: AbortSignal; + deepAssert?: boolean; + AssertionContextBoundary?: AssertionBoundary; + BeforeExecutions?: number; + BeforeTasks?: number; + MaxPictures?: number; + [key: string]: unknown; + }; +}; + +export function nonNegative( + value: number | undefined, + fallback: number, + integer = true, +): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return fallback; + } + return integer ? Math.floor(value) : value; +} + +export function normalizeActionEvidenceOptions(options?: { + AfterActPictures?: number; + Interval?: number; +}): ActionEvidenceOptions { + return { + AfterActPictures: nonNegative( + options?.AfterActPictures, + DEFAULT_AFTER_FRAMES, + ), + Interval: nonNegative(options?.Interval, DEFAULT_FRAME_INTERVAL_MS), + }; +} + +export function normalizeAssertEvidenceOptions( + options?: Record | null, +): NormalizedAssertEvidenceOptions { + const boundary = options?.AssertionContextBoundary; + return { + deepAssert: options?.deepAssert !== false, + AssertionContextBoundary: + boundary === 'session' || boundary === 'lastAssert' + ? boundary + : 'lastAssert', + BeforeExecutions: nonNegative( + typeof options?.BeforeExecutions === 'number' + ? options.BeforeExecutions + : undefined, + DEFAULT_BEFORE_EXECUTIONS, + ), + BeforeTasks: nonNegative( + typeof options?.BeforeTasks === 'number' ? options.BeforeTasks : undefined, + DEFAULT_BEFORE_TASKS, + ), + MaxPictures: nonNegative( + typeof options?.MaxPictures === 'number' ? options.MaxPictures : undefined, + DEFAULT_MAX_PICTURES, + ), + }; +} + +export function resolveAssertCallArgs( + message?: string | object, + options?: AssertCallArgs['options'], +): AssertCallArgs { + if (message && typeof message === 'object' && !Array.isArray(message)) { + return { + options: { + ...(options || {}), + ...(message as AssertCallArgs['options']), + }, + }; + } + return { + ...(typeof message === 'string' ? { message } : {}), + ...(options ? { options } : {}), + }; +} + +export function isActionSpaceTask( + task: Pick | undefined, +): boolean { + return task?.type === 'Action Space'; +} + +export function isAssertTask( + task: Pick | undefined, +): boolean { + if (!task) { + return false; + } + if (task.type === 'Assert' || task.type === 'Assertion') { + return true; + } + return task.type === 'Insight' && task.subType === 'Assert'; +} + +export function isFinalPlanningSummary( + task: Pick | undefined, +): boolean { + if (!task || task.type !== 'Planning' || task.status !== 'finished') { + return false; + } + const output = task.output as + | { shouldContinuePlanning?: boolean; actions?: unknown[] } + | undefined; + return output?.shouldContinuePlanning === false && !output?.actions?.length; +} + +export function actionEvidencePhase(value: string | undefined): string { + if (!value) { + return ''; + } + const match = ACTION_EVIDENCE_PHASE_RE.exec(value); + return match?.[1] ?? ''; +} + +function statusText(status: string | undefined): string { + if (!status) { + return ''; + } + return STATUS_TEXT[status] ?? status; +} + +function taskPrompt(task: ExecutionTask): string { + const param = task.param as Record | undefined; + if (typeof param?.assertion === 'string' && param.assertion.trim()) { + return param.assertion; + } + if (typeof param?.thought === 'string' && param.thought.trim()) { + return param.thought; + } + if (typeof task.thought === 'string' && task.thought.trim()) { + return task.thought; + } + if (typeof param?.description === 'string' && param.description.trim()) { + return param.description; + } + const locate = param?.locate as { prompt?: unknown; description?: unknown }; + if (typeof locate?.description === 'string' && locate.description.trim()) { + return locate.description; + } + if (typeof locate?.prompt === 'string' && locate.prompt.trim()) { + return locate.prompt; + } + if (typeof param?.userInstructionDisplay === 'string') { + return param.userInstructionDisplay; + } + if (typeof param?.userInstruction === 'string') { + return param.userInstruction; + } + if (typeof param?.dataDemand === 'string') { + return param.dataDemand; + } + return ''; +} + +function failureReason(task: ExecutionTask): string | undefined { + if (typeof task.errorMessage === 'string' && task.errorMessage.trim()) { + return task.errorMessage; + } + const error = task.error as { message?: string } | string | undefined; + if (typeof error === 'string' && error.trim()) { + return error; + } + if (error && typeof error === 'object' && typeof error.message === 'string') { + return error.message; + } + return undefined; +} + +export function recentAssertionExecutions( + allExecutions: Array, + maxExecutions: number, + boundary: AssertionBoundary, +): Array { + const nonEmpty = allExecutions.filter( + (item) => Array.isArray(item.tasks) && item.tasks.length > 0, + ); + let start = 0; + if (boundary === 'lastAssert') { + for (let index = nonEmpty.length - 1; index >= 0; index--) { + if (nonEmpty[index].tasks.some((task) => isAssertTask(task))) { + start = index + 1; + break; + } + } + } + return maxExecutions > 0 ? nonEmpty.slice(start).slice(-maxExecutions) : []; +} + +function eligibleTasksFromExecutions( + executions: Array, +): Array<{ execution: ExecutionDump | IExecutionDump; task: ExecutionTask }> { + const selected: Array<{ + execution: ExecutionDump | IExecutionDump; + task: ExecutionTask; + }> = []; + for (const execution of executions) { + for (const task of execution.tasks || []) { + if (task.type === 'Log' || isFinalPlanningSummary(task)) { + continue; + } + selected.push({ execution, task }); + } + } + return selected; +} + +export function buildEvaluationContext( + executions: Array, + beforeTasks: number, +): AssertionEvaluationContext { + const eligible = eligibleTasksFromExecutions(executions); + const window = beforeTasks > 0 ? eligible.slice(-beforeTasks) : []; + const contextExecutions = executions.map((execution) => ({ + executionId: execution.id, + title: execution.name || '', + })); + const tasks = window.map(({ execution, task }) => ({ + executionTitle: execution.name || '', + taskId: task.taskId, + type: task.type, + ...(task.subType ? { subType: task.subType } : {}), + prompt: taskPrompt(task) || '(无描述)', + status: task.status, + ...(failureReason(task) ? { failureReason: failureReason(task) } : {}), + })); + + const summaryParts = contextExecutions.map((item) => item.title).filter(Boolean); + return { + summary: + summaryParts.length > 0 + ? `关联执行:${summaryParts.join(';')}` + : '无关联执行', + executions: contextExecutions, + tasks, + }; +} + +function screenshotIdentity(screenshot: { + id?: string; + base64?: string; +}): { id?: string; content?: string } { + return { + ...(screenshot.id ? { id: screenshot.id } : {}), + ...(screenshot.base64 ? { content: screenshot.base64 } : {}), + }; +} + +export function selectAssertionEvidenceImages( + executions: Array, + maxPictures: number, +): AssertionEvidenceImage[] { + if (maxPictures <= 0) { + return []; + } + + const candidates: AssertionEvidenceImage[] = []; + for (const execution of executions) { + for (const task of execution.tasks || []) { + if (!isActionSpaceTask(task)) { + continue; + } + for (const record of task.recorder || []) { + const phase = actionEvidencePhase(record.timing); + if (!phase) { + continue; + } + const screenshot = record.screenshot; + const url = screenshot?.base64; + if (!url) { + continue; + } + const subtype = task.subType || task.type; + candidates.push({ + name: `${execution.name || ''} / ${subtype} / ${phase}`, + url, + ...(typeof screenshot.capturedAt === 'number' + ? { capturedAt: screenshot.capturedAt } + : {}), + ...(screenshot.id ? { id: screenshot.id } : {}), + } as AssertionEvidenceImage & { id?: string }); + } + } + } + + const selected: AssertionEvidenceImage[] = []; + const seenIds = new Set(); + const seenContent = new Set(); + + for ( + let index = candidates.length - 1; + index >= 0 && selected.length < maxPictures; + index-- + ) { + const image = candidates[index] as AssertionEvidenceImage & { id?: string }; + const identity = screenshotIdentity({ id: image.id, base64: image.url }); + if (identity.id && seenIds.has(identity.id)) { + continue; + } + if (identity.content && seenContent.has(identity.content)) { + continue; + } + if (identity.id) { + seenIds.add(identity.id); + } + if (identity.content) { + seenContent.add(identity.content); + } + selected.push({ + name: image.name, + url: image.url, + ...(image.capturedAt === undefined + ? {} + : { capturedAt: image.capturedAt }), + }); + } + + return selected; +} + +export function citationForContextTask( + task: AssertionEvaluationContext['tasks'][number], + index: number, +): string { + const label = task.subType ? `${task.type}/${task.subType}` : task.type; + const failure = task.failureReason + ? `;失败原因:${task.failureReason}` + : ''; + return `Task ${index}: [${task.executionTitle}] ${label} - ${task.prompt || '(无描述)'} -> ${statusText(task.status)}${failure}`; +} + +function analysisSection(thought: string, section: string): string { + const start = thought.indexOf(section); + if (start < 0) { + return ''; + } + const contentStart = start + section.length; + const following = ANALYSIS_SECTIONS.map((candidate) => + candidate === section ? -1 : thought.indexOf(candidate, contentStart), + ).filter((index) => index >= 0); + const end = following.length > 0 ? Math.min(...following) : thought.length; + return thought.slice(contentStart, end).trim(); +} + +function stripForbiddenTerms(value: string): string { + return value + .replace(/构建响应/g, '') + .replace(/响应构建/g, '') + .replace(/DATA_DEMAND/g, '') + .replace(/StatementIsTruthy/g, '') + .trim(); +} + +export function composeAssertThought(input: { + modelThought?: string; + assertion: string; + passed: boolean; + evaluationContext?: AssertionEvaluationContext; + evidenceImages?: AssertionEvidenceImage[]; +}): string { + const raw = stripForbiddenTerms(input.modelThought || ''); + const current = + analysisSection(raw, '当前界面判断') || + (raw && !ANALYSIS_SECTIONS.some((section) => raw.includes(section)) + ? raw + : ''); + const screenshot = + analysisSection(raw, '截图证据分析') || + (input.evidenceImages && input.evidenceImages.length > 0 + ? '对比动作前后界面可见内容,结合断言目标判断状态是否成立。' + : '当前没有可用的动作前后截图,依据最终界面判断。'); + const conclusion = + analysisSection(raw, '最终结论') || + (input.passed + ? `断言「${input.assertion}」成立。` + : `断言「${input.assertion}」不成立。`); + + const tasks = input.evaluationContext?.tasks ?? []; + const relatedLines = + tasks.length === 0 + ? ['没有可引用的关联任务。'] + : tasks.flatMap((task, index) => { + const citation = citationForContextTask(task, index + 1); + return [ + citation, + `该任务执行了「${task.prompt || '(无描述)'}」,状态为${statusText(task.status)},据此影响断言判断。`, + ]; + }); + + return [ + '当前界面判断', + current || + (input.passed + ? `最终界面满足断言「${input.assertion}」。` + : `最终界面未满足断言「${input.assertion}」。`), + '关联 task 分析', + relatedLines.join('\n'), + '截图证据分析', + screenshot, + '最终结论', + conclusion, + ].join('\n'); +} + +export function deepAssertEvidence( + task: Pick | undefined, +): AssertionEvidenceImage[] | undefined { + if (!task || !isAssertTask(task) || task.param?.deepAssert === false) { + return undefined; + } + const param = task.param as + | { assertionEvidenceImages?: AssertionEvidenceImage[]; deepAssert?: boolean } + | undefined; + if ( + param?.assertionEvidenceImages === undefined && + param?.deepAssert !== true + ) { + return undefined; + } + return param?.assertionEvidenceImages ?? []; +} + +export function buildDeepAssertScreenshots( + task: Pick | undefined, +): + | Array<{ + screenshot: string; + timing: string; + screenshotTimestamp?: number; + }> + | null + | undefined { + const evidence = deepAssertEvidence(task); + if (evidence === undefined) { + return undefined; + } + if (evidence.length === 0) { + return null; + } + return evidence + .slice() + .reverse() + .map((image, index) => ({ + screenshot: image.url, + timing: `参考图${index + 1} / ${image.name}`, + ...(image.capturedAt === undefined + ? {} + : { screenshotTimestamp: image.capturedAt }), + })); +} + +export function asEvaluationContext( + value: unknown, +): AssertionEvaluationContext | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const candidate = value as AssertionEvaluationContext; + if ( + typeof candidate.summary !== 'string' || + !Array.isArray(candidate.executions) || + !Array.isArray(candidate.tasks) + ) { + return undefined; + } + return candidate; +} + +export function asEvidenceImages( + value: unknown, +): AssertionEvidenceImage[] | undefined { + return Array.isArray(value) ? (value as AssertionEvidenceImage[]) : undefined; +} + +export function isCurrentScreenshotFallback( + task: Pick | undefined, +): boolean { + return task?.param?.assertionEvidenceFallback === 'currentScreenshot'; +} + +export function buildAssertionEvidenceModelContext(input: { + assertion: string; + evaluationContext?: AssertionEvaluationContext; + evidenceImages: AssertionEvidenceImage[]; +}): string { + const taskLines = (input.evaluationContext?.tasks ?? []).map((task, index) => + citationForContextTask(task, index + 1), + ); + const imageLines = input.evidenceImages.map( + (image, index) => `图${index + 1}: ${image.name}`, + ); + + return [ + `断言目标:${input.assertion}`, + input.evaluationContext?.summary + ? `所选执行摘要:${input.evaluationContext.summary}` + : '', + taskLines.length > 0 + ? `所选任务证据:\n${taskLines.join('\n')}` + : '所选任务证据:无', + imageLines.length > 0 + ? `具名证据图(与 assertionEvidenceImages 一一对应,最新在前):\n${imageLines.join('\n')}` + : '具名证据图:无', + '判断规则:先看最终是否成功;不要只因为路径或中间动作不同就判失败。只有断言明确要求动态过程时,才用证据链评过程。', + '请用中文按以下四段作答,且不要出现 DATA_DEMAND、StatementIsTruthy、构建响应、响应构建:当前界面判断;关联 task 分析(逐条引用上述 Task N);截图证据分析(比较 before-calling 与 after-calling-* 的可见变化,不要罗列图名);最终结论。', + ] + .filter(Boolean) + .join('\n'); +} diff --git a/packages/core/src/agent/execution-session.ts b/packages/core/src/agent/execution-session.ts index 687b9e0a7c..79fdbe07d4 100644 --- a/packages/core/src/agent/execution-session.ts +++ b/packages/core/src/agent/execution-session.ts @@ -13,6 +13,8 @@ import type { type ExecutionSessionOptions = ExecutionTaskProgressOptions & { tasks?: ExecutionTaskApply[]; referenceImages?: readonly ExecutionReferenceImage[]; + actionEvidenceAfterFrameCount?: number; + actionEvidenceFrameIntervalMs?: number; onSnapshotChange?: ( runner: TaskRunner, error?: TaskExecutionError, diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index 986baaaacf..0bb61af1bf 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -18,6 +18,11 @@ export { type LocateCache, type PlanningCache, TaskCache } from './task-cache'; export { cacheFileExt } from './task-cache'; export { TaskExecutor } from './tasks'; +export { + buildDeepAssertScreenshots, + deepAssertEvidence, + isCurrentScreenshotFallback, +} from './assertion-evidence'; export type { MidsceneUsageMetrics, UsageBucket } from './metrics'; export type { GherkinStepKeyword, diff --git a/packages/core/src/agent/insight.ts b/packages/core/src/agent/insight.ts index c1debc3637..4e7e828a90 100644 --- a/packages/core/src/agent/insight.ts +++ b/packages/core/src/agent/insight.ts @@ -8,6 +8,7 @@ import type { ServiceExtractParam, UIContext, } from '@/types'; +import { resolveAssertCallArgs } from './assertion-evidence'; import { TaskExecutionError, type TaskExecutor } from './tasks'; import { parsePrompt } from './utils'; @@ -113,14 +114,37 @@ export class Insight implements InsightAPI { async aiAssert( assertion: TUserPrompt, - message?: string, + message?: string | AssertOptions, options?: AssertOptions, ): Promise { + const resolved = resolveAssertCallArgs(message, options); + const assertOptions = resolved.options as AssertOptions | undefined; const serviceOptions: QueryOptions = { - domIncluded: options?.domIncluded ?? defaultQueryOptions.domIncluded, + domIncluded: + assertOptions?.domIncluded ?? defaultQueryOptions.domIncluded, screenshotIncluded: - options?.screenshotIncluded ?? defaultQueryOptions.screenshotIncluded, - ...(options?.context !== undefined ? { context: options.context } : {}), + assertOptions?.screenshotIncluded ?? + defaultQueryOptions.screenshotIncluded, + ...(assertOptions?.context !== undefined + ? { context: assertOptions.context } + : {}), + ...(assertOptions?.deepAssert === undefined + ? {} + : { deepAssert: assertOptions.deepAssert }), + ...(assertOptions?.AssertionContextBoundary === undefined + ? {} + : { + AssertionContextBoundary: assertOptions.AssertionContextBoundary, + }), + ...(assertOptions?.BeforeExecutions === undefined + ? {} + : { BeforeExecutions: assertOptions.BeforeExecutions }), + ...(assertOptions?.BeforeTasks === undefined + ? {} + : { BeforeTasks: assertOptions.BeforeTasks }), + ...(assertOptions?.MaxPictures === undefined + ? {} + : { MaxPictures: assertOptions.MaxPictures }), }; const { textPrompt, multimodalPrompt } = parsePrompt(assertion); const assertionText = @@ -134,15 +158,15 @@ export class Insight implements InsightAPI { this.resolveModelRuntime(), serviceOptions, multimodalPrompt, - this.executionOptions(options, true), + this.executionOptions(assertOptions, true), ); const pass = Boolean(output); const failureMessage = pass ? undefined - : `Assertion failed: ${message || assertionText}\nReason: ${thought || '(no_reason)'}`; + : `Assertion failed: ${resolved.message || assertionText}\nReason: ${thought || '(no_reason)'}`; - if (options?.keepRawResponse) { + if (assertOptions?.keepRawResponse) { return { pass, thought, message: failureMessage }; } if (!pass) { @@ -154,9 +178,9 @@ export class Insight implements InsightAPI { const diagnosticMessage = error.task?.errorMessage || error.cause.message; const reason = thought || diagnosticMessage || '(no_reason)'; - const failureMessage = `Assertion failed: ${message || assertionText}\nReason: ${reason}`; + const failureMessage = `Assertion failed: ${resolved.message || assertionText}\nReason: ${reason}`; - if (options?.keepRawResponse) { + if (assertOptions?.keepRawResponse) { return { pass: false, thought, message: failureMessage }; } throw new Error(failureMessage, { cause: error.cause }); diff --git a/packages/core/src/agent/tasks.ts b/packages/core/src/agent/tasks.ts index 125e6014a6..08cd397895 100644 --- a/packages/core/src/agent/tasks.ts +++ b/packages/core/src/agent/tasks.ts @@ -22,14 +22,17 @@ import type { AiActEffort, AiActProgressData, AiActProgressPhase, + AssertionEvidenceImage, DetailedLocateParam, DeviceAction, + ExecutionDump, ExecutionRecorderItem, ExecutionTask, ExecutionTaskApply, ExecutionTaskInsightQueryApply, ExecutionTaskPlanningApply, ExecutionTaskProgressOptions, + IExecutionDump, MidsceneYamlFlowItem, PlanningAIResponse, PlanningAction, @@ -42,6 +45,17 @@ import type { import { ServiceError, aiActProgressScope } from '@/types'; import { getDebug } from '@midscene/shared/logger'; import { assert } from '@midscene/shared/utils'; +import { + asEvaluationContext, + asEvidenceImages, + buildAssertionEvidenceModelContext, + buildEvaluationContext, + composeAssertThought, + normalizeActionEvidenceOptions, + normalizeAssertEvidenceOptions, + recentAssertionExecutions, + selectAssertionEvidenceImages, +} from './assertion-evidence'; import { ExecutionSession } from './execution-session'; import { withFileChooser } from './file-chooser'; import { @@ -122,6 +136,10 @@ export class TaskExecutor { useDeviceTime?: boolean; + private readonly getAssertionExecutions?: () => Array< + ExecutionDump | IExecutionDump + >; + // @deprecated use .interface instead get page() { return this.interface; @@ -138,6 +156,7 @@ export class TaskExecutor { useDeviceTime?: boolean; hooks?: TaskExecutorHooks; actionSpace: DeviceAction[]; + getAssertionExecutions?: () => Array; }, ) { this.interface = interfaceInstance; @@ -147,6 +166,7 @@ export class TaskExecutor { this.replanningCycleLimit = opts.replanningCycleLimit; this.waitAfterAction = opts.waitAfterAction; this.useDeviceTime = opts.useDeviceTime; + this.getAssertionExecutions = opts.getAssertionExecutions; this.hooks = opts.hooks; this.providedActionSpace = opts.actionSpace; this.taskBuilder = new TaskBuilder({ @@ -164,6 +184,8 @@ export class TaskExecutor { tasks?: ExecutionTaskApply[]; uiContext?: UIContext; referenceImages?: readonly ExecutionReferenceImage[]; + actionEvidenceAfterFrameCount?: number; + actionEvidenceFrameIntervalMs?: number; onSnapshotChange?: ( runner: TaskRunner, error?: TaskExecutionError, @@ -181,6 +203,18 @@ export class TaskExecutor { onTaskStart: this.onTaskStartCallback, tasks: options?.tasks, referenceImages: options?.referenceImages, + ...(options?.actionEvidenceAfterFrameCount === undefined + ? {} + : { + actionEvidenceAfterFrameCount: + options.actionEvidenceAfterFrameCount, + }), + ...(options?.actionEvidenceFrameIntervalMs === undefined + ? {} + : { + actionEvidenceFrameIntervalMs: + options.actionEvidenceFrameIntervalMs, + }), onSnapshotChange: async (runner, error) => { await this.hooks?.onSnapshotChange?.(runner, error); await options?.onSnapshotChange?.(runner, error); @@ -380,6 +414,10 @@ export class TaskExecutor { deepLocate?: boolean, abortSignal?: AbortSignal, reportOptions?: ActionReportOptions, + actionEvidence?: { + AfterActPictures?: number; + Interval?: number; + }, ): Promise< ExecutionResult< | { @@ -401,6 +439,7 @@ export class TaskExecutor { deepLocate, abortSignal, reportOptions, + actionEvidence, ); }); } @@ -450,6 +489,10 @@ export class TaskExecutor { deepLocate?: boolean, abortSignal?: AbortSignal, reportOptions?: ActionReportOptions, + actionEvidence?: { + AfterActPictures?: number; + Interval?: number; + }, ): Promise< ExecutionResult< | { @@ -462,6 +505,8 @@ export class TaskExecutor { const conversationHistory = new ConversationHistory(); const promptDisplay = reportOptions?.prompt || userPromptToString(userPrompt); + const normalizedActionEvidence = + normalizeActionEvidenceOptions(actionEvidence); // Per-call reporter that maps the runner's native task events to aiAct // action progress for the action batch currently running. Kept local (not @@ -475,6 +520,9 @@ export class TaskExecutor { taskTitleStr(reportOptions?.type || 'Act', promptDisplay), { referenceImages: userPromptToMultimodalPrompt(userPrompt)?.images, + actionEvidenceAfterFrameCount: + normalizedActionEvidence.AfterActPictures, + actionEvidenceFrameIntervalMs: normalizedActionEvidence.Interval, onTaskEvent: async (event) => { await activeActionReporter?.(event); }, @@ -878,6 +926,14 @@ export class TaskExecutor { abortSignal?: AbortSignal; }, ) { + const assertEvidence = + type === 'Assert' ? normalizeAssertEvidenceOptions(opt) : undefined; + const assertionText = + type === 'Assert' + ? typeof demand === 'string' + ? demand + : JSON.stringify(demand) + : undefined; const queryTask: ExecutionTaskInsightQueryApply = { type: 'Insight', subType: type, @@ -890,6 +946,36 @@ export class TaskExecutor { multimodalPrompt, } as never) : demand, // for user param presentation in report right sidebar + ...(type === 'Assert' && assertionText !== undefined && assertEvidence + ? { + assertion: assertionText, + deepAssert: assertEvidence.deepAssert, + AssertionContextBoundary: + assertEvidence.AssertionContextBoundary, + BeforeExecutions: assertEvidence.BeforeExecutions, + BeforeTasks: assertEvidence.BeforeTasks, + MaxPictures: assertEvidence.MaxPictures, + ...(asEvaluationContext(opt?.evaluationContext) + ? { + evaluationContext: asEvaluationContext( + opt?.evaluationContext, + ), + } + : {}), + ...(asEvidenceImages(opt?.assertionEvidenceImages) + ? { + assertionEvidenceImages: asEvidenceImages( + opt?.assertionEvidenceImages, + ), + } + : {}), + ...(opt?.assertionEvidenceFallback === 'currentScreenshot' + ? { + assertionEvidenceFallback: 'currentScreenshot' as const, + } + : {}), + } + : {}), }, executor: async (taskContext) => { const { task } = taskContext; @@ -942,11 +1028,63 @@ export class TaskExecutor { ); } + let evidenceImages = Array.isArray(opt?.assertionEvidenceImages) + ? ([...opt.assertionEvidenceImages] as AssertionEvidenceImage[]) + : []; + let evidenceFallback = opt?.assertionEvidenceFallback as + | 'currentScreenshot' + | undefined; + if ( + type === 'Assert' && + assertEvidence?.deepAssert && + assertEvidence.MaxPictures !== 0 && + evidenceImages.length === 0 + ) { + evidenceFallback = 'currentScreenshot'; + if (task.param) { + task.param.assertionEvidenceFallback = 'currentScreenshot'; + task.param.assertionEvidenceImages = []; + } + } else if ( + type === 'Assert' && + assertEvidence?.deepAssert && + task.param + ) { + task.param.assertionEvidenceImages = evidenceImages; + } + + const extractOpt = + type === 'Assert' && assertEvidence?.deepAssert + ? { + ...opt, + deepAssert: true, + assertionEvidenceImages: evidenceImages, + assertionEvidenceFallback: evidenceFallback, + screenshotIncluded: + evidenceFallback === 'currentScreenshot' + ? opt?.screenshotIncluded + : false, + context: [ + opt?.context, + buildAssertionEvidenceModelContext({ + assertion: assertionText || String(demand), + evaluationContext: task.param?.evaluationContext, + evidenceImages, + }), + ] + .filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0, + ) + .join('\n\n'), + } + : opt; + try { extractResult = await this.service.extract( demandInput, modelRuntime, - opt, + extractOpt, extraPageDescription, multimodalPrompt, uiContext, @@ -963,8 +1101,9 @@ export class TaskExecutor { recordAndReleaseScreenshotSequence(task, uiContext); } - const { data, thought, dump } = extractResult; + const { data, thought: rawThought, dump } = extractResult; applyDump(dump); + let thought = rawThought; let outputResult = data; if (ifTypeRestricted) { @@ -995,6 +1134,18 @@ export class TaskExecutor { // the model dump before aiAssert turns the result into a thrown error. if (type === 'Assert') { outputResult = Boolean(outputResult); + if (assertEvidence?.deepAssert) { + thought = composeAssertThought({ + modelThought: thought, + assertion: assertionText || String(demand), + passed: outputResult, + evaluationContext: task.param?.evaluationContext, + evidenceImages: + (task.param?.assertionEvidenceImages as + | AssertionEvidenceImage[] + | undefined) || evidenceImages, + }); + } } return { @@ -1018,6 +1169,50 @@ export class TaskExecutor { uiContext?: UIContext; }, ): Promise> { + const assertEvidence = + type === 'Assert' ? normalizeAssertEvidenceOptions(opt) : undefined; + const assertionText = + type === 'Assert' + ? typeof demand === 'string' + ? demand + : JSON.stringify(demand) + : undefined; + let assertOpt = opt; + if (type === 'Assert' && assertEvidence?.deepAssert) { + const selectedExecutions = recentAssertionExecutions( + this.getAssertionExecutions?.() ?? [], + assertEvidence.BeforeExecutions, + assertEvidence.AssertionContextBoundary, + ); + const evaluationContext = buildEvaluationContext( + selectedExecutions, + assertEvidence.BeforeTasks, + ); + const assertionEvidenceImages = selectAssertionEvidenceImages( + selectedExecutions, + assertEvidence.MaxPictures, + ); + assertOpt = { + ...opt, + deepAssert: true, + AssertionContextBoundary: assertEvidence.AssertionContextBoundary, + BeforeExecutions: assertEvidence.BeforeExecutions, + BeforeTasks: assertEvidence.BeforeTasks, + MaxPictures: assertEvidence.MaxPictures, + evaluationContext, + assertionEvidenceImages, + }; + } else if (type === 'Assert' && assertEvidence) { + assertOpt = { + ...opt, + deepAssert: false, + AssertionContextBoundary: assertEvidence.AssertionContextBoundary, + BeforeExecutions: assertEvidence.BeforeExecutions, + BeforeTasks: assertEvidence.BeforeTasks, + MaxPictures: assertEvidence.MaxPictures, + }; + } + const session = this.createExecutionSession( taskTitleStr( type, @@ -1033,11 +1228,39 @@ export class TaskExecutor { const runner = session.getRunner(); const executionModelRuntime = { ...modelRuntime, executionId: runner.id }; + + if ( + type === 'Assert' && + assertEvidence?.deepAssert && + assertEvidence.MaxPictures === 0 + ) { + const failedAssertTask: ExecutionTaskInsightQueryApply = { + type: 'Insight', + subType: 'Assert', + param: { + assertion: assertionText, + dataDemand: demand, + deepAssert: true, + AssertionContextBoundary: assertEvidence.AssertionContextBoundary, + BeforeExecutions: assertEvidence.BeforeExecutions, + BeforeTasks: assertEvidence.BeforeTasks, + MaxPictures: 0, + evaluationContext: asEvaluationContext(assertOpt?.evaluationContext), + assertionEvidenceImages: [], + }, + executor: async () => { + throw new Error('MaxPictures is 0'); + }, + }; + await session.appendAndRun(failedAssertTask); + throw new Error('MaxPictures is 0'); + } + const queryTask = await this.createTypeQueryTask( type, demand, executionModelRuntime, - opt, + assertOpt, multimodalPrompt, executionOptions, ); diff --git a/packages/core/src/agent/ui-observer.ts b/packages/core/src/agent/ui-observer.ts index 55e339c2d7..503fa4daac 100644 --- a/packages/core/src/agent/ui-observer.ts +++ b/packages/core/src/agent/ui-observer.ts @@ -156,7 +156,7 @@ export class UIObservationImpl implements UIObservation { async aiAssert( assertion: TUserPrompt, - message?: string, + message?: string | ObservationAssertOptions, options?: ObservationAssertOptions, ): Promise { this.ensureUsable(); diff --git a/packages/core/src/ai-model/workflows/insight/extraction.ts b/packages/core/src/ai-model/workflows/insight/extraction.ts index 2e0013f1b9..31b0d15b7d 100644 --- a/packages/core/src/ai-model/workflows/insight/extraction.ts +++ b/packages/core/src/ai-model/workflows/insight/extraction.ts @@ -1,4 +1,8 @@ -import type { ServiceExtractOption, UIContext } from '@/types'; +import type { + AssertionEvidenceImage, + ServiceExtractOption, + UIContext, +} from '@/types'; import { getDebug } from '@midscene/shared/logger'; import type { ChatCompletionSystemMessageParam, @@ -36,10 +40,17 @@ export async function AiExtractElementInfo(options: { }) { const { dataQuery, context, extractOption, multimodalPrompt, modelRuntime } = options; + const evidenceImages = Array.isArray(extractOption?.assertionEvidenceImages) + ? (extractOption.assertionEvidenceImages as AssertionEvidenceImage[]) + : []; + const useEvidenceImages = + extractOption?.deepAssert !== false && evidenceImages.length > 0; const insightProtocol = modelRuntime.adapter.insight.protocol; const systemPrompt = buildInsightSystemPrompt({ - screenshotIncluded: extractOption?.screenshotIncluded !== false, - referenceImagesIncluded: !!multimodalPrompt?.images?.length, + screenshotIncluded: + extractOption?.screenshotIncluded !== false && !useEvidenceImages, + referenceImagesIncluded: + !!multimodalPrompt?.images?.length || useEvidenceImages, insightProtocol, }); const screenshotBase64 = context.screenshot.base64; @@ -51,7 +62,25 @@ export async function AiExtractElementInfo(options: { const userContent: ChatCompletionUserMessageParam['content'] = []; - if (extractOption?.screenshotIncluded !== false) { + if (useEvidenceImages) { + userContent.push({ + type: 'text', + text: `The following ${evidenceImages.length} images are assertion evidence-chain screenshots, ordered newest first. Image names match assertionEvidenceImages. Use them as the primary visual evidence.`, + }); + evidenceImages.forEach((image, index) => { + userContent.push({ + type: 'text', + text: `Evidence ${index + 1}/${evidenceImages.length}: ${image.name}`, + }); + userContent.push({ + type: 'image_url', + image_url: { + url: image.url, + detail: 'high', + }, + }); + }); + } else if (extractOption?.screenshotIncluded !== false) { const screenshotSequence = context.screenshotSequence; if (screenshotSequence && screenshotSequence.length > 1) { userContent.push({ diff --git a/packages/core/src/task-runner.ts b/packages/core/src/task-runner.ts index 0d8bb50245..de5f586b52 100644 --- a/packages/core/src/task-runner.ts +++ b/packages/core/src/task-runner.ts @@ -1,3 +1,9 @@ +import { + DEFAULT_AFTER_FRAMES, + DEFAULT_FRAME_INTERVAL_MS, + isActionSpaceTask, + nonNegative, +} from '@/agent/assertion-evidence'; import type { ScreenshotItem } from '@/screenshot-item'; import { setTimingFieldOnce } from '@/task-timing'; import { @@ -13,6 +19,7 @@ import { type PlanningActionParamError, type UIContext, } from '@/types'; +import { sleep } from '@/utils'; import { type SerializedError, serializeError, @@ -95,6 +102,8 @@ export interface ExecutionReferenceImage { type TaskRunnerInitOptions = ExecutionTaskProgressOptions & { tasks?: ExecutionTaskApply[]; referenceImages?: readonly ExecutionReferenceImage[]; + actionEvidenceAfterFrameCount?: number; + actionEvidenceFrameIntervalMs?: number; /** * Coarse "the execution snapshot changed" signal. Fires on any state change * (append, status flips, completion) with the whole runner, so consumers can @@ -135,6 +144,10 @@ export class TaskRunner { private readonly referenceImageUrls = new Set(); + private readonly actionEvidenceAfterFrameCount: number; + + private readonly actionEvidenceFrameIntervalMs: number; + constructor( name: string, uiContextBuilder: () => Promise, @@ -152,6 +165,14 @@ export class TaskRunner { this.onSnapshotChange = options?.onSnapshotChange; this.onTaskEvent = options?.onTaskEvent; this.executionLogTime = Date.now(); + this.actionEvidenceAfterFrameCount = nonNegative( + options?.actionEvidenceAfterFrameCount, + DEFAULT_AFTER_FRAMES, + ); + this.actionEvidenceFrameIntervalMs = nonNegative( + options?.actionEvidenceFrameIntervalMs, + DEFAULT_FRAME_INTERVAL_MS, + ); for (const image of options?.referenceImages ?? []) { this.referenceImageUrls.add(image.url); } @@ -229,7 +250,7 @@ export class TaskRunner { private attachRecorderItem( task: ExecutionTask, screenshot: ScreenshotItem | undefined, - phase: 'after-calling', + phase: string, ): void { if (!phase || !screenshot) { return; @@ -249,6 +270,20 @@ export class TaskRunner { task.recorder.push(recorderItem); } + private async capturePostActionFrames(task: ExecutionTask): Promise { + for ( + let index = 0; + index < this.actionEvidenceAfterFrameCount; + index++ + ) { + if (index > 0 && this.actionEvidenceFrameIntervalMs > 0) { + await sleep(this.actionEvidenceFrameIntervalMs); + } + const screenshot = await this.captureScreenshot(); + this.attachRecorderItem(task, screenshot, `after-calling-${index + 1}`); + } + } + private markTaskAsPending(task: ExecutionTaskApply): ExecutionTask { return { taskId: uuid(), @@ -370,6 +405,9 @@ export class TaskRunner { setTimingFieldOnce(task.timing, 'getUiContextEnd'); task.uiContext = uiContext; + if (isActionSpaceTask(task)) { + this.attachRecorderItem(task, uiContext?.screenshot, 'before-calling'); + } const executorContext: ExecutorContext = { task, element: previousFindOutput?.element, @@ -410,9 +448,15 @@ export class TaskRunner { returnValue = await task.executor(executorContext); } + if (isActionSpaceTask(task)) { + setTimingFieldOnce(task.timing, 'captureAfterCallingSnapshotStart'); + await this.capturePostActionFrames(task); + setTimingFieldOnce(task.timing, 'captureAfterCallingSnapshotEnd'); + } + const isLastTask = taskIndex === this.tasks.length - 1; - if (isLastTask) { + if (isLastTask && !isActionSpaceTask(task)) { setTimingFieldOnce(task.timing, 'captureAfterCallingSnapshotStart'); const screenshot = await this.captureScreenshot(); this.attachRecorderItem(task, screenshot, 'after-calling'); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2f4b6dadd7..9aad757cfe 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -300,10 +300,37 @@ export interface AgentWaitForOpt extends ServiceExtractOption { timeoutMs?: number; } +export type AssertionBoundary = 'lastAssert' | 'session'; + +export interface AssertionEvidenceImage { + name: string; + url: string; + capturedAt?: number; +} + +export interface AssertionEvaluationContext { + summary: string; + executions: Array<{ executionId?: string; title: string }>; + tasks: Array<{ + executionTitle: string; + taskId?: string; + type: string; + subType?: string; + prompt: string; + status: string; + failureReason?: string; + }>; +} + export interface AgentAssertOpt { keepRawResponse?: boolean; context?: string; abortSignal?: AbortSignal; + deepAssert?: boolean; + AssertionContextBoundary?: AssertionBoundary; + BeforeExecutions?: number; + BeforeTasks?: number; + MaxPictures?: number; } export interface AgentAssertResult { @@ -340,7 +367,7 @@ export interface InsightAPI< aiAsk(prompt: TUserPrompt, options?: QueryOpt): Promise; aiAssert( assertion: TUserPrompt, - message?: string, + message?: string | AssertOpt, options?: AssertOpt, ): Promise; } @@ -685,8 +712,17 @@ task - service-query */ export interface ExecutionTaskInsightQueryParam { dataDemand: ServiceExtractParam; + assertion?: string; domIncluded?: boolean | 'visible-only'; context?: string; + deepAssert?: boolean; + AssertionContextBoundary?: AssertionBoundary; + BeforeExecutions?: number; + BeforeTasks?: number; + MaxPictures?: number; + evaluationContext?: AssertionEvaluationContext; + assertionEvidenceFallback?: 'currentScreenshot'; + assertionEvidenceImages?: AssertionEvidenceImage[]; } export interface ExecutionTaskInsightQueryOutput { @@ -708,6 +744,14 @@ task - assertion */ export interface ExecutionTaskInsightAssertionParam { assertion: string; + deepAssert?: boolean; + AssertionContextBoundary?: AssertionBoundary; + BeforeExecutions?: number; + BeforeTasks?: number; + MaxPictures?: number; + evaluationContext?: AssertionEvaluationContext; + assertionEvidenceFallback?: 'currentScreenshot'; + assertionEvidenceImages?: AssertionEvidenceImage[]; } export type ExecutionTaskInsightAssertionApply = ExecutionTaskApply< diff --git a/packages/core/tests/unit-test/assertion-evidence-execution.test.ts b/packages/core/tests/unit-test/assertion-evidence-execution.test.ts new file mode 100644 index 0000000000..81677e8c81 --- /dev/null +++ b/packages/core/tests/unit-test/assertion-evidence-execution.test.ts @@ -0,0 +1,240 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { TaskExecutor } from '@/agent/tasks'; +import { getModelRuntime } from '@/ai-model/models'; +import { ScreenshotItem } from '@/screenshot-item'; +import type { ExecutionTask, IExecutionDump, ServiceDump } from '@/types'; +import { describe, expect, it, rs } from '@rstest/core'; + +const runtimeDumpPath = resolve( + process.cwd(), + 'tests/unit-test/fixtures/deep-assert-runtime-dump.json', +); + +const createMockUIContext = async (screenshotData = 'mock-screenshot') => ({ + screenshot: ScreenshotItem.create(screenshotData, Date.now()), + shotSize: { width: 1920, height: 1080 }, + shrunkShotToLogicalRatio: 1, +}); + +const createMockDump = (data: unknown, thought?: string): ServiceDump => ({ + logTime: Date.now(), + type: 'extract', + logId: 'mock-log-id', + userQuery: {}, + data, + taskInfo: { + durationMs: 100, + rawResponse: JSON.stringify(data), + reasoning_content: thought, + }, +}); + +const shot = (value: string, capturedAt: number) => + ScreenshotItem.create(`data:image/png;base64,${value}`, capturedAt); + +const actionTask = ( + id: string, + frames: Array<{ timing: string; screenshot: ScreenshotItem }>, +): ExecutionTask => + ({ + taskId: id, + type: 'Action Space', + subType: 'Tap', + status: 'finished', + thought: 'tap submit', + param: { description: 'submit button' }, + recorder: frames.map((frame) => ({ + type: 'screenshot', + ts: frame.screenshot.capturedAt, + screenshot: frame.screenshot, + timing: frame.timing, + })), + }) as ExecutionTask; + +const planningTask = (id: string): ExecutionTask => + ({ + taskId: id, + type: 'Planning', + subType: 'Plan', + status: 'finished', + param: { userInstructionDisplay: 'submit the form' }, + output: { + shouldContinuePlanning: false, + actions: [{ type: 'Tap' }], + }, + }) as ExecutionTask; + +const modelRuntime = getModelRuntime({ + modelName: 'mock-model', + modelDescription: 'mock-model-description', + intent: 'insight', + slot: 'insight', +}); + +const createExecutor = ( + history: IExecutionDump[], + thought = + '当前界面判断\n表单已提交。\n关联 task 分析\n占位\n截图证据分析\n前后界面发生变化。\n最终结论\n断言成立。', +) => { + const extract = rs.fn(async () => ({ + data: { StatementIsTruthy: true }, + thought, + dump: createMockDump({ StatementIsTruthy: true }, thought), + })); + const taskExecutor = new TaskExecutor({} as never, { + contextRetrieverFn: rs.fn(async () => createMockUIContext('current')), + extract, + } as never, { + actionSpace: [], + getAssertionExecutions: () => history, + }); + return { taskExecutor, extract }; +}; + +describe('assertion evidence execution', () => { + it('accepts evidence-chain options as the second argument and persists them', async () => { + const { taskExecutor } = createExecutor([]); + const { runner } = await taskExecutor.createTypeQueryExecution( + 'Assert', + 'state is correct', + modelRuntime, + { + deepAssert: true, + BeforeTasks: 2, + MaxPictures: 2, + }, + ); + expect(runner.tasks[0]?.param).toMatchObject({ + assertion: 'state is correct', + deepAssert: true, + BeforeTasks: 2, + MaxPictures: 2, + }); + }); + + it('passes newest unique action evidence to the model', async () => { + const beforeShot = shot('before', 1); + const afterShot = shot('after', 2); + const history = [ + { + id: 'act-1', + name: 'Act - submit', + tasks: [ + planningTask('plan-1'), + actionTask('tap-1', [ + { timing: 'before-calling', screenshot: beforeShot }, + { timing: 'after-calling-1', screenshot: afterShot }, + ]), + ], + }, + ]; + const { taskExecutor, extract } = createExecutor(history); + const { runner } = await taskExecutor.createTypeQueryExecution( + 'Assert', + 'state is correct', + modelRuntime, + { deepAssert: true, MaxPictures: 2 }, + ); + expect( + runner.tasks[0]?.param?.assertionEvidenceImages?.map((image) => image.url), + ).toEqual([afterShot.base64, beforeShot.base64]); + expect(extract.mock.calls[0]?.[2]?.assertionEvidenceImages?.[0]?.url).toBe( + afterShot.base64, + ); + }); + + it('does not build an evidence chain when deepAssert is false', async () => { + const history = [ + { + id: 'act-1', + name: 'Act - submit', + tasks: [ + actionTask('tap-1', [ + { timing: 'before-calling', screenshot: shot('before', 1) }, + ]), + ], + }, + ]; + const { taskExecutor } = createExecutor(history); + const { runner } = await taskExecutor.createTypeQueryExecution( + 'Assert', + 'state is correct', + modelRuntime, + { deepAssert: false }, + ); + expect(runner.tasks[0]?.param?.deepAssert).toBe(false); + expect(runner.tasks[0]?.param?.assertionEvidenceImages).toBeUndefined(); + }); + + it('fails the assert task when MaxPictures is 0', async () => { + const { taskExecutor } = createExecutor([]); + await expect( + taskExecutor.createTypeQueryExecution( + 'Assert', + 'state is correct', + modelRuntime, + { deepAssert: true, MaxPictures: 0 }, + ), + ).rejects.toThrow(/MaxPictures is 0/); + }); + + it('falls back to the current screenshot when there is no history', async () => { + const { taskExecutor } = createExecutor([]); + const { runner } = await taskExecutor.createTypeQueryExecution( + 'Assert', + 'state is correct', + modelRuntime, + { deepAssert: true, MaxPictures: 2 }, + ); + expect(runner.tasks[0]?.param?.assertionEvidenceFallback).toBe( + 'currentScreenshot', + ); + expect(runner.tasks[0]?.param?.assertionEvidenceImages).toEqual([]); + }); + + it('writes a full AfterActPictures=3 MaxPictures=4 dump for report verification', async () => { + const frames = [1, 2, 3, 4].map((index) => + shot(`frame-${index}`, index * 10), + ); + const history: IExecutionDump[] = [ + { + id: 'act-1', + name: 'Act - submit form', + tasks: [ + planningTask('plan-1'), + actionTask('tap-1', [ + { timing: 'before-calling', screenshot: frames[0] }, + { timing: 'after-calling-1', screenshot: frames[1] }, + { timing: 'after-calling-2', screenshot: frames[2] }, + { timing: 'after-calling-3', screenshot: frames[3] }, + ]), + ], + }, + ]; + const { taskExecutor } = createExecutor(history); + const { runner } = await taskExecutor.createTypeQueryExecution( + 'Assert', + 'the form is submitted', + modelRuntime, + { + deepAssert: true, + BeforeTasks: 2, + MaxPictures: 4, + AssertionContextBoundary: 'session', + }, + ); + + const dump = { + executions: [history[0], runner.dump().toJSON()], + }; + mkdirSync(dirname(runtimeDumpPath), { recursive: true }); + writeFileSync(runtimeDumpPath, JSON.stringify(dump, null, 2), 'utf8'); + + expect(runner.tasks[0]?.param?.BeforeTasks).toBe(2); + expect(runner.tasks[0]?.param?.assertionEvidenceImages?.length).toBe(4); + expect(runner.tasks[0]?.thought).toContain('当前界面判断'); + expect(runner.tasks[0]?.thought).toContain('Task 1:'); + expect(runner.tasks[0]?.thought).toContain('Task 2:'); + }); +}); diff --git a/packages/core/tests/unit-test/assertion-evidence.test.ts b/packages/core/tests/unit-test/assertion-evidence.test.ts new file mode 100644 index 0000000000..79a967667a --- /dev/null +++ b/packages/core/tests/unit-test/assertion-evidence.test.ts @@ -0,0 +1,269 @@ +import { + buildDeepAssertScreenshots, + buildEvaluationContext, + citationForContextTask, + composeAssertThought, + deepAssertEvidence, + normalizeActionEvidenceOptions, + normalizeAssertEvidenceOptions, + recentAssertionExecutions, + resolveAssertCallArgs, + selectAssertionEvidenceImages, +} from '@/agent/assertion-evidence'; +import { ScreenshotItem } from '@/screenshot-item'; +import type { ExecutionTask, IExecutionDump } from '@/types'; +import { describe, expect, it } from '@rstest/core'; + +const shot = (base64: string, capturedAt: number) => + ScreenshotItem.create(base64, capturedAt); + +const actionTask = ( + recorder: Array<{ timing: string; screenshot: ScreenshotItem }>, + extra?: Partial, +): ExecutionTask => + ({ + taskId: extra?.taskId || 'action-1', + type: 'Action Space', + subType: extra?.subType || 'Tap', + status: extra?.status || 'finished', + thought: extra?.thought || 'tap submit', + param: extra?.param || { description: 'submit button' }, + recorder: recorder.map((item) => ({ + type: 'screenshot' as const, + ts: item.screenshot.capturedAt, + screenshot: item.screenshot, + timing: item.timing, + })), + ...extra, + }) as ExecutionTask; + +const planningTask = (extra?: Partial): ExecutionTask => + ({ + taskId: extra?.taskId || 'plan-1', + type: 'Planning', + subType: extra?.subType || 'Plan', + status: extra?.status || 'finished', + param: extra?.param || { userInstructionDisplay: 'open settings' }, + output: extra?.output || { + shouldContinuePlanning: false, + actions: [{ type: 'Tap' }], + }, + ...extra, + }) as ExecutionTask; + +const assertTask = (extra?: Partial): ExecutionTask => + ({ + taskId: extra?.taskId || 'assert-1', + type: 'Insight', + subType: 'Assert', + status: extra?.status || 'finished', + param: extra?.param || { assertion: 'done' }, + ...extra, + }) as ExecutionTask; + +const execution = ( + id: string, + name: string, + tasks: ExecutionTask[], +): IExecutionDump => ({ + id, + name, + tasks, +}); + +describe('assertion evidence options', () => { + it('accepts evidence-chain options as the second argument', () => { + const resolved = resolveAssertCallArgs({ + deepAssert: true, + BeforeTasks: 2, + MaxPictures: 2, + }); + expect(resolved.message).toBeUndefined(); + expect(resolved.options).toMatchObject({ + deepAssert: true, + BeforeTasks: 2, + MaxPictures: 2, + }); + }); + + it('keeps the legacy three-argument form', () => { + const resolved = resolveAssertCallArgs('failed', { + deepAssert: false, + }); + expect(resolved.message).toBe('failed'); + expect(resolved.options).toMatchObject({ deepAssert: false }); + }); + + it('normalizes illegal numbers to defaults', () => { + expect( + normalizeActionEvidenceOptions({ + AfterActPictures: -1, + Interval: Number.NaN, + }), + ).toEqual({ AfterActPictures: 1, Interval: 50 }); + expect( + normalizeAssertEvidenceOptions({ + BeforeExecutions: -3, + BeforeTasks: Number.POSITIVE_INFINITY, + MaxPictures: -8, + AssertionContextBoundary: 'other' as never, + }), + ).toEqual({ + deepAssert: true, + AssertionContextBoundary: 'lastAssert', + BeforeExecutions: 1, + BeforeTasks: 1, + MaxPictures: 2, + }); + }); +}); + +describe('assertion evidence history and images', () => { + it('passes newest unique action evidence to the model', () => { + const beforeShot = shot('data:image/png;base64,before', 1); + const afterShot = shot('data:image/png;base64,after', 2); + const planningShot = shot('data:image/png;base64,planning', 3); + const history = [ + execution('e1', 'Act - submit', [ + { + ...planningTask(), + recorder: [ + { + type: 'screenshot', + ts: 3, + screenshot: planningShot, + timing: 'after-calling', + }, + ], + }, + actionTask([ + { timing: 'before-calling', screenshot: beforeShot }, + { timing: 'after-calling-1', screenshot: afterShot }, + ]), + ]), + ]; + + const images = selectAssertionEvidenceImages(history, 2); + expect(images.map((image) => image.url)).toEqual([ + afterShot.base64, + beforeShot.base64, + ]); + expect(images.every((image) => /before-calling|after-calling-1$/.test(image.name))).toBe( + true, + ); + }); + + it('uses lastAssert and session boundaries', () => { + const older = execution('old', 'Act - older', [ + actionTask([{ timing: 'before-calling', screenshot: shot('old', 1) }], { + taskId: 'old-action', + }), + ]); + const previousAssert = execution('asserted', 'Assert - previous', [ + assertTask({ taskId: 'prev-assert' }), + ]); + const newer = execution('new', 'Act - newer', [ + actionTask([{ timing: 'before-calling', screenshot: shot('new', 2) }], { + taskId: 'new-action', + }), + ]); + + const lastAssert = recentAssertionExecutions( + [older, previousAssert, newer], + 2, + 'lastAssert', + ); + expect(lastAssert.map((item) => item.id)).toEqual(['new']); + + const session = recentAssertionExecutions( + [older, previousAssert, newer], + 2, + 'session', + ); + expect(session.map((item) => item.id)).toEqual(['asserted', 'new']); + }); + + it('keeps a contiguous recent task window and skips final planning summaries', () => { + const history = [ + execution('e1', 'Act - flow', [ + planningTask({ + taskId: 'final-plan', + output: { shouldContinuePlanning: false, actions: [] }, + }), + planningTask({ taskId: 'locate', subType: 'Locate' }), + actionTask( + [{ timing: 'before-calling', screenshot: shot('a', 1) }], + { taskId: 'tap' }, + ), + ]), + ]; + const context = buildEvaluationContext(history, 2); + expect(context.tasks.map((task) => task.taskId)).toEqual(['locate', 'tap']); + }); +}); + +describe('assertion evidence report helpers', () => { + it('returns undefined for disabled or legacy asserts', () => { + expect( + deepAssertEvidence( + assertTask({ param: { assertion: 'x', deepAssert: false } }), + ), + ).toBeUndefined(); + expect(deepAssertEvidence(assertTask({ param: { assertion: 'x' } }))).toBeUndefined(); + }); + + it('reverses model images for report display', () => { + const screenshots = buildDeepAssertScreenshots( + assertTask({ + param: { + assertion: 'state is correct', + deepAssert: true, + assertionEvidenceImages: [ + { name: 'Act / Tap / after-calling-1', url: 'after', capturedAt: 2 }, + { name: 'Act / Tap / before-calling', url: 'before', capturedAt: 1 }, + ], + }, + }), + ); + expect(screenshots).toEqual([ + { + screenshot: 'before', + timing: '参考图1 / Act / Tap / before-calling', + screenshotTimestamp: 1, + }, + { + screenshot: 'after', + timing: '参考图2 / Act / Tap / after-calling-1', + screenshotTimestamp: 2, + }, + ]); + }); + + it('writes exact task citations into the four analysis sections', () => { + const context = buildEvaluationContext( + [ + execution('e1', 'Act - submit', [ + actionTask( + [{ timing: 'before-calling', screenshot: shot('a', 1) }], + { taskId: 'tap', thought: 'tap submit' }, + ), + ]), + ], + 1, + ); + const thought = composeAssertThought({ + assertion: 'state is correct', + passed: true, + evaluationContext: context, + evidenceImages: [ + { name: 'Act - submit / Tap / before-calling', url: 'a', capturedAt: 1 }, + ], + }); + expect(thought).toContain('当前界面判断'); + expect(thought).toContain('关联 task 分析'); + expect(thought).toContain('截图证据分析'); + expect(thought).toContain('最终结论'); + expect(thought).toContain(citationForContextTask(context.tasks[0], 1)); + expect(thought).not.toContain('DATA_DEMAND'); + }); +}); diff --git a/packages/core/tests/unit-test/insight-extract-prompt.test.ts b/packages/core/tests/unit-test/insight-extract-prompt.test.ts index 67d17bc03b..7bd795ee93 100644 --- a/packages/core/tests/unit-test/insight-extract-prompt.test.ts +++ b/packages/core/tests/unit-test/insight-extract-prompt.test.ts @@ -145,6 +145,44 @@ describe('insight extraction prompt assembly', () => { }); }); + it('sends evidence-chain images newest first instead of the current screenshot', async () => { + const context = createFakeContext(); + + await AiExtractElementInfo<{ result: boolean }>({ + context, + dataQuery: { + StatementIsTruthy: 'Boolean, whether the form is submitted', + }, + extractOption: { + deepAssert: true, + assertionEvidenceImages: [ + { + name: 'Act / Tap / after-calling-1', + url: 'data:image/png;base64,after', + capturedAt: 2, + }, + { + name: 'Act / Tap / before-calling', + url: 'data:image/png;base64,before', + capturedAt: 1, + }, + ], + }, + modelRuntime: getModelRuntime(modelConfig), + }); + + const msgs = rs.mocked(callAI).mock.calls[0]?.[0]; + const userContent = msgs?.[1]?.content as Array>; + const imageUrls = userContent + .filter((item) => item.type === 'image_url') + .map((item) => item.image_url.url); + expect(imageUrls).toEqual([ + 'data:image/png;base64,after', + 'data:image/png;base64,before', + ]); + expect(imageUrls).not.toContain(context.screenshot.base64); + }); + it('passes abortSignal to the AI caller', async () => { const abortController = new AbortController(); diff --git a/packages/core/tests/unit-test/task-runner/index.test.ts b/packages/core/tests/unit-test/task-runner/index.test.ts index 97c9ded894..f8460f0ca2 100644 --- a/packages/core/tests/unit-test/task-runner/index.test.ts +++ b/packages/core/tests/unit-test/task-runner/index.test.ts @@ -667,5 +667,37 @@ describe( expect(caughtError?.task?.thought).toMatch(/… \[truncated\]$/); expect(caughtError?.task?.errorMessage).toMatch(/… \[truncated\]$/); }); + + it('records before and numbered post-action frames only for action tasks', async () => { + const actionTask: ExecutionTaskActionApply = { + type: 'Action Space', + subType: 'Tap', + param: { description: 'submit' }, + executor: rs.fn(), + }; + const planningTask: ExecutionTaskApply = { + type: 'Planning', + subType: 'Plan', + param: { userInstruction: 'do it' }, + executor: rs.fn(async () => ({ + output: { actions: [{ type: 'Tap' }], shouldContinuePlanning: false }, + })), + }; + const runner = new TaskRunner('test', fakeUIContextBuilder, { + tasks: [planningTask, actionTask], + actionEvidenceAfterFrameCount: 2, + actionEvidenceFrameIntervalMs: 0, + }); + await runner.flush(); + + expect( + runner.tasks[1].recorder?.map((item) => item.timing), + ).toEqual(['before-calling', 'after-calling-1', 'after-calling-2']); + expect( + runner.tasks[0].recorder?.some((item) => + String(item.timing).startsWith('after-calling-'), + ), + ).toBeFalsy(); + }); }, ); diff --git a/packages/core/tests/unit-test/tasks-null-data.test.ts b/packages/core/tests/unit-test/tasks-null-data.test.ts index b7e954b337..ee1af64707 100644 --- a/packages/core/tests/unit-test/tasks-null-data.test.ts +++ b/packages/core/tests/unit-test/tasks-null-data.test.ts @@ -246,7 +246,7 @@ describe('TaskExecutor - Null Data Handling', () => { } as any), ).resolves.toMatchObject({ output: false, - thought: 'Could not verify assertion', + thought: expect.stringContaining('Could not verify assertion'), }); expect(queryTask.log).toMatchObject({ @@ -264,7 +264,10 @@ describe('TaskExecutor - Null Data Handling', () => { 'Boolean, based on the current screenshot and its contents if provided, unless the user explicitly asks to compare with reference images, whether the following statement is true: Page title is correct', }, getModelRuntime(mockModelConfig), - {}, + expect.objectContaining({ + deepAssert: true, + assertionEvidenceFallback: 'currentScreenshot', + }), '', undefined, expectEmptyUIContext(), diff --git a/packages/test/src/midscene/index.ts b/packages/test/src/midscene/index.ts index 7b9e448272..e9634afdeb 100644 --- a/packages/test/src/midscene/index.ts +++ b/packages/test/src/midscene/index.ts @@ -18,6 +18,8 @@ export interface MidsceneAiActOptions { deepLocate?: boolean; context?: string; abortSignal?: AbortSignal; + AfterActPictures?: number; + Interval?: number; } export interface MidsceneAiAssertOptions { @@ -26,6 +28,11 @@ export interface MidsceneAiAssertOptions { context?: string; abortSignal?: AbortSignal; keepRawResponse?: boolean; + deepAssert?: boolean; + AssertionContextBoundary?: 'lastAssert' | 'session'; + BeforeExecutions?: number; + BeforeTasks?: number; + MaxPictures?: number; } export interface MidscenePromptImage { @@ -144,6 +151,14 @@ const aiActOptionsInputSchema = z.strictObject({ .string() .optional() .describe('Additional context supplied to the UI Agent.'), + AfterActPictures: z + .number() + .optional() + .describe('How many post-action screenshots to record.'), + Interval: z + .number() + .optional() + .describe('Interval in milliseconds between post-action screenshots.'), }); export const aiActInputSchema = z.strictObject({ @@ -166,6 +181,26 @@ const aiAssertOptionsInputSchema = z.strictObject({ .string() .optional() .describe('Additional context supplied to the UI Agent.'), + deepAssert: z + .boolean() + .optional() + .describe('Whether the assertion uses the action evidence chain.'), + AssertionContextBoundary: z + .enum(['lastAssert', 'session']) + .optional() + .describe('How far back the assertion looks for historical tasks.'), + BeforeExecutions: z + .number() + .optional() + .describe('How many previous executions are included.'), + BeforeTasks: z + .number() + .optional() + .describe('How many previous tasks are included.'), + MaxPictures: z + .number() + .optional() + .describe('Maximum number of evidence images sent to the model.'), }); export const aiAssertInputSchema = z.strictObject({ diff --git a/packages/visualizer/src/utils/replay-scripts.ts b/packages/visualizer/src/utils/replay-scripts.ts index 6987967e4f..48e34cb9ee 100644 --- a/packages/visualizer/src/utils/replay-scripts.ts +++ b/packages/visualizer/src/utils/replay-scripts.ts @@ -1,6 +1,12 @@ 'use client'; import { mousePointer } from '@/utils'; -import { paramStr, typeStr } from '@midscene/core/agent'; +import { + buildDeepAssertScreenshots, + deepAssertEvidence, + isCurrentScreenshotFallback, + paramStr, + typeStr, +} from '@midscene/core/agent'; import { getTaskSearchArea } from '@midscene/core/dump/task-service-dump'; import { getCenterHighlightBox, @@ -354,6 +360,44 @@ export const allScriptsFromDump = ( }; }; +const buildDeepAssertReplayScripts = ( + task: ExecutionTask, + imageWidth: number, + imageHeight: number, +): AnimationScript[] | null => { + const evidence = deepAssertEvidence(task); + if (evidence === undefined) { + return null; + } + const screenshots = buildDeepAssertScreenshots(task); + const images = + screenshots && screenshots.length > 0 + ? screenshots + : isCurrentScreenshotFallback(task) && task.uiContext?.screenshot?.base64 + ? [ + { + screenshot: task.uiContext.screenshot.base64, + timing: '参考图1 / current screenshot', + screenshotTimestamp: task.uiContext.screenshot.capturedAt, + }, + ] + : []; + if (images.length === 0) { + return []; + } + return images.map((image) => ({ + type: 'img' as const, + img: image.screenshot, + duration: stillDuration, + camera: createFullPageCameraState(imageWidth, imageHeight), + title: typeStr(task), + subTitle: image.timing, + imageWidth, + imageHeight, + taskId: task.taskId, + })); +}; + export const generateAnimationScripts = ( execution: ExecutionDump | IExecutionDump | null, task: ExecutionTask | number, @@ -366,6 +410,16 @@ export const generateAnimationScripts = ( return null; } + const selectedTask = + task !== -1 && typeof task !== 'number' + ? task + : typeof task === 'number' && task >= 0 + ? execution.tasks[task] + : undefined; + if (selectedTask && deepAssertEvidence(selectedTask) !== undefined) { + return buildDeepAssertReplayScripts(selectedTask, imageWidth, imageHeight); + } + let tasksIncluded: ExecutionTask[] = []; let taskStartIndex = 0; if (task === -1) { @@ -463,6 +517,18 @@ export const generateAnimationScripts = ( initSubTitle = paramStr(task); } + if (deepAssertEvidence(task) !== undefined) { + const evidenceScripts = buildDeepAssertReplayScripts( + task, + imageWidth, + imageHeight, + ); + if (evidenceScripts?.length) { + scripts.push(...evidenceScripts); + } + return; + } + if (task.type === 'Planning') { let locateElements: LocateResultElement[] = []; if (task.subType === 'Plan') { diff --git a/packages/visualizer/tests/replay-scripts-camera.test.ts b/packages/visualizer/tests/replay-scripts-camera.test.ts index f666649f66..640d0b8a27 100644 --- a/packages/visualizer/tests/replay-scripts-camera.test.ts +++ b/packages/visualizer/tests/replay-scripts-camera.test.ts @@ -9,6 +9,9 @@ import { describe, expect, it, rs } from '@rstest/core'; rs.mock('@midscene/core/agent', () => ({ paramStr: () => '', typeStr: (task: { type: string }) => task.type, + deepAssertEvidence: () => undefined, + buildDeepAssertScreenshots: () => undefined, + isCurrentScreenshotFallback: () => false, })); import {