mercury-agent 0.4.21 → 0.4.22

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.
@@ -0,0 +1,356 @@
1
+ # Fix: E2BIG — Argument list too long on bwrap/pi spawn
2
+
3
+ **Severity:** P0 — causes container crash on long conversations
4
+ **Affected:** All runtimes (bwrap + gVisor/direct), Docker Desktop especially
5
+ **File:** `src/agent/container-entry.ts`
6
+
7
+ ## Symptom
8
+
9
+ ```
10
+ Container failed (exit code 1): Error: E2BIG: argument list too long, posix_spawn 'bwrap'
11
+ ```
12
+
13
+ Happens when a conversation has enough history that the assembled CLI args + env vars
14
+ exceed Linux's `ARG_MAX` (~2MB). The user sees: *"Something went wrong processing your request."*
15
+
16
+ ## Root Cause
17
+
18
+ `invokePiOnce()` (line ~741) passes both the **system prompt** and **user prompt**
19
+ (which includes full conversation history XML) as CLI arguments:
20
+
21
+ ```typescript
22
+ // CURRENT — both prompts are argv strings
23
+ const piArgs = [
24
+ "--print", "--mode", "json", ...sessionArgs,
25
+ "--provider", provider, "--model", model,
26
+ ...toolModeArgs, "--no-extensions",
27
+ "-e", "/app/src/extensions/permission-guard.ts",
28
+ "-e", "/app/resources/pi-extensions/subagent/index.ts",
29
+ systemPromptFlag,
30
+ systemPrompt, // <-- can be large (system + ext prompt)
31
+ buildPrompt(payload), // <-- HUGE (conversation history XML)
32
+ ];
33
+ ```
34
+
35
+ These args are then passed to `spawn("bwrap", [...bwrapArgs, ...piArgs])` or
36
+ `spawn("pi", piArgs)`. Both go through `posix_spawn`/`execve`, which enforces
37
+ `ARG_MAX` on the combined size of all argv + envp strings.
38
+
39
+ The non-bwrap (gVisor) path has the **same latent bug** — it just has slightly
40
+ more headroom because bwrap's ~20 setup args aren't in the way.
41
+
42
+ ## Fix: Hybrid stdin + file-path delivery
43
+
44
+ pi already supports both mechanisms natively — **no pi changes needed**:
45
+
46
+ 1. **`resolvePromptInput()`** in `resource-loader.js:15-29`: if the value of
47
+ `--system-prompt` / `--append-system-prompt` is a path to an existing file,
48
+ pi reads the file content. Otherwise it uses the string as-is.
49
+
50
+ 2. **`readPipedStdin()`** in `main.js:40-55`: when `!process.stdin.isTTY`
51
+ (always true inside Docker with `stdio: ["pipe", ...]`), pi reads stdin
52
+ and prepends it to the initial message. In `--print` mode (which Mercury
53
+ uses), this becomes the user prompt.
54
+
55
+ ### Strategy
56
+
57
+ | Prompt | Delivery | Why |
58
+ |--------|----------|-----|
59
+ | System prompt | Write to file in IO_DIR, pass **file path** as arg | `resolvePromptInput` reads it; path string is tiny |
60
+ | User prompt | Pipe via **stdin** | `readPipedStdin` reads it; zero argv footprint |
61
+ | `MERCURY_EXT_SYSTEM_PROMPT` | Append to system prompt file (already concatenated in code) | Covered by the same file-path mechanism |
62
+
63
+ This removes both large strings from argv entirely.
64
+
65
+ ---
66
+
67
+ ## Implementation Guide
68
+
69
+ All changes are in **`src/agent/container-entry.ts`** unless noted.
70
+
71
+ ### Step 1: Add `unlinkSync` to fs import
72
+
73
+ ```diff
74
+ import {
75
+ accessSync,
76
+ constants,
77
+ existsSync,
78
+ readdirSync,
79
+ readFileSync,
80
+ renameSync,
81
+ + unlinkSync,
82
+ writeFileSync,
83
+ } from "node:fs";
84
+ ```
85
+
86
+ ### Step 2: Update `buildBwrapArgs` to accept optional `ioDir`
87
+
88
+ The system prompt file lives in IO_DIR, which must be visible inside the
89
+ bwrap sandbox. Add it as a **read-only** bind mount.
90
+
91
+ ```diff
92
+ -function buildBwrapArgs(workspace: string, command: string[]): string[] {
93
+ +function buildBwrapArgs(workspace: string, command: string[], ioDir?: string): string[] {
94
+ const args: string[] = [
95
+ "--ro-bind", "/usr", "/usr",
96
+ "--symlink", "usr/lib", "/lib",
97
+ "--symlink", "usr/bin", "/bin",
98
+ "--symlink", "usr/sbin", "/sbin",
99
+ ];
100
+ if (existsSync("/usr/lib64")) {
101
+ args.push("--symlink", "usr/lib64", "/lib64");
102
+ }
103
+ args.push("--ro-bind", "/app", "/app", "--ro-bind", "/etc", "/etc");
104
+ if (existsSync("/docs")) {
105
+ args.push("--ro-bind", "/docs", "/docs");
106
+ }
107
+ + if (ioDir) {
108
+ + args.push("--ro-bind", ioDir, ioDir);
109
+ + }
110
+ args.push(
111
+ "--bind", "/spaces", "/spaces",
112
+ ```
113
+
114
+ ### Step 3: Rewrite prompt construction in `invokePiOnce`
115
+
116
+ Replace the current piArgs construction (lines ~720-758) with:
117
+
118
+ ```typescript
119
+ function invokePiOnce(
120
+ payload: Payload,
121
+ provider: string,
122
+ model: string,
123
+ capabilities: ModelCapabilities,
124
+ ): Promise<PiJsonlParseResult> {
125
+ return new Promise((resolve, reject) => {
126
+ const overridePiPrompt =
127
+ process.env.OVERRIDE_PI_SYSTEM_PROMPT === "true" ||
128
+ process.env.OVERRIDE_PI_SYSTEM_PROMPT === "1";
129
+
130
+ // Combine base system prompt with extension-injected fragments
131
+ let systemPrompt = buildSystemPrompt(
132
+ capabilities,
133
+ payload,
134
+ overridePiPrompt,
135
+ );
136
+ const extPrompt = process.env.MERCURY_EXT_SYSTEM_PROMPT;
137
+ if (extPrompt) {
138
+ systemPrompt = `${systemPrompt}\n\n${extPrompt}`;
139
+ }
140
+
141
+ // Build the user prompt (conversation history + current message)
142
+ const userPrompt = buildPrompt(payload);
143
+
144
+ const sessionArgs = ["--no-session"];
145
+
146
+ const toolModeArgs = capabilities.tools
147
+ ? ([] as string[])
148
+ : (["--no-tools", "--no-skills"] as string[]);
149
+
150
+ const systemPromptFlag = overridePiPrompt
151
+ ? "--system-prompt"
152
+ : "--append-system-prompt";
153
+
154
+ // --- E2BIG fix: deliver large prompts via file + stdin ---
155
+ //
156
+ // System prompt → temp file in IO_DIR (pi's resolvePromptInput reads files).
157
+ // User prompt → stdin pipe (pi's readPipedStdin reads when !isTTY).
158
+ // This keeps argv small regardless of conversation length.
159
+ const ioDir = process.env.IO_DIR;
160
+ let systemPromptArg: string;
161
+ let systemPromptFile: string | undefined;
162
+ if (ioDir) {
163
+ systemPromptFile = path.join(ioDir, "system-prompt.txt");
164
+ writeFileSync(systemPromptFile, systemPrompt);
165
+ systemPromptArg = systemPromptFile; // pi reads the file, not the path string
166
+ } else {
167
+ systemPromptArg = systemPrompt; // dev fallback: inline (small payloads)
168
+ }
169
+
170
+ // piArgs no longer contains the user prompt — it goes via stdin.
171
+ // The positional message arg is omitted entirely.
172
+ const piArgs = [
173
+ "--print",
174
+ "--mode",
175
+ "json",
176
+ ...sessionArgs,
177
+ "--provider",
178
+ provider,
179
+ "--model",
180
+ model,
181
+ ...toolModeArgs,
182
+ "--no-extensions",
183
+ "-e",
184
+ "/app/src/extensions/permission-guard.ts",
185
+ "-e",
186
+ "/app/resources/pi-extensions/subagent/index.ts",
187
+ systemPromptFlag,
188
+ systemPromptArg,
189
+ // NOTE: no positional prompt arg — user prompt piped via stdin
190
+ ];
191
+ ```
192
+
193
+ ### Step 4: Update both spawn sites
194
+
195
+ Replace the spawn block (lines ~776-793):
196
+
197
+ ```typescript
198
+ const piSpawnedAt = Date.now();
199
+ let proc: ReturnType<typeof spawn>;
200
+ if (useBubblewrap) {
201
+ const bwrapArgs = [
202
+ ...buildBwrapArgs(payload.spaceWorkspace, ["pi"], ioDir ?? undefined),
203
+ ...piArgs,
204
+ ];
205
+ proc = spawn("bwrap", bwrapArgs, {
206
+ stdio: ["pipe", "pipe", "pipe"], // changed: stdin is now "pipe"
207
+ env: process.env,
208
+ });
209
+ } else {
210
+ proc = spawn("pi", piArgs, {
211
+ cwd: payload.spaceWorkspace,
212
+ stdio: ["pipe", "pipe", "pipe"], // changed: stdin is now "pipe"
213
+ env: process.env,
214
+ });
215
+ }
216
+
217
+ // Pipe user prompt via stdin — avoids E2BIG on long conversations.
218
+ // pi's readPipedStdin() reads this when !process.stdin.isTTY (always true in Docker).
219
+ proc.stdin!.on("error", () => {}); // swallow EPIPE if pi exits early
220
+ proc.stdin!.end(userPrompt, "utf8");
221
+ ```
222
+
223
+ ### Step 5: Clean up system prompt file on close
224
+
225
+ In the `proc.on("close", ...)` handler (line ~815), add cleanup at the top:
226
+
227
+ ```diff
228
+ proc.on("close", (code) => {
229
+ + // Clean up temp system prompt file (belt-and-suspenders; IO_DIR is ephemeral)
230
+ + if (systemPromptFile) {
231
+ + try { unlinkSync(systemPromptFile); } catch {}
232
+ + }
233
+ logTiming("container.pi.done", {
234
+ piDurationMs: Date.now() - piSpawnedAt,
235
+ exitCode: code ?? null,
236
+ });
237
+ ```
238
+
239
+ Also add the same cleanup in the `proc.on("error", ...)` handler:
240
+
241
+ ```diff
242
+ proc.on("error", (error) => {
243
+ + if (systemPromptFile) {
244
+ + try { unlinkSync(systemPromptFile); } catch {}
245
+ + }
246
+ reject(error);
247
+ });
248
+ ```
249
+
250
+ ---
251
+
252
+ ## P0 companion: Byte-budget guard on history (defense-in-depth)
253
+
254
+ Even with the delivery fix, it's good practice to cap history size. This also
255
+ reduces token waste on very long conversations.
256
+
257
+ In `buildHistoryXml` (line ~533), replace the unbounded entries construction:
258
+
259
+ ```typescript
260
+ const HISTORY_CHAR_BUDGET = 400_000; // ~100K tokens — well under model limits
261
+
262
+ function buildHistoryXml(messages: StoredMessage[]): string | null {
263
+ // Pair up user+assistant turns (existing logic unchanged)
264
+ const turns: Array<{ user: StoredMessage; assistant?: StoredMessage }> = [];
265
+ let pendingUser: StoredMessage | null = null;
266
+
267
+ for (const m of messages) {
268
+ if (m.role === "user") {
269
+ if (pendingUser) {
270
+ turns.push({ user: pendingUser });
271
+ }
272
+ pendingUser = m;
273
+ } else if (m.role === "assistant" && pendingUser) {
274
+ turns.push({ user: pendingUser, assistant: m });
275
+ pendingUser = null;
276
+ }
277
+ }
278
+ if (pendingUser) turns.push({ user: pendingUser });
279
+
280
+ if (turns.length === 0) return null;
281
+
282
+ // Build newest-first, stop when budget exhausted
283
+ const entries: string[] = [];
284
+ let usedChars = 0;
285
+
286
+ for (let i = turns.length - 1; i >= 0; i--) {
287
+ const { user, assistant } = turns[i];
288
+ const ts = formatContextTimestamp(user.createdAt);
289
+ const userLine = ` <user>${escapeXmlText(user.content)}</user>`;
290
+ const assistantLine = assistant
291
+ ? `\n <assistant>${escapeXmlText(assistant.content)}</assistant>`
292
+ : "";
293
+ const entry = ` <turn timestamp="${ts}">\n${userLine}${assistantLine}\n </turn>`;
294
+
295
+ if (usedChars + entry.length > HISTORY_CHAR_BUDGET) break;
296
+ usedChars += entry.length;
297
+ entries.unshift(entry); // maintain chronological order
298
+ }
299
+
300
+ if (entries.length === 0) return null;
301
+ return `<history>\n${entries.join("\n")}\n</history>`;
302
+ }
303
+ ```
304
+
305
+ ---
306
+
307
+ ## How to verify
308
+
309
+ ### 1. Unit test: large payload doesn't crash
310
+
311
+ Create a payload with >128KB of conversation history. Verify `invokePiOnce`
312
+ spawns successfully (no E2BIG). The container needs Docker running.
313
+
314
+ ### 2. Manual test: long WhatsApp conversation
315
+
316
+ Find (or create) a space with 50+ messages. Send a new message. Verify the
317
+ agent responds instead of crashing with "Something went wrong."
318
+
319
+ ### 3. Regression test: short conversations still work
320
+
321
+ Normal short conversations should be completely unaffected. The prompts reach
322
+ pi with identical content — only the delivery channel changed.
323
+
324
+ ### 4. Verify stdin content reaches pi correctly
325
+
326
+ Add a temporary `logTiming("prompt.sizes", { systemPromptLen: systemPrompt.length, userPromptLen: userPrompt.length })` before spawn. Compare with pi's
327
+ output to confirm the full prompt arrived.
328
+
329
+ ---
330
+
331
+ ## Edge cases to watch
332
+
333
+ | Case | Behavior |
334
+ |------|----------|
335
+ | IO_DIR unset (local dev) | System prompt stays inline in argv; user prompt still goes via stdin. Safe for small dev payloads. |
336
+ | pi exits before reading stdin | `proc.stdin.on("error", () => {})` swallows EPIPE. pi's exit code/stderr still captured normally. |
337
+ | System prompt file path collides with real text | Only triggers if the system prompt string happens to be a valid file path. Extremely unlikely for a multi-KB prompt. If paranoid, use a path with a UUID. |
338
+ | bwrap + IO_DIR mount | IO_DIR is `--ro-bind` (read-only). pi can read the system prompt file but cannot write to IO_DIR. Correct for security. |
339
+ | Retry logic in `runModelChain` | Each retry calls `invokePiOnce` fresh — each writes a new system prompt file and pipes stdin. Previous file is cleaned up on close. No stale state between retries. |
340
+ | `MERCURY_EXT_SYSTEM_PROMPT` | Already concatenated into `systemPrompt` before the file write. Covered automatically. |
341
+
342
+ ---
343
+
344
+ ## Summary of changes
345
+
346
+ | What | Where | Lines affected |
347
+ |------|-------|----------------|
348
+ | Add `unlinkSync` import | fs import block | 1 line |
349
+ | `buildBwrapArgs` accepts `ioDir` | Function signature + body | ~4 lines |
350
+ | System prompt → file in IO_DIR | `invokePiOnce`, before piArgs | ~8 lines |
351
+ | Remove user prompt from piArgs | `invokePiOnce`, piArgs array | Remove 1 line |
352
+ | stdin: "ignore" → "pipe" | Both spawn sites | 2 lines |
353
+ | Pipe user prompt to stdin | After spawn | 2 lines |
354
+ | Cleanup system prompt file | close + error handlers | 4 lines |
355
+ | **Total** | | **~22 lines changed** |
356
+ | Byte-budget history (optional companion) | `buildHistoryXml` | Replace function body |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -22,6 +22,10 @@ MERCURY_ANTHROPIC_API_KEY=
22
22
  # TradeStation: allow non-SIM account orders via POST /api/tradestation/orders (default: false)
