@superdurable/dex-agents 0.4.0 → 0.6.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 (144) hide show
  1. package/README.md +616 -55
  2. package/dist/src/agent-approval-display.d.ts +135 -0
  3. package/dist/src/agent-approval-display.js +536 -0
  4. package/dist/src/agent-approval-display.js.map +1 -0
  5. package/dist/src/agent-context-compaction.d.ts +1 -1
  6. package/dist/src/agent-framework-runner.d.ts +160 -12
  7. package/dist/src/agent-framework-runner.js +4 -0
  8. package/dist/src/agent-framework-runner.js.map +1 -1
  9. package/dist/src/agent-local-directory-media-store.d.ts +111 -0
  10. package/dist/src/agent-local-directory-media-store.js +417 -0
  11. package/dist/src/agent-local-directory-media-store.js.map +1 -0
  12. package/dist/src/agent-media.d.ts +279 -0
  13. package/dist/src/agent-media.js +457 -0
  14. package/dist/src/agent-media.js.map +1 -0
  15. package/dist/src/agent-session-codecs.d.ts +5 -1
  16. package/dist/src/agent-session-codecs.js +76 -4
  17. package/dist/src/agent-session-codecs.js.map +1 -1
  18. package/dist/src/agent-session-failure.d.ts +4 -2
  19. package/dist/src/agent-session-failure.js +6 -2
  20. package/dist/src/agent-session-failure.js.map +1 -1
  21. package/dist/src/agent-session-flow-graph.d.ts +46 -0
  22. package/dist/src/agent-session-flow-graph.js +51 -0
  23. package/dist/src/agent-session-flow-graph.js.map +1 -1
  24. package/dist/src/agent-session-flow.d.ts +28 -3
  25. package/dist/src/agent-session-flow.js +21 -1
  26. package/dist/src/agent-session-flow.js.map +1 -1
  27. package/dist/src/agent-session-persistence.d.ts +33 -0
  28. package/dist/src/agent-session-persistence.js +22 -1
  29. package/dist/src/agent-session-persistence.js.map +1 -1
  30. package/dist/src/agent-session-start.d.ts +1 -1
  31. package/dist/src/agent-session-start.js +2 -2
  32. package/dist/src/agent-session-start.js.map +1 -1
  33. package/dist/src/agent-session-steps.d.ts +2 -0
  34. package/dist/src/agent-session-steps.js +74 -5
  35. package/dist/src/agent-session-steps.js.map +1 -1
  36. package/dist/src/agent-session-summary.d.ts +138 -0
  37. package/dist/src/agent-session-summary.js +151 -0
  38. package/dist/src/agent-session-summary.js.map +1 -0
  39. package/dist/src/agent-session-tracer.js +1 -1
  40. package/dist/src/agent-session-tracer.js.map +1 -1
  41. package/dist/src/agent-session-transitions.d.ts +11 -3
  42. package/dist/src/agent-session-transitions.js +58 -6
  43. package/dist/src/agent-session-transitions.js.map +1 -1
  44. package/dist/src/agent-session-values.d.ts +179 -2
  45. package/dist/src/agent-session-values.js.map +1 -1
  46. package/dist/src/agent-session-wait-steps.js +14 -2
  47. package/dist/src/agent-session-wait-steps.js.map +1 -1
  48. package/dist/src/ai-sdk/ai-sdk-model-client.d.ts +15 -3
  49. package/dist/src/ai-sdk/ai-sdk-model-client.js +36 -7
  50. package/dist/src/ai-sdk/ai-sdk-model-client.js.map +1 -1
  51. package/dist/src/ai-sdk/index.d.ts +1 -1
  52. package/dist/src/ai-sdk/index.js +1 -1
  53. package/dist/src/ai-sdk/index.js.map +1 -1
  54. package/dist/src/{stepwise → builtin-loop}/agent-definition.d.ts +120 -5
  55. package/dist/src/builtin-loop/agent-definition.js.map +1 -0
  56. package/dist/src/builtin-loop/builtin-agent-session-flow.d.ts +77 -0
  57. package/dist/src/{stepwise/stepwise-agent-session-flow.js → builtin-loop/builtin-agent-session-flow.js} +19 -9
  58. package/dist/src/builtin-loop/builtin-agent-session-flow.js.map +1 -0
  59. package/dist/src/{stepwise/stepwise-agent-runner.d.ts → builtin-loop/builtin-loop-agent-runner.d.ts} +72 -14
  60. package/dist/src/{stepwise/stepwise-agent-runner.js → builtin-loop/builtin-loop-agent-runner.js} +284 -41
  61. package/dist/src/builtin-loop/builtin-loop-agent-runner.js.map +1 -0
  62. package/dist/src/{stepwise/stepwise-input-items.d.ts → builtin-loop/builtin-loop-input-items.d.ts} +7 -3
  63. package/dist/src/{stepwise/stepwise-input-items.js → builtin-loop/builtin-loop-input-items.js} +57 -10
  64. package/dist/src/builtin-loop/builtin-loop-input-items.js.map +1 -0
  65. package/dist/src/builtin-loop/function-tool.d.ts +282 -0
  66. package/dist/src/{stepwise → builtin-loop}/function-tool.js +63 -3
  67. package/dist/src/builtin-loop/function-tool.js.map +1 -0
  68. package/dist/src/builtin-loop/index.d.ts +8 -0
  69. package/dist/src/builtin-loop/index.js +13 -0
  70. package/dist/src/builtin-loop/index.js.map +1 -0
  71. package/dist/src/builtin-loop/json-schema-validation.js.map +1 -0
  72. package/dist/src/{stepwise → builtin-loop}/model-client.d.ts +36 -7
  73. package/dist/src/builtin-loop/model-client.js.map +1 -0
  74. package/dist/src/{stepwise → builtin-loop}/scripted-model-client.d.ts +8 -5
  75. package/dist/src/{stepwise → builtin-loop}/scripted-model-client.js +8 -5
  76. package/dist/src/builtin-loop/scripted-model-client.js.map +1 -0
  77. package/dist/src/{stepwise → builtin-loop}/tool-source.d.ts +2 -2
  78. package/dist/src/builtin-loop/tool-source.js.map +1 -0
  79. package/dist/src/connectors/connector-input-schema.js +1 -1
  80. package/dist/src/connectors/connector-input-schema.js.map +1 -1
  81. package/dist/src/connectors/connector-step-context.d.ts +19 -2
  82. package/dist/src/connectors/connector-step-context.js +61 -5
  83. package/dist/src/connectors/connector-step-context.js.map +1 -1
  84. package/dist/src/connectors/connector-tool-source.d.ts +63 -14
  85. package/dist/src/connectors/connector-tool-source.js +124 -25
  86. package/dist/src/connectors/connector-tool-source.js.map +1 -1
  87. package/dist/src/index.d.ts +8 -4
  88. package/dist/src/index.js +5 -1
  89. package/dist/src/index.js.map +1 -1
  90. package/dist/src/mcp/index.d.ts +1 -0
  91. package/dist/src/mcp/index.js +1 -0
  92. package/dist/src/mcp/index.js.map +1 -1
  93. package/dist/src/mcp/mcp-dex-conventions.d.ts +57 -0
  94. package/dist/src/mcp/mcp-dex-conventions.js +90 -0
  95. package/dist/src/mcp/mcp-dex-conventions.js.map +1 -0
  96. package/dist/src/mcp/mcp-image-content.d.ts +20 -0
  97. package/dist/src/mcp/mcp-image-content.js +82 -0
  98. package/dist/src/mcp/mcp-image-content.js.map +1 -0
  99. package/dist/src/mcp/mcp-tool-source.d.ts +205 -18
  100. package/dist/src/mcp/mcp-tool-source.js +289 -47
  101. package/dist/src/mcp/mcp-tool-source.js.map +1 -1
  102. package/dist/src/s3/index.d.ts +1 -0
  103. package/dist/src/s3/index.js +9 -0
  104. package/dist/src/s3/index.js.map +1 -0
  105. package/dist/src/s3/s3-media-store.d.ts +138 -0
  106. package/dist/src/s3/s3-media-store.js +255 -0
  107. package/dist/src/s3/s3-media-store.js.map +1 -0
  108. package/dist/src/stepwise/index.d.ts +41 -8
  109. package/dist/src/stepwise/index.js +35 -5
  110. package/dist/src/stepwise/index.js.map +1 -1
  111. package/dist/src/testing/agent-session-test-client.d.ts +6 -3
  112. package/dist/src/testing/agent-session-test-client.js +10 -3
  113. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  114. package/dist/src/testing/private-dex-engine.d.ts +8 -0
  115. package/dist/src/testing/private-dex-engine.js +17 -1
  116. package/dist/src/testing/private-dex-engine.js.map +1 -1
  117. package/dist/src/testing/scripted-model-client.d.ts +1 -1
  118. package/dist/src/testing/scripted-model-client.js +2 -2
  119. package/dist/src/testing/scripted-model-client.js.map +1 -1
  120. package/dist/src/tool-source-router.d.ts +3 -3
  121. package/dist/src/tool-source-router.js +2 -2
  122. package/dist/src/ui-stream/agent-event-json.d.ts +2 -1
  123. package/dist/src/ui-stream/agent-event-json.js +3 -1
  124. package/dist/src/ui-stream/agent-event-json.js.map +1 -1
  125. package/dist/src/ui-stream/agent-ui-messages.d.ts +11 -3
  126. package/dist/src/ui-stream/agent-ui-messages.js +29 -5
  127. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -1
  128. package/package.json +17 -4
  129. package/dist/src/stepwise/agent-definition.js.map +0 -1
  130. package/dist/src/stepwise/function-tool.d.ts +0 -130
  131. package/dist/src/stepwise/function-tool.js.map +0 -1
  132. package/dist/src/stepwise/json-schema-validation.js.map +0 -1
  133. package/dist/src/stepwise/model-client.js.map +0 -1
  134. package/dist/src/stepwise/scripted-model-client.js.map +0 -1
  135. package/dist/src/stepwise/stepwise-agent-runner.js.map +0 -1
  136. package/dist/src/stepwise/stepwise-agent-session-flow.d.ts +0 -35
  137. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +0 -1
  138. package/dist/src/stepwise/stepwise-input-items.js.map +0 -1
  139. package/dist/src/stepwise/tool-source.js.map +0 -1
  140. /package/dist/src/{stepwise → builtin-loop}/agent-definition.js +0 -0
  141. /package/dist/src/{stepwise → builtin-loop}/json-schema-validation.d.ts +0 -0
  142. /package/dist/src/{stepwise → builtin-loop}/json-schema-validation.js +0 -0
  143. /package/dist/src/{stepwise → builtin-loop}/model-client.js +0 -0
  144. /package/dist/src/{stepwise → builtin-loop}/tool-source.js +0 -0
