@mongodb-js/agent-engine-runner-shared 0.11.3

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 (220) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE.md +201 -0
  3. package/README.md +29 -0
  4. package/dist/agent_config.d.ts +167 -0
  5. package/dist/agent_config.d.ts.map +1 -0
  6. package/dist/agent_config.js +544 -0
  7. package/dist/call_interrupted.d.ts +12 -0
  8. package/dist/call_interrupted.d.ts.map +1 -0
  9. package/dist/call_interrupted.js +11 -0
  10. package/dist/checkpoint_workspace.d.ts +25 -0
  11. package/dist/checkpoint_workspace.d.ts.map +1 -0
  12. package/dist/checkpoint_workspace.js +44 -0
  13. package/dist/context.d.ts +235 -0
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/context.js +322 -0
  16. package/dist/db_config.d.ts +28 -0
  17. package/dist/db_config.d.ts.map +1 -0
  18. package/dist/db_config.js +66 -0
  19. package/dist/db_naming.d.ts +54 -0
  20. package/dist/db_naming.d.ts.map +1 -0
  21. package/dist/db_naming.js +94 -0
  22. package/dist/error_reporting.d.ts +67 -0
  23. package/dist/error_reporting.d.ts.map +1 -0
  24. package/dist/error_reporting.js +311 -0
  25. package/dist/generated/workflow/v1/activity_pb.d.ts +342 -0
  26. package/dist/generated/workflow/v1/activity_pb.d.ts.map +1 -0
  27. package/dist/generated/workflow/v1/activity_pb.js +115 -0
  28. package/dist/generated/workflow/v1/common_pb.d.ts +184 -0
  29. package/dist/generated/workflow/v1/common_pb.d.ts.map +1 -0
  30. package/dist/generated/workflow/v1/common_pb.js +86 -0
  31. package/dist/generated/workflow/v1/runtime_pb.d.ts +200 -0
  32. package/dist/generated/workflow/v1/runtime_pb.d.ts.map +1 -0
  33. package/dist/generated/workflow/v1/runtime_pb.js +40 -0
  34. package/dist/generated/workflow/v1/state_pb.d.ts +254 -0
  35. package/dist/generated/workflow/v1/state_pb.d.ts.map +1 -0
  36. package/dist/generated/workflow/v1/state_pb.js +68 -0
  37. package/dist/guardrails_evaluator/core.d.ts +23 -0
  38. package/dist/guardrails_evaluator/core.d.ts.map +1 -0
  39. package/dist/guardrails_evaluator/core.js +122 -0
  40. package/dist/guardrails_evaluator/index.d.ts +10 -0
  41. package/dist/guardrails_evaluator/index.d.ts.map +1 -0
  42. package/dist/guardrails_evaluator/index.js +11 -0
  43. package/dist/guardrails_evaluator/regex.d.ts +20 -0
  44. package/dist/guardrails_evaluator/regex.d.ts.map +1 -0
  45. package/dist/guardrails_evaluator/regex.js +233 -0
  46. package/dist/hooks.d.ts +109 -0
  47. package/dist/hooks.d.ts.map +1 -0
  48. package/dist/hooks.js +216 -0
  49. package/dist/http_path.d.ts +18 -0
  50. package/dist/http_path.d.ts.map +1 -0
  51. package/dist/http_path.js +53 -0
  52. package/dist/index.d.ts +35 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +41 -0
  55. package/dist/launcher.d.ts +130 -0
  56. package/dist/launcher.d.ts.map +1 -0
  57. package/dist/launcher.js +325 -0
  58. package/dist/logger.d.ts +96 -0
  59. package/dist/logger.d.ts.map +1 -0
  60. package/dist/logger.js +204 -0
  61. package/dist/mcp_oauth.d.ts +51 -0
  62. package/dist/mcp_oauth.d.ts.map +1 -0
  63. package/dist/mcp_oauth.js +389 -0
  64. package/dist/mcp_oauth_secret.d.ts +21 -0
  65. package/dist/mcp_oauth_secret.d.ts.map +1 -0
  66. package/dist/mcp_oauth_secret.js +122 -0
  67. package/dist/mcp_tools.d.ts +71 -0
  68. package/dist/mcp_tools.d.ts.map +1 -0
  69. package/dist/mcp_tools.js +301 -0
  70. package/dist/memory_appbound.d.ts +42 -0
  71. package/dist/memory_appbound.d.ts.map +1 -0
  72. package/dist/memory_appbound.js +159 -0
  73. package/dist/memory_writer.d.ts +49 -0
  74. package/dist/memory_writer.d.ts.map +1 -0
  75. package/dist/memory_writer.js +171 -0
  76. package/dist/metrics.d.ts +84 -0
  77. package/dist/metrics.d.ts.map +1 -0
  78. package/dist/metrics.js +205 -0
  79. package/dist/models.d.ts +1458 -0
  80. package/dist/models.d.ts.map +1 -0
  81. package/dist/models.js +1726 -0
  82. package/dist/node_logger.d.ts +43 -0
  83. package/dist/node_logger.d.ts.map +1 -0
  84. package/dist/node_logger.js +158 -0
  85. package/dist/owner_callback.d.ts +16 -0
  86. package/dist/owner_callback.d.ts.map +1 -0
  87. package/dist/owner_callback.js +40 -0
  88. package/dist/progress.d.ts +57 -0
  89. package/dist/progress.d.ts.map +1 -0
  90. package/dist/progress.js +140 -0
  91. package/dist/runtime.d.ts +131 -0
  92. package/dist/runtime.d.ts.map +1 -0
  93. package/dist/runtime.js +351 -0
  94. package/dist/secure_llm_proxy.d.ts +115 -0
  95. package/dist/secure_llm_proxy.d.ts.map +1 -0
  96. package/dist/secure_llm_proxy.js +922 -0
  97. package/dist/secure_wrapper.d.ts +332 -0
  98. package/dist/secure_wrapper.d.ts.map +1 -0
  99. package/dist/secure_wrapper.js +1249 -0
  100. package/dist/server/aer.d.ts +61 -0
  101. package/dist/server/aer.d.ts.map +1 -0
  102. package/dist/server/aer.js +1124 -0
  103. package/dist/server/auth.d.ts +56 -0
  104. package/dist/server/auth.d.ts.map +1 -0
  105. package/dist/server/auth.js +132 -0
  106. package/dist/server/base.d.ts +104 -0
  107. package/dist/server/base.d.ts.map +1 -0
  108. package/dist/server/base.js +150 -0
  109. package/dist/server/callInterrupt.d.ts +49 -0
  110. package/dist/server/callInterrupt.d.ts.map +1 -0
  111. package/dist/server/callInterrupt.js +68 -0
  112. package/dist/server/callback_delivery.d.ts +14 -0
  113. package/dist/server/callback_delivery.d.ts.map +1 -0
  114. package/dist/server/callback_delivery.js +141 -0
  115. package/dist/server/chunk_types.d.ts +50 -0
  116. package/dist/server/chunk_types.d.ts.map +1 -0
  117. package/dist/server/chunk_types.js +62 -0
  118. package/dist/server/cors.d.ts +52 -0
  119. package/dist/server/cors.d.ts.map +1 -0
  120. package/dist/server/cors.js +107 -0
  121. package/dist/server/drain.d.ts +169 -0
  122. package/dist/server/drain.d.ts.map +1 -0
  123. package/dist/server/drain.js +455 -0
  124. package/dist/server/function.d.ts +77 -0
  125. package/dist/server/function.d.ts.map +1 -0
  126. package/dist/server/function.js +337 -0
  127. package/dist/server/http_retry.d.ts +37 -0
  128. package/dist/server/http_retry.d.ts.map +1 -0
  129. package/dist/server/http_retry.js +157 -0
  130. package/dist/server/index.d.ts +7 -0
  131. package/dist/server/index.d.ts.map +1 -0
  132. package/dist/server/index.js +5 -0
  133. package/dist/server/metadata.d.ts +50 -0
  134. package/dist/server/metadata.d.ts.map +1 -0
  135. package/dist/server/metadata.js +193 -0
  136. package/dist/server/oe_url.d.ts +36 -0
  137. package/dist/server/oe_url.d.ts.map +1 -0
  138. package/dist/server/oe_url.js +50 -0
  139. package/dist/server/owner_url.d.ts +35 -0
  140. package/dist/server/owner_url.d.ts.map +1 -0
  141. package/dist/server/owner_url.js +146 -0
  142. package/dist/server/query.d.ts +42 -0
  143. package/dist/server/query.d.ts.map +1 -0
  144. package/dist/server/query.js +28 -0
  145. package/dist/server/tool.d.ts +138 -0
  146. package/dist/server/tool.d.ts.map +1 -0
  147. package/dist/server/tool.js +1017 -0
  148. package/dist/span_names.d.ts +21 -0
  149. package/dist/span_names.d.ts.map +1 -0
  150. package/dist/span_names.js +31 -0
  151. package/dist/structured_logging/constants.d.ts +17 -0
  152. package/dist/structured_logging/constants.d.ts.map +1 -0
  153. package/dist/structured_logging/constants.js +71 -0
  154. package/dist/structured_logging/env.d.ts +18 -0
  155. package/dist/structured_logging/env.d.ts.map +1 -0
  156. package/dist/structured_logging/env.js +39 -0
  157. package/dist/structured_logging/install.d.ts +56 -0
  158. package/dist/structured_logging/install.d.ts.map +1 -0
  159. package/dist/structured_logging/install.js +107 -0
  160. package/dist/structured_logging/layout.d.ts +9 -0
  161. package/dist/structured_logging/layout.d.ts.map +1 -0
  162. package/dist/structured_logging/layout.js +144 -0
  163. package/dist/structured_logging/serialize.d.ts +27 -0
  164. package/dist/structured_logging/serialize.d.ts.map +1 -0
  165. package/dist/structured_logging/serialize.js +61 -0
  166. package/dist/structured_logging/stdio_capture.d.ts +59 -0
  167. package/dist/structured_logging/stdio_capture.d.ts.map +1 -0
  168. package/dist/structured_logging/stdio_capture.js +164 -0
  169. package/dist/structured_logging/uncaught.d.ts +14 -0
  170. package/dist/structured_logging/uncaught.d.ts.map +1 -0
  171. package/dist/structured_logging/uncaught.js +58 -0
  172. package/dist/structured_logging.d.ts +48 -0
  173. package/dist/structured_logging.d.ts.map +1 -0
  174. package/dist/structured_logging.js +47 -0
  175. package/dist/tls_client.d.ts +61 -0
  176. package/dist/tls_client.d.ts.map +1 -0
  177. package/dist/tls_client.js +298 -0
  178. package/dist/tool_api_error.d.ts +62 -0
  179. package/dist/tool_api_error.d.ts.map +1 -0
  180. package/dist/tool_api_error.js +399 -0
  181. package/dist/tool_memory_ownership.d.ts +10 -0
  182. package/dist/tool_memory_ownership.d.ts.map +1 -0
  183. package/dist/tool_memory_ownership.js +36 -0
  184. package/dist/toolpod_handlers.d.ts +126 -0
  185. package/dist/toolpod_handlers.d.ts.map +1 -0
  186. package/dist/toolpod_handlers.js +1016 -0
  187. package/dist/tracing/exporters.d.ts +51 -0
  188. package/dist/tracing/exporters.d.ts.map +1 -0
  189. package/dist/tracing/exporters.js +327 -0
  190. package/dist/tracing/index.d.ts +3 -0
  191. package/dist/tracing/index.d.ts.map +1 -0
  192. package/dist/tracing/index.js +2 -0
  193. package/dist/tracing/setup.d.ts +76 -0
  194. package/dist/tracing/setup.d.ts.map +1 -0
  195. package/dist/tracing/setup.js +436 -0
  196. package/dist/utils.d.ts +204 -0
  197. package/dist/utils.d.ts.map +1 -0
  198. package/dist/utils.js +867 -0
  199. package/dist/workflow/activity.d.ts +71 -0
  200. package/dist/workflow/activity.d.ts.map +1 -0
  201. package/dist/workflow/activity.js +357 -0
  202. package/dist/workflow/attempt.d.ts +12 -0
  203. package/dist/workflow/attempt.d.ts.map +1 -0
  204. package/dist/workflow/attempt.js +96 -0
  205. package/dist/workflow/client.d.ts +46 -0
  206. package/dist/workflow/client.d.ts.map +1 -0
  207. package/dist/workflow/client.js +299 -0
  208. package/dist/workflow/context.d.ts +37 -0
  209. package/dist/workflow/context.d.ts.map +1 -0
  210. package/dist/workflow/context.js +350 -0
  211. package/dist/workflow/heartbeat.d.ts +15 -0
  212. package/dist/workflow/heartbeat.d.ts.map +1 -0
  213. package/dist/workflow/heartbeat.js +78 -0
  214. package/dist/workflow/index.d.ts +14 -0
  215. package/dist/workflow/index.d.ts.map +1 -0
  216. package/dist/workflow/index.js +10 -0
  217. package/dist/workflow/memory.d.ts +17 -0
  218. package/dist/workflow/memory.d.ts.map +1 -0
  219. package/dist/workflow/memory.js +184 -0
  220. package/package.json +73 -0
