@mono-agent/agent-runtime 0.20.3 → 0.20.5
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/README.md +2 -0
- package/package.json +1 -1
- package/src/agent/tools/bash.js +29 -2
- package/src/agent/tools/exec.js +4 -2
- package/src/agent/tools/index.js +2 -0
- package/src/agent/tools/pi-bridge.js +126 -23
- package/src/agent/tools/shared/process-jobs.js +15 -2
- package/src/ai/failure.js +16 -3
- package/src/ai/types.js +4 -3
- package/types/agent/tools/bash.d.ts +14 -0
- package/types/agent/tools/index.d.ts +1 -1
- package/types/agent/tools/pi-bridge.d.ts +1 -0
- package/types/agent/tools/shared/process-jobs.d.ts +2 -1
- package/types/ai/types.d.ts +8 -6
package/README.md
CHANGED
package/package.json
CHANGED
package/src/agent/tools/bash.js
CHANGED
|
@@ -54,6 +54,31 @@ export function normalizeProcessTimeoutMs(value, fallback = DEFAULT_BASH_TIMEOUT
|
|
|
54
54
|
return Math.max(1, Math.min(Math.floor(n), cap));
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Background process-job timeout: positive-integer milliseconds with no
|
|
59
|
+
* foreground ceiling. A background budget belongs to the host's `processJobs`
|
|
60
|
+
* settings (`maxRuntimeMs`), which clamp it on their own side; reusing the
|
|
61
|
+
* foreground default as a cap here silently discarded the long runtime a caller
|
|
62
|
+
* deliberately asked for. `undefined` means "no explicit request", leaving the
|
|
63
|
+
* host default in force.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeBackgroundTimeoutMs(value) {
|
|
66
|
+
const n = Number(value);
|
|
67
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
68
|
+
return Math.max(1, Math.floor(n));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Legacy Bash `timeout` for a background job: the same seconds-vs-milliseconds
|
|
73
|
+
* heuristic as {@link normalizeBashTimeoutMs}, minus the foreground ceiling.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeBackgroundBashTimeoutMs(value) {
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
78
|
+
const floored = Math.floor(n);
|
|
79
|
+
return Math.max(1_000, floored <= 600 ? floored * 1_000 : floored);
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/**
|
|
58
83
|
* Compatibility wrapper retained for direct callers and tests.
|
|
59
84
|
*
|
|
@@ -131,9 +156,11 @@ export async function bashToolRun(
|
|
|
131
156
|
}
|
|
132
157
|
|
|
133
158
|
if (background === true && processJobsController) {
|
|
159
|
+
// Deliberately re-derived from the raw params: `timeoutMs` above carries the
|
|
160
|
+
// foreground ceiling, which is not this job's budget.
|
|
134
161
|
const requestedTimeoutMs = timeout_ms !== undefined
|
|
135
|
-
?
|
|
136
|
-
: (timeout
|
|
162
|
+
? normalizeBackgroundTimeoutMs(timeout_ms)
|
|
163
|
+
: normalizeBackgroundBashTimeoutMs(timeout);
|
|
137
164
|
const handedOff = await handOffProcessJob({
|
|
138
165
|
controller: processJobsController,
|
|
139
166
|
tool: "Bash",
|
package/src/agent/tools/exec.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
5
5
|
import { DEFAULT_MAX_BASH_OUTPUT_CHARS } from "./shared/constants.js";
|
|
6
|
-
import { normalizeProcessTimeoutMs } from "./bash.js";
|
|
6
|
+
import { normalizeBackgroundTimeoutMs, normalizeProcessTimeoutMs } from "./bash.js";
|
|
7
7
|
import { capChars } from "./shared/output-truncation.js";
|
|
8
8
|
import {
|
|
9
9
|
isPathAllowed,
|
|
@@ -101,7 +101,9 @@ export async function execToolRun(
|
|
|
101
101
|
tool: "Exec",
|
|
102
102
|
prepared,
|
|
103
103
|
summary: `Exec command (${args.length} argument${args.length === 1 ? "" : "s"}; values redacted)`,
|
|
104
|
-
|
|
104
|
+
// Re-derived from the raw param: `timeoutMs` carries the foreground
|
|
105
|
+
// ceiling, and a background job is bounded by processJobs instead.
|
|
106
|
+
timeoutMs: timeout_ms === undefined ? undefined : normalizeBackgroundTimeoutMs(timeout_ms),
|
|
105
107
|
maxOutputChars: max_output_chars === undefined ? undefined : maxChars,
|
|
106
108
|
startedAt,
|
|
107
109
|
failed,
|
package/src/agent/tools/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
4
4
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
5
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
6
6
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
|
+
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
7
8
|
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
8
9
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
10
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
@@ -185,6 +186,20 @@ function writeFileChangeDetails(path, before, after) {
|
|
|
185
186
|
};
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Render a budget for a tool description: the exact milliseconds the host
|
|
191
|
+
* enforces, plus a human unit, so the model can both reason about the scale and
|
|
192
|
+
* pass an exact value back.
|
|
193
|
+
*/
|
|
194
|
+
function formatDurationForModel(ms) {
|
|
195
|
+
const hours = ms / 3_600_000;
|
|
196
|
+
const minutes = ms / 60_000;
|
|
197
|
+
const human = hours >= 1
|
|
198
|
+
? `${Number.isInteger(hours) ? String(hours) : hours.toFixed(1)}h`
|
|
199
|
+
: (minutes >= 1 ? `${Number.isInteger(minutes) ? String(minutes) : minutes.toFixed(1)}m` : `${String(Math.round(ms / 1_000))}s`);
|
|
200
|
+
return `${human} (${String(ms)} ms)`;
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
function limitedNumber(value, fallback) {
|
|
189
204
|
const n = Number(value);
|
|
190
205
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
@@ -208,10 +223,19 @@ function withToolLimits(name, params, limits = {}) {
|
|
|
208
223
|
if (name === "Bash" || name === "Exec") {
|
|
209
224
|
const timeoutLimit = limits.bashTimeoutMs || DEFAULT_BASH_TIMEOUT_MS;
|
|
210
225
|
next.max_output_chars = limitedNumber(next.max_output_chars, limits.bashOutputLimitChars || limits.toolTextLimitChars || 20000);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
226
|
+
// A background hand-off is bounded by the host's processJobs budget, so the
|
|
227
|
+
// FOREGROUND DEFAULT must never bound it: falling back to that default is what
|
|
228
|
+
// silently capped multi-hour background jobs at two minutes, and the `else`
|
|
229
|
+
// branch injects it even when no timeout was requested at all. An explicitly
|
|
230
|
+
// configured bashTimeoutMs still narrows them — a host that sets one means it
|
|
231
|
+
// for background work too.
|
|
232
|
+
const effectiveTimeoutLimit = next.background === true ? limits.bashTimeoutMs : timeoutLimit;
|
|
233
|
+
if (effectiveTimeoutLimit !== undefined) {
|
|
234
|
+
if (name === "Bash" && next.timeout_ms === undefined && next.timeout !== undefined) {
|
|
235
|
+
next.timeout = normalizeBashTimeoutMs(next.timeout, effectiveTimeoutLimit);
|
|
236
|
+
} else {
|
|
237
|
+
next.timeout_ms = normalizeProcessTimeoutMs(next.timeout_ms, effectiveTimeoutLimit);
|
|
238
|
+
}
|
|
215
239
|
}
|
|
216
240
|
}
|
|
217
241
|
return next;
|
|
@@ -389,7 +413,7 @@ function readSkillTool(skillNames = [], { skillsRoot, dataDir, skills = [] } = {
|
|
|
389
413
|
return {
|
|
390
414
|
name: "ReadSkill",
|
|
391
415
|
label: "Read Skill",
|
|
392
|
-
description: "Load the complete instructions for a named skill. Use ReadSkill instead of Read for SKILL.md files.",
|
|
416
|
+
description: "Load the complete instructions for a named skill. Use ReadSkill instead of Read for SKILL.md files. If a skill's instructions are already present in this conversation, apply those instead of loading it again.",
|
|
393
417
|
parameters: objectSchema({ name: { type: "string", enum: enumNames } }, ["name"]),
|
|
394
418
|
async execute(_toolCallId, { name }) {
|
|
395
419
|
if (sharedRoot) {
|
|
@@ -468,10 +492,28 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
468
492
|
type: "integer",
|
|
469
493
|
description: "Deprecated compatibility timeout. Values up to 600 mean seconds and larger values mean milliseconds; use timeout_ms instead.",
|
|
470
494
|
};
|
|
495
|
+
const foregroundTimeoutLimitMs = toolLimits?.bashTimeoutMs || DEFAULT_BASH_TIMEOUT_MS;
|
|
496
|
+
const backgroundLimitMs = processJobsController?.limits?.maxRuntimeMs;
|
|
471
497
|
const processTimeoutSchema = {
|
|
472
498
|
type: "integer",
|
|
473
499
|
minimum: 1,
|
|
474
|
-
description:
|
|
500
|
+
description: `Exact timeout in milliseconds. A foreground run is capped at ${formatDurationForModel(foregroundTimeoutLimitMs)} and is killed at that point, so anything longer belongs in the background${
|
|
501
|
+
backgroundLimitMs === undefined
|
|
502
|
+
? ""
|
|
503
|
+
: `, where this host allows up to ${formatDurationForModel(backgroundLimitMs)}`
|
|
504
|
+
}.`,
|
|
505
|
+
};
|
|
506
|
+
// Shared by Exec and Bash, and injected only when the host supplies a
|
|
507
|
+
// process-job controller. House style for a tool description is
|
|
508
|
+
// capability + when-to-prefer + caveat, so the middle sentence is what tells
|
|
509
|
+
// the model which commands belong here rather than in the foreground.
|
|
510
|
+
const backgroundSchema = {
|
|
511
|
+
type: "boolean",
|
|
512
|
+
description: `Run as a durable background process job and notify this conversation when it finishes. Prefer this for work that outlives a reply — builds, full test suites, long installs, migrations, long-running watchers — and leave it off whenever you need the output to answer right now. Do not use for commands that daemonize into another POSIX process group or session.${
|
|
513
|
+
backgroundLimitMs === undefined
|
|
514
|
+
? ""
|
|
515
|
+
: ` This host runs a background job for up to ${formatDurationForModel(backgroundLimitMs)}; \`timeout_ms\` may lower that but never raise it, and the start receipt reports \`max_runtime_ms\`, the budget actually granted — check it, because a job is killed at that limit.`
|
|
516
|
+
}`,
|
|
475
517
|
};
|
|
476
518
|
// Per-tool closure config (cwd/event sink/limits/policy) plus the per-instance
|
|
477
519
|
// ToolContext `ctx` that the tool impls and shared helpers read from.
|
|
@@ -533,9 +575,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
533
575
|
timeout_ms: processTimeoutSchema,
|
|
534
576
|
timeout: legacyBashTimeoutSchema,
|
|
535
577
|
max_output_chars: bashLimitSchema,
|
|
536
|
-
...(processJobsController ? {
|
|
537
|
-
background: { type: "boolean", description: "Run as a durable background process job and notify this conversation when it finishes. Do not use for commands that daemonize into another POSIX process group or session." },
|
|
538
|
-
} : {}),
|
|
578
|
+
...(processJobsController ? { background: backgroundSchema } : {}),
|
|
539
579
|
}, ["command"]), bashToolRun, toolContext),
|
|
540
580
|
Exec: createBuiltinTool("Exec", "Exec", "Execute one program directly from an argv array without shell parsing. Prefer this for ordinary commands; use Bash only when shell syntax is required.", objectSchema({
|
|
541
581
|
executable: { type: "string", minLength: 1 },
|
|
@@ -543,9 +583,7 @@ export function getPiBuiltinTools(allowedTools, {
|
|
|
543
583
|
workdir: { type: "string" },
|
|
544
584
|
timeout_ms: processTimeoutSchema,
|
|
545
585
|
max_output_chars: bashLimitSchema,
|
|
546
|
-
...(processJobsController ? {
|
|
547
|
-
background: { type: "boolean", description: "Run as a durable background process job and notify this conversation when it finishes. Do not use for commands that daemonize into another POSIX process group or session." },
|
|
548
|
-
} : {}),
|
|
586
|
+
...(processJobsController ? { background: backgroundSchema } : {}),
|
|
549
587
|
}, ["executable"]), execToolRun, toolContext),
|
|
550
588
|
NodeRepl: nodeReplController
|
|
551
589
|
? createBuiltinTool(
|
|
@@ -693,6 +731,9 @@ async function connectMcpClient(name, cfg, { cwd, sandboxPolicy, sandboxEngine,
|
|
|
693
731
|
retainedByMcpApps: false,
|
|
694
732
|
privateCapabilityUrl,
|
|
695
733
|
closed: false,
|
|
734
|
+
// AbortControllers for calls currently on the wire. Teardown aborts these
|
|
735
|
+
// BEFORE closing so the server is told to stop — see closeConnectedMcpClient.
|
|
736
|
+
inFlight: new Set(),
|
|
696
737
|
};
|
|
697
738
|
} catch (error) {
|
|
698
739
|
try { await transport?.close?.(); } catch { /* best-effort */ }
|
|
@@ -868,6 +909,25 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
868
909
|
parameters: sourceTool.inputSchema || /** @type {any} */ (sourceTool).input_schema || objectSchema({}),
|
|
869
910
|
async execute(toolCallId, params, signal) {
|
|
870
911
|
if (signal?.aborted) throw new Error("tool execution aborted");
|
|
912
|
+
// OWN this request's abort signal rather than forwarding the caller's
|
|
913
|
+
// directly, so run teardown can cancel the call ON THE WIRE. The SDK
|
|
914
|
+
// turns an abort into `notifications/cancelled`; closing the client
|
|
915
|
+
// only rejects the caller locally (Protocol._onclose) and then
|
|
916
|
+
// SIGTERM/SIGKILLs a stdio child with its request still outstanding —
|
|
917
|
+
// which is how a proxy's upstream work was left dangling in #664.
|
|
918
|
+
const callAbort = new AbortController();
|
|
919
|
+
const forwardAbort = () => callAbort.abort(signal?.reason);
|
|
920
|
+
signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
921
|
+
const inFlight = { controller: callAbort, label: `${serverName}:${sourceTool.name}` };
|
|
922
|
+
connected.inFlight.add(inFlight);
|
|
923
|
+
try {
|
|
924
|
+
return await callMcpTool();
|
|
925
|
+
} finally {
|
|
926
|
+
connected.inFlight.delete(inFlight);
|
|
927
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function callMcpTool() {
|
|
871
931
|
const textLimit = limits.mcpTextLimitChars || MCP_TEXT_RESULT_LIMIT;
|
|
872
932
|
const imageInlineMaxBytes = limits.imageInlineMaxBytes ?? MCP_IMAGE_INLINE_MAX_BYTES;
|
|
873
933
|
const normalizedParams = normalizeMcpToolParams(serverName, sourceTool.name, params || {}, { qaOutputDir, ctx });
|
|
@@ -913,7 +973,7 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
913
973
|
timeout: mcpCallTimeoutMs,
|
|
914
974
|
resetTimeoutOnProgress: true,
|
|
915
975
|
maxTotalTimeout: mcpCallMaxTotalTimeoutMs,
|
|
916
|
-
signal,
|
|
976
|
+
signal: callAbort.signal,
|
|
917
977
|
onprogress,
|
|
918
978
|
},
|
|
919
979
|
).catch((error) => {
|
|
@@ -925,7 +985,7 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
925
985
|
const out = await withTimeout(
|
|
926
986
|
request,
|
|
927
987
|
mcpCallTimeoutMs,
|
|
928
|
-
signal,
|
|
988
|
+
callAbort.signal,
|
|
929
989
|
`${serverName}:${sourceTool.name}`,
|
|
930
990
|
(reset) => {
|
|
931
991
|
resetInactivityTimeout = reset;
|
|
@@ -976,6 +1036,7 @@ export async function initPiMcpTools(mcpConfig, reservedNames = new Set(), {
|
|
|
976
1036
|
} : {}),
|
|
977
1037
|
},
|
|
978
1038
|
};
|
|
1039
|
+
}
|
|
979
1040
|
},
|
|
980
1041
|
});
|
|
981
1042
|
}
|
|
@@ -1067,15 +1128,30 @@ async function registerMcpAppForToolResult({
|
|
|
1067
1128
|
const connection = {
|
|
1068
1129
|
connectionId: connected.connectionId,
|
|
1069
1130
|
readResource: async (uri) => await connected.client.readResource({ uri }),
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1131
|
+
// Same in-flight registration as the tool path: a retained MCP App
|
|
1132
|
+
// connection torn down mid-call must cancel on the wire, not be closed out
|
|
1133
|
+
// from under the request. See closeConnectedMcpClient.
|
|
1134
|
+
callTool: async (name, args, signal) => {
|
|
1135
|
+
const callAbort = new AbortController();
|
|
1136
|
+
const forwardAbort = () => callAbort.abort(signal?.reason);
|
|
1137
|
+
signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1138
|
+
const inFlight = { controller: callAbort, label: `${serverName}:${name}` };
|
|
1139
|
+
connected.inFlight.add(inFlight);
|
|
1140
|
+
try {
|
|
1141
|
+
return await connected.client.callTool(
|
|
1142
|
+
{ name, arguments: args && typeof args === "object" && !Array.isArray(args) ? args : {} },
|
|
1143
|
+
undefined,
|
|
1144
|
+
{
|
|
1145
|
+
timeout: 120_000,
|
|
1146
|
+
maxTotalTimeout: 120_000,
|
|
1147
|
+
signal: callAbort.signal,
|
|
1148
|
+
},
|
|
1149
|
+
);
|
|
1150
|
+
} finally {
|
|
1151
|
+
connected.inFlight.delete(inFlight);
|
|
1152
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1079
1155
|
close: async () => {
|
|
1080
1156
|
connected.retainedByMcpApps = false;
|
|
1081
1157
|
await closeConnectedMcpClient(connected, 5_000);
|
|
@@ -1139,9 +1215,36 @@ function selectMcpAppResource(response, resourceUri) {
|
|
|
1139
1215
|
};
|
|
1140
1216
|
}
|
|
1141
1217
|
|
|
1218
|
+
/**
|
|
1219
|
+
* Cancel every call still on the wire, then close.
|
|
1220
|
+
*
|
|
1221
|
+
* Order matters. `Protocol._onclose` rejects pending requests LOCALLY with
|
|
1222
|
+
* ConnectionClosed and never notifies the server, and StdioClientTransport then
|
|
1223
|
+
* escalates stdin.end -> SIGTERM -> SIGKILL — so closing first kills a stdio
|
|
1224
|
+
* proxy mid-request with its upstream work still outstanding, and the caller
|
|
1225
|
+
* gets a bare "Connection closed" that cannot be told apart from a server that
|
|
1226
|
+
* died on its own. Aborting first makes the SDK emit `notifications/cancelled`
|
|
1227
|
+
* so the server can unwind, and rejects the caller with a sentence naming the
|
|
1228
|
+
* server and tool. See mono-agent#664.
|
|
1229
|
+
*/
|
|
1230
|
+
function cancelInFlightMcpCalls(connected) {
|
|
1231
|
+
const pending = connected?.inFlight;
|
|
1232
|
+
if (!pending || pending.size === 0) return;
|
|
1233
|
+
for (const entry of [...pending]) {
|
|
1234
|
+
try {
|
|
1235
|
+
entry.controller.abort(new McpError(
|
|
1236
|
+
ErrorCode.ConnectionClosed,
|
|
1237
|
+
`${entry.label} was cancelled because its MCP connection was torn down.`,
|
|
1238
|
+
));
|
|
1239
|
+
} catch { /* best-effort */ }
|
|
1240
|
+
}
|
|
1241
|
+
pending.clear();
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1142
1244
|
async function closeConnectedMcpClient(connected, timeoutMs) {
|
|
1143
1245
|
if (!connected || connected.closed === true) return;
|
|
1144
1246
|
connected.closed = true;
|
|
1247
|
+
cancelInFlightMcpCalls(connected);
|
|
1145
1248
|
const { client, transport } = connected;
|
|
1146
1249
|
try { await closeWithTimeout(client?.close?.bind(client), timeoutMs); } catch { /* best-effort */ }
|
|
1147
1250
|
try { await closeWithTimeout(transport?.close?.bind(transport), timeoutMs); } catch { /* best-effort */ }
|
|
@@ -16,7 +16,7 @@ import { startPreparedProcess } from "./process-runner.js";
|
|
|
16
16
|
* timeoutMs?: number,
|
|
17
17
|
* maxOutputChars?: number,
|
|
18
18
|
* launch: (options?: {timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, onStdout?: (chunk: Buffer) => void, onStderr?: (chunk: Buffer) => void}) => ReturnType<typeof startPreparedProcess>,
|
|
19
|
-
* }) => Promise<{jobId: string, state: "queued"|"starting"|"running", startedAt: string|null}>} start
|
|
19
|
+
* }) => Promise<{jobId: string, state: "queued"|"starting"|"running", startedAt: string|null, maxRuntimeMs?: number}>} start
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -82,9 +82,10 @@ export async function handOffProcessJob({
|
|
|
82
82
|
job_id: result.jobId,
|
|
83
83
|
state: result.state,
|
|
84
84
|
started_at: result.startedAt,
|
|
85
|
+
...(result.maxRuntimeMs === undefined ? {} : { max_runtime_ms: result.maxRuntimeMs }),
|
|
85
86
|
};
|
|
86
87
|
return {
|
|
87
|
-
text: JSON.stringify(payload)
|
|
88
|
+
text: `${BACKGROUND_START_GUIDANCE}\n${JSON.stringify(payload)}`,
|
|
88
89
|
outcome: {
|
|
89
90
|
status: "ok",
|
|
90
91
|
code: "background_started",
|
|
@@ -111,6 +112,16 @@ export async function handOffProcessJob({
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A bare id/state payload leaves the model to guess what happens next, and the
|
|
117
|
+
* cheapest wrong guess is a polling loop. Completion delivers its own turn, so
|
|
118
|
+
* the result says so itself rather than relying on the schema line alone. No
|
|
119
|
+
* operator command is named on purpose: the model has a shell, and naming a
|
|
120
|
+
* status command invites exactly the polling this forbids.
|
|
121
|
+
*/
|
|
122
|
+
const BACKGROUND_START_GUIDANCE =
|
|
123
|
+
"Background process job started (tool-authored guidance): this conversation is woken with a new turn when the job reaches a terminal state, and its output arrives with that turn. Do not poll, sleep, wait on it, or re-run the command to check progress, and do not report the work as finished yet. `max_runtime_ms` is the budget the host granted; if it is below what you requested, the host capped it and the job will be killed at that limit, so plan the work around the granted budget rather than re-running the same command.";
|
|
124
|
+
|
|
114
125
|
const PUBLIC_BACKGROUND_START_FAILURES = Object.freeze({
|
|
115
126
|
background_unsupported: "Background process jobs are unsupported for this tool call.",
|
|
116
127
|
background_unsupported_channel: "Background process jobs are unsupported for this channel.",
|
|
@@ -181,6 +192,8 @@ function validProcessJobStartResult(value) {
|
|
|
181
192
|
if (!value || typeof value !== "object") return false;
|
|
182
193
|
if (typeof value.jobId !== "string" || value.jobId.trim().length === 0 || value.jobId.length > 256) return false;
|
|
183
194
|
if (value.state !== "queued" && value.state !== "starting" && value.state !== "running") return false;
|
|
195
|
+
if (value.maxRuntimeMs !== undefined
|
|
196
|
+
&& (!Number.isSafeInteger(value.maxRuntimeMs) || value.maxRuntimeMs <= 0)) return false;
|
|
184
197
|
if (value.startedAt === null) return true;
|
|
185
198
|
if (typeof value.startedAt !== "string") return false;
|
|
186
199
|
const timestamp = Date.parse(value.startedAt);
|
package/src/ai/failure.js
CHANGED
|
@@ -84,7 +84,7 @@ const PROVIDER_AUTH_RE = /(no api key|missing api key|api key required|invalid a
|
|
|
84
84
|
// like worklab's coordinator, independent of retryableProviderFailureInfo) maps
|
|
85
85
|
// that same terse text to the generic "spawn" kind instead of
|
|
86
86
|
// "provider_unavailable".
|
|
87
|
-
const PROVIDER_UNAVAILABLE_RE = /(econn|enotfound|etimedout|timed? ?out|service unavailable|503|502|gateway|fetch failed|network|websocket|\bconnection (?:error|refused|failed)\b|\bcould not connect\b)/i;
|
|
87
|
+
const PROVIDER_UNAVAILABLE_RE = /(econn|enotfound|etimedout|timed? ?out|service unavailable|503|502|gateway|fetch failed|network|websocket|\bconnection (?:error|refused|failed)\b|\bcould not connect\b|\bstream ended without finish_reason\b)/i;
|
|
88
88
|
const TOOL_FAILURE_RE = /(tool .* failed|mcp tool|permission denied|EACCES|read-only file system)/i;
|
|
89
89
|
const NON_RETRYABLE_PROVIDER_RE = /(invalid[_ ]request|unknown parameter|no api key|missing api key|api key required|invalid api key|incorrect api key|provider is not configured:|authentication|authorization|not authorized|forbidden|billing|insufficient[_ ]quota|quota exceeded|model[_ ]not[_ ]found|unsupported model|permission denied|bad request|401|403|404)/i;
|
|
90
90
|
// pi 0.80's openai-client-style bridge collapses a connection-refused/unreachable
|
|
@@ -92,7 +92,15 @@ const NON_RETRYABLE_PROVIDER_RE = /(invalid[_ ]request|unknown parameter|no api
|
|
|
92
92
|
// no fetch failed) — the `\bconnection (?:error|refused|failed)\b|\bcould not connect\b`
|
|
93
93
|
// alternation below is the motivating fix so that case still fails over instead of
|
|
94
94
|
// being classified as non-retryable.
|
|
95
|
-
|
|
95
|
+
// pi-ai 0.83.0 adds a second such bare sentence: openai-completions.js throws
|
|
96
|
+
// "Stream ended without finish_reason" when an SSE stream terminates without a
|
|
97
|
+
// finish_reason chunk. Because pi-models.js pins every custom/OpenAI-compatible
|
|
98
|
+
// provider to the openai-completions api, a truncated gateway response was terminal
|
|
99
|
+
// on the first occurrence — a configured fallback chain never advanced off the
|
|
100
|
+
// primary. Matched as the full sentence on purpose: mono-agent's own TUI/web
|
|
101
|
+
// transports emit a similar "Stream ended without a finish or error frame." that is a
|
|
102
|
+
// local protocol fault and must NOT trigger a provider failover.
|
|
103
|
+
const RETRYABLE_PROVIDER_RE = /(currently overloaded|server(?:s)? (?:is |are )?overloaded|try again later|retry your request|request id|service unavailable|temporar(?:y|ily)|timed? ?out|stream disconnected|fetch failed|econnreset|econnrefused|eai_again|enotfound|etimedout|network|429|too many requests|500|502|503|504|gateway|internal server error|\bconnection (?:error|refused|failed)\b|\bcould not connect\b|\bstream ended without finish_reason\b)/i;
|
|
96
104
|
export const PROVIDER_ABORT_RE = /\b(?:terminated|aborted before final output|aborted before final|stream aborted|stream was aborted|stream disconnected|websocket (?:error|disconnected|closed)|socket hang up|und_err_socket|econnreset|premature close)\b/i;
|
|
97
105
|
|
|
98
106
|
/**
|
|
@@ -129,7 +137,12 @@ function retryableProviderSubkind(text) {
|
|
|
129
137
|
if (/timed? ?out|etimedout/i.test(text)) return "timeout";
|
|
130
138
|
// pi 0.80's terse "Connection error." (no ECONNREFUSED/fetch-failed detail) still
|
|
131
139
|
// needs to land in the "network" subkind so a down provider fails over.
|
|
132
|
-
|
|
140
|
+
// pi-ai 0.83.0's openai-completions adapter throws the bare sentence
|
|
141
|
+
// "Stream ended without finish_reason" when an OpenAI-compatible SSE stream closes
|
|
142
|
+
// cleanly but no chunk ever carried a truthy finish_reason — i.e. a gateway
|
|
143
|
+
// truncated the response. That is a provider-side outage, so it belongs in the same
|
|
144
|
+
// "network" subkind as the terse connection error above.
|
|
145
|
+
if (/stream disconnected|fetch failed|econnreset|econnrefused|eai_again|enotfound|network|\bconnection (?:error|refused|failed)\b|\bcould not connect\b|\bstream ended without finish_reason\b/i.test(text)) return "network";
|
|
133
146
|
if (/500|502|503|504|service unavailable|gateway|internal server error/i.test(text)) return "server_error";
|
|
134
147
|
if (/retry your request|try again later|request id|processing your request/i.test(text)) return "retryable_request";
|
|
135
148
|
return null;
|
package/src/ai/types.js
CHANGED
|
@@ -214,9 +214,10 @@
|
|
|
214
214
|
* @property {number} [toolPayloadMaxBytes] Hard cap on a single tool_result payload.
|
|
215
215
|
* @property {number} [mcpCallTimeoutMs] Per-MCP-call inactivity timeout.
|
|
216
216
|
* @property {number} [mcpCallMaxTotalTimeoutMs] Hard wall-clock cap for one MCP call.
|
|
217
|
-
* @property {number} [bashTimeoutMs]
|
|
218
|
-
*
|
|
219
|
-
*
|
|
217
|
+
* @property {number} [bashTimeoutMs] Foreground ceiling and default for
|
|
218
|
+
* Bash/Exec timeouts on the Pi bridge, applied by `normalizePiBuiltinToolParams`
|
|
219
|
+
* (defaults to 120_000). Background hand-offs deliberately ignore it: a process job is
|
|
220
|
+
* bounded by the host's `processJobs.maxRuntimeMs` instead.
|
|
220
221
|
*/
|
|
221
222
|
|
|
222
223
|
/**
|
|
@@ -7,6 +7,20 @@ export function normalizeBashTimeoutMs(value: any, fallback?: number): any;
|
|
|
7
7
|
* Exact millisecond timeout used by Bash.timeout_ms and Exec.timeout_ms.
|
|
8
8
|
*/
|
|
9
9
|
export function normalizeProcessTimeoutMs(value: any, fallback?: number): any;
|
|
10
|
+
/**
|
|
11
|
+
* Background process-job timeout: positive-integer milliseconds with no
|
|
12
|
+
* foreground ceiling. A background budget belongs to the host's `processJobs`
|
|
13
|
+
* settings (`maxRuntimeMs`), which clamp it on their own side; reusing the
|
|
14
|
+
* foreground default as a cap here silently discarded the long runtime a caller
|
|
15
|
+
* deliberately asked for. `undefined` means "no explicit request", leaving the
|
|
16
|
+
* host default in force.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeBackgroundTimeoutMs(value: any): number;
|
|
19
|
+
/**
|
|
20
|
+
* Legacy Bash `timeout` for a background job: the same seconds-vs-milliseconds
|
|
21
|
+
* heuristic as {@link normalizeBashTimeoutMs}, minus the foreground ceiling.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeBackgroundBashTimeoutMs(value: any): number;
|
|
10
24
|
/**
|
|
11
25
|
* Compatibility wrapper retained for direct callers and tests.
|
|
12
26
|
*
|
|
@@ -5,7 +5,7 @@ export { globToolImpl } from "./glob.js";
|
|
|
5
5
|
export { grepToolImpl } from "./grep.js";
|
|
6
6
|
export { createWebToolController } from "./web-controller.js";
|
|
7
7
|
export { resolveRgPath } from "./shared/ripgrep.js";
|
|
8
|
-
export { bashToolImpl, bashToolRun, normalizeBashTimeoutMs, normalizeProcessTimeoutMs } from "./bash.js";
|
|
8
|
+
export { bashToolImpl, bashToolRun, normalizeBackgroundBashTimeoutMs, normalizeBackgroundTimeoutMs, normalizeBashTimeoutMs, normalizeProcessTimeoutMs } from "./bash.js";
|
|
9
9
|
export { execToolImpl, execToolRun } from "./exec.js";
|
|
10
10
|
export { webFetchToolImpl, performWebFetch } from "./web-fetch.js";
|
|
11
11
|
export { webSearchToolImpl, performWebSearch } from "./web-search.js";
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* timeoutMs?: number,
|
|
11
11
|
* maxOutputChars?: number,
|
|
12
12
|
* launch: (options?: {timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, onStdout?: (chunk: Buffer) => void, onStderr?: (chunk: Buffer) => void}) => ReturnType<typeof startPreparedProcess>,
|
|
13
|
-
* }) => Promise<{jobId: string, state: "queued"|"starting"|"running", startedAt: string|null}>} start
|
|
13
|
+
* }) => Promise<{jobId: string, state: "queued"|"starting"|"running", startedAt: string|null, maxRuntimeMs?: number}>} start
|
|
14
14
|
*/
|
|
15
15
|
/**
|
|
16
16
|
* Transfer one prepared command to the injected host controller. From the
|
|
@@ -59,6 +59,7 @@ export type ProcessJobsController = {
|
|
|
59
59
|
jobId: string;
|
|
60
60
|
state: "queued" | "starting" | "running";
|
|
61
61
|
startedAt: string | null;
|
|
62
|
+
maxRuntimeMs?: number;
|
|
62
63
|
}>;
|
|
63
64
|
};
|
|
64
65
|
import { startPreparedProcess } from "./process-runner.js";
|
package/types/ai/types.d.ts
CHANGED
|
@@ -177,9 +177,10 @@
|
|
|
177
177
|
* @property {number} [toolPayloadMaxBytes] Hard cap on a single tool_result payload.
|
|
178
178
|
* @property {number} [mcpCallTimeoutMs] Per-MCP-call inactivity timeout.
|
|
179
179
|
* @property {number} [mcpCallMaxTotalTimeoutMs] Hard wall-clock cap for one MCP call.
|
|
180
|
-
* @property {number} [bashTimeoutMs]
|
|
181
|
-
*
|
|
182
|
-
*
|
|
180
|
+
* @property {number} [bashTimeoutMs] Foreground ceiling and default for
|
|
181
|
+
* Bash/Exec timeouts on the Pi bridge, applied by `normalizePiBuiltinToolParams`
|
|
182
|
+
* (defaults to 120_000). Background hand-offs deliberately ignore it: a process job is
|
|
183
|
+
* bounded by the host's `processJobs.maxRuntimeMs` instead.
|
|
183
184
|
*/
|
|
184
185
|
/**
|
|
185
186
|
* @typedef {Object} RuntimeCompactionPolicy
|
|
@@ -754,9 +755,10 @@ export type RuntimeToolLimits = {
|
|
|
754
755
|
*/
|
|
755
756
|
mcpCallMaxTotalTimeoutMs?: number;
|
|
756
757
|
/**
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
758
|
+
* Foreground ceiling and default for
|
|
759
|
+
* Bash/Exec timeouts on the Pi bridge, applied by `normalizePiBuiltinToolParams`
|
|
760
|
+
* (defaults to 120_000). Background hand-offs deliberately ignore it: a process job is
|
|
761
|
+
* bounded by the host's `processJobs.maxRuntimeMs` instead.
|
|
760
762
|
*/
|
|
761
763
|
bashTimeoutMs?: number;
|
|
762
764
|
};
|