browserclaw-agent 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-loop.d.ts +23 -15
- package/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +123 -47
- package/dist/agent-loop.js.map +1 -1
- package/dist/config.d.ts +10 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -16
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +7 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -12
- package/dist/index.js.map +1 -1
- package/dist/lesson-store.d.ts +11 -0
- package/dist/lesson-store.d.ts.map +1 -0
- package/dist/lesson-store.js +323 -0
- package/dist/lesson-store.js.map +1 -0
- package/dist/llm.d.ts.map +1 -1
- package/dist/llm.js +40 -26
- package/dist/llm.js.map +1 -1
- package/dist/port-probe.d.ts +8 -0
- package/dist/port-probe.d.ts.map +1 -0
- package/dist/port-probe.js +22 -0
- package/dist/port-probe.js.map +1 -0
- package/dist/prompt-log.d.ts +17 -0
- package/dist/prompt-log.d.ts.map +1 -0
- package/dist/prompt-log.js +28 -0
- package/dist/prompt-log.js.map +1 -0
- package/dist/routes.d.ts +3 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +227 -0
- package/dist/routes.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +121 -0
- package/dist/server.js.map +1 -0
- package/dist/session-manager.d.ts +40 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +635 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/skill-generator.d.ts +5 -0
- package/dist/skill-generator.d.ts.map +1 -0
- package/dist/skill-generator.js +173 -0
- package/dist/skill-generator.js.map +1 -0
- package/dist/skill-store.d.ts +7 -0
- package/dist/skill-store.d.ts.map +1 -0
- package/dist/skill-store.js +85 -0
- package/dist/skill-store.js.map +1 -0
- package/dist/skills/extract-items.d.ts +9 -0
- package/dist/skills/extract-items.d.ts.map +1 -0
- package/dist/skills/extract-items.js +131 -0
- package/dist/skills/extract-items.js.map +1 -0
- package/dist/skills/loop-detection.d.ts.map +1 -1
- package/dist/skills/loop-detection.js +19 -2
- package/dist/skills/loop-detection.js.map +1 -1
- package/dist/skills/press-and-hold.d.ts.map +1 -1
- package/dist/skills/press-and-hold.js +2 -11
- package/dist/skills/press-and-hold.js.map +1 -1
- package/dist/trajectory-store.d.ts +20 -0
- package/dist/trajectory-store.d.ts.map +1 -0
- package/dist/trajectory-store.js +61 -0
- package/dist/trajectory-store.js.map +1 -0
- package/package.json +7 -15
package/dist/agent-loop.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CrawlPage, BrowserClaw } from 'browserclaw';
|
|
2
2
|
import type { UserMessage } from './types.js';
|
|
3
|
-
import type { AgentAction, AgentLoopResult, CatalogSkill } from './types.js';
|
|
3
|
+
import type { AgentAction, AgentLoopResult, CatalogSkill, TaskLesson } from './types.js';
|
|
4
|
+
import type { AgentConfig } from './config.js';
|
|
4
5
|
/**
|
|
5
6
|
* Wrap drained user interjections in a nonce-tagged block so the LLM can
|
|
6
7
|
* distinguish user data from system instructions. Sanitizes the text to
|
|
@@ -26,24 +27,31 @@ export declare function shouldCheckTermination(step: number): boolean;
|
|
|
26
27
|
export interface PageHolder {
|
|
27
28
|
page: CrawlPage;
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Extension points for consumers of the agent loop.
|
|
32
|
+
*
|
|
33
|
+
* - `systemPrompt` — Replace or extend the main system prompt.
|
|
34
|
+
* - `customActions` — Handle action types not built into the agent.
|
|
35
|
+
* Return `{ outcome }` to override the step outcome text.
|
|
36
|
+
* - `buildTask` — Pre-process the user prompt before the planner sees it.
|
|
37
|
+
* Useful for adding context or domain constraints.
|
|
38
|
+
* - `getLesson` — Inject a lesson provider (replaces the default S3-backed store).
|
|
39
|
+
* Return `null` to skip lesson loading.
|
|
40
|
+
*/
|
|
41
|
+
export interface AgentLoopOptions {
|
|
42
|
+
systemPrompt?: string | ((defaultPrompt: string) => string);
|
|
43
|
+
customActions?: Record<string, (action: AgentAction, page: CrawlPage, browser?: BrowserClaw) => Promise<{
|
|
44
|
+
outcome?: string;
|
|
45
|
+
} | undefined>>;
|
|
46
|
+
buildTask?: (prompt: string) => string | Promise<string>;
|
|
47
|
+
getLesson?: (prompt: string) => Promise<TaskLesson | null>;
|
|
48
|
+
config?: Partial<AgentConfig>;
|
|
49
|
+
}
|
|
29
50
|
export interface UserChatHooks {
|
|
30
51
|
/** Drain all pending user interjections. Called at the top of each step. */
|
|
31
52
|
drainMessages: () => UserMessage[];
|
|
32
53
|
/** Per-session random token used to wrap user-interjection content so the LLM can't confuse user data for system instructions. */
|
|
33
54
|
nonce: string;
|
|
34
55
|
}
|
|
35
|
-
export
|
|
36
|
-
waitForUser?: () => Promise<string>;
|
|
37
|
-
browser?: BrowserClaw;
|
|
38
|
-
domainSkill?: CatalogSkill | null;
|
|
39
|
-
maxSteps?: number;
|
|
40
|
-
userChat?: UserChatHooks;
|
|
41
|
-
/** Custom action handlers keyed by action name. Called before built-in actions. */
|
|
42
|
-
customActions?: Record<string, (action: AgentAction, page: CrawlPage) => Promise<void>>;
|
|
43
|
-
/** Override the default agent system prompt. */
|
|
44
|
-
systemPrompt?: string;
|
|
45
|
-
/** Transform the prompt before it reaches the planner and agent loop. */
|
|
46
|
-
buildTask?: (prompt: string) => string;
|
|
47
|
-
}
|
|
48
|
-
export declare function runAgentLoop(prompt: string, pageOrHolder: CrawlPage | PageHolder, emit: (event: string, data: unknown) => void, signal: AbortSignal, options?: AgentLoopOptions): Promise<AgentLoopResult>;
|
|
56
|
+
export declare function runAgentLoop(prompt: string, pageOrHolder: CrawlPage | PageHolder, emit: (event: string, data: unknown) => void, signal: AbortSignal, waitForUser?: () => Promise<string>, browser?: BrowserClaw, domainSkill?: CatalogSkill | null, maxSteps?: number, userChat?: UserChatHooks, options?: AgentLoopOptions): Promise<AgentLoopResult>;
|
|
49
57
|
//# sourceMappingURL=agent-loop.d.ts.map
|
package/dist/agent-loop.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAW1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C,OAAO,KAAK,EAAE,WAAW,EAAa,eAAe,EAAiB,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACnH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAY/C;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiC5F;AA4lBD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAsB1D;AAMD,wBAAgB,8BAA8B,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAIvE;AAoND,MAAM,MAAM,mBAAmB,GAAG;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtG;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAG5D;AA2CD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,SAAS,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IAC5D,aAAa,CAAC,EAAE,MAAM,CACpB,MAAM,EACN,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC,CAC3G,CAAC;IACF,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAC3D,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,aAAa,EAAE,MAAM,WAAW,EAAE,CAAC;IACnC,kIAAkI;IAClI,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,SAAS,GAAG,UAAU,EACpC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,EAC5C,MAAM,EAAE,WAAW,EACnB,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,EACnC,OAAO,CAAC,EAAE,WAAW,EACrB,WAAW,CAAC,EAAE,YAAY,GAAG,IAAI,EACjC,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,aAAa,EACxB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,eAAe,CAAC,CAw4B1B"}
|
package/dist/agent-loop.js
CHANGED
|
@@ -6,12 +6,21 @@ import { detectPageState, shouldBlockDone } from './skills/page-state.js';
|
|
|
6
6
|
import { diagnoseStuckAgent, formatRecovery } from './skills/recovery.js';
|
|
7
7
|
import { TabManager } from './skills/tab-manager.js';
|
|
8
8
|
import { getCdpBaseUrl, activateCdpTarget } from './skills/cdp-utils.js';
|
|
9
|
+
import { extractItems } from './skills/extract-items.js';
|
|
9
10
|
import { webSearch } from './web-search.js';
|
|
10
11
|
import { llmJson, llmVision, sanitizeErrorText, getTokenUsage } from './llm.js';
|
|
11
|
-
import { getLesson, formatLessonForPrompt } from './lesson-store.js';
|
|
12
12
|
import { LlmParseError } from './types.js';
|
|
13
|
+
import { defaultAgentConfig, INTERJECTION_INJECTION_MAX_CHARS } from './config.js';
|
|
13
14
|
import { logger } from './logger.js';
|
|
14
|
-
|
|
15
|
+
function formatLessonForPrompt(lesson) {
|
|
16
|
+
const worked = lesson.domains.filter((d) => d.status === 'worked');
|
|
17
|
+
if (worked.length === 0)
|
|
18
|
+
return '';
|
|
19
|
+
const lines = ['Sites that completed this type of task before (use if they still fit):'];
|
|
20
|
+
for (const d of worked)
|
|
21
|
+
lines.push(` ${d.domain} (${d.reason})`);
|
|
22
|
+
return lines.join('\n');
|
|
23
|
+
}
|
|
15
24
|
/**
|
|
16
25
|
* Wrap drained user interjections in a nonce-tagged block so the LLM can
|
|
17
26
|
* distinguish user data from system instructions. Sanitizes the text to
|
|
@@ -105,8 +114,13 @@ Rules:
|
|
|
105
114
|
- "back" to go back in browser history. Use this instead of manually tracking URLs when you need to return to the previous page.
|
|
106
115
|
- "press_and_hold" solves press-and-hold human-verification challenges. Include "hold_ms" (milliseconds) to override the default duration when a prior attempt was too short or the UI specifies a time.
|
|
107
116
|
- "click_cloudflare" solves Cloudflare "Verify you are human" checkbox challenges. The system locates and clicks the checkbox.
|
|
108
|
-
- "extract" when the accessibility snapshot is missing data you need — prices, descriptions, table values, form field values, counts, or any text that's visually on the page but absent from the snapshot.
|
|
117
|
+
- "extract" when the accessibility snapshot is missing data you need — prices, descriptions, table values, form field values, counts, or any text that's visually on the page but absent from the snapshot.
|
|
118
|
+
- With an "expression" (JavaScript string): runs that expression and returns the value. Examples: 'document.querySelector(".price")?.textContent', 'Array.from(document.querySelectorAll("td")).map(el=>el.textContent.trim())'.
|
|
119
|
+
- Without an "expression": runs a built-in structured-items extractor that tries __NEXT_DATA__ → Apollo state → __INITIAL_STATE__ → JSON-LD → DOM card heuristics, in that order, and returns an array of records with fields like price, address, bedrooms, url. Prefer this on list/results pages — it is more reliable than hand-written selector code.
|
|
109
120
|
- On modern JS/SPA sites (Next.js, React apps), listing and detail data is usually embedded in structured state — try these first before hand-rolling selectors: JSON.parse(document.getElementById('__NEXT_DATA__').textContent), window.__APOLLO_STATE__, window.__INITIAL_STATE__, or JSON-LD via document.querySelectorAll('script[type="application/ld+json"]'). One well-aimed extract from structured state beats five DOM-selector attempts.
|
|
121
|
+
- On any filtered search-results page where you need structured item data (prices, addresses, names, URLs across multiple cards), your FIRST extract must use the built-in extractor: call "extract" with NO "expression" field at all. It auto-tries __NEXT_DATA__ / Apollo / __INITIAL_STATE__ / JSON-LD / DOM cards and returns an array of records. Do not write your own selector code for list pages — it fails more often than it succeeds. Only if the built-in extractor's "source" comes back "none" should you attempt a custom "expression".
|
|
122
|
+
- If the built-in extract returned items whose URLs are present but some named per-item fields are missing (e.g. JSON-LD often carries only the URL), do not give up. Navigate to each item URL in turn and run another "extract" with NO "expression" — the built-in extractor also handles single-item detail pages and will return the missing fields. Collect enough complete items to satisfy the task's count before calling done.
|
|
123
|
+
- On cards with nested links (photo, title, container), the outer link often routes to a promo or profile rather than the listing. When similar links stack within a card, extract hrefs first to disambiguate — click the link whose URL path matches the target, not one guessed from the visible label.
|
|
110
124
|
- "web_search" to search the web when you don't know which site to use, need to find a specific service, or want to compare options across sources. Provide "query"; returns top results with titles, URLs, and snippets. Use this instead of guessing URLs.
|
|
111
125
|
- "switch_tab" to switch to a different open tab. Use the tab_id from the tab list shown in the context. Use this when a link opened a new tab and you want to return to a previous one, or when the information you need is in a different tab.
|
|
112
126
|
- "close_tab" to close a tab by tab_id. Use only when a tab is no longer needed.
|
|
@@ -192,9 +206,12 @@ Before giving up:
|
|
|
192
206
|
- Only "fail" after you've genuinely exhausted your options.
|
|
193
207
|
|
|
194
208
|
When to call "done":
|
|
195
|
-
-
|
|
196
|
-
-
|
|
197
|
-
-
|
|
209
|
+
- First, re-read the original prompt. Every explicit constraint (filters like "dog-friendly", "under $4,200", "in Chelsea"; counts like "5 listings"; date/time bounds) must be satisfied by an action you took and confirmed on the page — not merely mentioned in memory. If a constraint hasn't been applied and verified, apply it now; do not call done yet.
|
|
210
|
+
- Once all constraints are satisfied and your "memory" contains the answer, "done" is your next action. Missing a sub-detail (one nice-to-have field) is not a reason to keep gathering.
|
|
211
|
+
- When the task asks for a list of items with named per-item fields, every named field is required for every item you report. If an item is missing any named field, do a focused follow-up extract for that field, or drop the item and get another that has all fields. Never call done with items that have "not recovered" or "unknown" in a named field.
|
|
212
|
+
- If an item you considered fails one of the task's explicit constraints (price over cap, wrong location, wrong date, etc.), drop it and keep hunting — open the next candidate from the list, scroll for more, or paginate. Do not call done with fewer qualifying items than the task asked for just because the first few you inspected didn't qualify.
|
|
213
|
+
- A partial, honest answer delivered now beats a complete answer you never deliver. State uncertainty explicitly — "couldn't verify X" is a valid part of a done answer, but only for optional fields, never for explicit constraints.
|
|
214
|
+
- Do not extract, scroll, or click "just to be sure" once every constraint is satisfied. That is how runs time out.
|
|
198
215
|
- Exception — transactional tasks (book, buy, submit, send): verify the action actually completed. A click on "Submit" is not the same as a successful submission; look for confirmation text, a reference number, or a state change before calling done.
|
|
199
216
|
|
|
200
217
|
Data grounding:
|
|
@@ -223,16 +240,20 @@ function isPageReady(snapshot) {
|
|
|
223
240
|
}
|
|
224
241
|
const PAGE_READY_RETRIES = 2;
|
|
225
242
|
const PAGE_READY_WAIT_MS = 2000;
|
|
243
|
+
const SNAPSHOT_TIMEOUT_MS = 10000;
|
|
226
244
|
async function safeSnapshot(page) {
|
|
227
245
|
let snapshot;
|
|
246
|
+
const t0 = Date.now();
|
|
247
|
+
logger.info('snapshot: starting');
|
|
228
248
|
try {
|
|
229
|
-
snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
249
|
+
snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
250
|
+
logger.info({ ms: Date.now() - t0 }, 'snapshot: complete');
|
|
230
251
|
}
|
|
231
252
|
catch (firstErr) {
|
|
232
253
|
logger.warn({ error: sanitizeErrorText(firstErr instanceof Error ? firstErr.message : 'unknown') }, 'Snapshot failed — retrying');
|
|
233
254
|
await page.waitFor({ timeMs: PAGE_READY_WAIT_MS });
|
|
234
255
|
try {
|
|
235
|
-
snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
256
|
+
snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
236
257
|
}
|
|
237
258
|
catch (err) {
|
|
238
259
|
logger.error({ error: sanitizeErrorText(err instanceof Error ? err.message : 'unknown') }, 'Snapshot failed after retry');
|
|
@@ -246,7 +267,7 @@ async function safeSnapshot(page) {
|
|
|
246
267
|
for (let i = 0; i < PAGE_READY_RETRIES; i++) {
|
|
247
268
|
await page.waitFor({ timeMs: PAGE_READY_WAIT_MS });
|
|
248
269
|
try {
|
|
249
|
-
snapshot = (await page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
270
|
+
snapshot = (await page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
250
271
|
if (isPageReady(snapshot) === 'ready')
|
|
251
272
|
break;
|
|
252
273
|
}
|
|
@@ -537,6 +558,7 @@ function parseActions(parsed) {
|
|
|
537
558
|
options: item.options,
|
|
538
559
|
direction: item.direction,
|
|
539
560
|
expression: item.expression,
|
|
561
|
+
query: item.query,
|
|
540
562
|
tab_id: item.tab_id,
|
|
541
563
|
};
|
|
542
564
|
});
|
|
@@ -571,11 +593,7 @@ export function assertExtractExpressionAllowed(expression) {
|
|
|
571
593
|
throw new Error('extract: expression references disallowed API (cookies, storage, network, or eval)');
|
|
572
594
|
}
|
|
573
595
|
}
|
|
574
|
-
async function executeAction(action, page,
|
|
575
|
-
if (customActions?.[action.action] !== undefined) {
|
|
576
|
-
await customActions[action.action](action, page);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
596
|
+
async function executeAction(action, page, config) {
|
|
579
597
|
switch (action.action) {
|
|
580
598
|
case 'click':
|
|
581
599
|
if (action.ref === undefined || action.ref === '')
|
|
@@ -593,7 +611,9 @@ async function executeAction(action, page, customActions) {
|
|
|
593
611
|
if (action.url === undefined || action.url === '')
|
|
594
612
|
throw new Error('navigate action requires url');
|
|
595
613
|
assertNavigateUrlAllowed(action.url);
|
|
614
|
+
logger.info({ url: action.url }, 'navigate: starting');
|
|
596
615
|
await page.goto(action.url);
|
|
616
|
+
logger.info({ url: action.url }, 'navigate: complete');
|
|
597
617
|
break;
|
|
598
618
|
case 'back':
|
|
599
619
|
await page.evaluate('window.history.back()');
|
|
@@ -612,11 +632,11 @@ async function executeAction(action, page, customActions) {
|
|
|
612
632
|
break;
|
|
613
633
|
case 'scroll':
|
|
614
634
|
await page.evaluate(action.direction === 'up'
|
|
615
|
-
? `window.scrollBy(0, -${String(
|
|
616
|
-
: `window.scrollBy(0, ${String(
|
|
635
|
+
? `window.scrollBy(0, -${String(config.scrollPixels)})`
|
|
636
|
+
: `window.scrollBy(0, ${String(config.scrollPixels)})`);
|
|
617
637
|
break;
|
|
618
638
|
case 'wait':
|
|
619
|
-
await page.waitFor({ timeMs:
|
|
639
|
+
await page.waitFor({ timeMs: config.waitActionMs });
|
|
620
640
|
break;
|
|
621
641
|
case 'done':
|
|
622
642
|
case 'fail':
|
|
@@ -630,12 +650,12 @@ async function executeAction(action, page, customActions) {
|
|
|
630
650
|
break;
|
|
631
651
|
}
|
|
632
652
|
}
|
|
633
|
-
function getWaitMs(action) {
|
|
653
|
+
function getWaitMs(action, config) {
|
|
634
654
|
switch (action) {
|
|
635
655
|
case 'type':
|
|
636
|
-
return
|
|
656
|
+
return config.waitAfterTypeMs;
|
|
637
657
|
case 'click':
|
|
638
|
-
return
|
|
658
|
+
return config.waitAfterClickMs;
|
|
639
659
|
case 'navigate':
|
|
640
660
|
case 'back':
|
|
641
661
|
case 'select':
|
|
@@ -651,7 +671,7 @@ function getWaitMs(action) {
|
|
|
651
671
|
case 'done':
|
|
652
672
|
case 'fail':
|
|
653
673
|
case 'ask_user':
|
|
654
|
-
return
|
|
674
|
+
return config.waitAfterOtherMs;
|
|
655
675
|
}
|
|
656
676
|
}
|
|
657
677
|
function describeActionError(action, err) {
|
|
@@ -808,8 +828,9 @@ Use ONLY data that appears in the memory or steps below — never invent values.
|
|
|
808
828
|
return { ready: false, missing: 'judgment unavailable' };
|
|
809
829
|
}
|
|
810
830
|
}
|
|
811
|
-
export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options) {
|
|
812
|
-
const
|
|
831
|
+
export async function runAgentLoop(prompt, pageOrHolder, emit, signal, waitForUser, browser, domainSkill, maxSteps, userChat, options) {
|
|
832
|
+
const cfg = defaultAgentConfig(options?.config);
|
|
833
|
+
maxSteps ??= cfg.maxSteps;
|
|
813
834
|
// Accept either a bare CrawlPage or a PageHolder. When a PageHolder is
|
|
814
835
|
// provided the caller's reference is updated on tab switches.
|
|
815
836
|
const holder = 'page' in pageOrHolder && typeof pageOrHolder.page === 'object'
|
|
@@ -831,6 +852,9 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options)
|
|
|
831
852
|
let pendingSiteSwitch = null;
|
|
832
853
|
let duplicateMemoryCount = 0;
|
|
833
854
|
let consecutiveNotReadyJudgments = 0;
|
|
855
|
+
let lastJudgmentMemoryLength = 0;
|
|
856
|
+
const bannedRefs = new Map();
|
|
857
|
+
const BAN_THRESHOLD = 2;
|
|
834
858
|
const forceComplete = async (reason, precomputedAnswer) => {
|
|
835
859
|
logger.warn({ step, reason }, 'Force-completing run');
|
|
836
860
|
const answer = precomputedAnswer ?? (await getFinalSummary(refinedPrompt, history));
|
|
@@ -856,27 +880,29 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options)
|
|
|
856
880
|
final_url: currentUrl,
|
|
857
881
|
};
|
|
858
882
|
};
|
|
859
|
-
const effectivePrompt = buildTask !== undefined ? buildTask(prompt) : prompt;
|
|
860
883
|
// ── Load task lessons ──────────────────────────────────────────────────────
|
|
861
884
|
let taskLesson = null;
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
885
|
+
if (options?.getLesson !== undefined) {
|
|
886
|
+
try {
|
|
887
|
+
taskLesson = await options.getLesson(prompt);
|
|
888
|
+
if (taskLesson !== null) {
|
|
889
|
+
const blocked = taskLesson.domains.filter((d) => d.status === 'blocked').length;
|
|
890
|
+
const worked = taskLesson.domains.filter((d) => d.status === 'worked').length;
|
|
891
|
+
logger.info({ blocked, worked, hash: taskLesson.task_hash }, 'Loaded task lessons');
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
logger.error({ err }, 'Failed to load task lessons');
|
|
868
896
|
}
|
|
869
|
-
}
|
|
870
|
-
catch (err) {
|
|
871
|
-
logger.error({ err }, 'Failed to load task lessons');
|
|
872
897
|
}
|
|
873
898
|
// ── Planning + goal refinement (single LLM call) ──────────────────────────
|
|
874
899
|
// If the prompt is vague (e.g. "find apartments in Chelsea"), the planner
|
|
875
900
|
// also produces a SMART task with clear scope and stopping criteria.
|
|
876
|
-
let refinedPrompt =
|
|
901
|
+
let refinedPrompt = prompt;
|
|
877
902
|
let planText = null;
|
|
878
903
|
try {
|
|
879
|
-
|
|
904
|
+
const taskInput = options?.buildTask !== undefined ? await options.buildTask(prompt) : prompt;
|
|
905
|
+
let planMessage = `User prompt: ${taskInput}`;
|
|
880
906
|
if (domainSkill !== undefined && domainSkill !== null) {
|
|
881
907
|
planMessage += `\n\nWe have a proven skill for this site: "${domainSkill.skill.title}" — ${domainSkill.skill.description}`;
|
|
882
908
|
planMessage += '\nLeverage it — no need to rediscover what already works.';
|
|
@@ -884,7 +910,7 @@ export async function runAgentLoop(prompt, pageOrHolder, emit, signal, options)
|
|
|
884
910
|
if (taskLesson !== null) {
|
|
885
911
|
const lessonText = formatLessonForPrompt(taskLesson);
|
|
886
912
|
if (lessonText !== '') {
|
|
887
|
-
planMessage += `\n\n${lessonText}
|
|
913
|
+
planMessage += `\n\n${lessonText}`;
|
|
888
914
|
}
|
|
889
915
|
}
|
|
890
916
|
const plan = await llmJson({
|
|
@@ -1090,7 +1116,14 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1090
1116
|
logger.info({ step }, 'Judge: answer ready — force-completing');
|
|
1091
1117
|
return await forceComplete('Judge ruled answer ready', judgment.answer);
|
|
1092
1118
|
}
|
|
1093
|
-
|
|
1119
|
+
const memoryGrew = mem.length > lastJudgmentMemoryLength + 50;
|
|
1120
|
+
lastJudgmentMemoryLength = mem.length;
|
|
1121
|
+
if (memoryGrew) {
|
|
1122
|
+
consecutiveNotReadyJudgments = 0;
|
|
1123
|
+
}
|
|
1124
|
+
else {
|
|
1125
|
+
consecutiveNotReadyJudgments++;
|
|
1126
|
+
}
|
|
1094
1127
|
if (consecutiveNotReadyJudgments >= JUDGE_FATIGUE_LIMIT) {
|
|
1095
1128
|
logger.warn({ step, nudges: consecutiveNotReadyJudgments, stillMissing: judgment.missing }, 'Judge fatigue — force-completing with partial answer');
|
|
1096
1129
|
return await forceComplete(`Fatigue: ${String(consecutiveNotReadyJudgments)} consecutive not-ready judgments; still missing "${judgment.missing}"`);
|
|
@@ -1165,13 +1198,18 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1165
1198
|
const userMessage = interjectionBlock !== null ? `${interjectionBlock}\n\n---\n\n${baseUserMessage}` : baseUserMessage;
|
|
1166
1199
|
// On the last step, force the agent to produce a final answer
|
|
1167
1200
|
const isLastStep = stepsRemaining === 0;
|
|
1168
|
-
const
|
|
1201
|
+
const resolvedSystemPrompt = options?.systemPrompt === undefined
|
|
1202
|
+
? SYSTEM_PROMPT
|
|
1203
|
+
: typeof options.systemPrompt === 'function'
|
|
1204
|
+
? options.systemPrompt(SYSTEM_PROMPT)
|
|
1205
|
+
: options.systemPrompt;
|
|
1206
|
+
const systemPrompt = isLastStep ? LAST_STEP_PROMPT : resolvedSystemPrompt;
|
|
1169
1207
|
let actions;
|
|
1170
1208
|
try {
|
|
1171
1209
|
const parsed = await llmJson({
|
|
1172
1210
|
system: systemPrompt,
|
|
1173
1211
|
message: userMessage,
|
|
1174
|
-
maxTokens:
|
|
1212
|
+
maxTokens: cfg.llmMaxTokens,
|
|
1175
1213
|
});
|
|
1176
1214
|
try {
|
|
1177
1215
|
actions = parseActions(parsed);
|
|
@@ -1179,6 +1217,10 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1179
1217
|
catch (parseErr) {
|
|
1180
1218
|
throw new LlmParseError(parseErr instanceof Error ? parseErr.message : 'Invalid action structure', JSON.stringify(parsed).slice(0, 200));
|
|
1181
1219
|
}
|
|
1220
|
+
const banned = actions[0]?.ref !== undefined ? bannedRefs.get(actions[0].ref) : undefined;
|
|
1221
|
+
if (banned !== undefined && banned.failures >= BAN_THRESHOLD && banned.action === actions[0].action) {
|
|
1222
|
+
throw new LlmParseError(`Ref "${actions[0].ref ?? ''}" is blocked after ${String(banned.failures)} consecutive "${actions[0].action}" failures on it. Pick a different ref from the current snapshot, or try a different approach (URL parameters, keyboard, different element). Do NOT use "${actions[0].ref ?? ''}" again.`, JSON.stringify(parsed).slice(0, 200));
|
|
1223
|
+
}
|
|
1182
1224
|
consecutiveParseFailures = 0;
|
|
1183
1225
|
crossSiteParseFailures = 0;
|
|
1184
1226
|
consecutiveApiFailures = 0;
|
|
@@ -1235,7 +1277,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1235
1277
|
// LLM responded but not with valid JSON — burn a step, the call was made
|
|
1236
1278
|
consecutiveParseFailures++;
|
|
1237
1279
|
crossSiteParseFailures++;
|
|
1238
|
-
logger.warn({
|
|
1280
|
+
logger.warn({
|
|
1281
|
+
step,
|
|
1282
|
+
consecutive: consecutiveParseFailures,
|
|
1283
|
+
crossSite: crossSiteParseFailures,
|
|
1284
|
+
rawSnippet: err.responseSnippet,
|
|
1285
|
+
}, 'LLM returned non-JSON response');
|
|
1239
1286
|
emit('step_error', {
|
|
1240
1287
|
step,
|
|
1241
1288
|
error: 'LLM response was not valid JSON',
|
|
@@ -1268,7 +1315,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1268
1315
|
}
|
|
1269
1316
|
// API/network error — don't burn a step, the agent never got to act
|
|
1270
1317
|
consecutiveApiFailures++;
|
|
1271
|
-
logger.error({
|
|
1318
|
+
logger.error({
|
|
1319
|
+
step,
|
|
1320
|
+
attempt: consecutiveApiFailures,
|
|
1321
|
+
maxAttempts: MAX_API_FAILURES,
|
|
1322
|
+
errorMessage: err instanceof Error ? sanitizeErrorText(err.message).slice(0, 300) : 'unknown',
|
|
1323
|
+
}, 'LLM API error');
|
|
1272
1324
|
emit('step_error', { step, error: 'AI service temporarily unavailable', type: 'api_error' });
|
|
1273
1325
|
if (consecutiveApiFailures >= MAX_API_FAILURES) {
|
|
1274
1326
|
return {
|
|
@@ -1370,7 +1422,6 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1370
1422
|
step++;
|
|
1371
1423
|
break;
|
|
1372
1424
|
}
|
|
1373
|
-
// #1 Evaluate fallback: LLM-requested JS extraction
|
|
1374
1425
|
if (action.action === 'extract') {
|
|
1375
1426
|
if (action.expression !== undefined && action.expression !== '') {
|
|
1376
1427
|
try {
|
|
@@ -1387,7 +1438,18 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1387
1438
|
}
|
|
1388
1439
|
}
|
|
1389
1440
|
else {
|
|
1390
|
-
|
|
1441
|
+
const items = await extractItems(holder.page);
|
|
1442
|
+
const payload = JSON.stringify({
|
|
1443
|
+
source: items.source,
|
|
1444
|
+
count: items.count,
|
|
1445
|
+
truncated: items.truncated,
|
|
1446
|
+
records: items.records,
|
|
1447
|
+
});
|
|
1448
|
+
agentStep.action.extract_result = payload.slice(0, EXTRACT_RESULT_MAX_CHARS);
|
|
1449
|
+
if (payload.length > EXTRACT_RESULT_MAX_CHARS) {
|
|
1450
|
+
agentStep.action.extract_result += '\n…(truncated)';
|
|
1451
|
+
}
|
|
1452
|
+
logger.info({ step, source: items.source, count: items.count }, 'extract items auto');
|
|
1391
1453
|
}
|
|
1392
1454
|
logger.info({ step, result: agentStep.action.extract_result.slice(0, 100) }, 'Extract action');
|
|
1393
1455
|
step++;
|
|
@@ -1458,12 +1520,20 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1458
1520
|
// --- Normal actions ---
|
|
1459
1521
|
const preActionUrl = await holder.page.url();
|
|
1460
1522
|
try {
|
|
1461
|
-
|
|
1523
|
+
const customHandler = options?.customActions?.[action.action];
|
|
1524
|
+
if (customHandler !== undefined) {
|
|
1525
|
+
const result = await customHandler(action, holder.page, browser);
|
|
1526
|
+
if (result?.outcome !== undefined)
|
|
1527
|
+
agentStep.outcome = result.outcome;
|
|
1528
|
+
}
|
|
1529
|
+
else {
|
|
1530
|
+
await executeAction(action, holder.page, cfg);
|
|
1531
|
+
}
|
|
1462
1532
|
// After typing, detect autocomplete/combobox fields
|
|
1463
1533
|
if (action.action === 'type') {
|
|
1464
1534
|
await holder.page.waitFor({ timeMs: 400 });
|
|
1465
1535
|
try {
|
|
1466
|
-
const postTypeSnapshot = (await holder.page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
1536
|
+
const postTypeSnapshot = (await holder.page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
1467
1537
|
if (/combobox|listbox|aria-autocomplete|suggestion|dropdown/i.test(postTypeSnapshot)) {
|
|
1468
1538
|
agentStep.action.error_feedback =
|
|
1469
1539
|
'AUTOCOMPLETE DETECTED: A dropdown/suggestion list appeared after typing. Wait for it to fully load, then click the correct suggestion. Do NOT press Enter.';
|
|
@@ -1479,7 +1549,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1479
1549
|
let postActionUrl = preActionUrl;
|
|
1480
1550
|
try {
|
|
1481
1551
|
postActionUrl = await holder.page.url();
|
|
1482
|
-
const postSnapshot = (await holder.page.snapshot({ interactive: true, compact: true })).snapshot;
|
|
1552
|
+
const postSnapshot = (await holder.page.snapshot({ interactive: true, compact: true, timeoutMs: SNAPSHOT_TIMEOUT_MS })).snapshot;
|
|
1483
1553
|
const outcome = validateAction(action, preActionUrl, postActionUrl, snapshot.length, postSnapshot.length);
|
|
1484
1554
|
if (outcome !== undefined) {
|
|
1485
1555
|
agentStep.outcome = outcome;
|
|
@@ -1505,6 +1575,12 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1505
1575
|
emit('step_error', { step, action: action.action, error: rawMessage });
|
|
1506
1576
|
agentStep.action.error_feedback = feedback;
|
|
1507
1577
|
recentFailureCount++;
|
|
1578
|
+
if (action.ref !== undefined && action.ref !== '' && /not found or not visible/i.test(rawMessage)) {
|
|
1579
|
+
const entry = bannedRefs.get(action.ref) ?? { action: action.action, failures: 0 };
|
|
1580
|
+
entry.failures += 1;
|
|
1581
|
+
entry.action = action.action;
|
|
1582
|
+
bannedRefs.set(action.ref, entry);
|
|
1583
|
+
}
|
|
1508
1584
|
await holder.page.waitFor({ timeMs: 1000 });
|
|
1509
1585
|
if (await detectPopup(holder.page)) {
|
|
1510
1586
|
await dismissPopup(holder.page);
|
|
@@ -1540,7 +1616,7 @@ Respond with JSON: {"plan": "your revised plan here"}`,
|
|
|
1540
1616
|
break; // New tab opened — need new snapshot
|
|
1541
1617
|
}
|
|
1542
1618
|
}
|
|
1543
|
-
const waitMs = getWaitMs(action.action);
|
|
1619
|
+
const waitMs = getWaitMs(action.action, cfg);
|
|
1544
1620
|
await holder.page.waitFor({ timeMs: waitMs });
|
|
1545
1621
|
step++;
|
|
1546
1622
|
}
|