Skip to content

Commit 543fe8a

Browse files
lmorchardclaude
andcommitted
feat: prompt improvements, CLI Dockerfile, and sanitize AI generation logs
Prompt improvements (based on eval run analysis): - Acknowledge dynamic pages that load content on scroll (remove false "no scrolling needed" claim) - Add guidance for click failures, autocomplete/combobox fields, date pickers, stale element refs, and PDF-avoidance (use HTML versions) - Fix bug: hasWebSearch was not passed to actionLoopSystemPromptTemplate, so the webSearch tool instruction was never shown to the model - Add step-error recovery guidance (stale refs, retrying with different approach, no repeated actions) - Refine abort() vs done(): only abort when core task fails; done() is acceptable when supplementary details are inaccessible - Require explicit criterion confirmation in done() answers CLI Dockerfile (for eval/standalone use): - Uses --ignore-scripts + pnpm rebuild esbuild to avoid prepare lifecycle running before source is copied - Installs Playwright system deps as root, browser as node user - INSTALL_PLAYWRIGHT_BROWSERS ARG to skip browser install in CI/tests - Stub config files for both root and node users Strip binary image data from ai:generation logs by default: - JSONConsoleLogger sanitizes image content blocks in AI_GENERATION events (replaces raw bytes with { type, mediaType, size, data: '<omitted>' }) - Avoids 50-100MB stdout.txt files from embedded JPEG data - Opt-out via sanitizeGenerationImages: false Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 45b86d4 commit 543fe8a

4 files changed

Lines changed: 227 additions & 4 deletions

File tree

packages/cli/Dockerfile

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
FROM node:22-slim
2+
3+
# Install pnpm
4+
RUN npm install -g pnpm
5+
6+
# Build argument to control Playwright browser installation
7+
ARG INSTALL_PLAYWRIGHT_BROWSERS=true
8+
9+
# Set working directory to repo root
10+
WORKDIR /app
11+
12+
# Copy workspace manifests so pnpm can resolve the workspace graph
13+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
14+
15+
# Copy all package manifests before installing so pnpm can link the workspace
16+
COPY packages/core/package.json ./packages/core/package.json
17+
COPY packages/cli/package.json ./packages/cli/package.json
18+
COPY packages/server/package.json ./packages/server/package.json
19+
COPY packages/extension/package.json ./packages/extension/package.json
20+
21+
# Install all workspace dependencies.
22+
# --ignore-scripts prevents the root prepare lifecycle script from running
23+
# pilo-core build before its source has been copied into the image.
24+
# We then selectively rebuild only packages with native binaries (esbuild)
25+
# so their postinstall scripts run without triggering the root prepare.
26+
RUN pnpm install --frozen-lockfile --ignore-scripts
27+
RUN pnpm rebuild esbuild
28+
29+
# Copy core source and build it (CLI depends on pilo-core)
30+
COPY packages/core/ ./packages/core/
31+
RUN pnpm --filter pilo-core run build
32+
33+
# Install Playwright system dependencies as root (requires apt)
34+
RUN if [ "$INSTALL_PLAYWRIGHT_BROWSERS" = "true" ]; then \
35+
npx playwright install-deps chromium; \
36+
fi
37+
38+
# Copy CLI source and build
39+
COPY packages/cli/ ./packages/cli/
40+
RUN pnpm --filter pilo-cli run build
41+
42+
# Set environment variables
43+
ENV NODE_ENV=production
44+
45+
# Create minimal stub config for root user (container may run as root)
46+
RUN mkdir -p /root/.config/pilo && echo '{}' > /root/.config/pilo/config.json
47+
48+
# Ensure node user owns the app files
49+
RUN chown -R node:node /app
50+
51+
# Switch to non-root user
52+
USER node
53+
54+
# Create minimal stub configs to satisfy the CLI config guard.
55+
# The guard only checks for file existence; actual provider/key config
56+
# is supplied via environment variables (dev-mode env var loading).
57+
# Both root and node user paths are created because callers may run
58+
# the container as either user.
59+
RUN mkdir -p /home/node/.config/pilo && echo '{}' > /home/node/.config/pilo/config.json
60+
61+
# Install Playwright browsers as node user (so they end up in /home/node/.cache)
62+
# ARG must be redeclared after USER to be accessible (with same default)
63+
ARG INSTALL_PLAYWRIGHT_BROWSERS=true
64+
RUN if [ "$INSTALL_PLAYWRIGHT_BROWSERS" = "true" ]; then \
65+
npx playwright install chromium; \
66+
fi
67+
68+
# Run pilo CLI - subcommand args are appended by the caller
69+
ENTRYPOINT ["node", "/app/packages/cli/dist/cli/src/cli.js"]

