@ducci/jarvis 1.0.25 → 1.0.27
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,153 @@
|
|
|
1
|
+
# Finding 009: Non-String `response` Field Crashes Telegram Delivery
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-01
|
|
4
|
+
**Severity:** High — caused "Sorry, something went wrong sending the response" with no useful information for the user
|
|
5
|
+
**Status:** Fixed
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Observed Session
|
|
10
|
+
|
|
11
|
+
The session ran 19 agent runs, all completing successfully (`ok` or `checkpoint_reached`). The crash occurred on run 19. The user asked:
|
|
12
|
+
|
|
13
|
+
> "List me all tool calls you did In this session. Tool name and args are enough to display for each entry."
|
|
14
|
+
|
|
15
|
+
The model returned valid JSON but placed the list of tool calls as a JSON **array** (not a string) in the `response` field:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"response": [
|
|
20
|
+
{ "tool": "exec", "args": { "cmd": "find ..." } },
|
|
21
|
+
...16 entries...
|
|
22
|
+
],
|
|
23
|
+
"logSummary": "Enumerated every tool call made during the session..."
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The Telegram user received: **"Sorry, something went wrong sending the response. Please try again."**
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Bug Chain
|
|
32
|
+
|
|
33
|
+
### Step 1 — Agent parses valid JSON, stores non-string response
|
|
34
|
+
|
|
35
|
+
`runAgentLoop` in `src/server/agent.js` successfully parsed the model's response JSON. The extraction logic had no type check:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
response = parsed.response || content;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`parsed.response` was an array (truthy) → `response` was set to the array. No validation. The array propagated through `finalResponse` all the way to the return value of `handleChat`.
|
|
42
|
+
|
|
43
|
+
### Step 2 — Telegram handler crashes calling `.trim()` on an array
|
|
44
|
+
|
|
45
|
+
In `src/channels/telegram/index.js`:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
const text = result.response?.trim()
|
|
49
|
+
|| 'The agent encountered an error...';
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`?.` guards against `null` and `undefined` only — not against wrong types. Arrays do not have a `.trim()` method. This threw:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
TypeError: result.response.trim is not a function
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Step 3 — Delivery catch block sends the generic error
|
|
59
|
+
|
|
60
|
+
The TypeError was caught by the outer delivery try/catch, which replied:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Sorry, something went wrong sending the response. Please try again.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The user had no idea what failed. The agent had completed successfully — only the delivery step crashed.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Root Causes
|
|
71
|
+
|
|
72
|
+
**Primary**: `agent.js` never validates that `parsed.response` is a string after JSON parsing. The response contract ("Your message to the user, in plain text.") is documented but never enforced. Any JSON value — array, object, number, null — passes through silently.
|
|
73
|
+
|
|
74
|
+
**Secondary**: `telegram/index.js` assumed `result.response` would always be a string or null/undefined, and called `.trim()` without type-guarding.
|
|
75
|
+
|
|
76
|
+
The same primary bug exists in the wrap-up path (line ~315):
|
|
77
|
+
```js
|
|
78
|
+
response = parsedWrapUp.response || '';
|
|
79
|
+
```
|
|
80
|
+
This would fail identically if the wrap-up model returned a non-string `response`.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## What Was Not Caught Earlier
|
|
85
|
+
|
|
86
|
+
- The JSONL log stored `response: [array]` but the run status was `ok` — nothing flagged as an error on the agent side.
|
|
87
|
+
- The error only surfaces in the Telegram delivery layer, which has no visibility into the JSONL log.
|
|
88
|
+
- The model had valid intent (listing tool calls as a structured data type) — it just put the data in the wrong JSON field type.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Fix
|
|
93
|
+
|
|
94
|
+
### 1. `src/server/agent.js` — normalize response to string at both sites
|
|
95
|
+
|
|
96
|
+
**Main response path:**
|
|
97
|
+
```js
|
|
98
|
+
// Before:
|
|
99
|
+
response = parsed.response || content;
|
|
100
|
+
|
|
101
|
+
// After:
|
|
102
|
+
response = typeof parsed.response === 'string'
|
|
103
|
+
? parsed.response
|
|
104
|
+
: JSON.stringify(parsed.response, null, 2);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Wrap-up path:**
|
|
108
|
+
```js
|
|
109
|
+
// Before:
|
|
110
|
+
response = parsedWrapUp.response || '';
|
|
111
|
+
|
|
112
|
+
// After:
|
|
113
|
+
response = typeof parsedWrapUp.response === 'string'
|
|
114
|
+
? parsedWrapUp.response
|
|
115
|
+
: parsedWrapUp.response != null ? JSON.stringify(parsedWrapUp.response, null, 2) : '';
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
When the model returns a non-string (array, object), it is JSON-stringified with 2-space indentation. The user gets a readable representation of the intended content rather than a crash. This preserves the model's intent while enforcing the string contract.
|
|
119
|
+
|
|
120
|
+
### 2. `src/channels/telegram/index.js` — defense-in-depth type guard
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
// Before:
|
|
124
|
+
const text = result.response?.trim()
|
|
125
|
+
|| 'The agent encountered an error and could not produce a response. Please try again.';
|
|
126
|
+
|
|
127
|
+
// After:
|
|
128
|
+
const rawResponse = typeof result.response === 'string'
|
|
129
|
+
? result.response
|
|
130
|
+
: result.response != null ? JSON.stringify(result.response, null, 2) : '';
|
|
131
|
+
const text = rawResponse.trim()
|
|
132
|
+
|| 'The agent encountered an error and could not produce a response. Please try again.';
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 3. `docs/system-prompt.md` — explicit type constraint on `response`
|
|
136
|
+
|
|
137
|
+
Added one sentence to the `## Response Format` section:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
The `response` value must be a plain text string — never an array or object. If you need to present structured data (e.g. a list of items), format it as text within the string value.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Outcome
|
|
146
|
+
|
|
147
|
+
| Fix | Files changed |
|
|
148
|
+
|-----|--------------|
|
|
149
|
+
| Coerce `parsed.response` to string in main and wrap-up paths | `src/server/agent.js` |
|
|
150
|
+
| Type guard before `.trim()` call | `src/channels/telegram/index.js` |
|
|
151
|
+
| Explicit type constraint on `response` field | `docs/system-prompt.md` |
|
|
152
|
+
|
|
153
|
+
**Effect on the debugging session**: instead of "Sorry, something went wrong sending the response", the user would have received the tool call list formatted as a readable JSON string.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Finding 010: Non-String `checkpoint.remaining` Crashes Zero-Progress Detection
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-01
|
|
4
|
+
**Severity:** High — caused "Sorry, something went wrong" in Telegram with no useful context; crashed the handoff loop mid-run
|
|
5
|
+
**Status:** Fixed
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Observed Session
|
|
10
|
+
|
|
11
|
+
The session ran 13+ agent runs working on OWASP ZAP installation. Runs 8–13 were consecutive `checkpoint_reached` handoffs. On entry 14 (immediately after entry 13), the server logged:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
status: error
|
|
15
|
+
response: "An unexpected server error occurred: (run.checkpoint.remaining || "").trim is not a function"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The Telegram user received:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Sorry, something went wrong: (run.checkpoint.remaining || "").trim is not a function
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Bug Chain
|
|
27
|
+
|
|
28
|
+
### Step 1 — Wrap-up call returns non-string `remaining`
|
|
29
|
+
|
|
30
|
+
At iteration limit, `runAgentLoop` sends the `WRAP_UP_NOTE` and parses the model's JSON response. The model returned `checkpoint.remaining` as a non-string value (array or object) instead of a plain text string. `parsedWrapUp.checkpoint` was stored and returned with no type validation.
|
|
31
|
+
|
|
32
|
+
### Step 2 — Zero-progress detection crashes on `.trim()`
|
|
33
|
+
|
|
34
|
+
In `_runHandleChat`, finding 007 introduced zero-progress detection:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
const currentRemaining = (run.checkpoint.remaining || '').trim();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The `|| ''` guard only catches falsy values (null, undefined). A truthy non-string (array, object) passes through the `||` and `.trim()` is called on a non-string:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
TypeError: (run.checkpoint.remaining || "").trim is not a function
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Step 3 — Outer catch logs the error and re-throws
|
|
47
|
+
|
|
48
|
+
The `try/catch` at the top of the handoff loop caught the TypeError, wrote an `error` status log entry, and re-threw. The Telegram handler surfaced the raw error message.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Secondary Issues
|
|
53
|
+
|
|
54
|
+
**`resumeContent` (line 520)**: `run.checkpoint.remaining || 'Continue with the task.'` — if `remaining` is a truthy non-string, it would be pushed directly into `session.messages` as the next user message content. The message API expects a string, so this would produce a malformed conversation message.
|
|
55
|
+
|
|
56
|
+
**`failedApproaches` spread (lines 461–463)**: If the model returns `failedApproaches` as a non-array (string, object), `push(...value)` would spread wrong data. A string spreads individual characters; an object spreads its enumerable values.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Root Cause
|
|
61
|
+
|
|
62
|
+
Same class of bug as finding 009 (non-string `response` field). Finding 009 hardened `response` and `logSummary` extraction, but the `checkpoint` sub-object fields were not included in that hardening pass. Models — especially smaller/free models under iteration-limit pressure — sometimes return structured data (arrays, objects) in fields the system prompt specifies as plain text strings.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Fix
|
|
67
|
+
|
|
68
|
+
### `src/server/agent.js` — normalize checkpoint fields at source
|
|
69
|
+
|
|
70
|
+
Added a normalization block immediately inside the `if (parsedWrapUp.checkpoint)` branch, before any checkpoint field is accessed downstream:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
const cp = parsedWrapUp.checkpoint;
|
|
74
|
+
// remaining must be a string — used as the next run's resume prompt
|
|
75
|
+
if (typeof cp.remaining !== 'string') {
|
|
76
|
+
cp.remaining = Array.isArray(cp.remaining)
|
|
77
|
+
? cp.remaining.map(String).join('\n')
|
|
78
|
+
: cp.remaining != null ? JSON.stringify(cp.remaining) : '';
|
|
79
|
+
}
|
|
80
|
+
// failedApproaches must be an array of strings — spread into session metadata
|
|
81
|
+
if (!Array.isArray(cp.failedApproaches)) {
|
|
82
|
+
cp.failedApproaches = [];
|
|
83
|
+
} else {
|
|
84
|
+
cp.failedApproaches = cp.failedApproaches.map(item =>
|
|
85
|
+
typeof item === 'string' ? item : JSON.stringify(item)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Array coercion for `remaining`**: when the model returns an array (e.g., `["install Java", "create symlink"]`), elements are joined with newlines rather than JSON-stringified — producing a natural readable resume prompt rather than raw JSON syntax.
|
|
91
|
+
|
|
92
|
+
**Centralized normalization**: fixing at source (right after parse) rather than at each use site means lines 469 and 520 need no change. Any future use of `checkpoint.remaining` or `checkpoint.failedApproaches` is automatically safe.
|
|
93
|
+
|
|
94
|
+
### `src/server/agent.js` — update `WRAP_UP_NOTE`
|
|
95
|
+
|
|
96
|
+
Added explicit type constraints to the `remaining` field description and a trailing instruction:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
"remaining": "What still needs to be done — as a plain text string, never an array or object."
|
|
100
|
+
...
|
|
101
|
+
remaining must be a plain text string. failedApproaches must be a JSON array of strings.
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## What Was Not Changed
|
|
107
|
+
|
|
108
|
+
- `agent.js` lines 469 and 520 — no changes needed; normalization at source makes them safe
|
|
109
|
+
- `src/channels/telegram/index.js` — finding 007 and 009 already added `.catch(() => {})` and type guards on delivery
|
|
110
|
+
- `sessions.js`, `tools.js` — no changes needed
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Outcome
|
|
115
|
+
|
|
116
|
+
| Fix | Files changed |
|
|
117
|
+
|-----|--------------|
|
|
118
|
+
| Normalize `checkpoint.remaining` to string and `checkpoint.failedApproaches` to string array at source | `src/server/agent.js` |
|
|
119
|
+
| Add explicit type constraints to WRAP_UP_NOTE | `src/server/agent.js` |
|
|
120
|
+
|
|
121
|
+
**Effect**: instead of a `TypeError` crash mid-handoff-loop, the model's non-string `remaining` value is coerced to a readable string and used as the resume prompt. The session continues normally.
|
package/docs/system-prompt.md
CHANGED
|
@@ -30,6 +30,8 @@ There are two types of responses depending on whether you need to use tools:
|
|
|
30
30
|
"logSummary": "A concise explanation of what you did and why, written for a human reading the logs."
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
The `response` value must be a plain text string — never an array or object. If you need to present structured data (e.g. a list of items), format it as text within the string value.
|
|
34
|
+
|
|
33
35
|
Never include markdown code fences, preamble, or any text outside this JSON object. If you cannot complete a task, explain why in the `response` field — still as valid JSON.
|
|
34
36
|
|
|
35
37
|
## Tool Use
|
package/package.json
CHANGED
|
@@ -67,8 +67,11 @@ export async function startTelegramChannel(config) {
|
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
69
|
const MAX_TG = 4096;
|
|
70
|
-
// Guard against empty response (e.g.
|
|
71
|
-
const
|
|
70
|
+
// Guard against empty or non-string response (e.g. model returns array instead of string)
|
|
71
|
+
const rawResponse = typeof result.response === 'string'
|
|
72
|
+
? result.response
|
|
73
|
+
: result.response != null ? JSON.stringify(result.response, null, 2) : '';
|
|
74
|
+
const text = rawResponse.trim()
|
|
72
75
|
|| 'The agent encountered an error and could not produce a response. Please try again.';
|
|
73
76
|
if (text.length <= MAX_TG) {
|
|
74
77
|
await ctx.reply(text);
|
package/src/server/agent.js
CHANGED
|
@@ -19,12 +19,12 @@ Respond with your normal JSON, but add a checkpoint field:
|
|
|
19
19
|
"logSummary": "Human-readable summary of what happened in this run.",
|
|
20
20
|
"checkpoint": {
|
|
21
21
|
"progress": "What has been fully completed so far.",
|
|
22
|
-
"remaining": "What still needs to be done to finish the task.",
|
|
22
|
+
"remaining": "What still needs to be done to finish the task — as a plain text string, never an array or object.",
|
|
23
23
|
"failedApproaches": ["Concise description of each approach that was tried and failed, e.g. 'downloading subfinder via curl from GitHub releases — connection reset'. Omit array entries for things that succeeded. Leave as empty array if nothing failed."]
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
The checkpoint field will be used to automatically resume the task in the next run. failedApproaches is injected into the next run so the agent does not waste iterations repeating strategies that already failed.]`;
|
|
27
|
+
The checkpoint field will be used to automatically resume the task in the next run. failedApproaches is injected into the next run so the agent does not waste iterations repeating strategies that already failed. remaining must be a plain text string. failedApproaches must be a JSON array of strings.]`;
|
|
28
28
|
|
|
29
29
|
// Serializes concurrent requests for the same session. Maps sessionId to the
|
|
30
30
|
// tail of the current request chain (a Promise that resolves when the last
|
|
@@ -247,7 +247,9 @@ async function runAgentLoop(client, config, session, prepareMessages) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
session.messages.push({ role: 'assistant', content });
|
|
250
|
-
response = parsed.response
|
|
250
|
+
response = typeof parsed.response === 'string'
|
|
251
|
+
? parsed.response
|
|
252
|
+
: JSON.stringify(parsed.response, null, 2);
|
|
251
253
|
logSummary = parsed.logSummary || '';
|
|
252
254
|
|
|
253
255
|
done = true;
|
|
@@ -312,9 +314,27 @@ async function runAgentLoop(client, config, session, prepareMessages) {
|
|
|
312
314
|
session.messages.push({ role: 'assistant', content: wrapUpContent });
|
|
313
315
|
|
|
314
316
|
if (parsedWrapUp) {
|
|
315
|
-
response = parsedWrapUp.response
|
|
317
|
+
response = typeof parsedWrapUp.response === 'string'
|
|
318
|
+
? parsedWrapUp.response
|
|
319
|
+
: parsedWrapUp.response != null ? JSON.stringify(parsedWrapUp.response, null, 2) : '';
|
|
316
320
|
logSummary = parsedWrapUp.logSummary || '';
|
|
317
321
|
if (parsedWrapUp.checkpoint) {
|
|
322
|
+
// Normalize checkpoint fields to their expected types. Models sometimes
|
|
323
|
+
// return arrays or objects in fields that must be strings — the same class
|
|
324
|
+
// of bug fixed for `response` in finding 009.
|
|
325
|
+
const cp = parsedWrapUp.checkpoint;
|
|
326
|
+
if (typeof cp.remaining !== 'string') {
|
|
327
|
+
cp.remaining = Array.isArray(cp.remaining)
|
|
328
|
+
? cp.remaining.map(String).join('\n')
|
|
329
|
+
: cp.remaining != null ? JSON.stringify(cp.remaining) : '';
|
|
330
|
+
}
|
|
331
|
+
if (!Array.isArray(cp.failedApproaches)) {
|
|
332
|
+
cp.failedApproaches = [];
|
|
333
|
+
} else {
|
|
334
|
+
cp.failedApproaches = cp.failedApproaches.map(item =>
|
|
335
|
+
typeof item === 'string' ? item : JSON.stringify(item)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
318
338
|
return {
|
|
319
339
|
iteration,
|
|
320
340
|
response,
|