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

Showing the changes to packages/plugins/openapi/src (5 of 10 files). Show all

Unified · Split

5 files
  1. packages/plugins/openapi/src/sdk/backing.ts +22 −1
  2. packages/plugins/openapi/src/sdk/index.ts +2 −1
  3. packages/plugins/openapi/src/sdk/invoke.ts +34 −12
  4. packages/plugins/openapi/src/sdk/plugin.test.ts +34 −0
  5. packages/plugins/openapi/src/sdk/plugin.ts +4 −0
packages/plugins/openapi/src/sdk/backing.ts+22−1
@@ -28,7 +28,7 @@ import { · 27 unmodified lines
28 streamOperationBindingsFromStructure,28 streamOperationBindingsFromStructure,
29} from "./extract";29} from "./extract";
30import { compileToolDefinitions, type ToolDefinition } from "./definitions";30import { compileToolDefinitions, type ToolDefinition } from "./definitions";
31import { annotationsForOperation, invokeWithLayer } from "./invoke";31import { annotationsForOperation, buildRequest, invokeWithLayer } from "./invoke";
32import { parse, type ParsedDocument } from "./parse";32import { parse, type ParsedDocument } from "./parse";
33import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";33import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";
34import { type OpenapiStore, type StoredOperation } from "./store";34import { type OpenapiStore, type StoredOperation } from "./store";
@@ -645,6 +645,27 @@ export const invokeOpenApiBackedTool = (input: { · 610 unmodified lines
645 });645 });
646 });646 });
647 647
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). */
655export 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
648export const resolveOpenApiBackedAnnotations = (input: {669export const resolveOpenApiBackedAnnotations = (input: {
649 readonly ctx: PluginCtx<OpenapiStore>;670 readonly ctx: PluginCtx<OpenapiStore>;
650 readonly integration: string;671 readonly integration: string;
packages/plugins/openapi/src/sdk/index.ts+2−1
@@ -12,7 +12,7 @@ export { · 11 unmodified lines
12 type ByteRange,12 type ByteRange,
13 type KeepPathItem,13 type KeepPathItem,
14} from "./split";14} from "./split";
15export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";15export { invoke, invokeWithLayer, buildRequest, annotationsForOperation } from "./invoke";
16export {16export {
17 buildDefsJsonStreaming,17 buildDefsJsonStreaming,
18 compileAndPersistOpenApiOperations,18 compileAndPersistOpenApiOperations,
@@ -28,6 +28,7 @@ export { · 9 unmodified lines
28 openApiToolDefsFromCompiled,28 openApiToolDefsFromCompiled,
29 resolveOpenApiBackedAnnotations,29 resolveOpenApiBackedAnnotations,
30 resolveOpenApiBackedTools,30 resolveOpenApiBackedTools,
31 validateOpenApiBackedToolArgs,
31 type CompiledOpenApiSpec,32 type CompiledOpenApiSpec,
32 type OpenApiPersistResult,33 type OpenApiPersistResult,
33} from "./backing";34} from "./backing";
packages/plugins/openapi/src/sdk/invoke.ts+34−12
@@ -643,25 +643,22 @@ const applyRequestBody = ( · 642 unmodified lines
643};643};
644 644
645// ---------------------------------------------------------------------------645// ---------------------------------------------------------------------------
646// Public API — invoke a single operation646// 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).
647// ---------------------------------------------------------------------------654// ---------------------------------------------------------------------------
648 655
649export const invoke = Effect.fn("OpenApi.invoke")(function* (656export const buildRequest = Effect.fn("OpenApi.buildRequest")(function* (
650 operation: OperationBinding,657 operation: OperationBinding,
651 args: Record<string, unknown>,658 args: Record<string, unknown>,
652 resolvedHeaders: Record<string, string>,659 resolvedHeaders: Record<string, string>,
653 sourceQueryParams: Record<string, string> = {},660 sourceQueryParams: Record<string, string> = {},
654) {661) {
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
665 const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters);662 const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters);
666 663
667 const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;664 const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
@@ -808,6 +805,31 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 140 unmodified lines
808 805
809 request = applyHeaders(request, resolvedHeaders);806 request = applyHeaders(request, resolvedHeaders);
810 807
808 return request;
809});
810
811// ---------------------------------------------------------------------------
812// Public API — invoke a single operation
813// ---------------------------------------------------------------------------
814
815export 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
811 const response = yield* client.execute(request).pipe(833 const response = yield* client.execute(request).pipe(
812 Effect.mapError(834 Effect.mapError(
813 (err) =>835 (err) =>
packages/plugins/openapi/src/sdk/plugin.test.ts+34−0
@@ -565,6 +565,40 @@ describe("OpenAPI Plugin", () => { · 564 unmodified lines
565 ),565 ),
566 );566 );
567 567
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
568 it.effect("describes OpenAPI invocation results payload-first with http meta beside data", () =>602 it.effect("describes OpenAPI invocation results payload-first with http meta beside data", () =>
569 Effect.scoped(603 Effect.scoped(
570 Effect.gen(function* () {604 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/plugin.ts+4−0
@@ -44,6 +44,7 @@ import { · 43 unmodified lines
44 openApiStoredOperationsFromCompiled,44 openApiStoredOperationsFromCompiled,
45 resolveOpenApiBackedAnnotations,45 resolveOpenApiBackedAnnotations,
46 resolveOpenApiBackedTools,46 resolveOpenApiBackedTools,
47 validateOpenApiBackedToolArgs,
47} from "./backing";48} from "./backing";
48import { resolveServerUrl } from "./openapi-utils";49import { resolveServerUrl } from "./openapi-utils";
49 50
@@ -1031,6 +1032,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 981 unmodified lines
1031 });1032 });
1032 },1033 },
1033 1034
1035 validateToolArgs: ({ ctx: validateCtx, toolRow, args }) =>
1036 validateOpenApiBackedToolArgs({ ctx: validateCtx, toolRow, args }),
1037
1034 resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) =>1038 resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) =>
1035 resolveOpenApiBackedAnnotations({1039 resolveOpenApiBackedAnnotations({
1036 ctx: annotationsCtx,1040 ctx: annotationsCtx,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t