@@ -10,12 +10,20 @@ import { createRequire } from "node:module";
10
10
  import { setTimeout as delay } from "node:timers/promises";
11
11
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
12
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
+ import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14
14
  import { CallToolResultSchema, CreateTaskResultSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
15
- import { ToolExecutionError, toolEffectFromMcpToolAnnotations, } from "../agent-framework-runner.js";
15
+ import { ToolExecutionError, ToolOutcomeUnknownError, toolEffectFromMcpToolAnnotations, } from "../agent-framework-runner.js";
16
16
  import { buildResourceAndPromptToolDefinitions, readServerName, resourceAndPromptToolNames, runResourceAndPromptTool, serializeToolOutputJson, } from "./mcp-resource-and-prompt-tools.js";
17
+ import { readDexErrorOutcome, readDexToolDeclaration, readDexToolOutcome, rejectedOutcomeFallbackMessage, unknownOutcomeFallbackMessage, } from "./mcp-dex-conventions.js";
18
+ import { hasImageContent, renderToolFailureText, storeToolResultImages } from "./mcp-image-content.js";
17
19
  import { redactMcpServerConfigSecrets, validateMcpServerConfig } from "./mcp-server-config.js";
18
- /** `_meta` key that carries the session's idempotency key on every `tools/call`. */
20
+ /**
21
+ * `_meta` key that carries the session's idempotency key on every `tools/call`.
22
+ *
23
+ * The key is `<flowId>/<turnId>/<toolCallId>`: the same on every attempt of one tool call, and unique per
24
+ * call. A server that follows the Dex MCP conventions derives its own call ID from it, so a retried attempt
25
+ * is recognized as the same call.
26
+ */
19
27
  export const mcpIdempotencyKeyMetaKey = "io.superdurable.dex/idempotencyKey";
20
28
  /** `AgentEvents` type for an MCP `notifications/progress` of a running tool call. */
21
29
  export const mcpToolProgressEventType = "mcp.tool_progress";
@@ -31,7 +39,7 @@ export const defaultMcpTaskTtlMs = 86_400_000;
31
39
  export const defaultMcpConnectionTimeoutMs = 30_000;
32
40
  /** Default number of connection attempts before a request reports the server unreachable. */
33
41
  export const defaultMcpConnectionAttempts = 3;
34
- /** Default {@link McpToolSourceOptions.sourceName}, which stored Stepwise calls are routed by. */
42
+ /** Default {@link McpToolSourceOptions.sourceName}, which stored built-in loop calls are routed by. */
35
43
  export const defaultMcpSourceName = "mcp";
36
44
  /** Default age in milliseconds after which `listTools` lists a server again, in the background: 5 minutes. */
37
45
  export const defaultMcpListingTtlMs = 300_000;
@@ -50,6 +58,13 @@ const taskTtlWaitMultiple = 4;
50
58
  // A durable poll is a Step: backing off to a minute bounds a long task's history.
51
59
  const durableTaskPollBackoffCapSeconds = 60;
52
60
  const maximumDurableTaskPollIntervalSeconds = 3_600;
61
+ // unclassified ranks highest: nothing is known, and it runs like a non-idempotent write.
62
+ const effectConservatism = {
63
+ read: 0,
64
+ idempotent_write: 1,
65
+ non_idempotent_write: 2,
66
+ unclassified: 3,
67
+ };
53
68
  // The server or a gateway may report InternalError after the call ran, so it leaves the outcome unknown.
54
69
  const noResultErrorCodes = new Set([
55
70
  ErrorCode.ConnectionClosed,
@@ -94,16 +109,26 @@ export class McpToolCallInterruptedError extends Error {
94
109
  export class McpToolListingError extends Error {
95
110
  serverName;
96
111
  detail;
112
+ /**
113
+ * The HTTP status a Streamable HTTP server answered to the failed listing, such as 401 or 403 for denied access.
114
+ * It is `undefined` for a stdio server, or when no HTTP response came, such as for a refused connection or a
115
+ * timeout.
116
+ *
117
+ * @since 0.6.0
118
+ */
119
+ httpStatus;
97
120
  /**
98
121
  * Creates the error.
99
122
  * @param serverName - Configured name of the last MCP server that failed.
100
123
  * @param detail - What went wrong with that server.
124
+ * @param httpStatus - The HTTP status the server answered, when it answered one. Since 0.6.0.
101
125
  */
102
- constructor(serverName, detail) {
126
+ constructor(serverName, detail, httpStatus) {
103
127
  super(`could not list the tools of MCP server ${serverName}: ${detail}`);
104
128
  this.serverName = serverName;
105
129
  this.detail = detail;
106
130
  this.name = "McpToolListingError";
131
+ this.httpStatus = httpStatus;
107
132
  }
108
133
  }
109
134
  const nodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom");
@@ -123,6 +148,14 @@ const nodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom");
123
148
  * the session's `defaultToolEffect`, `unclassified` by default. Annotations are trusted by default. A
124
149
  * {@link ToolPolicy} in `AgentSessionOptions.toolPolicies` keyed by {@link McpTool.name} wins.
125
150
  *
151
+ * A server that follows the Dex MCP conventions declares each tool's effect and approval in its definition's
152
+ * `_meta` instead, under `mcpToolEffectMetaKey` and `mcpToolApprovalMetaKey`, as a connector manifest does for
153
+ * `ConnectorToolSource`. A session {@link ToolPolicy} for such a tool may keep or raise its safety but never
154
+ * lowers it: the call gets the more conservative of the two effects, and its writes run in the model's order.
155
+ * A result or JSON-RPC error
156
+ * whose `_meta` reports `mcpToolOutcomeUnknown` records `outcome_unknown` without a retry, and
157
+ * `mcpToolOutcomeRejected` records `failed`. See the package README, "Dex MCP conventions".
158
+ *
126
159
  * The source uses `@modelcontextprotocol/sdk` 1.x, which speaks MCP up to 2025-11-25: every request
127
160
  * opens a new connection, performs the `initialize` handshake, ends the HTTP protocol session, and
128
161
  * closes it, so no protocol session outlives a request. Every `tools/call` carries the session's
@@ -131,11 +164,19 @@ const nodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom");
131
164
  * Results map onto the session contract:
132
165
  * - A result becomes the tool output: text blocks joined with newlines, other blocks and structured
133
166
  * content as JSON.
167
+ * - With {@link McpToolSourceOptions.mediaStore}, a successful result's `image` blocks are uploaded and returned
168
+ * as {@link ToolExecutionResult.images}, and the text shows `[image <n>]` in their place. More than
169
+ * `maxImagesPerMessage` images, invalid base64, or bytes the store refuses throw {@link ToolExecutionError}.
170
+ * - A failed result's text, with or without a media store, shows `[image <n> omitted]` for each `image` block, so
171
+ * no image bytes reach the recorded error.
134
172
  * - An `isError` result, a JSON-RPC error response other than an internal error, and a failed,
135
173
  * cancelled, or `input_required` task throw {@link ToolExecutionError}, so the session records
136
174
  * `failed` and the model sees the text. The 2025-11-25 tasks deliver input requests as server requests
137
175
  * that must be answered on a live connection, so no durable question can carry them; the source cancels
138
176
  * an `input_required` task.
177
+ * - A result or JSON-RPC error whose `_meta` reports the Dex outcome `unknown` throws
178
+ * {@link ToolOutcomeUnknownError}, so the session records `outcome_unknown` and never sends the call again.
179
+ * The outcome `rejected` throws {@link ToolExecutionError}. Either wins over `isError` and the error code.
139
180
  * - No result after the request was sent throws {@link McpToolCallInterruptedError}; the tool Step's
140
181
  * effect decides whether it is re-issued.
141
182
  * - A call that was never sent, because its server is not configured or cannot be reached, throws
@@ -213,6 +254,7 @@ export class McpToolSource {
213
254
  listingTtlMs;
214
255
  listingFailureBackoffMs;
215
256
  configuredSourceName;
257
+ mediaStore;
216
258
  tools;
217
259
  resourceAndPromptTools;
218
260
  /**
@@ -252,10 +294,11 @@ export class McpToolSource {
252
294
  throw new TypeError("listingTtlMs and listingFailureBackoffMs must be positive");
253
295
  }
254
296
  this.configuredSourceName = options.sourceName ?? defaultMcpSourceName;
297
+ this.mediaStore = options.mediaStore;
255
298
  for (const name of serversByName.keys()) {
256
299
  this.listings.set(name, { tools: undefined, listedAtMs: 0, failure: undefined, failedUntilMs: 0, pending: undefined });
257
300
  }
258
- this.connector = new McpConnector({ name: options.clientName ?? "dex-agents", version: dexAgentsVersion() }, this.connectionTimeoutMs);
301
+ this.connector = new McpConnector({ name: options.clientName ?? "dex-agents", version: dexAgentsVersion() }, this.connectionTimeoutMs, warningLoggerFallingBackToConsole(options.logWarning));
259
302
  this.resourceAndPromptTools =
260
303
  options.resourceAndPromptToolsEnabled === true && serversByName.size > 0
261
304
  ? buildResourceAndPromptToolDefinitions([...serversByName.keys()]).map((definition) => ({
@@ -315,7 +358,7 @@ export class McpToolSource {
315
358
  return [...this.tools.values()];
316
359
  }
317
360
  /**
318
- * The name a Stepwise agent's `toolSources` routes stored calls by: `sourceName` as passed, or
361
+ * The name a built-in loop agent's `toolSources` routes stored calls by: `sourceName` as passed, or
319
362
  * {@link defaultMcpSourceName}, `"mcp"`.
320
363
  */
321
364
  get sourceName() {
@@ -341,7 +384,7 @@ export class McpToolSource {
341
384
  return this.tools === undefined ? [] : [...this.tools.values()];
342
385
  }
343
386
  /**
344
- * Returns the tool names this source lists whatever its servers offer, so the Stepwise runner refuses a name
387
+ * Returns the tool names this source lists whatever its servers offer, so the built-in loop runner refuses a name
345
388
  * collision when it is constructed instead of on every model call.
346
389
  * @returns The resource and prompt tool names when they are enabled, else none.
347
390
  */
@@ -365,16 +408,19 @@ export class McpToolSource {
365
408
  return tool;
366
409
  }
367
410
  /**
368
- * Returns a {@link ToolPolicy} for every listed tool whose annotations declare an effect.
411
+ * Returns a {@link ToolPolicy} for every listed tool whose server declares an effect.
369
412
  *
370
- * Use it with a framework adapter that offers the MCP tools as its own tools: pass it in the
371
- * adapter's `toolPolicies` and spread your own policies after it, so yours win.
413
+ * A tool with a {@link McpTool.dexEffect} gets that effect and requires approval exactly when
414
+ * {@link McpTool.isApprovalRecommended}. To give a framework adapter these policies together with your own,
415
+ * use {@link mergeToolPolicies}. Do not spread your policies after these: yours would then replace a
416
+ * declaration and could lower its effect.
372
417
  * @example
373
418
  * ```ts
374
419
  * await tools.listTools();
375
- * const toolPolicies = { ...tools.toolPolicies(), files__delete: { effect: "non_idempotent_write" } };
420
+ * const declaredPolicies = tools.toolPolicies();
376
421
  * ```
377
- * @returns Policies by {@link McpTool.name}, with the effect's default approval.
422
+ * @returns Policies by {@link McpTool.name}: the declared approval for a tool with a
423
+ * {@link McpTool.dexEffect}, else the effect's default approval.
378
424
  * @throws {RangeError} When {@link listTools} has not run.
379
425
  */
380
426
  toolPolicies() {
@@ -383,22 +429,78 @@ export class McpToolSource {
383
429
  }
384
430
  const policies = {};
385
431
  for (const tool of this.tools.values()) {
386
- if (tool.frameworkEffect !== undefined) {
432
+ if (tool.dexEffect !== undefined) {
433
+ policies[tool.name] = { effect: tool.dexEffect, requiresApproval: tool.isApprovalRecommended === true };
434
+ }
435
+ else if (tool.frameworkEffect !== undefined) {
387
436
  policies[tool.name] = { effect: tool.frameworkEffect };
388
437
  }
389
438
  }
390
439
  return policies;
391
440
  }
441
+ /**
442
+ * Returns the application's tool policies merged with what the listed servers declare.
443
+ *
444
+ * Use it with a framework adapter that offers the MCP tools as its own tools: pass the result as the
445
+ * adapter's `toolPolicies`, so every call gets the policy {@link deferredToolCall} would give it.
446
+ *
447
+ * For a tool with a {@link McpTool.dexEffect}, your policy may keep or raise its safety but never lowers
448
+ * it. The tool gets the more conservative of `dexEffect` and your policy's effect, ranked `read`,
449
+ * `idempotent_write`, `non_idempotent_write`, then `unclassified`. Your `requiresApproval`, when set, wins,
450
+ * so `false` turns off a recommended approval. When you leave it out, the tool requires approval when
451
+ * {@link McpTool.isApprovalRecommended} is `true` or your policy's effect requires approval by default.
452
+ *
453
+ * For any other tool, your policy wins over its annotation effect, as before. Policies for tools that no
454
+ * listed server offers pass through unchanged.
455
+ * @example
456
+ * ```ts
457
+ * await tools.listTools();
458
+ * const toolPolicies = tools.mergeToolPolicies({
459
+ * files__delete: { effect: "non_idempotent_write" },
460
+ * lookup_order: { effect: "read" },
461
+ * });
462
+ * ```
463
+ * @param applicationPolicies - Your policies by tool name, keyed by {@link McpTool.name} for MCP tools.
464
+ * @returns A new record with every entry of `applicationPolicies` and of {@link toolPolicies}. A tool with
465
+ * a {@link McpTool.dexEffect} gets the combined policy, with `requiresApproval` set.
466
+ * @throws {RangeError} When {@link listTools} has not run.
467
+ * @since 0.5.0
468
+ */
469
+ mergeToolPolicies(applicationPolicies) {
470
+ if (this.tools === undefined) {
471
+ throw new RangeError("call listTools before merging MCP tool policies");
472
+ }
473
+ const policies = { ...this.toolPolicies(), ...applicationPolicies };
474
+ for (const tool of this.tools.values()) {
475
+ if (tool.dexEffect !== undefined) {
476
+ const applicationPolicy = Object.hasOwn(applicationPolicies, tool.name)
477
+ ? applicationPolicies[tool.name]
478
+ : undefined;
479
+ policies[tool.name] = combineDeclaredToolPolicy(tool.dexEffect, tool.isApprovalRecommended === true, applicationPolicy);
480
+ }
481
+ }
482
+ return policies;
483
+ }
392
484
  /**
393
485
  * Builds the {@link DeferredToolCall} for one model call of a listed MCP tool.
394
486
  *
395
487
  * The policy comes from `turn.resolveToolPolicy` with the tool's annotation effect, so an explicit
396
488
  * {@link ToolPolicy} wins. The call's `toolSourceReference` names the server, the tool's MCP name, and
397
489
  * its task support.
490
+ *
491
+ * For a tool with a {@link McpTool.dexEffect}, the session's {@link ToolPolicy} for the tool may keep or
492
+ * raise its safety but never lowers it. The call gets the more conservative of `dexEffect` and the policy's
493
+ * effect, ranked `read`, `idempotent_write`, `non_idempotent_write`, then `unclassified`. It waits for
494
+ * approval when the policy sets `requiresApproval`. When the policy leaves `requiresApproval` out, the call
495
+ * waits when {@link McpTool.isApprovalRecommended} is `true` or the policy's effect requires approval by
496
+ * default. Without a policy, it waits exactly when {@link McpTool.isApprovalRecommended} is `true`. A write
497
+ * runs in the model's order after the turn's earlier writes (`DeferredToolCall.ordered`). Changed in
498
+ * 0.5.0: for a tool with a `dexEffect`, a {@link ToolPolicy} may raise the effect or the approval but never
499
+ * lowers the effect.
398
500
  * @param turn - The `RunModel` context.
399
501
  * @param call - Framework tool call ID, the {@link McpTool.name} the model called, the arguments as JSON
400
502
  * text, and optionally `listedTool`, the {@link McpTool} the listing offered for this model call, as the
401
- * Stepwise runner passes it, so a refresh that finished meanwhile cannot change or remove it. Without it, the
503
+ * built-in loop runner passes it, so a refresh that finished meanwhile cannot change or remove it. Without it, the
402
504
  * tool is looked up in the current listing.
403
505
  * @returns The call to put in `ToolCallsRequested.toolCalls`.
404
506
  * @throws {RangeError} When no `listedTool` is given and {@link listTools} has not run or the tool is unknown.
@@ -406,20 +508,35 @@ export class McpToolSource {
406
508
  deferredToolCall(turn, call) {
407
509
  const listedTool = call.listedTool;
408
510
  const tool = isMcpTool(listedTool) && listedTool.name === call.toolName ? listedTool : this.tool(call.toolName);
511
+ const reference = this.isResourceAndPromptTool(call.toolName)
512
+ ? {}
513
+ : {
514
+ toolSourceReference: JSON.stringify({
515
+ mcp_server: tool.serverName,
516
+ mcp_tool: tool.mcpToolName,
517
+ ...(tool.taskSupport === undefined ? {} : { task_support: tool.taskSupport }),
518
+ }),
519
+ };
520
+ if (tool.dexEffect === undefined) {
521
+ return {
522
+ toolCallId: call.toolCallId,
523
+ toolName: call.toolName,
524
+ arguments: call.arguments,
525
+ policy: turn.resolveToolPolicy(call.toolName, tool.frameworkEffect),
526
+ ...reference,
527
+ };
528
+ }
529
+ const sessionPolicy = turn.toolPolicies !== undefined && Object.hasOwn(turn.toolPolicies, call.toolName)
530
+ ? turn.toolPolicies[call.toolName]
531
+ : undefined;
532
+ const policy = combineDeclaredToolPolicy(tool.dexEffect, tool.isApprovalRecommended === true, sessionPolicy);
409
533
  return {
410
534
  toolCallId: call.toolCallId,
411
535
  toolName: call.toolName,
412
536
  arguments: call.arguments,
413
- policy: turn.resolveToolPolicy(call.toolName, tool.frameworkEffect),
414
- ...(this.isResourceAndPromptTool(call.toolName)
415
- ? {}
416
- : {
417
- toolSourceReference: JSON.stringify({
418
- mcp_server: tool.serverName,
419
- mcp_tool: tool.mcpToolName,
420
- ...(tool.taskSupport === undefined ? {} : { task_support: tool.taskSupport }),
421
- }),
422
- }),
537
+ policy,
538
+ ...reference,
539
+ ordered: policy.effect !== "read",
423
540
  };
424
541
  }
425
542
  /**
@@ -456,13 +573,17 @@ export class McpToolSource {
456
573
  * A Streamable HTTP server without protocol sessions that answers with a task gets {@link ToolTaskStarted}.
457
574
  * The source saves the task ID as the heartbeat checkpoint first; a retried attempt that reads it returns
458
575
  * the same task without calling the tool again.
576
+ *
577
+ * Changed in 0.5.0: takes `options`, whose `requestMeta` adds entries to the `tools/call` request's `_meta`.
459
578
  * @param call - The approved call from the tool Step.
579
+ * @param options - `requestMeta`: more `_meta` entries for the `tools/call` request, as in
580
+ * {@link McpToolCallOptions.requestMeta}. The idempotency key entry wins on a name clash. Defaults to none.
460
581
  * @returns The tool output the model sees, or {@link ToolTaskStarted} for a task that `PollToolTask` polls.
461
582
  * @throws {ToolExecutionError} When the arguments are not a JSON object, the tool failed, the server
462
583
  * answered with an error or asked for input, or the call was never sent.
463
584
  * @throws {McpToolCallInterruptedError} When the call was sent and no result came back.
464
585
  */
465
- async executeDeferredToolCall(call) {
586
+ async executeDeferredToolCall(call, options = {}) {
466
587
  const toolArguments = parseToolArguments(call.toolCall.arguments);
467
588
  if (this.isResourceAndPromptTool(call.toolCall.toolName)) {
468
589
  return this.runResourceAndPromptTool(call.toolCall.toolName, toolArguments, call.cancellationSignal);
@@ -485,8 +606,41 @@ export class McpToolSource {
485
606
  ? {}
486
607
  : { saveHeartbeatCheckpoint: (checkpoint) => call.saveHeartbeatCheckpoint(checkpoint) }),
487
608
  signal: call.cancellationSignal,
609
+ ...(options.requestMeta === undefined ? {} : { requestMeta: options.requestMeta }),
488
610
  }, isTaskPolledBySession);
489
611
  }
612
+ /**
613
+ * Returns whether {@link executeDeferredToolCall} will resume a task that an earlier attempt of the same call
614
+ * started and can never send a new `tools/call` for this call.
615
+ *
616
+ * It is `true` only when the call's heartbeat checkpoint names a task of the same server and tool that the
617
+ * session polls, and the session still polls tool tasks (`call.supportsToolTasks`). Then the call returns that
618
+ * task at once, without contacting the server. In every other case, including a task polled inside the tool
619
+ * Step, such as one bound to an MCP protocol session, the call may send `tools/call`: a task the server no longer
620
+ * knows is called again. Use it to skip a check that only guards a new send, such as checking a tool's current
621
+ * listing, so a catalog that is down or changed does not stop the recovery of a task already running.
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * if (!tools.willResumeToolTaskWithoutSending(call)) {
626
+ * await checkCurrentListing(call.toolCall.toolName);
627
+ * }
628
+ * return tools.executeDeferredToolCall(call);
629
+ * ```
630
+ * @param call - The tool Step's call, with this source's own `toolSourceReference`.
631
+ * @returns `true` when the call resumes a session-polled task and sends nothing; otherwise `false`.
632
+ * @since 0.5.0
633
+ */
634
+ willResumeToolTaskWithoutSending(call) {
635
+ const reference = parseToolSourceReference(call.toolCall.toolSourceReference);
636
+ const saved = parseJsonObject(call.heartbeatCheckpoint);
637
+ return (call.supportsToolTasks === true &&
638
+ reference !== undefined &&
639
+ saved?.["mcp_server"] === reference.serverName &&
640
+ saved["mcp_tool"] === reference.mcpToolName &&
641
+ typeof saved["mcp_task_id"] === "string" &&
642
+ saved["mcp_task_polled_by_session"] === true);
643
+ }
490
644
  /**
491
645
  * Polls one MCP task that {@link executeDeferredToolCall} started, once.
492
646
  *
@@ -498,6 +652,8 @@ export class McpToolSource {
498
652
  * server's `pollInterval` to at most a minute.
499
653
  * @throws {ToolExecutionError} When the task failed, was cancelled, returned an `isError` result, or asked
500
654
  * for input.
655
+ * @throws {ToolOutcomeUnknownError} When the task's result reported the Dex outcome `unknown`; the session
656
+ * records `outcome_unknown`.
501
657
  * @throws {McpToolCallInterruptedError} When the server could not be reached, no longer knows the task, or
502
658
  * answered a poll with an error. The task may still run, so `PollToolTask` retries and then records the
503
659
  * call by its effect.
@@ -518,9 +674,11 @@ export class McpToolSource {
518
674
  const outcome = await call.pollTaskOnce(taskId, record.taskPollIntervalSeconds ?? null);
519
675
  switch (outcome.kind) {
520
676
  case "succeeded":
521
- return { output: outcome.output };
677
+ return await this.toolExecutionResult(outcome, target);
522
678
  case "failed":
523
679
  throw new ToolExecutionError(outcome.message);
680
+ case "outcome_unknown":
681
+ throw new ToolOutcomeUnknownError(outcome.message);
524
682
  case "task_pending":
525
683
  return { kind: "tool_task_pending", pollIntervalSeconds: outcome.pollIntervalSeconds };
526
684
  case "interrupted":
@@ -633,16 +791,24 @@ export class McpToolSource {
633
791
  async resolveTarget(toolName, toolSourceReference) {
634
792
  return this.targetFromReference(toolName, toolSourceReference) ?? (await this.targetFromListing(toolName));
635
793
  }
794
+ async toolExecutionResult(outcome, target) {
795
+ if (this.mediaStore === undefined || outcome.result === undefined || !hasImageContent(outcome.result)) {
796
+ return { output: outcome.output };
797
+ }
798
+ return await storeToolResultImages(outcome.result, this.mediaStore, target.toolName);
799
+ }
636
800
  async callTarget(target, toolArguments, options, isTaskPolledBySession) {
637
801
  const call = new McpToolCall(this.connector, this.callLimits(isTaskPolledBySession), target, toolArguments, options);
638
802
  const outcome = await call.run(savedTaskFromCheckpoint(options.heartbeatCheckpoint, target)?.taskId);
639
803
  switch (outcome.kind) {
640
804
  case "succeeded":
641
- return { output: outcome.output };
805
+ return await this.toolExecutionResult(outcome, target);
642
806
  case "task_started":
643
807
  return { kind: "tool_task_started", taskId: outcome.taskId, pollIntervalSeconds: outcome.pollIntervalSeconds };
644
808
  case "failed":
645
809
  throw new ToolExecutionError(outcome.message);
810
+ case "outcome_unknown":
811
+ throw new ToolOutcomeUnknownError(outcome.message);
646
812
  case "interrupted":
647
813
  case "task_lost":
648
814
  case "task_pending":
@@ -695,7 +861,7 @@ export class McpToolSource {
695
861
  const failure = error instanceof McpToolListingError ? error : new McpToolListingError(server.name, describeError(error));
696
862
  listing.failure = failure;
697
863
  listing.failedUntilMs = Date.now() + this.listingFailureBackoffMs;
698
- console.warn(`left MCP server ${server.name} out of the tool listing: ${failure.message}`);
864
+ this.connector.logWarning(`left MCP server ${server.name} out of the tool listing: ${failure.message}`);
699
865
  }
700
866
  }
701
867
  combineServerTools() {
@@ -704,7 +870,7 @@ export class McpToolSource {
704
870
  for (const tool of this.listings.get(serverName)?.tools ?? []) {
705
871
  const existing = tools.get(tool.name);
706
872
  if (existing !== undefined) {
707
- console.warn(`left MCP tool ${tool.serverName}/${tool.mcpToolName} out: ${existing.serverName}/${existing.mcpToolName} already has the agent tool name ${tool.name}`);
873
+ this.connector.logWarning(`left MCP tool ${tool.serverName}/${tool.mcpToolName} out: ${existing.serverName}/${existing.mcpToolName} already has the agent tool name ${tool.name}`);
708
874
  continue;
709
875
  }
710
876
  tools.set(tool.name, tool);
@@ -717,6 +883,7 @@ export class McpToolSource {
717
883
  }
718
884
  async listServerTools(server) {
719
885
  let detail = "no listing attempt";
886
+ let httpStatus;
720
887
  for (let listingAttempt = 1; listingAttempt <= this.connectionAttempts; listingAttempt += 1) {
721
888
  let connection;
722
889
  try {
@@ -737,6 +904,7 @@ export class McpToolSource {
737
904
  }
738
905
  catch (error) {
739
906
  detail = describeError(error);
907
+ httpStatus = streamableHttpStatusOf(error);
740
908
  }
741
909
  finally {
742
910
  await this.connector.disconnectQuietly(connection);
@@ -745,16 +913,18 @@ export class McpToolSource {
745
913
  await delay(connectionRetryDelayMs * listingAttempt);
746
914
  }
747
915
  }
748
- throw new McpToolListingError(server.name, detail);
916
+ throw new McpToolListingError(server.name, detail, httpStatus);
749
917
  }
750
918
  }
751
919
  /** Opens one connection per request with a bounded handshake, and ends it. */
752
920
  class McpConnector {
753
921
  clientInfo;
754
922
  connectionTimeoutMs;
755
- constructor(clientInfo, connectionTimeoutMs) {
923
+ logWarning;
924
+ constructor(clientInfo, connectionTimeoutMs, logWarning) {
756
925
  this.clientInfo = clientInfo;
757
926
  this.connectionTimeoutMs = connectionTimeoutMs;
927
+ this.logWarning = logWarning;
758
928
  }
759
929
  async connect(server) {
760
930
  const client = new Client(this.clientInfo, { capabilities: {} });
@@ -801,14 +971,14 @@ class McpConnector {
801
971
  }
802
972
  catch (error) {
803
973
  // The server times idle sessions out; the outcome is already known.
804
- console.warn(`ending an MCP session failed: ${describeError(error)}`);
974
+ this.logWarning(`ending an MCP session failed: ${describeError(error)}`);
805
975
  }
806
976
  try {
807
977
  await client.close();
808
978
  }
809
979
  catch (error) {
810
980
  // The outcome is already known; a failed close only leaks this one connection.
811
- console.warn(`closing an MCP connection failed: ${describeError(error)}`);
981
+ this.logWarning(`closing an MCP connection failed: ${describeError(error)}`);
812
982
  }
813
983
  }
814
984
  }
@@ -842,7 +1012,7 @@ class McpToolCall {
842
1012
  return outcome;
843
1013
  }
844
1014
  // Only CallTool retries an attempt, and it may send the call again.
845
- console.warn(`calling MCP tool ${this.target.mcpToolName} again: ${outcome.detail}`);
1015
+ this.connector.logWarning(`calling MCP tool ${this.target.mcpToolName} again: ${outcome.detail}`);
846
1016
  }
847
1017
  return this.withConnection((connection) => this.sendToolCall(connection));
848
1018
  }
@@ -915,7 +1085,12 @@ class McpToolCall {
915
1085
  }
916
1086
  }
917
1087
  async sendToolCall({ client, transport }) {
918
- const meta = this.options.idempotencyKey === undefined ? undefined : { [mcpIdempotencyKeyMetaKey]: this.options.idempotencyKey };
1088
+ const meta = this.options.idempotencyKey === undefined && this.options.requestMeta === undefined
1089
+ ? undefined
1090
+ : {
1091
+ ...this.options.requestMeta,
1092
+ ...(this.options.idempotencyKey === undefined ? {} : { [mcpIdempotencyKeyMetaKey]: this.options.idempotencyKey }),
1093
+ };
919
1094
  const params = { name: this.target.mcpToolName, arguments: this.toolArguments, ...(meta === undefined ? {} : { _meta: meta }) };
920
1095
  const requestOptions = {
921
1096
  timeout: this.limits.requestTimeoutMs,
@@ -945,8 +1120,9 @@ class McpToolCall {
945
1120
  })).task;
946
1121
  }
947
1122
  catch (error) {
948
- if (error instanceof McpError && !noResultErrorCodes.has(error.code)) {
949
- return { kind: "failed", message: error.message };
1123
+ const outcome = error instanceof McpError ? outcomeFromMcpError(error) : undefined;
1124
+ if (outcome !== undefined) {
1125
+ return outcome;
950
1126
  }
951
1127
  throw error;
952
1128
  }
@@ -1017,7 +1193,7 @@ class McpToolCall {
1017
1193
  return { kind: "failed", message: `MCP task ${taskId} was cancelled${reason}` };
1018
1194
  }
1019
1195
  if (task.status === "input_required") {
1020
- await cancelTaskQuietly(client, taskId);
1196
+ await cancelTaskQuietly(client, taskId, this.connector.logWarning);
1021
1197
  const detail = task.statusMessage === undefined ? "" : ` (${task.statusMessage})`;
1022
1198
  return {
1023
1199
  kind: "failed",
@@ -1055,7 +1231,7 @@ class McpToolCallReporter {
1055
1231
  });
1056
1232
  }
1057
1233
  }
1058
- function mcpToolFromListing(server, tool) {
1234
+ export function mcpToolFromListing(server, tool) {
1059
1235
  const annotations = tool.annotations === undefined
1060
1236
  ? undefined
1061
1237
  : {
@@ -1064,6 +1240,13 @@ function mcpToolFromListing(server, tool) {
1064
1240
  ...(tool.annotations.destructiveHint === undefined ? {} : { destructiveHint: tool.annotations.destructiveHint }),
1065
1241
  };
1066
1242
  const trustsAnnotations = server.trustsToolAnnotations ?? true;
1243
+ const declaration = trustsAnnotations ? readDexToolDeclaration(tool._meta, server.name, tool.name) : undefined;
1244
+ // A Dex declaration replaces the annotations, even when its effect is invalid.
1245
+ const frameworkEffect = declaration !== undefined
1246
+ ? declaration.effect
1247
+ : trustsAnnotations && annotations !== undefined
1248
+ ? toolEffectFromMcpToolAnnotations(annotations)
1249
+ : undefined;
1067
1250
  return {
1068
1251
  name: buildAgentToolName(server.name, tool.name),
1069
1252
  serverName: server.name,
@@ -1071,10 +1254,24 @@ function mcpToolFromListing(server, tool) {
1071
1254
  description: tool.description ?? tool.title ?? `MCP tool ${tool.name} of ${server.name}`,
1072
1255
  inputSchema: tool.inputSchema,
1073
1256
  annotations,
1074
- frameworkEffect: trustsAnnotations && annotations !== undefined ? toolEffectFromMcpToolAnnotations(annotations) : undefined,
1257
+ frameworkEffect,
1258
+ dexEffect: declaration?.effect,
1259
+ isApprovalRecommended: declaration?.effect !== undefined && declaration.isApprovalRecommended,
1075
1260
  taskSupport: tool.execution?.taskSupport,
1076
1261
  };
1077
1262
  }
1263
+ /** Returns a declared tool's policy; a session policy may raise its safety, never lower it. */
1264
+ function combineDeclaredToolPolicy(declaredEffect, isApprovalRecommended, sessionPolicy) {
1265
+ if (sessionPolicy === undefined) {
1266
+ return { effect: declaredEffect, requiresApproval: isApprovalRecommended };
1267
+ }
1268
+ const effect = effectConservatism[sessionPolicy.effect] > effectConservatism[declaredEffect] ? sessionPolicy.effect : declaredEffect;
1269
+ const isApprovalRequiredBySessionEffect = sessionPolicy.effect === "non_idempotent_write" || sessionPolicy.effect === "unclassified";
1270
+ return {
1271
+ effect,
1272
+ requiresApproval: sessionPolicy.requiresApproval ?? (isApprovalRecommended || isApprovalRequiredBySessionEffect),
1273
+ };
1274
+ }
1078
1275
  function buildAgentToolName(serverName, mcpToolName) {
1079
1276
  const candidate = `${serverName}__${mcpToolName}`.replace(/[^A-Za-z0-9_-]/g, "_");
1080
1277
  if (candidate.length <= toolNameLengthLimit) {
@@ -1142,12 +1339,32 @@ function savedTaskFromCheckpoint(checkpoint, target) {
1142
1339
  ? { taskId, isPolledBySession: saved["mcp_task_polled_by_session"] === true }
1143
1340
  : undefined;
1144
1341
  }
1145
- function outcomeFromToolResult(result) {
1146
- const text = renderToolResultText(result);
1342
+ export function outcomeFromToolResult(result) {
1343
+ const failureText = hasImageContent(result) ? renderToolFailureText(result) : renderToolResultText(result);
1344
+ // The Dex outcome wins over isError in both directions.
1345
+ const dexOutcome = readDexToolOutcome(result._meta);
1346
+ if (dexOutcome === "unknown") {
1347
+ return { kind: "outcome_unknown", message: failureText === "" ? unknownOutcomeFallbackMessage : failureText };
1348
+ }
1349
+ if (dexOutcome === "rejected") {
1350
+ return { kind: "failed", message: failureText === "" ? rejectedOutcomeFallbackMessage : failureText };
1351
+ }
1147
1352
  if (result.isError === true) {
1148
- return { kind: "failed", message: text === "" ? "the MCP tool reported an error" : text };
1353
+ return { kind: "failed", message: failureText === "" ? "the MCP tool reported an error" : failureText };
1354
+ }
1355
+ return { kind: "succeeded", output: renderToolResultText(result), result };
1356
+ }
1357
+ /** Maps a JSON-RPC error answer; `undefined` leaves the outcome open, so the effect class decides. */
1358
+ export function outcomeFromMcpError(error) {
1359
+ // A Dex-aware server's outcome is known, even for an error code that usually leaves it open.
1360
+ const dexOutcome = readDexErrorOutcome(error.data);
1361
+ if (dexOutcome === "unknown") {
1362
+ return { kind: "outcome_unknown", message: error.message };
1363
+ }
1364
+ if (dexOutcome === "rejected" || !noResultErrorCodes.has(error.code)) {
1365
+ return { kind: "failed", message: error.message };
1149
1366
  }
1150
- return { kind: "succeeded", output: text };
1367
+ return undefined;
1151
1368
  }
1152
1369
  /** Seconds until the next durable poll: the server's hint, backed off from the last interval to a minute. */
1153
1370
  function durableTaskPollIntervalSeconds(task, previousSeconds) {
@@ -1161,13 +1378,38 @@ function taskPollIntervalMs(task) {
1161
1378
  }
1162
1379
  return Math.min(Math.max(task.pollInterval, minimumTaskPollIntervalMs), maximumTaskPollIntervalMs);
1163
1380
  }
1164
- async function cancelTaskQuietly(client, taskId) {
1381
+ /** Returns the HTTP status of a Streamable HTTP failure in the error or its causes, if any. */
1382
+ function streamableHttpStatusOf(error) {
1383
+ let current = error;
1384
+ for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
1385
+ if (current instanceof StreamableHTTPError && current.code !== undefined && current.code > 0) {
1386
+ return current.code;
1387
+ }
1388
+ current = current.cause;
1389
+ }
1390
+ return undefined;
1391
+ }
1392
+ /** Wraps an application's warning callback so that a throwing callback falls back to `console.warn`. */
1393
+ function warningLoggerFallingBackToConsole(logWarning) {
1394
+ if (logWarning === undefined) {
1395
+ return (message) => console.warn(message);
1396
+ }
1397
+ return (message) => {
1398
+ try {
1399
+ logWarning(message);
1400
+ }
1401
+ catch (error) {
1402
+ console.warn(`${message} (logWarning failed: ${describeError(error)})`);
1403
+ }
1404
+ };
1405
+ }
1406
+ async function cancelTaskQuietly(client, taskId, logWarning) {
1165
1407
  try {
1166
1408
  await client.experimental.tasks.cancelTask(taskId, { timeout: taskCancelTimeoutMs });
1167
1409
  }
1168
1410
  catch (error) {
1169
1411
  // Cancellation is cooperative; the tool's outcome is already reported either way.
1170
- console.warn(`tasks/cancel for MCP task ${taskId} failed: ${describeError(error)}`);
1412
+ logWarning(`tasks/cancel for MCP task ${taskId} failed: ${describeError(error)}`);
1171
1413
  }
1172
1414
  }
1173
1415
  function describeError(error) {