History

maybe: runtime tools

Rhys Sullivan committed 7 months ago

Commit 3033ded1 parent aa467b921 files · +1110 −167Browse files

Unified · Split

21 files
  1. apps/server/src/dev-backend.ts +20 −0
  2. apps/server/src/handlers/sources.ts +3 −0
  3. apps/server/src/index.ts +2 −1
  4. apps/server/src/main.ts +15 −9
  5. apps/server/src/services/executor.ts +47 −3
  6. apps/web/src/pages/source-detail.tsx +19 −10
  7. apps/web/src/pages/sources.tsx +82 −34
  8. apps/web/src/shell.tsx +36 −1
  9. apps/web/vite.config.ts +83 −5
  10. packages/core/api/src/sources/api.ts +3 −0
  11. packages/core/sdk/src/in-memory/tool-registry.ts +83 −7
  12. packages/core/sdk/src/index.test.ts +41 −1
  13. packages/core/sdk/src/index.ts +9 −0
  14. packages/core/sdk/src/runtime-tools.ts +183 −0
  15. packages/core/sdk/src/sources.ts +37 −1
  16. packages/core/sdk/src/tools.ts +44 −0
  17. packages/core/storage-file/src/index.test.ts +24 −0
  18. packages/core/storage-file/src/tool-registry.ts +71 −7
  19. packages/plugins/openapi/src/sdk/plugin.test.ts +164 −2
  20. packages/plugins/openapi/src/sdk/plugin.ts +134 −83
  21. packages/plugins/openapi/src/sdk/real-specs.test.ts +10 −3
