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
305 ],305 ],
306});306});
307 307
308const 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
308const oauthErrorPlugin = definePlugin(() => ({329const oauthErrorPlugin = definePlugin(() => ({
309 id: "oauth-error-test" as const,330 id: "oauth-error-test" as const,
310 storage: () => ({}),331 storage: () => ({}),
@@ -1215,6 +1236,33 @@ describe("tool discovery", () => { · 904 unmodified lines
1215 }),1236 }),
1216 );1237 );
1217 1238
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
1218 it.effect("preserves nested upstream error bodies through ToolResult.fail", () =>1266 it.effect("preserves nested upstream error bodies through ToolResult.fail", () =>
1219 Effect.gen(function* () {1267 Effect.gen(function* () {
1220 const executor = yield* makeExecutorWith([structuredFailurePlugin] as const);1268 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
189 },189 },
190 });190 });
191 191
192const 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
192const expectedToolFailure = (value: unknown): ToolError | null => {214const expectedToolFailure = (value: unknown): ToolError | null => {
193 if (isUserActionableError(value)) {215 if (isUserActionableError(value)) {
194 return {216 return {
@@ -223,6 +245,8 @@ const expectedToolFailure = (value: unknown): ToolError | null => { · 28 unmodified lines
223 message: cause.userMessage,245 message: cause.userMessage,
224 };246 };
225 }247 }
248 const binding = bindingToolFailure(cause);
249 if (binding) return binding;
226 const issues = validationIssues(cause);250 const issues = validationIssues(cause);
227 if (issues) {251 if (issues) {
228 return {252 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
1944 if (!existing.can_remove) {1944 if (!existing.can_remove) {
1945 return yield* new IntegrationRemovalNotAllowedError({ slug });1945 return yield* new IntegrationRemovalNotAllowedError({ slug });
1946 }1946 }
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 }
1947 // Drop owned connections / tools / definitions for this integration.1960 // Drop owned connections / tools / definitions for this integration.
1948 const where = (b: AnyCb) => b("integration", "=", String(slug));1961 const where = (b: AnyCb) => b("integration", "=", String(slug));
1949 yield* core.deleteMany("tool", { where });1962 yield* core.deleteMany("tool", { where });
@@ -2121,6 +2134,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 171 unmodified lines
2121 2134
2122 const result: ResolveToolsResult = yield* runtime.plugin2135 const result: ResolveToolsResult = yield* runtime.plugin
2123 .resolveTools({2136 .resolveTools({
2137 ctx: runtime.ctx,
2124 integration: rowToIntegration(integrationRow),2138 integration: rowToIntegration(integrationRow),
2125 config: decodeJsonColumn(integrationRow.config),2139 config: decodeJsonColumn(integrationRow.config),
2126 httpClientLayer: runtime.ctx.httpClientLayer,2140 httpClientLayer: runtime.ctx.httpClientLayer,
@@ -3212,6 +3226,30 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1085 unmodified lines
3212 );3226 );
3213 if (effective.action === "block") return null;3227 if (effective.action === "block") return null;
3214 3228
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
3215 const definitionRows = yield* core.findMany("definition", {3253 const definitionRows = yield* core.findMany("definition", {
3216 where: (b: AnyCb) =>3254 where: (b: AnyCb) =>
3217 b.and(3255 b.and(
@@ -3223,15 +3261,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
3223 const defs = new Map<string, unknown>();3261 const defs = new Map<string, unknown>();
3224 for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));3262 for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));
3225 3263
3226 const referenced = collectReferencedDefinitions(3264 const referenced = collectReferencedDefinitions([inputSchema, outputSchema], defs);
3227 [tool.inputSchema, tool.outputSchema],
3228 defs,
3229 );
3230 const preview = yield* Effect.tryPromise({3265 const preview = yield* Effect.tryPromise({
3231 try: () =>3266 try: () =>
3232 buildToolTypeScriptPreview({3267 buildToolTypeScriptPreview({
3233 inputSchema: tool.inputSchema,3268 inputSchema,
3234 outputSchema: tool.outputSchema,3269 outputSchema,
3235 defs,3270 defs,
3236 }),3271 }),
3237 catch: (cause) =>3272 catch: (cause) =>
@@ -3243,8 +3278,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
3243 address,3278 address,
3244 name: tool.name,3279 name: tool.name,
3245 description: tool.description,3280 description: tool.description,
3246 inputSchema: tool.inputSchema,3281 inputSchema,
3247 outputSchema: tool.outputSchema,3282 outputSchema,
3248 schemaDefinitions:3283 schemaDefinitions:
3249 Object.keys(referenced).length > 03284 Object.keys(referenced).length > 0
3250 ? (referenced as Record<string, unknown>)3285 ? (referenced as Record<string, unknown>)
@@ -3271,6 +3306,53 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
3271 return yield* provider.list();3306 return yield* provider.list();
3272 });3307 });
3273 3308
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
3274 // ------------------------------------------------------------------3356 // ------------------------------------------------------------------
3275 // Policies — owner-ranked (user=0 inner, org=1 outer).3357 // Policies — owner-ranked (user=0 inner, org=1 outer).
3276 // ------------------------------------------------------------------3358 // ------------------------------------------------------------------
@@ -3700,6 +3782,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 423 unmodified lines
3700 credential,3782 credential,
3701 args,3783 args,
3702 elicit: buildElicit(address, args, handler),3784 elicit: buildElicit(address, args, handler),
3785 invokeOptions: options,
3703 }),3786 }),
3704 );3787 );
3705 }).pipe(3788 }).pipe(
@@ -3854,8 +3937,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 148 unmodified lines
3854 providers: {3937 providers: {
3855 list: () => providersList(),3938 list: () => providersList(),
3856 items: (key) => providersItems(key),3939 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),
3857 },3944 },
3858 oauth,3945 oauth,
3946 execute: (address, args, options) => execute(address, args, options),
3859 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),3947 transaction: <A, E>(effect: Effect.Effect<A, E>) => transaction(effect),
3860 };3948 };
3861 3949
packages/core/sdk/src/plugin.ts+67−0
@@ -26,9 +26,11 @@ import type { · 25 unmodified lines
26 ConnectionName,26 ConnectionName,
27 IntegrationSlug,27 IntegrationSlug,
28 Owner,28 Owner,
29 ProviderItemId,
29 ProviderKey,30 ProviderKey,
30 Subject,31 Subject,
31 Tenant,32 Tenant,
33 ToolAddress,
32} from "./ids";34} from "./ids";
33import type { IntegrationDetectionResult } from "./types";35import type { IntegrationDetectionResult } from "./types";
34import type {36import type {
@@ -36,8 +38,10 @@ import type { · 1 unmodified line
36 ElicitationHandler,38 ElicitationHandler,
37 ElicitationRequest,39 ElicitationRequest,
38 ElicitationResponse,40 ElicitationResponse,
41 InvokeOptions,
39} from "./elicitation";42} from "./elicitation";
40import type {43import type {
44 ExecuteError,
41 ConnectionNotFoundError,45 ConnectionNotFoundError,
42 CredentialProviderNotRegisteredError,46 CredentialProviderNotRegisteredError,
43 IntegrationNotFoundError,47 IntegrationNotFoundError,
@@ -244,11 +248,41 @@ export interface PluginCtx<TStore = unknown> { · 200 unmodified lines
244 readonly items: (248 readonly items: (
245 provider: ProviderKey,249 provider: ProviderKey,
246 ) => Effect.Effect<readonly ProviderEntry[], StorageFailure>;250 ) => 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>;
247 };271 };
248 272
249 /** Shared OAuth service. */273 /** Shared OAuth service. */
250 readonly oauth: OAuthService;274 readonly oauth: OAuthService;
251 275
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
252 /** Run `effect` inside a FumaDB transaction (atomic across plugin storage +286 /** Run `effect` inside a FumaDB transaction (atomic across plugin storage +
253 * core integration/tool writes). */287 * core integration/tool writes). */
254 readonly transaction: <A, E>(effect: Effect.Effect<A, E>) => Effect.Effect<A, E | StorageFailure>;288 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
261// ---------------------------------------------------------------------------295// ---------------------------------------------------------------------------
262 296
263export interface ResolveToolsInput<TStore = unknown> {297export interface ResolveToolsInput<TStore = unknown> {
298 readonly ctx?: PluginCtx<TStore>;
264 /** The catalog record (public projection) whose connection is being resolved. */299 /** The catalog record (public projection) whose connection is being resolved. */
265 readonly integration: Integration;300 readonly integration: Integration;
266 /** The plugin's stored opaque config for that integration. */301 /** The plugin's stored opaque config for that integration. */
@@ -302,6 +337,18 @@ export interface ResolveToolsResult { · 35 unmodified lines
302 readonly incompleteReason?: string;337 readonly incompleteReason?: string;
303}338}
304 339
340export interface ProjectToolSchemaInput<TStore = unknown> {
341 readonly ctx: PluginCtx<TStore>;
342 readonly toolRow: ToolInvocationRow;
343 readonly inputSchema?: unknown;
344 readonly outputSchema?: unknown;
345}
346
347export interface ProjectToolSchemaResult {
348 readonly inputSchema?: unknown;
349 readonly outputSchema?: unknown;
350}
351
305// ---------------------------------------------------------------------------352// ---------------------------------------------------------------------------
306// Resolved credential handed to `invokeTool` so the plugin renders auth onto353// Resolved credential handed to `invokeTool` so the plugin renders auth onto
307// the request (D11: "auth state derived into the auth-template format").354// the request (D11: "auth state derived into the auth-template format").
@@ -457,6 +504,8 @@ export interface InvokeToolInput<TStore = unknown> { · 149 unmodified lines
457 readonly credential: ToolInvocationCredential;504 readonly credential: ToolInvocationCredential;
458 readonly args: unknown;505 readonly args: unknown;
459 readonly elicit: Elicit;506 readonly elicit: Elicit;
507 /** Original caller options for nested same-request tool calls. */
508 readonly invokeOptions?: InvokeOptions;
460}509}
461 510
462/** Input for `validateToolArgs` — no credential/elicit: validation runs511/** Input for `validateToolArgs` — no credential/elicit: validation runs
@@ -477,6 +526,11 @@ export interface ConnectionLifecycleInput<TStore = unknown> { · 14 unmodified lines
477 readonly connection: ConnectionRef;526 readonly connection: ConnectionRef;
478}527}
479 528
529export interface IntegrationLifecycleInput<TStore = unknown> {
530 readonly ctx: PluginCtx<TStore>;
531 readonly integration: IntegrationRecord;
532}
533
480export interface ConfigureIntegrationHandlerInput<TStore = unknown> {534export interface ConfigureIntegrationHandlerInput<TStore = unknown> {
481 readonly ctx: PluginCtx<TStore>;535 readonly ctx: PluginCtx<TStore>;
482 readonly integration: IntegrationSlug;536 readonly integration: IntegrationSlug;
@@ -615,6 +669,13 @@ export interface PluginSpec< · 132 unmodified lines
615 * for catalogs derived purely from stored state (specs, static config). */669 * for catalogs derived purely from stored state (specs, static config). */
616 readonly remoteToolCatalog?: boolean;670 readonly remoteToolCatalog?: boolean;
617 671
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
618 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the679 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the
619 * address. The plugin applies `input.credential` to the outbound request. */680 * address. The plugin applies `input.credential` to the outbound request. */
620 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;681 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
@@ -642,6 +703,12 @@ export interface PluginSpec< · 21 unmodified lines
642 input: ConnectionLifecycleInput<TStore>,703 input: ConnectionLifecycleInput<TStore>,
643 ) => Effect.Effect<void, unknown>;704 ) => Effect.Effect<void, unknown>;
644 705
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
645 /** Core-dispatched integration configuration (beyond auth). */712 /** Core-dispatched integration configuration (beyond auth). */
646 readonly integrationConfigure?: IntegrationConfigureDecl<TStore>;713 readonly integrationConfigure?: IntegrationConfigureDecl<TStore>;
647 714