Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/describe-tool-annotations.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions packages/core/execution/src/tool-invoker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type AnyPlugin,
type CredentialProvider,
type Elicit,
type ToolAnnotations,
type ToolDef,
} from "@executor-js/sdk";
import {
Expand Down Expand Up @@ -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<unknown, unknown>;
};

Expand Down Expand Up @@ -179,6 +181,7 @@ const makeTestPlugin = (config: {
description: spec.description,
inputSchema: spec.inputJsonSchema,
outputSchema: spec.outputJsonSchema,
...(spec.annotations ? { annotations: spec.annotations } : {}),
}),
),
}),
Expand Down Expand Up @@ -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" }),
},
{
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion packages/core/execution/src/tool-invoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ type DescribedTool = {
readonly outputTypeScript?: string;
readonly outputTypeScriptNote?: string;
readonly typeScriptDefinitions?: Record<string, string>;
/** 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";
Expand Down Expand Up @@ -135,7 +142,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap<string, DescribedTool> = 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[]; }; }',
},
},
],
Expand Down Expand Up @@ -883,6 +890,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* (
}
: {}),
typeScriptDefinitions: withToolResultDefinitions(schema.typeScriptDefinitions),
...(schema.annotations ? { annotations: schema.annotations } : {}),
};
return described;
});
28 changes: 27 additions & 1 deletion packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -5650,6 +5674,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
inputTypeScript: Option.getOrUndefined(preview)?.inputTypeScript,
outputTypeScript: Option.getOrUndefined(preview)?.outputTypeScript,
typeScriptDefinitions: Option.getOrUndefined(preview)?.typeScriptDefinitions,
annotations: toolAnnotationsView(tool.annotations),
});
}

Expand Down Expand Up @@ -5760,6 +5785,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
inputTypeScript: Option.getOrUndefined(view)?.inputTypeScript,
outputTypeScript: Option.getOrUndefined(view)?.outputTypeScript,
typeScriptDefinitions: Option.getOrUndefined(view)?.typeScriptDefinitions,
annotations: toolAnnotationsView(tool.annotations),
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool";
export type { CredentialProvider, ProviderEntry } from "./provider";

// Public projections / detection.
export { ToolSchemaView, IntegrationDetectionResult } from "./types";
export { ToolSchemaView, ToolAnnotationsView, IntegrationDetectionResult } from "./types";

// Health-check vocabulary (pure Schema + helpers).
export {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ import { ToolAddress } from "./ids";
// definitions for schema exploration, and optionally TypeScript preview strings.
// ---------------------------------------------------------------------------

// Mirrors the `ToolAnnotations` contract in `tool.ts` field for field. Plugins
// may stamp extra keys on a tool row (the mcp plugin carries its own routing
// data there); only the declared contract is projected, so the view never
// leaks a plugin's private bookkeeping to callers.
export const ToolAnnotationsView = Schema.Struct({
requiresApproval: Schema.optional(Schema.Boolean),
approvalDescription: Schema.optional(Schema.String),
mayElicit: Schema.optional(Schema.Boolean),
});
export type ToolAnnotationsView = typeof ToolAnnotationsView.Type;

export const ToolSchemaView = Schema.Struct({
address: ToolAddress,
name: Schema.optional(Schema.String),
Expand All @@ -29,6 +40,7 @@ export const ToolSchemaView = Schema.Struct({
inputTypeScript: Schema.optional(Schema.String),
outputTypeScript: Schema.optional(Schema.String),
typeScriptDefinitions: Schema.optional(Schema.Record(Schema.String, Schema.String)),
annotations: Schema.optional(ToolAnnotationsView),
});
export type ToolSchemaView = typeof ToolSchemaView.Type;

Expand Down
Loading