History

Extend the plugin seam for virtual-tool plugins (#1361)

Plugins that publish higher-level tools over existing integrations need three things the seam did not offer: a way to invoke another catalog tool inside the same request context (ctx.execute, keeping policy, approval, and credential resolution in the core path), opaque provider-item storage for values they own that are not connections, and hooks to project tool schemas at read time and clean up on integration removal. Binding failures (a caller naming an unknown or mismatched connection) now surface as a binding_error tool result naming the role and integration instead of an opaque internal tool error.

Rhys Sullivan committed 4 months ago

Commit 469c0671 parent 44b82104 files · +235 −8Browse files

Unified · Split

4 files
  1. packages/core/execution/src/tool-invoker.test.ts +48 −0
  2. packages/core/execution/src/tool-invoker.ts +24 −0
  3. packages/core/sdk/src/executor.ts +96 −8
  4. packages/core/sdk/src/plugin.ts +67 −0
packages/core/execution/src/tool-invoker.test.ts+48−0
@@ -305,6 +305,27 @@ const unmarkedErrorPlugin = makeTestPlugin({ · 304 unmodified lines
305305 ],
306306 });
307307
308+const bindingErrorPlugin = makeTestPlugin({
309+ pluginId: "binding-error-test",
310+ integration: "binding",
311+ tools: [
312+ {
313+ name: "sync",
314+ description: "Sync with a caller-selected connection",
315+ inputJsonSchema: EmptyInputJson,
316+ validator: EmptyValidator,
317+ handler: () =>
318+ Effect.fail({
319+ _tag: "BindingError",
320+ message: 'unknown connection "personal" for role "github" (github)',
321+ role: "github",
322+ integration: "github",
323+ requestedConnection: "personal",
324+ }),
325+ },
326+ ],
327+});
328+
308329 const oauthErrorPlugin = definePlugin(() => ({
309330 id: "oauth-error-test" as const,
310331 storage: () => ({}),
@@ -1215,6 +1236,33 @@ describe("tool discovery", () => { · 904 unmodified lines
12151236 }),
12161237 );
12171238
1239+ it.effect("returns binding errors as ToolResult.fail", () =>
1240+ Effect.gen(function* () {
1241+ const executor = yield* makeExecutorWith([bindingErrorPlugin] as const);
1242+ yield* provision(executor as never, [
1243+ { pluginId: "binding-error-test", integration: "binding" },
1244+ ]);
1245+ const invoker = makeExecutorToolInvoker(executor, {
1246+ invokeOptions: { onElicitation: acceptAll },
1247+ });
1248+
1249+ const result = yield* invoker.invoke({ path: "binding.org.main.sync", args: {} });
1250+
1251+ expect(result).toEqual({
1252+ ok: false,
1253+ error: {
1254+ code: "binding_error",
1255+ message: 'unknown connection "personal" for role "github" (github)',
1256+ details: {
1257+ role: "github",
1258+ integration: "github",
1259+ requestedConnection: "personal",
1260+ },
1261+ },
1262+ });
1263+ }),
1264+ );
1265+
12181266 it.effect("preserves nested upstream error bodies through ToolResult.fail", () =>
12191267 Effect.gen(function* () {
12201268 const executor = yield* makeExecutorWith([structuredFailurePlugin] as const);
packages/core/execution/src/tool-invoker.ts+24−0
@@ -189,6 +189,28 @@ const credentialResolutionToolFailure = (input: { · 188 unmodified lines
189189 },
190190 });
191191
192+const bindingToolFailure = (value: unknown): ToolError | null => {
193+ if (!Predicate.isTagged(value, "BindingError")) return null;
194+ const maybeBinding = value as {
195+ readonly message?: unknown;
196+ readonly role?: unknown;
197+ readonly integration?: unknown;
198+ readonly requestedConnection?: unknown;
199+ };
200+ const details: Record<string, string> = {};
201+ if (typeof maybeBinding.role === "string") details.role = maybeBinding.role;
202+ if (typeof maybeBinding.integration === "string") details.integration = maybeBinding.integration;
203+ if (typeof maybeBinding.requestedConnection === "string") {
204+ details.requestedConnection = maybeBinding.requestedConnection;
205+ }
206+ return {
207+ code: "binding_error",
208+ message:
209+ typeof maybeBinding.message === "string" ? maybeBinding.message : "Tool binding failed.",
210+ ...(Object.keys(details).length > 0 ? { details } : {}),
211+ };
212+};
213+
192214 const expectedToolFailure = (value: unknown): ToolError | null => {
193215 if (isUserActionableError(value)) {
194216 return {
@@ -223,6 +245,8 @@ const expectedToolFailure = (value: unknown): ToolError | null => { · 28 unmodified lines
223245 message: cause.userMessage,
224246 };
225247 }
248+ const binding = bindingToolFailure(cause);
249+ if (binding) return binding;
226250 const issues = validationIssues(cause);
227251 if (issues) {
228252 return {
packages/core/sdk/src/executor.ts+96−8
@@ -1944,6 +1944,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1943 unmodified lines
19441944 if (!existing.can_remove) {
19451945 return yield* new IntegrationRemovalNotAllowedError({ slug });
19461946 }
1947+ const runtime = runtimes.get(existing.plugin_id);
1948+ if (runtime?.plugin.removeIntegration) {
1949+ yield* runtime.plugin
1950+ .removeIntegration({
1951+ ctx: runtime.ctx,
1952+ integration: rowToIntegrationRecord(existing, describeAuthMethodsForRow(existing)),
1953+ })
1954+ .pipe(
1955+ Effect.mapError((cause) =>
1956+ pluginStorageFailure(existing.plugin_id, "removeIntegration", cause),
1957+ ),
1958+ );
1959+ }
19471960 // Drop owned connections / tools / definitions for this integration.
19481961 const where = (b: AnyCb) => b("integration", "=", String(slug));
19491962 yield* core.deleteMany("tool", { where });
@@ -2121,6 +2134,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 171 unmodified lines
21212134
21222135 const result: ResolveToolsResult = yield* runtime.plugin
21232136 .resolveTools({
2137+ ctx: runtime.ctx,
21242138 integration: rowToIntegration(integrationRow),
21252139 config: decodeJsonColumn(integrationRow.config),
21262140 httpClientLayer: runtime.ctx.httpClientLayer,
@@ -3212,6 +3226,30 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1085 unmodified lines
32123226 );
32133227 if (effective.action === "block") return null;
32143228
3229+ const runtime = runtimes.get(row.plugin_id);
3230+ const projected = runtime?.plugin.projectToolSchema
3231+ ? yield* runtime.plugin
3232+ .projectToolSchema({
3233+ ctx: runtime.ctx,
3234+ toolRow: row,
3235+ inputSchema: tool.inputSchema,
3236+ outputSchema: tool.outputSchema,
3237+ })
3238+ .pipe(
3239+ Effect.mapError((cause) =>
3240+ pluginStorageFailure(row.plugin_id, "projectToolSchema", cause),
3241+ ),
3242+ )
3243+ : null;
3244+ const inputSchema =
3245+ projected && Object.prototype.hasOwnProperty.call(projected, "inputSchema")
3246+ ? projected.inputSchema
3247+ : tool.inputSchema;
3248+ const outputSchema =
3249+ projected && Object.prototype.hasOwnProperty.call(projected, "outputSchema")
3250+ ? projected.outputSchema
3251+ : tool.outputSchema;
3252+
32153253 const definitionRows = yield* core.findMany("definition", {
32163254 where: (b: AnyCb) =>
32173255 b.and(
@@ -3223,15 +3261,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
32233261 const defs = new Map<string, unknown>();
32243262 for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));
32253263
3226− const referenced = collectReferencedDefinitions(
3227− [tool.inputSchema, tool.outputSchema],
3228− defs,
3229− );
3264+ const referenced = collectReferencedDefinitions([inputSchema, outputSchema], defs);
32303265 const preview = yield* Effect.tryPromise({
32313266 try: () =>
32323267 buildToolTypeScriptPreview({
3233− inputSchema: tool.inputSchema,
3234− outputSchema: tool.outputSchema,
3268+ inputSchema,
3269+ outputSchema,
32353270 defs,
32363271 }),
32373272 catch: (cause) =>
@@ -3243,8 +3278,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
32433278 address,
32443279 name: tool.name,
32453280 description: tool.description,
3246− inputSchema: tool.inputSchema,
3247− outputSchema: tool.outputSchema,
3281+ inputSchema,
3282+ outputSchema,
32483283 schemaDefinitions:
32493284 Object.keys(referenced).length > 0
32503285 ? (referenced as Record<string, unknown>)
@@ -3271,6 +3306,53 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
32713306 return yield* provider.list();
32723307 });
32733308
3309+ const providersGet = (
3310+ key: ProviderKey,
3311+ id: ProviderItemId,
3312+ ): Effect.Effect<string | null, StorageFailure> =>
3313+ Effect.gen(function* () {
3314+ const provider = credentialProviders.get(String(key));
3315+ if (!provider) return null;
3316+ return yield* provider.get(id);
3317+ });
3318+
3319+ const providersHas = (
3320+ key: ProviderKey,
3321+ id: ProviderItemId,
3322+ ): Effect.Effect<boolean, StorageFailure> =>
3323+ Effect.gen(function* () {
3324+ const provider = credentialProviders.get(String(key));
3325+ if (!provider) return false;
3326+ if (provider.has) return yield* provider.has(id);
3327+ const value = yield* provider.get(id);
3328+ return value !== null;
3329+ });
3330+
3331+ const providersSetDefault = (
3332+ id: ProviderItemId,
3333+ value: string,
3334+ ): Effect.Effect<ProviderKey, CredentialProviderNotRegisteredError | StorageFailure> =>
3335+ Effect.gen(function* () {
3336+ const provider = defaultWritableProvider();
3337+ if (!provider || !provider.set) {
3338+ return yield* new CredentialProviderNotRegisteredError({
3339+ provider: ProviderKey.make("default"),
3340+ });
3341+ }
3342+ yield* provider.set(id, value);
3343+ return provider.key;
3344+ });
3345+
3346+ const providersRemove = (
3347+ key: ProviderKey,
3348+ id: ProviderItemId,
3349+ ): Effect.Effect<void, StorageFailure> =>
3350+ Effect.gen(function* () {
3351+ const provider = credentialProviders.get(String(key));
3352+ if (!provider || !provider.delete) return;
3353+ yield* provider.delete(id);
3354+ });
3355+
32743356 // ------------------------------------------------------------------
32753357 // Policies — owner-ranked (user=0 inner, org=1 outer).
32763358 // ------------------------------------------------------------------
@@ -3700,6 +3782,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 423 unmodified lines
37003782 credential,
37013783 args,
37023784 elicit: buildElicit(address, args, handler),
3785+ invokeOptions: options,
37033786 }),
37043787 );
37053788 }).pipe(
@@ -3854,8 +3937,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 148 unmodified lines
38543937 providers: {
38553938 list: () => providersList(),
38563939 items: (key) => providersItems(key),
3940+ get: (key, id) => providersGet(key, id),
3941+ has: (key, id) => providersHas(key, id),
3942+ setDefault: (id, value) => providersSetDefault(id, value),
3943+ remove: (key, id) => providersRemove(key, id),
38573944 },
38583945 oauth,
3946+ execute: (address, args, options) => execute(address, args, options),
38593947 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
38603948 };
38613949
packages/core/sdk/src/plugin.ts+67−0
@@ -26,9 +26,11 @@ import type { · 25 unmodified lines
2626 ConnectionName,
2727 IntegrationSlug,
2828 Owner,
29+ ProviderItemId,
2930 ProviderKey,
3031 Subject,
3132 Tenant,
33+ ToolAddress,
3234 } from "./ids";
3335 import type { IntegrationDetectionResult } from "./types";
3436 import type {
@@ -36,8 +38,10 @@ import type { · 1 unmodified line
3638 ElicitationHandler,
3739 ElicitationRequest,
3840 ElicitationResponse,
41+ InvokeOptions,
3942 } from "./elicitation";
4043 import type {
44+ ExecuteError,
4145 ConnectionNotFoundError,
4246 CredentialProviderNotRegisteredError,
4347 IntegrationNotFoundError,
@@ -244,11 +248,41 @@ export interface PluginCtx<TStore = unknown> { · 200 unmodified lines
244248 readonly items: (
245249 provider: ProviderKey,
246250 ) => Effect.Effect<readonly ProviderEntry[], StorageFailure>;
251+ /** Read an opaque item from a provider. Plugins use this for secret values
252+ * they own that are not modeled as connections. */
253+ readonly get: (
254+ provider: ProviderKey,
255+ id: ProviderItemId,
256+ ) => Effect.Effect<string | null, StorageFailure>;
257+ readonly has: (
258+ provider: ProviderKey,
259+ id: ProviderItemId,
260+ ) => Effect.Effect<boolean, StorageFailure>;
261+ /** Write through the executor's default writable provider and return the
262+ * provider key that owns the item. */
263+ readonly setDefault: (
264+ id: ProviderItemId,
265+ value: string,
266+ ) => Effect.Effect<ProviderKey, CredentialProviderNotRegisteredError | StorageFailure>;
267+ readonly remove: (
268+ provider: ProviderKey,
269+ id: ProviderItemId,
270+ ) => Effect.Effect<void, StorageFailure>;
247271 };
248272
249273 /** Shared OAuth service. */
250274 readonly oauth: OAuthService;
251275
276+ /** Invoke another catalog tool through the same executor request context:
277+ * policy, approval, credential resolution, and plugin dispatch all stay in
278+ * the core path. Intended for plugin sandboxes that expose higher-level
279+ * virtual tools over existing integration tools. */
280+ readonly execute: (
281+ address: ToolAddress,
282+ args: unknown,
283+ options?: InvokeOptions,
284+ ) => Effect.Effect<unknown, ExecuteError>;
285+
252286 /** Run `effect` inside a FumaDB transaction (atomic across plugin storage +
253287 * core integration/tool writes). */
254288 readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>;
@@ -261,6 +295,7 @@ export interface PluginCtx<TStore = unknown> { · 6 unmodified lines
261295 // ---------------------------------------------------------------------------
262296
263297 export interface ResolveToolsInput<TStore = unknown> {
298+ readonly ctx?: PluginCtx<TStore>;
264299 /** The catalog record (public projection) whose connection is being resolved. */
265300 readonly integration: Integration;
266301 /** The plugin's stored opaque config for that integration. */
@@ -302,6 +337,18 @@ export interface ResolveToolsResult { · 35 unmodified lines
302337 readonly incompleteReason?: string;
303338 }
304339
340+export interface ProjectToolSchemaInput<TStore = unknown> {
341+ readonly ctx: PluginCtx<TStore>;
342+ readonly toolRow: ToolInvocationRow;
343+ readonly inputSchema?: unknown;
344+ readonly outputSchema?: unknown;
345+}
346+
347+export interface ProjectToolSchemaResult {
348+ readonly inputSchema?: unknown;
349+ readonly outputSchema?: unknown;
350+}
351+
305352 // ---------------------------------------------------------------------------
306353 // Resolved credential handed to `invokeTool` so the plugin renders auth onto
307354 // the request (D11: "auth state derived into the auth-template format").
@@ -457,6 +504,8 @@ export interface InvokeToolInput<TStore = unknown> { · 149 unmodified lines
457504 readonly credential: ToolInvocationCredential;
458505 readonly args: unknown;
459506 readonly elicit: Elicit;
507+ /** Original caller options for nested same-request tool calls. */
508+ readonly invokeOptions?: InvokeOptions;
460509 }
461510
462511 /** Input for `validateToolArgs` — no credential/elicit: validation runs
@@ -477,6 +526,11 @@ export interface ConnectionLifecycleInput<TStore = unknown> { · 14 unmodified lines
477526 readonly connection: ConnectionRef;
478527 }
479528
529+export interface IntegrationLifecycleInput<TStore = unknown> {
530+ readonly ctx: PluginCtx<TStore>;
531+ readonly integration: IntegrationRecord;
532+}
533+
480534 export interface ConfigureIntegrationHandlerInput<TStore = unknown> {
481535 readonly ctx: PluginCtx<TStore>;
482536 readonly integration: IntegrationSlug;
@@ -615,6 +669,13 @@ export interface PluginSpec< · 132 unmodified lines
615669 * for catalogs derived purely from stored state (specs, static config). */
616670 readonly remoteToolCatalog?: boolean;
617671
672+ /** Project a persisted tool row's stable schemas into the request-visible
673+ * schema view. Use this only for volatile presentation data that should be
674+ * current at read time, not persisted at catalog-refresh time. */
675+ readonly projectToolSchema?: (
676+ input: ProjectToolSchemaInput<TStore>,
677+ ) => Effect.Effect<ProjectToolSchemaResult, unknown>;
678+
618679 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the
619680 * address. The plugin applies `input.credential` to the outbound request. */
620681 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
@@ -642,6 +703,12 @@ export interface PluginSpec< · 21 unmodified lines
642703 input: ConnectionLifecycleInput<TStore>,
643704 ) => Effect.Effect<void, unknown>;
644705
706+ /** Plugin-side cleanup when a removable integration is removed. Core still
707+ * owns deleting the integration, connection, tool, and definition rows. */
708+ readonly removeIntegration?: (
709+ input: IntegrationLifecycleInput<TStore>,
710+ ) => Effect.Effect<void, unknown>;
711+
645712 /** Core-dispatched integration configuration (beyond auth). */
646713 readonly integrationConfigure?: IntegrationConfigureDecl<TStore>;
647714