diff --git a/.changeset/describe-tool-annotations.md b/.changeset/describe-tool-annotations.md new file mode 100644 index 000000000..127b8f38a --- /dev/null +++ b/.changeset/describe-tool-annotations.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Return a tool's declared annotations from `tools.schema` and `describe.tool`. Code inside `execute` can now read `requiresApproval`, `approvalDescription` and `mayElicit` without parsing the tool's prose description. diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad7634..584b65ee0 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 738a35882..0a9bc35d1 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -513,7 +513,9 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { }; }>(resume); expect(resumed.result?.structuredContent?.status).not.toBe("execution_not_found"); - expect(resumed.result?.structuredContent?.recovery).not.toBe("re_execute"); + expect(resumed.result?.structuredContent?.recovery, JSON.stringify(resumed.result)).not.toBe( + "re_execute", + ); expect(resumed.result?.isError).toBeFalsy(); expect(resumed.result?.structuredContent?.status).toBe("completed"); }, 60_000); diff --git a/e2e/scenarios/tool-annotations.test.ts b/e2e/scenarios/tool-annotations.test.ts new file mode 100644 index 000000000..398583db7 --- /dev/null +++ b/e2e/scenarios/tool-annotations.test.ts @@ -0,0 +1,44 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { ToolAddress } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([] as const); +const annotations = Schema.Struct({ requiresApproval: Schema.Boolean }); +const descriptions = Schema.Struct({ + gated: Schema.Struct({ annotations }), + plain: Schema.Record(Schema.String, Schema.Unknown), +}); + +scenario( + "Tool discovery · declared approval annotations survive API and sandbox descriptions", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + const address = ToolAddress.make("executor.coreTools.connections.create"); + const view = yield* client.tools.schema({ query: { address } }); + expect(view.annotations).toEqual({ requiresApproval: true }); + const described = yield* session.call("execute", { + code: ` + return JSON.stringify({ + gated: await tools.describe.tool({ path: "executor.coreTools.connections.create" }), + plain: await tools.describe.tool({ path: "executor.coreTools.connections.list" }), + }); + `, + }); + expect(described.ok, described.text).toBe(true); + const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(descriptions))( + described.text, + ); + expect(result.gated.annotations).toEqual(view.annotations); + expect(result.plain).not.toHaveProperty("annotations"); + }), +); diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 747bd2310..c9e85459d 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -18,6 +18,7 @@ import { type AnyPlugin, type CredentialProvider, type Elicit, + type ToolAnnotations, type ToolDef, } from "@executor-js/sdk"; import { @@ -127,6 +128,7 @@ type TestToolSpec = { readonly outputJsonSchema?: unknown; /** Standard-schema validator applied to args in `invokeTool`. */ readonly validator?: Validator; + readonly annotations?: ToolAnnotations; readonly handler: (input: ToolHandlerInput) => Effect.Effect; }; @@ -160,6 +162,12 @@ const validateArgs = ( ); }; +const withPrivateAnnotations = (annotations: ToolAnnotations) => ({ + ...annotations, + upstreamToolName: "private-provider-tool", + _meta: { privateMarker: "not-public" }, +}); + const makeTestPlugin = (config: { readonly pluginId: string; readonly integration: string; @@ -179,6 +187,11 @@ const makeTestPlugin = (config: { description: spec.description, inputSchema: spec.inputJsonSchema, outputSchema: spec.outputJsonSchema, + ...(spec.annotations + ? { + annotations: withPrivateAnnotations(spec.annotations), + } + : {}), }), ), }), @@ -238,6 +251,11 @@ const crmPlugin = makeTestPlugin({ description: "Create a CRM contact record", inputJsonSchema: ContactInputJson, validator: ContactValidator, + annotations: { + requiresApproval: true, + approvalDescription: "Creates a contact record in the CRM", + mayElicit: false, + }, handler: () => Effect.succeed({ id: "contact_1" }), }, { @@ -878,6 +896,22 @@ describe("tool discovery", () => { }), ); + it.effect("describes a tool's declared annotations, and omits the key when it has none", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + + const annotated = yield* describeTool(executor, "crm.org.main.createContact"); + expect(annotated.annotations).toEqual({ + requiresApproval: true, + approvalDescription: "Creates a contact record in the CRM", + mayElicit: false, + }); + + const plain = yield* describeTool(executor, "crm.org.main.listContacts"); + expect(plain.annotations).toBeUndefined(); + }), + ); + it.effect("serves an observed shape with a provenance note once a schemaless tool runs", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 2df47644e..d2ef3db20 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -82,6 +82,13 @@ type DescribedTool = { readonly outputTypeScript?: string; readonly outputTypeScriptNote?: string; readonly typeScriptDefinitions?: Record; + /** The tool's declared annotations, when it carries any. Lets code inside + * `execute` branch on approval posture without parsing the description. */ + readonly annotations?: { + readonly requiresApproval?: boolean; + readonly approvalDescription?: string; + readonly mayElicit?: boolean; + }; /** Set when the path resolves to no tool — mirrors invoke's tool_not_found. */ readonly error?: { readonly code: "tool_not_found"; @@ -135,7 +142,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap = new Map< outputTypeScript: "DescribedTool", typeScriptDefinitions: { DescribedTool: - '{ path: string; name: string; description?: string; inputTypeScript?: string; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', + '{ path: string; name: string; description?: string; inputTypeScript?: string; outputTypeScript?: string; typeScriptDefinitions?: { [k: string]: string; }; annotations?: { requiresApproval?: boolean; approvalDescription?: string; mayElicit?: boolean; }; error?: { code: "tool_not_found"; message: string; suggestions?: string[]; }; }', }, }, ], @@ -883,6 +890,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( } : {}), typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions), + ...(schema.annotations ? { annotations: schema.annotations } : {}), }; return described; }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index efaf11921..995234400 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -199,7 +199,7 @@ import { ORG_SUBJECT, type ExecutorOwnerPolicyContext, } from "./owner-policy"; -import { ToolSchemaView, type IntegrationDetectionResult } from "./types"; +import { ToolAnnotationsView, ToolSchemaView, type IntegrationDetectionResult } from "./types"; import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool"; import { buildToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; @@ -1219,6 +1219,30 @@ const rowToTool = ( }; }; +// Projects a tool's annotations onto the schema view. Plugins persist extra +// keys alongside the declared contract (the mcp plugin stores its upstream tool +// name and `_meta` there so they survive to invokeTool), so the three declared +// fields are picked explicitly rather than spread: a caller reading the view +// gets the contract in `tool.ts` and nothing a plugin keeps for itself. +const toolAnnotationsView = ( + annotations: ToolAnnotations | undefined, +): ToolAnnotationsView | undefined => { + if (!annotations) return undefined; + const view: { + requiresApproval?: boolean; + approvalDescription?: string; + mayElicit?: boolean; + } = {}; + if (typeof annotations.requiresApproval === "boolean") { + view.requiresApproval = annotations.requiresApproval; + } + if (typeof annotations.approvalDescription === "string") { + view.approvalDescription = annotations.approvalDescription; + } + if (typeof annotations.mayElicit === "boolean") view.mayElicit = annotations.mayElicit; + return Object.keys(view).length > 0 ? ToolAnnotationsView.make(view) : undefined; +}; + // --------------------------------------------------------------------------- // Condition builders // --------------------------------------------------------------------------- @@ -5650,6 +5674,7 @@ export const createExecutor =