History

Surface OpenAPI argument errors to the caller instead of redacting them (#1243)

* Surface pre-flight OpenAPI invocation errors as expected tool failures Pre-flight OpenApiInvocationError raises (missing/unresolved path params, missing or malformed request body) happen before any HTTP request and their messages are generated locally from the spec, but the dispatcher only classified ToolNotFoundError, ToolBlockedError, and validation-issue ToolInvocationError as expected failures. These caller-argument errors fell into the defect-redaction branch, so the model saw an opaque "Internal tool error [id]" with no hint that the fix is renaming or adding an argument. Classify them as invalid_tool_arguments with the real message, keyed on the pre-flight shape: statusCode None and no cause. Transport failures (None + cause carrying the raw client error) and post-response failures (Some status) keep flowing through redaction. * Validate OpenAPI args before pausing for approval Approval elicitation ran before any argument validation, so a call with a missing required path parameter or request body paused for user approval and then failed on the exact pre-flight check the approval could never satisfy (observed with queryCreate: approved, then "Missing required request body"). Add an optional validateToolArgs plugin hook that core calls only when the call is about to pause for approval (same predicate enforceApproval applies, now shared as approvalRequired). The openapi plugin implements it by running the request-building pass extracted from invoke (buildRequest: path params, request body, base64 payloads) and discarding the built request, so the validation semantics are exactly the ones invocation applies rather than a parallel re-implementation. Legacy rows without a persisted binding skip validation instead of re-parsing the spec.

Rhys Sullivan committed 4 months ago

Commit e39dbcf1 parent fd16b2b10 files · +312 −18Browse files

Unified · Split

10 files
  1. packages/core/execution/src/tool-invoker.leak.test.ts +142 −1
  2. packages/core/execution/src/tool-invoker.ts +32 −1
  3. packages/core/sdk/src/executor.ts +22 −2
  4. packages/core/sdk/src/index.ts +1 −0
  5. packages/core/sdk/src/plugin.ts +19 −0
  6. packages/plugins/openapi/src/sdk/backing.ts +22 −1
  7. packages/plugins/openapi/src/sdk/index.ts +2 −1
  8. packages/plugins/openapi/src/sdk/invoke.ts +34 −12
  9. packages/plugins/openapi/src/sdk/plugin.test.ts +34 −0
  10. packages/plugins/openapi/src/sdk/plugin.ts +4 −0
packages/core/execution/src/tool-invoker.leak.test.ts+142−1
@@ -1,5 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Data, Effect, Schema } from "effect";
2+import { Data, Effect, Option, Schema } from "effect";
33
44 import { ElicitationResponse, createExecutor, definePlugin } from "@executor-js/sdk";
55 import { makeTestConfig } from "@executor-js/sdk/testing";
@@ -20,6 +20,17 @@ class FakePluginInvocationError extends Data.TaggedError("PluginInvocationError" · 14 unmodified lines
2020 readonly cause: unknown;
2121 }> {}
2222
23+// Mirrors the openapi plugin's OpenApiInvocationError shape (the dispatcher
24+// matches it structurally by tag, not by class identity, since this package
25+// does not depend on the plugin). Pre-flight raises carry statusCode: None
26+// and no cause and must surface verbatim; transport/post-response failures
27+// must stay opaque.
28+class FakeOpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
29+ readonly message: string;
30+ readonly statusCode: Option.Option<number>;
31+ readonly cause?: unknown;
32+}> {}
33+
2334 const leakyPlugin = definePlugin(() => ({
2435 id: "leaky-test" as const,
2536 storage: () => ({}),
@@ -66,6 +77,59 @@ const leakyPlugin = definePlugin(() => ({ · 40 unmodified lines
6677 ),
6778 ),
6879 },
80+ {
81+ name: "failsPreflightPathParam",
82+ description: "",
83+ inputSchema: EmptyInputSchema,
84+ handler: () =>
85+ Effect.fail(
86+ new FakeOpenApiInvocationError({
87+ message: "Missing required path parameter: datasourceId",
88+ statusCode: Option.none(),
89+ }),
90+ ),
91+ },
92+ {
93+ name: "failsPreflightBody",
94+ description: "",
95+ inputSchema: EmptyInputSchema,
96+ handler: () =>
97+ Effect.fail(
98+ new FakeOpenApiInvocationError({
99+ message: "Missing required request body",
100+ statusCode: Option.none(),
101+ }),
102+ ),
103+ },
104+ {
105+ name: "failsOpenApiTransport",
106+ description: "",
107+ inputSchema: EmptyInputSchema,
108+ handler: () =>
109+ Effect.fail(
110+ new FakeOpenApiInvocationError({
111+ message: "HTTP request failed",
112+ statusCode: Option.none(),
113+ cause: {
114+ _tag: "RequestError",
115+ request: { url: "https://internal.service.local/v1?token=tok-456" },
116+ },
117+ }),
118+ ),
119+ },
120+ {
121+ name: "failsOpenApiPostResponse",
122+ description: "",
123+ inputSchema: EmptyInputSchema,
124+ handler: () =>
125+ Effect.fail(
126+ new FakeOpenApiInvocationError({
127+ message: "Failed to read response body",
128+ statusCode: Option.some(502),
129+ cause: { _tag: "ResponseError", note: "internal parser detail" },
130+ }),
131+ ),
132+ },
69133 ],
70134 },
71135 ],
@@ -111,3 +175,80 @@ describe("internal-error leak audit (opaque defects)", () => { · 39 unmodified lines
111175 }),
112176 );
113177 });
178+
179+describe("openapi pre-flight invocation errors (expected failures)", () => {
180+ it.effect("missing path parameter surfaces verbatim as invalid_tool_arguments", () =>
181+ Effect.gen(function* () {
182+ const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
183+ const invoker = makeExecutorToolInvoker(executor, {
184+ invokeOptions: { onElicitation: acceptAll },
185+ });
186+
187+ const result = yield* invoker.invoke({ path: "leaky.failsPreflightPathParam", args: {} });
188+ expect(result).toEqual({
189+ ok: false,
190+ error: {
191+ code: "invalid_tool_arguments",
192+ message: "Missing required path parameter: datasourceId",
193+ },
194+ });
195+ }),
196+ );
197+
198+ it.effect("missing request body surfaces verbatim as invalid_tool_arguments", () =>
199+ Effect.gen(function* () {
200+ const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
201+ const invoker = makeExecutorToolInvoker(executor, {
202+ invokeOptions: { onElicitation: acceptAll },
203+ });
204+
205+ const result = yield* invoker.invoke({ path: "leaky.failsPreflightBody", args: {} });
206+ expect(result).toEqual({
207+ ok: false,
208+ error: {
209+ code: "invalid_tool_arguments",
210+ message: "Missing required request body",
211+ },
212+ });
213+ }),
214+ );
215+
216+ it.effect("transport failure (statusCode None but cause present) stays opaque", () =>
217+ Effect.gen(function* () {
218+ const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
219+ const invoker = makeExecutorToolInvoker(executor, {
220+ invokeOptions: { onElicitation: acceptAll },
221+ });
222+
223+ const err = yield* Effect.flip(
224+ invoker.invoke({ path: "leaky.failsOpenApiTransport", args: {} }),
225+ );
226+ expect(err).toBeInstanceOf(ExecutionToolError);
227+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
228+ const msg = (err as { message: string }).message;
229+ expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
230+ expect(msg).not.toContain("internal.service.local");
231+ expect(msg).not.toContain("tok-456");
232+ expect(msg).not.toContain("HTTP request failed");
233+ }),
234+ );
235+
236+ it.effect("post-response failure (statusCode Some) stays opaque", () =>
237+ Effect.gen(function* () {
238+ const executor = yield* createExecutor(makeTestConfig({ plugins: [leakyPlugin()] as const }));
239+ const invoker = makeExecutorToolInvoker(executor, {
240+ invokeOptions: { onElicitation: acceptAll },
241+ });
242+
243+ const err = yield* Effect.flip(
244+ invoker.invoke({ path: "leaky.failsOpenApiPostResponse", args: {} }),
245+ );
246+ expect(err).toBeInstanceOf(ExecutionToolError);
247+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: leak test inspects the rendered message to assert it is the opaque generic
248+ const msg = (err as { message: string }).message;
249+ expect(msg).toMatch(/^Internal tool error \[[0-9a-f]{8}\]$/);
250+ expect(msg).not.toContain("Failed to read response body");
251+ expect(msg).not.toContain("internal parser detail");
252+ }),
253+ );
254+});
packages/core/execution/src/tool-invoker.ts+32−1
@@ -148,6 +148,29 @@ const validationIssues = (value: unknown): readonly unknown[] | null => { · 147 unmodified lines
148148 return Array.isArray(issues) ? issues : null;
149149 };
150150
151+// Pre-flight OpenAPI invocation failures (missing/unresolved path params,
152+// missing or malformed request body) are caller-argument errors raised before
153+// any HTTP request is sent. Their messages are generated locally from the
154+// spec (no upstream data, no transport internals), so they are safe to
155+// surface, and actionable: the fix is renaming or adding an argument.
156+// Discriminator: pre-flight raises carry `statusCode: None` and no `cause`.
157+// Transport failures share `statusCode: None` but wrap the raw client error
158+// (which can hold internal URLs) as `cause`, and post-response failures carry
159+// a status code; both stay on the opaque-defect path. Matched structurally
160+// because this package does not depend on the openapi plugin.
161+const openApiPreflightMessage = (value: unknown): string | null => {
162+ if (!Predicate.isTagged(value, "OpenApiInvocationError")) return null;
163+ const err = value as {
164+ readonly message?: unknown;
165+ readonly statusCode?: unknown;
166+ readonly cause?: unknown;
167+ };
168+ if (err.cause !== undefined) return null;
169+ if (!Predicate.isTagged(err.statusCode, "None")) return null;
170+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: structural match on the openapi plugin's tagged error; the pre-flight message is locally generated (no upstream data) and is the payload being surfaced
171+ return typeof err.message === "string" && err.message.length > 0 ? err.message : null;
172+};
173+
151174 const credentialResolutionToolFailure = (input: {
152175 readonly label: string;
153176 readonly message: string;
@@ -186,7 +209,8 @@ const expectedToolFailure = (value: unknown): ToolError | null => { · 32 unmodified lines
186209 };
187210 }
188211 if (Predicate.isTagged(value, "ToolInvocationError")) {
189− const issues = validationIssues((value as { readonly cause?: unknown }).cause);
212+ const cause = (value as { readonly cause?: unknown }).cause;
213+ const issues = validationIssues(cause);
190214 if (issues) {
191215 return {
192216 code: "invalid_tool_arguments",
@@ -194,6 +218,13 @@ const expectedToolFailure = (value: unknown): ToolError | null => { · 1 unmodified line
194218 details: { issues },
195219 };
196220 }
221+ const preflight = openApiPreflightMessage(cause);
222+ if (preflight) {
223+ return {
224+ code: "invalid_tool_arguments",
225+ message: preflight,
226+ };
227+ }
197228 }
198229 return null;
199230 };
packages/core/sdk/src/executor.ts+22−2
@@ -2935,6 +2935,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2934 unmodified lines
29352935 });
29362936 };
29372937
2938+ // The single source of truth for "will enforceApproval pause this call".
2939+ // Read before pre-approval arg validation so the extra validation pass
2940+ // only runs for calls that would otherwise burn a user approval.
2941+ const approvalRequired = (
2942+ annotations: ToolAnnotations | undefined,
2943+ policy: EffectivePolicy,
2944+ ): boolean => {
2945+ if (policy.action === "approve") return false;
2946+ return policy.action === "require_approval" || annotations?.requiresApproval === true;
2947+ };
2948+
29382949 const enforceApproval = (
29392950 annotations: ToolAnnotations | undefined,
29402951 address: ToolAddress,
@@ -2943,9 +2954,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
29432954 handler: ElicitationHandler,
29442955 ) =>
29452956 Effect.gen(function* () {
2946− if (policy.action === "approve") return;
2957+ if (!approvalRequired(annotations, policy)) return;
29472958 const policyForcesApproval = policy.action === "require_approval";
2948− if (!policyForcesApproval && !annotations?.requiresApproval) return;
29492959 const message = annotations?.approvalDescription
29502960 ? annotations.approvalDescription
29512961 : policyForcesApproval && policy.pattern
@@ -3154,6 +3164,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 202 unmodified lines
31543164 .pipe(wrapInvocationError);
31553165 resolvedAnnotations = map[String(parsed.tool)] ?? annotations;
31563166 }
3167+ // When this call is about to pause for approval, validate args
3168+ // first: a call that can only fail (missing required path param /
3169+ // body) must be rejected here, not after the user grants an approval
3170+ // that then goes to waste. Non-pausing calls skip this — invokeTool
3171+ // raises the identical failure moments later without the extra pass.
3172+ if (approvalRequired(resolvedAnnotations, policy) && runtime.plugin.validateToolArgs) {
3173+ yield* runtime.plugin
3174+ .validateToolArgs({ ctx: runtime.ctx, toolRow: row, args })
3175+ .pipe(wrapInvocationError);
3176+ }
31573177 yield* enforceApproval(resolvedAnnotations, address, args, policy, handler);
31583178
31593179 // Resolve every named credential input (`variable → value`); `value` is
packages/core/sdk/src/index.ts+1−0
@@ -288,6 +288,7 @@ export { · 287 unmodified lines
288288 type StaticToolInput,
289289 type ConfigureIntegrationHandlerInput,
290290 type InvokeToolInput,
291+ type ValidateToolArgsInput,
291292 type ConnectionLifecycleInput,
292293 type IntegrationConfigureDecl,
293294 type IntegrationConfigureSchema,
packages/core/sdk/src/plugin.ts+19−0
@@ -406,6 +406,15 @@ export interface InvokeToolInput<TStore = unknown> { · 405 unmodified lines
406406 readonly elicit: Elicit;
407407 }
408408
409+/** Input for `validateToolArgs` — no credential/elicit: validation runs
410+ * before approval enforcement and credential resolution, so it must depend
411+ * on nothing but the tool row and the caller's args. */
412+export interface ValidateToolArgsInput<TStore = unknown> {
413+ readonly ctx: PluginCtx<TStore>;
414+ readonly toolRow: ToolInvocationRow;
415+ readonly args: unknown;
416+}
417+
409418 /** Called when the executor removes / refreshes a connection owned by this
410419 * plugin's integration — plugin-side cleanup or re-resolution only; the
411420 * executor handles the core tool rows. */
@@ -522,6 +531,16 @@ export interface PluginSpec< · 110 unmodified lines
522531 * address. The plugin applies `input.credential` to the outbound request. */
523532 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
524533
534+ /** Validate a dynamic tool's args before the executor enforces approval,
535+ * so a call guaranteed to fail is rejected instead of pausing for an
536+ * approval the user can only waste. Fail with the same error `invokeTool`
537+ * would raise for those args; succeed (void) when the args would reach the
538+ * wire. Must be side-effect free: no credential use, no elicitation, no
539+ * outbound request. Omit to skip pre-approval validation. */
540+ readonly validateToolArgs?: (
541+ input: ValidateToolArgsInput<TStore>,
542+ ) => Effect.Effect<void, unknown>;
543+
525544 /** Bulk resolve annotations for a set of tool rows under one connection. */
526545 readonly resolveAnnotations?: (input: {
527546 readonly ctx: PluginCtx<TStore>;
packages/plugins/openapi/src/sdk/backing.ts+22−1
@@ -28,7 +28,7 @@ import { · 27 unmodified lines
2828 streamOperationBindingsFromStructure,
2929 } from "./extract";
3030 import { compileToolDefinitions, type ToolDefinition } from "./definitions";
31−import { annotationsForOperation, invokeWithLayer } from "./invoke";
31+import { annotationsForOperation, buildRequest, invokeWithLayer } from "./invoke";
3232 import { parse, type ParsedDocument } from "./parse";
3333 import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";
3434 import { type OpenapiStore, type StoredOperation } from "./store";
@@ -645,6 +645,27 @@ export const invokeOpenApiBackedTool = (input: { · 610 unmodified lines
645645 });
646646 });
647647
648+/** Pre-approval argument validation: run the exact request-building pass
649+ * `invoke` runs (path params, request body, base64 payloads) and discard the
650+ * built request. Fails with the same pre-flight OpenApiInvocationError the
651+ * real invocation would raise, so a call guaranteed to fail is rejected
652+ * before the executor pauses it for approval. Legacy rows without a
653+ * persisted binding skip validation (re-parsing the spec here would repeat
654+ * invokeTool's heavy fallback for no user-visible gain). */
655+export const validateOpenApiBackedToolArgs = (input: {
656+ readonly ctx: PluginCtx<OpenapiStore>;
657+ readonly toolRow: { readonly integration: string; readonly name: string };
658+ readonly args: unknown;
659+}) =>
660+ Effect.gen(function* () {
661+ const operation = yield* input.ctx.storage.getOperation(
662+ input.toolRow.integration,
663+ input.toolRow.name,
664+ );
665+ if (!operation) return;
666+ yield* buildRequest(operation.binding, (input.args ?? {}) as Record<string, unknown>, {});
667+ });
668+
648669 export const resolveOpenApiBackedAnnotations = (input: {
649670 readonly ctx: PluginCtx<OpenapiStore>;
650671 readonly integration: string;
packages/plugins/openapi/src/sdk/index.ts+2−1
@@ -12,7 +12,7 @@ export { · 11 unmodified lines
1212 type ByteRange,
1313 type KeepPathItem,
1414 } from "./split";
15−export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";
15+export { invoke, invokeWithLayer, buildRequest, annotationsForOperation } from "./invoke";
1616 export {
1717 buildDefsJsonStreaming,
1818 compileAndPersistOpenApiOperations,
@@ -28,6 +28,7 @@ export { · 9 unmodified lines
2828 openApiToolDefsFromCompiled,
2929 resolveOpenApiBackedAnnotations,
3030 resolveOpenApiBackedTools,
31+ validateOpenApiBackedToolArgs,
3132 type CompiledOpenApiSpec,
3233 type OpenApiPersistResult,
3334 } from "./backing";
packages/plugins/openapi/src/sdk/invoke.ts+34−12
@@ -643,25 +643,22 @@ const applyRequestBody = ( · 642 unmodified lines
643643 };
644644
645645 // ---------------------------------------------------------------------------
646−// Public API — invoke a single operation
646+// Public API — build the outbound request for an operation
647+//
648+// The full pre-flight phase of an invocation: read/validate caller args
649+// (path params, request body, base64 payloads) and construct the
650+// HttpClientRequest. Pure — nothing is sent. Exposed separately so the
651+// executor's approval flow can validate args BEFORE raising an approval
652+// elicitation, guaranteeing the validation semantics are exactly what
653+// `invoke` applies (one code path, not a parallel re-implementation).
647654 // ---------------------------------------------------------------------------
648655
649−export const invoke = Effect.fn("OpenApi.invoke")(function* (
656+export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* (
650657 operation: OperationBinding,
651658 args: Record<string, unknown>,
652659 resolvedHeaders: Record<string, string>,
653660 sourceQueryParams: Record<string, string> = {},
654661 ) {
655− const client = yield* HttpClient.HttpClient;
656−
657− yield* Effect.annotateCurrentSpan({
658− "http.method": operation.method.toUpperCase(),
659− "http.route": operation.pathTemplate,
660− "plugin.openapi.method": operation.method.toUpperCase(),
661− "plugin.openapi.path_template": operation.pathTemplate,
662− "plugin.openapi.headers.resolved_count": Object.keys(resolvedHeaders).length,
663− });
664−
665662 const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters);
666663
667664 const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
@@ -808,6 +805,31 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 140 unmodified lines
808805
809806 request = applyHeaders(request, resolvedHeaders);
810807
808+ return request;
809+});
810+
811+// ---------------------------------------------------------------------------
812+// Public API — invoke a single operation
813+// ---------------------------------------------------------------------------
814+
815+export const invoke = Effect.fn("OpenApi.invoke")(function* (
816+ operation: OperationBinding,
817+ args: Record<string, unknown>,
818+ resolvedHeaders: Record<string, string>,
819+ sourceQueryParams: Record<string, string> = {},
820+) {
821+ const client = yield* HttpClient.HttpClient;
822+
823+ yield* Effect.annotateCurrentSpan({
824+ "http.method": operation.method.toUpperCase(),
825+ "http.route": operation.pathTemplate,
826+ "plugin.openapi.method": operation.method.toUpperCase(),
827+ "plugin.openapi.path_template": operation.pathTemplate,
828+ "plugin.openapi.headers.resolved_count": Object.keys(resolvedHeaders).length,
829+ });
830+
831+ const request = yield* buildRequest(operation, args, resolvedHeaders, sourceQueryParams);
832+
811833 const response = yield* client.execute(request).pipe(
812834 Effect.mapError(
813835 (err) =>
packages/plugins/openapi/src/sdk/plugin.test.ts+34−0
@@ -565,6 +565,40 @@ describe("OpenAPI Plugin", () => { · 564 unmodified lines
565565 ),
566566 );
567567
568+ it.effect("rejects invalid args before raising the approval elicitation", () =>
569+ Effect.scoped(
570+ Effect.gen(function* () {
571+ const server = yield* servePluginTestApi();
572+ const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
573+
574+ const conn = yield* addOpenApiTestConnection(executor, server, { slug: "test" });
575+ const calls = { count: 0 };
576+ // createItem requires approval (POST) and a request body. Omitting
577+ // the body must fail pre-flight validation WITHOUT consuming an
578+ // approval: the user would otherwise approve a call that can only
579+ // fail.
580+ const failure = yield* executor
581+ .execute(
582+ conn.address("items.createItem"),
583+ {},
584+ {
585+ onElicitation: () =>
586+ Effect.sync(() => {
587+ calls.count++;
588+ return { action: "accept" as const, content: {} };
589+ }),
590+ },
591+ )
592+ .pipe(Effect.flip);
593+
594+ expect(calls.count).toBe(0);
595+ expect(Predicate.isTagged(failure, "ToolInvocationError")).toBe(true);
596+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: asserts the exact caller-facing message the pre-flight failure carries
597+ expect((failure as { message: string }).message).toBe("Missing required request body");
598+ }),
599+ ),
600+ );
601+
568602 it.effect("describes OpenAPI invocation results payload-first with http meta beside data", () =>
569603 Effect.scoped(
570604 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/plugin.ts+4−0
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
4444 openApiStoredOperationsFromCompiled,
4545 resolveOpenApiBackedAnnotations,
4646 resolveOpenApiBackedTools,
47+ validateOpenApiBackedToolArgs,
4748 } from "./backing";
4849 import { resolveServerUrl } from "./openapi-utils";
4950
@@ -1031,6 +1032,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 981 unmodified lines
10311032 });
10321033 },
10331034
1035+ validateToolArgs: ({ ctx: validateCtx, toolRow, args }) =>
1036+ validateOpenApiBackedToolArgs({ ctx: validateCtx, toolRow, args }),
1037+
10341038 resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) =>
10351039 resolveOpenApiBackedAnnotations({
10361040 ctx: annotationsCtx,