@superdurable/dex-agents 0.3.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 (139) hide show
  1. package/README.md +598 -45
  2. package/dist/src/agent-context-compaction.d.ts +1 -1
  3. package/dist/src/agent-framework-runner.d.ts +210 -10
  4. package/dist/src/agent-framework-runner.js +30 -0
  5. package/dist/src/agent-framework-runner.js.map +1 -1
  6. package/dist/src/agent-local-directory-media-store.d.ts +111 -0
  7. package/dist/src/agent-local-directory-media-store.js +417 -0
  8. package/dist/src/agent-local-directory-media-store.js.map +1 -0
  9. package/dist/src/agent-media.d.ts +279 -0
  10. package/dist/src/agent-media.js +457 -0
  11. package/dist/src/agent-media.js.map +1 -0
  12. package/dist/src/agent-session-codecs.d.ts +3 -1
  13. package/dist/src/agent-session-codecs.js +53 -4
  14. package/dist/src/agent-session-codecs.js.map +1 -1
  15. package/dist/src/agent-session-failure.d.ts +4 -2
  16. package/dist/src/agent-session-failure.js +6 -2
  17. package/dist/src/agent-session-failure.js.map +1 -1
  18. package/dist/src/agent-session-flow-graph.d.ts +46 -0
  19. package/dist/src/agent-session-flow-graph.js +51 -0
  20. package/dist/src/agent-session-flow-graph.js.map +1 -1
  21. package/dist/src/agent-session-flow-graph.json +98 -81
  22. package/dist/src/agent-session-flow.d.ts +5 -3
  23. package/dist/src/agent-session-flow.js +10 -1
  24. package/dist/src/agent-session-flow.js.map +1 -1
  25. package/dist/src/agent-session-start.d.ts +1 -1
  26. package/dist/src/agent-session-start.js +2 -2
  27. package/dist/src/agent-session-start.js.map +1 -1
  28. package/dist/src/agent-session-steps.d.ts +9 -0
  29. package/dist/src/agent-session-steps.js +219 -34
  30. package/dist/src/agent-session-steps.js.map +1 -1
  31. package/dist/src/agent-session-tracer.js +1 -1
  32. package/dist/src/agent-session-tracer.js.map +1 -1
  33. package/dist/src/agent-session-transitions.d.ts +4 -0
  34. package/dist/src/agent-session-transitions.js +29 -1
  35. package/dist/src/agent-session-transitions.js.map +1 -1
  36. package/dist/src/agent-session-values.d.ts +112 -1
  37. package/dist/src/agent-session-values.js +6 -0
  38. package/dist/src/agent-session-values.js.map +1 -1
  39. package/dist/src/agent-session-wait-steps.js +17 -5
  40. package/dist/src/agent-session-wait-steps.js.map +1 -1
  41. package/dist/src/ai-sdk/ai-sdk-model-client.d.ts +15 -3
  42. package/dist/src/ai-sdk/ai-sdk-model-client.js +36 -7
  43. package/dist/src/ai-sdk/ai-sdk-model-client.js.map +1 -1
  44. package/dist/src/ai-sdk/index.d.ts +1 -1
  45. package/dist/src/ai-sdk/index.js +1 -1
  46. package/dist/src/ai-sdk/index.js.map +1 -1
  47. package/dist/src/{stepwise → builtin-loop}/agent-definition.d.ts +120 -5
  48. package/dist/src/builtin-loop/agent-definition.js.map +1 -0
  49. package/dist/src/builtin-loop/builtin-agent-session-flow.d.ts +69 -0
  50. package/dist/src/{stepwise/stepwise-agent-session-flow.js → builtin-loop/builtin-agent-session-flow.js} +15 -9
  51. package/dist/src/builtin-loop/builtin-agent-session-flow.js.map +1 -0
  52. package/dist/src/{stepwise/stepwise-agent-runner.d.ts → builtin-loop/builtin-loop-agent-runner.d.ts} +85 -26
  53. package/dist/src/{stepwise/stepwise-agent-runner.js → builtin-loop/builtin-loop-agent-runner.js} +320 -213
  54. package/dist/src/builtin-loop/builtin-loop-agent-runner.js.map +1 -0
  55. package/dist/src/{stepwise/stepwise-input-items.d.ts → builtin-loop/builtin-loop-input-items.d.ts} +7 -3
  56. package/dist/src/{stepwise/stepwise-input-items.js → builtin-loop/builtin-loop-input-items.js} +57 -10
  57. package/dist/src/builtin-loop/builtin-loop-input-items.js.map +1 -0
  58. package/dist/src/builtin-loop/function-tool.d.ts +282 -0
  59. package/dist/src/{stepwise → builtin-loop}/function-tool.js +63 -3
  60. package/dist/src/builtin-loop/function-tool.js.map +1 -0
  61. package/dist/src/builtin-loop/index.d.ts +8 -0
  62. package/dist/src/builtin-loop/index.js +13 -0
  63. package/dist/src/builtin-loop/index.js.map +1 -0
  64. package/dist/src/builtin-loop/json-schema-validation.js.map +1 -0
  65. package/dist/src/{stepwise → builtin-loop}/model-client.d.ts +36 -7
  66. package/dist/src/builtin-loop/model-client.js.map +1 -0
  67. package/dist/src/{stepwise → builtin-loop}/scripted-model-client.d.ts +8 -5
  68. package/dist/src/{stepwise → builtin-loop}/scripted-model-client.js +8 -5
  69. package/dist/src/builtin-loop/scripted-model-client.js.map +1 -0
  70. package/dist/src/{stepwise → builtin-loop}/tool-source.d.ts +13 -3
  71. package/dist/src/builtin-loop/tool-source.js.map +1 -0
  72. package/dist/src/connectors/connector-input-schema.d.ts +19 -0
  73. package/dist/src/connectors/connector-input-schema.js +228 -0
  74. package/dist/src/connectors/connector-input-schema.js.map +1 -0
  75. package/dist/src/connectors/connector-step-context.d.ts +94 -0
  76. package/dist/src/connectors/connector-step-context.js +180 -0
  77. package/dist/src/connectors/connector-step-context.js.map +1 -0
  78. package/dist/src/connectors/connector-tool-source.d.ts +401 -0
  79. package/dist/src/connectors/connector-tool-source.js +628 -0
  80. package/dist/src/connectors/connector-tool-source.js.map +1 -0
  81. package/dist/src/connectors/index.d.ts +3 -0
  82. package/dist/src/connectors/index.js +11 -0
  83. package/dist/src/connectors/index.js.map +1 -0
  84. package/dist/src/index.d.ts +7 -3
  85. package/dist/src/index.js +6 -3
  86. package/dist/src/index.js.map +1 -1
  87. package/dist/src/mcp/index.d.ts +1 -0
  88. package/dist/src/mcp/index.js +1 -0
  89. package/dist/src/mcp/index.js.map +1 -1
  90. package/dist/src/mcp/mcp-dex-conventions.d.ts +57 -0
  91. package/dist/src/mcp/mcp-dex-conventions.js +90 -0
  92. package/dist/src/mcp/mcp-dex-conventions.js.map +1 -0
  93. package/dist/src/mcp/mcp-image-content.d.ts +20 -0
  94. package/dist/src/mcp/mcp-image-content.js +82 -0
  95. package/dist/src/mcp/mcp-image-content.js.map +1 -0
  96. package/dist/src/mcp/mcp-tool-source.d.ts +178 -17
  97. package/dist/src/mcp/mcp-tool-source.js +237 -34
  98. package/dist/src/mcp/mcp-tool-source.js.map +1 -1
  99. package/dist/src/s3/index.d.ts +1 -0
  100. package/dist/src/s3/index.js +9 -0
  101. package/dist/src/s3/index.js.map +1 -0
  102. package/dist/src/s3/s3-media-store.d.ts +138 -0
  103. package/dist/src/s3/s3-media-store.js +255 -0
  104. package/dist/src/s3/s3-media-store.js.map +1 -0
  105. package/dist/src/stepwise/index.d.ts +41 -8
  106. package/dist/src/stepwise/index.js +35 -5
  107. package/dist/src/stepwise/index.js.map +1 -1
  108. package/dist/src/testing/agent-session-test-client.d.ts +6 -3
  109. package/dist/src/testing/agent-session-test-client.js +10 -3
  110. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  111. package/dist/src/testing/scripted-model-client.d.ts +1 -1
  112. package/dist/src/testing/scripted-model-client.js +2 -2
  113. package/dist/src/testing/scripted-model-client.js.map +1 -1
  114. package/dist/src/tool-source-router.d.ts +211 -0
  115. package/dist/src/tool-source-router.js +380 -0
  116. package/dist/src/tool-source-router.js.map +1 -0
  117. package/dist/src/ui-stream/agent-event-json.d.ts +2 -1
  118. package/dist/src/ui-stream/agent-event-json.js +3 -1
  119. package/dist/src/ui-stream/agent-event-json.js.map +1 -1
  120. package/dist/src/ui-stream/agent-ui-messages.d.ts +11 -3
  121. package/dist/src/ui-stream/agent-ui-messages.js +29 -5
  122. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -1
  123. package/package.json +25 -3
  124. package/dist/src/stepwise/agent-definition.js.map +0 -1
  125. package/dist/src/stepwise/function-tool.d.ts +0 -130
  126. package/dist/src/stepwise/function-tool.js.map +0 -1
  127. package/dist/src/stepwise/json-schema-validation.js.map +0 -1
  128. package/dist/src/stepwise/model-client.js.map +0 -1
  129. package/dist/src/stepwise/scripted-model-client.js.map +0 -1
  130. package/dist/src/stepwise/stepwise-agent-runner.js.map +0 -1
  131. package/dist/src/stepwise/stepwise-agent-session-flow.d.ts +0 -35
  132. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +0 -1
  133. package/dist/src/stepwise/stepwise-input-items.js.map +0 -1
  134. package/dist/src/stepwise/tool-source.js.map +0 -1
  135. /package/dist/src/{stepwise → builtin-loop}/agent-definition.js +0 -0
  136. /package/dist/src/{stepwise → builtin-loop}/json-schema-validation.d.ts +0 -0
  137. /package/dist/src/{stepwise → builtin-loop}/json-schema-validation.js +0 -0
  138. /package/dist/src/{stepwise → builtin-loop}/model-client.js +0 -0
  139. /package/dist/src/{stepwise → builtin-loop}/tool-source.js +0 -0
