mercury-agent 0.4.21 → 0.4.23
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 |
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Bug: mercury run fails when registry pull fails but local build exists
|
|
2
|
+
|
|
3
|
+
**Severity:** P1 — blocks users who build locally but have a registry image configured
|
|
4
|
+
**Introduced in:** 0.4.22 (commit 75e8e71)
|
|
5
|
+
**File:** `src/cli/mercury.ts`, `runAction()` (~line 187)
|
|
6
|
+
|
|
7
|
+
## Symptom
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
$ mercury build # succeeds → mercury-agent:latest exists locally
|
|
11
|
+
$ mercury run
|
|
12
|
+
Image 'ghcr.io/avishai-tsabari/mercury-agent:latest' not found locally, pulling...
|
|
13
|
+
Error response from daemon: unauthorized
|
|
14
|
+
|
|
15
|
+
Error: Failed to pull 'ghcr.io/avishai-tsabari/mercury-agent:latest'.
|
|
16
|
+
If this is a private registry, authenticate first:
|
|
17
|
+
docker login ghcr.io
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The user has a working local image (`mercury-agent:latest`) from `mercury build`,
|
|
21
|
+
but `mercury run` exits with an error because the configured registry image
|
|
22
|
+
can't be pulled (private registry, no auth, offline, etc.).
|
|
23
|
+
|
|
24
|
+
## Root cause
|
|
25
|
+
|
|
26
|
+
The 0.4.22 change replaced the local-fallback logic with auto-pull. The old
|
|
27
|
+
code tried `mercury-agent:latest` as a fallback when the configured image
|
|
28
|
+
wasn't found. The new code attempts `docker pull` and hard-exits on failure —
|
|
29
|
+
it never checks whether a locally built image exists.
|
|
30
|
+
|
|
31
|
+
### Before (0.4.21)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
configured image not found?
|
|
35
|
+
→ try mercury-agent:latest locally
|
|
36
|
+
→ found? use it (with warning)
|
|
37
|
+
→ not found? error: pull or build
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### After (0.4.22)
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
configured image not found?
|
|
44
|
+
→ is registry image? attempt pull
|
|
45
|
+
→ pull fails? hard exit ← BUG: should fall back to local
|
|
46
|
+
→ not registry? error: run mercury build
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Fix
|
|
50
|
+
|
|
51
|
+
After a failed pull of a registry image, check if `mercury-agent:latest`
|
|
52
|
+
exists locally before giving up. If it does, use it with a warning.
|
|
53
|
+
|
|
54
|
+
### Implementation (lines ~187–213 of `src/cli/mercury.ts`)
|
|
55
|
+
|
|
56
|
+
Replace the current block:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
if (imageCheck.status !== 0) {
|
|
60
|
+
const isRegistryImage = imageName.includes("/");
|
|
61
|
+
if (isRegistryImage) {
|
|
62
|
+
console.log(`Image '${imageName}' not found locally, pulling...`);
|
|
63
|
+
const pull = spawnSync("docker", ["pull", imageName], {
|
|
64
|
+
stdio: "inherit",
|
|
65
|
+
});
|
|
66
|
+
if (pull.signal) {
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
if (pull.status !== 0) {
|
|
70
|
+
const firstSegment = imageName.split("/")[0];
|
|
71
|
+
const registry = firstSegment.includes(".")
|
|
72
|
+
? firstSegment
|
|
73
|
+
: "docker.io";
|
|
74
|
+
console.error(`\nError: Failed to pull '${imageName}'.`);
|
|
75
|
+
console.error(
|
|
76
|
+
"If this is a private registry, authenticate first:\n" +
|
|
77
|
+
` docker login ${registry}`,
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
console.error(`Error: Container image '${imageName}' not found.`);
|
|
83
|
+
console.error("Run 'mercury build' to build it.");
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
With:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
if (imageCheck.status !== 0) {
|
|
93
|
+
const isRegistryImage = imageName.includes("/");
|
|
94
|
+
let resolved = false;
|
|
95
|
+
|
|
96
|
+
if (isRegistryImage) {
|
|
97
|
+
console.log(`Image '${imageName}' not found locally, pulling...`);
|
|
98
|
+
const pull = spawnSync("docker", ["pull", imageName], {
|
|
99
|
+
stdio: "inherit",
|
|
100
|
+
});
|
|
101
|
+
if (pull.signal) {
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (pull.status === 0) {
|
|
105
|
+
resolved = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Fallback: if the configured image isn't available (pull failed, not a
|
|
110
|
+
// registry image, or never pulled), try the local build tag.
|
|
111
|
+
if (!resolved) {
|
|
112
|
+
const localTag = "mercury-agent:latest";
|
|
113
|
+
if (imageName !== localTag) {
|
|
114
|
+
const localCheck = spawnSync("docker", ["image", "inspect", localTag], {
|
|
115
|
+
stdio: "pipe",
|
|
116
|
+
});
|
|
117
|
+
if (localCheck.status === 0) {
|
|
118
|
+
console.log(
|
|
119
|
+
`\nℹ️ Using locally built ${localTag} (configured image unavailable)\n`,
|
|
120
|
+
);
|
|
121
|
+
process.env.MERCURY_AGENT_IMAGE = localTag;
|
|
122
|
+
resolved = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
if (isRegistryImage) {
|
|
129
|
+
const firstSegment = imageName.split("/")[0];
|
|
130
|
+
const registry = firstSegment.includes(".")
|
|
131
|
+
? firstSegment
|
|
132
|
+
: "docker.io";
|
|
133
|
+
console.error(`\nError: Failed to pull '${imageName}'.`);
|
|
134
|
+
console.error(
|
|
135
|
+
"If this is a private registry, authenticate first:\n" +
|
|
136
|
+
` docker login ${registry}\n` +
|
|
137
|
+
"Or build locally:\n" +
|
|
138
|
+
" mercury build",
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
console.error(`Error: Container image '${imageName}' not found.`);
|
|
142
|
+
console.error("Run 'mercury build' to build it.");
|
|
143
|
+
}
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Behavior after fix
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
configured image not found?
|
|
153
|
+
→ is registry image? attempt pull
|
|
154
|
+
→ pull succeeds? use it ✓
|
|
155
|
+
→ pull fails? continue to fallback
|
|
156
|
+
→ try mercury-agent:latest locally
|
|
157
|
+
→ found? use it (with info message) ✓
|
|
158
|
+
→ not found? error with both suggestions (login + build) ✓
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## How to verify
|
|
162
|
+
|
|
163
|
+
1. Build locally: `mercury build`
|
|
164
|
+
2. Ensure no GHCR auth: `docker logout ghcr.io`
|
|
165
|
+
3. Set `.env` to `MERCURY_AGENT_IMAGE=ghcr.io/avishai-tsabari/mercury-agent:latest`
|
|
166
|
+
4. Run `mercury run` — should fall back to local image with info message
|
|
167
|
+
5. Remove local image: `docker rmi mercury-agent:latest`
|
|
168
|
+
6. Run `mercury run` — should error with both `docker login` and `mercury build` suggestions
|
package/package.json
CHANGED
|
@@ -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.
|