@mono-agent/agent-runtime 0.4.0 → 0.5.0

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.
Files changed (119) hide show
  1. package/ARCHITECTURE.md +37 -16
  2. package/MIGRATION.md +185 -7
  3. package/README.md +24 -14
  4. package/package.json +103 -10
  5. package/src/agent/allowlists.js +3 -0
  6. package/src/agent/approval.js +3 -0
  7. package/src/agent/compaction.js +244 -1
  8. package/src/agent/prompt/skill-index.js +6 -0
  9. package/src/agent/sandbox-seam.js +227 -0
  10. package/src/agent/tool-bloat.js +8 -2
  11. package/src/agent/tools/bash.js +16 -8
  12. package/src/agent/tools/edit.js +7 -3
  13. package/src/agent/tools/glob.js +11 -5
  14. package/src/agent/tools/grep.js +11 -5
  15. package/src/agent/tools/pi-bridge.js +227 -45
  16. package/src/agent/tools/read.js +28 -3
  17. package/src/agent/tools/shared/output-truncation.js +8 -3
  18. package/src/agent/tools/shared/path-resolver.js +24 -18
  19. package/src/agent/tools/shared/ripgrep.js +33 -6
  20. package/src/agent/tools/shared/runtime-context.js +60 -50
  21. package/src/agent/tools/shared/tool-context.js +157 -0
  22. package/src/agent/tools/web-fetch.js +21 -10
  23. package/src/agent/tools/web-search.js +11 -4
  24. package/src/agent/tools/write.js +7 -3
  25. package/src/agent/transcript.js +16 -1
  26. package/src/ai/cost.js +128 -3
  27. package/src/ai/failure.js +96 -4
  28. package/src/ai/live-input-prompt.js +11 -1
  29. package/src/ai/providers/claude-cli.js +2 -2
  30. package/src/ai/providers/claude-sdk.js +55 -12
  31. package/src/ai/providers/codex-app.js +36 -23
  32. package/src/ai/providers/opencode-app.js +2 -2
  33. package/src/ai/providers/opencode-discovery.js +2 -2
  34. package/src/ai/providers/pi-events.js +5 -0
  35. package/src/ai/providers/pi-models.js +21 -4
  36. package/src/ai/providers/pi-native/compaction-driver.js +394 -0
  37. package/src/ai/providers/pi-native/result-builder.js +312 -0
  38. package/src/ai/providers/pi-native/session-lifecycle.js +438 -0
  39. package/src/ai/providers/pi-native/stream-subscriber.js +148 -0
  40. package/src/ai/providers/pi-native/structured-output.js +130 -0
  41. package/src/ai/providers/pi-native/turn-runner.js +278 -0
  42. package/src/ai/providers/pi-native.js +415 -1106
  43. package/src/ai/runtime/model-refs.js +30 -0
  44. package/src/ai/runtime/registry.js +29 -0
  45. package/src/ai/runtime/router.js +96 -12
  46. package/src/ai/runtime/session-liveness.js +110 -0
  47. package/src/ai/runtime/sessions.js +16 -0
  48. package/src/ai/types.js +252 -19
  49. package/src/index.js +1 -1
  50. package/src/pi-auth.js +147 -16
  51. package/src/runtime-brand.js +21 -1
  52. package/src/runtime.js +72 -8
  53. package/types/agent/allowlists.d.ts +25 -0
  54. package/types/agent/approval.d.ts +30 -0
  55. package/types/agent/compaction.d.ts +97 -0
  56. package/types/agent/index.d.ts +5 -0
  57. package/types/agent/prompt/skill-index.d.ts +19 -0
  58. package/types/agent/sandbox-seam.d.ts +148 -0
  59. package/types/agent/tool-bloat.d.ts +22 -0
  60. package/types/agent/tools/bash.d.ts +16 -0
  61. package/types/agent/tools/edit.d.ts +14 -0
  62. package/types/agent/tools/glob.d.ts +16 -0
  63. package/types/agent/tools/grep.d.ts +22 -0
  64. package/types/agent/tools/index.d.ts +10 -0
  65. package/types/agent/tools/pi-bridge.d.ts +144 -0
  66. package/types/agent/tools/read.d.ts +19 -0
  67. package/types/agent/tools/shared/constants.d.ts +13 -0
  68. package/types/agent/tools/shared/dedup.d.ts +8 -0
  69. package/types/agent/tools/shared/output-truncation.d.ts +22 -0
  70. package/types/agent/tools/shared/path-resolver.d.ts +6 -0
  71. package/types/agent/tools/shared/ripgrep.d.ts +38 -0
  72. package/types/agent/tools/shared/runtime-context.d.ts +27 -0
  73. package/types/agent/tools/shared/tool-context.d.ts +48 -0
  74. package/types/agent/tools/web-fetch.d.ts +13 -0
  75. package/types/agent/tools/web-search.d.ts +11 -0
  76. package/types/agent/tools/write.d.ts +12 -0
  77. package/types/agent/transcript.d.ts +45 -0
  78. package/types/ai/backend.d.ts +41 -0
  79. package/types/ai/cost.d.ts +96 -0
  80. package/types/ai/failure.d.ts +117 -0
  81. package/types/ai/file-change-stats.d.ts +75 -0
  82. package/types/ai/index.d.ts +7 -0
  83. package/types/ai/live-input-prompt.d.ts +6 -0
  84. package/types/ai/observer.d.ts +68 -0
  85. package/types/ai/providers/claude-cli.d.ts +211 -0
  86. package/types/ai/providers/claude-sdk.d.ts +66 -0
  87. package/types/ai/providers/claude-subagents.d.ts +2 -0
  88. package/types/ai/providers/codex-app.d.ts +151 -0
  89. package/types/ai/providers/opencode-app.d.ts +95 -0
  90. package/types/ai/providers/opencode-discovery.d.ts +4 -0
  91. package/types/ai/providers/pi-errors.d.ts +3 -0
  92. package/types/ai/providers/pi-events.d.ts +24 -0
  93. package/types/ai/providers/pi-messages.d.ts +5 -0
  94. package/types/ai/providers/pi-models.d.ts +57 -0
  95. package/types/ai/providers/pi-native/compaction-driver.d.ts +86 -0
  96. package/types/ai/providers/pi-native/result-builder.d.ts +168 -0
  97. package/types/ai/providers/pi-native/session-lifecycle.d.ts +62 -0
  98. package/types/ai/providers/pi-native/stream-subscriber.d.ts +50 -0
  99. package/types/ai/providers/pi-native/structured-output.d.ts +69 -0
  100. package/types/ai/providers/pi-native/turn-runner.d.ts +60 -0
  101. package/types/ai/providers/pi-native.d.ts +18 -0
  102. package/types/ai/registry.d.ts +1 -0
  103. package/types/ai/runtime/capabilities-used.d.ts +21 -0
  104. package/types/ai/runtime/capabilities.d.ts +33 -0
  105. package/types/ai/runtime/context-windows.d.ts +8 -0
  106. package/types/ai/runtime/fast-mode.d.ts +2 -0
  107. package/types/ai/runtime/model-refs.d.ts +35 -0
  108. package/types/ai/runtime/registry.d.ts +38 -0
  109. package/types/ai/runtime/router.d.ts +56 -0
  110. package/types/ai/runtime/session-liveness.d.ts +55 -0
  111. package/types/ai/runtime/sessions.d.ts +38 -0
  112. package/types/ai/streaming/codex-events.d.ts +30 -0
  113. package/types/ai/streaming/opencode-events.d.ts +41 -0
  114. package/types/ai/types.d.ts +702 -0
  115. package/types/index.d.ts +7 -0
  116. package/types/pi-auth.d.ts +28 -0
  117. package/types/runtime-brand.d.ts +30 -0
  118. package/types/runtime.d.ts +10 -0
  119. package/src/ai/providers/pi-sdk.js +0 -35
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  // Context-compaction policy + heuristics for the pi-native bridge.
2
4
  //