packages/core/src/loggers/json.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,63 @@ const DEFAULT_EXCLUDED_EVENTS: readonly WebAgentEventType[] = [
1313
export interface JSONConsoleLoggerOptions {
1414
/** Include screenshot image events (default: false) */
1515
includeScreenshotImages?: boolean;
16+
/**
17+
* Strip binary image data from ai:generation message history (default: true).
18+
* Images are replaced with { type, mediaType, size, data: '<omitted>' }.
19+
* Disable only if you need the raw bytes for debugging.
20+
*/
21+
sanitizeGenerationImages?: boolean;
22+
}
23+
24+
/**
25+
* Strip binary image data from ai:generation event data before logging.
26+
* Image content blocks are replaced with a lightweight descriptor:
27+
* { type: 'image', mediaType, size, data: '<omitted>' }
28+
* The actual JPEG files are already saved separately alongside stdout.txt.
29+
*/
30+
function sanitizeAiGenerationData(data: unknown): unknown {
31+
if (!data || typeof data !== "object") return data;
32+
const d = data as Record<string, unknown>;
33+
if (!Array.isArray(d.messages)) return data;
34+
35+
return {
36+
...d,
37+
messages: d.messages.map((msg: unknown) => {
38+
if (!msg || typeof msg !== "object") return msg;
39+
const m = msg as Record<string, unknown>;
40+
if (!Array.isArray(m.content)) return msg;
41+
42+
return {
43+
...m,
44+
content: m.content.map((block: unknown) => {
45+
if (!block || typeof block !== "object") return block;
46+
const b = block as Record<string, unknown>;
47+
if (b.type === "image" && ArrayBuffer.isView(b.image)) {
48+
const buf = b.image as ArrayBufferView;
49+
return {
50+
type: "image",
51+
mediaType: b.mediaType,
52+
size: buf.byteLength,
53+
data: "<omitted>",
54+
};
55+
}
56+
return block;
57+
}),
58+
};
59+
}),
60+
};
1661
}
1762

1863
export class JSONConsoleLogger implements Logger {
1964
private emitter: WebAgentEventEmitter | null = null;
2065
private handlers: [WebAgentEventType, (data: any) => void][] = [];
2166
private excludedEvents: Set<WebAgentEventType>;
67+
private sanitizeGenerationImages: boolean;
2268

2369
constructor(options: JSONConsoleLoggerOptions = {}) {
2470
// Build excluded events set
2571
this.excludedEvents = new Set(options.includeScreenshotImages ? [] : DEFAULT_EXCLUDED_EVENTS);
72+
this.sanitizeGenerationImages = options.sanitizeGenerationImages !== false; // default: true
2673
}
2774

2875
initialize(emitter: WebAgentEventEmitter): void {
@@ -55,7 +102,15 @@ export class JSONConsoleLogger implements Logger {
55102
return;
56103
}
57104

58-
const json = JSON.stringify({ event: type, data }, null, 0);
105+
// Strip raw image bytes from AI generation messages to keep stdout.txt manageable.
106+
// Screenshots are already saved separately as JPEG files; the binary arrays
107+
// in the message history serve no purpose in the log and can reach 50-100MB.
108+
const safeData =
109+
this.sanitizeGenerationImages && type === WebAgentEventType.AI_GENERATION
110+
? sanitizeAiGenerationData(data)
111+
: data;
112+
113+
const json = JSON.stringify({ event: type, data: safeData }, null, 0);
59114
console.log(json);
60115
};
61116
}

packages/core/src/prompts.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ You adapt to situations and find creative ways to complete tasks without getting
122122
123123
IMPORTANT:
124124
- You can see the entire page content through the accessibility tree snapshot.
125-
- You do not need to scroll or click links to navigate within a page - all content is visible to you.
125+
- The accessibility tree shows all currently loaded page elements. On dynamic pages, some content may only appear after scrolling or interaction — if expected data isn't visible, try scrolling or interacting to trigger loading.
126126
- Focus on the elements you need to interact with directly.
127127
`.trim();
128128

@@ -285,15 +285,21 @@ Analyze the current page state and determine your next action based on previous
285285
- extract() if you need more information
286286
287287
**Best Practices:**
288-
- Full page content is visible - no scrolling needed
288+
- The accessibility tree shows currently loaded elements; dynamic pages may load more content on scroll
289289
- Clear obstructing modals/popups first
290290
- Prefer click() over goto() for page navigation
291291
- Submit forms via enter() or submit button after filling
292292
- Find alternative elements if primary ones aren't available
293+
- When click() fails due to element interception, try focus() first, then keyboard navigation (Tab, Enter, arrow keys), or press Escape to dismiss overlapping overlays
294+
- For autocomplete/combobox search fields (e.g., flight origin/destination, location pickers): after fill(), use focus() on a visible suggestion in the dropdown followed by enter() to select it — click() on autocomplete suggestions often times out
295+
- For date pickers and calendar widgets: prefer typing dates directly into the date input field using fill() rather than clicking through calendar months; if the field doesn't respond to fill(), try focus() on it first; avoid repeated calendar navigation clicks — if clicking "next month" fails twice, try filling the date field directly or using keyboard input
296+
- When you receive an 'Invalid element reference' error, the page DOM has changed — read the updated page snapshot on your next turn and use the new element refs; do not retry old ref IDs
293297
- Adapt your approach based on what's actually available
294298
- If you don't find relevant links or buttons, and the site has a search form, prioritize using it for navigation
295-
- Use abort() only after trying reasonable alternatives (site down, access blocked, required data unavailable)
299+
- If you have found the core information requested but cannot access supplementary details due to site limitations, use done() with what you have — only use abort() when the core task cannot be completed at all
296300
- For research: Use extract() immediately when finding relevant data
301+
- For academic papers or documents that require reading, counting, or extracting content (e.g., counting figures/tables, reading body text): PDFs are often unscrollable and unreadable — use webSearch to find an HTML version (e.g., ACL Anthology, Semantic Scholar) or the abstract page before attempting the PDF
302+
{% if hasWebSearch %}- If you need to search the web, use webSearch({query}) directly rather than filling in a browser search engine (DuckDuckGo, Google, Bing, etc.) — webSearch avoids CAPTCHA and bot detection that will block browser-based searches{% endif %}
297303
{% if hasGuardrails %}- Verify guardrail compliance before each action{% endif %}
298304
299305
**When using done():**
@@ -304,6 +310,7 @@ Provide your final answer:
304310
- Include all requested information
305311
- Format results as VALID Markdown
306312
- NEVER return raw JSON - ALWAYS format structured data as VALID Markdown
313+
- If the task required finding content that meets specific criteria (minimum rating, review count, price range, ingredient count, etc.), explicitly confirm each criterion was met in your answer
307314
308315
{% if hasGuardrails %}
309316
🚨 **GUARDRAIL COMPLIANCE:** Any action violating the provided guardrails is FORBIDDEN.
@@ -317,6 +324,7 @@ ${toolCallInstruction}
317324
const buildActionLoopSystemPrompt = (hasGuardrails: boolean, hasWebSearch: boolean = false) =>
318325
actionLoopSystemPromptTemplate({
319326
hasGuardrails,
327+
hasWebSearch,
320328
toolExamples: buildToolExamples(hasWebSearch),
321329
currentDate: getCurrentFormattedDate(),
322330
});
@@ -428,6 +436,11 @@ const stepErrorFeedbackTemplate = buildPromptTemplate(
428436
CRITICAL: ALL TOOL CALLS MUST COMPLY WITH THE PROVIDED GUARDRAILS
429437
{% endif %}
430438
439+
**Recovery guidance:**
440+
- If the error mentions "Invalid element reference" or "does not exist on the current page": the page DOM has changed and your cached ref IDs are stale. Do NOT retry the same ref. Wait for the next page snapshot (it will arrive automatically) and use only the new ref IDs shown there.
441+
- If an action keeps failing after 2 attempts, try a different approach: use a different element, navigate differently, or use extract() to re-read the current state.
442+
- Do NOT repeat the same action with the same arguments.
443+
431444
**Available Tools:**
432445
{{ toolExamples }}
433446
@@ -512,6 +525,7 @@ const taskValidationFeedbackTemplate = buildPromptTemplate(
512525
**Feedback:** {{ feedback }}
513526
514527
Do not repeat your previous answer. Address the issues identified above.
528+
If you cannot address the feedback due to genuine site limitations (disabled UI, inaccessible content), call done() with the best answer available rather than aborting.
515529
`.trim(),
516530
);
517531