@@ -12,10 +12,18 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
12
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13
13
  import { StreamableHTTPClientTransport } 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,
@@ -123,6 +138,14 @@ const nodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom");
123
138
  * the session's `defaultToolEffect`, `unclassified` by default. Annotations are trusted by default. A
124
139
  * {@link ToolPolicy} in `AgentSessionOptions.toolPolicies` keyed by {@link McpTool.name} wins.
125
140
  *
141
+ * A server that follows the Dex MCP conventions declares each tool's effect and approval in its definition's
142
+ * `_meta` instead, under `mcpToolEffectMetaKey` and `mcpToolApprovalMetaKey`, as a connector manifest does for
143
+ * `ConnectorToolSource`. A session {@link ToolPolicy} for such a tool may keep or raise its safety but never
144
+ * lowers it: the call gets the more conservative of the two effects, and its writes run in the model's order.
145
+ * A result or JSON-RPC error
146
+ * whose `_meta` reports `mcpToolOutcomeUnknown` records `outcome_unknown` without a retry, and
147
+ * `mcpToolOutcomeRejected` records `failed`. See the package README, "Dex MCP conventions".
148
+ *
126
149
  * The source uses `@modelcontextprotocol/sdk` 1.x, which speaks MCP up to 2025-11-25: every request
127
150
  * opens a new connection, performs the `initialize` handshake, ends the HTTP protocol session, and