3
5
  // The hand-rolled in-loop compaction manager (transformContext/afterToolCall)
@@ -10,6 +12,28 @@
10
12
  // - isLikelyContextTermination: classifies a provider error/termination as a
11
13
  // context-pressure event.
12
14
 
15
+ /**
16
+ * @typedef {Object} AgentCompactionPolicy
17
+ * @property {boolean} enabled
18
+ * @property {number} contextWindow
19
+ * @property {number} triggerRatio
20
+ * @property {number} triggerTokens
21
+ * @property {number} keepRecentTokens
22
+ * @property {number} summaryMaxTokens
23
+ * @property {boolean} fixedOverheadEnabled
24
+ * @property {number} compactionMinSavingsTokens
25
+ * @property {number} toolPayloadCompactionTriggerChars
26
+ * @property {number} toolPruneTriggerTokens
27
+ * @property {number} toolTextLimitChars
28
+ * @property {number} bashOutputLimitChars
29
+ * @property {number} mcpTextLimitChars
30
+ * @property {number} searchResultLimit
31
+ * @property {number} imageInlineMaxBytes
32
+ * @property {number} toolPayloadMaxBytes
33
+ * @property {number} mcpCallTimeoutMs
34
+ * @property {number} mcpCallMaxTotalTimeoutMs
35
+ */
36
+
13
37
  const DEFAULT_CONTEXT_WINDOW = 128000;
14
38
  const DEFAULT_TRIGGER_RATIO = 0.85;
15
39
  const DEFAULT_KEEP_RECENT_TOKENS = 24000;
@@ -25,20 +49,49 @@ const DEFAULT_TOOL_TEXT_LIMIT_CHARS = 64000;
25
49
  const DEFAULT_BASH_OUTPUT_LIMIT_CHARS = 64000;
26
50
  const DEFAULT_MCP_TEXT_LIMIT_CHARS = 48000;
27
51
  const DEFAULT_SEARCH_RESULT_LIMIT = 100;
28
- const DEFAULT_IMAGE_INLINE_MAX_BYTES = 250000;
52
+ // Images are returned to vision models whole (a Read of an image attachment, an
53
+ // MCP screenshot). The byte size is large but token cost is driven by image
54
+ // tokens, not base64 length, so allow multi-MB screenshots through instead of
55
+ // clipping them to a "[truncated]" summary the model can't see. Clamp ceiling
56
+ // (10MB) is enforced in resolveAgentCompactionPolicy.
57
+ const DEFAULT_IMAGE_INLINE_MAX_BYTES = 5_000_000;
29
58
  const DEFAULT_TOOL_PAYLOAD_MAX_BYTES = 262144;
30
59
  const DEFAULT_MCP_CALL_TIMEOUT_MS = 120000;
60
+ // Hard wall clock for a single MCP tool call. Progress notifications reset the
61
+ // inactivity timeout above but must never extend a call past this cap (45 min) —
62
+ // sized for legitimately long tools (audio transcription, ask-the-user waits).
63
+ const DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 2_700_000;
31
64
 