apps/server/src/dev-backend.ts+20−0
@@ -0,0 +1,20 @@
1+import { createServerHandlersWithExecutor, type ServerHandlers } from "./main";
2+import { createServerExecutorHandle } from "./services/executor";
3+
4+export type HotBackend = ServerHandlers & {
5+ readonly dispose: () => Promise<void>;
6+};
7+
8+export const createHotBackend = async (): Promise<HotBackend> => {
9+ const handle = await createServerExecutorHandle();
10+ const handlers = await createServerHandlersWithExecutor(handle.executor);
11+
12+ return {
13+ ...handlers,
14+ dispose: async () => {
15+ await handlers.api.dispose().catch(() => undefined);
16+ await handlers.mcp.close().catch(() => undefined);
17+ await handle.dispose();
18+ },
19+ };
20+};
apps/server/src/handlers/sources.ts+3−0
@@ -17,6 +17,9 @@ export const SourcesHandlers = HttpApiBuilder.group( · 16 unmodified lines
1717 id: s.id,
1818 name: s.name,
1919 kind: s.kind,
20+ runtime: s.runtime,
21+ canRemove: s.canRemove,
22+ canRefresh: s.canRefresh,
2023 }));
2124 }),
2225 )
apps/server/src/index.ts+2−1
@@ -1,3 +1,4 @@
11 export { createApiHandler, createServerHandlers, type ApiHandler, type ServerHandlers } from "./main";
2−export { ExecutorServiceLayer, getExecutor } from "./services/executor";
2+export { createServerHandlersWithExecutor } from "./main";
3+export { ExecutorServiceLayer, createServerExecutorHandle, disposeExecutor, getExecutor, reloadExecutor } from "./services/executor";
34 export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp";
apps/server/src/main.ts+15−9
@@ -16,7 +16,7 @@ import { SecretsHandlers } from "./handlers/secrets"; · 15 unmodified lines
1616 import { OpenApiHandlersLive } from "./handlers/openapi";
1717 import { McpSourceHandlersLive } from "./handlers/mcp-source";
1818 import { OnePasswordHandlersLive } from "./handlers/onepassword";
19−import { ExecutorServiceLayer, getExecutor } from "./services/executor";
19+import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";
2020 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
2121
2222 // ---------------------------------------------------------------------------
@@ -29,7 +29,7 @@ const ExecutorApiWithPlugins = addGroup(OpenApiGroup).add(McpGroup).add(OnePassw · 6 unmodified lines
2929 // API Layer
3030 // ---------------------------------------------------------------------------
3131
32−const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe(
32+const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe(
3333 Layer.provide([
3434 ToolsHandlers,
3535 SourcesHandlers,
@@ -38,7 +38,6 @@ const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 2 unmodified lines
3838 McpSourceHandlersLive,
3939 OnePasswordHandlersLive,
4040 ]),
41− Layer.provide(ExecutorServiceLayer),
4241 );
4342
4443 // ---------------------------------------------------------------------------
@@ -53,24 +52,30 @@ export type ServerHandlers = { · 8 unmodified lines
5352 readonly mcp: McpRequestHandler;
5453 };
5554
56−export const createServerHandlers = async (): Promise<ServerHandlers> => {
57− const executor = await getExecutor();
58−
59− const api = HttpApiBuilder.toWebHandler(
55+const createApiHandlerWithExecutor = (executor: ServerExecutor) =>
56+ HttpApiBuilder.toWebHandler(
6057 HttpApiSwagger.layer().pipe(
6158 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
6259 Layer.provideMerge(HttpApiBuilder.middlewareCors()),
63− Layer.provideMerge(ApiLive),
60+ Layer.provideMerge(ApiBase),
61+ Layer.provideMerge(Layer.succeed(ExecutorService, executor)),
6462 Layer.provideMerge(HttpServer.layerContext),
6563 ),
6664 { middleware: HttpMiddleware.logger },
6765 );
6866
67+export const createServerHandlersWithExecutor = async (
68+ executor: ServerExecutor,
69+): Promise<ServerHandlers> => {
70+ const api = createApiHandlerWithExecutor(executor);
6971 const mcp = createMcpRequestHandler({ executor });
7072
7173 return { api, mcp };
7274 };
7375
76+export const createServerHandlers = async (): Promise<ServerHandlers> =>
77+ createServerHandlersWithExecutor(await getExecutor());
78+
7479 // ---------------------------------------------------------------------------
7580 // Backwards compat — standalone API handler (no MCP)
7681 // ---------------------------------------------------------------------------
@@ -80,7 +85,8 @@ export const createApiHandler = () => · 3 unmodified lines
8085 HttpApiSwagger.layer().pipe(
8186 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
8287 Layer.provideMerge(HttpApiBuilder.middlewareCors()),
83− Layer.provideMerge(ApiLive),
88+ Layer.provideMerge(ApiBase),
89+ Layer.provideMerge(ExecutorServiceLayer),
8490 Layer.provideMerge(HttpServer.layerContext),
8591 ),
8692 { middleware: HttpMiddleware.logger },
apps/server/src/services/executor.ts+47−3
@@ -21,6 +21,10 @@ type ServerPlugins = readonly [ · 20 unmodified lines
2121 ExecutorPlugin<"onepassword", OnePasswordExtension>,
2222 ];
2323 export type ServerExecutor = Executor<ServerPlugins>;
24+export type ServerExecutorHandle = {
25+ readonly executor: ServerExecutor;
26+ readonly dispose: () => Promise<void>;
27+};
2428
2529 // ---------------------------------------------------------------------------
2630 // Service tag
@@ -78,17 +82,57 @@ const ExecutorLayer = Layer.effect( · 51 unmodified lines
7882 );
7983
8084 // ---------------------------------------------------------------------------
81−// ManagedRuntime — keeps the SQLite scope alive for the process lifetime
85+// ManagedRuntime — shared singleton for production, scoped handles for dev HMR
8286 // ---------------------------------------------------------------------------
8387
84−const runtime = ManagedRuntime.make(ExecutorLayer);
88+export const createServerExecutorHandle = async (): Promise<ServerExecutorHandle> => {
89+ const runtime = ManagedRuntime.make(ExecutorLayer);
90+ const executor = await runtime.runPromise(ExecutorService);
91+
92+ return {
93+ executor,
94+ dispose: async () => {
95+ await Effect.runPromise(executor.close()).catch(() => undefined);
96+ await runtime.dispose().catch(() => undefined);
97+ },
98+ };
99+};
100+
101+let sharedHandlePromise: Promise<ServerExecutorHandle> | null = null;
102+
103+const loadSharedHandle = (): Promise<ServerExecutorHandle> => {
104+ if (!sharedHandlePromise) {
105+ sharedHandlePromise = createServerExecutorHandle();
106+ }
107+ return sharedHandlePromise;
108+};
85109
86110 /**
87111 * Get the shared executor instance. The ManagedRuntime keeps the SQLite
88112 * connection (and everything else) alive until the process exits.
89113 */
90114 export const getExecutor = (): Promise<ServerExecutor> =>
91− runtime.runPromise(ExecutorService);
115+ loadSharedHandle().then((handle) => handle.executor);
116+
117+/**
118+ * Dispose the shared executor/runtime. Mainly useful in development when the
119+ * backend module graph is hot-reloaded and we need fresh plugin init.
120+ */
121+export const disposeExecutor = async (): Promise<void> => {
122+ const currentHandlePromise = sharedHandlePromise;
123+ sharedHandlePromise = null;
124+
125+ const handle = await currentHandlePromise?.catch(() => null);
126+ await handle?.dispose().catch(() => undefined);
127+};
128+
129+/**
130+ * Dispose and eagerly recreate the shared executor.
131+ */
132+export const reloadExecutor = async (): Promise<ServerExecutor> => {
133+ await disposeExecutor();
134+ return getExecutor();
135+};
92136
93137 /**
94138 * Provide `ExecutorService` to an Effect layer using the shared runtime.
apps/web/src/pages/source-detail.tsx+19−10
@@ -34,6 +34,8 @@ export function SourceDetailPage(props: { namespace: string }) { · 33 unmodified lines
3434 const [refreshing, setRefreshing] = useState(false);
3535
3636 const sourceData = Result.isSuccess(source) ? source.value : null;
37+ const canRefresh = sourceData ? (sourceData.canRefresh ?? true) : false;
38+ const canRemove = sourceData ? (sourceData.canRemove ?? true) : false;
3739
3840 const sourceTools: ToolSummary[] = useMemo(() => {
3941 if (!Result.isSuccess(tools)) return [];
@@ -85,6 +87,11 @@ export function SourceDetailPage(props: { namespace: string }) { · 45 unmodified lines
8587 <h2 className="truncate text-sm font-semibold text-foreground">
8688 {sourceData?.name ?? namespace}
8789 </h2>
90+ {sourceData?.runtime && (
91+ <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
92+ built-in
93+ </span>
94+ )}
8895 <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">
8996 {sourceData?.kind ?? "source"}
9097 </span>
@@ -96,16 +103,18 @@ export function SourceDetailPage(props: { namespace: string }) { · 5 unmodified lines
96103 </div>
97104
98105 <div className="flex shrink-0 items-center gap-2">
99− <button
100− type="button"
101− onClick={() => void handleRefresh()}
102− disabled={refreshing}
103− className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-[12px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
104− >
105− {refreshing ? "Refreshing…" : "Refresh"}
106− </button>
106+ {canRefresh && (
107+ <button
108+ type="button"
109+ onClick={() => void handleRefresh()}
110+ disabled={refreshing}
111+ className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-[12px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
112+ >
113+ {refreshing ? "Refreshing…" : "Refresh"}
114+ </button>
115+ )}
107116
108− {confirmDelete ? (
117+ {canRemove && (confirmDelete ? (
109118 <div className="flex items-center gap-2">
110119 <span className="text-[11px] font-medium text-destructive">
111120 Confirm?
@@ -135,7 +144,7 @@ export function SourceDetailPage(props: { namespace: string }) { · 23 unmodified lines
135144 >
136145 Delete
137146 </button>
138− )}
147+ ))}
139148 </div>
140149 </div>
141150
apps/web/src/pages/sources.tsx+82−34
@@ -25,6 +25,53 @@ export function SourcesPage() { · 24 unmodified lines
2525 ? sourcePlugins.find((p) => p.key === adding)
2626 : undefined;
2727
28+ const renderSourceGrid = (sources: readonly {
29+ id: string;
30+ name: string;
31+ kind: string;
32+ runtime?: boolean;
33+ }[]) => (
34+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
35+ {sources.map((s) => (
36+ <Link
37+ key={s.id}
38+ to="/sources/$namespace"
39+ params={{ namespace: s.id }}
40+ className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90"
41+ >
42+ <div className="flex items-start gap-3">
43+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
44+ <svg viewBox="0 0 16 16" className="size-4">
45+ <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" />
46+ <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
47+ </svg>
48+ </div>
49+ <div className="min-w-0 flex-1">
50+ <div className="flex items-start justify-between gap-2">
51+ <div className="truncate text-sm font-semibold text-foreground">
52+ {s.name}
53+ </div>
54+ <div className="flex shrink-0 items-center gap-1.5">
55+ {s.runtime && (
56+ <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
57+ built-in
58+ </span>
59+ )}
60+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">
61+ {s.kind}
62+ </span>
63+ </div>
64+ </div>
65+ <div className="mt-0.5 text-xs text-muted-foreground">
66+ {s.id}
67+ </div>
68+ </div>
69+ </div>
70+ </Link>
71+ ))}
72+ </div>
73+ );
74+
2875 if (plugin) {
2976 const AddComponent = plugin.add;
3077 return (
@@ -54,7 +101,7 @@ export function SourcesPage() { · 23 unmodified lines
54101 Sources
55102 </h1>
56103 <p className="mt-1.5 text-[14px] text-muted-foreground">
57− Connected tool providers in this workspace.
104+ Tool providers available in this workspace.
58105 </p>
59106 </div>
60107 <div className="flex gap-2">
@@ -82,8 +129,11 @@ export function SourcesPage() { · 21 unmodified lines
82129 onFailure: () => (
83130 <p className="text-sm text-destructive">Failed to load sources</p>
84131 ),
85− onSuccess: ({ value }) =>
86− value.length === 0 ? (
132+ onSuccess: ({ value }) => {
133+ const builtInSources = value.filter((source) => source.runtime);
134+ const connectedSources = value.filter((source) => !source.runtime);
135+
136+ return value.length === 0 ? (
87137 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
88138 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">
89139 <svg viewBox="0 0 24 24" fill="none" className="size-5">
@@ -98,39 +148,37 @@ export function SourcesPage() { · 8 unmodified lines
98148 </p>
99149 </div>
100150 ) : (
101− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
102− {value.map((s) => (
103− <Link
104− key={s.id}
105− to="/sources/$namespace"
106− params={{ namespace: s.id }}
107− className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90"
108− >
109− <div className="flex items-start gap-3">
110− <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
111− <svg viewBox="0 0 16 16" className="size-4">
112− <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" />
113− <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
114− </svg>
115− </div>
116− <div className="min-w-0 flex-1">
117− <div className="flex items-start justify-between gap-2">
118− <div className="truncate text-sm font-semibold text-foreground">
119− {s.name}
120− </div>
121− <span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">
122− {s.kind}
123− </span>
124− </div>
125− <div className="mt-0.5 text-xs text-muted-foreground">
126− {s.id}
127− </div>
128− </div>
151+ <div className="space-y-8">
152+ {builtInSources.length > 0 && (
153+ <section className="space-y-3">
154+ <div>
155+ <h2 className="text-sm font-semibold text-foreground">
156+ Built-in
157+ </h2>
158+ <p className="mt-1 text-[13px] text-muted-foreground">
159+ Runtime sources exposed by the loaded executor plugins.
160+ </p>
161+ </div>
162+ {renderSourceGrid(builtInSources)}
163+ </section>
164+ )}
165+
166+ {connectedSources.length > 0 && (
167+ <section className="space-y-3">
168+ <div>
169+ <h2 className="text-sm font-semibold text-foreground">
170+ Connected
171+ </h2>
172+ <p className="mt-1 text-[13px] text-muted-foreground">
173+ User-configured sources available in this workspace.
174+ </p>
129175 </div>
130− </Link>
131− ))}
176+ {renderSourceGrid(connectedSources)}
177+ </section>
178+ )}
132179 </div>
133− ),
180+ );
181+ },
134182 })}
135183 </div>
136184 </div>
apps/web/src/shell.tsx+36−1
@@ -1,6 +1,13 @@
11 import { Link, Outlet, useLocation } from "@tanstack/react-router";
22 import { useEffect, useRef, useState } from "react";
3−import { useAtomValue, sourcesAtom, Result } from "@executor/react";
3+import {
4+ useAtomRefresh,
5+ useAtomValue,
6+ Result,
7+ sourceToolsAtom,
8+ sourcesAtom,
9+ toolsAtom,
10+} from "@executor/react";
411
512 // ── NavItem ──────────────────────────────────────────────────────────────
613
@@ -157,6 +164,14 @@ function SidebarContent(props: { · 150 unmodified lines
157164 export function Shell() {
158165 const location = useLocation();
159166 const pathname = location.pathname;
167+ const currentSourceId = pathname.startsWith("/sources/")
168+ ? decodeURIComponent(pathname.slice("/sources/".length).split("/")[0] ?? "")
169+ : null;
170+ const refreshSources = useAtomRefresh(sourcesAtom());
171+ const refreshTools = useAtomRefresh(toolsAtom());
172+ const refreshSourceTools = useAtomRefresh(
173+ sourceToolsAtom(currentSourceId ?? "__runtime__"),
174+ );
160175 const lastPathname = useRef(pathname);
161176 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
162177 if (lastPathname.current !== pathname) {
@@ -174,6 +189,26 @@ export function Shell() { · 11 unmodified lines
174189 };
175190 }, [mobileSidebarOpen]);
176191
192+ useEffect(() => {
193+ if (!import.meta.hot) {
194+ return;
195+ }
196+
197+ const refreshBackendData = () => {
198+ refreshSources();
199+ refreshTools();
200+ if (currentSourceId) {
201+ refreshSourceTools();
202+ }
203+ };
204+
205+ import.meta.hot.on("executor:backend-updated", refreshBackendData);
206+
207+ return () => {
208+ import.meta.hot?.off("executor:backend-updated", refreshBackendData);
209+ };
210+ }, [currentSourceId, refreshSourceTools, refreshSources, refreshTools]);
211+
177212 return (
178213 <div className="flex h-screen overflow-hidden">
179214 {/* Desktop sidebar */}
apps/web/vite.config.ts+83−5
@@ -1,9 +1,24 @@
1+import { resolve } from "node:path";
12 import { defineConfig } from "vite";
23 import react from "@vitejs/plugin-react";
34 import tailwindcss from "@tailwindcss/vite";
4−import { createServerHandlers } from "@executor/server";
55
6−const handlersPromise = createServerHandlers();
6+type HotBackend = {
7+ readonly api: {
8+ readonly handler: (request: Request) => Promise<Response>;
9+ readonly dispose: () => Promise<void>;
10+ };
11+ readonly mcp: {
12+ readonly handleRequest: (request: Request) => Promise<Response>;
13+ readonly close: () => Promise<void>;
14+ };
15+ readonly dispose: () => Promise<void>;
16+};
17+
18+const DEV_BACKEND_MODULE_ID = `/@fs/${resolve(
19+ process.cwd(),
20+ "../server/src/dev-backend.ts",
21+).replace(/\\/g, "/")}`;
722
823 // Build a Web Request from a Node IncomingMessage
924 const toWebRequest = async (
@@ -69,6 +84,69 @@ export default defineConfig({ · 59 unmodified lines
6984 {
7085 name: "executor-api",
7186 configureServer(server) {
87+ let backendPromise: Promise<HotBackend> | null = null;
88+ let reloadPromise: Promise<void> | null = null;
89+
90+ const loadBackend = () =>
91+ server
92+ .ssrLoadModule(DEV_BACKEND_MODULE_ID)
93+ .then((mod) => mod.createHotBackend() as Promise<HotBackend>);
94+
95+ const getBackend = () => {
96+ if (!backendPromise) {
97+ backendPromise = loadBackend();
98+ }
99+ return backendPromise;
100+ };
101+
102+ const shouldReloadBackend = (file: string) =>
103+ /\.(ts|tsx)$/.test(file) &&
104+ !file.includes("/apps/web/") &&
105+ (
106+ file.includes("/apps/server/") ||
107+ file.includes("/packages/core/") ||
108+ file.includes("/packages/plugins/") ||
109+ file.includes("/packages/hosts/")
110+ );
111+
112+ server.httpServer?.once("close", () => {
113+ const pending = backendPromise;
114+ backendPromise = null;
115+ void pending?.then((backend) => backend.dispose()).catch(() => undefined);
116+ });
117+
118+ server.watcher.on("change", (file) => {
119+ if (!shouldReloadBackend(file) || reloadPromise) {
120+ return;
121+ }
122+
123+ reloadPromise = (async () => {
124+ server.config.logger.info(`[executor-api] hot reloading backend: ${file}`);
125+
126+ const previous = await getBackend().catch(() => null);
127+
128+ server.moduleGraph.invalidateAll();
129+ backendPromise = loadBackend();
130+
131+ await backendPromise;
132+ await previous?.dispose().catch(() => undefined);
133+
134+ server.ws.send({
135+ type: "custom",
136+ event: "executor:backend-updated",
137+ data: { file },
138+ });
139+ })()
140+ .catch((error) => {
141+ server.config.logger.error(
142+ `[executor-api] backend reload failed: ${error instanceof Error ? error.message : String(error)}`,
143+ );
144+ })
145+ .finally(() => {
146+ reloadPromise = null;
147+ });
148+ });
149+
72150 server.middlewares.use(async (req, res, next) => {
73151 const url = req.url ?? "/";
74152
@@ -80,12 +158,12 @@ export default defineConfig({ · 5 unmodified lines
80158
81159 if (!isApi && !isMcp) return next();
82160
83− const handlers = await handlersPromise;
161+ const backend = await getBackend();
84162 const request = await toWebRequest(req);
85163
86164 const response = isMcp
87− ? await handlers.mcp.handleRequest(request)
88− : await handlers.api.handler(request);
165+ ? await backend.mcp.handleRequest(request)
166+ : await backend.api.handler(request);
89167
90168 await sendWebResponse(response, res);
91169 });
packages/core/api/src/sources/api.ts+3−0
@@ -17,6 +17,9 @@ const SourceResponse = Schema.Struct({ · 16 unmodified lines
1717 id: Schema.String,
1818 name: Schema.String,
1919 kind: Schema.String,
20+ runtime: Schema.optional(Schema.Boolean),
21+ canRemove: Schema.optional(Schema.Boolean),
22+ canRefresh: Schema.optional(Schema.Boolean),
2023 });
2124
2225 const SourceRemoveResponse = Schema.Struct({
packages/core/sdk/src/in-memory/tool-registry.ts+83−7
@@ -2,18 +2,41 @@ import { Effect } from "effect"; · 1 unmodified line
22
33 import { ToolId } from "../ids";
44 import { ToolNotFoundError, ToolInvocationError } from "../errors";
5−import type { ToolRegistration, ToolInvoker, ToolListFilter, InvokeOptions } from "../tools";
5+import type {
6+ ToolRegistration,
7+ ToolInvoker,
8+ ToolListFilter,
9+ InvokeOptions,
10+ RuntimeToolHandler,
11+} from "../tools";
612 import { reattachDefs } from "../schema-refs";
713
814 export const makeInMemoryToolRegistry = () => {
915 const tools = new Map<string, ToolRegistration>();
16+ const runtimeTools = new Map<string, ToolRegistration>();
17+ const runtimeHandlers = new Map<string, RuntimeToolHandler>();
1018 const invokers = new Map<string, ToolInvoker>();
1119 const sharedDefs = new Map<string, unknown>();
20+ const runtimeDefs = new Map<string, unknown>();
21+
22+ const getTool = (toolId: ToolId): ToolRegistration | undefined =>
23+ runtimeTools.get(toolId) ?? tools.get(toolId);
24+
25+ const getDefs = (): Map<string, unknown> => {
26+ const defs = new Map<string, unknown>();
27+ for (const [k, v] of sharedDefs) defs.set(k, v);
28+ for (const [k, v] of runtimeDefs) defs.set(k, v);
29+ return defs;
30+ };
1231
1332 return {
1433 list: (filter?: ToolListFilter) =>
1534 Effect.sync(() => {
16− let result = [...tools.values()];
35+ const byId = new Map<string, ToolRegistration>();
36+ for (const tool of tools.values()) byId.set(tool.id, tool);
37+ for (const tool of runtimeTools.values()) byId.set(tool.id, tool);
38+
39+ let result = [...byId.values()];
1740 if (filter?.sourceId) {
1841 const sid = filter.sourceId;
1942 result = result.filter((t) => t.sourceId === sid);
@@ -36,12 +59,12 @@ export const makeInMemoryToolRegistry = () => { · 16 unmodified lines
3659 }),
3760
3861 schema: (toolId: ToolId) =>
39− Effect.fromNullable(tools.get(toolId)).pipe(
62+ Effect.fromNullable(getTool(toolId)).pipe(
4063 Effect.mapError(() => new ToolNotFoundError({ toolId })),
4164 Effect.map((t) => ({
4265 id: t.id,
43− inputSchema: reattachDefs(t.inputSchema, sharedDefs),
44− outputSchema: reattachDefs(t.outputSchema, sharedDefs),
66+ inputSchema: reattachDefs(t.inputSchema, getDefs()),
67+ outputSchema: reattachDefs(t.outputSchema, getDefs()),
4568 })),
4669 ),
4770
@@ -51,6 +74,9 @@ export const makeInMemoryToolRegistry = () => { · 3 unmodified lines
5174 for (const [k, v] of sharedDefs) {
5275 result[k] = v;
5376 }
77+ for (const [k, v] of runtimeDefs) {
78+ result[k] = v;
79+ }
5480 return result;
5581 }),
5682
@@ -61,6 +87,20 @@ export const makeInMemoryToolRegistry = () => { · 4 unmodified lines
6187 }
6288 }),
6389
90+ registerRuntimeDefinitions: (defs: Record<string, unknown>) =>
91+ Effect.sync(() => {
92+ for (const [k, v] of Object.entries(defs)) {
93+ runtimeDefs.set(k, v);
94+ }
95+ }),
96+
97+ unregisterRuntimeDefinitions: (names: readonly string[]) =>
98+ Effect.sync(() => {
99+ for (const name of names) {
100+ runtimeDefs.delete(name);
101+ }
102+ }),
103+
64104 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>
65105 Effect.sync(() => {
66106 invokers.set(pluginKey, invoker);
@@ -68,8 +108,12 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line
68108
69109 resolveAnnotations: (toolId: ToolId) =>
70110 Effect.gen(function* () {
71− const tool = tools.get(toolId);
111+ const tool = getTool(toolId);
72112 if (!tool) return undefined;
113+ const runtimeHandler = runtimeHandlers.get(toolId);
114+ if (runtimeHandler?.resolveAnnotations) {
115+ return yield* runtimeHandler.resolveAnnotations();
116+ }
73117 const invoker = invokers.get(tool.pluginKey);
74118 if (!invoker?.resolveAnnotations) return undefined;
75119 return yield* invoker.resolveAnnotations(toolId);
@@ -77,9 +121,13 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line
77121
78122 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>
79123 Effect.gen(function* () {
80− const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe(
124+ const tool = yield* Effect.fromNullable(getTool(toolId)).pipe(
81125 Effect.mapError(() => new ToolNotFoundError({ toolId })),
82126 );
127+ const runtimeHandler = runtimeHandlers.get(toolId);
128+ if (runtimeHandler) {
129+ return yield* runtimeHandler.invoke(args, options);
130+ }
83131 const invoker = invokers.get(tool.pluginKey);
84132 if (!invoker) {
85133 return yield* new ToolInvocationError({
@@ -98,10 +146,32 @@ export const makeInMemoryToolRegistry = () => { · 12 unmodified lines
98146 }
99147 }),
100148
149+ registerRuntime: (newTools: readonly ToolRegistration[]) =>
150+ Effect.sync(() => {
151+ for (const t of newTools) {
152+ runtimeTools.set(t.id, t);
153+ }
154+ }),
155+
156+ registerRuntimeHandler: (toolId: ToolId, handler: RuntimeToolHandler) =>
157+ Effect.sync(() => {
158+ runtimeHandlers.set(toolId, handler);
159+ }),
160+
161+ unregisterRuntime: (toolIds: readonly ToolId[]) =>
162+ Effect.sync(() => {
163+ for (const id of toolIds) {
164+ runtimeTools.delete(id);
165+ runtimeHandlers.delete(id);
166+ }
167+ }),
168+
101169 unregister: (toolIds: readonly ToolId[]) =>
102170 Effect.sync(() => {
103171 for (const id of toolIds) {
104172 tools.delete(id);
173+ runtimeTools.delete(id);
174+ runtimeHandlers.delete(id);
105175 }
106176 }),
107177
@@ -112,6 +182,12 @@ export const makeInMemoryToolRegistry = () => { · 4 unmodified lines
112182 tools.delete(id);
113183 }
114184 }
185+ for (const [id, t] of runtimeTools) {
186+ if (t.sourceId === sourceId) {
187+ runtimeTools.delete(id);
188+ runtimeHandlers.delete(id);
189+ }
190+ }
115191 }),
116192 };
117193 };
packages/core/sdk/src/index.test.ts+41−1
@@ -4,11 +4,13 @@ import { Effect, Exit, Schema } from "effect"; · 3 unmodified lines
44 import {
55 createExecutor,
66 makeTestConfig,
7+ makeInMemorySourceRegistry,
78 inMemoryToolsPlugin,
89 tool,
910 FormElicitation,
1011 UrlElicitation,
1112 ElicitationResponse,
13+ Source,
1214 type MemoryToolContext,
1315 type ToolId,
1416 type InvokeOptions,
@@ -40,6 +42,42 @@ describe("SDK Executor", () => { · 25 unmodified lines
4042 }),
4143 );
4244
45+ it.effect("runtime sources are listed separately from source managers", () =>
46+ Effect.gen(function* () {
47+ const sources = makeInMemorySourceRegistry();
48+
49+ yield* sources.registerRuntime(new Source({
50+ id: "executor.openapi",
51+ name: "OpenAPI",
52+ kind: "openapi",
53+ runtime: true,
54+ canRemove: false,
55+ canRefresh: false,
56+ }));
57+
58+ yield* sources.addManager({
59+ kind: "openapi",
60+ list: () =>
61+ Effect.succeed([
62+ new Source({
63+ id: "vercel",
64+ name: "Vercel API",
65+ kind: "openapi",
66+ runtime: false,
67+ canRemove: true,
68+ canRefresh: false,
69+ }),
70+ ]),
71+ remove: () => Effect.void,
72+ });
73+
74+ expect((yield* sources.list()).map((source) => source.id)).toEqual([
75+ "executor.openapi",
76+ "vercel",
77+ ]);
78+ }),
79+ );
80+
4381 it.effect("memory plugin registers tools and they are discoverable", () =>
4482 Effect.gen(function* () {
4583 const executor = yield* createExecutor(
@@ -355,7 +393,9 @@ describe("SDK Executor", () => { · 309 unmodified lines
355393 );
356394
357395 const error = yield* Effect.flip(
358− executor.tools.invoke("auth.login", {}, autoApprove),
396+ executor.tools.invoke("auth.login", {}, {
397+ onElicitation: undefined as never,
398+ }),
359399 );
360400 expect(error._tag).toBe("ElicitationDeclinedError");
361401 }),
packages/core/sdk/src/index.ts+9−0
@@ -20,6 +20,7 @@ export { · 19 unmodified lines
2020 ToolAnnotations,
2121 ToolListFilter,
2222 type ToolInvoker,
23+ type RuntimeToolHandler,
2324 type InvokeOptions,
2425 } from "./tools";
2526
@@ -81,6 +82,14 @@ export { · 55 unmodified lines
8182 // Schema ref utilities
8283 export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs";
8384
85+// Runtime tools
86+export {
87+ registerRuntimeTools,
88+ runtimeTool,
89+ type RuntimeSourceDefinition,
90+ type RuntimeToolDefinition,
91+} from "./runtime-tools";
92+
8493 // In-memory implementations
8594 export { makeInMemoryToolRegistry } from "./in-memory/tool-registry";
8695 export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store";
packages/core/sdk/src/runtime-tools.ts+183−0
@@ -0,0 +1,183 @@
1+import { Effect, JSONSchema, Schema } from "effect";
2+
3+import { ToolId } from "./ids";
4+import { ToolInvocationError } from "./errors";
5+import { Source } from "./sources";
6+import {
7+ ToolInvocationResult,
8+ type ToolRegistration,
9+ type RuntimeToolHandler,
10+} from "./tools";
11+import { hoistDefinitions } from "./schema-refs";
12+
13+export interface RuntimeToolDefinition<TInput = unknown, TOutput = unknown> {
14+ readonly id: string;
15+ readonly sourceId?: string;
16+ readonly name: string;
17+ readonly description?: string;
18+ readonly inputSchema: Schema.Schema<TInput, any, never>;
19+ readonly outputSchema?: Schema.Schema<TOutput, any, never>;
20+ readonly handler: (args: TInput) => Effect.Effect<unknown, unknown>;
21+}
22+
23+export interface RuntimeSourceDefinition {
24+ readonly id: string;
25+ readonly name: string;
26+ readonly kind?: string;
27+ readonly canRemove?: boolean;
28+ readonly canRefresh?: boolean;
29+}
30+
31+interface RuntimeHandlerEntry {
32+ readonly decode: (args: unknown) => unknown;
33+ readonly handler: (args: unknown) => Effect.Effect<unknown, unknown>;
34+}
35+
36+export const runtimeTool = <TInput, TOutput>(
37+ def: RuntimeToolDefinition<TInput, TOutput>,
38+): RuntimeToolDefinition<TInput, TOutput> => def;
39+
40+const buildRuntimeTool = (
41+ pluginKey: string,
42+ sourceId: string,
43+ def: RuntimeToolDefinition,
44+): {
45+ readonly registration: ToolRegistration;
46+ readonly definitions: Record<string, unknown>;
47+ readonly entry: RuntimeHandlerEntry;
48+} => {
49+ const inputJson = JSONSchema.make(def.inputSchema);
50+ const outputJson = def.outputSchema ? JSONSchema.make(def.outputSchema) : undefined;
51+
52+ const inputHoist = hoistDefinitions(inputJson);
53+ const outputHoist = hoistDefinitions(outputJson);
54+
55+ return {
56+ registration: {
57+ id: ToolId.make(def.id),
58+ pluginKey,
59+ sourceId,
60+ name: def.name,
61+ description: def.description,
62+ inputSchema: inputHoist.stripped,
63+ outputSchema: outputHoist.stripped,
64+ },
65+ definitions: {
66+ ...inputHoist.defs,
67+ ...outputHoist.defs,
68+ },
69+ entry: {
70+ decode: Schema.decodeUnknownSync(def.inputSchema),
71+ handler: def.handler as (args: unknown) => Effect.Effect<unknown, unknown>,
72+ },
73+ };
74+};
75+
76+const toRuntimeHandler = (
77+ toolId: ToolId,
78+ entry: RuntimeHandlerEntry,
79+): RuntimeToolHandler => ({
80+ invoke: (args) =>
81+ Effect.try({
82+ try: () => entry.decode(args),
83+ catch: (err) =>
84+ new ToolInvocationError({
85+ toolId,
86+ message: `Invalid input: ${err instanceof Error ? err.message : String(err)}`,
87+ cause: err,
88+ }),
89+ }).pipe(
90+ Effect.flatMap((input) => entry.handler(input)),
91+ Effect.map((data) => new ToolInvocationResult({ data, error: null })),
92+ Effect.mapError((err) =>
93+ err instanceof ToolInvocationError
94+ ? err
95+ : new ToolInvocationError({
96+ toolId,
97+ message: err instanceof Error ? err.message : String(err),
98+ cause: err,
99+ }),
100+ ),
101+ ),
102+});
103+
104+export const registerRuntimeTools = <
105+ const TTools extends readonly RuntimeToolDefinition<any, any>[],
106+>(input: {
107+ readonly registry: {
108+ readonly registerRuntimeDefinitions: (
109+ defs: Record<string, unknown>,
110+ ) => Effect.Effect<void>;
111+ readonly unregisterRuntimeDefinitions: (
112+ names: readonly string[],
113+ ) => Effect.Effect<void>;
114+ readonly registerRuntime: (
115+ tools: readonly ToolRegistration[],
116+ ) => Effect.Effect<void>;
117+ readonly registerRuntimeHandler: (
118+ toolId: ToolId,
119+ handler: RuntimeToolHandler,
120+ ) => Effect.Effect<void>;
121+ readonly unregisterRuntime: (
122+ toolIds: readonly ToolId[],
123+ ) => Effect.Effect<void>;
124+ };
125+ readonly sources?: {
126+ readonly registerRuntime: (source: Source) => Effect.Effect<void>;
127+ readonly unregisterRuntime: (sourceId: string) => Effect.Effect<void>;
128+ };
129+ readonly pluginKey: string;
130+ readonly source?: RuntimeSourceDefinition;
131+ readonly tools: TTools;
132+}) =>
133+ Effect.gen(function* () {
134+ const built = yield* Effect.forEach(input.tools, (tool) =>
135+ Effect.sync(() => {
136+ const sourceId = tool.sourceId ?? input.source?.id;
137+ if (!sourceId) {
138+ throw new Error(
139+ `Runtime tool "${tool.id}" is missing a sourceId and no shared runtime source was provided`,
140+ );
141+ }
142+ return buildRuntimeTool(input.pluginKey, sourceId, tool);
143+ }),
144+ );
145+
146+ if (input.source && input.sources) {
147+ yield* input.sources.registerRuntime(new Source({
148+ id: input.source.id,
149+ name: input.source.name,
150+ kind: input.source.kind ?? input.pluginKey,
151+ runtime: true,
152+ canRemove: input.source.canRemove ?? false,
153+ canRefresh: input.source.canRefresh ?? false,
154+ }));
155+ }
156+
157+ const defs: Record<string, unknown> = {};
158+ for (const tool of built) {
159+ Object.assign(defs, tool.definitions);
160+ yield* input.registry.registerRuntimeHandler(
161+ tool.registration.id,
162+ toRuntimeHandler(tool.registration.id, tool.entry),
163+ );
164+ }
165+
166+ yield* input.registry.registerRuntimeDefinitions(defs);
167+ yield* input.registry.registerRuntime(built.map((tool) => tool.registration));
168+
169+ const toolIds = built.map((tool) => tool.registration.id);
170+ const defNames = Object.keys(defs);
171+
172+ return {
173+ toolIds,
174+ close: () =>
175+ Effect.gen(function* () {
176+ yield* input.registry.unregisterRuntime(toolIds);
177+ yield* input.registry.unregisterRuntimeDefinitions(defNames);
178+ if (input.source && input.sources) {
179+ yield* input.sources.unregisterRuntime(input.source.id);
180+ }
181+ }),
182+ };
183+ });
packages/core/sdk/src/sources.ts+37−1
@@ -11,6 +11,12 @@ export class Source extends Schema.Class<Source>("Source")({ · 10 unmodified lines
1111 name: Schema.String,
1212 /** Plugin kind that manages this source (e.g. "openapi", "mcp") */
1313 kind: Schema.String,
14+ /** True when the source is provided by the running executor */
15+ runtime: Schema.optional(Schema.Boolean),
16+ /** Whether the source supports removal */
17+ canRemove: Schema.optional(Schema.Boolean),
18+ /** Whether the source supports refresh */
19+ canRefresh: Schema.optional(Schema.Boolean),
1420 }) {}
1521
1622 // ---------------------------------------------------------------------------
@@ -46,6 +52,12 @@ export class SourceRegistry extends Context.Tag( · 29 unmodified lines
4652 /** Register a source manager (called by plugins during init) */
4753 readonly addManager: (manager: SourceManager) => Effect.Effect<void>;
4854
55+ /** Register a runtime-only source entry. */
56+ readonly registerRuntime: (source: Source) => Effect.Effect<void>;
57+
58+ /** Unregister a runtime-only source entry by id. */
59+ readonly unregisterRuntime: (sourceId: string) => Effect.Effect<void>;
60+
4961 /** List all sources across all plugins */
5062 readonly list: () => Effect.Effect<readonly Source[]>;
5163
@@ -63,6 +75,7 @@ export class SourceRegistry extends Context.Tag( · 11 unmodified lines
6375
6476 export const makeInMemorySourceRegistry = () => {
6577 const managers = new Map<string, SourceManager>();
78+ const runtimeSources = new Map<string, Source>();
6679
6780 return {
6881 addManager: (manager: SourceManager) =>
@@ -70,9 +83,19 @@ export const makeInMemorySourceRegistry = () => { · 1 unmodified line
7083 managers.set(manager.kind, manager);
7184 }),
7285
86+ registerRuntime: (source: Source) =>
87+ Effect.sync(() => {
88+ runtimeSources.set(source.id, source);
89+ }),
90+
91+ unregisterRuntime: (sourceId: string) =>
92+ Effect.sync(() => {
93+ runtimeSources.delete(sourceId);
94+ }),
95+
7396 list: () =>
7497 Effect.gen(function* () {
75− const all: Source[] = [];
98+ const all: Source[] = [...runtimeSources.values()];
7699 for (const manager of managers.values()) {
77100 const sources = yield* manager.list();
78101 all.push(...sources);
@@ -82,6 +105,14 @@ export const makeInMemorySourceRegistry = () => { · 3 unmodified lines
82105
83106 remove: (sourceId: string) =>
84107 Effect.gen(function* () {
108+ const runtimeSource = runtimeSources.get(sourceId);
109+ if (runtimeSource) {
110+ if (runtimeSource.canRemove) {
111+ runtimeSources.delete(sourceId);
112+ }
113+ return;
114+ }
115+
85116 for (const manager of managers.values()) {
86117 const sources = yield* manager.list();
87118 if (sources.some((s) => s.id === sourceId)) {
@@ -93,6 +124,11 @@ export const makeInMemorySourceRegistry = () => { · 5 unmodified lines
93124
94125 refresh: (sourceId: string) =>
95126 Effect.gen(function* () {
127+ const runtimeSource = runtimeSources.get(sourceId);
128+ if (runtimeSource) {
129+ return;
130+ }
131+
96132 for (const manager of managers.values()) {
97133 const sources = yield* manager.list();
98134 if (sources.some((s) => s.id === sourceId)) {
packages/core/sdk/src/tools.ts+44−0
@@ -98,6 +98,19 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 97 unmodified lines
9898 defs: Record<string, unknown>,
9999 ) => Effect.Effect<void>;
100100
101+ /**
102+ * Register named schema definitions for runtime tools. These remain
103+ * runtime-only and are not persisted by storage-backed registries.
104+ */
105+ readonly registerRuntimeDefinitions: (
106+ defs: Record<string, unknown>,
107+ ) => Effect.Effect<void>;
108+
109+ /** Remove named schema definitions that were registered for runtime tools. */
110+ readonly unregisterRuntimeDefinitions: (
111+ names: readonly string[],
112+ ) => Effect.Effect<void>;
113+
101114 /**
102115 * Register a plugin invoker. Must be called before registering tools
103116 * with the corresponding pluginKey.
@@ -119,6 +132,25 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 15 unmodified lines
119132 tools: readonly ToolRegistration[],
120133 ) => Effect.Effect<void>;
121134
135+ /**
136+ * Register runtime-only tools. These should behave like normal tools for
137+ * listing, schema lookup, discovery, and invocation, but are not persisted.
138+ */
139+ readonly registerRuntime: (
140+ tools: readonly ToolRegistration[],
141+ ) => Effect.Effect<void>;
142+
143+ /** Register a runtime-only handler for a specific tool id. */
144+ readonly registerRuntimeHandler: (
145+ toolId: ToolId,
146+ handler: RuntimeToolHandler,
147+ ) => Effect.Effect<void>;
148+
149+ /** Unregister runtime-only tools by id without touching persisted storage. */
150+ readonly unregisterRuntime: (
151+ toolIds: readonly ToolId[],
152+ ) => Effect.Effect<void>;
153+
122154 /** Unregister tools by id (used by plugins on cleanup) */
123155 readonly unregister: (
124156 toolIds: readonly ToolId[],
@@ -151,6 +183,18 @@ export interface ToolInvoker { · 26 unmodified lines
151183 ) => Effect.Effect<ToolAnnotations | undefined>;
152184 }
153185
186+export interface RuntimeToolHandler {
187+ readonly invoke: (
188+ args: unknown,
189+ options: InvokeOptions,
190+ ) => Effect.Effect<
191+ ToolInvocationResult,
192+ ToolInvocationError | ElicitationDeclinedError
193+ >;
194+
195+ readonly resolveAnnotations?: () => Effect.Effect<ToolAnnotations | undefined>;
196+}
197+
154198 // ---------------------------------------------------------------------------
155199 // ToolRegistration — pure data, no closures
156200 // ---------------------------------------------------------------------------
packages/core/storage-file/src/index.test.ts+24−0
@@ -122,6 +122,30 @@ describe("KvToolRegistry", () => { · 121 unmodified lines
122122 }),
123123 ),
124124 );
125+
126+ it.effect("runtime tools are listed but not persisted", () =>
127+ withKv((kv) =>
128+ Effect.gen(function* () {
129+ const reg1 = makeKvToolRegistry(scopeKv(kv, "tools"), scopeKv(kv, "defs"));
130+ yield* reg1.registerRuntime([
131+ {
132+ id: ToolId.make("executor.test.runtime"),
133+ pluginKey: "test",
134+ sourceId: "executor.test",
135+ name: "runtime",
136+ description: "Runtime-only tool",
137+ },
138+ ]);
139+
140+ const listed = yield* reg1.list();
141+ expect(listed.map((tool) => tool.id)).toContain("executor.test.runtime");
142+
143+ const reg2 = makeKvToolRegistry(scopeKv(kv, "tools"), scopeKv(kv, "defs"));
144+ const relisted = yield* reg2.list();
145+ expect(relisted.map((tool) => tool.id)).not.toContain("executor.test.runtime");
146+ }),
147+ ),
148+ );
125149 });
126150
127151 // ---------------------------------------------------------------------------
packages/core/storage-file/src/tool-registry.ts+71−7
@@ -6,7 +6,12 @@ import { Effect, Schema } from "effect"; · 5 unmodified lines
66
77 import type { ToolId, ScopedKv } from "@executor/sdk";
88 import { ToolNotFoundError, ToolInvocationError, ToolRegistration } from "@executor/sdk";
9−import type { ToolInvoker, ToolListFilter, InvokeOptions } from "@executor/sdk";
9+import type {
10+ ToolInvoker,
11+ ToolListFilter,
12+ InvokeOptions,
13+ RuntimeToolHandler,
14+} from "@executor/sdk";
1015 import { reattachDefs } from "@executor/sdk";
1116
1217 // ---------------------------------------------------------------------------
@@ -25,9 +30,12 @@ export const makeKvToolRegistry = ( · 12 unmodified lines
2530 toolsKv: ScopedKv,
2631 defsKv: ScopedKv,
2732 ) => {
33+ const runtimeTools = new Map<string, ToolRegistration>();
34+ const runtimeHandlers = new Map<string, RuntimeToolHandler>();
35+ const runtimeDefs = new Map<string, unknown>();
2836 const invokers = new Map<string, ToolInvoker>();
2937
30− const getTool = (id: string): Effect.Effect<ToolRegistration | null> =>
38+ const getPersistedTool = (id: string): Effect.Effect<ToolRegistration | null> =>
3139 Effect.gen(function* () {
3240 const raw = yield* toolsKv.get(id);
3341 if (!raw) return null;
@@ -43,13 +51,19 @@ export const makeKvToolRegistry = ( · 9 unmodified lines
4351 const getDefsMap = (): Effect.Effect<Map<string, unknown>> =>
4452 Effect.gen(function* () {
4553 const entries = yield* defsKv.list();
46− return new Map(entries.map((e) => [e.key, JSON.parse(e.value)]));
54+ const defs = new Map(entries.map((e) => [e.key, JSON.parse(e.value)]));
55+ for (const [k, v] of runtimeDefs) defs.set(k, v);
56+ return defs;
4757 });
4858
4959 return {
5060 list: (filter?: ToolListFilter) =>
5161 Effect.gen(function* () {
52− let tools = yield* getAllTools();
62+ const byId = new Map<string, ToolRegistration>();
63+ for (const tool of yield* getAllTools()) byId.set(tool.id, tool);
64+ for (const tool of runtimeTools.values()) byId.set(tool.id, tool);
65+
66+ let tools = [...byId.values()];
5367 if (filter?.sourceId) {
5468 const sid = filter.sourceId;
5569 tools = tools.filter((t) => t.sourceId === sid);
@@ -73,7 +87,7 @@ export const makeKvToolRegistry = ( · 17 unmodified lines
7387
7488 schema: (toolId: ToolId) =>
7589 Effect.gen(function* () {
76− const t = yield* getTool(toolId);
90+ const t = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId));
7791 if (!t) return yield* new ToolNotFoundError({ toolId });
7892 const defs = yield* getDefsMap();
7993 return {
@@ -96,13 +110,31 @@ export const makeKvToolRegistry = ( · 16 unmodified lines
96110 }
97111 }),
98112
113+ registerRuntimeDefinitions: (newDefs: Record<string, unknown>) =>
114+ Effect.sync(() => {
115+ for (const [name, schema] of Object.entries(newDefs)) {
116+ runtimeDefs.set(name, schema);
117+ }
118+ }),
119+
120+ unregisterRuntimeDefinitions: (names: readonly string[]) =>
121+ Effect.sync(() => {
122+ for (const name of names) {
123+ runtimeDefs.delete(name);
124+ }
125+ }),
126+
99127 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>
100128 Effect.sync(() => { invokers.set(pluginKey, invoker); }),
101129
102130 resolveAnnotations: (toolId: ToolId) =>
103131 Effect.gen(function* () {
104− const tool = yield* getTool(toolId);
132+ const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId));
105133 if (!tool) return undefined;
134+ const runtimeHandler = runtimeHandlers.get(toolId);
135+ if (runtimeHandler?.resolveAnnotations) {
136+ return yield* runtimeHandler.resolveAnnotations();
137+ }
106138 const invoker = invokers.get(tool.pluginKey);
107139 if (!invoker?.resolveAnnotations) return undefined;
108140 return yield* invoker.resolveAnnotations(toolId);
@@ -110,8 +142,12 @@ export const makeKvToolRegistry = ( · 1 unmodified line
110142
111143 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>
112144 Effect.gen(function* () {
113− const tool = yield* getTool(toolId);
145+ const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId));
114146 if (!tool) return yield* new ToolNotFoundError({ toolId });
147+ const runtimeHandler = runtimeHandlers.get(toolId);
148+ if (runtimeHandler) {
149+ return yield* runtimeHandler.invoke(args, options);
150+ }
115151 const invoker = invokers.get(tool.pluginKey);
116152 if (!invoker) {
117153 return yield* new ToolInvocationError({
@@ -130,9 +166,31 @@ export const makeKvToolRegistry = ( · 12 unmodified lines
130166 }
131167 }),
132168
169+ registerRuntime: (newTools: readonly ToolRegistration[]) =>
170+ Effect.sync(() => {
171+ for (const t of newTools) {
172+ runtimeTools.set(t.id, t);
173+ }
174+ }),
175+
176+ registerRuntimeHandler: (toolId: ToolId, handler: RuntimeToolHandler) =>
177+ Effect.sync(() => {
178+ runtimeHandlers.set(toolId, handler);
179+ }),
180+
181+ unregisterRuntime: (toolIds: readonly ToolId[]) =>
182+ Effect.sync(() => {
183+ for (const id of toolIds) {
184+ runtimeTools.delete(id);
185+ runtimeHandlers.delete(id);
186+ }
187+ }),
188+
133189 unregister: (toolIds: readonly ToolId[]) =>
134190 Effect.gen(function* () {
135191 for (const id of toolIds) {
192+ runtimeTools.delete(id);
193+ runtimeHandlers.delete(id);
136194 yield* toolsKv.delete(id);
137195 }
138196 }),
@@ -145,6 +203,12 @@ export const makeKvToolRegistry = ( · 6 unmodified lines
145203 yield* toolsKv.delete(t.id);
146204 }
147205 }
206+ for (const [id, t] of runtimeTools) {
207+ if (t.sourceId === sourceId) {
208+ runtimeTools.delete(id);
209+ runtimeHandlers.delete(id);
210+ }
211+ }
148212 }),
149213 };
150214 };
packages/plugins/openapi/src/sdk/plugin.test.ts+164−2
@@ -11,7 +11,17 @@ import { · 10 unmodified lines
1111 } from "@effect/platform";
1212 import { NodeHttpServer } from "@effect/platform-node";
1313
14−import { createExecutor, makeTestConfig, SecretId, type InvokeOptions } from "@executor/sdk";
14+import {
15+ createExecutor,
16+ makeInMemoryPolicyEngine,
17+ makeInMemorySecretStore,
18+ makeInMemorySourceRegistry,
19+ makeInMemoryToolRegistry,
20+ makeTestConfig,
21+ ScopeId,
22+ SecretId,
23+ type InvokeOptions,
24+} from "@executor/sdk";
1525 import { openApiPlugin } from "./plugin";
1626
1727 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
@@ -118,6 +128,154 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 100 unmodified lines
118128 }),
119129 );
120130
131+ it.effect("registers runtime executor.openapi tools", () =>
132+ Effect.gen(function* () {
133+ const httpClient = yield* HttpClient.HttpClient;
134+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
135+
136+ const executor = yield* createExecutor(
137+ makeTestConfig({
138+ plugins: [
139+ openApiPlugin({ httpClientLayer: clientLayer }),
140+ ] as const,
141+ }),
142+ );
143+
144+ const tools = yield* executor.tools.list();
145+ const ids = tools.map((t) => t.id);
146+ expect(ids).toContain("executor.openapi.previewSpec");
147+ expect(ids).toContain("executor.openapi.addSource");
148+ }),
149+ );
150+
151+ it.effect("lists executor.openapi as a runtime source", () =>
152+ Effect.gen(function* () {
153+ const httpClient = yield* HttpClient.HttpClient;
154+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
155+
156+ const executor = yield* createExecutor(
157+ makeTestConfig({
158+ plugins: [
159+ openApiPlugin({ httpClientLayer: clientLayer }),
160+ ] as const,
161+ }),
162+ );
163+
164+ const sources = yield* executor.sources.list();
165+
166+ expect(sources).toContainEqual(expect.objectContaining({
167+ id: "executor.openapi",
168+ name: "OpenAPI",
169+ kind: "openapi",
170+ runtime: true,
171+ canRemove: false,
172+ canRefresh: false,
173+ }));
174+ }),
175+ );
176+
177+ it.effect("closing an executor does not remove added source tools from the shared registry", () =>
178+ Effect.gen(function* () {
179+ const httpClient = yield* HttpClient.HttpClient;
180+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
181+
182+ const scope = {
183+ id: ScopeId.make("test-scope"),
184+ parentId: null,
185+ name: "test",
186+ createdAt: new Date(),
187+ } as const;
188+
189+ const sharedConfig = {
190+ scope,
191+ tools: makeInMemoryToolRegistry(),
192+ sources: makeInMemorySourceRegistry(),
193+ secrets: makeInMemorySecretStore(),
194+ policies: makeInMemoryPolicyEngine(),
195+ };
196+
197+ const executor1 = yield* createExecutor({
198+ ...sharedConfig,
199+ plugins: [
200+ openApiPlugin({ httpClientLayer: clientLayer }),
201+ ] as const,
202+ });
203+
204+ yield* executor1.openapi.addSpec({
205+ spec: specJson,
206+ namespace: "persisted",
207+ });
208+
209+ expect((yield* executor1.tools.list()).map((tool) => tool.id)).toContain(
210+ "persisted.items.listItems",
211+ );
212+
213+ yield* executor1.close();
214+
215+ const executor2 = yield* createExecutor({
216+ ...sharedConfig,
217+ plugins: [
218+ openApiPlugin({ httpClientLayer: clientLayer }),
219+ ] as const,
220+ });
221+
222+ expect((yield* executor2.tools.list()).map((tool) => tool.id)).toContain(
223+ "persisted.items.listItems",
224+ );
225+ }),
226+ );
227+
228+ it.effect("invokes runtime previewSpec through executor.tools.invoke", () =>
229+ Effect.gen(function* () {
230+ const httpClient = yield* HttpClient.HttpClient;
231+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
232+
233+ const executor = yield* createExecutor(
234+ makeTestConfig({
235+ plugins: [
236+ openApiPlugin({ httpClientLayer: clientLayer }),
237+ ] as const,
238+ }),
239+ );
240+
241+ const result = yield* executor.tools.invoke(
242+ "executor.openapi.previewSpec",
243+ { spec: specJson },
244+ autoApprove,
245+ );
246+
247+ expect(result.error).toBeNull();
248+ expect((result.data as { operationCount: number }).operationCount).toBeGreaterThanOrEqual(2);
249+ }),
250+ );
251+
252+ it.effect("invokes runtime addSource through executor.tools.invoke", () =>
253+ Effect.gen(function* () {
254+ const httpClient = yield* HttpClient.HttpClient;
255+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
256+
257+ const executor = yield* createExecutor(
258+ makeTestConfig({
259+ plugins: [
260+ openApiPlugin({ httpClientLayer: clientLayer }),
261+ ] as const,
262+ }),
263+ );
264+
265+ const result = yield* executor.tools.invoke(
266+ "executor.openapi.addSource",
267+ { spec: specJson, namespace: "runtime" },
268+ autoApprove,
269+ );
270+
271+ expect(result.error).toBeNull();
272+ expect(result.data).toEqual({ sourceId: "runtime", toolCount: 3 });
273+ expect((yield* executor.tools.list()).map((t) => t.id)).toContain(
274+ "runtime.items.listItems",
275+ );
276+ }),
277+ );
278+
121279 it.effect("resolves secret-backed headers at invocation time", () =>
122280 Effect.gen(function* () {
123281 const httpClient = yield* HttpClient.HttpClient;
@@ -299,7 +457,11 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 175 unmodified lines
299457
300458 yield* executor.openapi.removeSpec("removable");
301459
302− expect(yield* executor.tools.list()).toHaveLength(0);
460+ const remaining = yield* executor.tools.list();
461+ expect(remaining.map((tool) => tool.id)).toEqual([
462+ "executor.openapi.previewSpec",
463+ "executor.openapi.addSource",
464+ ]);
303465 }),
304466 );
305467 });
packages/plugins/openapi/src/sdk/plugin.ts+134−83
@@ -1,10 +1,12 @@
1−import { Effect, Option } from "effect";
1+import { Effect, Option, Schema } from "effect";
22 import { FetchHttpClient, HttpClient } from "@effect/platform";
33 import type { Layer } from "effect";
44
55 import {
66 Source,
77 definePlugin,
8+ registerRuntimeTools,
9+ runtimeTool,
810 type ExecutorPlugin,
911 type PluginContext,
1012 ToolId,
@@ -18,19 +20,20 @@ import { makeOpenApiInvoker } from "./invoke"; · 7 unmodified lines
1820 import { resolveBaseUrl } from "./openapi-utils";
1921 import type { OpenApiOperationStore } from "./operation-store";
2022 import { makeInMemoryOperationStore } from "./kv-operation-store";
21−import { previewSpec, type SpecPreview } from "./preview";
23+import { previewSpec, SpecPreview } from "./preview";
2224 import {
25+ HeaderValue as HeaderValueSchema,
2326 InvocationConfig,
2427 OperationBinding,
28+ type HeaderValue as HeaderValueValue,
2529 } from "./types";
2630
27−
2831 // ---------------------------------------------------------------------------
2932 // Plugin config
3033 // ---------------------------------------------------------------------------
3134
3235 /** A header value — either a static string or a reference to a secret */
33−export type HeaderValue = string | { readonly secretId: string; readonly prefix?: string };
36+export type HeaderValue = HeaderValueValue;
3437
3538 export interface OpenApiSpecConfig {
3639 readonly spec: string;
@@ -46,9 +49,7 @@ export interface OpenApiSpecConfig { · 9 unmodified lines
4649
4750 export interface OpenApiPluginExtension {
4851 /** Preview a spec without registering — returns metadata, auth strategies, header presets */
49− readonly previewSpec: (
50− specText: string,
51− ) => Effect.Effect<SpecPreview, Error>;
52+ readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>;
5253
5354 /** Add an OpenAPI spec and register its operations as tools */
5455 readonly addSpec: (
@@ -63,14 +64,35 @@ export interface OpenApiPluginExtension { · 8 unmodified lines
6364 // Helpers
6465 // ---------------------------------------------------------------------------
6566
67+const PreviewSpecInputSchema = Schema.Struct({
68+ spec: Schema.String,
69+});
70+type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
71+
72+const AddSourceInputSchema = Schema.Struct({
73+ spec: Schema.String,
74+ baseUrl: Schema.optional(Schema.String),
75+ namespace: Schema.optional(Schema.String),
76+ headers: Schema.optional(
77+ Schema.Record({ key: Schema.String, value: HeaderValueSchema }),
78+ ),
79+});
80+type AddSourceInput = typeof AddSourceInputSchema.Type;
81+
82+const AddSourceOutputSchema = Schema.Struct({
83+ sourceId: Schema.String,
84+ toolCount: Schema.Number,
85+});
86+
6687 const toRegistration = (
6788 def: ToolDefinition,
6889 namespace: string,
6990 ): ToolRegistration => {
7091 const op = def.operation;
7192 const description = Option.getOrElse(op.description, () =>
72− Option.getOrElse(op.summary, () =>
73− `${op.method.toUpperCase()} ${op.pathTemplate}`,
93+ Option.getOrElse(
94+ op.summary,
95+ () => `${op.method.toUpperCase()} ${op.pathTemplate}`,
7496 ),
7597 );
7698 return {
@@ -101,10 +123,8 @@ export const openApiPlugin = (options?: { · 24 unmodified lines
101123 readonly operationStore?: OpenApiOperationStore;
102124 }): ExecutorPlugin<"openapi", OpenApiPluginExtension> => {
103125 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
104− const operationStore = options?.operationStore ?? makeInMemoryOperationStore();
105−
106− // Track added sources so we can list them
107− const addedSources = new Map<string, Source>();
126+ const operationStore =
127+ options?.operationStore ?? makeInMemoryOperationStore();
108128
109129 return definePlugin({
110130 key: "openapi",
@@ -120,111 +140,142 @@ export const openApiPlugin = (options?: { · 9 unmodified lines
120140 }),
121141 );
122142
123− // Restore source metadata from persistent store
124− const savedMetas = yield* operationStore.listSourceMeta();
125− for (const meta of savedMetas) {
126− addedSources.set(meta.namespace, new Source({
127− id: meta.namespace,
128− name: meta.name,
129− kind: "openapi",
130− }));
131− }
132−
133143 // Tools are already persisted in the KV tool registry — no need to
134144 // re-register them. We only need the source list and the invoker.
135−
136145 // Register source manager so the core can list/remove/refresh our sources
137146 yield* ctx.sources.addManager({
138147 kind: "openapi",
139148
140149 list: () =>
141− Effect.sync(() => [...addedSources.values()]),
150+ operationStore.listSourceMeta().pipe(
151+ Effect.map((metas) =>
152+ metas.map(
153+ (meta) =>
154+ new Source({
155+ id: meta.namespace,
156+ name: meta.name,
157+ kind: "openapi",
158+ runtime: false,
159+ canRemove: true,
160+ canRefresh: false,
161+ }),
162+ ),
163+ ),
164+ ),
142165
143166 remove: (sourceId: string) =>
144167 Effect.gen(function* () {
145168 yield* operationStore.removeByNamespace(sourceId);
146169 yield* operationStore.removeSourceMeta(sourceId);
147170 yield* ctx.tools.unregisterBySource(sourceId);
148− addedSources.delete(sourceId);
149171 }),
150172
151173 // TODO: refresh requires storing original config per namespace
152174 });
153175
176+ const addSpecInternal = (config: OpenApiSpecConfig) =>
177+ Effect.gen(function* () {
178+ const doc = yield* parse(config.spec);
179+ const result = yield* extract(doc);
180+
181+ const namespace =
182+ config.namespace ??
183+ Option.getOrElse(result.title, () => "api")
184+ .toLowerCase()
185+ .replace(/[^a-z0-9]+/g, "_");
186+
187+ if (doc.components?.schemas) {
188+ yield* ctx.tools.registerDefinitions(doc.components.schemas);
189+ }
190+
191+ const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
192+ const invocationConfig = new InvocationConfig({
193+ baseUrl,
194+ headers: config.headers ?? {},
195+ });
196+
197+ const definitions = compileToolDefinitions(result.operations);
198+
199+ const registrations = definitions.map((def) =>
200+ toRegistration(def, namespace),
201+ );
202+
203+ yield* Effect.forEach(
204+ definitions,
205+ (def) =>
206+ operationStore.put(
207+ ToolId.make(`${namespace}.${def.toolPath}`),
208+ namespace,
209+ toBinding(def),
210+ invocationConfig,
211+ ),
212+ { discard: true },
213+ );
214+
215+ yield* ctx.tools.register(registrations);
216+
217+ const sourceName = Option.getOrElse(result.title, () => namespace);
218+ yield* operationStore.putSourceMeta({
219+ namespace,
220+ name: sourceName,
221+ });
222+
223+ return { sourceId: namespace, toolCount: registrations.length };
224+ });
225+
226+ const runtimeTools = yield* registerRuntimeTools({
227+ registry: ctx.tools,
228+ sources: ctx.sources,
229+ pluginKey: "openapi",
230+ source: {
231+ id: "executor.openapi",
232+ name: "OpenAPI",
233+ },
234+ tools: [
235+ runtimeTool({
236+ id: "executor.openapi.previewSpec",
237+ name: "previewSpec",
238+ description:
239+ "Preview an OpenAPI document before adding it as a source",
240+ inputSchema: PreviewSpecInputSchema,
241+ outputSchema: SpecPreview,
242+ handler: ({ spec }: PreviewSpecInput) => previewSpec(spec),
243+ }),
244+ runtimeTool({
245+ id: "executor.openapi.addSource",
246+ name: "addSource",
247+ description:
248+ "Add an OpenAPI source and register its operations as tools",
249+ inputSchema: AddSourceInputSchema,
250+ outputSchema: AddSourceOutputSchema,
251+ handler: (input: AddSourceInput) => addSpecInternal(input),
252+ }),
253+ ],
254+ });
255+
154256 return {
155257 extension: {
156258 previewSpec: (specText: string) => previewSpec(specText),
157259
158260 addSpec: (config: OpenApiSpecConfig) =>
159− Effect.gen(function* () {
160− const doc = yield* parse(config.spec);
161− const result = yield* extract(doc);
162−
163− const namespace =
164− config.namespace ??
165− Option.getOrElse(result.title, () => "api")
166− .toLowerCase()
167− .replace(/[^a-z0-9]+/g, "_");
168−
169− if (doc.components?.schemas) {
170− yield* ctx.tools.registerDefinitions(doc.components.schemas);
171− }
172−
173− const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
174− const invocationConfig = new InvocationConfig({
175− baseUrl,
176− headers: config.headers ?? {},
177− });
178−
179− const definitions = compileToolDefinitions(result.operations);
180−
181− const registrations = definitions.map((def) =>
182− toRegistration(def, namespace),
183− );
184−
185− yield* Effect.forEach(
186− definitions,
187− (def) =>
188− operationStore.put(
189− ToolId.make(`${namespace}.${def.toolPath}`),
190− namespace,
191− toBinding(def),
192− invocationConfig,
193− ),
194− { discard: true },
195− );
196−
197− yield* ctx.tools.register(registrations);
198−
199− // Track the source — persist and cache
200− const sourceName = Option.getOrElse(result.title, () => namespace);
201− yield* operationStore.putSourceMeta({ namespace, name: sourceName });
202− addedSources.set(namespace, new Source({
203− id: namespace,
204− name: sourceName,
205− kind: "openapi",
206− }));
207−
208− return { toolCount: registrations.length };
209− }),
261+ addSpecInternal(config).pipe(
262+ Effect.map(({ toolCount }) => ({ toolCount })),
263+ ),
210264
211265 removeSpec: (namespace: string) =>
212266 Effect.gen(function* () {
213− const toolIds = yield* operationStore.removeByNamespace(namespace);
267+ const toolIds =
268+ yield* operationStore.removeByNamespace(namespace);
214269 if (toolIds.length > 0) {
215270 yield* ctx.tools.unregister(toolIds);
216271 }
217272 yield* operationStore.removeSourceMeta(namespace);
218− addedSources.delete(namespace);
219273 }),
220274 },
221275
222276 close: () =>
223277 Effect.gen(function* () {
224− for (const sourceId of addedSources.keys()) {
225− yield* ctx.tools.unregisterBySource(sourceId);
226− }
227− addedSources.clear();
278+ yield* runtimeTools.close();
228279 }),
229280 };
230281 }),
packages/plugins/openapi/src/sdk/real-specs.test.ts+10−3
@@ -114,9 +114,12 @@ describe("Real specs: Cloudflare API", () => { · 113 unmodified lines
114114 expect(result.toolCount).toBeGreaterThan(1000);
115115
116116 const tools = yield* executor.tools.list();
117− expect(tools.length).toBe(result.toolCount);
117+ expect(tools.length).toBe(result.toolCount + 2);
118118
119− for (const tool of tools) {
119+ const cloudflareTools = tools.filter((tool) => tool.sourceId === "cloudflare");
120+ expect(cloudflareTools.length).toBe(result.toolCount);
121+
122+ for (const tool of cloudflareTools) {
120123 expect(tool.pluginKey).toBe("openapi");
121124 expect(tool.sourceId).toBe("cloudflare");
122125 }
@@ -212,7 +215,11 @@ describe("Real specs: Cloudflare API", () => { · 89 unmodified lines
212215
213216 yield* executor.openapi.removeSpec("cloudflare");
214217
215− expect(yield* executor.tools.list()).toHaveLength(0);
218+ const remaining = yield* executor.tools.list();
219+ expect(remaining.map((tool) => tool.id)).toEqual([
220+ "executor.openapi.previewSpec",
221+ "executor.openapi.addSource",
222+ ]);
216223 }),
217224 );
218225 });