128
151
  * closes it, so no protocol session outlives a request. Every `tools/call` carries the session's
@@ -131,11 +154,19 @@ const nodeInspectSymbol = Symbol.for("nodejs.util.inspect.custom");
131
154
  * Results map onto the session contract:
132
155
  * - A result becomes the tool output: text blocks joined with newlines, other blocks and structured
133
156
  * content as JSON.
157
+ * - With {@link McpToolSourceOptions.mediaStore}, a successful result's `image` blocks are uploaded and returned
158
+ * as {@link ToolExecutionResult.images}, and the text shows `[image <n>]` in their place. More than
159
+ * `maxImagesPerMessage` images, invalid base64, or bytes the store refuses throw {@link ToolExecutionError}.
160
+ * - A failed result's text, with or without a media store, shows `[image <n> omitted]` for each `image` block, so
161
+ * no image bytes reach the recorded error.
134
162
  * - An `isError` result, a JSON-RPC error response other than an internal error, and a failed,
135
163
  * cancelled, or `input_required` task throw {@link ToolExecutionError}, so the session records
136
164
  * `failed` and the model sees the text. The 2025-11-25 tasks deliver input requests as server requests
137
165
  * that must be answered on a live connection, so no durable question can carry them; the source cancels
138
166
  * an `input_required` task.
167
+ * - A result or JSON-RPC error whose `_meta` reports the Dex outcome `unknown` throws
168
+ * {@link ToolOutcomeUnknownError}, so the session records `outcome_unknown` and never sends the call again.
169
+ * The outcome `rejected` throws {@link ToolExecutionError}. Either wins over `isError` and the error code.
139
170
  * - No result after the request was sent throws {@link McpToolCallInterruptedError}; the tool Step's
140
171
  * effect decides whether it is re-issued.
141
172
  * - A call that was never sent, because its server is not configured or cannot be reached, throws
