@ducci/jarvis 1.0.28 → 1.0.29

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,121 @@
1
+ # Finding 012: Empty-Content Nudge Includes Tools and Loses Recovery Text
2
+
3
+ **Date:** 2026-03-02
4
+ **Severity:** Medium — user sees generic error when model produces a partial recovery response
5
+ **Status:** Fixed
6
+
7
+ ---
8
+
9
+ ## Observed Session
10
+
11
+ Session `21fb43a7-2b11-4208-99fb-e6b54fddc07b`, entry 9 in session.jsonl:
12
+
13
+ ```
14
+ status=format_error
15
+ model=nvidia/nemotron-3-nano-30b-a3b:free
16
+ iteration=3
17
+ userInput='Ok. Read the results folder. Is there anything?'
18
+ logSummary='Model returned non-JSON final response after recovery attempts.'
19
+ response='The model did not produce a response. Please try again.'
20
+ ```
21
+
22
+ The user received: **"The model did not produce a response. Please try again."**
23
+
24
+ ---
25
+
26
+ ## What Happened
27
+
28
+ 1. The agent executed two tool calls:
29
+ - `list_dir /root/.jarvis/projects/cybersecurity/results` → success
30
+ - `exec "list_dir /root/.jarvis/projects/cybersecurity/results/dviet.de"` → exit 127 (`list_dir: not found`)
31
+ - The model confused the `list_dir` jarvis tool with a shell command
32
+
33
+ 2. After the failed exec, the model returned `assistantMessage.content = null` with no `tool_calls` — it "went silent"
34
+
35
+ 3. Finding 011's empty-content nudge was triggered
36
+
37
+ 4. The nudge **also failed** — no valid JSON response was produced
38
+
39
+ 5. The agent fell through to `format_error` with the fallback message
40
+
41
+ ---
42
+
43
+ ## Bug Chain
44
+
45
+ ### Bug 1 — toolDefs included in empty nudge
46
+
47
+ ```js
48
+ const nudgeResult = await callModelWithFallback(client, config, emptyNudge, toolDefs);
49
+ ```
50
+
51
+ When the model is confused after a tool failure, it may respond to the nudge with **another tool call** instead of text. If it does:
52
+
53
+ ```
54
+ nudgeResult.choices[0].message.content = null
55
+ nudgeContent = ''
56
+ JSON.parse('') → throws
57
+ catch: // Give up — content stays ''
58
+ ```
59
+
60
+ The model had an opportunity to call more tools instead of producing a text response — the wrong behavior for a recovery nudge.
61
+
62
+ ### Bug 2 — content assigned after parse
63
+
64
+ ```js
65
+ const nudgeContent = nudgeResult.choices[0]?.message?.content || '';
66
+ parsed = JSON.parse(nudgeContent); // ← throws on non-JSON or empty
67
+ content = nudgeContent; // ← only reached if parse succeeded
68
+ ```
69
+
70
+ If the model responds to the nudge with non-empty but non-JSON text (e.g. a plain English answer), `JSON.parse` throws and `content` is **never updated**. The non-JSON text is discarded. The `!parsed` handler then shows the fallback message instead of the model's actual text.
71
+
72
+ ---
73
+
74
+ ## Difference from Finding 011
75
+
76
+ | Finding | Problem | Trigger |
77
+ |---------|---------|---------|
78
+ | 011 | Empty model response propagates to Telegram | Initial empty content, no recovery chain |
79
+ | 012 | Recovery nudge discards best-effort text; model can respond with tool call | Recovery nudge called with toolDefs + content assigned after parse |
80
+
81
+ Finding 012 is a refinement of the recovery path introduced in Finding 011.
82
+
83
+ ---
84
+
85
+ ## Fix
86
+
87
+ ### `src/server/agent.js` — empty-content nudge block
88
+
89
+ **Before:**
90
+ ```js
91
+ const nudgeResult = await callModelWithFallback(client, config, emptyNudge, toolDefs);
92
+ const nudgeContent = nudgeResult.choices[0]?.message?.content || '';
93
+ parsed = JSON.parse(nudgeContent);
94
+ content = nudgeContent;
95
+ ```
96
+
97
+ **After:**
98
+ ```js
99
+ // No tools: force text response, prevent model from calling another tool
100
+ const nudgeResult = await callModelWithFallback(client, config, emptyNudge, []);
101
+ const nudgeContent = nudgeResult.choices[0]?.message?.content || '';
102
+ // Persist before parsing — if JSON parse throws, content still carries the
103
+ // model's best-effort text so the !parsed handler can show it to the user
104
+ if (nudgeContent.trim()) {
105
+ content = nudgeContent;
106
+ }
107
+ parsed = JSON.parse(nudgeContent);
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Outcome
113
+
114
+ | Nudge response | Before | After |
115
+ |---|---|---|
116
+ | Valid JSON | Clean recovery | Clean recovery (no change) |
117
+ | Non-JSON text | Text discarded, fallback shown | Text shown to user |
118
+ | Tool call (no content) | content='', fallback shown | Less likely; content='', fallback shown |
119
+ | Empty again | content='', fallback shown | content='', fallback shown (no change) |
120
+
121
+ The user in the observed session would have received the model's best-effort text about the results folder contents, rather than "The model did not produce a response. Please try again."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducci/jarvis",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "A fully automated agent system that lives on a server.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -218,17 +218,24 @@ async function runAgentLoop(client, config, session, prepareMessages) {
218
218
  if (!content.trim()) {
219
219
  // Model returned no content at all — use a targeted nudge instead of the
220
220
  // standard JSON recovery chain (designed for non-empty non-JSON responses).
221
+ // Send with no tools so the model cannot respond with another tool call,
222
+ // which would leave content empty and discard any recovery text.
221
223
  try {
222
224
  const emptyNudge = [
223
225
  ...preparedMessages,
224
226
  { role: 'user', content: 'You returned an empty response. ' + FORMAT_NUDGE },
225
227
  ];
226
- const nudgeResult = await callModelWithFallback(client, config, emptyNudge, toolDefs);
228
+ const nudgeResult = await callModelWithFallback(client, config, emptyNudge, []);
227
229
  const nudgeContent = nudgeResult.choices[0]?.message?.content || '';
230
+ // Persist nudge text before parsing — if JSON parse throws, content still
231
+ // carries the model's best-effort text so the !parsed handler can show it
232
+ // rather than falling back to "The model did not produce a response."
233
+ if (nudgeContent.trim()) {
234
+ content = nudgeContent;
235
+ }
228
236
  parsed = JSON.parse(nudgeContent);
229
- content = nudgeContent;
230
237
  } catch {
231
- // Give up — fall through to !parsed handler below
238
+ // Fall through to !parsed handler; content may now carry the nudge text
232
239
  }
233
240
  } else {
234
241
  try {