From f0400c97763b7a1042b7e80eaeac5bf69927e2d3 Mon Sep 17 00:00:00 2001 From: Davies Ayo Date: Sat, 12 Sep 2026 12:37:29 +1000 Subject: [PATCH] Return tool annotations from describe.tool `ToolSchemaView` carried everything about a tool except the annotations a plugin declares on it, so code written inside `execute` could not tell an approval-gated tool from a plain one except by reading its prose description. The data was already persisted on the tool row and already used by the executor's own approval copy. `tools.schema` now projects the three fields declared in `ToolAnnotations` (`requiresApproval`, `approvalDescription`, `mayElicit`), and `describe.tool` passes them through. The fields are picked explicitly rather than spread, because plugins keep private bookkeeping alongside the contract: the mcp plugin stores its upstream tool name and `_meta` map in the same column, and none of that should reach a caller. The key is omitted when a tool declares no annotations, so the describe payload does not grow for the tools that have nothing to say. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/describe-tool-annotations.md | 5 ++++ .../core/execution/src/tool-invoker.test.ts | 22 +++++++++++++++ packages/core/execution/src/tool-invoker.ts | 10 ++++++- packages/core/sdk/src/executor.ts | 28 ++++++++++++++++++- packages/core/sdk/src/index.ts | 2 +- packages/core/sdk/src/types.ts | 12 ++++++++ 6 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 .changeset/describe-tool-annotations.md diff --git a/.changeset/describe-tool-annotations.md b/.changeset/describe-tool-annotations.md new file mode 100644 index 0000000000..127b8f38af --- /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/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 747bd23105..531c7ff28a 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; }; @@ -179,6 +181,7 @@ const makeTestPlugin = (config: { description: spec.description, inputSchema: spec.inputJsonSchema, outputSchema: spec.outputJsonSchema, + ...(spec.annotations ? { annotations: spec.annotations } : {}), }), ), }), @@ -238,6 +241,10 @@ const crmPlugin = makeTestPlugin({ description: "Create a CRM contact record", inputJsonSchema: ContactInputJson, validator: ContactValidator, + annotations: { + requiresApproval: true, + approvalDescription: "Creates a contact record in the CRM", + }, handler: () => Effect.succeed({ id: "contact_1" }), }, { @@ -878,6 +885,21 @@ 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", + }); + + 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 2df47644ed..d2ef3db205 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 efaf119212..995234400b 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 =