65
+ /**
66
+ * @param {*} value
67
+ * @param {number} fallback
68
+ * @param {number} min
69
+ * @param {number} max
70
+ * @returns {number}
71
+ */
32
72
  function clampNumber(value, fallback, min, max) {
33
73
  const n = Number(value);
34
74
  if (!Number.isFinite(n)) return fallback;
35
75
  return Math.min(Math.max(n, min), max);
36
76
  }
37
77
 
78
+ /**
79
+ * @param {*} value
80
+ * @param {number} fallback
81
+ * @param {number} min
82
+ * @param {number} max
83
+ * @returns {number}
84
+ */
38
85
  function clampInteger(value, fallback, min, max) {
39
86
  return Math.floor(clampNumber(value, fallback, min, max));
40
87
  }
41
88
 
89
+ /**
90
+ * @param {Object<string, *>} [settings]
91
+ * @param {Object} [model]
92
+ * @param {number} [model.contextWindow]
93
+ * @returns {AgentCompactionPolicy}
94
+ */
42
95
  export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
43
96
  const contextWindow = clampInteger(model?.contextWindow, DEFAULT_CONTEXT_WINDOW, 32000, 10_000_000);
44
97
  const triggerRatio = clampNumber(
@@ -57,6 +110,11 @@ export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
57
110
  triggerTokens: Math.min(ratioTrigger, reserveTrigger),
58
111
  keepRecentTokens: clampInteger(settings.agent_compaction_keep_recent_tokens, DEFAULT_KEEP_RECENT_TOKENS, 4000, 200000),
59
112
  summaryMaxTokens: clampInteger(settings.agent_compaction_summary_max_tokens, DEFAULT_SUMMARY_MAX_TOKENS, 1000, 64000),
113
+ // ON by default; the proactive fixed-overhead correction (system prompt +
114
+ // tool schemas + per-turn message) is disabled only when explicitly false.
115
+ // Read by the compaction driver off the resolved policy so it never has to
116
+ // re-sniff the raw settings/policy inputs.
117
+ fixedOverheadEnabled: settings.agent_compaction_fixed_overhead_enabled !== false,
60
118
  compactionMinSavingsTokens: clampInteger(settings.agent_compaction_min_savings_tokens, DEFAULT_MIN_SAVINGS_TOKENS, 0, 500000),
61
119
  toolPayloadCompactionTriggerChars: clampInteger(
62
120
  settings.agent_tool_payload_compaction_trigger_chars,
@@ -72,9 +130,194 @@ export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
72
130
  imageInlineMaxBytes: clampInteger(settings.agent_image_inline_max_bytes, DEFAULT_IMAGE_INLINE_MAX_BYTES, 0, 10 * 1024 * 1024),
73
131
  toolPayloadMaxBytes: clampInteger(settings.agent_tool_payload_max_bytes, DEFAULT_TOOL_PAYLOAD_MAX_BYTES, 0, 16 * 1024 * 1024),
74
132
  mcpCallTimeoutMs: clampInteger(settings.agent_mcp_call_timeout_ms, DEFAULT_MCP_CALL_TIMEOUT_MS, 1000, Number.MAX_SAFE_INTEGER),
133
+ mcpCallMaxTotalTimeoutMs: clampInteger(
134
+ settings.agent_mcp_call_max_total_timeout_ms,
135
+ DEFAULT_MCP_CALL_MAX_TOTAL_TIMEOUT_MS,
136
+ 1000,
137
+ Number.MAX_SAFE_INTEGER,
138
+ ),
139
+ };
140
+ }
141
+
142
+ // --- Typed policy objects <-> deprecated `settings` shim -------------------
143
+ //
144
+ // RuntimeRunOptions now carries typed `toolLimits` / `compaction` policy
145
+ // objects. The DEPRECATED `settings` bag remains a per-group fallback: it is
146
+ // consumed only when the corresponding typed object is ABSENT, and consuming it
147
+ // surfaces one `deprecated_settings_option` runtime_warning per run.
148
+ //
149
+ // resolveAgentCompactionPolicy stays the canonical settings->policy clamp/mapper
150
+ // (its signature is unchanged — worklab deep-imports it). The helpers below
151
+ // project the typed objects back onto the same snake_case settings keys so the
152
+ // resolution goes through that one clamp path unchanged, whether the values came
153
+ // from a typed object or the legacy settings bag.
154
+
155
+ // Typed toolLimits field -> settings key. `bashTimeoutMs` is intentionally
156
+ // ABSENT: no `agent_bash_*_timeout` setting exists today and this phase does not
157
+ // invent new timeout behavior, so the field is documented on the RuntimeToolLimits
158
+ // typedef but not wired through the settings shim or any tool.
159
+ const TOOL_LIMIT_SETTINGS_KEYS = /** @type {const} */ ({
160
+ toolTextLimitChars: "agent_tool_text_limit_chars",
161
+ bashOutputLimitChars: "agent_bash_output_limit_chars",
162
+ mcpTextLimitChars: "agent_mcp_text_limit_chars",
163
+ searchResultLimit: "agent_search_result_limit",
164
+ imageInlineMaxBytes: "agent_image_inline_max_bytes",
165
+ toolPayloadMaxBytes: "agent_tool_payload_max_bytes",
166
+ mcpCallTimeoutMs: "agent_mcp_call_timeout_ms",
167
+ mcpCallMaxTotalTimeoutMs: "agent_mcp_call_max_total_timeout_ms",
168
+ });
169
+
170
+ // Typed compaction field -> settings key. `contextWindowOverride` is ABSENT: it
171
+ // has no legacy settings equivalent and is applied directly at the live-window
172
+ // resolution site (resolveLiveCompactionPolicy), not through this shim.
173
+ const COMPACTION_SETTINGS_KEYS = /** @type {const} */ ({
174
+ enabled: "agent_compaction_enabled",
175
+ triggerRatio: "agent_compaction_trigger_ratio",
176
+ keepRecentTokens: "agent_compaction_keep_recent_tokens",
177
+ summaryMaxTokens: "agent_compaction_summary_max_tokens",
178
+ minSavingsTokens: "agent_compaction_min_savings_tokens",
179
+ fixedOverheadEnabled: "agent_compaction_fixed_overhead_enabled",
180
+ });
181
+
182
+ export const DEPRECATED_SETTINGS_WARNING_KIND = "deprecated_settings_option";
183
+
184
+ /**
185
+ * @param {Object<string, *>|null|undefined} group
186
+ * @param {Record<string, string>} keyMap
187
+ * @param {Object<string, *>} out
188
+ */
189
+ function copyTypedGroup(group, keyMap, out) {
190
+ for (const [field, settingKey] of Object.entries(keyMap)) {
191
+ if (group && group[field] !== undefined) out[settingKey] = group[field];
192
+ }
193
+ }
194
+
195
+ /**
196
+ * @param {Object<string, *>|null|undefined} settings
197
+ * @param {Record<string, string>} keyMap
198
+ * @param {Object<string, *>} out
199
+ * @param {Array<string>} consumed
200
+ */
201
+ function copySettingsGroup(settings, keyMap, out, consumed) {
202
+ if (!settings || typeof settings !== "object") return;
203
+ for (const settingKey of Object.values(keyMap)) {
204
+ if (settings[settingKey] !== undefined) {
205
+ out[settingKey] = settings[settingKey];
206
+ consumed.push(settingKey);
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Fold the typed `toolLimits` / `compaction` policy objects and the deprecated
213
+ * `settings` bag into ONE settings-like object resolveAgentCompactionPolicy
214
+ * consumes, honoring PER-GROUP precedence: when a typed object is present its
215
+ * fields win and the legacy settings keys for that group are ignored entirely;
216
+ * when the typed object is absent, that group's settings keys are consumed (and
217
+ * reported in `consumedSettingsKeys` so the caller can emit exactly one
218
+ * deprecation warning per run).
219
+ * @param {{toolLimits?: Object<string, *>, compaction?: Object<string, *>, settings?: Object<string, *>}} [options]
220
+ * @returns {{settingsLike: Object<string, *>, consumedSettingsKeys: Array<string>}}
221
+ */
222
+ export function resolveRuntimePolicyInputs({ toolLimits, compaction, settings } = {}) {
223
+ /** @type {Object<string, *>} */
224
+ const settingsLike = {};
225
+ /** @type {Array<string>} */
226
+ const consumedSettingsKeys = [];
227
+
228
+ if (toolLimits && typeof toolLimits === "object") {
229
+ copyTypedGroup(toolLimits, TOOL_LIMIT_SETTINGS_KEYS, settingsLike);
230
+ } else {
231
+ copySettingsGroup(settings, TOOL_LIMIT_SETTINGS_KEYS, settingsLike, consumedSettingsKeys);
232
+ }
233
+
234
+ if (compaction && typeof compaction === "object") {
235
+ copyTypedGroup(compaction, COMPACTION_SETTINGS_KEYS, settingsLike);
236
+ } else {
237
+ copySettingsGroup(settings, COMPACTION_SETTINGS_KEYS, settingsLike, consumedSettingsKeys);
238
+ }
239
+
240
+ return { settingsLike, consumedSettingsKeys };
241
+ }
242
+
243
+ /**
244
+ * Build the one-per-run deprecation warning fired when the legacy `settings`
245
+ * bag was consumed as a policy fallback. Shape matches the other pi/claude
246
+ * bridge runtime warnings ({warning_kind, source, message}).
247
+ * @param {ReadonlyArray<string>} consumedKeys
248
+ * @returns {{warning_kind: string, source: string, message: string, settings_keys: Array<string>}}
249
+ */
250
+ export function deprecatedSettingsWarning(consumedKeys) {
251
+ const keys = Array.from(consumedKeys || []);
252
+ return {
253
+ warning_kind: DEPRECATED_SETTINGS_WARNING_KIND,
254
+ source: "runtime",
255
+ message:
256
+ "runOptions.settings is deprecated; pass the typed `toolLimits` / `compaction` policy objects instead "
257
+ + "(host migration helper: resolveRuntimePolicies in @mono-agent/runtime-adapter). Consumed settings keys: "
258
+ + `${keys.join(", ")}.`,
259
+ settings_keys: keys,
260
+ };
261
+ }
262
+
263
+ // Estimate the FIXED per-request overhead the provider meters but the raw
264
+ // transcript estimate excludes: the system prompt, the tool/MCP schemas, and the
265
+ // per-turn user message(s). estimateCurrentContextTokens' raw branch sums ONLY
266
+ // session.buildContext().messages (the transcript), so on a seeded session whose
267
+ // last-assistant usage is stale/0 the proactive-compaction trigger under-counts
268
+ // and under-fires, letting the real request overflow the window. Adding this
269
+ // overhead to the raw estimate makes the trigger reflect what the provider counts.
270
+ //
271
+ // Uses Math.ceil(len/4) to mirror pi-ai's chars/4 heuristic — consistency with
272
+ // the transcript estimate matters more than precision. Pure + dependency-free.
273
+ /**
274
+ * @param {Object} [options]
275
+ * @param {string} [options.systemPrompt]
276
+ * @param {Array<Object>} [options.tools]
277
+ * @param {Array<Object>} [options.messages]
278
+ * @returns {{systemPromptTokens: number, toolSchemaTokens: number, userMessageTokens: number, fixedOverheadTokens: number}}
279
+ */
280
+ export function estimateFixedOverheadTokens({ systemPrompt, tools, messages } = {}) {
281
+ const tokensForChars = (value) => Math.ceil(String(value ?? "").length / 4);
282
+
283
+ const systemPromptTokens = tokensForChars(systemPrompt);
284
+
285
+ let toolSchemaTokens = 0;
286
+ for (const tool of Array.isArray(tools) ? tools : []) {
287
+ try {
288
+ const serialized = JSON.stringify({
289
+ name: tool?.name,
290
+ description: tool?.description,
291
+ parameters: tool?.parameters ?? tool?.inputSchema ?? {},
292
+ });
293
+ toolSchemaTokens += tokensForChars(serialized);
294
+ } catch {
295
+ // Circular/unserializable tool schema — count it as 0 rather than throw.
296
+ }
297
+ }
298
+
299
+ let userMessageTokens = 0;
300
+ for (const message of Array.isArray(messages) ? messages : []) {
301
+ try {
302
+ userMessageTokens += tokensForChars(JSON.stringify(message?.content ?? ""));
303
+ } catch {
304
+ // Unserializable content — count it as 0 rather than throw.
305
+ }
306
+ }
307
+
308
+ return {
309
+ systemPromptTokens,
310
+ toolSchemaTokens,
311
+ userMessageTokens,
312
+ fixedOverheadTokens: systemPromptTokens + toolSchemaTokens + userMessageTokens,
75
313
  };
76
314
  }
77
315
 
316
+ /**
317
+ * @param {string} message
318
+ * @param {Object<string, *>} [diagnostics]
319
+ * @returns {boolean}
320
+ */
78
321
  export function isLikelyContextTermination(message, diagnostics = {}) {
79
322
  const text = String(message || "");
80
323
  if (!/terminated|aborted before final output|aborted before final|stream.*aborted|context window|context budget/i.test(text)) return false;
@@ -23,6 +23,9 @@ export function getSkillAccessDirs(skills = []) {
23
23
  return [...new Set(skills.map(skillDir).filter(Boolean))];
24
24
  }
25
25
 
26
+ /**
27
+ * @param {{assetsPath?: string, skillsRoot?: any}} [options]
28
+ */
26
29
  export function buildSkillPathNote({ assetsPath, skillsRoot } = {}) {
27
30
  const lines = [];
28
31
  if (skillsRoot) lines.push(`Configured skills root: ${resolve(skillsRoot)}`);
@@ -31,6 +34,9 @@ export function buildSkillPathNote({ assetsPath, skillsRoot } = {}) {
31
34
  return lines.join("\n");
32
35
  }
33
36
 
37
+ /**
38
+ * @param {{body?: string, assetsPath?: string, skillsRoot?: any, maxChars?: number}} [options]
39
+ */
34
40
  export function formatSkillBodyWithPathNote({ body, assetsPath, skillsRoot, maxChars = 12000 } = {}) {
35
41
  const text = [
36
42
  buildSkillPathNote({ assetsPath, skillsRoot }),
@@ -0,0 +1,227 @@
1
+ // Injectable sandbox seam.
2
+ //
3
+ // agent-runtime is a provider-agnostic kernel. This module removes its last
4
+ // workspace dependency by turning "how commands get sandboxed" into data a
5
+ // host hands the kernel, not code the kernel imports. A host constructs a
6
+ // `RuntimeSandbox` implementation and passes it to `createRuntime({sandbox})`
7
+ // (see runtime.js); every internal tool helper that used to import the sandbox
8
+ // implementation directly (bash.js, pi-bridge.js's MCP-stdio launcher,
9
+ // web-fetch.js, web-search.js,
10
+ // tool-context.js's policy merge) now reads `ctx.sandbox` and calls through
11
+ // this interface instead.
12
+ //
13
+ // `passthroughSandbox` is the zero-dependency default every ToolContext gets
14
+ // when no host supplies one (createToolContext/resetToolContext in
15
+ // ./tools/shared/tool-context.js). Its contract covers both no-injected-sandbox
16
+ // paths equally:
17
+ // - No policy at all (both sides of a merge undefined, or a resolved policy
18
+ // that is undefined): every operation is allowed, byte-identical to
19
+ // running with no sandbox implementation installed at all.
20
+ // - A policy IS present and its `mode` demands real enforcement ("native")
21
+ // but nothing was injected to actually enforce it: FAIL CLOSED. This is a
22
+ // deliberate behavior change from before this seam existed (where the
23
+ // real sandbox implementation was bundled in agent-runtime and always did
24
+ // the enforcing) — a host that configures a policy without wiring a real
25
+ // RuntimeSandbox implementation used to get silent, unenforced
26
+ // "enforcement"; now it gets a loud, actionable error instead. See
27
+ // MIGRATION.md.
28
+ // - `mergePolicies` still keeps I13's monotonic guarantee (a request-scoped
29
+ // policy can only tighten, never weaken, the host-configured one) for the
30
+ // two axes the passthrough itself inspects (`mode`, `network.mode`). It
31
+ // is deliberately SHALLOW — no readable/writableRoots intersection, no
32
+ // denyWrite union — the real, byte-identical merge algorithm is owned by
33
+ // @mono-agent/runtime-adapter in packages/runtime-adapter/src/sandbox.ts
34
+ // (`mergeSandboxPolicies`) and is wired in by every mono-agent host through
35
+ // runtime-adapter.
36
+ //
37
+ // Real hosts (mono-agent, via @mono-agent/runtime-adapter) inject
38
+ // `{mergePolicies: mergeSandboxPolicies, prepareCommand: prepareSandboxedCommand,
39
+ // networkAllowsUrl: networkPolicyAllowsUrl}` from packages/runtime-adapter/src/sandbox.ts.
40
+ // Those exports already conform to this interface exactly, so the injection is a direct pass-through (see runtime-adapter's
41
+ // sandbox-impl.ts for the thin TS-side adapter that satisfies the seam's
42
+ // type shape).
43
+
44
+ // @ts-check
45
+
46
+ /**
47
+ * @typedef {Object} SandboxCommandSpec
48
+ * @property {string} command
49
+ * @property {ReadonlyArray<string>} [args]
50
+ * @property {string} [cwd]
51
+ * @property {Object<string, string|undefined>} [env]
52
+ */
53
+
54
+ /**
55
+ * @typedef {SandboxCommandSpec & {sandboxed: boolean, cleanup?: () => Promise<void>}} PreparedSandboxCommand
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} SandboxNetworkPolicyLike
60
+ * @property {string} [mode]
61
+ * @property {ReadonlyArray<string>} [allowlist]
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} SandboxPolicy
66
+ * Opaque, host-defined sandbox policy. The kernel itself only ever inspects
67
+ * `mode` (passthroughSandbox's fail-closed check) and `network.mode`
68
+ * (passthroughSandbox's networkAllowsUrl) — every other field (root,
69
+ * readableRoots, writableRoots, denyWrite, ...) is read directly off whatever
70
+ * the injected `mergePolicies` returns by path-resolver.js, and is opaque
71
+ * pass-through data as far as this module is concerned. Real hosts get the
72
+ * full, richly-typed policy from @mono-agent/runtime-adapter
73
+ * (`packages/runtime-adapter/src/sandbox.ts`), whose `SandboxPolicy` is a
74
+ * structural superset of this shape.
75
+ * @property {string} [mode]
76
+ * @property {SandboxNetworkPolicyLike} [network]
77
+ * @property {ReadonlyArray<string>} [readableRoots]
78
+ * @property {ReadonlyArray<string>} [writableRoots]
79
+ * @property {ReadonlyArray<string>} [denyWrite]
80
+ * @property {string} [root]
81
+ */
82
+
83
+ /**
84
+ * @typedef {Object} RuntimeSandboxEngine
85
+ * Optional concrete sandboxing backend a caller can hand to `prepareCommand`
86
+ * (matches @mono-agent/runtime-adapter's `SandboxEngine`). Not used by
87
+ * `passthroughSandbox` (see module doc: adapters live in runtime-adapter, not
88
+ * the kernel) — documented here only because it is part of the
89
+ * `prepareCommand` input shape real implementations accept.
90
+ * @property {() => Promise<boolean>} isAvailable
91
+ * @property {(command: SandboxCommandSpec, policy: SandboxPolicy) => Promise<PreparedSandboxCommand>} prepareCommand
92
+ */
93
+
94
+ /**
95
+ * @typedef {Object} PrepareSandboxedCommandInput
96
+ * @property {SandboxPolicy} [policy]
97
+ * @property {RuntimeSandboxEngine} [engine]
98
+ * @property {SandboxCommandSpec} command
99
+ */
100
+
101
+ /**
102
+ * @typedef {Object} RuntimeSandbox
103
+ * The injectable sandbox seam. A host constructs one (or reuses
104
+ * `passthroughSandbox`) and passes it to `createRuntime({sandbox})`.
105
+ * @property {(configured: (SandboxPolicy|undefined), request: (SandboxPolicy|undefined)) => (SandboxPolicy|undefined)} mergePolicies
106
+ * Monotonic tighten-only merge (I13): the result must never allow anything
107
+ * `configured` alone would have denied.
108
+ * @property {(input: PrepareSandboxedCommandInput) => Promise<PreparedSandboxCommand>} prepareCommand
109
+ * Resolves a command spec into whatever the underlying process spawner
110
+ * should actually exec (identity when no enforcement is needed).
111
+ * @property {(policy: (SandboxPolicy|undefined), url: string) => boolean} networkAllowsUrl
112
+ * Whether a tool call to `url` is allowed under `policy`.
113
+ * @property {ReadonlyArray<string>} [additionalReadPaths]
114
+ * Optional host-provided extra read-allowed roots beyond the tool
115
+ * context's workspace/repoRoot. Not consumed by any kernel helper today;
116
+ * documented for forward-compatibility with host-side path resolution.
117
+ */
118
+
119
+ const SANDBOX_UNAVAILABLE_CODE = "sandbox_unavailable";
120
+
121
+ /**
122
+ * Plain Error with a `.code`/`.details` shape matching the workspace's
123
+ * CodedError convention (code discriminant + details that echo it, see the
124
+ * agent-contracts package's CodedError) — duplicated here rather than
125
+ * imported, since the kernel must not depend on any workspace package.
126
+ */
127
+ class SandboxUnavailableError extends Error {
128
+ /**
129
+ * @param {string} message
130
+ * @param {Record<string, unknown>} [details]
131
+ */
132
+ constructor(message, details = {}) {
133
+ super(message);
134
+ this.name = "SandboxUnavailableError";
135
+ this.code = SANDBOX_UNAVAILABLE_CODE;
136
+ this.details = { ...details, code: SANDBOX_UNAVAILABLE_CODE };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * @param {string|undefined} mode
142
+ * @returns {boolean}
143
+ */
144
+ function isRealSandboxMode(mode) {
145
+ return typeof mode === "string" && mode !== "off";
146
+ }
147
+
148
+ /**
149
+ * @param {SandboxNetworkPolicyLike|undefined} configured
150
+ * @param {SandboxNetworkPolicyLike|undefined} request
151
+ * @returns {SandboxNetworkPolicyLike|undefined}
152
+ */
153
+ function mergeNetwork(configured, request) {
154
+ if (configured === undefined) return request;
155
+ if (request === undefined) return configured;
156
+ // The passthrough only distinguishes "all" (unrestricted) from everything
157
+ // else (restricted); whichever side is NOT "all" tightens the result.
158
+ if (configured.mode === "all" && request.mode === "all") return { mode: "all" };
159
+ return configured.mode === "all" ? request : configured;
160
+ }
161
+
162
+ /**
163
+ * @param {SandboxPolicy|undefined} configured
164
+ * @param {SandboxPolicy|undefined} request
165
+ * @returns {SandboxPolicy|undefined}
166
+ */
167
+ function mergePolicies(configured, request) {
168
+ if (configured === undefined) return request;
169
+ if (request === undefined) return configured;
170
+ // Only two modes exist today ("native" | "off"); "native" tightens over
171
+ // "off" regardless of which side introduced it.
172
+ const configuredIsReal = isRealSandboxMode(configured.mode);
173
+ const requestIsReal = isRealSandboxMode(request.mode);
174
+ const mode = configuredIsReal ? configured.mode : (requestIsReal ? request.mode : (request.mode ?? configured.mode));
175
+ return {
176
+ ...configured,
177
+ ...request,
178
+ mode,
179
+ network: mergeNetwork(configured.network, request.network),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Identity command preparation — no sandboxing infrastructure of its own.
185
+ * Fails closed the moment a policy actually demands enforcement, rather than
186
+ * silently returning an unsandboxed command under a policy that looks
187
+ * enforced.
188
+ * @param {PrepareSandboxedCommandInput} input
189
+ * @returns {Promise<PreparedSandboxCommand>}
190
+ */
191
+ async function prepareCommand({ policy, command }) {
192
+ if (policy === undefined || !isRealSandboxMode(policy.mode)) {
193
+ return { ...command, args: command.args ?? [], cwd: command.cwd ?? process.cwd(), sandboxed: false };
194
+ }
195
+ throw new SandboxUnavailableError(
196
+ "Sandbox policy requires enforcement (mode !== \"off\") but no RuntimeSandbox implementation is configured. "
197
+ + "Inject a real implementation via createRuntime({sandbox}) (mono-agent hosts get this automatically through "
198
+ + "the runtime-adapter package) or relax the policy.",
199
+ { mode: policy.mode, command: command.command },
200
+ );
201
+ }
202
+
203
+ /**
204
+ * @param {SandboxPolicy|undefined} policy
205
+ * @param {string} _url
206
+ * @returns {boolean}
207
+ */
208
+ function networkAllowsUrl(policy, _url) {
209
+ if (policy === undefined) return true;
210
+ // A policy whose mode demands real enforcement must fail closed on network
211
+ // access too when no `network` sub-field was given — an absent `network`
212
+ // is not "all", and this is the passthrough's safety net for hand-built
213
+ // policies from hosts with no real RuntimeSandbox wired in (see module
214
+ // doc's fail-closed rationale, and prepareCommand above for the analogous
215
+ // command-side check this mirrors via isRealSandboxMode).
216
+ if (isRealSandboxMode(policy.mode)) return policy.network?.mode === "all";
217
+ return policy.network === undefined || policy.network.mode === "all";
218
+ }
219
+
220
+ /** @type {RuntimeSandbox} */
221
+ export const passthroughSandbox = {
222
+ mergePolicies,
223
+ prepareCommand,
224
+ networkAllowsUrl,
225
+ };
226
+
227
+ export { SandboxUnavailableError, SANDBOX_UNAVAILABLE_CODE };
@@ -88,12 +88,17 @@ function summaryText(toolName, originalBytes, maxBytes, savedPaths) {
88
88
  export function summarisePayload(toolName, contentBlocks, persistArtifact, options = {}) {
89
89
  const {
90
90
  maxBytes = MAX_TOOL_RESULT_BYTES,
91
+ imageMaxBytes = maxBytes,
91
92
  toolUseId = null,
92
93
  now = Date.now,
93
94
  } = options;
94
95
  const blocks = Array.isArray(contentBlocks) ? contentBlocks : [];
95
96
  const originalBytes = totalBytes(blocks);
96
- if (originalBytes <= maxBytes) {
97
+ // Images get their own (typically larger) budget so a vision model can still
98
+ // see large screenshots; text/other payloads stay bound by maxBytes.
99
+ const imageBytes = blocks.reduce((sum, block) => sum + (block?.type === "image" ? blockBytes(block) : 0), 0);
100
+ const otherBytes = originalBytes - imageBytes;
101
+ if (otherBytes <= maxBytes && imageBytes <= imageMaxBytes) {
97
102
  return { rewrittenBlocks: blocks, savedPaths: [], originalBytes, truncated: false };
98
103
  }
99
104
 
@@ -117,11 +122,12 @@ export async function applyToolBloatGuard(toolName, executePromise, options = {}
117
122
  persistArtifact = null,
118
123
  toolUseId = null,
119
124
  maxBytes = MAX_TOOL_RESULT_BYTES,
125
+ imageMaxBytes = maxBytes,
120
126
  onTruncate = null,
121
127
  } = options;
122
128
  const result = await executePromise;
123
129
  if (!result || typeof result !== "object" || !Array.isArray(result.content)) return result;
124
- const summary = summarisePayload(toolName, result.content, persistArtifact, { maxBytes, toolUseId });
130
+ const summary = summarisePayload(toolName, result.content, persistArtifact, { maxBytes, imageMaxBytes, toolUseId });
125
131
  if (!summary.truncated) return result;
126
132
  if (typeof onTruncate === "function") {
127
133
  try {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { prepareSandboxedCommand } from "@mono-agent/sandbox";
3
+ import { passthroughSandbox } from "../sandbox-seam.js";
4
4
  import { DEFAULT_MAX_BASH_OUTPUT_CHARS } from "./shared/constants.js";
5
5
  import { capChars } from "./shared/output-truncation.js";
6
6
  import {
@@ -8,7 +8,8 @@ import {
8
8
  isWorkdirAllowed,
9
9
  workspaceRoot,
10
10
  } from "./shared/path-resolver.js";
11
- import { resolveSandboxPolicy } from "./shared/runtime-context.js";
11
+ import { readToolRuntime } from "./shared/runtime-context.js";
12
+ import { resolveSandboxPolicy } from "./shared/tool-context.js";
12
13
 
13
14
  const DEFAULT_BASH_TIMEOUT_MS = 120000;
14
15
  const BASH_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@@ -112,18 +113,24 @@ function runCommand(commandSpec, { timeoutMs, signal, maxBufferBytes = BASH_MAX_
112
113
  });
113
114
  }
114
115
 
115
- export async function bashToolImpl({ command, timeout = DEFAULT_BASH_TIMEOUT_MS, max_output_chars, workdir }, { signal, sandboxPolicy, sandboxEngine } = {}) {
116
- const policy = resolveSandboxPolicy(sandboxPolicy);
117
- const pathOptions = { sandboxPolicy: policy };
116
+ /**
117
+ * @param {{command: string, timeout?: number, max_output_chars?: number, workdir?: string}} params
118
+ * @param {{signal?: any, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
119
+ */
120
+ export async function bashToolImpl({ command, timeout = DEFAULT_BASH_TIMEOUT_MS, max_output_chars, workdir }, { signal, sandboxPolicy, sandboxEngine, ctx } = {}) {
121
+ const resolvedCtx = ctx ?? readToolRuntime();
122
+ const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
123
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
124
+ const pathOptions = { sandboxPolicy: policy, ctx };
118
125
  if (workdir && !isWorkdirAllowed(workdir, pathOptions)) return `Error: Working directory not allowed: ${workdir}`;
119
- const cwd = workspaceRoot(workdir);
126
+ const cwd = workspaceRoot(workdir, ctx);
120
127
  if (!isPathAllowed(cwd, workdir, pathOptions)) return `Error: Working directory not allowed: ${cwd}`;
121
128
  if (!existsSync(cwd)) return `Error: Working directory not found: ${cwd}`;
122
129
  const maxChars = Number(max_output_chars) || DEFAULT_MAX_BASH_OUTPUT_CHARS;
123
130
  const timeoutMs = normalizeBashTimeoutMs(timeout);
124
131
  let prepared;
125
132
  try {
126
- prepared = await prepareSandboxedCommand({
133
+ prepared = await sandbox.prepareCommand({
127
134
  policy,
128
135
  engine: sandboxEngine ?? undefined,
129
136
  command: { command: "/bin/bash", args: ["-lc", command], cwd },
@@ -146,11 +153,12 @@ export async function bashToolImpl({ command, timeout = DEFAULT_BASH_TIMEOUT_MS,
146
153
  label: "Bash",
147
154
  maxChars,
148
155
  strategy: "head_tail",
156
+ ctx,
149
157
  });
150
158
  }
151
159
  if (result.signal) return `Exit code 1:\nCommand terminated by ${result.signal}`;
152
160
  const output = result.stdout && result.stderr
153
161
  ? `STDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`
154
162
  : (result.stdout || result.stderr || "(no output)");
155
- return capChars(output, { label: "Bash", maxChars, strategy: "head_tail" });
163
+ return capChars(output, { label: "Bash", maxChars, strategy: "head_tail", ctx });
156
164
  }
@@ -1,9 +1,13 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isPathAllowed, isWritablePathAllowed, resolveToolPath } from "./shared/path-resolver.js";
3
3
 
4
- export async function editToolImpl({ file_path, old_string, new_string, replace_all = false, workdir }, { sandboxPolicy } = {}) {
5
- const target = resolveToolPath(file_path, workdir);
6
- const pathOptions = { sandboxPolicy };
4
+ /**
5
+ * @param {{file_path: string, old_string: string, new_string: string, replace_all?: boolean, workdir?: string}} params
6
+ * @param {{sandboxPolicy?: any, ctx?: any}} [options]
7
+ */
8
+ export async function editToolImpl({ file_path, old_string, new_string, replace_all = false, workdir }, { sandboxPolicy, ctx } = {}) {
9
+ const target = resolveToolPath(file_path, workdir, ctx);
10
+ const pathOptions = { sandboxPolicy, ctx };
7
11
  if (!isPathAllowed(target, workdir, pathOptions) || !isWritablePathAllowed(target, workdir, pathOptions)) return `Error: Path not allowed: ${file_path}`;
8
12
  if (!existsSync(target)) return `Error: File not found: ${file_path}`;
9
13
  const content = readFileSync(target, "utf8");