@@ -0,0 +1,1249 @@
1
+ /**
2
+ * SecureToolWrapper — routes all tool calls through OE for logging and policy enforcement.
3
+ *
4
+ * Provides:
5
+ * SecureToolWrapper: wraps tool execution calls to route through OE
6
+ * createSecureToolFunction: wraps a tool function with runtime context lookup
7
+ * requestOeApproval / reportOeResult: low-level OE HTTP helpers (exported for SecureLLMProxy)
8
+ * PolicyDeniedException / ToolExecutionError / LLMInvocationError: exception classes
9
+ */
10
+ import { isDeepStrictEqual } from "node:util";
11
+ import { getLogger } from "./logger.js";
12
+ import { ownerDelivered } from "./owner_callback.js";
13
+ import { SuspendPayloadSchema, ToolExecuteResponseSchema, coerceTokenUsage, } from "./models.js";
14
+ import { classifyToolAPIError, requestCredentialValues, ToolAPIErrorSchema, } from "./tool_api_error.js";
15
+ import { getRequestTimeout, getToolReadTimeout, logToolRequest, logToolResult, logCachedResult, logPolicyBlocked, OE_RETRYABLE_MAX_ATTEMPTS, } from "./utils.js";
16
+ import { getCurrentExecutionId, getCurrentOeOwnerUrl, getCurrentUserId, getCurrentWrapper, getRequestedSuspend, runWithCallAbortSignal, runWithCustomerOrigin, reportOeOwnerUrlFailure, runWithSuspendRequestContext, withExecutionSignal, } from "./context.js";
17
+ import { getSuspendHandler } from "./hooks.js";
18
+ import { fetchPlatform, getFetchOptionsForLongCall, getFetchOptionsWithTLS, } from "./tls_client.js";
19
+ import { discardResponseBody, retryAfterWaitMs, waitForRetry, } from "./server/http_retry.js";
20
+ import { SpanStatusCode } from "@opentelemetry/api";
21
+ import { getCurrentTraceContext, getTracer } from "./tracing/setup.js";
22
+ import { runWithToolMemoryReadOwnership } from "./tool_memory_ownership.js";
23
+ import { ActivityKind, DurableActivityDeniedError, DurableActivityInterrupted, ReplayedActivityFailedError, WorkflowClient, activityRequiresReconstruction, allocateActivityOrdinal, currentAttemptContext, runSerialActivity, toolActivityKey, } from "./workflow/index.js";
24
+ import { semanticInputFromJson, valueToJson } from "./workflow/activity.js";
25
+ const logger = getLogger("agent_engine_runner_shared.secure_wrapper");
26
+ const DURABLE_TOOL_RESULT_JSON_ERROR = "durable workflow values must be JSON-safe";
27
+ function isObject(v) {
28
+ return typeof v === "object" && v !== null && !Array.isArray(v);
29
+ }
30
+ /**
31
+ * Extract token usage from an LLM response's `response_metadata`.
32
+ *
33
+ * Handles provider-variant key names:
34
+ * - OpenAI: `response_metadata.usage.{prompt_tokens, completion_tokens, total_tokens}`
35
+ * - Anthropic: `response_metadata.usage.{input_tokens, output_tokens}`
36
+ * - Some providers: `response_metadata.token_usage.{...}`
37
+ *
38
+ * Returns an object with keys `prompt_tokens`, `completion_tokens`,
39
+ * `total_tokens`, `model`. All values may be `null` if unavailable.
40
+ *
41
+ * Mirrors Python's `agent_engine_runner_shared.secure_wrapper.extract_usage` 1:1.
42
+ */
43
+ export function extractUsage(response, fallbackModel = null) {
44
+ const result = {
45
+ prompt_tokens: null,
46
+ completion_tokens: null,
47
+ total_tokens: null,
48
+ model: fallbackModel,
49
+ };
50
+ const respObj = isObject(response) ? response : {};
51
+ const rm = isObject(respObj["response_metadata"])
52
+ ? respObj["response_metadata"]
53
+ : {};
54
+ // Coerce each key independently so an empty or malformed `usage`
55
+ // still falls through to `token_usage`.
56
+ let usageTu = coerceTokenUsage(rm["usage"]);
57
+ if (usageTu === undefined) {
58
+ usageTu = coerceTokenUsage(rm["token_usage"]);
59
+ }
60
+ if (usageTu === undefined) {
61
+ usageTu = coerceTokenUsage(respObj["usage_metadata"]);
62
+ }
63
+ if (usageTu === undefined) {
64
+ usageTu = coerceTokenUsage(respObj["usage"]);
65
+ }
66
+ if (usageTu !== undefined) {
67
+ const prompt = usageTu.promptTokens ?? usageTu.inputTokens;
68
+ const completion = usageTu.completionTokens ?? usageTu.outputTokens;
69
+ const total = usageTu.totalTokens;
70
+ if (prompt != null)
71
+ result.prompt_tokens = prompt;
72
+ if (completion != null)
73
+ result.completion_tokens = completion;
74
+ if (total != null) {
75
+ result.total_tokens = total;
76
+ }
77
+ else if (result.prompt_tokens !== null &&
78
+ result.completion_tokens !== null) {
79
+ result.total_tokens = result.prompt_tokens + result.completion_tokens;
80
+ }
81
+ if (usageTu.model)
82
+ result.model = usageTu.model;
83
+ }
84
+ // Python: `rm.get("model_name") or rm.get("model") or fallback_model`
85
+ const modelName = rm["model_name"] ||
86
+ rm["model"] ||
87
+ result.model ||
88
+ fallbackModel;
89
+ result.model = modelName ?? null;
90
+ return result;
91
+ }
92
+ /**
93
+ * Extract token usage from a tool-pod usage dict into `reportOeResult` kwargs.
94
+ *
95
+ * Uses `!= null` checks instead of truthy `||` so a valid `0` token count is
96
+ * not treated as missing. Computes `total_tokens` when the provider omits it.
97
+ *
98
+ * Mirrors Python's `_extract_pod_usage`. Python keeps the leading underscore
99
+ * as a "private" convention and leaves the function unexported. TS exports
100
+ * this (TS `noUnusedLocals` flags unused module-level functions, and the
101
+ * leading-underscore identifier convention doesn't translate cleanly to TS).
102
+ *
103
+ * @internal — call sites are expected to live inside the agent-engine-runner-shared
104
+ * package or downstream framework SDKs; not part of the stable public API.
105
+ */
106
+ export function extractPodUsage(usage, fallbackModel = null) {
107
+ if (!usage || Object.keys(usage).length === 0) {
108
+ return {
109
+ prompt_tokens: null,
110
+ completion_tokens: null,
111
+ total_tokens: null,
112
+ model: fallbackModel,
113
+ };
114
+ }
115
+ // Note: prefers `input_tokens` over `prompt_tokens` (Anthropic-first),
116
+ // which is the opposite of `extractUsage`. Matches Python verbatim.
117
+ let prompt = usage["input_tokens"];
118
+ if (prompt == null)
119
+ prompt = usage["prompt_tokens"];
120
+ let completion = usage["output_tokens"];
121
+ if (completion == null)
122
+ completion = usage["completion_tokens"];
123
+ let total = usage["total_tokens"];
124
+ if (total == null && prompt != null && completion != null) {
125
+ total = prompt + completion;
126
+ }
127
+ let model = usage["model"];
128
+ if (!model)
129
+ model = fallbackModel;
130
+ return {
131
+ prompt_tokens: prompt ?? null,
132
+ completion_tokens: completion ?? null,
133
+ total_tokens: total ?? null,
134
+ model: model ?? null,
135
+ };
136
+ }
137
+ // =============================================================================
138
+ // Exceptions
139
+ // =============================================================================
140
+ export class PolicyDeniedException extends Error {
141
+ reason;
142
+ /** Guardrail policy identity when the denial came from a guardrail policy. */
143
+ guardrailMeta;
144
+ constructor(reason, guardrailMeta = null, options) {
145
+ super(`Policy denied: ${reason}`, options);
146
+ this.name = "PolicyDeniedException";
147
+ this.reason = reason;
148
+ this.guardrailMeta = guardrailMeta;
149
+ }
150
+ }
151
+ class OERetryAfterError extends Error {
152
+ waitMs;
153
+ constructor(waitMs, options) {
154
+ super(`OE retry after ${waitMs}ms`, options);
155
+ this.name = "OERetryAfterError";
156
+ this.waitMs = waitMs;
157
+ }
158
+ }
159
+ /** Raised when tool execution fails. */
160
+ export class ToolExecutionError extends Error {
161
+ error;
162
+ constructor(error, options) {
163
+ super(`Tool execution error: ${error}`, options);
164
+ this.name = "ToolExecutionError";
165
+ this.error = error;
166
+ }
167
+ }
168
+ const TERMINAL_EXECUTION_REASONS = {
169
+ "execution already error": "This execution already ended in error; later tool calls are rejected.",
170
+ "execution already completed": "This execution already completed; later tool calls are rejected.",
171
+ "execution already cancelled": "This execution was cancelled; later tool calls are rejected.",
172
+ };
173
+ export class TerminalExecutionError extends ToolExecutionError {
174
+ constructor(error, options) {
175
+ super(error, options);
176
+ this.name = "TerminalExecutionError";
177
+ }
178
+ }
179
+ export function raiseForOeRejection(reason, guardrailMeta = null) {
180
+ const text = reason ?? "Policy denied";
181
+ if (Object.hasOwn(TERMINAL_EXECUTION_REASONS, text)) {
182
+ throw new TerminalExecutionError(TERMINAL_EXECUTION_REASONS[text]);
183
+ }
184
+ throw new PolicyDeniedException(text, guardrailMeta);
185
+ }
186
+ /**
187
+ * Raised when a tool call outlives its deadline.
188
+ *
189
+ * Distinct from PolicyDeniedException on purpose. A timeout means the call was
190
+ * permitted and ran — it just ran too long — so reporting it as a denial sends
191
+ * the developer to debug governance instead of their tool.
192
+ */
193
+ export class ToolCallTimeoutError extends ToolExecutionError {
194
+ toolName;
195
+ timeoutSeconds;
196
+ elapsedSeconds;
197
+ constructor(toolName, timeoutSeconds, elapsedSeconds, options) {
198
+ const message = `Tool '${toolName}' timed out after ${elapsedSeconds.toFixed(1)}s ` +
199
+ `(deadline ${timeoutSeconds.toFixed(0)}s). It can be given longer via ` +
200
+ `RUNNER_TOOL_READ_TIMEOUT, or interrupted while running.`;
201
+ super(message, options);
202
+ this.name = "ToolCallTimeoutError";
203
+ this.message = message;
204
+ this.toolName = toolName;
205
+ this.timeoutSeconds = timeoutSeconds;
206
+ this.elapsedSeconds = elapsedSeconds;
207
+ }
208
+ }
209
+ /**
210
+ * Whether an error is this call outliving a deadline, as opposed to a transport
211
+ * failure. Covers the caller's AbortSignal.timeout and Undici's own
212
+ * headers/body budgets, which surface as a TypeError carrying a code.
213
+ */
214
+ function isCallDeadlineError(exc) {
215
+ if (exc.name === "TimeoutError")
216
+ return true;
217
+ const code = exc.cause?.code;
218
+ return code === "UND_ERR_HEADERS_TIMEOUT" || code === "UND_ERR_BODY_TIMEOUT";
219
+ }
220
+ class DurableToolActivityFailedError extends Error {
221
+ constructor(message, options) {
222
+ super(message, options);
223
+ this.name = "DurableToolActivityFailedError";
224
+ }
225
+ }
226
+ const DURABLE_TOOL_FAILURE_PREFIX = "__agentic_durable_tool_failure_v1__:";
227
+ const DURABLE_TOOL_FAILURE_FALLBACK = "durable activity failed";
228
+ function toolErrorMessage(error) {
229
+ const message = error instanceof ToolExecutionError
230
+ ? error.error
231
+ : error instanceof Error
232
+ ? error.message
233
+ : String(error);
234
+ return message || DURABLE_TOOL_FAILURE_FALLBACK;
235
+ }
236
+ function encodeDurableToolFailure(error) {
237
+ const payload = {
238
+ kind: "execution",
239
+ message: toolErrorMessage(error),
240
+ };
241
+ if (error instanceof ToolCallTimeoutError) {
242
+ payload["kind"] = "timeout";
243
+ payload["tool_name"] = error.toolName;
244
+ payload["timeout_seconds"] = error.timeoutSeconds;
245
+ payload["elapsed_seconds"] = error.elapsedSeconds;
246
+ }
247
+ else if (error instanceof ExternalAPICallError) {
248
+ payload["kind"] = "external_api";
249
+ payload["tool_api_error"] = error.tool_api_error;
250
+ }
251
+ return `${DURABLE_TOOL_FAILURE_PREFIX}${JSON.stringify(payload)}`;
252
+ }
253
+ function decodeDurableToolFailure(message, cause) {
254
+ if (!message.startsWith(DURABLE_TOOL_FAILURE_PREFIX)) {
255
+ return new ToolExecutionError(message || DURABLE_TOOL_FAILURE_FALLBACK, {
256
+ cause,
257
+ });
258
+ }
259
+ let payload;
260
+ try {
261
+ payload = JSON.parse(message.slice(DURABLE_TOOL_FAILURE_PREFIX.length));
262
+ }
263
+ catch {
264
+ return new ToolExecutionError(message, { cause });
265
+ }
266
+ if (!isObject(payload))
267
+ return new ToolExecutionError(message, { cause });
268
+ const failureMessage = typeof payload["message"] === "string" && payload["message"]
269
+ ? payload["message"]
270
+ : DURABLE_TOOL_FAILURE_FALLBACK;
271
+ const kind = payload["kind"];
272
+ if (kind === "external_api") {
273
+ const parsed = ToolAPIErrorSchema.safeParse(payload["tool_api_error"]);
274
+ if (!parsed.success) {
275
+ return new ToolExecutionError(failureMessage, { cause });
276
+ }
277
+ return new ExternalAPICallError(failureMessage, parsed.data, { cause });
278
+ }
279
+ if (kind !== "timeout") {
280
+ return new ToolExecutionError(failureMessage, { cause });
281
+ }
282
+ const toolName = payload["tool_name"];
283
+ const timeoutSeconds = payload["timeout_seconds"];
284
+ const elapsedSeconds = payload["elapsed_seconds"];
285
+ if (typeof toolName !== "string" ||
286
+ typeof timeoutSeconds !== "number" ||
287
+ !Number.isFinite(timeoutSeconds) ||
288
+ typeof elapsedSeconds !== "number" ||
289
+ !Number.isFinite(elapsedSeconds)) {
290
+ return new ToolExecutionError(failureMessage, { cause });
291
+ }
292
+ return new ToolCallTimeoutError(toolName, timeoutSeconds, elapsedSeconds, {
293
+ cause,
294
+ });
295
+ }
296
+ export class ExternalAPICallError extends ToolExecutionError {
297
+ tool_api_error;
298
+ constructor(error, toolApiError, options) {
299
+ super(error, options);
300
+ this.name = "ExternalAPICallError";
301
+ this.tool_api_error = toolApiError;
302
+ }
303
+ }
304
+ export class LLMInvocationError extends Error {
305
+ error;
306
+ /** Invoke-owner attribution. Only `"llm"` means the provider failed. */
307
+ source;
308
+ /**
309
+ * Machine-readable classification the tool pod stamped on the failure
310
+ * (e.g. a provider credential rejection), when it did. Travels to the
311
+ * OE/UI on the ERROR chunk metadata instead of the generic invocation
312
+ * code so consumers can classify without string-matching prose.
313
+ */
314
+ error_code;
315
+ constructor(error, options) {
316
+ super(`LLM invocation error: ${error}`, options);
317
+ this.name = "LLMInvocationError";
318
+ this.error = error;
319
+ this.source = options?.source;
320
+ this.error_code = options?.error_code;
321
+ }
322
+ }
323
+ /**
324
+ * Request approval from OE before executing a tool/LLM call.
325
+ * Any HTTP or network error becomes PolicyDeniedException — fail-safe.
326
+ */
327
+ export async function requestOeApproval(args) {
328
+ try {
329
+ return await requestOeApprovalRaw(args);
330
+ }
331
+ catch (exc) {
332
+ if (exc instanceof OERetryAfterError) {
333
+ throw new PolicyDeniedException("OE unreachable; blocking for safety", null, { cause: exc });
334
+ }
335
+ throw exc;
336
+ }
337
+ }
338
+ async function requestOeApprovalRaw(args) {
339
+ const { oeUrl, executionId, toolName, arguments: toolArgs, step, kind, isLocal = true, redactFields, providerType, scopes, metadata, toolCallId, customHeaders, timeoutMs, } = args;
340
+ const { traceId, spanId } = getCurrentTraceContext();
341
+ const body = {
342
+ execution_id: executionId,
343
+ tool_name: toolName,
344
+ arguments: toolArgs,
345
+ step_number: step,
346
+ tool_call_id: toolCallId ?? null,
347
+ kind: kind ?? null,
348
+ is_local: isLocal,
349
+ redact_fields: redactFields ?? [],
350
+ provider_type: providerType ?? null,
351
+ scopes: scopes ?? [],
352
+ metadata: metadata ?? {},
353
+ custom_headers: customHeaders,
354
+ trace_id: traceId ?? undefined,
355
+ span_id: spanId ?? undefined,
356
+ };
357
+ let resp;
358
+ // The OE keeps /tool/execute open until the tool returns, so this deadline
359
+ // bounds the tool's own runtime. Callers with a different profile (the LLM
360
+ // proxy) pass their own timeoutMs.
361
+ const deadlineMs = timeoutMs ?? getToolReadTimeout() * 1000;
362
+ const startedAt = Date.now();
363
+ try {
364
+ // Undici's own headers/body budgets have to be raised to the call deadline
365
+ // too, or they cap it independently of the AbortSignal. The connect budget
366
+ // stays short so an unreachable OE still fails fast.
367
+ const tlsOptions = getFetchOptionsForLongCall(oeUrl, deadlineMs);
368
+ resp = await fetchPlatform(`${oeUrl.replace(/\/$/, "")}/tool/execute`, {
369
+ method: "POST",
370
+ headers: { "Content-Type": "application/json" },
371
+ body: JSON.stringify(body),
372
+ // Combine the per-call read deadline with the execution-wide abort signal
373
+ // so an AER execution timeout cancels this in-flight OE call (Python gets
374
+ // this from asyncio.wait_for cancelling the inner coroutine).
375
+ signal: withExecutionSignal(AbortSignal.timeout(deadlineMs)),
376
+ ...tlsOptions,
377
+ });
378
+ if (!resp.ok) {
379
+ const status = resp.status;
380
+ const statusText = resp.statusText;
381
+ const retryAfter = resp.headers?.get?.("Retry-After") ?? null;
382
+ await discardResponseBody(resp);
383
+ if (status === 503) {
384
+ const waitMs = retryAfterWaitMs(retryAfter);
385
+ if (waitMs !== null) {
386
+ throw new OERetryAfterError(waitMs);
387
+ }
388
+ }
389
+ throw new Error(`HTTP ${status}: ${statusText}`);
390
+ }
391
+ }
392
+ catch (exc) {
393
+ if (exc instanceof OERetryAfterError) {
394
+ throw exc;
395
+ }
396
+ // AbortSignal.any propagates the reason of whichever signal fired, so a
397
+ // TimeoutError is this call's own deadline; an AbortError is the execution
398
+ // being torn down (cancel/interrupt), which is not a timeout. Undici raises
399
+ // its own headers/body deadline as a TypeError with a code rather than a
400
+ // TimeoutError, so match that too — it is still the call outliving a
401
+ // deadline, and reporting it as an unreachable OE is what this replaces.
402
+ if (exc instanceof Error && isCallDeadlineError(exc)) {
403
+ const elapsedSeconds = (Date.now() - startedAt) / 1000;
404
+ logger.error({ step, toolName, elapsedSeconds }, `Step ${step}: Tool '${toolName}' timed out`);
405
+ throw new ToolCallTimeoutError(toolName, deadlineMs / 1000, elapsedSeconds, { cause: exc });
406
+ }
407
+ logger.error({ step, err: String(exc) }, `Step ${step}: Failed to request OE approval`);
408
+ throw new PolicyDeniedException("OE unreachable; blocking for safety", null, {
409
+ cause: exc,
410
+ });
411
+ }
412
+ // Reading the body is part of the fail-safe contract: a 200 with a
413
+ // non-JSON/truncated body (e.g. a proxy error page) must still block rather
414
+ // than surface a raw SyntaxError that bypasses the policy-denied path.
415
+ let data;
416
+ try {
417
+ data = await resp.json();
418
+ }
419
+ catch (exc) {
420
+ logger.error({ step, err: String(exc) }, `Step ${step}: Failed to read OE approval response`);
421
+ throw new PolicyDeniedException("OE response unreadable; blocking for safety", null, { cause: exc });
422
+ }
423
+ // A schema mismatch is a programming/contract bug, not a network error, so
424
+ // let the ZodError propagate rather than masking it as a policy denial.
425
+ return ToolExecuteResponseSchema.parse(data);
426
+ }
427
+ /**
428
+ * Repeat the same /tool/execute while OE says the failure is retryable.
429
+ * Reservation-loss returns status=error with retryable=true after releasing
430
+ * the step stamp. Retrying the same step here keeps durable activity from
431
+ * recording FAILED on a call that can still succeed.
432
+ */
433
+ export async function requestOeApprovalRetryable(args) {
434
+ // Started as the active span before the first requestOeApproval call, so
435
+ // its trace_id/span_id — read via getCurrentTraceContext() inside
436
+ // requestOeApproval — reflect this span, not whatever was active before it.
437
+ // Without a span here, a retried call (OE-side reservation loss) is
438
+ // invisible: the surrounding tool-node span just looks slower, with no
439
+ // record of how many attempts happened or how long each took.
440
+ return getTracer().startActiveSpan("secure_wrapper.request_oe_approval", async (span) => {
441
+ let attemptCount = 0;
442
+ try {
443
+ let response;
444
+ for (let attempt = 0; attempt < OE_RETRYABLE_MAX_ATTEMPTS; attempt++) {
445
+ try {
446
+ response = await requestOeApprovalRaw(args);
447
+ }
448
+ catch (err) {
449
+ if (!(err instanceof OERetryAfterError)) {
450
+ throw err;
451
+ }
452
+ attemptCount = attempt + 1;
453
+ if (attempt >= OE_RETRYABLE_MAX_ATTEMPTS - 1) {
454
+ throw new PolicyDeniedException("OE unreachable; blocking for safety", null, { cause: err });
455
+ }
456
+ const waited = await waitForRetry(err.waitMs, withExecutionSignal(new AbortController().signal));
457
+ if (!waited) {
458
+ throw new PolicyDeniedException("OE unreachable; blocking for safety", null, { cause: err });
459
+ }
460
+ continue;
461
+ }
462
+ attemptCount = attempt + 1;
463
+ if (response.status !== "error" || !response.retryable) {
464
+ break;
465
+ }
466
+ }
467
+ if (response === undefined) {
468
+ throw new PolicyDeniedException("OE unreachable; blocking for safety");
469
+ }
470
+ return response;
471
+ }
472
+ catch (err) {
473
+ span.recordException(err);
474
+ span.setStatus({ code: SpanStatusCode.ERROR });
475
+ throw err;
476
+ }
477
+ finally {
478
+ span.setAttribute("attempt_count", attemptCount);
479
+ span.end();
480
+ }
481
+ });
482
+ }
483
+ /**
484
+ * Report an execution result and require OE to acknowledge settlement.
485
+ *
486
+ * Started as the active span before getCurrentTraceContext() is read below,
487
+ * so the trace_id/span_id put on the wire reflect this span. Without a span
488
+ * here, a slow-to-ack OE (or a retried settlement) is invisible: the
489
+ * surrounding tool-node span just looks slower, with no record of how many
490
+ * attempts happened.
491
+ */
492
+ export async function reportOeResult(args) {
493
+ return getTracer().startActiveSpan("secure_wrapper.report_oe_result", async (span) => {
494
+ let attemptCount = 0;
495
+ try {
496
+ await reportOeResultAttempts(args, (count) => {
497
+ attemptCount = count;
498
+ });
499
+ }
500
+ catch (err) {
501
+ span.recordException(err);
502
+ span.setStatus({ code: SpanStatusCode.ERROR });
503
+ throw err;
504
+ }
505
+ finally {
506
+ span.setAttribute("attempt_count", attemptCount);
507
+ span.end();
508
+ }
509
+ });
510
+ }
511
+ async function reportOeResultAttempts(args, onAttempt) {
512
+ const { oeUrl, executionId, toolName, step, status, result, error, durationMs, podName, promptTokens, completionTokens, totalTokens, model, kind, metadata, toolCallId, ownerUrl, onOwnerFailure, toolApiError, } = args;
513
+ const { traceId, spanId } = getCurrentTraceContext();
514
+ const body = {
515
+ execution_id: executionId,
516
+ step_number: step,
517
+ tool_name: toolName,
518
+ tool_call_id: toolCallId ?? undefined,
519
+ status,
520
+ result,
521
+ error: error ?? undefined,
522
+ duration_ms: durationMs,
523
+ pod_name: podName ?? undefined,
524
+ prompt_tokens: promptTokens ?? undefined,
525
+ completion_tokens: completionTokens ?? undefined,
526
+ total_tokens: totalTokens ?? undefined,
527
+ model: model ?? undefined,
528
+ kind: kind ?? undefined,
529
+ metadata: metadata ?? {},
530
+ trace_id: traceId ?? undefined,
531
+ span_id: spanId ?? undefined,
532
+ tool_api_error: toolApiError ?? undefined,
533
+ };
534
+ const serializedBody = JSON.stringify(body);
535
+ const serviceUrl = `${oeUrl.replace(/\/$/, "")}/tool/result`;
536
+ // Owner pre-attempt per the shared owner-callback policy (owner_callback.ts):
537
+ // a single best-effort POST that does not consume the service retry budget.
538
+ const ownerResultUrl = ownerUrl
539
+ ? `${ownerUrl.replace(/\/$/, "")}/tool/result`
540
+ : null;
541
+ const attemptOffset = ownerResultUrl === null ? 0 : 1;
542
+ if (ownerResultUrl !== null)
543
+ onAttempt(1);
544
+ if (ownerResultUrl !== null &&
545
+ (await ownerDelivered(ownerResultUrl, () => ({
546
+ method: "POST",
547
+ headers: { "Content-Type": "application/json" },
548
+ body: serializedBody,
549
+ signal: AbortSignal.timeout(getRequestTimeout() * 1000),
550
+ ...getFetchOptionsWithTLS(ownerResultUrl),
551
+ }), (msg) => logger.warn(`/tool/result ${msg} URL ${serviceUrl}`)))) {
552
+ return;
553
+ }
554
+ if (ownerResultUrl !== null) {
555
+ onOwnerFailure?.();
556
+ }
557
+ const tlsOptions = getFetchOptionsWithTLS(serviceUrl);
558
+ let lastError;
559
+ for (let attempt = 0; attempt < 3; attempt += 1) {
560
+ let retryWaitMs = null;
561
+ onAttempt(attemptOffset + attempt + 1);
562
+ let response;
563
+ try {
564
+ response = await fetchPlatform(serviceUrl, {
565
+ method: "POST",
566
+ headers: { "Content-Type": "application/json" },
567
+ body: serializedBody,
568
+ signal: AbortSignal.timeout(getRequestTimeout() * 1000),
569
+ ...tlsOptions,
570
+ });
571
+ }
572
+ catch (error) {
573
+ lastError = error;
574
+ }
575
+ if (response !== undefined) {
576
+ const ok = response.ok;
577
+ const responseStatus = response.status;
578
+ const retryAfter = response.headers?.get?.("Retry-After") ?? null;
579
+ await discardResponseBody(response);
580
+ if (ok)
581
+ return;
582
+ const error = new ToolExecutionError(`OE rejected result settlement with HTTP ${responseStatus}`);
583
+ if (responseStatus >= 400 && responseStatus < 500) {
584
+ throw error;
585
+ }
586
+ lastError = error;
587
+ if (responseStatus === 503) {
588
+ retryWaitMs = retryAfterWaitMs(retryAfter);
589
+ }
590
+ }
591
+ if (attempt < 2) {
592
+ await new Promise((resolve) => setTimeout(resolve, retryWaitMs ?? 100 * 2 ** attempt));
593
+ }
594
+ }
595
+ throw new ToolExecutionError(`OE did not acknowledge tool result settlement: ${String(lastError)}`);
596
+ }
597
+ // =============================================================================
598
+ // Interrupt marker
599
+ // =============================================================================
600
+ // Marker key for an OE-triggered call interruption, kept out of the plain
601
+ // shape a real tool result could return, and namespaced to avoid collision
602
+ // with a tool's own artifact keys.
603
+ //
604
+ import { CALL_INTERRUPTED_ARTIFACT_KEY } from "./call_interrupted.js";
605
+ import { CALL_INTERRUPT_REASON } from "./server/drain.js";
606
+ // Re-exported so existing `secure_wrapper.js` importers keep working; the
607
+ // canonical owner is `./call_interrupted.js` (see its comment).
608
+ export { CALL_INTERRUPTED_ARTIFACT_KEY };
609
+ export const INTERRUPTED_CALL_CONTENT = "This call was stopped before completing.";
610
+ // Identity-checked sentinel distinguishing an OE-triggered interrupt from any
611
+ // real tool return value. Never shape-checked — a tool cannot return this by accident.
612
+ class CallInterrupted {
613
+ }
614
+ const CALL_INTERRUPTED = new CallInterrupted();
615
+ /**
616
+ * Race a tool body against its per-call abort signal. Node has no preemptive
617
+ * cancel: the abort stops the wait so the graph continues, while the body's
618
+ * detached promise runs out — its late settlement is swallowed by the race's
619
+ * own handlers.
620
+ */
621
+ function raceWithCallAbort(task, signal) {
622
+ if (signal.aborted) {
623
+ return Promise.reject(callAbortError());
624
+ }
625
+ return new Promise((resolve, reject) => {
626
+ const onAbort = () => reject(callAbortError());
627
+ signal.addEventListener("abort", onAbort, { once: true });
628
+ task.then((value) => {
629
+ signal.removeEventListener("abort", onAbort);
630
+ resolve(value);
631
+ }, (error) => {
632
+ signal.removeEventListener("abort", onAbort);
633
+ reject(error instanceof Error ? error : new Error(String(error)));
634
+ });
635
+ });
636
+ }
637
+ /** The race's rejection shape: AbortError, so error paths read it as interrupted. */
638
+ function callAbortError() {
639
+ const error = new Error(INTERRUPTED_CALL_CONTENT);
640
+ error.name = "AbortError";
641
+ return error;
642
+ }
643
+ /** A whole-run drain latched before the tool body started. */
644
+ function drainAbortError() {
645
+ const error = new Error("execution is draining");
646
+ error.name = "AbortError";
647
+ return error;
648
+ }
649
+ const INTERRUPTED_WIRE_MARKER = Object.freeze({
650
+ [CALL_INTERRUPTED_ARTIFACT_KEY]: true,
651
+ });
652
+ function isLocalTool(options) {
653
+ return ((options.isLocal ?? true) &&
654
+ !options.providerType &&
655
+ (options.scopes?.length ?? 0) === 0);
656
+ }
657
+ function buildToolActivityInput(args, options) {
658
+ const input = {
659
+ arguments: args,
660
+ is_local: isLocalTool(options),
661
+ };
662
+ if (options.metadata !== undefined)
663
+ input.metadata = options.metadata;
664
+ if (options.providerType != null) {
665
+ input.provider_type = options.providerType;
666
+ }
667
+ if (options.scopes !== undefined && options.scopes.length > 0) {
668
+ input.scopes = [...options.scopes].sort();
669
+ }
670
+ if (options.toolCallId)
671
+ input.tool_call_id = options.toolCallId;
672
+ return input;
673
+ }
674
+ function encodeToolActivityResult(value) {
675
+ let encoded;
676
+ if (value === CALL_INTERRUPTED) {
677
+ encoded = INTERRUPTED_WIRE_MARKER;
678
+ }
679
+ else if (typeof value !== "object" ||
680
+ value === null ||
681
+ Array.isArray(value) ||
682
+ !(CALL_INTERRUPTED_ARTIFACT_KEY in value)) {
683
+ encoded = value;
684
+ }
685
+ else {
686
+ const result = { ...value };
687
+ delete result[CALL_INTERRUPTED_ARTIFACT_KEY];
688
+ encoded = result;
689
+ }
690
+ assertDurableToolResultJsonSafe(encoded);
691
+ return encoded;
692
+ }
693
+ function assertDurableToolResultJsonSafe(value) {
694
+ let roundTripped;
695
+ try {
696
+ roundTripped = valueToJson(semanticInputFromJson(value));
697
+ }
698
+ catch {
699
+ throw new TypeError(DURABLE_TOOL_RESULT_JSON_ERROR);
700
+ }
701
+ if (!isDeepStrictEqual(value, roundTripped)) {
702
+ throw new TypeError(DURABLE_TOOL_RESULT_JSON_ERROR);
703
+ }
704
+ }
705
+ function decodeToolActivityResult(value, rawOnInterrupt) {
706
+ const isInterrupt = typeof value === "object" &&
707
+ value !== null &&
708
+ !Array.isArray(value) &&
709
+ Object.keys(value).length === 1 &&
710
+ value[CALL_INTERRUPTED_ARTIFACT_KEY] === true;
711
+ if (!isInterrupt)
712
+ return value;
713
+ return rawOnInterrupt ? CALL_INTERRUPTED : { interrupted: true };
714
+ }
715
+ export class OperationalStepAllocator {
716
+ n = 0;
717
+ next() {
718
+ this.n += 1;
719
+ return this.n;
720
+ }
721
+ observeAtLeast(n) {
722
+ if (n <= 0)
723
+ return;
724
+ if (n > this.n)
725
+ this.n = n;
726
+ }
727
+ current() {
728
+ return this.n;
729
+ }
730
+ }
731
+ export class SecureToolWrapper {
732
+ oeUrl;
733
+ executionId;
734
+ customHeaders;
735
+ /**
736
+ * Validated replica-specific OE owner base URL, or null. Forwarded
737
+ * to `reportOeResult` for the in-process (local-callback) tool-result path so
738
+ * settlement prefers the owning OE replica.
739
+ */
740
+ oeOwnerUrl;
741
+ ownerUrlFailed = false;
742
+ /** Shared with SecureLLMProxy for this execution (single-AER assumption). */
743
+ operationalSteps;
744
+ durableMemory = null;
745
+ /**
746
+ * Per-execution drain registry, enabling the per-call abort (POST
747
+ * /interrupt/call) for tools this wrapper runs locally; null leaves local
748
+ * calls unaddressable, as before.
749
+ */
750
+ drainRegistry;
751
+ constructor(oeUrl, executionId, customHeaders, oeOwnerUrl, drainRegistry) {
752
+ this.oeUrl = oeUrl.replace(/\/$/, "");
753
+ this.executionId = executionId;
754
+ this.customHeaders = customHeaders ?? {};
755
+ this.oeOwnerUrl = oeOwnerUrl ?? null;
756
+ this.drainRegistry = drainRegistry ?? null;
757
+ this.operationalSteps = new OperationalStepAllocator();
758
+ }
759
+ currentOeOwnerUrl() {
760
+ if (this.ownerUrlFailed)
761
+ return null;
762
+ if (getCurrentExecutionId() === this.executionId) {
763
+ return getCurrentOeOwnerUrl();
764
+ }
765
+ return this.oeOwnerUrl;
766
+ }
767
+ reportOeOwnerFailure() {
768
+ this.ownerUrlFailed = true;
769
+ if (getCurrentExecutionId() === this.executionId) {
770
+ reportOeOwnerUrlFailure();
771
+ }
772
+ }
773
+ /** Current operational-step watermark for compatibility readers. */
774
+ get stepCounter() {
775
+ return this.operationalSteps.current();
776
+ }
777
+ /** Allocate the next operational step_number for this execution. */
778
+ nextOperationalStep() {
779
+ return this.operationalSteps.next();
780
+ }
781
+ /** Raise the allocator watermark (e.g. from OE latest_step_number). */
782
+ observeOperationalStep(n) {
783
+ this.operationalSteps.observeAtLeast(n);
784
+ }
785
+ async close() {
786
+ // no-op — available for future cleanup
787
+ }
788
+ /**
789
+ * Execute a tool call through OE.
790
+ *
791
+ * Flow:
792
+ * 1. POST /tool/execute — OE approves or denies
793
+ * 2. OE returns a final outcome or routes the call back in process
794
+ * 3. Wrapper logs the outcome and converts suspend payloads back into framework interrupts
795
+ */
796
+ async executeTool(toolName, args, options = {}) {
797
+ const step = this.nextOperationalStep();
798
+ // The tool's redact_fields policy must reach the debug argument dump —
799
+ // it ships to the centralized log sink even at debug level.
800
+ logToolRequest(toolName, args, step, "TOOL", options.redactFields ?? []);
801
+ if (currentAttemptContext() === null) {
802
+ return this.executeToolNative(toolName, args, step, options);
803
+ }
804
+ return this.executeToolDurably(toolName, args, step, options);
805
+ }
806
+ async executeToolDurably(toolName, args, step, options) {
807
+ const { toolCallId, rawOnInterrupt = false } = options;
808
+ const durableMemory = this.durableMemory;
809
+ const activityKey = toolCallId ? toolActivityKey(toolCallId) : undefined;
810
+ const activityOrdinal = allocateActivityOrdinal(activityKey);
811
+ try {
812
+ const result = await runSerialActivity({
813
+ client: new WorkflowClient(this.oeUrl),
814
+ kind: ActivityKind.TOOL,
815
+ name: toolName,
816
+ activityOrdinal,
817
+ semanticInput: buildToolActivityInput(args, options),
818
+ execute: (context) => this.executeToolActivity(toolName, args, step, options, context.activityId),
819
+ onActivityResolved: durableMemory === null
820
+ ? undefined
821
+ : (client, context, value) => durableMemory.synchronizeTool(client, context, value, getCurrentUserId(), toolCallId, toolName),
822
+ exclusive: activityKey === undefined,
823
+ });
824
+ return decodeToolActivityResult(result, rawOnInterrupt);
825
+ }
826
+ catch (error) {
827
+ if (error instanceof DurableActivityDeniedError) {
828
+ if (error.cause instanceof PolicyDeniedException)
829
+ throw error.cause;
830
+ throw new PolicyDeniedException(error.message, null, {
831
+ cause: error,
832
+ });
833
+ }
834
+ if (error instanceof ReplayedActivityFailedError) {
835
+ throw decodeDurableToolFailure(error.message, error);
836
+ }
837
+ if (error instanceof DurableToolActivityFailedError) {
838
+ throw decodeDurableToolFailure(error.message, error);
839
+ }
840
+ throw error;
841
+ }
842
+ }
843
+ async executeToolActivity(toolName, args, step, options, activityId) {
844
+ try {
845
+ if (isLocalTool(options) &&
846
+ options.localExecutor !== undefined &&
847
+ activityRequiresReconstruction(activityId)) {
848
+ // OE already resolved this exact admitted activity. Re-enter its
849
+ // captured callback so LangGraph can reconstruct interrupt().
850
+ return encodeToolActivityResult(await options.localExecutor());
851
+ }
852
+ const result = await this.executeToolNative(toolName, args, step, { ...options, rawOnInterrupt: true }, "reject");
853
+ return encodeToolActivityResult(result);
854
+ }
855
+ catch (error) {
856
+ if (error instanceof PolicyDeniedException) {
857
+ throw new DurableActivityDeniedError(error.reason, { cause: error });
858
+ }
859
+ if (options.isFrameworkControlFlow?.(error) === true) {
860
+ throw new DurableActivityInterrupted(error);
861
+ }
862
+ throw new DurableToolActivityFailedError(encodeDurableToolFailure(error), {
863
+ cause: error,
864
+ });
865
+ }
866
+ }
867
+ async executeToolNative(toolName, args, step, options, interruptMode = "framework") {
868
+ const { metadata, providerType, scopes, toolCallId, rawOnInterrupt = false, isLocal = true, localExecutor, isFrameworkControlFlow, } = options;
869
+ const effectiveIsLocal = isLocalTool({
870
+ isLocal,
871
+ providerType,
872
+ scopes,
873
+ });
874
+ const effectiveLocalExecutor = effectiveIsLocal ? localExecutor : undefined;
875
+ const response = await requestOeApprovalRetryable({
876
+ oeUrl: this.oeUrl,
877
+ executionId: this.executionId,
878
+ toolName,
879
+ arguments: args,
880
+ step,
881
+ isLocal: effectiveIsLocal,
882
+ redactFields: options.redactFields,
883
+ providerType,
884
+ scopes,
885
+ toolCallId,
886
+ metadata,
887
+ customHeaders: this.customHeaders,
888
+ });
889
+ if (response.elicitation) {
890
+ if (interruptMode === "reject") {
891
+ throw new ToolExecutionError("durable Tool activities support terminal outcomes only; " +
892
+ "the framework adapter must own authorization interrupts");
893
+ }
894
+ const interrupt = getSuspendHandler();
895
+ if (interrupt == null) {
896
+ throw new Error("No suspend handler registered. Ensure the framework SDK calls registerSuspendHandler() before run().");
897
+ }
898
+ interrupt({
899
+ suspend_reason: "authorization_required",
900
+ suspend_context: {
901
+ authorization_url: response.elicitation.authorization_url,
902
+ elicitation_id: response.elicitation.elicitation_id,
903
+ message: response.elicitation.message,
904
+ created: response.elicitation.created,
905
+ },
906
+ });
907
+ throw new Error("authorization_required interrupt must halt execution; suspend handler should throw (e.g. LangGraph GraphInterrupt).");
908
+ }
909
+ if (!response.proceed) {
910
+ const reason = response.reason ?? "Policy denied";
911
+ if (!Object.hasOwn(TERMINAL_EXECUTION_REASONS, reason)) {
912
+ logPolicyBlocked(toolName, step, reason);
913
+ }
914
+ raiseForOeRejection(reason, response.guardrail_meta ?? null);
915
+ }
916
+ if (response.latest_step_number != null) {
917
+ this.observeOperationalStep(response.latest_step_number);
918
+ }
919
+ if (response.from_cache) {
920
+ logCachedResult(toolName, step);
921
+ }
922
+ if (response.route_to === "callback") {
923
+ if (!effectiveIsLocal) {
924
+ throw new ToolExecutionError("OE returned a local callback route for a tool declared as remote");
925
+ }
926
+ if (effectiveLocalExecutor == null) {
927
+ // Registered-tool adapters always install this executor. Merely
928
+ // running in AER is not enough for a low-level caller: the wrapper
929
+ // still needs the original framework tool object to invoke.
930
+ throw new ToolExecutionError("Local callback route is missing its registered tool executor");
931
+ }
932
+ const startedAt = performance.now();
933
+ // Register the call with the drain registry so POST /interrupt/call can
934
+ // address it. A controller exists only for tools that declared
935
+ // call-interrupt support: Node has no preemptive cancel, so the abort
936
+ // stops the wait, fires the signal the opted-in body honors, and lets
937
+ // the graph continue. A tool without the declaration answers
938
+ // not_cancellable and its outcome flows honestly.
939
+ const registry = this.drainRegistry;
940
+ const supportsCallInterrupt = options.supportsCallInterrupt === true;
941
+ let callController;
942
+ let preAborted = false;
943
+ if (registry != null) {
944
+ callController = supportsCallInterrupt
945
+ ? new AbortController()
946
+ : undefined;
947
+ try {
948
+ preAborted = registry.beginWork(this.executionId, callController, step);
949
+ }
950
+ catch {
951
+ // A whole-run drain latched between the OE approval and this
952
+ // dispatch: the run is terminal, so the tool must not start.
953
+ throw drainAbortError();
954
+ }
955
+ }
956
+ let localResult;
957
+ let suspendMarker = null;
958
+ try {
959
+ await runWithSuspendRequestContext(async () => {
960
+ if (preAborted) {
961
+ // The Stop landed in the OE-approval → in-graph-dispatch handoff:
962
+ // skip the body and settle interrupted.
963
+ throw callAbortError();
964
+ }
965
+ localResult = await runWithCustomerOrigin(() => {
966
+ const controller = callController;
967
+ if (controller === undefined) {
968
+ return Promise.resolve().then(() => effectiveLocalExecutor());
969
+ }
970
+ // The body must be scheduled inside the signal's scope:
971
+ // AsyncLocalStorage context is captured when the promise chain is
972
+ // created, so a chain built outside would run signal-blind.
973
+ return runWithCallAbortSignal(controller.signal, () => raceWithCallAbort(Promise.resolve().then(() => effectiveLocalExecutor()), controller.signal));
974
+ });
975
+ suspendMarker = getRequestedSuspend();
976
+ });
977
+ }
978
+ catch (error) {
979
+ // Snapshot the stop decision before the settlement awaits below: a
980
+ // Stop landing mid-report must not repaint an already-classified
981
+ // genuine error (or a drain abort) as a stopped call.
982
+ const stoppedByCallInterrupt = preAborted ||
983
+ (callController !== undefined &&
984
+ callController.signal.reason === CALL_INTERRUPT_REASON);
985
+ // Close interruptibility ahead of the report: a Stop landing
986
+ // mid-report reads already_settled, not a stop the durable record
987
+ // will contradict. No await runs between the snapshot and this claim,
988
+ // so no abort can slip between them.
989
+ registry?.claimSettlement(this.executionId, step);
990
+ const interrupted = isFrameworkControlFlow?.(error) === true ||
991
+ (error instanceof Error && error.name === "AbortError");
992
+ const propagatedError = error;
993
+ const durationMs = performance.now() - startedAt;
994
+ const status = interrupted ? "interrupted" : "error";
995
+ const classified = status === "error"
996
+ ? await classifyToolAPIError(propagatedError, undefined, requestCredentialValues())
997
+ : undefined;
998
+ const errorText = classified
999
+ ? classified.message
1000
+ : propagatedError instanceof Error
1001
+ ? `${propagatedError.name}: ${propagatedError.message}`
1002
+ : String(propagatedError);
1003
+ logToolResult(toolName, step, status, null, errorText, durationMs);
1004
+ try {
1005
+ await reportOeResult({
1006
+ oeUrl: this.oeUrl,
1007
+ ownerUrl: this.currentOeOwnerUrl(),
1008
+ onOwnerFailure: () => this.reportOeOwnerFailure(),
1009
+ executionId: this.executionId,
1010
+ toolName,
1011
+ step,
1012
+ toolCallId,
1013
+ status,
1014
+ result: null,
1015
+ error: errorText,
1016
+ durationMs,
1017
+ metadata,
1018
+ toolApiError: classified?.toolApiError,
1019
+ });
1020
+ }
1021
+ catch (settlementError) {
1022
+ throw new AggregateError([propagatedError, settlementError], "In-process tool result settlement failed", { cause: settlementError });
1023
+ }
1024
+ if (stoppedByCallInterrupt) {
1025
+ // This call was deliberately stopped: the graph continues with the
1026
+ // stopped-call result instead of unwinding.
1027
+ return rawOnInterrupt ? CALL_INTERRUPTED : { interrupted: true };
1028
+ }
1029
+ throw propagatedError;
1030
+ }
1031
+ finally {
1032
+ if (registry != null) {
1033
+ registry.endWork(this.executionId, callController, step);
1034
+ }
1035
+ }
1036
+ const durationMs = performance.now() - startedAt;
1037
+ if (suspendMarker !== null && interruptMode === "reject") {
1038
+ const error = new ToolExecutionError("durable Tool activities support terminal outcomes only; " +
1039
+ "the framework adapter must own suspension and resume");
1040
+ logToolResult(toolName, step, "error", null, error.message, durationMs);
1041
+ await reportOeResult({
1042
+ oeUrl: this.oeUrl,
1043
+ ownerUrl: this.currentOeOwnerUrl(),
1044
+ onOwnerFailure: () => this.reportOeOwnerFailure(),
1045
+ executionId: this.executionId,
1046
+ toolName,
1047
+ step,
1048
+ toolCallId,
1049
+ status: "error",
1050
+ result: null,
1051
+ error: error.message,
1052
+ durationMs,
1053
+ metadata,
1054
+ });
1055
+ throw error;
1056
+ }
1057
+ const status = suspendMarker === null ? "success" : "suspend";
1058
+ const reportedResult = suspendMarker === null ? localResult : JSON.stringify(suspendMarker);
1059
+ logToolResult(toolName, step, status, reportedResult, null, durationMs);
1060
+ await reportOeResult({
1061
+ oeUrl: this.oeUrl,
1062
+ ownerUrl: this.currentOeOwnerUrl(),
1063
+ onOwnerFailure: () => this.reportOeOwnerFailure(),
1064
+ executionId: this.executionId,
1065
+ toolName,
1066
+ step,
1067
+ toolCallId,
1068
+ status,
1069
+ result: reportedResult,
1070
+ error: null,
1071
+ durationMs,
1072
+ metadata,
1073
+ });
1074
+ if (suspendMarker !== null) {
1075
+ // suspendPayloadToJson records this out of band, so relayed tool output
1076
+ // cannot forge a wait.
1077
+ return this.handleSuspend(reportedResult);
1078
+ }
1079
+ return localResult;
1080
+ }
1081
+ if (response.route_to != null) {
1082
+ throw new ToolExecutionError(`Unexpected OE tool route: ${response.route_to}`);
1083
+ }
1084
+ const status = response.status ?? "error";
1085
+ const result = response.result;
1086
+ const error = response.error;
1087
+ const durationMs = response.duration_ms ?? 0;
1088
+ logToolResult(toolName, step, status, result, error ?? null, durationMs);
1089
+ if (status === "interrupted") {
1090
+ // Only the LangGraph tool-wrapping path requests the raw sentinel and
1091
+ // coerces it immediately; every other caller keeps the plain dict.
1092
+ return rawOnInterrupt ? CALL_INTERRUPTED : { interrupted: true };
1093
+ }
1094
+ if (status === "error") {
1095
+ if (response.tool_api_error) {
1096
+ throw new ExternalAPICallError(error ?? "Unknown error", response.tool_api_error);
1097
+ }
1098
+ throw new ToolExecutionError(error ?? "Unknown error");
1099
+ }
1100
+ // Suspend is honored only from the OE-confirmed status channel, whose
1101
+ // provenance is the tool author's suspendPayloadToJson call — never from
1102
+ // sniffing result content, which a relayed untrusted payload controls.
1103
+ // A successful result is returned verbatim.
1104
+ if (status === "suspend") {
1105
+ if (interruptMode === "reject") {
1106
+ throw new ToolExecutionError("durable Tool activities support terminal outcomes only; " +
1107
+ "the framework adapter must own suspension and resume");
1108
+ }
1109
+ return this.handleSuspend(result);
1110
+ }
1111
+ if (status !== "success") {
1112
+ throw new ToolExecutionError(`Unexpected OE tool status: ${status}`);
1113
+ }
1114
+ return result;
1115
+ }
1116
+ /** Fire the framework HITL interrupt for an OE-confirmed suspend. */
1117
+ handleSuspend(result) {
1118
+ let parsed = result;
1119
+ if (typeof result === "string") {
1120
+ try {
1121
+ parsed = JSON.parse(result);
1122
+ }
1123
+ catch {
1124
+ /* not JSON, keep as-is */
1125
+ }
1126
+ }
1127
+ if (typeof parsed !== "object" ||
1128
+ parsed === null ||
1129
+ Array.isArray(parsed)) {
1130
+ throw new ToolExecutionError("OE reported suspend but the result is not a suspend payload");
1131
+ }
1132
+ const interrupt = getSuspendHandler();
1133
+ if (interrupt == null) {
1134
+ throw new Error("No suspend handler registered. Ensure the framework SDK calls registerSuspendHandler() before run().");
1135
+ }
1136
+ const payload = SuspendPayloadSchema.parse(parsed);
1137
+ const humanDecision = interrupt(payload);
1138
+ if (typeof humanDecision !== "object" ||
1139
+ humanDecision === null ||
1140
+ Array.isArray(humanDecision)) {
1141
+ throw new TypeError(`Expected object from HITL interrupt, got ${typeof humanDecision}`);
1142
+ }
1143
+ return JSON.stringify(humanDecision);
1144
+ }
1145
+ }
1146
+ function isTwoElementArray(value) {
1147
+ return Array.isArray(value) && value.length === 2;
1148
+ }
1149
+ /**
1150
+ * Shapes a tool result for LangChain's content_and_artifact wire format.
1151
+ * `toolDeclaredFormat` (the tool's own original format, independent of the
1152
+ * forced wire-level `responseFormat`) decides whether a real result gets
1153
+ * shape-matched into (content, artifact) — otherwise it's never split.
1154
+ */
1155
+ function coerceContentAndArtifact(result, responseFormat, toolDeclaredFormat = "content") {
1156
+ if (result === CALL_INTERRUPTED) {
1157
+ if (responseFormat === "content_and_artifact") {
1158
+ return [
1159
+ INTERRUPTED_CALL_CONTENT,
1160
+ { [CALL_INTERRUPTED_ARTIFACT_KEY]: true },
1161
+ ];
1162
+ }
1163
+ return { interrupted: true };
1164
+ }
1165
+ if (responseFormat !== "content_and_artifact")
1166
+ return result;
1167
+ if (toolDeclaredFormat === "content_and_artifact" &&
1168
+ isTwoElementArray(result)) {
1169
+ const [content, artifact] = result;
1170
+ // Only the interrupted branch above may set this key — a tool's own
1171
+ // artifact must never be able to forge it.
1172
+ if (typeof artifact === "object" &&
1173
+ artifact !== null &&
1174
+ CALL_INTERRUPTED_ARTIFACT_KEY in artifact) {
1175
+ const stripped = { ...artifact };
1176
+ delete stripped[CALL_INTERRUPTED_ARTIFACT_KEY];
1177
+ return [content, stripped];
1178
+ }
1179
+ return result;
1180
+ }
1181
+ return [result, null];
1182
+ }
1183
+ /**
1184
+ * Create a wrapped tool function that routes through SecureToolWrapper.
1185
+ *
1186
+ * The wrapper is looked up from AsyncLocalStorage context at call time,
1187
+ * so the tool can be built before execution context exists.
1188
+ *
1189
+ * The registered tool must expose an `invoke` method for both approved local
1190
+ * execution and the direct-execution debugging path. Direct execution is
1191
+ * UNSAFE and only for debugging.
1192
+ */
1193
+ export function createSecureToolFunction(originalTool, toolName, allowDirect = false, options = {}) {
1194
+ const { metadata, providerType, scopes, responseFormat = "content", toolDeclaredFormat, redactFields, isLocal = true, isFrameworkControlFlow, supportsCallInterrupt = false, } = options;
1195
+ const effectiveToolFormat = toolDeclaredFormat ?? responseFormat;
1196
+ return async (kwargs = {}, config) => {
1197
+ // When a tool is invoked with a ToolCall, LangChain JS exposes it on
1198
+ // config.toolCall (toolCall.id is the stable id) and also mirrors the id
1199
+ // onto config.configurable.tool_call_id. Prefer config.toolCall.id — that is
1200
+ // the documented shape ToolNode produces — and fall back to configurable.
1201
+ // Forward it to OE for the execution-log join key.
1202
+ const rawToolCallId = config?.toolCall?.id ?? config?.configurable?.tool_call_id;
1203
+ const toolCallId = typeof rawToolCallId === "string" ? rawToolCallId : undefined;
1204
+ const wrapper = getCurrentWrapper();
1205
+ if (wrapper == null) {
1206
+ if (!allowDirect) {
1207
+ throw new Error(`SecureToolWrapper missing for tool ${toolName}; ` +
1208
+ "execution not allowed (RUNNER_ALLOW_DIRECT_TOOL_EXECUTION=false)");
1209
+ }
1210
+ logger.warn(`No wrapper for tool ${toolName}, executing directly (UNSAFE)`);
1211
+ const tool = originalTool;
1212
+ return coerceContentAndArtifact(await tool.invoke(kwargs), responseFormat, effectiveToolFormat);
1213
+ }
1214
+ // Capture the registered tool object and this invocation's arguments. OE
1215
+ // approves first; invoking this closure afterward keeps execution on the
1216
+ // framework's original stack with its live native context.
1217
+ const localExecutor = isLocal
1218
+ ? () => runWithToolMemoryReadOwnership(() => {
1219
+ const tool = originalTool;
1220
+ return tool.invoke(kwargs);
1221
+ })
1222
+ : undefined;
1223
+ try {
1224
+ const result = await wrapper.executeTool(toolName, kwargs, {
1225
+ metadata,
1226
+ providerType,
1227
+ scopes,
1228
+ toolCallId,
1229
+ rawOnInterrupt: true,
1230
+ redactFields,
1231
+ isLocal,
1232
+ localExecutor,
1233
+ isFrameworkControlFlow,
1234
+ // Absent unless declared: a present-but-false key would read as a
1235
+ // declared capability to exact-shape consumers.
1236
+ ...(supportsCallInterrupt && { supportsCallInterrupt }),
1237
+ });
1238
+ return coerceContentAndArtifact(result, responseFormat, effectiveToolFormat);
1239
+ }
1240
+ catch (exc) {
1241
+ // Both branches re-throw; the only distinction is that a
1242
+ // ToolExecutionError is logged as it propagates to the graph.
1243
+ if (exc instanceof ToolExecutionError) {
1244
+ logger.info({ tool_name: toolName, error: exc.error }, `Propagating tool execution error to graph for ${toolName}: ${exc.error}`);
1245
+ }
1246
+ throw exc;
1247
+ }
1248
+ };
1249
+ }