packages/core/test/loggers.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,91 @@ describe("JSONConsoleLogger", () => {
829829
expect(parsed.data.temperature).toBe(0.7);
830830
});
831831

832+
it("should sanitize image Buffer data in AI_GENERATION messages", () => {
833+
const fakeJpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // JPEG header bytes
834+
const eventData: AIGenerationEventData = {
835+
timestamp: Date.now(),
836+
iterationId: "test-1",
837+
prompt: "Analyze the page",
838+
schema: {},
839+
messages: [
840+
{
841+
role: "user",
842+
content: [
843+
{ type: "text", text: "What do you see?" },
844+
{ type: "image", image: fakeJpeg, mimeType: "image/jpeg" },
845+
] as any,
846+
},
847+
],
848+
finishReason: "stop",
849+
usage: {
850+
totalTokens: 200,
851+
inputTokens: 150,
852+
outputTokens: 50,
853+
inputTokenDetails: { noCacheTokens: 150, cacheReadTokens: 0, cacheWriteTokens: 0 },
854+
outputTokenDetails: { textTokens: 50, reasoningTokens: 0 },
855+
},
856+
providerMetadata: {},
857+
warnings: [],
858+
};
859+
860+
emitter.emitEvent({
861+
type: WebAgentEventType.AI_GENERATION,
862+
data: eventData,
863+
});
864+
865+
expect(mockConsole.log).toHaveBeenCalledTimes(1);
866+
const output = mockConsole.log.mock.calls[0][0];
867+
const parsed = JSON.parse(output);
868+
869+
const imageBlock = parsed.data.messages[0].content[1];
870+
expect(imageBlock.type).toBe("image");
871+
expect(imageBlock.data).toBe("<omitted>");
872+
expect(imageBlock.size).toBe(fakeJpeg.byteLength);
873+
// Raw byte array must not appear in the output
874+
expect(output).not.toContain("255"); // 0xff byte value
875+
});
876+
877+
it("should preserve raw image data when sanitizeGenerationImages is false", () => {
878+
logger.dispose();
879+
const rawLogger = new JSONConsoleLogger({ sanitizeGenerationImages: false });
880+
rawLogger.initialize(emitter);
881+
882+
const fakeJpeg = Buffer.from([0xff, 0xd8]);
883+
const eventData: AIGenerationEventData = {
884+
timestamp: Date.now(),
885+
iterationId: "test-1",
886+
prompt: "Analyze the page",
887+
schema: {},
888+
messages: [
889+
{
890+
role: "user",
891+
content: [{ type: "image", image: fakeJpeg, mimeType: "image/jpeg" }] as any,
892+
},
893+
],
894+
finishReason: "stop",
895+
usage: {
896+
totalTokens: 100,
897+
inputTokens: 80,
898+
outputTokens: 20,
899+
inputTokenDetails: { noCacheTokens: 80, cacheReadTokens: 0, cacheWriteTokens: 0 },
900+
outputTokenDetails: { textTokens: 20, reasoningTokens: 0 },
901+
},
902+
providerMetadata: {},
903+
warnings: [],
904+
};
905+
906+
emitter.emitEvent({ type: WebAgentEventType.AI_GENERATION, data: eventData });
907+
908+
expect(mockConsole.log).toHaveBeenCalledTimes(1);
909+
const parsed = JSON.parse(mockConsole.log.mock.calls[0][0]);
910+
const imageBlock = parsed.data.messages[0].content[0];
911+
// data field should NOT be '<omitted>' when sanitization is disabled
912+
expect(imageBlock.data).not.toBe("<omitted>");
913+
914+
rawLogger.dispose();
915+
});
916+
832917
it("should output JSON for AI_GENERATION_ERROR events", () => {
833918
const eventData: AIGenerationErrorEventData = {
834919
timestamp: Date.now(),

0 commit comments

Comments
 (0)