@ducci/jarvis 1.0.24 → 1.0.25

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.
package/docs/agent.md CHANGED
@@ -503,6 +503,23 @@ Schema:
503
503
  - No additional per-run tool-call cap beyond iterations.
504
504
  - No token limit enforcement in v1.
505
505
 
506
+ ### Two-Level Tool Timeout Architecture
507
+
508
+ Every tool execution is governed by two independent timeout layers:
509
+
510
+ **Layer 1 — Outer wrapper** (`executeTool` in `src/server/tools.js`):
511
+ ```js
512
+ const timeoutMs = tool.timeout || TOOL_TIMEOUT_MS; // TOOL_TIMEOUT_MS = 60_000
513
+ return await Promise.race([fn(toolArgs, ...), timeout]);
514
+ ```
515
+ This is the hard cap. `tool.timeout` is a top-level property on the tool registry entry (not inside `definition` or `code`). `exec` has `timeout: 300_000` (5 minutes); `system_install` also has 5 minutes; all other tools default to 60s.
516
+
517
+ **Layer 2 — Inner timeout** (inside the tool's `code`): e.g. `execAsync(cmd, { timeout: 270000 })`. Should be slightly shorter than Layer 1 to ensure a clean error from the inner call rather than a hard kill from the outer wrapper.
518
+
519
+ **Declaring a custom timeout on a tool** (via `save_tool`): pass the optional `timeout` parameter (in ms, capped at 600,000). This writes the top-level `timeout` property to the tool entry in `tools.json`.
520
+
521
+ **Important**: Modifying a seed tool's code via `save_tool` does NOT change its outer timeout — seed tools are restored to their original definition on server restart via `seedTools()`.
522
+
506
523
  ## User Info
507
524
 
508
525
  User info is stored as a small JSON file in the Jarvis data directory: `~/.jarvis/data/user-info.json`. `save_user_info` appends to the collection, and `read_user_info` returns the full set.
@@ -0,0 +1,118 @@
1
+ # Finding 008: exec Timeout Architecture — Agent Cannot Increase Its Own Timeout
2
+
3
+ **Date:** 2026-02-28
4
+ **Severity:** Medium — caused 5 wasted user interactions and agent confusion; no crashes or data loss
5
+ **Status:** Fixed
6
+
7
+ ---
8
+
9
+ ## What Happened
10
+
11
+ A user asked Jarvis to run a cybersecurity scan script (`nuclei` + `nmap`) against `https://dviet.de`. The script ran via the `exec` tool and timed out after 60 seconds. The user then asked the agent to "increase the timeout to 5 minutes."
12
+
13
+ The agent attempted this in two ways:
14
+
15
+ 1. **Run 11**: Used `exec` to run `sed -i 's/"timeout": 60000/"timeout": 300000/g' tools.json` — changing the `timeout` value inside the exec tool's `code` string from 60s to 300s.
16
+ 2. **Run 13**: Called `save_tool` to recreate the exec tool with a "5-minute timeout" description and modified code.
17
+
18
+ Both attempts failed. The scan timed out at 60s in every subsequent run. The agent and user concluded "the platform enforces a 60-second cap" — true, but neither understood why the agent's changes had no effect.
19
+
20
+ ---
21
+
22
+ ## Root Cause
23
+
24
+ ### The Two-Level Timeout Architecture
25
+
26
+ Every tool execution is governed by two independent timeouts:
27
+
28
+ **Layer 1 — Outer wrapper** (`executeTool` in `src/server/tools.js`):
29
+
30
+ ```js
31
+ const timeoutMs = tool.timeout || TOOL_TIMEOUT_MS; // TOOL_TIMEOUT_MS = 60_000
32
+ const timeout = new Promise((_, reject) =>
33
+ setTimeout(() => reject(new Error(`Tool '${name}' timed out after ${timeoutMs / 1000}s`)), timeoutMs)
34
+ );
35
+ return await Promise.race([fn(toolArgs, ...), timeout]);
36
+ ```
37
+
38
+ `tool.timeout` is a **top-level property on the tool registry entry** — not inside `definition` or `code`. Only `system_install` had this property set (to 300,000ms). The exec tool had no top-level `timeout`, so it always used the 60s default.
39
+
40
+ **Layer 2 — Inner timeout** (inside the tool's `code`):
41
+
42
+ The exec seed tool's code contains `execAsync(args.cmd, { timeout: 60000 })`. This is just a string stored in tools.json. Changing this number (via sed or save_tool) only affects the inner `execAsync` behavior — the outer Promise.race at 60s fires first anyway.
43
+
44
+ ### Why the Agent's Fixes Had No Effect
45
+
46
+ 1. **sed on the code string**: Changed the inner `execAsync` timeout from 60s to 300s. But the outer wrapper uses `tool.timeout || 60_000`, and `exec` has no `tool.timeout` property. The outer race still fires at 60s and wins.
47
+
48
+ 2. **`save_tool` recreation**: `save_tool` writes `{ definition, code }` to tools.json — it had no `timeout` parameter. The exec entry still had no top-level `timeout` after `save_tool`. Same result.
49
+
50
+ ### Why seedTools() Makes This Permanent
51
+
52
+ Even if a manual edit to tools.json successfully added `exec.timeout = 300000`, the next server restart would run `seedTools()`, compare against `SEED_TOOLS.exec` (which has no timeout), and restore it — losing the change.
53
+
54
+ ---
55
+
56
+ ## What Changed
57
+
58
+ ### 1. `exec` seed tool now has a 5-minute timeout (`src/server/tools.js`)
59
+
60
+ Added `timeout: 300_000` as a top-level property on the exec seed tool — the simplest fix:
61
+
62
+ ```js
63
+ exec: {
64
+ timeout: 300_000, // 5 minutes — Layer 1 outer wrapper reads this
65
+ definition: { ... },
66
+ code: `... execAsync(args.cmd, { timeout: 270000 }) ...` // 4.5 min inner, leaves headroom
67
+ }
68
+ ```
69
+
70
+ The inner `execAsync` timeout was also updated from 60s to 270s (4.5 min) so it fires cleanly before the outer wrapper, giving a proper error message rather than a hard kill.
71
+
72
+ ### 2. `save_tool` now accepts a `timeout` parameter (`src/server/tools.js`)
73
+
74
+ The `save_tool` tool now accepts an optional `timeout` field (in milliseconds, max 600,000 = 10 minutes). When provided, it is written as a top-level property on the tool entry:
75
+
76
+ ```js
77
+ const entry = { definition: { ... }, code: args.code };
78
+ if (args.timeout !== undefined) {
79
+ const t = Number(args.timeout);
80
+ entry.timeout = Math.min(t, 600_000);
81
+ }
82
+ tools[args.name] = entry;
83
+ ```
84
+
85
+ This allows the agent to create custom tools with explicit timeout declarations when wrapping slow operations.
86
+
87
+ ### 3. System prompt updated (`docs/system-prompt.md`)
88
+
89
+ Added a `## Execution Timeouts` section documenting:
90
+ - `exec` = 5-minute cap (covers scans, builds, and most long-running commands)
91
+ - `system_install` = 5-minute cap, use for package installation
92
+ - Custom tools via `save_tool` = 60s default, pass `timeout` param to extend
93
+ - Background execution pattern for processes > 5 minutes
94
+
95
+ ### 4. `agent.md` updated (`docs/agent.md`)
96
+
97
+ Added a `### Two-Level Tool Timeout Architecture` subsection under `## Limits and Timeouts` explaining:
98
+ - The outer wrapper and how `tool.timeout` is read
99
+ - The inner timeout in tool code and its relationship to the outer
100
+ - How to declare a custom timeout via `save_tool`
101
+ - Why seed tool modifications via `save_tool` don't change the outer timeout (seedTools() restores on restart)
102
+
103
+ ---
104
+
105
+ ## What Was Not Changed
106
+
107
+ - `TOOL_TIMEOUT_MS` constant — remains at 60,000ms (the default for tools without an explicit timeout)
108
+ - `system_install` — unchanged
109
+ - The handoff system, checkpoint memory, loop detection — all unchanged
110
+ - `seedTools()` update detection logic — unchanged
111
+
112
+ ---
113
+
114
+ ## Outcome
115
+
116
+ - `exec` now has a 5-minute timeout — long-running scans, builds, and downloads work without any workaround
117
+ - The agent can set custom timeouts on tools it creates via `save_tool`
118
+ - The system prompt and docs explain the architecture so the agent doesn't waste iterations on an unsolvable problem
@@ -54,6 +54,21 @@ The `exec` tool runs real shell commands on the server. Use it responsibly:
54
54
  - **Avoid commands with unbounded runtime.** If a command could run indefinitely or scan an unknown-size tree, scope it first.
55
55
  - **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.
56
56
 
57
+ ## Execution Timeouts
58
+
59
+ Every tool call is wrapped in a server-side timeout that the tool's code cannot override:
60
+
61
+ - **`exec`** — 5-minute cap. Sufficient for scans, builds, and most long-running commands.
62
+ - **`system_install`** — 5-minute cap. Use for installing system binaries via a package manager.
63
+ - **Custom tools via `save_tool`** — default 60s unless you pass `timeout` (in ms, max 600000). If a custom tool wraps a slow operation, set `timeout` explicitly.
64
+
65
+ **For truly long-running processes (> 5 minutes)**: run in the background and poll for results:
66
+ ```sh
67
+ nohup long-running-command > /tmp/output.log 2>&1 & echo $!
68
+ # Check progress later
69
+ cat /tmp/output.log
70
+ ```
71
+
57
72
  ## Failure Recovery
58
73
 
59
74
  When a tool or command fails:
@@ -71,6 +86,7 @@ When building a custom tool with `save_tool`:
71
86
  - **Installing an npm package**: use the `npm_install` tool — it handles the correct install directory automatically. Then create the tool with `save_tool`. The tool code can `require('<package-name>')` directly.
72
87
  - **Installing a system binary** (e.g. nuclei, jq, ffmpeg, git): use the `system_install` tool — never use exec for this. It auto-detects the available package manager (brew/apt-get/snap) and has a 5-minute timeout sized for real downloads.
73
88
  - **Available bindings in tool code**: `args`, `fs`, `path`, `process`, `require`, `__jarvisDir` (absolute path to the jarvis server directory).
89
+ - **Long-running custom tools**: if your tool wraps an operation that takes more than 60 seconds (e.g. a network call, a slow computation), pass `timeout` in milliseconds to `save_tool` (max 600000 = 10 minutes). Example: `save_tool({ name: "run_scan", timeout: 300000, ... })`.
74
90
 
75
91
  ## logSummary Guidelines
76
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducci/jarvis",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "description": "A fully automated agent system that lives on a server.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -43,6 +43,7 @@ const SEED_TOOLS = {
43
43
  `,
44
44
  },
45
45
  exec: {
46
+ timeout: 300_000, // 5 minutes — scans, builds, and long commands need more than 60s
46
47
  definition: {
47
48
  type: 'function',
48
49
  function: {
@@ -67,7 +68,7 @@ const SEED_TOOLS = {
67
68
  try {
68
69
  const { stdout, stderr } = await execAsync(args.cmd, {
69
70
  encoding: "utf8",
70
- timeout: 60000,
71
+ timeout: 270000, // 4.5 min — leaves headroom before the outer 5-min tool timeout
71
72
  maxBuffer: 2 * 1024 * 1024,
72
73
  });
73
74
  return { status: "ok", exitCode: 0, stdout, stderr };
@@ -144,12 +145,16 @@ const SEED_TOOLS = {
144
145
  type: 'string',
145
146
  description: 'The body of an async function. Must end with a return statement — the returned value becomes the tool result. Available bindings: args (your tool parameters), fs (node:fs), path (node:path), process, require, __jarvisDir (absolute path to the jarvis server directory — use path.resolve(__jarvisDir, "../..") to get the project root for npm installs). Do NOT wrap in a function declaration. Example: const raw = await fs.promises.readFile(args.filePath, "utf8"); const data = JSON.parse(raw); return { count: data.length, first: data[0] };',
146
147
  },
148
+ timeout: {
149
+ type: 'number',
150
+ description: 'Optional execution timeout in milliseconds for this tool (max 600000 = 10 minutes). Use this when the tool wraps a slow operation (e.g. a network request or long computation) that exceeds the default 60-second limit. If omitted, the default 60-second timeout applies.',
151
+ },
147
152
  },
148
153
  required: ['name', 'description', 'parameters', 'code'],
149
154
  },
150
155
  },
151
156
  },
152
- code: `const toolsFile = path.join(process.env.HOME, '.jarvis/data/tools/tools.json'); const raw = await fs.promises.readFile(toolsFile, 'utf8').catch(() => '{}'); const tools = JSON.parse(raw); let parameters = args.parameters; if (typeof parameters === 'string') { try { parameters = JSON.parse(parameters); } catch { return { status: 'error', error: 'parameters must be a JSON Schema object, not a string. Pass the object directly, not as a JSON-serialized string.' }; } } if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) { return { status: 'error', error: 'parameters must be a JSON Schema object (e.g. { type: "object", properties: {...} }).' }; } tools[args.name] = { definition: { type: 'function', function: { name: args.name, description: args.description, parameters } }, code: args.code }; await fs.promises.writeFile(toolsFile, JSON.stringify(tools, null, 2), 'utf8'); return { status: 'ok', saved: args.name };`,
157
+ code: `const toolsFile = path.join(process.env.HOME, '.jarvis/data/tools/tools.json'); const raw = await fs.promises.readFile(toolsFile, 'utf8').catch(() => '{}'); const tools = JSON.parse(raw); let parameters = args.parameters; if (typeof parameters === 'string') { try { parameters = JSON.parse(parameters); } catch { return { status: 'error', error: 'parameters must be a JSON Schema object, not a string. Pass the object directly, not as a JSON-serialized string.' }; } } if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) { return { status: 'error', error: 'parameters must be a JSON Schema object (e.g. { type: "object", properties: {...} }).' }; } const entry = { definition: { type: 'function', function: { name: args.name, description: args.description, parameters } }, code: args.code }; if (args.timeout !== undefined) { const t = Number(args.timeout); if (!Number.isFinite(t) || t <= 0) return { status: 'error', error: 'timeout must be a positive number in milliseconds.' }; entry.timeout = Math.min(t, 600_000); } tools[args.name] = entry; await fs.promises.writeFile(toolsFile, JSON.stringify(tools, null, 2), 'utf8'); return { status: 'ok', saved: args.name, timeout: entry.timeout || 60000 };`,
153
158
  },
154
159
  get_tool: {
155
160
  definition: {