@ducci/jarvis 1.0.31 → 1.0.33

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,142 @@
1
+ # Finding 015: Failed Runs Leave Tool History in Context (Context Bloat Death Spiral)
2
+
3
+ **Date:** 2026-03-02
4
+ **Severity:** High — caused 3 consecutive `model_error: Empty choices array` failures; session unusable
5
+ **Status:** Fixed
6
+
7
+ ---
8
+
9
+ ## Observed Session
10
+
11
+ Session `6123209d-ce5a-44d0-be12-29aac58b4cf3`. Model: `nvidia/nemotron-3-nano-30b-a3b:free`. User requested a ZAP security scanning project.
12
+
13
+ | Entry | Trigger | Status | messageCount at failure | toolCalls |
14
+ |-------|---------|--------|------------------------|-----------|
15
+ | 1 | "hi all good?" | ok | — | 0 |
16
+ | 2 | ZAP task (run 1) | checkpoint_reached | — | 10 |
17
+ | 3 | handoff resume (run 2) | checkpoint_reached | — | 10 |
18
+ | 4 | handoff resume (run 3) | model_error (empty choices, iter 7) | 22 | 26 |
19
+ | 5 | "Why I get Model returned an empty response?" | model_error (empty choices, iter 3) | 27 | 2 |
20
+ | 6 | "Why I get Model returned an empty response again?!!" | model_error (empty choices, iter 5) | 37 | 4 |
21
+
22
+ The session ended without producing any result. The user received `'Model returned an empty response.'` three times.
23
+
24
+ ---
25
+
26
+ ## Root Cause 1: Failed runs leave tool call history in session
27
+
28
+ ### What happened
29
+
30
+ The handoff loop strips tool call messages for `checkpoint_reached` runs:
31
+
32
+ ```js
33
+ session.messages.splice(runStartIndex, session.messages.length - runStartIndex - 1);
34
+ ```
35
+
36
+ Runs that end with `model_error` or `format_error` received **no strip**. Every tool call message (assistant+tool pair, nudge injections) from the failed run remained in `session.messages`, with only a synthetic error note appended afterward.
37
+
38
+ Run 3 had 26 tool calls across 7 iterations — approximately 13 messages added to the session. These were preserved verbatim. Each subsequent user turn started with more context than the last.
39
+
40
+ ### Message count growth
41
+
42
+ - Before run 3: ~8 messages (runs 1 and 2 were both checkpoint_reached and stripped correctly)
43
+ - After entry 4 (model_error, no strip): 21 messages + synthetic note = 22
44
+ - After entry 5 (model_error, no strip): 27 messages + synthetic note = 28
45
+ - At entry 6: 37 messages in context
46
+
47
+ The free model returns `choices: []` when the context exceeds what it can handle. Each failure added more context, making the next failure more likely: a **positive feedback death spiral**.
48
+
49
+ ### Fix
50
+
51
+ Apply the same splice that checkpoint runs already use:
52
+
53
+ ```js
54
+ if (finalStatus === 'model_error' || finalStatus === 'format_error') {
55
+ session.messages.splice(runStartIndex, session.messages.length - runStartIndex);
56
+ // then push synthetic error note as before
57
+ }
58
+ ```
59
+
60
+ The strip runs before the synthetic error note is pushed, returning the session to its pre-run state plus one concise note. The JSONL log preserves all tool results for retrospective inspection via `read_session_log`.
61
+
62
+ **File**: `src/server/agent.js` — `_runHandleChat`, non-checkpoint break path
63
+
64
+ ---
65
+
66
+ ## Root Cause 2: No detection or escalation for consecutive model_errors
67
+
68
+ ### What happened
69
+
70
+ After two consecutive `model_error: Empty choices array` entries (4 and 5), no protective mechanism fired. The system continued accepting new user messages and spawning new runs indefinitely.
71
+
72
+ Existing protection mechanisms all missed this case:
73
+ - `maxHandoffs` — only applies to `checkpoint_reached` runs
74
+ - `consecutiveFailures` — tracks tool failures within a single run
75
+ - Zero-progress detection — only applies to `checkpoint_reached` runs
76
+
77
+ ### Fix
78
+
79
+ Detect the pattern structurally in `session.messages` before starting each new run: if the last two assistant messages are both synthetic `model_error` notes, the session is in a confirmed failure loop. Escalate to `intervention_required` without running another agent loop.
80
+
81
+ ```js
82
+ function hasConsecutiveModelErrors(messages) {
83
+ const assistantTail = messages.filter(m => m.role === 'assistant').slice(-2);
84
+ return (
85
+ assistantTail.length === 2 &&
86
+ assistantTail.every(
87
+ m =>
88
+ typeof m.content === 'string' &&
89
+ m.content.startsWith('[System: Previous run failed (model_error)')
90
+ )
91
+ );
92
+ }
93
+ ```
94
+
95
+ This uses no additional state: it reads the session history directly. Old sessions are handled correctly. One failure is allowed (transient errors are real); two consecutive failures mean the session cannot self-recover.
96
+
97
+ Combined with Fix 1, consecutive model_errors in this session would have played out as:
98
+ 1. Entry 4 (run 3): model_error → strip → synthetic note. Session back to 9 messages.
99
+ 2. Entry 5 (user "Why?"): run 4 starts with 10 messages. If it still fails → strip → synthetic note. Two model_error notes now in session.
100
+ 3. Entry 6 (user "Why again?!"): `hasConsecutiveModelErrors` fires → `intervention_required` returned immediately. User gets a clear message: start a new session or switch model.
101
+
102
+ **File**: `src/server/agent.js` — `hasConsecutiveModelErrors` function + check at top of handoff loop
103
+
104
+ ---
105
+
106
+ ## Root Cause 3: Empty choices error message provides no actionable guidance
107
+
108
+ ### What happened
109
+
110
+ The `choices.length === 0` path returned:
111
+
112
+ ```
113
+ Model returned an empty response.
114
+ ```
115
+
116
+ When the user asked "why?", the agent — with ZAP tool call context still present — continued ZAP investigation instead of explaining the API failure. The opaque error and the polluted context compounded: the model had no clear signal about what went wrong and no guidance on how to recover.
117
+
118
+ ### Fix
119
+
120
+ Include the context size and recovery guidance in the response:
121
+
122
+ ```js
123
+ response: `Model returned an empty response (${preparedMessages.length} messages in context). This typically happens when the conversation is too long for the model. Try starting a new session or switching to a model with a larger context window.`,
124
+ ```
125
+
126
+ **File**: `src/server/agent.js` — `runAgentLoop`, empty choices early return
127
+
128
+ ---
129
+
130
+ ## Why Fix 1 is Primary
131
+
132
+ Fix 1 is the root fix. With context stripped after each failure, the model operates on a tiny session (~10 messages) on subsequent turns. The free model handles this easily. Fix 2 is a safety net for persistent non-context failures. Fix 3 improves user-facing error messages for the residual cases that slip through.
133
+
134
+ ---
135
+
136
+ ## Files Changed
137
+
138
+ | File | Change |
139
+ |------|--------|
140
+ | `src/server/agent.js` | Strip tool history on `model_error`/`format_error` (same as checkpoint) |
141
+ | `src/server/agent.js` | `hasConsecutiveModelErrors` function + check before each run in handoff loop |
142
+ | `src/server/agent.js` | Include message count in empty choices response |
@@ -0,0 +1,119 @@
1
+ # Finding 016: File Writing Corruption, Misleading Stderr Nudge, and Repeated-Error Loop
2
+
3
+ **Date:** 2026-03-02
4
+ **Severity:** High — agent burned 10 iterations on the wrong diagnosis; task abandoned
5
+ **Status:** Fixed
6
+
7
+ ---
8
+
9
+ ## Observed Session
10
+
11
+ Session `a25fd973-3e92-4902-a96d-536ef0eb3005`. Model: `nvidia/nemotron-3-nano-30b-a3b:free`. User asked to run a ZAP security scanner script.
12
+
13
+ The script (`scan.sh`) failed immediately with `$ZAP_CMD: command not found`. The agent used all 10 iterations investigating PATH issues and gave up without solving the problem or producing a handoff checkpoint.
14
+
15
+ ---
16
+
17
+ ## Root Cause 1: Shell Script Written with Escaped Dollar Signs
18
+
19
+ ### What happened
20
+
21
+ `scan.sh` was written in a prior session by the agent using `exec` with a shell command (echo or heredoc). Multi-layered escaping — JS string → JSON encoding → bash variable expansion — caused every `$` in the script to be written as `\$` in the file.
22
+
23
+ In bash, `"\$VAR"` (backslash-dollar in double quotes) suppresses variable expansion and produces the literal string `$VAR`. The script ran but nothing expanded:
24
+
25
+ ```
26
+ bash -x scan.sh http://testphp.vulnweb.com:
27
+
28
+ + DOMAIN='$1' ← should be 'http://testphp.vulnweb.com'
29
+ + OUTPUT_DIR='/path/results/$DOMAIN' ← should be '/path/results/http://...'
30
+ + '$ZAP_CMD' -cmd ... ← tries to run a command literally named '$ZAP_CMD'
31
+ scan.sh: line 27: $ZAP_CMD: command not found
32
+ ```
33
+
34
+ Secondary confirmation: the project directory contained folders literally named `$OUTPUT_DIR` and `$RESULTS_DIR`, created by a prior run of the broken script.
35
+
36
+ ### Fix
37
+
38
+ Added `write_file` as a seed tool. It calls `fs.promises.writeFile` directly — content arrives as a JSON string and is written to disk verbatim. No shell is involved, so no escaping layer can corrupt dollar signs or backslashes.
39
+
40
+ Added an optional `mode` parameter (e.g. `"755"`) to make scripts executable in the same call.
41
+
42
+ Updated the system prompt with a dedicated "Writing Files" section (peer-level to "exec Safety") stating: use `write_file` for all file creation — never `exec` with `echo`, `printf`, or heredoc.
43
+
44
+ **Files changed:**
45
+ - `src/server/tools.js` — `write_file` added to `SEED_TOOLS`
46
+ - `docs/system-prompt.md` — new "Writing Files" section; removed the buried `echo -e` bullet from exec Safety
47
+
48
+ ---
49
+
50
+ ## Root Cause 2: Stderr Nudge Misdirected the Model
51
+
52
+ ### What happened
53
+
54
+ After every failed tool call with stderr output, the system injected:
55
+
56
+ > *"Examine the stderr field in the tool result carefully — it likely describes the root cause of the failure."*
57
+
58
+ The stderr was `$ZAP_CMD: command not found` — which looks like a PATH problem. The real diagnostic clue was in the **stdout** of `bash -x`: `DOMAIN='$1'` (variable not expanded). By directing the model to stderr, the nudge trained its attention away from the evidence.
59
+
60
+ The model then spent iterations on: explicit PATH overrides, `command -v` checks, `sed -n l`, `cat -A`, `which bash` — all stderr-adjacent investigation — and never examined what the `-x` trace was actually showing.
61
+
62
+ ### Fix
63
+
64
+ Changed the stderr nudge to cover both stdout and stderr, with an explicit callout to `bash -x` as a debug tool whose key output is in stdout:
65
+
66
+ ```
67
+ [System: A command failed. Examine both the stdout AND stderr fields in the tool result —
68
+ stderr names the error, but stdout (especially from debug commands like bash -x) often shows
69
+ the root cause. Do not retry without first understanding what the full output is telling you.]
70
+ ```
71
+
72
+ **File changed:** `src/server/agent.js` — `stderrErrorInIteration` nudge
73
+
74
+ ---
75
+
76
+ ## Root Cause 3: No Detection for the Same Error Repeating Across Different Commands
77
+
78
+ ### What happened
79
+
80
+ The existing loop detector tracks `tool + args + result` triples. Because the model varied its tool calls each iteration (different PATH strings, different diagnostic commands), this never triggered. Meanwhile `$ZAP_CMD: command not found` appeared in stderr across 5+ tool calls from entirely different commands — a strong signal that the diagnosis is wrong, not the approach.
81
+
82
+ Existing detectors that missed this:
83
+ - `loopTracker` — requires identical tool + args + result; missed because args varied
84
+ - `consecutiveFailures` — tracks back-to-back failures; partially reset when some calls succeeded
85
+ - `maxHandoffs` / zero-progress — apply only to checkpoint-reached runs
86
+
87
+ ### Fix
88
+
89
+ Added `stderrTracker = new Map()` in `runAgentLoop` (parallel to `loopTracker`). After each failed tool call, the first line of stderr is extracted as the key and its count incremented.
90
+
91
+ When any stderr string reaches `CONSECUTIVE_FAILURE_THRESHOLD` (3), a targeted "step back" nudge is injected, quoting the repeating error, instead of the basic stderr nudge:
92
+
93
+ ```
94
+ [System: The error "$ZAP_CMD: command not found" has now appeared 3 times across different
95
+ commands. You are repeatedly diagnosing the wrong thing. Stop, step back, and reconsider
96
+ from scratch — what is this error fundamentally telling you about the state of the system?]
97
+ ```
98
+
99
+ Using only the first line of stderr (not the full message) makes the tracker robust to verbose multi-line output where later lines may contain timestamps or variable content.
100
+
101
+ **File changed:** `src/server/agent.js` — `stderrTracker`, `firstStderrLine` extraction, `repeatedStderr` check replacing basic stderr nudge when threshold reached
102
+
103
+ ---
104
+
105
+ ## Why the Session Never Produced a Checkpoint
106
+
107
+ The model produced a final text response on iteration 10 (it didn't time out — it gave up). This means `!done` was never true after the while loop, so the wrap-up / checkpoint path never ran. The model exhausted its budget investigating PATH and on the last iteration concluded it couldn't solve the problem.
108
+
109
+ Fix 3 (repeated-error nudge) would have fired by iteration 5–6 with the specific message quoting `$ZAP_CMD: command not found`. At that point the model would have had a chance to reconsider what the error was telling it rather than continuing PATH investigation.
110
+
111
+ ---
112
+
113
+ ## Files Changed
114
+
115
+ | File | Change |
116
+ |------|--------|
117
+ | `src/server/tools.js` | Added `write_file` seed tool |
118
+ | `docs/system-prompt.md` | Added "Writing Files" section; removed `echo -e` bullet from exec Safety |
119
+ | `src/server/agent.js` | Added `stderrTracker`; first-line stderr key extraction; repeated-error nudge overriding basic stderr nudge; broadened stderr nudge wording to cover stdout |
@@ -54,7 +54,15 @@ The `exec` tool runs real shell commands on the server. Use it responsibly:
54
54
  - **Use known paths.** Prefer `process.cwd()`, `$HOME`, or paths you already know over broad searches. Use `which <binary>` to locate executables.
55
55
  - **Prefer targeted reads.** Use `grep`, `head`, or `tail` instead of `cat` on files you haven't seen before. Large file output is truncated anyway — a targeted command gives you better signal.
56
56
  - **Avoid commands with unbounded runtime.** If a command could run indefinitely or scan an unknown-size tree, scope it first.
57
- - **Writing multi-line files**: use `printf '...'` or a heredoc (`cat <<'EOF' > file`) instead of `echo -e`. The `-e` flag is not portable — on Ubuntu `/bin/sh` it is treated as literal text, corrupting the file.
57
+
58
+ ## Writing Files
59
+
60
+ Use the `write_file` tool to create or overwrite any file. Never use `exec` with `echo`, `printf`, or heredoc to write files.
61
+
62
+ Shell escaping through `exec` silently corrupts file content: dollar signs become `\$`, backslashes double up, and the resulting file looks correct when printed but is broken at runtime (variables never expand, scripts fail with "command not found"). `write_file` bypasses all shell interpretation — content arrives as a JSON string and lands in the file exactly as written.
63
+
64
+ - For shell scripts: pass `mode: "755"` to make the file executable in the same call.
65
+ - For any other file: omit `mode` or use `"644"`.
58
66
 
59
67
  ## Execution Timeouts
60
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducci/jarvis",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "description": "A fully automated agent system that lives on a server.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -69,6 +69,25 @@ async function callModelWithFallback(client, config, messages, tools) {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * Returns true if the last two assistant messages in the session are both
74
+ * synthetic model_error notes, indicating a confirmed failure loop that cannot
75
+ * self-resolve (e.g. persistent empty choices from context overflow).
76
+ */
77
+ function hasConsecutiveModelErrors(messages) {
78
+ const assistantTail = messages
79
+ .filter(m => m.role === 'assistant')
80
+ .slice(-2);
81
+ return (
82
+ assistantTail.length === 2 &&
83
+ assistantTail.every(
84
+ m =>
85
+ typeof m.content === 'string' &&
86
+ m.content.startsWith('[System: Previous run failed (model_error)')
87
+ )
88
+ );
89
+ }
90
+
72
91
  /**
73
92
  * Runs a single agent loop up to maxIterations.
74
93
  * Returns { iteration, response, logSummary, status, runToolCalls, checkpoint }.
@@ -84,6 +103,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
84
103
  let logSummary = '';
85
104
  let status = 'ok';
86
105
  let consecutiveFailures = 0;
106
+ const stderrTracker = new Map();
87
107
 
88
108
  while (iteration < config.maxIterations) {
89
109
  iteration++;
@@ -112,7 +132,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
112
132
  if (!modelResult.choices || modelResult.choices.length === 0) {
113
133
  return {
114
134
  iteration,
115
- response: 'Model returned an empty response.',
135
+ response: `Model returned an empty response (${preparedMessages.length} messages in context). This typically happens when the conversation is too long for the model. Try starting a new session or switching to a model with a larger context window.`,
116
136
  logSummary: `Model error on iteration ${iteration}: Empty choices array.`,
117
137
  status: 'model_error',
118
138
  runToolCalls,
@@ -180,6 +200,10 @@ async function runAgentLoop(client, config, session, prepareMessages) {
180
200
  consecutiveFailures++;
181
201
  if (resultObj && resultObj.stderr) {
182
202
  stderrErrorInIteration = true;
203
+ const firstStderrLine = resultObj.stderr.split('\n')[0].trim();
204
+ if (firstStderrLine) {
205
+ stderrTracker.set(firstStderrLine, (stderrTracker.get(firstStderrLine) || 0) + 1);
206
+ }
183
207
  }
184
208
  } else {
185
209
  consecutiveFailures = 0;
@@ -217,10 +241,16 @@ async function runAgentLoop(client, config, session, prepareMessages) {
217
241
  });
218
242
  }
219
243
 
220
- if (stderrErrorInIteration && !loopDetected) {
244
+ const repeatedStderr = [...stderrTracker.entries()].find(([, count]) => count >= CONSECUTIVE_FAILURE_THRESHOLD);
245
+ if (repeatedStderr && !loopDetected) {
246
+ session.messages.push({
247
+ role: 'user',
248
+ content: `[System: The error "${repeatedStderr[0].slice(0, 200)}" has now appeared ${repeatedStderr[1]} times across different commands. You are repeatedly diagnosing the wrong thing. Stop, step back, and reconsider from scratch — what is this error fundamentally telling you about the state of the system?]`,
249
+ });
250
+ } else if (stderrErrorInIteration && !loopDetected) {
221
251
  session.messages.push({
222
252
  role: 'user',
223
- content: '[System: A command failed and produced stderr output. Examine the stderr field in the tool result carefully it likely describes the root cause of the failure. Do not retry the same command without first addressing what stderr reports.]',
253
+ content: '[System: A command failed. Examine both the stdout AND stderr fields in the tool result — stderr names the error, but stdout (especially from debug commands like bash -x) often shows the root cause. Do not retry without first understanding what the full output is telling you.]',
224
254
  });
225
255
  }
226
256
 
@@ -482,6 +512,25 @@ async function _runHandleChat(config, sessionId, userMessage) {
482
512
  try {
483
513
  // Handoff loop
484
514
  while (true) {
515
+ // Safety check: if the last two assistant messages are both model_error
516
+ // synthetic notes, we are in a confirmed failure loop. Escalate immediately
517
+ // rather than burning more iterations on a stuck session.
518
+ if (hasConsecutiveModelErrors(session.messages)) {
519
+ finalResponse = 'The model has failed twice in a row. This is likely due to the conversation being too long for the model to process. Please start a new session or switch to a model with a larger context window.';
520
+ finalLogSummary = 'Consecutive model_error detected: session escalated to intervention_required without running another agent loop.';
521
+ finalStatus = 'intervention_required';
522
+ await appendLog(sessionId, {
523
+ iteration: 0,
524
+ model: config.selectedModel,
525
+ userInput: userMessage,
526
+ toolCalls: [],
527
+ response: finalResponse,
528
+ logSummary: finalLogSummary,
529
+ status: 'intervention_required',
530
+ });
531
+ break;
532
+ }
533
+
485
534
  const runStartIndex = session.messages.length;
486
535
  const run = await runAgentLoop(client, config, session, prepareMessages);
487
536
  allToolCalls.push(...run.runToolCalls);
@@ -505,8 +554,14 @@ async function _runHandleChat(config, sessionId, userMessage) {
505
554
  if (run.rawResponse) logEntry.rawResponse = run.rawResponse;
506
555
  await appendLog(sessionId, logEntry);
507
556
 
508
- // Inject synthetic error note so the model has context on the next user turn
557
+ // Inject synthetic error note so the model has context on the next user turn.
558
+ // For failed runs, also strip the tool call history — keeping it would bloat
559
+ // the context and create a positive-feedback death spiral where each failure
560
+ // makes the next one more likely (especially on free models with small context
561
+ // windows). The synthetic note is sufficient context; tool results are preserved
562
+ // in the JSONL log and accessible via read_session_log.
509
563
  if (finalStatus === 'model_error' || finalStatus === 'format_error') {
564
+ session.messages.splice(runStartIndex, session.messages.length - runStartIndex);
510
565
  const errorDetail = run.errorDetail ? ` Error detail: ${JSON.stringify(run.errorDetail)}` : '';
511
566
  session.messages.push({
512
567
  role: 'assistant',
@@ -347,6 +347,43 @@ const SEED_TOOLS = {
347
347
  }
348
348
  `,
349
349
  },
350
+ write_file: {
351
+ definition: {
352
+ type: 'function',
353
+ function: {
354
+ name: 'write_file',
355
+ description: 'Write content directly to a file on the filesystem, bypassing all shell escaping. Use this to create or overwrite any file — shell scripts, config files, code, etc. Content is written exactly as provided: dollar signs, backslashes, and special characters are preserved without modification. Always prefer this over exec+echo, exec+printf, or exec+heredoc for writing files. For shell scripts, pass mode: "755" to make the file executable. Example: write_file({ path: "/path/to/scan.sh", content: "#!/bin/bash\\nDOMAIN=$1\\n...", mode: "755" })',
356
+ parameters: {
357
+ type: 'object',
358
+ properties: {
359
+ path: {
360
+ type: 'string',
361
+ description: 'Absolute or relative path to the file to write. Parent directories are created automatically.',
362
+ },
363
+ content: {
364
+ type: 'string',
365
+ description: 'The content to write to the file. Written as-is — no shell interpretation occurs.',
366
+ },
367
+ mode: {
368
+ type: 'string',
369
+ description: 'Optional Unix file mode in octal string form, e.g. "755" for executable scripts, "644" for regular files. Defaults to "644".',
370
+ },
371
+ },
372
+ required: ['path', 'content'],
373
+ },
374
+ },
375
+ },
376
+ code: `
377
+ const targetPath = path.resolve(args.path);
378
+ await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
379
+ await fs.promises.writeFile(targetPath, args.content, 'utf8');
380
+ if (args.mode) {
381
+ await fs.promises.chmod(targetPath, parseInt(args.mode, 8));
382
+ }
383
+ const bytes = Buffer.byteLength(args.content, 'utf8');
384
+ return { status: 'ok', path: targetPath, bytes, mode: args.mode || '644' };
385
+ `,
386
+ },
350
387
  get_recent_sessions: {
351
388
  definition: {
352
389
  type: 'function',