23
23
  # MERCURY_TS_ALLOW_LIVE_ORDERS=
24
24
 
25
+ # ─── Model (uncomment to override mercury.yaml) ─────────────────────────
26
+ # MERCURY_MODEL_PROVIDER=google
27
+ # MERCURY_MODEL_CHAIN=[{"provider":"google","model":"gemma-4-31b-it"}]
28
+
25
29
  # ─── Optional: override mercury.yaml ─────────────────────────────────────
26
30
  # Non-secret settings can live in mercury.yaml (see resources/templates/mercury.example.yaml).
27
31
  # Any MERCURY_* variable set here overrides the YAML value for that key.
@@ -185,22 +185,24 @@ async function runAction(): Promise<void> {
185
185
  stdio: "pipe",
186
186
  });
187
187
  if (imageCheck.status !== 0) {
188
- // If the configured (registry) image isn't available, try the local build tag
189
- const localTag = "mercury-agent:latest";
190
- if (imageName !== localTag) {
191
- const localCheck = spawnSync("docker", ["image", "inspect", localTag], {
192
- stdio: "pipe",
188
+ const isRegistryImage = imageName.includes("/");
189
+ if (isRegistryImage) {
190
+ console.log(`Image '${imageName}' not found locally, pulling...`);
191
+ const pull = spawnSync("docker", ["pull", imageName], {
192
+ stdio: "inherit",
193
193
  });
194
- if (localCheck.status === 0) {
195
- console.log(
196
- `ℹ️ Registry image not found, using locally built ${localTag}\n`,
197
- );
198
- process.env.MERCURY_AGENT_IMAGE = localTag;
199
- } else {
200
- console.error(`Error: Container image '${imageName}' not found.`);
194
+ if (pull.signal) {
195
+ process.exit(1);
196
+ }
197
+ if (pull.status !== 0) {
198
+ const firstSegment = imageName.split("/")[0];
199
+ const registry = firstSegment.includes(".")
200
+ ? firstSegment
201
+ : "docker.io";
202
+ console.error(`\nError: Failed to pull '${imageName}'.`);
201
203
  console.error(
202
- `Pull it: docker pull ${imageName}\n` +
203
- `Or build: mercury build`,
204
+ "If this is a private registry, authenticate first:\n" +
205
+ ` docker login ${registry}`,
204
206
  );
205
207
  process.exit(1);
206
208
  }
@@ -274,7 +276,9 @@ function buildAction(): void {
274
276
  copyDirRecursive(resourcesSrc, resourcesDest);
275
277
  if (!existsSync(resourcesDest)) {
276
278
  console.error(`❌ Failed to copy resources/ into build context`);
277
- console.error(` Source: ${resourcesSrc} (exists: ${existsSync(resourcesSrc)})`);
279
+ console.error(
280
+ ` Source: ${resourcesSrc} (exists: ${existsSync(resourcesSrc)})`,
281
+ );
278
282
  console.error(` Dest: ${resourcesDest}`);
279
283
  process.exit(1);
280
284
  }