@kushalkhemka/opensec-pi-subagents 0.19.8 → 0.19.10
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/CHANGELOG.md +16 -0
- package/README.md +1 -0
- package/dist/agent-runner.d.ts +25 -0
- package/dist/agent-runner.js +115 -0
- package/dist/index.js +25 -4
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +8 -0
- package/package.json +18 -8
- package/src/agent-runner.ts +125 -0
- package/src/index.ts +24 -4
- package/src/settings.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.19.10] - 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A hung tool call held a subagent forever.** Turn limits only count finished turns, and a turn whose tool never returns (a `find /`, a server that never exits) never finishes, so `max_turns` and the grace turns could not stop it. Subagent `bash` calls now get a timeout (the new `toolTimeoutSeconds` setting, default 1200 s, `0` = off): Pi kills the command's whole process tree and the agent sees the timeout and carries on. Any other tool still running a minute past the limit is stopped by a watchdog and the agent is resumed with a note to split the work into bounded steps, up to 3 times per run (nested `Agent` / workflow calls are exempt). Resumed agents get the same protection.
|
|
15
|
+
|
|
16
|
+
## [0.19.9] - 2026-10-03
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **No load warning on Pi 1.0.** `@sinclair/typebox` and `typebox` were listed in `dependencies`, which Pi 1.0 reports as host-provided packages that can bypass its extension loader. They are now optional `"*"` peer dependencies (Pi supplies both on 0.87.1 and 1.0) and stay devDependencies for tests and typecheck.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- The development pins of `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` move from 0.84.2 to 1.0.0, so `npm test` and `prepublishOnly` run the e2e tests that need Pi 1.0 APIs.
|
|
25
|
+
|
|
10
26
|
## [0.19.8] - 2026-10-03
|
|
11
27
|
|
|
12
28
|
The Node build now carries the same fixes as the Bun build (0.19.5-bun.x).
|
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ model and thinking level unless their definition or call overrides them.
|
|
|
82
82
|
| Setting | Default | Meaning |
|
|
83
83
|
|---|---|---|
|
|
84
84
|
| Background concurrency (`/agents → Settings`) | `10` | How many background agents run at once. |
|
|
85
|
+
| Tool timeout (`/agents → Settings`, `toolTimeoutSeconds`) | `1200` | Seconds one subagent tool call may run (`0` = no limit). A `bash` call is killed at the limit and the agent sees the timeout; any other tool still running a minute later is stopped and the agent is told to continue another way (up to 3 times per run). |
|
|
85
86
|
|
|
86
87
|
## Notes
|
|
87
88
|
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -157,6 +157,31 @@ export declare function setRememberAgents(b: boolean): void;
|
|
|
157
157
|
export declare function getGraceTurns(): number;
|
|
158
158
|
/** Set the grace turns value (minimum 1). */
|
|
159
159
|
export declare function setGraceTurns(n: number): void;
|
|
160
|
+
export declare const TOOL_TIMEOUT_CEILING = 86400;
|
|
161
|
+
/** Get the per-tool-call time limit in seconds (0 = no limit). */
|
|
162
|
+
export declare function getToolTimeoutSeconds(): number;
|
|
163
|
+
/** Set the per-tool-call time limit in seconds (0 = no limit). */
|
|
164
|
+
export declare function setToolTimeoutSeconds(n: number): void;
|
|
165
|
+
/** Times one run is resumed after a stuck tool before it is left stopped. */
|
|
166
|
+
export declare const MAX_TOOL_RECOVERIES = 3;
|
|
167
|
+
export interface HungTool {
|
|
168
|
+
toolName: string;
|
|
169
|
+
seconds: number;
|
|
170
|
+
}
|
|
171
|
+
/** The prompt that resumes an agent whose tool call the watchdog stopped. */
|
|
172
|
+
export declare function hungToolPrompt(hung: HungTool): string;
|
|
173
|
+
/**
|
|
174
|
+
* Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
|
|
175
|
+
*
|
|
176
|
+
* `bash` calls without a timeout, or with a longer one, get the limit. The
|
|
177
|
+
* watchdog covers every tool: past the limit (plus slack) it aborts the run,
|
|
178
|
+
* which kills a running command's process tree, and `takeHung()` then reports
|
|
179
|
+
* the call so the caller can resume the agent with `hungToolPrompt()`.
|
|
180
|
+
*/
|
|
181
|
+
export declare function installToolTimeouts(session: AgentSession, limitSeconds?: number): {
|
|
182
|
+
takeHung(): HungTool | undefined;
|
|
183
|
+
dispose(): void;
|
|
184
|
+
};
|
|
160
185
|
/**
|
|
161
186
|
* Try to find the right model for an agent type.
|
|
162
187
|
* Priority: explicit option > config.model > parent model.
|
package/dist/agent-runner.js
CHANGED
|
@@ -348,6 +348,101 @@ let graceTurns = 5;
|
|
|
348
348
|
export function getGraceTurns() { return graceTurns; }
|
|
349
349
|
/** Set the grace turns value (minimum 1). */
|
|
350
350
|
export function setGraceTurns(n) { graceTurns = Math.max(1, n); }
|
|
351
|
+
/**
|
|
352
|
+
* Longest a single tool call may run, in seconds. 0 = no limit.
|
|
353
|
+
*
|
|
354
|
+
* Turn limits only count finished turns, and a turn whose tool never returns
|
|
355
|
+
* never finishes: a `find /` or a server that never closes would hold the agent
|
|
356
|
+
* forever. So a subagent's `bash` calls get this as their timeout (pi kills the
|
|
357
|
+
* whole process tree and reports the timeout to the model, which carries on),
|
|
358
|
+
* and a watchdog stops any other tool that overruns it and tells the agent to
|
|
359
|
+
* continue another way.
|
|
360
|
+
*/
|
|
361
|
+
let toolTimeoutSeconds = 1200;
|
|
362
|
+
export const TOOL_TIMEOUT_CEILING = 86_400;
|
|
363
|
+
/** Get the per-tool-call time limit in seconds (0 = no limit). */
|
|
364
|
+
export function getToolTimeoutSeconds() { return toolTimeoutSeconds; }
|
|
365
|
+
/** Set the per-tool-call time limit in seconds (0 = no limit). */
|
|
366
|
+
export function setToolTimeoutSeconds(n) {
|
|
367
|
+
toolTimeoutSeconds = Math.min(TOOL_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
|
|
368
|
+
}
|
|
369
|
+
/** How long past the limit the watchdog waits, so a timing-out `bash` reports its own timeout first. */
|
|
370
|
+
const WATCHDOG_SLACK_MS = 60_000;
|
|
371
|
+
const WATCHDOG_INTERVAL_MS = 5_000;
|
|
372
|
+
/** Times one run is resumed after a stuck tool before it is left stopped. */
|
|
373
|
+
export const MAX_TOOL_RECOVERIES = 3;
|
|
374
|
+
const WATCHDOG_EXEMPT_TOOLS = new Set(EXCLUDED_TOOL_NAMES);
|
|
375
|
+
function formatDuration(seconds) {
|
|
376
|
+
if (seconds < 120)
|
|
377
|
+
return `${seconds} seconds`;
|
|
378
|
+
return `${Math.round(seconds / 60)} minutes`;
|
|
379
|
+
}
|
|
380
|
+
/** The prompt that resumes an agent whose tool call the watchdog stopped. */
|
|
381
|
+
export function hungToolPrompt(hung) {
|
|
382
|
+
return `Your ${hung.toolName} call ran for ${formatDuration(hung.seconds)} without finishing, so it was stopped and its `
|
|
383
|
+
+ "output is lost. Continue the task without repeating that call: split the work into smaller, bounded steps "
|
|
384
|
+
+ "(search specific folders rather than the whole filesystem, limit depth and output, add timeouts).";
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
|
|
388
|
+
*
|
|
389
|
+
* `bash` calls without a timeout, or with a longer one, get the limit. The
|
|
390
|
+
* watchdog covers every tool: past the limit (plus slack) it aborts the run,
|
|
391
|
+
* which kills a running command's process tree, and `takeHung()` then reports
|
|
392
|
+
* the call so the caller can resume the agent with `hungToolPrompt()`.
|
|
393
|
+
*/
|
|
394
|
+
export function installToolTimeouts(session, limitSeconds = toolTimeoutSeconds) {
|
|
395
|
+
if (limitSeconds <= 0)
|
|
396
|
+
return { takeHung: () => undefined, dispose: () => { } };
|
|
397
|
+
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
398
|
+
session.agent.beforeToolCall = async (context, signal) => {
|
|
399
|
+
if (context.toolCall.name === "bash" && context.args && typeof context.args === "object") {
|
|
400
|
+
const args = context.args;
|
|
401
|
+
if (typeof args.timeout !== "number" || !(args.timeout > 0) || args.timeout > limitSeconds) {
|
|
402
|
+
args.timeout = limitSeconds;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return priorBeforeToolCall?.(context, signal);
|
|
406
|
+
};
|
|
407
|
+
const running = new Map();
|
|
408
|
+
let hung;
|
|
409
|
+
const unsubscribe = session.subscribe((event) => {
|
|
410
|
+
// Nested agents and workflows wait on child runs with their own limits.
|
|
411
|
+
if (event.type === "tool_execution_start" && !WATCHDOG_EXEMPT_TOOLS.has(event.toolName)) {
|
|
412
|
+
running.set(event.toolCallId, { toolName: event.toolName, started: Date.now() });
|
|
413
|
+
}
|
|
414
|
+
if (event.type === "tool_execution_end")
|
|
415
|
+
running.delete(event.toolCallId);
|
|
416
|
+
if (event.type === "agent_end")
|
|
417
|
+
running.clear();
|
|
418
|
+
});
|
|
419
|
+
const timer = setInterval(() => {
|
|
420
|
+
if (hung)
|
|
421
|
+
return;
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
for (const call of running.values()) {
|
|
424
|
+
if (now - call.started < limitSeconds * 1000 + WATCHDOG_SLACK_MS)
|
|
425
|
+
continue;
|
|
426
|
+
hung = { toolName: call.toolName, seconds: Math.round((now - call.started) / 1000) };
|
|
427
|
+
running.clear();
|
|
428
|
+
void session.abort();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
432
|
+
timer.unref?.();
|
|
433
|
+
return {
|
|
434
|
+
takeHung() {
|
|
435
|
+
const out = hung;
|
|
436
|
+
hung = undefined;
|
|
437
|
+
return out;
|
|
438
|
+
},
|
|
439
|
+
dispose() {
|
|
440
|
+
clearInterval(timer);
|
|
441
|
+
unsubscribe();
|
|
442
|
+
session.agent.beforeToolCall = priorBeforeToolCall;
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
351
446
|
/**
|
|
352
447
|
* Try to find the right model for an agent type.
|
|
353
448
|
* Priority: explicit option > config.model > parent model.
|
|
@@ -867,6 +962,7 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
867
962
|
});
|
|
868
963
|
}
|
|
869
964
|
options.onSessionCreated?.(session);
|
|
965
|
+
const toolTimeouts = installToolTimeouts(session);
|
|
870
966
|
// Track turns for graceful max_turns enforcement
|
|
871
967
|
let turnCount = 0;
|
|
872
968
|
const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns);
|
|
@@ -937,6 +1033,16 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
937
1033
|
return { responseText: "", session, aborted: true, steered: false };
|
|
938
1034
|
}
|
|
939
1035
|
await session.prompt(effectivePrompt);
|
|
1036
|
+
for (let recoveries = 0;; recoveries++) {
|
|
1037
|
+
const hung = toolTimeouts.takeHung();
|
|
1038
|
+
if (!hung)
|
|
1039
|
+
break;
|
|
1040
|
+
if (aborted || options.signal?.aborted || recoveries >= MAX_TOOL_RECOVERIES) {
|
|
1041
|
+
aborted = true;
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
await session.prompt(hungToolPrompt(hung));
|
|
1045
|
+
}
|
|
940
1046
|
// One more prompt when a schema was asked for and nothing usable came back
|
|
941
1047
|
// — the model answered in prose, or only ever called the tool invalidly.
|
|
942
1048
|
// Inside this `try`, so the turn tracking, the text collector and above all
|
|
@@ -949,6 +1055,7 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
949
1055
|
}
|
|
950
1056
|
}
|
|
951
1057
|
finally {
|
|
1058
|
+
toolTimeouts.dispose();
|
|
952
1059
|
unsubTurns();
|
|
953
1060
|
collector.unsubscribe();
|
|
954
1061
|
cleanupAbort();
|
|
@@ -1004,12 +1111,20 @@ export async function resumeAgent(session, prompt, options = {}) {
|
|
|
1004
1111
|
}
|
|
1005
1112
|
})
|
|
1006
1113
|
: () => { };
|
|
1114
|
+
const toolTimeouts = installToolTimeouts(session);
|
|
1007
1115
|
try {
|
|
1008
1116
|
if (options.signal?.aborted)
|
|
1009
1117
|
return { text: "" };
|
|
1010
1118
|
await session.prompt(prompt);
|
|
1119
|
+
for (let recoveries = 0; recoveries < MAX_TOOL_RECOVERIES && !options.signal?.aborted; recoveries++) {
|
|
1120
|
+
const hung = toolTimeouts.takeHung();
|
|
1121
|
+
if (!hung)
|
|
1122
|
+
break;
|
|
1123
|
+
await session.prompt(hungToolPrompt(hung));
|
|
1124
|
+
}
|
|
1011
1125
|
}
|
|
1012
1126
|
finally {
|
|
1127
|
+
toolTimeouts.dispose();
|
|
1013
1128
|
collector.unsubscribe();
|
|
1014
1129
|
unsubEvents();
|
|
1015
1130
|
cleanupAbort();
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { abortable } from "./abortable.js";
|
|
|
18
18
|
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
|
19
19
|
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
|
20
20
|
import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
|
|
21
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
|
|
21
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
|
|
22
22
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
23
23
|
import { inChildSessionContext } from "./child-context.js";
|
|
24
24
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
@@ -1351,6 +1351,7 @@ export default function (pi) {
|
|
|
1351
1351
|
setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
|
|
1352
1352
|
setDefaultMaxTurns,
|
|
1353
1353
|
setGraceTurns,
|
|
1354
|
+
setToolTimeoutSeconds,
|
|
1354
1355
|
setDefaultJoinMode,
|
|
1355
1356
|
setBackgroundByDefault,
|
|
1356
1357
|
setSchedulingEnabled,
|
|
@@ -3241,6 +3242,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3241
3242
|
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
3242
3243
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
3243
3244
|
graceTurns: getGraceTurns(),
|
|
3245
|
+
toolTimeoutSeconds: getToolTimeoutSeconds(),
|
|
3244
3246
|
defaultJoinMode: getDefaultJoinMode(),
|
|
3245
3247
|
backgroundByDefault: getBackgroundByDefault(),
|
|
3246
3248
|
schedulingEnabled: isSchedulingEnabled(),
|
|
@@ -3277,7 +3279,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3277
3279
|
const _settingsSnapshotIsComplete = true;
|
|
3278
3280
|
void _settingsSnapshotIsComplete;
|
|
3279
3281
|
const NUMERIC_IDS = new Set([
|
|
3280
|
-
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
|
|
3282
|
+
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "toolTimeoutSeconds", "maxSubagentDepth",
|
|
3281
3283
|
]);
|
|
3282
3284
|
async function showSettings(ctx) {
|
|
3283
3285
|
function buildItems() {
|
|
@@ -3285,6 +3287,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3285
3287
|
const mcf = manager.getMaxConcurrentForeground();
|
|
3286
3288
|
const dmt = getDefaultMaxTurns() ?? 0;
|
|
3287
3289
|
const gt = getGraceTurns();
|
|
3290
|
+
const tts = getToolTimeoutSeconds();
|
|
3288
3291
|
const msd = getMaxSubagentDepth();
|
|
3289
3292
|
// Label what unset actually does — it targets general-purpose even when
|
|
3290
3293
|
// that is unregistered (the permissive hardcoded tier), so showing "none"
|
|
@@ -3322,6 +3325,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3322
3325
|
currentValue: String(gt),
|
|
3323
3326
|
values: [String(gt)],
|
|
3324
3327
|
},
|
|
3328
|
+
{
|
|
3329
|
+
id: "toolTimeoutSeconds",
|
|
3330
|
+
label: "Tool timeout",
|
|
3331
|
+
description: "Seconds one subagent tool call may run before it is stopped and the agent continues (0 = no limit, Enter to type)",
|
|
3332
|
+
currentValue: String(tts),
|
|
3333
|
+
values: [String(tts)],
|
|
3334
|
+
},
|
|
3325
3335
|
{
|
|
3326
3336
|
id: "maxSubagentDepth",
|
|
3327
3337
|
label: "Nested depth",
|
|
@@ -3501,6 +3511,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3501
3511
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
3502
3512
|
}
|
|
3503
3513
|
}
|
|
3514
|
+
else if (id === "toolTimeoutSeconds") {
|
|
3515
|
+
const n = parseInt(value, 10);
|
|
3516
|
+
if (n >= 0) {
|
|
3517
|
+
setToolTimeoutSeconds(n);
|
|
3518
|
+
notifyApplied(ctx, n === 0 ? "Tool timeout off" : `Tool timeout set to ${getToolTimeoutSeconds()} s`);
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3504
3521
|
else if (id === "maxSubagentDepth") {
|
|
3505
3522
|
const n = parseInt(value, 10);
|
|
3506
3523
|
if (n >= 0) {
|
|
@@ -3671,7 +3688,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3671
3688
|
? String(getDefaultMaxTurns() ?? 0)
|
|
3672
3689
|
: result === "maxSubagentDepth"
|
|
3673
3690
|
? String(getMaxSubagentDepth())
|
|
3674
|
-
:
|
|
3691
|
+
: result === "toolTimeoutSeconds"
|
|
3692
|
+
? String(getToolTimeoutSeconds())
|
|
3693
|
+
: String(getGraceTurns());
|
|
3675
3694
|
const label = result === "maxConcurrent"
|
|
3676
3695
|
? "Max concurrency (1+)"
|
|
3677
3696
|
: result === "maxConcurrentForeground"
|
|
@@ -3680,7 +3699,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3680
3699
|
? "Default max turns (0 = unlimited)"
|
|
3681
3700
|
: result === "maxSubagentDepth"
|
|
3682
3701
|
? "Nested depth (0/1 = nesting off)"
|
|
3683
|
-
:
|
|
3702
|
+
: result === "toolTimeoutSeconds"
|
|
3703
|
+
? "Tool timeout in seconds (0 = no limit)"
|
|
3704
|
+
: "Grace turns (1+)";
|
|
3684
3705
|
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
3685
3706
|
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|
|
3686
3707
|
let input = await ctx.ui.input(label, current);
|
package/dist/settings.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface SubagentsSettings {
|
|
|
30
30
|
*/
|
|
31
31
|
defaultMaxTurns?: number;
|
|
32
32
|
graceTurns?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Longest one tool call of a subagent may run, in seconds (0 = no limit,
|
|
35
|
+
* default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
|
|
36
|
+
*/
|
|
37
|
+
toolTimeoutSeconds?: number;
|
|
33
38
|
defaultJoinMode?: JoinMode;
|
|
34
39
|
/**
|
|
35
40
|
* Whether a top-level `Agent` spawn that doesn't say runs detached.
|
|
@@ -300,6 +305,7 @@ export interface SettingsAppliers {
|
|
|
300
305
|
setMaxConcurrentForeground: (n: number) => void;
|
|
301
306
|
setDefaultMaxTurns: (n: number) => void;
|
|
302
307
|
setGraceTurns: (n: number) => void;
|
|
308
|
+
setToolTimeoutSeconds: (n: number) => void;
|
|
303
309
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
304
310
|
setBackgroundByDefault: (b: boolean) => void;
|
|
305
311
|
setSchedulingEnabled: (b: boolean) => void;
|
package/dist/settings.js
CHANGED
|
@@ -17,6 +17,7 @@ const VALID_AGENT_MENTION_MODES = new Set(["model", "direct", "off"]);
|
|
|
17
17
|
const MAX_CONCURRENT_CEILING = 1024;
|
|
18
18
|
const MAX_TURNS_CEILING = 10_000;
|
|
19
19
|
const GRACE_TURNS_CEILING = 1_000;
|
|
20
|
+
const TOOL_TIMEOUT_CEILING = 86_400;
|
|
20
21
|
const SUBAGENT_DEPTH_CEILING = 16;
|
|
21
22
|
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
22
23
|
function sanitize(raw) {
|
|
@@ -46,6 +47,11 @@ function sanitize(raw) {
|
|
|
46
47
|
r.graceTurns <= GRACE_TURNS_CEILING) {
|
|
47
48
|
out.graceTurns = r.graceTurns;
|
|
48
49
|
}
|
|
50
|
+
if (Number.isInteger(r.toolTimeoutSeconds) &&
|
|
51
|
+
r.toolTimeoutSeconds >= 0 &&
|
|
52
|
+
r.toolTimeoutSeconds <= TOOL_TIMEOUT_CEILING) {
|
|
53
|
+
out.toolTimeoutSeconds = r.toolTimeoutSeconds;
|
|
54
|
+
}
|
|
49
55
|
if (Number.isInteger(r.maxSubagentDepth) &&
|
|
50
56
|
r.maxSubagentDepth >= 0 &&
|
|
51
57
|
r.maxSubagentDepth <= SUBAGENT_DEPTH_CEILING) {
|
|
@@ -178,6 +184,8 @@ export function applySettings(s, appliers) {
|
|
|
178
184
|
appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
179
185
|
if (typeof s.graceTurns === "number")
|
|
180
186
|
appliers.setGraceTurns(s.graceTurns);
|
|
187
|
+
if (typeof s.toolTimeoutSeconds === "number")
|
|
188
|
+
appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
|
|
181
189
|
if (typeof s.maxSubagentDepth === "number")
|
|
182
190
|
appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
183
191
|
if (typeof s.fallbackSubagent === "string")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kushalkhemka/opensec-pi-subagents",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.10",
|
|
4
4
|
"description": "Fork of @tintinweb/pi-subagents with parent model/thinking inheritance and cached agent discovery.",
|
|
5
5
|
"author": "tintinweb",
|
|
6
6
|
"contributors": [
|
|
@@ -30,13 +30,21 @@
|
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@earendil-works/pi-ai": ">=0.84.0",
|
|
32
32
|
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
33
|
-
"@earendil-works/pi-tui": ">=0.84.0"
|
|
33
|
+
"@earendil-works/pi-tui": ">=0.84.0",
|
|
34
|
+
"@sinclair/typebox": "*",
|
|
35
|
+
"typebox": "*"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@sinclair/typebox": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"typebox": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
34
44
|
},
|
|
35
45
|
"dependencies": {
|
|
36
|
-
"@sinclair/typebox": "^0.34.49",
|
|
37
46
|
"croner": "^10.0.1",
|
|
38
|
-
"nanoid": "^5.1.16"
|
|
39
|
-
"typebox": "^1.3.7"
|
|
47
|
+
"nanoid": "^5.1.16"
|
|
40
48
|
},
|
|
41
49
|
"scripts": {
|
|
42
50
|
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
@@ -54,11 +62,13 @@
|
|
|
54
62
|
},
|
|
55
63
|
"devDependencies": {
|
|
56
64
|
"@biomejs/biome": "^2.4.14",
|
|
57
|
-
"@earendil-works/pi-ai": "0.
|
|
58
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
59
|
-
"@earendil-works/pi-tui": "0.
|
|
65
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
66
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
67
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
68
|
+
"@sinclair/typebox": "^0.34.49",
|
|
60
69
|
"@types/node": "^25.5.0",
|
|
61
70
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
71
|
+
"typebox": "^1.3.7",
|
|
62
72
|
"typescript": "^6.0.0",
|
|
63
73
|
"vitest": "^4.0.18"
|
|
64
74
|
},
|
package/src/agent-runner.ts
CHANGED
|
@@ -405,6 +405,112 @@ export function getGraceTurns(): number { return graceTurns; }
|
|
|
405
405
|
/** Set the grace turns value (minimum 1). */
|
|
406
406
|
export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Longest a single tool call may run, in seconds. 0 = no limit.
|
|
410
|
+
*
|
|
411
|
+
* Turn limits only count finished turns, and a turn whose tool never returns
|
|
412
|
+
* never finishes: a `find /` or a server that never closes would hold the agent
|
|
413
|
+
* forever. So a subagent's `bash` calls get this as their timeout (pi kills the
|
|
414
|
+
* whole process tree and reports the timeout to the model, which carries on),
|
|
415
|
+
* and a watchdog stops any other tool that overruns it and tells the agent to
|
|
416
|
+
* continue another way.
|
|
417
|
+
*/
|
|
418
|
+
let toolTimeoutSeconds = 1200;
|
|
419
|
+
export const TOOL_TIMEOUT_CEILING = 86_400;
|
|
420
|
+
|
|
421
|
+
/** Get the per-tool-call time limit in seconds (0 = no limit). */
|
|
422
|
+
export function getToolTimeoutSeconds(): number { return toolTimeoutSeconds; }
|
|
423
|
+
/** Set the per-tool-call time limit in seconds (0 = no limit). */
|
|
424
|
+
export function setToolTimeoutSeconds(n: number): void {
|
|
425
|
+
toolTimeoutSeconds = Math.min(TOOL_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** How long past the limit the watchdog waits, so a timing-out `bash` reports its own timeout first. */
|
|
429
|
+
const WATCHDOG_SLACK_MS = 60_000;
|
|
430
|
+
const WATCHDOG_INTERVAL_MS = 5_000;
|
|
431
|
+
/** Times one run is resumed after a stuck tool before it is left stopped. */
|
|
432
|
+
export const MAX_TOOL_RECOVERIES = 3;
|
|
433
|
+
const WATCHDOG_EXEMPT_TOOLS: ReadonlySet<string> = new Set(EXCLUDED_TOOL_NAMES);
|
|
434
|
+
|
|
435
|
+
export interface HungTool {
|
|
436
|
+
toolName: string;
|
|
437
|
+
seconds: number;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function formatDuration(seconds: number): string {
|
|
441
|
+
if (seconds < 120) return `${seconds} seconds`;
|
|
442
|
+
return `${Math.round(seconds / 60)} minutes`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The prompt that resumes an agent whose tool call the watchdog stopped. */
|
|
446
|
+
export function hungToolPrompt(hung: HungTool): string {
|
|
447
|
+
return `Your ${hung.toolName} call ran for ${formatDuration(hung.seconds)} without finishing, so it was stopped and its `
|
|
448
|
+
+ "output is lost. Continue the task without repeating that call: split the work into smaller, bounded steps "
|
|
449
|
+
+ "(search specific folders rather than the whole filesystem, limit depth and output, add timeouts).";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
|
|
454
|
+
*
|
|
455
|
+
* `bash` calls without a timeout, or with a longer one, get the limit. The
|
|
456
|
+
* watchdog covers every tool: past the limit (plus slack) it aborts the run,
|
|
457
|
+
* which kills a running command's process tree, and `takeHung()` then reports
|
|
458
|
+
* the call so the caller can resume the agent with `hungToolPrompt()`.
|
|
459
|
+
*/
|
|
460
|
+
export function installToolTimeouts(session: AgentSession, limitSeconds = toolTimeoutSeconds): {
|
|
461
|
+
takeHung(): HungTool | undefined;
|
|
462
|
+
dispose(): void;
|
|
463
|
+
} {
|
|
464
|
+
if (limitSeconds <= 0) return { takeHung: () => undefined, dispose: () => {} };
|
|
465
|
+
|
|
466
|
+
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
467
|
+
session.agent.beforeToolCall = async (context, signal) => {
|
|
468
|
+
if (context.toolCall.name === "bash" && context.args && typeof context.args === "object") {
|
|
469
|
+
const args = context.args as { timeout?: unknown };
|
|
470
|
+
if (typeof args.timeout !== "number" || !(args.timeout > 0) || args.timeout > limitSeconds) {
|
|
471
|
+
args.timeout = limitSeconds;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return priorBeforeToolCall?.(context, signal);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const running = new Map<string, { toolName: string; started: number }>();
|
|
478
|
+
let hung: HungTool | undefined;
|
|
479
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
480
|
+
// Nested agents and workflows wait on child runs with their own limits.
|
|
481
|
+
if (event.type === "tool_execution_start" && !WATCHDOG_EXEMPT_TOOLS.has(event.toolName)) {
|
|
482
|
+
running.set(event.toolCallId, { toolName: event.toolName, started: Date.now() });
|
|
483
|
+
}
|
|
484
|
+
if (event.type === "tool_execution_end") running.delete(event.toolCallId);
|
|
485
|
+
if (event.type === "agent_end") running.clear();
|
|
486
|
+
});
|
|
487
|
+
const timer = setInterval(() => {
|
|
488
|
+
if (hung) return;
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
for (const call of running.values()) {
|
|
491
|
+
if (now - call.started < limitSeconds * 1000 + WATCHDOG_SLACK_MS) continue;
|
|
492
|
+
hung = { toolName: call.toolName, seconds: Math.round((now - call.started) / 1000) };
|
|
493
|
+
running.clear();
|
|
494
|
+
void session.abort();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
498
|
+
(timer as { unref?: () => void }).unref?.();
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
takeHung() {
|
|
502
|
+
const out = hung;
|
|
503
|
+
hung = undefined;
|
|
504
|
+
return out;
|
|
505
|
+
},
|
|
506
|
+
dispose() {
|
|
507
|
+
clearInterval(timer);
|
|
508
|
+
unsubscribe();
|
|
509
|
+
session.agent.beforeToolCall = priorBeforeToolCall;
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
408
514
|
/**
|
|
409
515
|
* Try to find the right model for an agent type.
|
|
410
516
|
* Priority: explicit option > config.model > parent model.
|
|
@@ -1108,6 +1214,8 @@ export async function runAgent(
|
|
|
1108
1214
|
|
|
1109
1215
|
options.onSessionCreated?.(session);
|
|
1110
1216
|
|
|
1217
|
+
const toolTimeouts = installToolTimeouts(session);
|
|
1218
|
+
|
|
1111
1219
|
// Track turns for graceful max_turns enforcement
|
|
1112
1220
|
let turnCount = 0;
|
|
1113
1221
|
const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns);
|
|
@@ -1180,6 +1288,15 @@ export async function runAgent(
|
|
|
1180
1288
|
return { responseText: "", session, aborted: true, steered: false };
|
|
1181
1289
|
}
|
|
1182
1290
|
await session.prompt(effectivePrompt);
|
|
1291
|
+
for (let recoveries = 0; ; recoveries++) {
|
|
1292
|
+
const hung = toolTimeouts.takeHung();
|
|
1293
|
+
if (!hung) break;
|
|
1294
|
+
if (aborted || options.signal?.aborted || recoveries >= MAX_TOOL_RECOVERIES) {
|
|
1295
|
+
aborted = true;
|
|
1296
|
+
break;
|
|
1297
|
+
}
|
|
1298
|
+
await session.prompt(hungToolPrompt(hung));
|
|
1299
|
+
}
|
|
1183
1300
|
|
|
1184
1301
|
// One more prompt when a schema was asked for and nothing usable came back
|
|
1185
1302
|
// — the model answered in prose, or only ever called the tool invalidly.
|
|
@@ -1192,6 +1309,7 @@ export async function runAgent(
|
|
|
1192
1309
|
await session.prompt(structuredRetryPrompt(structuredCapture));
|
|
1193
1310
|
}
|
|
1194
1311
|
} finally {
|
|
1312
|
+
toolTimeouts.dispose();
|
|
1195
1313
|
unsubTurns();
|
|
1196
1314
|
collector.unsubscribe();
|
|
1197
1315
|
cleanupAbort();
|
|
@@ -1257,10 +1375,17 @@ export async function resumeAgent(
|
|
|
1257
1375
|
})
|
|
1258
1376
|
: () => {};
|
|
1259
1377
|
|
|
1378
|
+
const toolTimeouts = installToolTimeouts(session);
|
|
1260
1379
|
try {
|
|
1261
1380
|
if (options.signal?.aborted) return { text: "" };
|
|
1262
1381
|
await session.prompt(prompt);
|
|
1382
|
+
for (let recoveries = 0; recoveries < MAX_TOOL_RECOVERIES && !options.signal?.aborted; recoveries++) {
|
|
1383
|
+
const hung = toolTimeouts.takeHung();
|
|
1384
|
+
if (!hung) break;
|
|
1385
|
+
await session.prompt(hungToolPrompt(hung));
|
|
1386
|
+
}
|
|
1263
1387
|
} finally {
|
|
1388
|
+
toolTimeouts.dispose();
|
|
1264
1389
|
collector.unsubscribe();
|
|
1265
1390
|
unsubEvents();
|
|
1266
1391
|
cleanupAbort();
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { abortable } from "./abortable.js";
|
|
|
19
19
|
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
|
20
20
|
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
|
21
21
|
import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
|
|
22
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
|
|
22
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
|
|
23
23
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
24
24
|
import { inChildSessionContext } from "./child-context.js";
|
|
25
25
|
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
@@ -1462,6 +1462,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1462
1462
|
setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
|
|
1463
1463
|
setDefaultMaxTurns,
|
|
1464
1464
|
setGraceTurns,
|
|
1465
|
+
setToolTimeoutSeconds,
|
|
1465
1466
|
setDefaultJoinMode,
|
|
1466
1467
|
setBackgroundByDefault,
|
|
1467
1468
|
setSchedulingEnabled,
|
|
@@ -3506,6 +3507,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3506
3507
|
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
3507
3508
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
3508
3509
|
graceTurns: getGraceTurns(),
|
|
3510
|
+
toolTimeoutSeconds: getToolTimeoutSeconds(),
|
|
3509
3511
|
defaultJoinMode: getDefaultJoinMode(),
|
|
3510
3512
|
backgroundByDefault: getBackgroundByDefault(),
|
|
3511
3513
|
schedulingEnabled: isSchedulingEnabled(),
|
|
@@ -3552,7 +3554,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3552
3554
|
void _settingsSnapshotIsComplete;
|
|
3553
3555
|
|
|
3554
3556
|
const NUMERIC_IDS = new Set([
|
|
3555
|
-
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
|
|
3557
|
+
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "toolTimeoutSeconds", "maxSubagentDepth",
|
|
3556
3558
|
]);
|
|
3557
3559
|
|
|
3558
3560
|
async function showSettings(ctx: ExtensionCommandContext) {
|
|
@@ -3561,6 +3563,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3561
3563
|
const mcf = manager.getMaxConcurrentForeground();
|
|
3562
3564
|
const dmt = getDefaultMaxTurns() ?? 0;
|
|
3563
3565
|
const gt = getGraceTurns();
|
|
3566
|
+
const tts = getToolTimeoutSeconds();
|
|
3564
3567
|
const msd = getMaxSubagentDepth();
|
|
3565
3568
|
// Label what unset actually does — it targets general-purpose even when
|
|
3566
3569
|
// that is unregistered (the permissive hardcoded tier), so showing "none"
|
|
@@ -3599,6 +3602,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3599
3602
|
currentValue: String(gt),
|
|
3600
3603
|
values: [String(gt)],
|
|
3601
3604
|
},
|
|
3605
|
+
{
|
|
3606
|
+
id: "toolTimeoutSeconds",
|
|
3607
|
+
label: "Tool timeout",
|
|
3608
|
+
description: "Seconds one subagent tool call may run before it is stopped and the agent continues (0 = no limit, Enter to type)",
|
|
3609
|
+
currentValue: String(tts),
|
|
3610
|
+
values: [String(tts)],
|
|
3611
|
+
},
|
|
3602
3612
|
{
|
|
3603
3613
|
id: "maxSubagentDepth",
|
|
3604
3614
|
label: "Nested depth",
|
|
@@ -3780,6 +3790,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3780
3790
|
setGraceTurns(n);
|
|
3781
3791
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
3782
3792
|
}
|
|
3793
|
+
} else if (id === "toolTimeoutSeconds") {
|
|
3794
|
+
const n = parseInt(value, 10);
|
|
3795
|
+
if (n >= 0) {
|
|
3796
|
+
setToolTimeoutSeconds(n);
|
|
3797
|
+
notifyApplied(ctx, n === 0 ? "Tool timeout off" : `Tool timeout set to ${getToolTimeoutSeconds()} s`);
|
|
3798
|
+
}
|
|
3783
3799
|
} else if (id === "maxSubagentDepth") {
|
|
3784
3800
|
const n = parseInt(value, 10);
|
|
3785
3801
|
if (n >= 0) {
|
|
@@ -3964,7 +3980,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3964
3980
|
? String(getDefaultMaxTurns() ?? 0)
|
|
3965
3981
|
: result === "maxSubagentDepth"
|
|
3966
3982
|
? String(getMaxSubagentDepth())
|
|
3967
|
-
:
|
|
3983
|
+
: result === "toolTimeoutSeconds"
|
|
3984
|
+
? String(getToolTimeoutSeconds())
|
|
3985
|
+
: String(getGraceTurns());
|
|
3968
3986
|
|
|
3969
3987
|
const label = result === "maxConcurrent"
|
|
3970
3988
|
? "Max concurrency (1+)"
|
|
@@ -3974,7 +3992,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3974
3992
|
? "Default max turns (0 = unlimited)"
|
|
3975
3993
|
: result === "maxSubagentDepth"
|
|
3976
3994
|
? "Nested depth (0/1 = nesting off)"
|
|
3977
|
-
:
|
|
3995
|
+
: result === "toolTimeoutSeconds"
|
|
3996
|
+
? "Tool timeout in seconds (0 = no limit)"
|
|
3997
|
+
: "Grace turns (1+)";
|
|
3978
3998
|
|
|
3979
3999
|
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
3980
4000
|
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|
package/src/settings.ts
CHANGED
|
@@ -40,6 +40,11 @@ export interface SubagentsSettings {
|
|
|
40
40
|
*/
|
|
41
41
|
defaultMaxTurns?: number;
|
|
42
42
|
graceTurns?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Longest one tool call of a subagent may run, in seconds (0 = no limit,
|
|
45
|
+
* default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
|
|
46
|
+
*/
|
|
47
|
+
toolTimeoutSeconds?: number;
|
|
43
48
|
defaultJoinMode?: JoinMode;
|
|
44
49
|
/**
|
|
45
50
|
* Whether a top-level `Agent` spawn that doesn't say runs detached.
|
|
@@ -313,6 +318,7 @@ export interface SettingsAppliers {
|
|
|
313
318
|
setMaxConcurrentForeground: (n: number) => void;
|
|
314
319
|
setDefaultMaxTurns: (n: number) => void;
|
|
315
320
|
setGraceTurns: (n: number) => void;
|
|
321
|
+
setToolTimeoutSeconds: (n: number) => void;
|
|
316
322
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
317
323
|
setBackgroundByDefault: (b: boolean) => void;
|
|
318
324
|
setSchedulingEnabled: (b: boolean) => void;
|
|
@@ -350,6 +356,7 @@ const VALID_AGENT_MENTION_MODES: ReadonlySet<string> = new Set<AgentMentionMode>
|
|
|
350
356
|
const MAX_CONCURRENT_CEILING = 1024;
|
|
351
357
|
const MAX_TURNS_CEILING = 10_000;
|
|
352
358
|
const GRACE_TURNS_CEILING = 1_000;
|
|
359
|
+
const TOOL_TIMEOUT_CEILING = 86_400;
|
|
353
360
|
const SUBAGENT_DEPTH_CEILING = 16;
|
|
354
361
|
|
|
355
362
|
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
@@ -387,6 +394,13 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
387
394
|
) {
|
|
388
395
|
out.graceTurns = r.graceTurns as number;
|
|
389
396
|
}
|
|
397
|
+
if (
|
|
398
|
+
Number.isInteger(r.toolTimeoutSeconds) &&
|
|
399
|
+
(r.toolTimeoutSeconds as number) >= 0 &&
|
|
400
|
+
(r.toolTimeoutSeconds as number) <= TOOL_TIMEOUT_CEILING
|
|
401
|
+
) {
|
|
402
|
+
out.toolTimeoutSeconds = r.toolTimeoutSeconds as number;
|
|
403
|
+
}
|
|
390
404
|
if (
|
|
391
405
|
Number.isInteger(r.maxSubagentDepth) &&
|
|
392
406
|
(r.maxSubagentDepth as number) >= 0 &&
|
|
@@ -523,6 +537,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
523
537
|
}
|
|
524
538
|
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
525
539
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
540
|
+
if (typeof s.toolTimeoutSeconds === "number") appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
|
|
526
541
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
527
542
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
528
543
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|