@@ -213,6 +244,7 @@ export class McpToolSource {
213
244
  listingTtlMs;
214
245
  listingFailureBackoffMs;
215
246
  configuredSourceName;
247
+ mediaStore;
216
248
  tools;
217
249
  resourceAndPromptTools;
218
250
  /**
@@ -252,6 +284,7 @@ export class McpToolSource {
252
284
  throw new TypeError("listingTtlMs and listingFailureBackoffMs must be positive");
253
285
  }
254
286
  this.configuredSourceName = options.sourceName ?? defaultMcpSourceName;
287
+ this.mediaStore = options.mediaStore;
255
288
  for (const name of serversByName.keys()) {
256
289
  this.listings.set(name, { tools: undefined, listedAtMs: 0, failure: undefined, failedUntilMs: 0, pending: undefined });
257
290
  }
@@ -315,7 +348,7 @@ export class McpToolSource {
315
348
  return [...this.tools.values()];
316
349
  }
317
350
  /**
318
- * The name a Stepwise agent's `toolSources` routes stored calls by: `sourceName` as passed, or
351
+ * The name a built-in loop agent's `toolSources` routes stored calls by: `sourceName` as passed, or
319
352
  * {@link defaultMcpSourceName}, `"mcp"`.
320
353
  */
321
354
  get sourceName() {
@@ -341,7 +374,7 @@ export class McpToolSource {
341
374
  return this.tools === undefined ? [] : [...this.tools.values()];
342
375
  }
343
376
  /**
344
- * Returns the tool names this source lists whatever its servers offer, so the Stepwise runner refuses a name
377
+ * Returns the tool names this source lists whatever its servers offer, so the built-in loop runner refuses a name
345
378
  * collision when it is constructed instead of on every model call.
346
379
  * @returns The resource and prompt tool names when they are enabled, else none.
347
380
  */
@@ -365,16 +398,19 @@ export class McpToolSource {
365
398
  return tool;
366
399
  }
367
400
  /**
368
- * Returns a {@link ToolPolicy} for every listed tool whose annotations declare an effect.
401
+ * Returns a {@link ToolPolicy} for every listed tool whose server declares an effect.
369
402
  *
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.
403
+ * A tool with a {@link McpTool.dexEffect} gets that effect and requires approval exactly when
404
+ * {@link McpTool.isApprovalRecommended}. To give a framework adapter these policies together with your own,
405
+ * use {@link mergeToolPolicies}. Do not spread your policies after these: yours would then replace a
406
+ * declaration and could lower its effect.
372
407
  * @example
373
408
  * ```ts
374
409
  * await tools.listTools();
375
- * const toolPolicies = { ...tools.toolPolicies(), files__delete: { effect: "non_idempotent_write" } };
410
+ * const declaredPolicies = tools.toolPolicies();
376
411
  * ```
377
- * @returns Policies by {@link McpTool.name}, with the effect's default approval.
412
+ * @returns Policies by {@link McpTool.name}: the declared approval for a tool with a
413
+ * {@link McpTool.dexEffect}, else the effect's default approval.
378
414
  * @throws {RangeError} When {@link listTools} has not run.
379
415
  */
380
416
  toolPolicies() {
@@ -383,22 +419,78 @@ export class McpToolSource {
383
419
  }
384
420
  const policies = {};
385
421
  for (const tool of this.tools.values()) {
386
- if (tool.frameworkEffect !== undefined) {
422
+ if (tool.dexEffect !== undefined) {
423
+ policies[tool.name] = { effect: tool.dexEffect, requiresApproval: tool.isApprovalRecommended === true };
424
+ }
425
+ else if (tool.frameworkEffect !== undefined) {
387
426
  policies[tool.name] = { effect: tool.frameworkEffect };
388
427
  }
389
428
  }
390
429
  return policies;
391
430
  }
431
+ /**
432
+ * Returns the application's tool policies merged with what the listed servers declare.
433
+ *
434
+ * Use it with a framework adapter that offers the MCP tools as its own tools: pass the result as the
435
+ * adapter's `toolPolicies`, so every call gets the policy {@link deferredToolCall} would give it.
436
+ *
437
+ * For a tool with a {@link McpTool.dexEffect}, your policy may keep or raise its safety but never lowers
438
+ * it. The tool gets the more conservative of `dexEffect` and your policy's effect, ranked `read`,
439
+ * `idempotent_write`, `non_idempotent_write`, then `unclassified`. Your `requiresApproval`, when set, wins,
440
+ * so `false` turns off a recommended approval. When you leave it out, the tool requires approval when
441
+ * {@link McpTool.isApprovalRecommended} is `true` or your policy's effect requires approval by default.
442
+ *
443
+ * For any other tool, your policy wins over its annotation effect, as before. Policies for tools that no
444
+ * listed server offers pass through unchanged.
445
+ * @example
446
+ * ```ts
447
+ * await tools.listTools();
448
+ * const toolPolicies = tools.mergeToolPolicies({
449
+ * files__delete: { effect: "non_idempotent_write" },
450
+ * lookup_order: { effect: "read" },
451
+ * });
452
+ * ```
453
+ * @param applicationPolicies - Your policies by tool name, keyed by {@link McpTool.name} for MCP tools.
454
+ * @returns A new record with every entry of `applicationPolicies` and of {@link toolPolicies}. A tool with
455
+ * a {@link McpTool.dexEffect} gets the combined policy, with `requiresApproval` set.
456
+ * @throws {RangeError} When {@link listTools} has not run.
457
+ * @since 0.5.0
458
+ */
459
+ mergeToolPolicies(applicationPolicies) {
460
+ if (this.tools === undefined) {
461
+ throw new RangeError("call listTools before merging MCP tool policies");
462
+ }
463
+ const policies = { ...this.toolPolicies(), ...applicationPolicies };
464
+ for (const tool of this.tools.values()) {
465
+ if (tool.dexEffect !== undefined) {
466
+ const applicationPolicy = Object.hasOwn(applicationPolicies, tool.name)
467
+ ? applicationPolicies[tool.name]
468
+ : undefined;
469
+ policies[tool.name] = combineDeclaredToolPolicy(tool.dexEffect, tool.isApprovalRecommended === true, applicationPolicy);
470
+ }
471
+ }
472
+ return policies;
473
+ }
392
474
  /**
393
475
  * Builds the {@link DeferredToolCall} for one model call of a listed MCP tool.
394
476
  *
395
477
  * The policy comes from `turn.resolveToolPolicy` with the tool's annotation effect, so an explicit
396
478
  * {@link ToolPolicy} wins. The call's `toolSourceReference` names the server, the tool's MCP name, and
397
479
  * its task support.
480
+ *
481
+ * For a tool with a {@link McpTool.dexEffect}, the session's {@link ToolPolicy} for the tool may keep or
482
+ * raise its safety but never lowers it. The call gets the more conservative of `dexEffect` and the policy's
483
+ * effect, ranked `read`, `idempotent_write`, `non_idempotent_write`, then `unclassified`. It waits for
484
+ * approval when the policy sets `requiresApproval`. When the policy leaves `requiresApproval` out, the call
485
+ * waits when {@link McpTool.isApprovalRecommended} is `true` or the policy's effect requires approval by
486
+ * default. Without a policy, it waits exactly when {@link McpTool.isApprovalRecommended} is `true`. A write
487
+ * runs in the model's order after the turn's earlier writes (`DeferredToolCall.ordered`). Changed in
488
+ * 0.5.0: for a tool with a `dexEffect`, a {@link ToolPolicy} may raise the effect or the approval but never
489
+ * lowers the effect.
398
490
  * @param turn - The `RunModel` context.
399
491
  * @param call - Framework tool call ID, the {@link McpTool.name} the model called, the arguments as JSON
400
492
  * 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
493
+ * built-in loop runner passes it, so a refresh that finished meanwhile cannot change or remove it. Without it, the
402
494
  * tool is looked up in the current listing.
403
495
  * @returns The call to put in `ToolCallsRequested.toolCalls`.
404
496
  * @throws {RangeError} When no `listedTool` is given and {@link listTools} has not run or the tool is unknown.
@@ -406,20 +498,35 @@ export class McpToolSource {
406
498
  deferredToolCall(turn, call) {
407
499
  const listedTool = call.listedTool;
408
500
  const tool = isMcpTool(listedTool) && listedTool.name === call.toolName ? listedTool : this.tool(call.toolName);
501
+ const reference = this.isResourceAndPromptTool(call.toolName)
502
+ ? {}
503
+ : {
504
+ toolSourceReference: JSON.stringify({
505
+ mcp_server: tool.serverName,
506
+ mcp_tool: tool.mcpToolName,
507
+ ...(tool.taskSupport === undefined ? {} : { task_support: tool.taskSupport }),
508
+ }),
509
+ };
510
+ if (tool.dexEffect === undefined) {
511
+ return {
512
+ toolCallId: call.toolCallId,
513
+ toolName: call.toolName,
514
+ arguments: call.arguments,
515
+ policy: turn.resolveToolPolicy(call.toolName, tool.frameworkEffect),
516
+ ...reference,
517
+ };
518
+ }
519
+ const sessionPolicy = turn.toolPolicies !== undefined && Object.hasOwn(turn.toolPolicies, call.toolName)
520
+ ? turn.toolPolicies[call.toolName]
521
+ : undefined;
522
+ const policy = combineDeclaredToolPolicy(tool.dexEffect, tool.isApprovalRecommended === true, sessionPolicy);
409
523
  return {
410
524
  toolCallId: call.toolCallId,
411
525
  toolName: call.toolName,
412
526
  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
- }),
527
+ policy,
528
+ ...reference,
529
+ ordered: policy.effect !== "read",
423
530
  };
424
531
  }
425
532
  /**
@@ -456,13 +563,17 @@ export class McpToolSource {
456
563
  * A Streamable HTTP server without protocol sessions that answers with a task gets {@link ToolTaskStarted}.
457
564
  * The source saves the task ID as the heartbeat checkpoint first; a retried attempt that reads it returns
458
565
  * the same task without calling the tool again.
566
+ *
567
+ * Changed in 0.5.0: takes `options`, whose `requestMeta` adds entries to the `tools/call` request's `_meta`.
459
568
  * @param call - The approved call from the tool Step.
569
+ * @param options - `requestMeta`: more `_meta` entries for the `tools/call` request, as in
570
+ * {@link McpToolCallOptions.requestMeta}. The idempotency key entry wins on a name clash. Defaults to none.
460
571
  * @returns The tool output the model sees, or {@link ToolTaskStarted} for a task that `PollToolTask` polls.
461
572
  * @throws {ToolExecutionError} When the arguments are not a JSON object, the tool failed, the server
462
573
  * answered with an error or asked for input, or the call was never sent.
463
574
  * @throws {McpToolCallInterruptedError} When the call was sent and no result came back.
464
575
  */
465
- async executeDeferredToolCall(call) {
576
+ async executeDeferredToolCall(call, options = {}) {
466
577
  const toolArguments = parseToolArguments(call.toolCall.arguments);
467
578
  if (this.isResourceAndPromptTool(call.toolCall.toolName)) {
468
579
  return this.runResourceAndPromptTool(call.toolCall.toolName, toolArguments, call.cancellationSignal);
@@ -485,8 +596,41 @@ export class McpToolSource {
485
596
  ? {}
486
597
  : { saveHeartbeatCheckpoint: (checkpoint) => call.saveHeartbeatCheckpoint(checkpoint) }),
487
598
  signal: call.cancellationSignal,
599
+ ...(options.requestMeta === undefined ? {} : { requestMeta: options.requestMeta }),
488
600
  }, isTaskPolledBySession);
489
601
  }
602
+ /**
603
+ * Returns whether {@link executeDeferredToolCall} will resume a task that an earlier attempt of the same call
604
+ * started and can never send a new `tools/call` for this call.
605
+ *
606
+ * It is `true` only when the call's heartbeat checkpoint names a task of the same server and tool that the
607
+ * session polls, and the session still polls tool tasks (`call.supportsToolTasks`). Then the call returns that
608
+ * task at once, without contacting the server. In every other case, including a task polled inside the tool
609
+ * Step, such as one bound to an MCP protocol session, the call may send `tools/call`: a task the server no longer
610
+ * knows is called again. Use it to skip a check that only guards a new send, such as checking a tool's current
611
+ * listing, so a catalog that is down or changed does not stop the recovery of a task already running.
612
+ *
613
+ * @example
614
+ * ```ts
615
+ * if (!tools.willResumeToolTaskWithoutSending(call)) {
616
+ * await checkCurrentListing(call.toolCall.toolName);
617
+ * }
618
+ * return tools.executeDeferredToolCall(call);
619
+ * ```
620
+ * @param call - The tool Step's call, with this source's own `toolSourceReference`.
621
+ * @returns `true` when the call resumes a session-polled task and sends nothing; otherwise `false`.
622
+ * @since 0.5.0
623
+ */
624
+ willResumeToolTaskWithoutSending(call) {
625
+ const reference = parseToolSourceReference(call.toolCall.toolSourceReference);
626
+ const saved = parseJsonObject(call.heartbeatCheckpoint);
627
+ return (call.supportsToolTasks === true &&
628
+ reference !== undefined &&
629
+ saved?.["mcp_server"] === reference.serverName &&
630
+ saved["mcp_tool"] === reference.mcpToolName &&
631
+ typeof saved["mcp_task_id"] === "string" &&
632
+ saved["mcp_task_polled_by_session"] === true);
633
+ }
490
634
  /**
491
635
  * Polls one MCP task that {@link executeDeferredToolCall} started, once.
492
636
  *
@@ -498,6 +642,8 @@ export class McpToolSource {
498
642
  * server's `pollInterval` to at most a minute.
499
643
  * @throws {ToolExecutionError} When the task failed, was cancelled, returned an `isError` result, or asked
500
644
  * for input.
645
+ * @throws {ToolOutcomeUnknownError} When the task's result reported the Dex outcome `unknown`; the session
646
+ * records `outcome_unknown`.
501
647
  * @throws {McpToolCallInterruptedError} When the server could not be reached, no longer knows the task, or
502
648
  * answered a poll with an error. The task may still run, so `PollToolTask` retries and then records the
503
649
  * call by its effect.
@@ -518,9 +664,11 @@ export class McpToolSource {
518
664
  const outcome = await call.pollTaskOnce(taskId, record.taskPollIntervalSeconds ?? null);
519
665
  switch (outcome.kind) {
520
666
  case "succeeded":
521
- return { output: outcome.output };
667
+ return await this.toolExecutionResult(outcome, target);
522
668
  case "failed":
523
669
  throw new ToolExecutionError(outcome.message);
670
+ case "outcome_unknown":
671
+ throw new ToolOutcomeUnknownError(outcome.message);
524
672
  case "task_pending":
525
673
  return { kind: "tool_task_pending", pollIntervalSeconds: outcome.pollIntervalSeconds };
526
674
  case "interrupted":
@@ -633,16 +781,24 @@ export class McpToolSource {
633
781
  async resolveTarget(toolName, toolSourceReference) {
634
782
  return this.targetFromReference(toolName, toolSourceReference) ?? (await this.targetFromListing(toolName));
635
783
  }
784
+ async toolExecutionResult(outcome, target) {
785
+ if (this.mediaStore === undefined || outcome.result === undefined || !hasImageContent(outcome.result)) {
786
+ return { output: outcome.output };
787
+ }
788
+ return await storeToolResultImages(outcome.result, this.mediaStore, target.toolName);
789
+ }
636
790
  async callTarget(target, toolArguments, options, isTaskPolledBySession) {
637
791
  const call = new McpToolCall(this.connector, this.callLimits(isTaskPolledBySession), target, toolArguments, options);
638
792
  const outcome = await call.run(savedTaskFromCheckpoint(options.heartbeatCheckpoint, target)?.taskId);
639
793
  switch (outcome.kind) {
640
794
  case "succeeded":
641
- return { output: outcome.output };
795
+ return await this.toolExecutionResult(outcome, target);
642
796
  case "task_started":
643
797
  return { kind: "tool_task_started", taskId: outcome.taskId, pollIntervalSeconds: outcome.pollIntervalSeconds };
644
798
  case "failed":
645
799
  throw new ToolExecutionError(outcome.message);
800
+ case "outcome_unknown":
801
+ throw new ToolOutcomeUnknownError(outcome.message);
646
802
  case "interrupted":
647
803
  case "task_lost":
648
804
  case "task_pending":
@@ -915,7 +1071,12 @@ class McpToolCall {
915
1071
  }
916
1072
  }
917
1073
  async sendToolCall({ client, transport }) {
918
- const meta = this.options.idempotencyKey === undefined ? undefined : { [mcpIdempotencyKeyMetaKey]: this.options.idempotencyKey };
1074
+ const meta = this.options.idempotencyKey === undefined && this.options.requestMeta === undefined
1075
+ ? undefined
1076
+ : {
1077
+ ...this.options.requestMeta,
1078
+ ...(this.options.idempotencyKey === undefined ? {} : { [mcpIdempotencyKeyMetaKey]: this.options.idempotencyKey }),
1079
+ };
919
1080
  const params = { name: this.target.mcpToolName, arguments: this.toolArguments, ...(meta === undefined ? {} : { _meta: meta }) };
920
1081
  const requestOptions = {
921
1082
  timeout: this.limits.requestTimeoutMs,
@@ -945,8 +1106,9 @@ class McpToolCall {
945
1106
  })).task;
946
1107
  }
947
1108
  catch (error) {
948
- if (error instanceof McpError && !noResultErrorCodes.has(error.code)) {
949
- return { kind: "failed", message: error.message };
1109
+ const outcome = error instanceof McpError ? outcomeFromMcpError(error) : undefined;
1110
+ if (outcome !== undefined) {
1111
+ return outcome;
950
1112
  }
951
1113
  throw error;
952
1114
  }
@@ -1055,7 +1217,7 @@ class McpToolCallReporter {
1055
1217
  });
1056
1218
  }
1057
1219
  }
1058
- function mcpToolFromListing(server, tool) {
1220
+ export function mcpToolFromListing(server, tool) {
1059
1221
  const annotations = tool.annotations === undefined
1060
1222
  ? undefined
1061
1223
  : {
@@ -1064,6 +1226,13 @@ function mcpToolFromListing(server, tool) {
1064
1226
  ...(tool.annotations.destructiveHint === undefined ? {} : { destructiveHint: tool.annotations.destructiveHint }),
1065
1227
  };
1066
1228
  const trustsAnnotations = server.trustsToolAnnotations ?? true;
1229
+ const declaration = trustsAnnotations ? readDexToolDeclaration(tool._meta, server.name, tool.name) : undefined;
1230
+ // A Dex declaration replaces the annotations, even when its effect is invalid.
1231
+ const frameworkEffect = declaration !== undefined
1232
+ ? declaration.effect
1233
+ : trustsAnnotations && annotations !== undefined
1234
+ ? toolEffectFromMcpToolAnnotations(annotations)
1235
+ : undefined;
1067
1236
  return {
1068
1237
  name: buildAgentToolName(server.name, tool.name),
1069
1238
  serverName: server.name,
@@ -1071,10 +1240,24 @@ function mcpToolFromListing(server, tool) {
1071
1240
  description: tool.description ?? tool.title ?? `MCP tool ${tool.name} of ${server.name}`,
1072
1241
  inputSchema: tool.inputSchema,
1073
1242
  annotations,
1074
- frameworkEffect: trustsAnnotations && annotations !== undefined ? toolEffectFromMcpToolAnnotations(annotations) : undefined,
1243
+ frameworkEffect,
1244
+ dexEffect: declaration?.effect,
1245
+ isApprovalRecommended: declaration?.effect !== undefined && declaration.isApprovalRecommended,
1075
1246
  taskSupport: tool.execution?.taskSupport,
1076
1247
  };
1077
1248
  }
1249
+ /** Returns a declared tool's policy; a session policy may raise its safety, never lower it. */
1250
+ function combineDeclaredToolPolicy(declaredEffect, isApprovalRecommended, sessionPolicy) {
1251
+ if (sessionPolicy === undefined) {
1252
+ return { effect: declaredEffect, requiresApproval: isApprovalRecommended };
1253
+ }
1254
+ const effect = effectConservatism[sessionPolicy.effect] > effectConservatism[declaredEffect] ? sessionPolicy.effect : declaredEffect;
1255
+ const isApprovalRequiredBySessionEffect = sessionPolicy.effect === "non_idempotent_write" || sessionPolicy.effect === "unclassified";
1256
+ return {
1257
+ effect,
1258
+ requiresApproval: sessionPolicy.requiresApproval ?? (isApprovalRecommended || isApprovalRequiredBySessionEffect),
1259
+ };
1260
+ }
1078
1261
  function buildAgentToolName(serverName, mcpToolName) {
1079
1262
  const candidate = `${serverName}__${mcpToolName}`.replace(/[^A-Za-z0-9_-]/g, "_");
1080
1263
  if (candidate.length <= toolNameLengthLimit) {
@@ -1142,12 +1325,32 @@ function savedTaskFromCheckpoint(checkpoint, target) {
1142
1325
  ? { taskId, isPolledBySession: saved["mcp_task_polled_by_session"] === true }
1143
1326
  : undefined;
1144
1327
  }
1145
- function outcomeFromToolResult(result) {
1146
- const text = renderToolResultText(result);
1328
+ export function outcomeFromToolResult(result) {
1329
+ const failureText = hasImageContent(result) ? renderToolFailureText(result) : renderToolResultText(result);
1330
+ // The Dex outcome wins over isError in both directions.
1331
+ const dexOutcome = readDexToolOutcome(result._meta);
1332
+ if (dexOutcome === "unknown") {
1333
+ return { kind: "outcome_unknown", message: failureText === "" ? unknownOutcomeFallbackMessage : failureText };
1334
+ }
1335
+ if (dexOutcome === "rejected") {
1336
+ return { kind: "failed", message: failureText === "" ? rejectedOutcomeFallbackMessage : failureText };
1337
+ }
1147
1338
  if (result.isError === true) {
1148
- return { kind: "failed", message: text === "" ? "the MCP tool reported an error" : text };
1339
+ return { kind: "failed", message: failureText === "" ? "the MCP tool reported an error" : failureText };
1340
+ }
1341
+ return { kind: "succeeded", output: renderToolResultText(result), result };
1342
+ }
1343
+ /** Maps a JSON-RPC error answer; `undefined` leaves the outcome open, so the effect class decides. */
1344
+ export function outcomeFromMcpError(error) {
1345
+ // A Dex-aware server's outcome is known, even for an error code that usually leaves it open.
1346
+ const dexOutcome = readDexErrorOutcome(error.data);
1347
+ if (dexOutcome === "unknown") {
1348
+ return { kind: "outcome_unknown", message: error.message };
1349
+ }
1350
+ if (dexOutcome === "rejected" || !noResultErrorCodes.has(error.code)) {
1351
+ return { kind: "failed", message: error.message };
1149
1352
  }
1150
- return { kind: "succeeded", output: text };
1353
+ return undefined;
1151
1354
  }
1152
1355
  /** Seconds until the next durable poll: the server's hint, backed off from the last interval to a minute. */
1153
1356
  function durableTaskPollIntervalSeconds(task, previousSeconds) {