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 @@
1import { createServerHandlersWithExecutor, type ServerHandlers } from "./main";
2import { createServerExecutorHandle } from "./services/executor";
3
4export type HotBackend = ServerHandlers & {
5 readonly dispose: () => Promise<void>;
6};
7
8export 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
17 id: s.id,17 id: s.id,
18 name: s.name,18 name: s.name,
19 kind: s.kind,19 kind: s.kind,
20 runtime: s.runtime,
21 canRemove: s.canRemove,
22 canRefresh: s.canRefresh,
20 }));23 }));
21 }),24 }),
22 )25 )
apps/server/src/index.ts+2−1
@@ -1,3 +1,4 @@
1export { createApiHandler, createServerHandlers, type ApiHandler, type ServerHandlers } from "./main";1export { createApiHandler, createServerHandlers, type ApiHandler, type ServerHandlers } from "./main";
2export { ExecutorServiceLayer, getExecutor } from "./services/executor";2export { createServerHandlersWithExecutor } from "./main";
3export { ExecutorServiceLayer, createServerExecutorHandle, disposeExecutor, getExecutor, reloadExecutor } from "./services/executor";
3export { createMcpRequestHandler, runMcpStdioServer, type McpRequestHandler } from "./mcp";4export { 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
16import { OpenApiHandlersLive } from "./handlers/openapi";16import { OpenApiHandlersLive } from "./handlers/openapi";
17import { McpSourceHandlersLive } from "./handlers/mcp-source";17import { McpSourceHandlersLive } from "./handlers/mcp-source";
18import { OnePasswordHandlersLive } from "./handlers/onepassword";18import { OnePasswordHandlersLive } from "./handlers/onepassword";
19import { ExecutorServiceLayer, getExecutor } from "./services/executor";19import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";
20import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";20import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
21 21
22// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
@@ -29,7 +29,7 @@ const ExecutorApiWithPlugins = addGroup(OpenApiGroup).add(McpGroup).add(OnePassw · 6 unmodified lines
29// API Layer29// API Layer
30// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
31 31
32const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe(32const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe(
33 Layer.provide([33 Layer.provide([
34 ToolsHandlers,34 ToolsHandlers,
35 SourcesHandlers,35 SourcesHandlers,
@@ -38,7 +38,6 @@ const ApiLive = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 2 unmodified lines
38 McpSourceHandlersLive,38 McpSourceHandlersLive,
39 OnePasswordHandlersLive,39 OnePasswordHandlersLive,
40 ]),40 ]),
41 Layer.provide(ExecutorServiceLayer),
42);41);
43 42
44// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
@@ -53,24 +52,30 @@ export type ServerHandlers = { · 8 unmodified lines
53 readonly mcp: McpRequestHandler;52 readonly mcp: McpRequestHandler;
54};53};
55 54
56export const createServerHandlers = async (): Promise<ServerHandlers> => {55const createApiHandlerWithExecutor = (executor: ServerExecutor) =>
57 const executor = await getExecutor();56 HttpApiBuilder.toWebHandler(
58
59 const api = HttpApiBuilder.toWebHandler(
60 HttpApiSwagger.layer().pipe(57 HttpApiSwagger.layer().pipe(
61 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),58 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
62 Layer.provideMerge(HttpApiBuilder.middlewareCors()),59 Layer.provideMerge(HttpApiBuilder.middlewareCors()),
63 Layer.provideMerge(ApiLive),60 Layer.provideMerge(ApiBase),
61 Layer.provideMerge(Layer.succeed(ExecutorService, executor)),
64 Layer.provideMerge(HttpServer.layerContext),62 Layer.provideMerge(HttpServer.layerContext),
65 ),63 ),
66 { middleware: HttpMiddleware.logger },64 { middleware: HttpMiddleware.logger },
67 );65 );
68 66
67export const createServerHandlersWithExecutor = async (
68 executor: ServerExecutor,
69): Promise<ServerHandlers> => {
70 const api = createApiHandlerWithExecutor(executor);
69 const mcp = createMcpRequestHandler({ executor });71 const mcp = createMcpRequestHandler({ executor });
70 72
71 return { api, mcp };73 return { api, mcp };
72};74};
73 75
76export const createServerHandlers = async (): Promise<ServerHandlers> =>
77 createServerHandlersWithExecutor(await getExecutor());
78
74// ---------------------------------------------------------------------------79// ---------------------------------------------------------------------------
75// Backwards compat — standalone API handler (no MCP)80// Backwards compat — standalone API handler (no MCP)
76// ---------------------------------------------------------------------------81// ---------------------------------------------------------------------------
@@ -80,7 +85,8 @@ export const createApiHandler = () => · 3 unmodified lines
80 HttpApiSwagger.layer().pipe(85 HttpApiSwagger.layer().pipe(
81 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),86 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
82 Layer.provideMerge(HttpApiBuilder.middlewareCors()),87 Layer.provideMerge(HttpApiBuilder.middlewareCors()),
83 Layer.provideMerge(ApiLive),88 Layer.provideMerge(ApiBase),
89 Layer.provideMerge(ExecutorServiceLayer),
84 Layer.provideMerge(HttpServer.layerContext),90 Layer.provideMerge(HttpServer.layerContext),
85 ),91 ),
86 { middleware: HttpMiddleware.logger },92 { middleware: HttpMiddleware.logger },
apps/server/src/services/executor.ts+47−3
@@ -21,6 +21,10 @@ type ServerPlugins = readonly [ · 20 unmodified lines
21 ExecutorPlugin<"onepassword", OnePasswordExtension>,21 ExecutorPlugin<"onepassword", OnePasswordExtension>,
22];22];
23export type ServerExecutor = Executor<ServerPlugins>;23export type ServerExecutor = Executor<ServerPlugins>;
24export type ServerExecutorHandle = {
25 readonly executor: ServerExecutor;
26 readonly dispose: () => Promise<void>;
27};
24 28
25// ---------------------------------------------------------------------------29// ---------------------------------------------------------------------------
26// Service tag30// Service tag
@@ -78,17 +82,57 @@ const ExecutorLayer = Layer.effect( · 51 unmodified lines
78);82);
79 83
80// ---------------------------------------------------------------------------84// ---------------------------------------------------------------------------
81// ManagedRuntime — keeps the SQLite scope alive for the process lifetime85// ManagedRuntime — shared singleton for production, scoped handles for dev HMR
82// ---------------------------------------------------------------------------86// ---------------------------------------------------------------------------
83 87
84const runtime = ManagedRuntime.make(ExecutorLayer);88export 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
101let sharedHandlePromise: Promise<ServerExecutorHandle> | null = null;
102
103const loadSharedHandle = (): Promise<ServerExecutorHandle> => {
104 if (!sharedHandlePromise) {
105 sharedHandlePromise = createServerExecutorHandle();
106 }
107 return sharedHandlePromise;
108};
85 109
86/**110/**
87 * Get the shared executor instance. The ManagedRuntime keeps the SQLite111 * Get the shared executor instance. The ManagedRuntime keeps the SQLite
88 * connection (and everything else) alive until the process exits.112 * connection (and everything else) alive until the process exits.
89 */113 */
90export const getExecutor = (): Promise<ServerExecutor> =>114export 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 */
121export 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 */
132export const reloadExecutor = async (): Promise<ServerExecutor> => {
133 await disposeExecutor();
134 return getExecutor();
135};
92 136
93/**137/**
94 * Provide `ExecutorService` to an Effect layer using the shared runtime.138 * 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
34 const [refreshing, setRefreshing] = useState(false);34 const [refreshing, setRefreshing] = useState(false);
35 35
36 const sourceData = Result.isSuccess(source) ? source.value : null;36 const sourceData = Result.isSuccess(source) ? source.value : null;
37 const canRefresh = sourceData ? (sourceData.canRefresh ?? true) : false;
38 const canRemove = sourceData ? (sourceData.canRemove ?? true) : false;
37 39
38 const sourceTools: ToolSummary[] = useMemo(() => {40 const sourceTools: ToolSummary[] = useMemo(() => {
39 if (!Result.isSuccess(tools)) return [];41 if (!Result.isSuccess(tools)) return [];
@@ -85,6 +87,11 @@ export function SourceDetailPage(props: { namespace: string }) { · 45 unmodified lines
85 <h2 className="truncate text-sm font-semibold text-foreground">87 <h2 className="truncate text-sm font-semibold text-foreground">
86 {sourceData?.name ?? namespace}88 {sourceData?.name ?? namespace}
87 </h2>89 </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 )}
88 <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">95 <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">
89 {sourceData?.kind ?? "source"}96 {sourceData?.kind ?? "source"}
90 </span>97 </span>
@@ -96,16 +103,18 @@ export function SourceDetailPage(props: { namespace: string }) { · 5 unmodified lines
96 </div>103 </div>
97 104
98 <div className="flex shrink-0 items-center gap-2">105 <div className="flex shrink-0 items-center gap-2">
99 <button106 {canRefresh && (
100 type="button"107 <button
101 onClick={() => void handleRefresh()}108 type="button"
102 disabled={refreshing}109 onClick={() => void handleRefresh()}
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"110 disabled={refreshing}
104 >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"
105 {refreshing ? "Refreshing…" : "Refresh"}112 >
106 </button>113 {refreshing ? "Refreshing…" : "Refresh"}
114 </button>
115 )}
107 116
108 {confirmDelete ? (117 {canRemove && (confirmDelete ? (
109 <div className="flex items-center gap-2">118 <div className="flex items-center gap-2">
110 <span className="text-[11px] font-medium text-destructive">119 <span className="text-[11px] font-medium text-destructive">
111 Confirm?120 Confirm?
@@ -135,7 +144,7 @@ export function SourceDetailPage(props: { namespace: string }) { · 23 unmodified lines
135 >144 >
136 Delete145 Delete
137 </button>146 </button>
138 )}147 ))}
139 </div>148 </div>
140 </div>149 </div>
141 150
apps/web/src/pages/sources.tsx+82−34
@@ -25,6 +25,53 @@ export function SourcesPage() { · 24 unmodified lines
25 ? sourcePlugins.find((p) => p.key === adding)25 ? sourcePlugins.find((p) => p.key === adding)
26 : undefined;26 : undefined;
27 27
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
28 if (plugin) {75 if (plugin) {
29 const AddComponent = plugin.add;76 const AddComponent = plugin.add;
30 return (77 return (
@@ -54,7 +101,7 @@ export function SourcesPage() { · 23 unmodified lines
54 Sources101 Sources
55 </h1>102 </h1>
56 <p className="mt-1.5 text-[14px] text-muted-foreground">103 <p className="mt-1.5 text-[14px] text-muted-foreground">
57 Connected tool providers in this workspace.104 Tool providers available in this workspace.
58 </p>105 </p>
59 </div>106 </div>
60 <div className="flex gap-2">107 <div className="flex gap-2">
@@ -82,8 +129,11 @@ export function SourcesPage() { · 21 unmodified lines
82 onFailure: () => (129 onFailure: () => (
83 <p className="text-sm text-destructive">Failed to load sources</p>130 <p className="text-sm text-destructive">Failed to load sources</p>
84 ),131 ),
85 onSuccess: ({ value }) =>132 onSuccess: ({ value }) => {
86 value.length === 0 ? (133 const builtInSources = value.filter((source) => source.runtime);
134 const connectedSources = value.filter((source) => !source.runtime);
135
136 return value.length === 0 ? (
87 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">137 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
88 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">138 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">
89 <svg viewBox="0 0 24 24" fill="none" className="size-5">139 <svg viewBox="0 0 24 24" fill="none" className="size-5">
@@ -98,39 +148,37 @@ export function SourcesPage() { · 8 unmodified lines
98 </p>148 </p>
99 </div>149 </div>
100 ) : (150 ) : (
101 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">151 <div className="space-y-8">
102 {value.map((s) => (152 {builtInSources.length > 0 && (
103 <Link153 <section className="space-y-3">
104 key={s.id}154 <div>
105 to="/sources/$namespace"155 <h2 className="text-sm font-semibold text-foreground">
106 params={{ namespace: s.id }}156 Built-in
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"157 </h2>
108 >158 <p className="mt-1 text-[13px] text-muted-foreground">
109 <div className="flex items-start gap-3">159 Runtime sources exposed by the loaded executor plugins.
110 <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">160 </p>
111 <svg viewBox="0 0 16 16" className="size-4">161 </div>
112 <circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeWidth="1.2" />162 {renderSourceGrid(builtInSources)}
113 <path d="M8 5v6M5 8h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />163 </section>
114 </svg>164 )}
115 </div>165
116 <div className="min-w-0 flex-1">166 {connectedSources.length > 0 && (
117 <div className="flex items-start justify-between gap-2">167 <section className="space-y-3">
118 <div className="truncate text-sm font-semibold text-foreground">168 <div>
119 {s.name}169 <h2 className="text-sm font-semibold text-foreground">
120 </div>170 Connected
121 <span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground">171 </h2>
122 {s.kind}172 <p className="mt-1 text-[13px] text-muted-foreground">
123 </span>173 User-configured sources available in this workspace.
124 </div>174 </p>
125 <div className="mt-0.5 text-xs text-muted-foreground">
126 {s.id}
127 </div>
128 </div>
129 </div>175 </div>
130 </Link>176 {renderSourceGrid(connectedSources)}
131 ))}177 </section>
178 )}
132 </div>179 </div>
133 ),180 );
181 },
134 })}182 })}
135 </div>183 </div>
136 </div>184 </div>
apps/web/src/shell.tsx+36−1
@@ -1,6 +1,13 @@
1import { Link, Outlet, useLocation } from "@tanstack/react-router";1import { Link, Outlet, useLocation } from "@tanstack/react-router";
2import { useEffect, useRef, useState } from "react";2import { useEffect, useRef, useState } from "react";
3import { useAtomValue, sourcesAtom, Result } from "@executor/react";3import {
4 useAtomRefresh,
5 useAtomValue,
6 Result,
7 sourceToolsAtom,
8 sourcesAtom,
9 toolsAtom,
10} from "@executor/react";
4 11
5// ── NavItem ──────────────────────────────────────────────────────────────12// ── NavItem ──────────────────────────────────────────────────────────────
6 13
@@ -157,6 +164,14 @@ function SidebarContent(props: { · 150 unmodified lines
157export function Shell() {164export function Shell() {
158 const location = useLocation();165 const location = useLocation();
159 const pathname = location.pathname;166 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 );
160 const lastPathname = useRef(pathname);175 const lastPathname = useRef(pathname);
161 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);176 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
162 if (lastPathname.current !== pathname) {177 if (lastPathname.current !== pathname) {
@@ -174,6 +189,26 @@ export function Shell() { · 11 unmodified lines
174 };189 };
175 }, [mobileSidebarOpen]);190 }, [mobileSidebarOpen]);
176 191
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
177 return (212 return (
178 <div className="flex h-screen overflow-hidden">213 <div className="flex h-screen overflow-hidden">
179 {/* Desktop sidebar */}214 {/* Desktop sidebar */}
apps/web/vite.config.ts+83−5
@@ -1,9 +1,24 @@
1import { resolve } from "node:path";
1import { defineConfig } from "vite";2import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";3import react from "@vitejs/plugin-react";
3import tailwindcss from "@tailwindcss/vite";4import tailwindcss from "@tailwindcss/vite";
4import { createServerHandlers } from "@executor/server";
5 5
6const handlersPromise = createServerHandlers();6type 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
18const DEV_BACKEND_MODULE_ID = `/@fs/${resolve(
19 process.cwd(),
20 "../server/src/dev-backend.ts",
21).replace(/\\/g, "/")}`;
7 22
8// Build a Web Request from a Node IncomingMessage23// Build a Web Request from a Node IncomingMessage
9const toWebRequest = async (24const toWebRequest = async (
@@ -69,6 +84,69 @@ export default defineConfig({ · 59 unmodified lines
69 {84 {
70 name: "executor-api",85 name: "executor-api",
71 configureServer(server) {86 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
72 server.middlewares.use(async (req, res, next) => {150 server.middlewares.use(async (req, res, next) => {
73 const url = req.url ?? "/";151 const url = req.url ?? "/";
74 152
@@ -80,12 +158,12 @@ export default defineConfig({ · 5 unmodified lines
80 158
81 if (!isApi && !isMcp) return next();159 if (!isApi && !isMcp) return next();
82 160
83 const handlers = await handlersPromise;161 const backend = await getBackend();
84 const request = await toWebRequest(req);162 const request = await toWebRequest(req);
85 163
86 const response = isMcp164 const response = isMcp
87 ? await handlers.mcp.handleRequest(request)165 ? await backend.mcp.handleRequest(request)
88 : await handlers.api.handler(request);166 : await backend.api.handler(request);
89 167
90 await sendWebResponse(response, res);168 await sendWebResponse(response, res);
91 });169 });
packages/core/api/src/sources/api.ts+3−0
@@ -17,6 +17,9 @@ const SourceResponse = Schema.Struct({ · 16 unmodified lines
17 id: Schema.String,17 id: Schema.String,
18 name: Schema.String,18 name: Schema.String,
19 kind: Schema.String,19 kind: Schema.String,
20 runtime: Schema.optional(Schema.Boolean),
21 canRemove: Schema.optional(Schema.Boolean),
22 canRefresh: Schema.optional(Schema.Boolean),
20});23});
21 24
22const SourceRemoveResponse = Schema.Struct({25const 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
2 2
3import { ToolId } from "../ids";3import { ToolId } from "../ids";
4import { ToolNotFoundError, ToolInvocationError } from "../errors";4import { ToolNotFoundError, ToolInvocationError } from "../errors";
5import type { ToolRegistration, ToolInvoker, ToolListFilter, InvokeOptions } from "../tools";5import type {
6 ToolRegistration,
7 ToolInvoker,
8 ToolListFilter,
9 InvokeOptions,
10 RuntimeToolHandler,
11} from "../tools";
6import { reattachDefs } from "../schema-refs";12import { reattachDefs } from "../schema-refs";
7 13
8export const makeInMemoryToolRegistry = () => {14export const makeInMemoryToolRegistry = () => {
9 const tools = new Map<string, ToolRegistration>();15 const tools = new Map<string, ToolRegistration>();
16 const runtimeTools = new Map<string, ToolRegistration>();
17 const runtimeHandlers = new Map<string, RuntimeToolHandler>();
10 const invokers = new Map<string, ToolInvoker>();18 const invokers = new Map<string, ToolInvoker>();
11 const sharedDefs = new Map<string, unknown>();19 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 };
12 31
13 return {32 return {
14 list: (filter?: ToolListFilter) =>33 list: (filter?: ToolListFilter) =>
15 Effect.sync(() => {34 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()];
17 if (filter?.sourceId) {40 if (filter?.sourceId) {
18 const sid = filter.sourceId;41 const sid = filter.sourceId;
19 result = result.filter((t) => t.sourceId === sid);42 result = result.filter((t) => t.sourceId === sid);
@@ -36,12 +59,12 @@ export const makeInMemoryToolRegistry = () => { · 16 unmodified lines
36 }),59 }),
37 60
38 schema: (toolId: ToolId) =>61 schema: (toolId: ToolId) =>
39 Effect.fromNullable(tools.get(toolId)).pipe(62 Effect.fromNullable(getTool(toolId)).pipe(
40 Effect.mapError(() => new ToolNotFoundError({ toolId })),63 Effect.mapError(() => new ToolNotFoundError({ toolId })),
41 Effect.map((t) => ({64 Effect.map((t) => ({
42 id: t.id,65 id: t.id,
43 inputSchema: reattachDefs(t.inputSchema, sharedDefs),66 inputSchema: reattachDefs(t.inputSchema, getDefs()),
44 outputSchema: reattachDefs(t.outputSchema, sharedDefs),67 outputSchema: reattachDefs(t.outputSchema, getDefs()),
45 })),68 })),
46 ),69 ),
47 70
@@ -51,6 +74,9 @@ export const makeInMemoryToolRegistry = () => { · 3 unmodified lines
51 for (const [k, v] of sharedDefs) {74 for (const [k, v] of sharedDefs) {
52 result[k] = v;75 result[k] = v;
53 }76 }
77 for (const [k, v] of runtimeDefs) {
78 result[k] = v;
79 }
54 return result;80 return result;
55 }),81 }),
56 82
@@ -61,6 +87,20 @@ export const makeInMemoryToolRegistry = () => { · 4 unmodified lines
61 }87 }
62 }),88 }),
63 89
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
64 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>104 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>
65 Effect.sync(() => {105 Effect.sync(() => {
66 invokers.set(pluginKey, invoker);106 invokers.set(pluginKey, invoker);
@@ -68,8 +108,12 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line
68 108
69 resolveAnnotations: (toolId: ToolId) =>109 resolveAnnotations: (toolId: ToolId) =>
70 Effect.gen(function* () {110 Effect.gen(function* () {
71 const tool = tools.get(toolId);111 const tool = getTool(toolId);
72 if (!tool) return undefined;112 if (!tool) return undefined;
113 const runtimeHandler = runtimeHandlers.get(toolId);
114 if (runtimeHandler?.resolveAnnotations) {
115 return yield* runtimeHandler.resolveAnnotations();
116 }
73 const invoker = invokers.get(tool.pluginKey);117 const invoker = invokers.get(tool.pluginKey);
74 if (!invoker?.resolveAnnotations) return undefined;118 if (!invoker?.resolveAnnotations) return undefined;
75 return yield* invoker.resolveAnnotations(toolId);119 return yield* invoker.resolveAnnotations(toolId);
@@ -77,9 +121,13 @@ export const makeInMemoryToolRegistry = () => { · 1 unmodified line
77 121
78 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>122 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>
79 Effect.gen(function* () {123 Effect.gen(function* () {
80 const tool = yield* Effect.fromNullable(tools.get(toolId)).pipe(124 const tool = yield* Effect.fromNullable(getTool(toolId)).pipe(
81 Effect.mapError(() => new ToolNotFoundError({ toolId })),125 Effect.mapError(() => new ToolNotFoundError({ toolId })),
82 );126 );
127 const runtimeHandler = runtimeHandlers.get(toolId);
128 if (runtimeHandler) {
129 return yield* runtimeHandler.invoke(args, options);
130 }
83 const invoker = invokers.get(tool.pluginKey);131 const invoker = invokers.get(tool.pluginKey);
84 if (!invoker) {132 if (!invoker) {
85 return yield* new ToolInvocationError({133 return yield* new ToolInvocationError({
@@ -98,10 +146,32 @@ export const makeInMemoryToolRegistry = () => { · 12 unmodified lines
98 }146 }
99 }),147 }),
100 148
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
101 unregister: (toolIds: readonly ToolId[]) =>169 unregister: (toolIds: readonly ToolId[]) =>
102 Effect.sync(() => {170 Effect.sync(() => {
103 for (const id of toolIds) {171 for (const id of toolIds) {
104 tools.delete(id);172 tools.delete(id);
173 runtimeTools.delete(id);
174 runtimeHandlers.delete(id);
105 }175 }
106 }),176 }),
107 177
@@ -112,6 +182,12 @@ export const makeInMemoryToolRegistry = () => { · 4 unmodified lines
112 tools.delete(id);182 tools.delete(id);
113 }183 }
114 }184 }
185 for (const [id, t] of runtimeTools) {
186 if (t.sourceId === sourceId) {
187 runtimeTools.delete(id);
188 runtimeHandlers.delete(id);
189 }
190 }
115 }),191 }),
116 };192 };
117};193};
packages/core/sdk/src/index.test.ts+41−1
@@ -4,11 +4,13 @@ import { Effect, Exit, Schema } from "effect"; · 3 unmodified lines
4import {4import {
5 createExecutor,5 createExecutor,
6 makeTestConfig,6 makeTestConfig,
7 makeInMemorySourceRegistry,
7 inMemoryToolsPlugin,8 inMemoryToolsPlugin,
8 tool,9 tool,
9 FormElicitation,10 FormElicitation,
10 UrlElicitation,11 UrlElicitation,
11 ElicitationResponse,12 ElicitationResponse,
13 Source,
12 type MemoryToolContext,14 type MemoryToolContext,
13 type ToolId,15 type ToolId,
14 type InvokeOptions,16 type InvokeOptions,
@@ -40,6 +42,42 @@ describe("SDK Executor", () => { · 25 unmodified lines
40 }),42 }),
41 );43 );
42 44
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
43 it.effect("memory plugin registers tools and they are discoverable", () =>81 it.effect("memory plugin registers tools and they are discoverable", () =>
44 Effect.gen(function* () {82 Effect.gen(function* () {
45 const executor = yield* createExecutor(83 const executor = yield* createExecutor(
@@ -355,7 +393,9 @@ describe("SDK Executor", () => { · 309 unmodified lines
355 );393 );
356 394
357 const error = yield* Effect.flip(395 const error = yield* Effect.flip(
358 executor.tools.invoke("auth.login", {}, autoApprove),396 executor.tools.invoke("auth.login", {}, {
397 onElicitation: undefined as never,
398 }),
359 );399 );
360 expect(error._tag).toBe("ElicitationDeclinedError");400 expect(error._tag).toBe("ElicitationDeclinedError");
361 }),401 }),
packages/core/sdk/src/index.ts+9−0
@@ -20,6 +20,7 @@ export { · 19 unmodified lines
20 ToolAnnotations,20 ToolAnnotations,
21 ToolListFilter,21 ToolListFilter,
22 type ToolInvoker,22 type ToolInvoker,
23 type RuntimeToolHandler,
23 type InvokeOptions,24 type InvokeOptions,
24} from "./tools";25} from "./tools";
25 26
@@ -81,6 +82,14 @@ export { · 55 unmodified lines
81// Schema ref utilities82// Schema ref utilities
82export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs";83export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs";
83 84
85// Runtime tools
86export {
87 registerRuntimeTools,
88 runtimeTool,
89 type RuntimeSourceDefinition,
90 type RuntimeToolDefinition,
91} from "./runtime-tools";
92
84// In-memory implementations93// In-memory implementations
85export { makeInMemoryToolRegistry } from "./in-memory/tool-registry";94export { makeInMemoryToolRegistry } from "./in-memory/tool-registry";
86export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store";95export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store";
packages/core/sdk/src/runtime-tools.ts+183−0
@@ -0,0 +1,183 @@
1import { Effect, JSONSchema, Schema } from "effect";
2
3import { ToolId } from "./ids";
4import { ToolInvocationError } from "./errors";
5import { Source } from "./sources";
6import {
7 ToolInvocationResult,
8 type ToolRegistration,
9 type RuntimeToolHandler,
10} from "./tools";
11import { hoistDefinitions } from "./schema-refs";
12
13export 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
23export interface RuntimeSourceDefinition {
24 readonly id: string;
25 readonly name: string;
26 readonly kind?: string;
27 readonly canRemove?: boolean;
28 readonly canRefresh?: boolean;
29}
30
31interface RuntimeHandlerEntry {
32 readonly decode: (args: unknown) => unknown;
33 readonly handler: (args: unknown) => Effect.Effect<unknown, unknown>;
34}
35
36export const runtimeTool = <TInput, TOutput>(
37 def: RuntimeToolDefinition<TInput, TOutput>,
38): RuntimeToolDefinition<TInput, TOutput> => def;
39
40const 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
76const 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
104export 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
11 name: Schema.String,11 name: Schema.String,
12 /** Plugin kind that manages this source (e.g. "openapi", "mcp") */12 /** Plugin kind that manages this source (e.g. "openapi", "mcp") */
13 kind: Schema.String,13 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),
14}) {}20}) {}
15 21
16// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
@@ -46,6 +52,12 @@ export class SourceRegistry extends Context.Tag( · 29 unmodified lines
46 /** Register a source manager (called by plugins during init) */52 /** Register a source manager (called by plugins during init) */
47 readonly addManager: (manager: SourceManager) => Effect.Effect<void>;53 readonly addManager: (manager: SourceManager) => Effect.Effect<void>;
48 54
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
49 /** List all sources across all plugins */61 /** List all sources across all plugins */
50 readonly list: () => Effect.Effect<readonly Source[]>;62 readonly list: () => Effect.Effect<readonly Source[]>;
51 63
@@ -63,6 +75,7 @@ export class SourceRegistry extends Context.Tag( · 11 unmodified lines
63 75
64export const makeInMemorySourceRegistry = () => {76export const makeInMemorySourceRegistry = () => {
65 const managers = new Map<string, SourceManager>();77 const managers = new Map<string, SourceManager>();
78 const runtimeSources = new Map<string, Source>();
66 79
67 return {80 return {
68 addManager: (manager: SourceManager) =>81 addManager: (manager: SourceManager) =>
@@ -70,9 +83,19 @@ export const makeInMemorySourceRegistry = () => { · 1 unmodified line
70 managers.set(manager.kind, manager);83 managers.set(manager.kind, manager);
71 }),84 }),
72 85
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
73 list: () =>96 list: () =>
74 Effect.gen(function* () {97 Effect.gen(function* () {
75 const all: Source[] = [];98 const all: Source[] = [...runtimeSources.values()];
76 for (const manager of managers.values()) {99 for (const manager of managers.values()) {
77 const sources = yield* manager.list();100 const sources = yield* manager.list();
78 all.push(...sources);101 all.push(...sources);
@@ -82,6 +105,14 @@ export const makeInMemorySourceRegistry = () => { · 3 unmodified lines
82 105
83 remove: (sourceId: string) =>106 remove: (sourceId: string) =>
84 Effect.gen(function* () {107 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
85 for (const manager of managers.values()) {116 for (const manager of managers.values()) {
86 const sources = yield* manager.list();117 const sources = yield* manager.list();
87 if (sources.some((s) => s.id === sourceId)) {118 if (sources.some((s) => s.id === sourceId)) {
@@ -93,6 +124,11 @@ export const makeInMemorySourceRegistry = () => { · 5 unmodified lines
93 124
94 refresh: (sourceId: string) =>125 refresh: (sourceId: string) =>
95 Effect.gen(function* () {126 Effect.gen(function* () {
127 const runtimeSource = runtimeSources.get(sourceId);
128 if (runtimeSource) {
129 return;
130 }
131
96 for (const manager of managers.values()) {132 for (const manager of managers.values()) {
97 const sources = yield* manager.list();133 const sources = yield* manager.list();
98 if (sources.some((s) => s.id === sourceId)) {134 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
98 defs: Record<string, unknown>,98 defs: Record<string, unknown>,
99 ) => Effect.Effect<void>;99 ) => Effect.Effect<void>;
100 100
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
101 /**114 /**
102 * Register a plugin invoker. Must be called before registering tools115 * Register a plugin invoker. Must be called before registering tools
103 * with the corresponding pluginKey.116 * with the corresponding pluginKey.
@@ -119,6 +132,25 @@ export class ToolRegistry extends Context.Tag("@executor/sdk/ToolRegistry")< · 15 unmodified lines
119 tools: readonly ToolRegistration[],132 tools: readonly ToolRegistration[],
120 ) => Effect.Effect<void>;133 ) => Effect.Effect<void>;
121 134
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
122 /** Unregister tools by id (used by plugins on cleanup) */154 /** Unregister tools by id (used by plugins on cleanup) */
123 readonly unregister: (155 readonly unregister: (
124 toolIds: readonly ToolId[],156 toolIds: readonly ToolId[],
@@ -151,6 +183,18 @@ export interface ToolInvoker { · 26 unmodified lines
151 ) => Effect.Effect<ToolAnnotations | undefined>;183 ) => Effect.Effect<ToolAnnotations | undefined>;
152}184}
153 185
186export 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
154// ---------------------------------------------------------------------------198// ---------------------------------------------------------------------------
155// ToolRegistration — pure data, no closures199// ToolRegistration — pure data, no closures
156// ---------------------------------------------------------------------------200// ---------------------------------------------------------------------------
packages/core/storage-file/src/index.test.ts+24−0
@@ -122,6 +122,30 @@ describe("KvToolRegistry", () => { · 121 unmodified lines
122 }),122 }),
123 ),123 ),
124 );124 );
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 );
125});149});
126 150
127// ---------------------------------------------------------------------------151// ---------------------------------------------------------------------------
packages/core/storage-file/src/tool-registry.ts+71−7
@@ -6,7 +6,12 @@ import { Effect, Schema } from "effect"; · 5 unmodified lines
6 6
7import type { ToolId, ScopedKv } from "@executor/sdk";7import type { ToolId, ScopedKv } from "@executor/sdk";
8import { ToolNotFoundError, ToolInvocationError, ToolRegistration } from "@executor/sdk";8import { ToolNotFoundError, ToolInvocationError, ToolRegistration } from "@executor/sdk";
9import type { ToolInvoker, ToolListFilter, InvokeOptions } from "@executor/sdk";9import type {
10 ToolInvoker,
11 ToolListFilter,
12 InvokeOptions,
13 RuntimeToolHandler,
14} from "@executor/sdk";
10import { reattachDefs } from "@executor/sdk";15import { reattachDefs } from "@executor/sdk";
11 16
12// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
@@ -25,9 +30,12 @@ export const makeKvToolRegistry = ( · 12 unmodified lines
25 toolsKv: ScopedKv,30 toolsKv: ScopedKv,
26 defsKv: ScopedKv,31 defsKv: ScopedKv,
27) => {32) => {
33 const runtimeTools = new Map<string, ToolRegistration>();
34 const runtimeHandlers = new Map<string, RuntimeToolHandler>();
35 const runtimeDefs = new Map<string, unknown>();
28 const invokers = new Map<string, ToolInvoker>();36 const invokers = new Map<string, ToolInvoker>();
29 37
30 const getTool = (id: string): Effect.Effect<ToolRegistration | null> =>38 const getPersistedTool = (id: string): Effect.Effect<ToolRegistration | null> =>
31 Effect.gen(function* () {39 Effect.gen(function* () {
32 const raw = yield* toolsKv.get(id);40 const raw = yield* toolsKv.get(id);
33 if (!raw) return null;41 if (!raw) return null;
@@ -43,13 +51,19 @@ export const makeKvToolRegistry = ( · 9 unmodified lines
43 const getDefsMap = (): Effect.Effect<Map<string, unknown>> =>51 const getDefsMap = (): Effect.Effect<Map<string, unknown>> =>
44 Effect.gen(function* () {52 Effect.gen(function* () {
45 const entries = yield* defsKv.list();53 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;
47 });57 });
48 58
49 return {59 return {
50 list: (filter?: ToolListFilter) =>60 list: (filter?: ToolListFilter) =>
51 Effect.gen(function* () {61 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()];
53 if (filter?.sourceId) {67 if (filter?.sourceId) {
54 const sid = filter.sourceId;68 const sid = filter.sourceId;
55 tools = tools.filter((t) => t.sourceId === sid);69 tools = tools.filter((t) => t.sourceId === sid);
@@ -73,7 +87,7 @@ export const makeKvToolRegistry = ( · 17 unmodified lines
73 87
74 schema: (toolId: ToolId) =>88 schema: (toolId: ToolId) =>
75 Effect.gen(function* () {89 Effect.gen(function* () {
76 const t = yield* getTool(toolId);90 const t = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId));
77 if (!t) return yield* new ToolNotFoundError({ toolId });91 if (!t) return yield* new ToolNotFoundError({ toolId });
78 const defs = yield* getDefsMap();92 const defs = yield* getDefsMap();
79 return {93 return {
@@ -96,13 +110,31 @@ export const makeKvToolRegistry = ( · 16 unmodified lines
96 }110 }
97 }),111 }),
98 112
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
99 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>127 registerInvoker: (pluginKey: string, invoker: ToolInvoker) =>
100 Effect.sync(() => { invokers.set(pluginKey, invoker); }),128 Effect.sync(() => { invokers.set(pluginKey, invoker); }),
101 129
102 resolveAnnotations: (toolId: ToolId) =>130 resolveAnnotations: (toolId: ToolId) =>
103 Effect.gen(function* () {131 Effect.gen(function* () {
104 const tool = yield* getTool(toolId);132 const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId));
105 if (!tool) return undefined;133 if (!tool) return undefined;
134 const runtimeHandler = runtimeHandlers.get(toolId);
135 if (runtimeHandler?.resolveAnnotations) {
136 return yield* runtimeHandler.resolveAnnotations();
137 }
106 const invoker = invokers.get(tool.pluginKey);138 const invoker = invokers.get(tool.pluginKey);
107 if (!invoker?.resolveAnnotations) return undefined;139 if (!invoker?.resolveAnnotations) return undefined;
108 return yield* invoker.resolveAnnotations(toolId);140 return yield* invoker.resolveAnnotations(toolId);
@@ -110,8 +142,12 @@ export const makeKvToolRegistry = ( · 1 unmodified line
110 142
111 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>143 invoke: (toolId: ToolId, args: unknown, options: InvokeOptions) =>
112 Effect.gen(function* () {144 Effect.gen(function* () {
113 const tool = yield* getTool(toolId);145 const tool = runtimeTools.get(toolId) ?? (yield* getPersistedTool(toolId));
114 if (!tool) return yield* new ToolNotFoundError({ toolId });146 if (!tool) return yield* new ToolNotFoundError({ toolId });
147 const runtimeHandler = runtimeHandlers.get(toolId);
148 if (runtimeHandler) {
149 return yield* runtimeHandler.invoke(args, options);
150 }
115 const invoker = invokers.get(tool.pluginKey);151 const invoker = invokers.get(tool.pluginKey);
116 if (!invoker) {152 if (!invoker) {
117 return yield* new ToolInvocationError({153 return yield* new ToolInvocationError({
@@ -130,9 +166,31 @@ export const makeKvToolRegistry = ( · 12 unmodified lines
130 }166 }
131 }),167 }),
132 168
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
133 unregister: (toolIds: readonly ToolId[]) =>189 unregister: (toolIds: readonly ToolId[]) =>
134 Effect.gen(function* () {190 Effect.gen(function* () {
135 for (const id of toolIds) {191 for (const id of toolIds) {
192 runtimeTools.delete(id);
193 runtimeHandlers.delete(id);
136 yield* toolsKv.delete(id);194 yield* toolsKv.delete(id);
137 }195 }
138 }),196 }),
@@ -145,6 +203,12 @@ export const makeKvToolRegistry = ( · 6 unmodified lines
145 yield* toolsKv.delete(t.id);203 yield* toolsKv.delete(t.id);
146 }204 }
147 }205 }
206 for (const [id, t] of runtimeTools) {
207 if (t.sourceId === sourceId) {
208 runtimeTools.delete(id);
209 runtimeHandlers.delete(id);
210 }
211 }
148 }),212 }),
149 };213 };
150};214};
packages/plugins/openapi/src/sdk/plugin.test.ts+164−2
@@ -11,7 +11,17 @@ import { · 10 unmodified lines
11} from "@effect/platform";11} from "@effect/platform";
12import { NodeHttpServer } from "@effect/platform-node";12import { NodeHttpServer } from "@effect/platform-node";
13 13
14import { createExecutor, makeTestConfig, SecretId, type InvokeOptions } from "@executor/sdk";14import {
15 createExecutor,
16 makeInMemoryPolicyEngine,
17 makeInMemorySecretStore,
18 makeInMemorySourceRegistry,
19 makeInMemoryToolRegistry,
20 makeTestConfig,
21 ScopeId,
22 SecretId,
23 type InvokeOptions,
24} from "@executor/sdk";
15import { openApiPlugin } from "./plugin";25import { openApiPlugin } from "./plugin";
16 26
17const autoApprove: InvokeOptions = { onElicitation: "accept-all" };27const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
@@ -118,6 +128,154 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 100 unmodified lines
118 }),128 }),
119 );129 );
120 130
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
121 it.effect("resolves secret-backed headers at invocation time", () =>279 it.effect("resolves secret-backed headers at invocation time", () =>
122 Effect.gen(function* () {280 Effect.gen(function* () {
123 const httpClient = yield* HttpClient.HttpClient;281 const httpClient = yield* HttpClient.HttpClient;
@@ -299,7 +457,11 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 175 unmodified lines
299 457
300 yield* executor.openapi.removeSpec("removable");458 yield* executor.openapi.removeSpec("removable");
301 459
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 ]);
303 }),465 }),
304 );466 );
305});467});
packages/plugins/openapi/src/sdk/plugin.ts+134−83
@@ -1,10 +1,12 @@
1import { Effect, Option } from "effect";1import { Effect, Option, Schema } from "effect";
2import { FetchHttpClient, HttpClient } from "@effect/platform";2import { FetchHttpClient, HttpClient } from "@effect/platform";
3import type { Layer } from "effect";3import type { Layer } from "effect";
4 4
5import {5import {
6 Source,6 Source,
7 definePlugin,7 definePlugin,
8 registerRuntimeTools,
9 runtimeTool,
8 type ExecutorPlugin,10 type ExecutorPlugin,
9 type PluginContext,11 type PluginContext,
10 ToolId,12 ToolId,
@@ -18,19 +20,20 @@ import { makeOpenApiInvoker } from "./invoke"; · 7 unmodified lines
18import { resolveBaseUrl } from "./openapi-utils";20import { resolveBaseUrl } from "./openapi-utils";
19import type { OpenApiOperationStore } from "./operation-store";21import type { OpenApiOperationStore } from "./operation-store";
20import { makeInMemoryOperationStore } from "./kv-operation-store";22import { makeInMemoryOperationStore } from "./kv-operation-store";
21import { previewSpec, type SpecPreview } from "./preview";23import { previewSpec, SpecPreview } from "./preview";
22import {24import {
25 HeaderValue as HeaderValueSchema,
23 InvocationConfig,26 InvocationConfig,
24 OperationBinding,27 OperationBinding,
28 type HeaderValue as HeaderValueValue,
25} from "./types";29} from "./types";
26 30
27
28// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
29// Plugin config32// Plugin config
30// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
31 34
32/** A header value — either a static string or a reference to a secret */35/** A header value — either a static string or a reference to a secret */
33export type HeaderValue = string | { readonly secretId: string; readonly prefix?: string };36export type HeaderValue = HeaderValueValue;
34 37
35export interface OpenApiSpecConfig {38export interface OpenApiSpecConfig {
36 readonly spec: string;39 readonly spec: string;
@@ -46,9 +49,7 @@ export interface OpenApiSpecConfig { · 9 unmodified lines
46 49
47export interface OpenApiPluginExtension {50export interface OpenApiPluginExtension {
48 /** Preview a spec without registering — returns metadata, auth strategies, header presets */51 /** Preview a spec without registering — returns metadata, auth strategies, header presets */
49 readonly previewSpec: (52 readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>;
50 specText: string,
51 ) => Effect.Effect<SpecPreview, Error>;
52 53
53 /** Add an OpenAPI spec and register its operations as tools */54 /** Add an OpenAPI spec and register its operations as tools */
54 readonly addSpec: (55 readonly addSpec: (
@@ -63,14 +64,35 @@ export interface OpenApiPluginExtension { · 8 unmodified lines
63// Helpers64// Helpers
64// ---------------------------------------------------------------------------65// ---------------------------------------------------------------------------
65 66
67const PreviewSpecInputSchema = Schema.Struct({
68 spec: Schema.String,
69});
70type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
71
72const 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});
80type AddSourceInput = typeof AddSourceInputSchema.Type;
81
82const AddSourceOutputSchema = Schema.Struct({
83 sourceId: Schema.String,
84 toolCount: Schema.Number,
85});
86
66const toRegistration = (87const toRegistration = (
67 def: ToolDefinition,88 def: ToolDefinition,
68 namespace: string,89 namespace: string,
69): ToolRegistration => {90): ToolRegistration => {
70 const op = def.operation;91 const op = def.operation;
71 const description = Option.getOrElse(op.description, () =>92 const description = Option.getOrElse(op.description, () =>
72 Option.getOrElse(op.summary, () =>93 Option.getOrElse(
73 `${op.method.toUpperCase()} ${op.pathTemplate}`,94 op.summary,
95 () => `${op.method.toUpperCase()} ${op.pathTemplate}`,
74 ),96 ),
75 );97 );
76 return {98 return {
@@ -101,10 +123,8 @@ export const openApiPlugin = (options?: { · 24 unmodified lines
101 readonly operationStore?: OpenApiOperationStore;123 readonly operationStore?: OpenApiOperationStore;
102}): ExecutorPlugin<"openapi", OpenApiPluginExtension> => {124}): ExecutorPlugin<"openapi", OpenApiPluginExtension> => {
103 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;125 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
104 const operationStore = options?.operationStore ?? makeInMemoryOperationStore();126 const operationStore =
105 127 options?.operationStore ?? makeInMemoryOperationStore();
106 // Track added sources so we can list them
107 const addedSources = new Map<string, Source>();
108 128
109 return definePlugin({129 return definePlugin({
110 key: "openapi",130 key: "openapi",
@@ -120,111 +140,142 @@ export const openApiPlugin = (options?: { · 9 unmodified lines
120 }),140 }),
121 );141 );
122 142
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
133 // Tools are already persisted in the KV tool registry — no need to143 // Tools are already persisted in the KV tool registry — no need to
134 // re-register them. We only need the source list and the invoker.144 // re-register them. We only need the source list and the invoker.
135
136 // Register source manager so the core can list/remove/refresh our sources145 // Register source manager so the core can list/remove/refresh our sources
137 yield* ctx.sources.addManager({146 yield* ctx.sources.addManager({
138 kind: "openapi",147 kind: "openapi",
139 148
140 list: () =>149 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 ),
142 165
143 remove: (sourceId: string) =>166 remove: (sourceId: string) =>
144 Effect.gen(function* () {167 Effect.gen(function* () {
145 yield* operationStore.removeByNamespace(sourceId);168 yield* operationStore.removeByNamespace(sourceId);
146 yield* operationStore.removeSourceMeta(sourceId);169 yield* operationStore.removeSourceMeta(sourceId);
147 yield* ctx.tools.unregisterBySource(sourceId);170 yield* ctx.tools.unregisterBySource(sourceId);
148 addedSources.delete(sourceId);
149 }),171 }),
150 172
151 // TODO: refresh requires storing original config per namespace173 // TODO: refresh requires storing original config per namespace
152 });174 });
153 175
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
154 return {256 return {
155 extension: {257 extension: {
156 previewSpec: (specText: string) => previewSpec(specText),258 previewSpec: (specText: string) => previewSpec(specText),
157 259
158 addSpec: (config: OpenApiSpecConfig) =>260 addSpec: (config: OpenApiSpecConfig) =>
159 Effect.gen(function* () {261 addSpecInternal(config).pipe(
160 const doc = yield* parse(config.spec);262 Effect.map(({ toolCount }) => ({ toolCount })),
161 const result = yield* extract(doc);263 ),
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 }),
210 264
211 removeSpec: (namespace: string) =>265 removeSpec: (namespace: string) =>
212 Effect.gen(function* () {266 Effect.gen(function* () {
213 const toolIds = yield* operationStore.removeByNamespace(namespace);267 const toolIds =
268 yield* operationStore.removeByNamespace(namespace);
214 if (toolIds.length > 0) {269 if (toolIds.length > 0) {
215 yield* ctx.tools.unregister(toolIds);270 yield* ctx.tools.unregister(toolIds);
216 }271 }
217 yield* operationStore.removeSourceMeta(namespace);272 yield* operationStore.removeSourceMeta(namespace);
218 addedSources.delete(namespace);
219 }),273 }),
220 },274 },
221 275
222 close: () =>276 close: () =>
223 Effect.gen(function* () {277 Effect.gen(function* () {
224 for (const sourceId of addedSources.keys()) {278 yield* runtimeTools.close();
225 yield* ctx.tools.unregisterBySource(sourceId);
226 }
227 addedSources.clear();
228 }),279 }),
229 };280 };
230 }),281 }),
packages/plugins/openapi/src/sdk/real-specs.test.ts+10−3
@@ -114,9 +114,12 @@ describe("Real specs: Cloudflare API", () => { · 113 unmodified lines
114 expect(result.toolCount).toBeGreaterThan(1000);114 expect(result.toolCount).toBeGreaterThan(1000);
115 115
116 const tools = yield* executor.tools.list();116 const tools = yield* executor.tools.list();
117 expect(tools.length).toBe(result.toolCount);117 expect(tools.length).toBe(result.toolCount + 2);
118 118
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) {
120 expect(tool.pluginKey).toBe("openapi");123 expect(tool.pluginKey).toBe("openapi");
121 expect(tool.sourceId).toBe("cloudflare");124 expect(tool.sourceId).toBe("cloudflare");
122 }125 }
@@ -212,7 +215,11 @@ describe("Real specs: Cloudflare API", () => { · 89 unmodified lines
212 215
213 yield* executor.openapi.removeSpec("cloudflare");216 yield* executor.openapi.removeSpec("cloudflare");
214 217
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 ]);
216 }),223 }),
217 );224 );
218});225});