@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 CHANGED
@@ -343,6 +343,8 @@ grepToolImpl
343
343
  inspectCodexSubscriptionSearch
344
344
  isPathAllowed
345
345
  isWorkdirAllowed
346
+ normalizeBackgroundBashTimeoutMs
347
+ normalizeBackgroundTimeoutMs
346
348
  normalizeBashTimeoutMs
347
349
  normalizeProcessTimeoutMs
348
350
  performWebFetch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.20.3",
3
+ "version": "0.20.5",
4
4
  "description": "Agent runtime supporting Claude SDK/CLI, Codex, OpenCode, Pi SDK, and ACP v1 bridges out of the box",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
@@ -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
- ? timeoutMs
136
- : (timeout === undefined ? undefined : timeoutMs);
162
+ ? normalizeBackgroundTimeoutMs(timeout_ms)
163
+ : normalizeBackgroundBashTimeoutMs(timeout);
137
164
  const handedOff = await handOffProcessJob({
138
165
  controller: processJobsController,
139
166
  tool: "Bash",
@@ -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
- timeoutMs: timeout_ms === undefined ? undefined : timeoutMs,
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,
@@ -12,6 +12,8 @@ export { grepToolImpl } from "./grep.js";
12
12
  export {
13
13
  bashToolImpl,
14
14
  bashToolRun,
15
+ normalizeBackgroundBashTimeoutMs,
16
+ normalizeBackgroundTimeoutMs,
15
17
  normalizeBashTimeoutMs,
16
18
  normalizeProcessTimeoutMs,
17
19
  } from "./bash.js";
@@ -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
- if (name === "Bash" && next.timeout_ms === undefined && next.timeout !== undefined) {
212
- next.timeout = normalizeBashTimeoutMs(next.timeout, timeoutLimit);
213
- } else {
214
- next.timeout_ms = normalizeProcessTimeoutMs(next.timeout_ms, timeoutLimit);
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: "Exact timeout in milliseconds.",
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
- callTool: async (name, args, signal) => await connected.client.callTool(
1071
- { name, arguments: args && typeof args === "object" && !Array.isArray(args) ? args : {} },
1072
- undefined,
1073
- {
1074
- timeout: 120_000,
1075
- maxTotalTimeout: 120_000,
1076
- ...(signal ? { signal } : {}),
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
- 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)/i;
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
- if (/stream disconnected|fetch failed|econnreset|econnrefused|eai_again|enotfound|network|\bconnection (?:error|refused|failed)\b|\bcould not connect\b/i.test(text)) return "network";
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] Documented for forward-compat; NOT wired to
218
- * any tool today (no `agent_bash_*_timeout` mechanism exists bash reads its per-call
219
- * `timeout` argument), so setting it has no effect until a run-level default is introduced.
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";
@@ -138,6 +138,7 @@ export function initPiMcpTools(mcpConfig: any, reservedNames?: Set<any>, { limit
138
138
  retainedByMcpApps: boolean;
139
139
  privateCapabilityUrl: boolean;
140
140
  closed: boolean;
141
+ inFlight: Set<any>;
141
142
  }[];
142
143
  tools: any[];
143
144
  warnings: {
@@ -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";
@@ -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] Documented for forward-compat; NOT wired to
181
- * any tool today (no `agent_bash_*_timeout` mechanism exists bash reads its per-call
182
- * `timeout` argument), so setting it has no effect until a run-level default is introduced.
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
- * Documented for forward-compat; NOT wired to
758
- * any tool today (no `agent_bash_*_timeout` mechanism exists bash reads its per-call
759
- * `timeout` argument), so setting it has no effect until a run-level default is introduced.
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
  };