@kkeve/suite 0.0.12 → 0.0.13

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.
@@ -87,9 +87,10 @@ keve_aspect_1.keveAspect.register({
87
87
  phase: 'after',
88
88
  order: 20,
89
89
  async execute(ctx, result) {
90
- if (!result?.actions?.length || !result.success)
90
+ if (!result?.actions?.length)
91
91
  return;
92
- learned_actions_1.learnedActions.add(ctx.step, result.actions);
92
+ const url = ctx.page.url();
93
+ learned_actions_1.learnedActions.add(ctx.step, result.actions, url, result.success);
93
94
  },
94
95
  });
95
96
  // ─── (diagnostic extraction moved to page-agent/diagnostic.ts, called via onAfterTask hook) ──
@@ -202,7 +203,8 @@ exports.test = test_1.test.extend({
202
203
  catch { /* non-critical */ }
203
204
  }
204
205
  // ── Hand off to agent: agent does Re-Act (OR shortcut to blocked if fnBlocked) ──
205
- const learnedHint = learned_actions_1.learnedActions.getHint(options.step);
206
+ const currentUrl = page.url();
207
+ const learnedHint = learned_actions_1.learnedActions.getHint(options.step, currentUrl);
206
208
  let reactResult;
207
209
  let reactTimedOut = false;
208
210
  if (fnBlocked) {
@@ -4,21 +4,50 @@
4
4
  * When one keveGoal's Re-Act loop discovers a working action sequence,
5
5
  * other goals in the same round can use those discoveries as hints.
6
6
  *
7
- * Key format: "${step}" — the step description
8
- * Value: ActionLogEntry[] — the successful action sequence
7
+ * v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
8
+ * Exact match is tried first; if no hit, fuzzy search across all entries.
9
9
  */
10
+ interface CacheEntry {
11
+ step: string;
12
+ actions: any[];
13
+ url?: string;
14
+ success: boolean;
15
+ }
10
16
  export declare class LearnedActions {
11
17
  private cache;
12
- /** Store discovered actions for a step */
13
- add(step: string, actions: any[]): void;
14
- /** Retrieve discovered actions for a step */
18
+ /** Store discovered actions for a step (with optional page URL for context matching)
19
+ * @param success Whether the step succeeded — failed entries are stored with warnings only
20
+ */
21
+ add(step: string, actions: any[], url?: string, success?: boolean): void;
22
+ /** Retrieve discovered actions for a step (exact match) */
15
23
  get(step: string): any[] | undefined;
16
- /** Get a JSON hint string for injecting into Re-Act prompt */
17
- getHint(step: string): string | undefined;
24
+ /** Tokenize a Chinese/mixed step description for fuzzy matching.
25
+ * Uses bigram (character pair) extraction for Chinese — more granular than
26
+ * whitespace splitting, captures shared sub-phrases like "指标标准名称".
27
+ */
28
+ private tokenize;
29
+ /** Compute Jaccard similarity between two token sets */
30
+ private jaccard;
31
+ /** Find the best matching cached entry for a given step description */
32
+ private findBestMatch;
33
+ /** Get a hint string for injecting into Re-Act prompt.
34
+ * 1. Exact match → direct reuse
35
+ * 2. Fuzzy match by token similarity → similar step reuse
36
+ * 3. Same-page fallback → if same URL path, use the most recent successful entry
37
+ */
38
+ getHint(step: string, currentUrl?: string): string | undefined;
39
+ /** Extract path portion of URL for same-page matching (ignore query/hash) */
40
+ private extractPath;
41
+ /** Format a hint from a cache entry's actions.
42
+ * Success: action sequence summary + key discoveries
43
+ * Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
44
+ */
45
+ private formatHint;
18
46
  /** Get all cached entries */
19
- getAll(): Map<string, any[]>;
47
+ getAll(): Map<string, CacheEntry>;
20
48
  /** Clear all cached entries (typically at round start) */
21
49
  clear(): void;
22
50
  }
23
51
  /** Global singleton for same-round sharing */
24
52
  export declare const learnedActions: LearnedActions;
53
+ export {};
@@ -5,51 +5,243 @@
5
5
  * When one keveGoal's Re-Act loop discovers a working action sequence,
6
6
  * other goals in the same round can use those discoveries as hints.
7
7
  *
8
- * Key format: "${step}" — the step description
9
- * Value: ActionLogEntry[] — the successful action sequence
8
+ * v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
9
+ * Exact match is tried first; if no hit, fuzzy search across all entries.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.learnedActions = exports.LearnedActions = void 0;
13
13
  class LearnedActions {
14
14
  cache = new Map();
15
- /** Store discovered actions for a step */
16
- add(step, actions) {
17
- // Only store successful action sequences (ending with expected met)
15
+ /** Store discovered actions for a step (with optional page URL for context matching)
16
+ * @param success Whether the step succeeded — failed entries are stored with warnings only
17
+ */
18
+ add(step, actions, url, success = true) {
18
19
  if (actions.length > 0) {
19
- this.cache.set(step, actions);
20
+ this.cache.set(step, { step, actions, url, success });
20
21
  }
21
22
  }
22
- /** Retrieve discovered actions for a step */
23
+ /** Retrieve discovered actions for a step (exact match) */
23
24
  get(step) {
24
- return this.cache.get(step);
25
+ return this.cache.get(step)?.actions;
25
26
  }
26
- /** Get a JSON hint string for injecting into Re-Act prompt */
27
- getHint(step) {
28
- const actions = this.cache.get(step);
29
- if (!actions || actions.length === 0)
30
- return undefined;
31
- // Summarize: only include action type + role/name, not full snapshot
27
+ /** Tokenize a Chinese/mixed step description for fuzzy matching.
28
+ * Uses bigram (character pair) extraction for Chinese — more granular than
29
+ * whitespace splitting, captures shared sub-phrases like "指标标准名称".
30
+ */
31
+ tokenize(text) {
32
+ // 1. Split by delimiters to get chunks
33
+ const chunks = text
34
+ .replace(/[,。、;:""''()【】《》\s,.;:!?/\\|]+/g, ' ')
35
+ .split(' ')
36
+ .filter(t => t.length > 0);
37
+ // 2. For each chunk, extract character bigrams (for Chinese sub-phrase matching)
38
+ // AND keep the original chunk (for exact chunk matches)
39
+ const tokens = [];
40
+ for (const chunk of chunks) {
41
+ tokens.push(chunk.toLowerCase());
42
+ // Generate bigrams: sliding window of 2 chars
43
+ for (let i = 0; i < chunk.length - 1; i++) {
44
+ tokens.push(chunk.substring(i, i + 2).toLowerCase());
45
+ }
46
+ }
47
+ return tokens;
48
+ }
49
+ /** Compute Jaccard similarity between two token sets */
50
+ jaccard(a, b) {
51
+ const setA = new Set(a.map(t => t.toLowerCase()));
52
+ const setB = new Set(b.map(t => t.toLowerCase()));
53
+ const intersection = [...setA].filter(t => setB.has(t)).length;
54
+ const union = new Set([...setA, ...setB]).size;
55
+ return union === 0 ? 0 : intersection / union;
56
+ }
57
+ /** Find the best matching cached entry for a given step description */
58
+ findBestMatch(step) {
59
+ const tokens = this.tokenize(step);
60
+ let bestEntry;
61
+ let bestScore = 0;
62
+ // Threshold: 18% token overlap — low enough to catch "填写其他必填项" vs "必填项填入合法值",
63
+ // high enough to avoid completely unrelated steps matching
64
+ const THRESHOLD = 0.18;
65
+ for (const [, entry] of this.cache) {
66
+ const entryTokens = this.tokenize(entry.step);
67
+ const score = this.jaccard(tokens, entryTokens);
68
+ if (score > bestScore && score >= THRESHOLD) {
69
+ bestScore = score;
70
+ bestEntry = entry;
71
+ }
72
+ }
73
+ return bestEntry;
74
+ }
75
+ /** Get a hint string for injecting into Re-Act prompt.
76
+ * 1. Exact match → direct reuse
77
+ * 2. Fuzzy match by token similarity → similar step reuse
78
+ * 3. Same-page fallback → if same URL path, use the most recent successful entry
79
+ */
80
+ getHint(step, currentUrl) {
81
+ // 1. Try exact match first (prefer success entries over failures)
82
+ const exact = this.cache.get(step);
83
+ if (exact?.actions.length) {
84
+ const hint = this.formatHint(exact.step, exact.actions, false, exact.success);
85
+ if (hint)
86
+ return hint;
87
+ }
88
+ // 2. Fuzzy match by step description similarity (prefer success, but include failures)
89
+ const best = this.findBestMatch(step);
90
+ if (best?.actions.length) {
91
+ const hint = this.formatHint(best.step, best.actions, true, best.success);
92
+ if (hint)
93
+ return hint;
94
+ }
95
+ // 3. Same-page fallback: if on the same URL path as a cached entry, use the most
96
+ // relevant one (highest Jaccard, even if below fuzzy threshold).
97
+ // Minimum Jaccard = 0.08 to avoid completely unrelated steps on the same page.
98
+ if (currentUrl) {
99
+ const urlPath = this.extractPath(currentUrl);
100
+ let bestSamePage;
101
+ let bestSamePageScore = 0;
102
+ const SAME_PAGE_MIN = 0.08;
103
+ const stepTokens = this.tokenize(step);
104
+ for (const [, entry] of this.cache) {
105
+ if (entry.url && this.extractPath(entry.url) === urlPath) {
106
+ const entryTokens = this.tokenize(entry.step);
107
+ const score = this.jaccard(stepTokens, entryTokens);
108
+ if (score > bestSamePageScore && score >= SAME_PAGE_MIN) {
109
+ bestSamePageScore = score;
110
+ bestSamePage = entry;
111
+ }
112
+ }
113
+ }
114
+ if (bestSamePage?.actions.length) {
115
+ const hint = this.formatHint(bestSamePage.step, bestSamePage.actions, true, bestSamePage.success);
116
+ if (hint)
117
+ return hint;
118
+ }
119
+ }
120
+ return undefined;
121
+ }
122
+ /** Extract path portion of URL for same-page matching (ignore query/hash) */
123
+ extractPath(url) {
124
+ try {
125
+ const u = new URL(url);
126
+ return u.pathname;
127
+ }
128
+ catch {
129
+ return url;
130
+ }
131
+ }
132
+ /** Format a hint from a cache entry's actions.
133
+ * Success: action sequence summary + key discoveries
134
+ * Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
135
+ */
136
+ formatHint(sourceStep, actions, isFuzzy, success) {
137
+ if (!success) {
138
+ // Failed step — surface warnings/blockers AND combobox conflict info
139
+ const discoveries = [];
140
+ for (const a of actions) {
141
+ const out = a.toolOutput || '';
142
+ if (out.startsWith('⚠️'))
143
+ discoveries.push(out.slice(0, 120));
144
+ // Also capture combobox "重复" conflicts — valuable for later goals to avoid same data
145
+ if (out.includes('重复') || out.includes('duplicate') || out.includes('conflict')) {
146
+ discoveries.push(`DATA CONFLICT: ${out.slice(0, 120)}`);
147
+ }
148
+ }
149
+ if (discoveries.length === 0)
150
+ return undefined;
151
+ const prefix = isFuzzy
152
+ ? `Previous FAILED step SIMILAR to "${sourceStep}"`
153
+ : `Previous FAILED step "${sourceStep}"`;
154
+ return `${prefix} — avoid these approaches. Blocked by: ${discoveries.slice(-3).map(d => `[${d}]`).join('; ')}`;
155
+ }
156
+ // Success — full action sequence + discoveries (existing logic)
157
+ // Extract stable element identity from toolOutput (not from action fields which lack role/name)
32
158
  const summary = actions
33
- .filter(a => a.action.tool !== 'done')
159
+ .filter(a => a.action?.tool !== 'done' && a.tool !== 'done')
34
160
  .map(a => {
35
- const act = a.action;
161
+ const act = a.action || a;
162
+ const out = a.toolOutput || '';
36
163
  if (act.tool === 'navigate')
37
164
  return `navigate(${act.url})`;
38
- if (act.tool === 'click')
39
- return `click(${act.role} "${act.name}")`;
40
- if (act.tool === 'type')
41
- return `type(${act.role} "${act.name}", "${act.text}"${act.submit ? ', Enter' : ''})`;
165
+ if (act.tool === 'click') {
166
+ // Parse toolOutput: "✅ Clicked [ref=e688] button \"新增指标\"" → "button 新增指标"
167
+ // or: "⚠️ Clicked [ref=e2843] button \"确定\" — dialog still open" → "button 确定"
168
+ const descMatch = out.match(/Clicked \[ref=\w+\]\s+(\S+\s+"[^"]*"|\S+)/);
169
+ if (descMatch) {
170
+ // descMatch[1] = 'button "新增指标"' or 'button "确定"' or 'element'
171
+ const clean = descMatch[1].replace(/"/g, '').trim();
172
+ return `click(${clean})`;
173
+ }
174
+ return `click(?)`;
175
+ }
176
+ if (act.tool === 'type') {
177
+ // Parse toolOutput for stable type info
178
+ const typeMatch = out.match(/✅ (replace|append) "([^"]*)" into element \[ref=\w+\]/);
179
+ if (typeMatch)
180
+ return `type("${typeMatch[2].slice(0, 30)}"${typeMatch[1] === 'append' ? ', append' : ''})`;
181
+ // Combobox selection
182
+ const cbMatch = out.match(/✅ Selected "([^"]*)" in combobox/);
183
+ if (cbMatch)
184
+ return `select("${cbMatch[1].slice(0, 30)}")`;
185
+ return `type(?)`;
186
+ }
187
+ if (act.tool === 'fill_form') {
188
+ // Extract filled fields from multi-line toolOutput
189
+ // Lines look like: "✅ textbox [e2755] = "测试指标标准名称"" or "✅ combobox [e2800] = "KwaiBI官方数据集DEMO" (custom)"
190
+ const fields = [];
191
+ for (const line of out.split('\n')) {
192
+ const fieldMatch = line.match(/✅ (\w+) \[\w+\] = "?([^"]*?)"?\s*(\(custom\))?$/);
193
+ if (fieldMatch && fieldMatch[2]) {
194
+ const fType = fieldMatch[1];
195
+ const fVal = fieldMatch[2].slice(0, 25);
196
+ fields.push(`${fType}="${fVal}"`);
197
+ }
198
+ }
199
+ return fields.length ? `fill_form(${fields.join(', ')})` : 'fill_form(...)';
200
+ }
201
+ if (act.tool === 'select_option') {
202
+ const selMatch = out.match(/✅ Selected "([^"]*)"/);
203
+ if (selMatch)
204
+ return `select_option("${selMatch[1].slice(0, 30)}")`;
205
+ return `select_option(?)`;
206
+ }
42
207
  if (act.tool === 'hover')
43
- return `hover(${act.role} "${act.name}")`;
208
+ return `hover(${act.role || ''} "${act.name || ''}")`;
44
209
  if (act.tool === 'pressKey')
45
210
  return `pressKey(${act.key})`;
46
211
  if (act.tool === 'scroll')
47
212
  return `scroll(${act.direction})`;
48
213
  if (act.tool === 'wait')
49
214
  return `wait(${act.time}ms)`;
215
+ if (act.tool === 'execute_javascript') {
216
+ const jsResult = out.match(/✅ JS executed\. Result: (.+)/);
217
+ if (jsResult)
218
+ return `js(→ ${jsResult[1].slice(0, 40)})`;
219
+ return 'js(?)';
220
+ }
50
221
  return act.tool;
51
222
  });
52
- return `Previous successful actions for "${step}": ${summary.join(' → ')}`;
223
+ // Extract key toolOutput lines — discoveries that other goals need to know
224
+ // Focus on: combobox option results (✅/❌), dialog warnings (⚠️), validation messages
225
+ const discoveries = [];
226
+ for (const a of actions) {
227
+ const out = a.toolOutput || '';
228
+ // Combobox option selection results (most valuable for form reuse)
229
+ if (out.includes('combobox') && (out.includes('(custom)') || out.includes('could not find option'))) {
230
+ discoveries.push(out.slice(0, 120));
231
+ }
232
+ // Dialog warnings that blocked submission
233
+ if (out.startsWith('⚠️')) {
234
+ discoveries.push(out.slice(0, 120));
235
+ }
236
+ }
237
+ // Limit to 5 most important discoveries
238
+ const discoveryLines = discoveries.length > 0
239
+ ? `\nKey discoveries: ${discoveries.slice(-5).map(d => `[${d}]`).join('; ')}`
240
+ : '';
241
+ const prefix = isFuzzy
242
+ ? `Previous successful actions for SIMILAR step "${sourceStep}"`
243
+ : `Previous successful actions for "${sourceStep}"`;
244
+ return `${prefix}: ${summary.join(' → ')}${discoveryLines}`;
53
245
  }
54
246
  /** Get all cached entries */
55
247
  getAll() {
@@ -110,6 +110,11 @@ export declare class KevePageAgent {
110
110
  bbox?: number[];
111
111
  analysis?: string;
112
112
  }>;
113
+ /** Visual assertion: check a visual condition without interacting with the page */
114
+ visualAssertElement(screenshotBase64: string, assertion: string): Promise<{
115
+ passed: boolean;
116
+ reasoning: string;
117
+ }>;
113
118
  /** Build final result */
114
119
  private buildResult;
115
120
  }
@@ -61,6 +61,91 @@ const tools_1 = require("./tools");
61
61
  const config_1 = require("../config");
62
62
  // ─── System Prompt ────────────────────────────────────────────────────
63
63
  const SYSTEM_PROMPT = fs.readFileSync(path.join(__dirname, "system_prompt.md"), "utf-8");
64
+ // ─── Snapshot diff helpers (used by assembleUserPrompt to add Page Change to history) ───
65
+ /** Find the snapshot string of the previous step event before index `i`. */
66
+ function findPrevStepSnapshot(events, currentIdx) {
67
+ for (let j = currentIdx - 1; j >= 0; j--) {
68
+ if (events[j].type === 'step' && events[j].snapshot)
69
+ return events[j].snapshot;
70
+ }
71
+ return undefined;
72
+ }
73
+ /**
74
+ * Summarize key differences between two a11y snapshots.
75
+ * Returns a short human-readable string or '' if nothing notable changed.
76
+ *
77
+ * Detects: dialog/modal appearance/disappearance, new/removed elements,
78
+ * value changes in inputs, and repeated identical snapshots (no change).
79
+ */
80
+ function summarizeSnapshotDiff(prev, curr) {
81
+ if (prev === curr)
82
+ return 'no visible change on page';
83
+ const prevLines = prev.split('\n');
84
+ const currLines = curr.split('\n');
85
+ const changes = [];
86
+ // 1. Detect dialog/modal appearance or disappearance
87
+ const prevDialogs = prevLines.filter(l => /dialog|modal|alertdialog/i.test(l) && /\[ref=/.test(l));
88
+ const currDialogs = currLines.filter(l => /dialog|modal|alertdialog/i.test(l) && /\[ref=/.test(l));
89
+ if (currDialogs.length > prevDialogs.length) {
90
+ const newDialog = currDialogs.find(cd => !prevDialogs.some(pd => pd === cd));
91
+ if (newDialog) {
92
+ const nameMatch = newDialog.match(/"([^"]+)"/);
93
+ changes.push(`dialog "${nameMatch?.[1] || '?'}" appeared`);
94
+ }
95
+ }
96
+ else if (currDialogs.length < prevDialogs.length) {
97
+ changes.push('dialog closed');
98
+ }
99
+ // 2. Detect new warning/error text nodes (e.g. validation errors)
100
+ const prevWarnings = prevLines.filter(l => /warning|error|alert|⚠|❌/i.test(l) && /\[ref=/.test(l));
101
+ const currWarnings = currLines.filter(l => /warning|error|alert|⚠|❌/i.test(l) && /\[ref=/.test(l));
102
+ for (const w of currWarnings) {
103
+ if (!prevWarnings.some(pw => pw === w)) {
104
+ const textMatch = w.match(/"([^"]+)"/);
105
+ changes.push(`new warning/error: "${textMatch?.[1] || '?'}"`);
106
+ }
107
+ }
108
+ // 3. Count added/removed interactive elements (simplified: lines with [ref=])
109
+ const prevRefs = new Set(prevLines.filter(l => /\[ref=/.test(l)).map(l => {
110
+ const m = l.match(/\[ref=([a-f0-9]+)\]/);
111
+ return m?.[1] || '';
112
+ }));
113
+ const currRefs = new Set(currLines.filter(l => /\[ref=/.test(l)).map(l => {
114
+ const m = l.match(/\[ref=([a-f0-9]+)\]/);
115
+ return m?.[1] || '';
116
+ }));
117
+ const added = [...currRefs].filter(r => r && !prevRefs.has(r));
118
+ const removed = [...prevRefs].filter(r => r && !currRefs.has(r));
119
+ if (added.length > 0)
120
+ changes.push(`${added.length} new element(s) appeared`);
121
+ if (removed.length > 0)
122
+ changes.push(`${removed.length} element(s) removed`);
123
+ // 4. Detect value changes in textboxes (content after colon on same line)
124
+ const prevValues = new Map();
125
+ for (const l of prevLines) {
126
+ const refM = l.match(/\[ref=([a-f0-9]+)\]/);
127
+ if (refM) {
128
+ // Value is text after the last colon on the same line
129
+ const valM = l.match(/: (.+)$/);
130
+ if (valM)
131
+ prevValues.set(refM[1], valM[1].trim());
132
+ }
133
+ }
134
+ for (const l of currLines) {
135
+ const refM = l.match(/\[ref=([a-f0-9]+)\]/);
136
+ if (refM) {
137
+ const valM = l.match(/: (.+)$/);
138
+ if (valM && prevValues.has(refM[1])) {
139
+ const prevVal = prevValues.get(refM[1]);
140
+ const currVal = valM[1].trim();
141
+ if (prevVal !== currVal && currVal) {
142
+ changes.push(`[ref=${refM[1]}] value changed: "${prevVal}" → "${currVal}"`);
143
+ }
144
+ }
145
+ }
146
+ }
147
+ return changes.length > 0 ? changes.join('; ') : 'page content changed';
148
+ }
64
149
  // ─── KevePageAgent ──────────────────────────────────────────────────────
65
150
  class KevePageAgent {
66
151
  page;
@@ -115,7 +200,7 @@ class KevePageAgent {
115
200
  this.pushObservation(`Target application URL: ${targetUrl}. Use this URL for navigation. Do NOT construct URLs yourself — use the navigate tool with this URL or read process.env.KEVE_TARGET_URL.`);
116
201
  }
117
202
  if (options?.learnedActionsHint) {
118
- this.pushObservation(`Previous discoveries for similar steps:\n${options.learnedActionsHint}`);
203
+ this.pushObservation(`Previous discoveries for similar steps:\n${options.learnedActionsHint}\nUse these discoveries to avoid repeating failed approaches. If a previous step found a working way to fill a form field (especially combobox/dropdown), reuse that approach instead of trial-and-error.`);
119
204
  }
120
205
  // Step limit warning
121
206
  if (maxSteps <= 3) {
@@ -209,10 +294,12 @@ class KevePageAgent {
209
294
  if (actionName === 'execute_javascript' && execResult.output && execResult.output.startsWith('❌')) {
210
295
  const jsRulesHint = [
211
296
  'execute_javascript rules — fix your script:',
212
- '1. MUST start with `return` to capture the result.',
213
- '2. Use `var` for declarations — `const`/`let` cause SyntaxError.',
297
+ '1. Include `return` for multi-statement code. Single expressions are auto-wrapped.',
298
+ '2. const/let are auto-converted to var — no need to write var manually.',
214
299
  '3. NO top-level `await` or `async () => {}` wrappers — they return undefined.',
215
300
  '4. NO Playwright APIs (`page`, `locator`) — only browser DOM APIs.',
301
+ '5. FORM INTERACTION allowed: input.value=, dispatchEvent().',
302
+ '6. FORBIDDEN: style mutation (style.xxx=, style.setProperty), classList mutation, className=, innerHTML=.',
216
303
  ].join('\n');
217
304
  this.pushObservation(jsRulesHint);
218
305
  }
@@ -296,6 +383,53 @@ class KevePageAgent {
296
383
  await hooks?.onAfterStep?.(this, this.events);
297
384
  }
298
385
  stepCount++;
386
+ // ── Stuck detection: break retry loops early ──
387
+ // When the last 3 steps produce identical toolOutput (no page progress),
388
+ // inject a forceful observation to break the loop.
389
+ // Also detects oscillation patterns (e.g. A→B→A→B cycling between two states).
390
+ const recentSteps = this.events
391
+ .filter((e) => e.type === 'step')
392
+ .slice(-4);
393
+ if (recentSteps.length >= 3) {
394
+ const last3 = recentSteps.slice(-3);
395
+ const normalize = (s) => (s || '').replace(/executed for \d+ms/, '').slice(0, 80);
396
+ const outputs = last3.map(s => normalize(s.toolOutput));
397
+ const allSame = outputs.every(o => o === outputs[0]);
398
+ if (allSame) {
399
+ const stuckMsg = [
400
+ `⚠️ STUCK DETECTED: The last 3 actions all produced the same result: "${outputs[0].slice(0, 60)}"`,
401
+ `This means the page is NOT responding to your actions. You MUST change strategy:`,
402
+ `- Try a different tool (e.g. select_option instead of fill_form, execute_javascript instead of click)`,
403
+ `- Try a different element or value`,
404
+ `- If a dialog blocks submission, handle the sub-dialog/popup first or dismiss it with Escape`,
405
+ `- If stuck after 2 more attempts: call done(verdict="fail") honestly`,
406
+ ].join('\n');
407
+ this.pushObservation(stuckMsg);
408
+ }
409
+ // ── Oscillation detection: check if last 6 steps cycle between 2-3 states ──
410
+ // E.g. click确定→ownership popup → click返回→conflict warning → click确定→ownership popup
411
+ const recent6 = this.events
412
+ .filter((e) => e.type === 'step')
413
+ .slice(-6);
414
+ if (recent6.length >= 5 && !allSame) {
415
+ const norm6 = recent6.map(s => normalize(s.toolOutput));
416
+ // Extract the set of unique outputs (ignoring minor variations)
417
+ const uniqueOutputs = [...new Set(norm6)];
418
+ if (uniqueOutputs.length <= 2) {
419
+ // All recent steps cycle between ≤2 distinct outputs → oscillation
420
+ const oscMsg = [
421
+ `⚠️ OSCILLATION DETECTED: The last ${recent6.length} actions cycle between ${uniqueOutputs.length} states without progress:`,
422
+ ...uniqueOutputs.map(o => ` - "${o.slice(0, 50)}"`),
423
+ `You are going back and forth without advancing. You MUST break the cycle:`,
424
+ `- If clicking 确定 triggers a repeated warning, change the FORM VALUES (not just the name) to resolve the warning`,
425
+ `- If "口径重复" blocks submission, change the DATASET or INDICATOR (not just the indicator name)`,
426
+ `- If a sub-dialog keeps appearing, dismiss it with Escape or handle it first`,
427
+ `- Call done(verdict="fail") honestly if the conflict cannot be resolved within current steps`,
428
+ ].join('\n');
429
+ this.pushObservation(oscMsg);
430
+ }
431
+ }
432
+ }
299
433
  if (stepCount >= maxSteps) {
300
434
  console.error(`\x1b[31mStep count exceeded maximum limit (${maxSteps})\x1b[0m`);
301
435
  const finalSnapshot = await this.page.ariaSnapshot();
@@ -357,13 +491,21 @@ class KevePageAgent {
357
491
  prompt += '</agent_state>\n\n';
358
492
  // <agent_history>
359
493
  prompt += '<agent_history>\n';
360
- for (const event of this.events) {
494
+ for (let i = 0; i < this.events.length; i++) {
495
+ const event = this.events[i];
361
496
  if (event.type === 'step') {
362
497
  prompt += `<step_${event.stepIndex + 1}>\n`;
363
498
  prompt += `Evaluation of Previous Step: ${event.evaluation}\n`;
364
499
  prompt += `Memory: ${event.memory}\n`;
365
500
  prompt += `Next Goal: ${event.nextGoal}\n`;
366
501
  prompt += `Action: ${event.toolName} → ${event.toolOutput || event.toolError || 'unknown'}\n`;
502
+ // Add snapshot diff summary: compare this step's snapshot with the previous step's
503
+ const prevStep = i > 0 ? findPrevStepSnapshot(this.events, i) : undefined;
504
+ if (prevStep !== undefined) {
505
+ const diff = summarizeSnapshotDiff(prevStep, event.snapshot);
506
+ if (diff)
507
+ prompt += `Page Change: ${diff}\n`;
508
+ }
367
509
  prompt += `</step_${event.stepIndex + 1}>\n`;
368
510
  }
369
511
  else if (event.type === 'observation') {
@@ -496,6 +638,52 @@ IMPORTANT: Respond in Chinese.`,
496
638
  return { found: false, analysis: `LLM invoke error: ${e.message}` };
497
639
  }
498
640
  }
641
+ /** Visual assertion: check a visual condition without interacting with the page */
642
+ async visualAssertElement(screenshotBase64, assertion) {
643
+ const assertToolSchema = zod_1.z.object({
644
+ passed: zod_1.z.boolean().describe('Whether the assertion is true based on the screenshot'),
645
+ reasoning: zod_1.z.string().describe('Brief explanation of why the assertion passed or failed'),
646
+ });
647
+ const messages = [
648
+ {
649
+ role: 'system',
650
+ content: `You are a UI visual assertion checker. Given a screenshot and an assertion statement, determine whether the assertion is TRUE based on what you see.
651
+
652
+ Output JSON with:
653
+ - passed: true if the assertion holds, false otherwise
654
+ - reasoning: brief explanation of your judgment
655
+
656
+ IMPORTANT:
657
+ - Only judge what you can SEE in the screenshot — do not infer hidden states
658
+ - Be strict: if the visual evidence is ambiguous, return passed=false
659
+ - Respond in Chinese for reasoning`,
660
+ },
661
+ {
662
+ role: 'user',
663
+ content: [
664
+ { type: 'text', text: `请判断以下断言是否为真:${assertion}\n\n返回 passed (布尔值) 和 reasoning (中文说明)。` },
665
+ { type: 'image_url', image_url: { url: `data:image/png;base64,${screenshotBase64}`, detail: 'high' } },
666
+ ],
667
+ },
668
+ ];
669
+ try {
670
+ const result = await this.llm.invoke(messages, {
671
+ AssertResult: {
672
+ description: 'Return the visual assertion result',
673
+ inputSchema: assertToolSchema,
674
+ execute: async (args) => args,
675
+ },
676
+ }, this.abortController.signal, { toolChoiceName: 'AssertResult' });
677
+ const parsed = result.toolCall.args;
678
+ return {
679
+ passed: typeof parsed.passed === 'boolean' ? parsed.passed : false,
680
+ reasoning: parsed.reasoning || 'No reasoning provided',
681
+ };
682
+ }
683
+ catch (e) {
684
+ return { passed: false, reasoning: `LLM invoke error: ${e.message}` };
685
+ }
686
+ }
499
687
  /** Build final result */
500
688
  buildResult(success, data, stepCount, finalSnapshot) {
501
689
  const snapshot = finalSnapshot || '';