History

feat: wire source-backed tool registry into PM and Convex

Rhys Sullivan committed 8 months ago

Commit fe2d7fc1 parent 16bd61617 files · +1078 −138Browse files

Unified · Split

17 files
  1. v2/apps/convex/convex/control_plane/errors.ts +2 −1
  2. v2/apps/convex/convex/executor.test.ts +1 −0
  3. v2/apps/convex/convex/executor.ts +18 −4
  4. v2/apps/convex/convex/mcp.ts +15 −1
  5. v2/apps/convex/convex/runtimeCallbacks.ts +50 −19
  6. v2/apps/convex/convex/runtime_execution_port.ts +52 −46
  7. v2/apps/convex/convex/source_tool_registry.ts +186 −0
  8. v2/apps/pm/src/approvals-service.ts +2 −1
  9. v2/apps/pm/src/main.ts +86 −12
  10. v2/apps/pm/src/runtime-execution-port.ts +6 −43
  11. v2/packages/engine/src/index.ts +2 −0
  12. v2/packages/engine/src/source-tool-registry.test.ts +270 −0
  13. v2/packages/engine/src/source-tool-registry.ts +365 −0
  14. v2/packages/state-contracts/src/source-store.ts +2 −1
  15. v2/packages/state-contracts/src/tool-artifacts.ts +2 −1
  16. v2/packages/state-local-file/src/local-state-store.ts +2 −1
  17. v2/packages/state-local-file/src/persistence-error.ts +17 −8
v2/apps/convex/convex/control_plane/errors.ts+2−1
@@ -25,7 +25,8 @@ export const toSourceStoreError = ( · 24 unmodified lines
2525 ): SourceStoreError =>
2626 new SourceStoreError({
2727 operation,
28− filePath: "convex://control-plane",
28+ backend: "convex",
29+ location: "control-plane",
2930 message: "Convex control plane call failed",
3031 reason: null,
3132 details: renderCause(cause),
v2/apps/convex/convex/executor.test.ts+1−0
@@ -12,6 +12,7 @@ const setup = () => · 11 unmodified lines
1212 "./mcp.ts": () => import("./mcp"),
1313 "./executor.ts": () => import("./executor"),
1414 "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"),
15+ "./source_tool_registry.ts": () => import("./source_tool_registry"),
1516 "./controlPlane.ts": () => import("./controlPlane"),
1617 "./_generated/api.js": () => import("./_generated/api.js"),
1718 });
v2/apps/convex/convex/executor.ts+18−4
@@ -1,11 +1,25 @@
1−import { createRunExecutor } from "@executor-v2/engine";
1+import {
2+ createRunExecutor,
3+ type ToolRegistry,
4+} from "@executor-v2/engine";
25 import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
36 import * as Effect from "effect/Effect";
47
5−import { executeRuntimeRunInConvex } from "./runtime_execution_port";
8+import { createExecuteRuntimeRunInConvex } from "./runtime_execution_port";
69
7−const runExecutor = createRunExecutor(executeRuntimeRunInConvex);
10+export type ExecuteRunImplOptions = {
11+ toolRegistry?: ToolRegistry;
12+};
813
914 export const executeRunImpl = (
1015 input: ExecuteRunInput,
11−): Effect.Effect<ExecuteRunResult> => runExecutor.executeRun(input);
16+ options: ExecuteRunImplOptions = {},
17+): Effect.Effect<ExecuteRunResult> => {
18+ const runExecutor = createRunExecutor(
19+ createExecuteRuntimeRunInConvex({
20+ toolRegistry: options.toolRegistry,
21+ }),
22+ );
23+
24+ return runExecutor.executeRun(input);
25+};
v2/apps/convex/convex/mcp.ts+15−1
@@ -4,10 +4,24 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
44
55 import { httpAction } from "./_generated/server";
66 import { executeRunImpl } from "./executor";
7+import { createConvexSourceToolRegistry } from "./source_tool_registry";
8+
9+const readConfiguredWorkspaceId = (value: string | undefined): string => {
10+ const normalized = value?.trim();
11+ return normalized && normalized.length > 0 ? normalized : "ws_local";
12+};
13+
14+const workspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
715
816 export const mcpHandler = httpAction(async (ctx, request) => {
17+ const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);
18+
919 const runClient = createExecutorRunClient((input) =>
10− Effect.runPromise(executeRunImpl(input)),
20+ Effect.runPromise(
21+ executeRunImpl(input, {
22+ toolRegistry,
23+ }),
24+ ),
1125 );
1226
1327 return handleMcpHttpRequest(request, {
v2/apps/convex/convex/runtimeCallbacks.ts+50−19
@@ -1,15 +1,18 @@
11 import {
2− createRuntimeToolCallHandler,
3− createUnimplementedRuntimeToolInvoker,
2+ RuntimeAdapterError,
3+ createRuntimeToolCallService,
44 } from "@executor-v2/engine";
5−import type { RuntimeToolCallResult } from "@executor-v2/sdk";
5+import type {
6+ RuntimeToolCallRequest,
7+ RuntimeToolCallResult,
8+} from "@executor-v2/sdk";
69 import * as Data from "effect/Data";
710 import * as Effect from "effect/Effect";
811 import * as ParseResult from "effect/ParseResult";
912 import * as Schema from "effect/Schema";
1013
1114 import { httpAction, type ActionCtx } from "./_generated/server";
12−import { createConvexResolveToolCredentials } from "./credential_resolver";
15+import { createConvexSourceToolRegistry } from "./source_tool_registry";
1316
1417 class RuntimeToolCallBadRequestError extends Data.TaggedError(
1518 "RuntimeToolCallBadRequestError",
@@ -40,6 +43,13 @@ const RuntimeToolCallRequestSchema = Schema.Struct({ · 24 unmodified lines
4043
4144 const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
4245
46+const readConfiguredWorkspaceId = (value: string | undefined): string => {
47+ const normalized = value?.trim();
48+ return normalized && normalized.length > 0 ? normalized : "ws_local";
49+};
50+
51+const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
52+
4353 const badRequest = (message: string): Response =>
4454 Response.json(
4555 {
@@ -53,20 +63,13 @@ const badRequest = (message: string): Response => · 7 unmodified lines
5363 const formatBadRequestMessage = (error: RuntimeToolCallBadRequestError): string =>
5464 error.details.length > 0 ? `${error.message}: ${error.details}` : error.message;
5565
56−const formatUnknownDetails = (cause: unknown): string => String(cause);
66+const formatRuntimeAdapterError = (error: RuntimeAdapterError): string =>
67+ error.details ? `${error.message}: ${error.details}` : error.message;
5768
58−const handleToolCallHttpEffect = (
59− ctx: ActionCtx,
60− request: Request,
61−): Effect.Effect<Response, never> => {
62− const resolveCredentials = createConvexResolveToolCredentials(ctx);
63− const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("convex");
64− const handleToolCall = createRuntimeToolCallHandler({
65− resolveCredentials,
66− invokeRuntimeTool,
67− });
69+const formatUnknownDetails = (cause: unknown): string => String(cause);
6870
69− return Effect.gen(function* () {
71+const decodeToolCallRequest = (request: Request): Effect.Effect<RuntimeToolCallRequest, RuntimeToolCallBadRequestError> =>
72+ Effect.gen(function* () {
7073 const body = yield* Effect.tryPromise({
7174 try: () => request.json(),
7275 catch: (cause) =>
@@ -76,7 +79,7 @@ const handleToolCallHttpEffect = ( · 3 unmodified lines
7679 }),
7780 });
7881
79− const input = yield* decodeRuntimeToolCallRequest(body).pipe(
82+ return yield* decodeRuntimeToolCallRequest(body).pipe(
8083 Effect.mapError(
8184 (cause) =>
8285 new RuntimeToolCallBadRequestError({
@@ -85,15 +88,43 @@ const handleToolCallHttpEffect = ( · 2 unmodified lines
8588 }),
8689 ),
8790 );
91+ });
92+
93+const handleToolCallHttpEffect = (
94+ ctx: ActionCtx,
95+ request: Request,
96+): Effect.Effect<Response, never> =>
97+ Effect.gen(function* () {
98+ const input = yield* decodeToolCallRequest(request);
99+
100+ const workspaceId =
101+ input.credentialContext?.workspaceId?.trim() || fallbackWorkspaceId;
102+
103+ const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId);
104+ const runtimeToolCallService = createRuntimeToolCallService(toolRegistry);
105+
106+ const result = yield* runtimeToolCallService.callTool(input).pipe(
107+ Effect.map(
108+ (value): RuntimeToolCallResult => ({
109+ ok: true,
110+ value,
111+ }),
112+ ),
113+ Effect.catchTag("RuntimeAdapterError", (error) =>
114+ Effect.succeed<RuntimeToolCallResult>({
115+ ok: false,
116+ kind: "failed",
117+ error: formatRuntimeAdapterError(error),
118+ }),
119+ ),
120+ );
88121
89− const result = yield* handleToolCall(input);
90122 return Response.json(result, { status: 200 });
91123 }).pipe(
92124 Effect.catchTag("RuntimeToolCallBadRequestError", (error) =>
93125 Effect.succeed(badRequest(formatBadRequestMessage(error))),
94126 ),
95127 );
96−};
97128
98129 export const handleToolCallHttp = httpAction((ctx, request) =>
99130 Effect.runPromise(handleToolCallHttpEffect(ctx, request)),
v2/apps/convex/convex/runtime_execution_port.ts+52−46
@@ -1,8 +1,10 @@
11 import {
2− RuntimeAdapterError,
32 RuntimeExecutionPortError,
3+ createRuntimeToolCallService,
4+ createStaticToolRegistry,
45 type ExecuteRuntimeRun,
56 type ExecuteRuntimeRunInput,
7+ type ToolRegistry,
68 makeRuntimeAdapterRegistry,
79 type RuntimeExecuteError,
810 } from "@executor-v2/engine";
@@ -13,56 +15,60 @@ const convexRuntimeAdapter = makeLocalInProcessRuntimeAdapter(); · 4 unmodified lines
1315
1416 const runtimeAdapters = makeRuntimeAdapterRegistry([convexRuntimeAdapter]);
1517
16−const missingToolCallService = (runId: string, toolPath: string): RuntimeAdapterError =>
17− new RuntimeAdapterError({
18− operation: "call_tool",
19− runtimeKind: convexRuntimeAdapter.kind,
20− message: `No runtime tool-call service configured in convex for ${toolPath}`,
21− details: `runId=${runId}`,
22− });
18+export type ConvexRuntimeExecutionPortOptions = {
19+ toolRegistry?: ToolRegistry;
20+};
2321
24−export const executeRuntimeRunInConvex: ExecuteRuntimeRun = (
25− input: ExecuteRuntimeRunInput,
26−) =>
27− Effect.gen(function* () {
28− const runtimeAdapter = yield* runtimeAdapters.get(convexRuntimeAdapter.kind).pipe(
29− Effect.mapError(
30− (error) =>
31− new RuntimeExecutionPortError({
32− operation: "resolve_runtime_adapter",
33− message: error.message,
34− details: null,
35− }),
36− ),
37− );
22+export const createExecuteRuntimeRunInConvex = (
23+ options: ConvexRuntimeExecutionPortOptions = {},
24+): ExecuteRuntimeRun => {
25+ const toolRegistry =
26+ options.toolRegistry ??
27+ createStaticToolRegistry({
28+ tools: {},
29+ });
30+ const toolCallService = createRuntimeToolCallService(toolRegistry);
3831
39− const isAvailable = yield* runtimeAdapter.isAvailable();
40− if (!isAvailable) {
41− return yield* new RuntimeExecutionPortError({
42− operation: "runtime_available",
43− message: `Runtime '${convexRuntimeAdapter.kind}' is not available in this convex process.`,
44− details: null,
45− });
46− }
47−
48− return yield* runtimeAdapter
49− .execute({
50− runId: input.runId,
51− code: input.code,
52− timeoutMs: input.timeoutMs,
53− toolCallService: {
54− callTool: (toolInput) =>
55− Effect.fail(missingToolCallService(toolInput.runId, toolInput.toolPath)),
56− },
57− })
58− .pipe(
32+ return (input: ExecuteRuntimeRunInput) =>
33+ Effect.gen(function* () {
34+ const runtimeAdapter = yield* runtimeAdapters.get(convexRuntimeAdapter.kind).pipe(
5935 Effect.mapError(
60− (error: RuntimeExecuteError) =>
36+ (error) =>
6137 new RuntimeExecutionPortError({
62− operation: "runtime_execute",
38+ operation: "resolve_runtime_adapter",
6339 message: error.message,
64− details: error.details,
40+ details: null,
6541 }),
6642 ),
6743 );
68− });
44+
45+ const isAvailable = yield* runtimeAdapter.isAvailable();
46+ if (!isAvailable) {
47+ return yield* new RuntimeExecutionPortError({
48+ operation: "runtime_available",
49+ message: `Runtime '${convexRuntimeAdapter.kind}' is not available in this convex process.`,
50+ details: null,
51+ });
52+ }
53+
54+ return yield* runtimeAdapter
55+ .execute({
56+ runId: input.runId,
57+ code: input.code,
58+ timeoutMs: input.timeoutMs,
59+ toolCallService,
60+ })
61+ .pipe(
62+ Effect.mapError(
63+ (error: RuntimeExecuteError) =>
64+ new RuntimeExecutionPortError({
65+ operation: "runtime_execute",
66+ message: error.message,
67+ details: error.details,
68+ }),
69+ ),
70+ );
71+ });
72+};
73+
74+export const executeRuntimeRunInConvex = createExecuteRuntimeRunInConvex();
v2/apps/convex/convex/source_tool_registry.ts+186−0
@@ -0,0 +1,186 @@
1+import {
2+ createSourceToolRegistry,
3+ makeOpenApiToolProvider,
4+ makeToolProviderRegistry,
5+} from "@executor-v2/engine";
6+import {
7+ SourceStoreError,
8+ ToolArtifactStoreError,
9+ type SourceStore,
10+ type ToolArtifactStore,
11+} from "@executor-v2/persistence-ports";
12+import {
13+ SourceSchema,
14+ ToolArtifactSchema,
15+ type Source,
16+ type SourceId,
17+ type ToolArtifact,
18+ type WorkspaceId,
19+} from "@executor-v2/schema";
20+import { v } from "convex/values";
21+import * as Effect from "effect/Effect";
22+import * as Option from "effect/Option";
23+import * as Schema from "effect/Schema";
24+
25+import { api } from "./_generated/api";
26+import { query, type ActionCtx } from "./_generated/server";
27+
28+const runtimeApi = api as any;
29+
30+const decodeSource = Schema.decodeUnknownSync(SourceSchema);
31+const decodeToolArtifact = Schema.decodeUnknownSync(ToolArtifactSchema);
32+
33+const stripConvexSystemFields = (
34+ value: Record<string, unknown>,
35+): Record<string, unknown> => {
36+ const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
37+ return rest;
38+};
39+
40+const unsupportedSourceStoreMutation = (
41+ operation: "upsert" | "removeById",
42+): SourceStoreError =>
43+ new SourceStoreError({
44+ operation,
45+ backend: "convex",
46+ location: "source-tool-registry",
47+ message: `SourceStore.${operation} is not supported in source tool registry runtime`,
48+ reason: "unsupported_operation",
49+ details: null,
50+ });
51+
52+const unsupportedToolArtifactMutation = (): ToolArtifactStoreError =>
53+ new ToolArtifactStoreError({
54+ operation: "upsert",
55+ backend: "convex",
56+ location: "source-tool-registry",
57+ message: "ToolArtifactStore.upsert is not supported in source tool registry runtime",
58+ reason: "unsupported_operation",
59+ details: null,
60+ });
61+
62+const sourceStoreQueryError = (
63+ operation: string,
64+ cause: unknown,
65+): SourceStoreError =>
66+ new SourceStoreError({
67+ operation,
68+ backend: "convex",
69+ location: "source-tool-registry",
70+ message: "SourceStore query failed",
71+ reason: "convex_query_error",
72+ details: String(cause),
73+ });
74+
75+const toolArtifactStoreQueryError = (
76+ operation: string,
77+ cause: unknown,
78+): ToolArtifactStoreError =>
79+ new ToolArtifactStoreError({
80+ operation,
81+ backend: "convex",
82+ location: "source-tool-registry",
83+ message: "ToolArtifactStore query failed",
84+ reason: "convex_query_error",
85+ details: String(cause),
86+ });
87+
88+export const listSourcesForWorkspace = query({
89+ args: {
90+ workspaceId: v.string(),
91+ },
92+ handler: async (ctx, args): Promise<Array<Source>> => {
93+ const rows = await ctx.db
94+ .query("sources")
95+ .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
96+ .collect();
97+
98+ return rows.map((row) =>
99+ decodeSource(stripConvexSystemFields(row as unknown as Record<string, unknown>)),
100+ );
101+ },
102+});
103+
104+export const getToolArtifactBySource = query({
105+ args: {
106+ workspaceId: v.string(),
107+ sourceId: v.string(),
108+ },
109+ handler: async (ctx, args): Promise<ToolArtifact | null> => {
110+ const rows = await ctx.db
111+ .query("toolArtifacts")
112+ .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId))
113+ .collect();
114+
115+ const record = rows.find((row) => row.workspaceId === args.workspaceId) ?? null;
116+ if (!record) {
117+ return null;
118+ }
119+
120+ return decodeToolArtifact(
121+ stripConvexSystemFields(record as unknown as Record<string, unknown>),
122+ );
123+ },
124+});
125+
126+const createConvexSourceStore = (ctx: ActionCtx): SourceStore => ({
127+ getById: (workspaceId: WorkspaceId, sourceId: SourceId) =>
128+ Effect.tryPromise({
129+ try: () =>
130+ ctx
131+ .runQuery(runtimeApi.source_tool_registry.listSourcesForWorkspace, {
132+ workspaceId,
133+ })
134+ .then((sources: Array<Source>) =>
135+ Option.fromNullable(
136+ sources.find((source: Source) => source.id === sourceId) ?? null,
137+ ),
138+ ),
139+ catch: (cause) => sourceStoreQueryError("getById", cause),
140+ }),
141+
142+ listByWorkspace: (workspaceId: WorkspaceId) =>
143+ Effect.tryPromise({
144+ try: () =>
145+ ctx.runQuery(runtimeApi.source_tool_registry.listSourcesForWorkspace, {
146+ workspaceId,
147+ }),
148+ catch: (cause) => sourceStoreQueryError("listByWorkspace", cause),
149+ }),
150+
151+ upsert: () => Effect.fail(unsupportedSourceStoreMutation("upsert")),
152+
153+ removeById: () => Effect.fail(unsupportedSourceStoreMutation("removeById")),
154+});
155+
156+const createConvexToolArtifactStore = (ctx: ActionCtx): ToolArtifactStore => ({
157+ getBySource: (workspaceId: WorkspaceId, sourceId: SourceId) =>
158+ Effect.tryPromise({
159+ try: () =>
160+ ctx
161+ .runQuery(runtimeApi.source_tool_registry.getToolArtifactBySource, {
162+ workspaceId,
163+ sourceId,
164+ })
165+ .then((artifact) => Option.fromNullable(artifact)),
166+ catch: (cause) => toolArtifactStoreQueryError("getBySource", cause),
167+ }),
168+
169+ upsert: () => Effect.fail(unsupportedToolArtifactMutation()),
170+});
171+
172+export const createConvexSourceToolRegistry = (
173+ ctx: ActionCtx,
174+ workspaceId: string,
175+) => {
176+ const sourceStore = createConvexSourceStore(ctx);
177+ const toolArtifactStore = createConvexToolArtifactStore(ctx);
178+ const toolProviderRegistry = makeToolProviderRegistry([makeOpenApiToolProvider()]);
179+
180+ return createSourceToolRegistry({
181+ workspaceId,
182+ sourceStore,
183+ toolArtifactStore,
184+ toolProviderRegistry,
185+ });
186+};
v2/apps/pm/src/approvals-service.ts+2−1
@@ -19,7 +19,8 @@ const toSourceStoreError = ( · 18 unmodified lines
1919 ): SourceStoreError =>
2020 new SourceStoreError({
2121 operation,
22− filePath: "local://snapshot.json",
22+ backend: "local-file",
23+ location: "snapshot.json",
2324 message,
2425 reason: null,
2526 details,
v2/apps/pm/src/main.ts+86−12
@@ -5,17 +5,23 @@ import { · 4 unmodified lines
55 makeControlPlaneSourcesService,
66 makeControlPlaneWebHandler,
77 makeSourceCatalogService,
8+ makeSourceManagerService,
89 } from "@executor-v2/management-api";
910 import {
11+ RuntimeAdapterError,
1012 createRunExecutor,
11− createRuntimeToolCallHandler,
12− createUnimplementedRuntimeToolInvoker,
13+ createRuntimeToolCallService,
14+ createSourceToolRegistry,
15+ makeOpenApiToolProvider,
1316 makeRuntimeAdapterRegistry,
17+ makeToolProviderRegistry,
1418 } from "@executor-v2/engine";
1519 import {
1620 makeLocalSourceStore,
1721 makeLocalStateStore,
22+ makeLocalToolArtifactStore,
1823 } from "@executor-v2/persistence-local";
24+import { type RuntimeToolCallResult } from "@executor-v2/sdk";
1925 import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";
2026 import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess";
2127 import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";
@@ -24,7 +30,6 @@ import * as Layer from "effect/Layer"; · 2 unmodified lines
2430
2531 import { PmActorLive } from "./actor";
2632 import { createPmApprovalsService } from "./approvals-service";
27−import { createPmResolveToolCredentials } from "./credential-resolver";
2833 import { startPmHttpServer } from "./http-server";
2934 import { createPmMcpHandler } from "./mcp-handler";
3035 import { createPmExecuteRuntimeRun } from "./runtime-execution-port";
@@ -46,7 +51,16 @@ const readConfiguredRuntimeKind = (value: string | undefined): string | undefine · 15 unmodified lines
4651 return normalized.length > 0 ? normalized : undefined;
4752 };
4853
54+const readConfiguredWorkspaceId = (value: string | undefined): string => {
55+ const normalized = value?.trim();
56+ return normalized && normalized.length > 0 ? normalized : "ws_local";
57+};
58+
59+const formatRuntimeAdapterError = (error: RuntimeAdapterError): string =>
60+ error.details ? `${error.message}: ${error.details}` : error.message;
61+
4962 const port = parsePort(process.env.PORT);
63+const workspaceId = readConfiguredWorkspaceId(process.env.PM_WORKSPACE_ID);
5064
5165 const pmRuntimeAdapters = [
5266 makeLocalInProcessRuntimeAdapter(),
@@ -55,7 +69,6 @@ const pmRuntimeAdapters = [ · 2 unmodified lines
5569 ];
5670
5771 const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters);
58−
5972 const defaultRuntimeKind =
6073 readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind;
6174
@@ -71,10 +84,55 @@ const localStateStore = await Effect.runPromise( · 9 unmodified lines
7184 }).pipe(Effect.provide(BunContext.layer)),
7285 );
7386
87+const toolArtifactStore = await Effect.runPromise(
88+ makeLocalToolArtifactStore({
89+ rootDir: pmStateRootDir,
90+ }).pipe(Effect.provide(BunContext.layer)),
91+);
92+
7493 const sourceCatalog = makeSourceCatalogService(sourceStore);
94+const sourceManager = makeSourceManagerService(toolArtifactStore);
95+const baseSourcesService = makeControlPlaneSourcesService(sourceCatalog);
96+const sourcesService = {
97+ ...baseSourcesService,
98+ upsertSource: (input: Parameters<typeof baseSourcesService.upsertSource>[0]) =>
99+ Effect.gen(function* () {
100+ const source = yield* baseSourcesService.upsertSource(input);
101+
102+ if (source.kind !== "openapi") {
103+ return source;
104+ }
105+
106+ const openApiSpecResult = yield* Effect.tryPromise({
107+ try: async () => {
108+ const response = await fetch(source.endpoint);
109+ if (!response.ok) {
110+ throw new Error(`Failed fetching OpenAPI spec (${response.status})`);
111+ }
112+
113+ return await response.json();
114+ },
115+ catch: (cause) => String(cause),
116+ }).pipe(Effect.either);
117+
118+ if (openApiSpecResult._tag === "Left") {
119+ return source;
120+ }
121+
122+ yield* sourceManager
123+ .refreshOpenApiArtifact({
124+ source,
125+ openApiSpec: openApiSpecResult.right,
126+ })
127+ .pipe(Effect.ignore);
128+
129+ return source;
130+ }),
131+};
132+
75133 const approvalsService = createPmApprovalsService(localStateStore);
76134 const controlPlaneService = makeControlPlaneService({
77− sources: makeControlPlaneSourcesService(sourceCatalog),
135+ sources: sourcesService,
78136 approvals: approvalsService,
79137 });
80138
@@ -83,24 +141,40 @@ const controlPlaneWebHandler = makeControlPlaneWebHandler( · 2 unmodified lines
83141 PmActorLive(localStateStore),
84142 );
85143
86−const resolveCredentials = createPmResolveToolCredentials(localStateStore);
87−const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("pm");
88−const handleToolCall = createRuntimeToolCallHandler({
89− resolveCredentials,
90− invokeRuntimeTool,
144+const toolProviderRegistry = makeToolProviderRegistry([makeOpenApiToolProvider()]);
145+const toolRegistry = createSourceToolRegistry({
146+ workspaceId,
147+ sourceStore,
148+ toolArtifactStore,
149+ toolProviderRegistry,
91150 });
151+const runtimeToolCallService = createRuntimeToolCallService(toolRegistry);
92152
93153 const executeRuntimeRun = createPmExecuteRuntimeRun({
94154 defaultRuntimeKind,
95155 runtimeAdapters,
96− handleToolCall,
156+ toolRegistry,
97157 });
98158
99159 const runExecutor = createRunExecutor(executeRuntimeRun);
100160 const handleMcp = createPmMcpHandler(runExecutor.executeRun);
101161
102162 const handleToolCallHttp = createPmToolCallHttpHandler((input) =>
103− Effect.runPromise(handleToolCall(input)),
163+ Effect.runPromise(
164+ runtimeToolCallService.callTool(input).pipe(
165+ Effect.map((value): RuntimeToolCallResult => ({
166+ ok: true,
167+ value,
168+ })),
169+ Effect.catchTag("RuntimeAdapterError", (error) =>
170+ Effect.succeed<RuntimeToolCallResult>({
171+ ok: false,
172+ kind: "failed",
173+ error: formatRuntimeAdapterError(error),
174+ }),
175+ ),
176+ ),
177+ ),
104178 );
105179
106180 const server = startPmHttpServer({
v2/apps/pm/src/runtime-execution-port.ts+6−43
@@ -1,62 +1,25 @@
11 import {
2− RuntimeAdapterError,
2+ createRuntimeToolCallService,
33 RuntimeExecutionPortError,
44 type ExecuteRuntimeRun,
55 type ExecuteRuntimeRunInput,
66 type RuntimeAdapterRegistry,
77 type RuntimeExecuteError,
8− type RuntimeToolCallService,
8+ type ToolRegistry,
99 } from "@executor-v2/engine";
10−import {
11− type RuntimeToolCallRequest,
12− type RuntimeToolCallResult,
13−} from "@executor-v2/sdk";
1410 import * as Effect from "effect/Effect";
1511
1612 export type PmRuntimeExecutionPortOptions = {
1713 defaultRuntimeKind: string;
1814 runtimeAdapters: RuntimeAdapterRegistry;
19− handleToolCall: (
20− input: RuntimeToolCallRequest,
21− ) => Effect.Effect<RuntimeToolCallResult, never>;
15+ toolRegistry: ToolRegistry;
2216 };
2317
24−const runtimeToolCallService = (
25− options: PmRuntimeExecutionPortOptions,
26−): RuntimeToolCallService => ({
27− callTool: (input) =>
28− options.handleToolCall(input).pipe(
29− Effect.flatMap((result) => {
30− if (result.ok) {
31− return Effect.succeed(result.value);
32− }
33−
34− if (result.kind === "pending") {
35− return Effect.fail(
36− new RuntimeAdapterError({
37− operation: "call_tool",
38− runtimeKind: options.defaultRuntimeKind,
39− message: `Tool call pending approval: ${input.toolPath}`,
40− details: result.error ?? `approvalId=${result.approvalId}`,
41− }),
42− );
43− }
44−
45− return Effect.fail(
46− new RuntimeAdapterError({
47− operation: "call_tool",
48− runtimeKind: options.defaultRuntimeKind,
49− message: `Tool call ${result.kind}: ${input.toolPath}`,
50− details: result.error,
51− }),
52− );
53− }),
54− ),
55−});
56−
5718 export const createPmExecuteRuntimeRun = (
5819 options: PmRuntimeExecutionPortOptions,
5920 ): ExecuteRuntimeRun => {
21+ const runtimeToolCallService = createRuntimeToolCallService(options.toolRegistry);
22+
6023 return (input: ExecuteRuntimeRunInput) =>
6124 Effect.gen(function* () {
6225 const runtimeAdapter = yield* options.runtimeAdapters
@@ -86,7 +49,7 @@ export const createPmExecuteRuntimeRun = ( · 23 unmodified lines
8649 runId: input.runId,
8750 code: input.code,
8851 timeoutMs: input.timeoutMs,
89− toolCallService: runtimeToolCallService(options),
52+ toolCallService: runtimeToolCallService,
9053 })
9154 .pipe(
9255 Effect.mapError(
v2/packages/engine/src/index.ts+2−0
@@ -33,6 +33,8 @@ export { · 32 unmodified lines
3333 type ToolRegistryToolSummary,
3434 } from "./tool-registry";
3535
36+export { createSourceToolRegistry } from "./source-tool-registry";
37+
3638 export {
3739 makeOpenApiToolProvider,
3840 openApiToolDescriptorsFromManifest,
v2/packages/engine/src/source-tool-registry.test.ts+270−0
@@ -0,0 +1,270 @@
1+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2+
3+import {
4+ HttpApi,
5+ HttpApiEndpoint,
6+ HttpApiGroup,
7+ HttpApiSchema,
8+ OpenApi,
9+} from "@effect/platform";
10+import { describe, expect, it } from "@effect/vitest";
11+import { makeSourceManagerService } from "@executor-v2/management-api";
12+import {
13+ type SourceStore,
14+ type ToolArtifactStore,
15+} from "@executor-v2/persistence-ports";
16+import { SourceSchema, type Source, type ToolArtifact } from "@executor-v2/schema";
17+import * as Data from "effect/Data";
18+import * as Effect from "effect/Effect";
19+import * as Option from "effect/Option";
20+import * as Schema from "effect/Schema";
21+
22+import { makeOpenApiToolProvider } from "./openapi-provider";
23+import { makeToolProviderRegistry } from "./tool-providers";
24+import { createSourceToolRegistry } from "./source-tool-registry";
25+
26+const decodeSource = Schema.decodeUnknownSync(SourceSchema);
27+
28+type TestServer = {
29+ baseUrl: string;
30+ requests: Array<string>;
31+ close: () => Promise<void>;
32+};
33+
34+class TestServerReleaseError extends Data.TaggedError("TestServerReleaseError")<{
35+ message: string;
36+}> {}
37+
38+const githubOwnerParam = HttpApiSchema.param("owner", Schema.String);
39+const githubRepoParam = HttpApiSchema.param("repo", Schema.String);
40+
41+class GitHubReposApi extends HttpApiGroup.make("repos").add(
42+ HttpApiEndpoint.get("getRepo")`/repos/${githubOwnerParam}/${githubRepoParam}`.addSuccess(
43+ Schema.Unknown,
44+ ),
45+) {}
46+
47+class GitHubApi extends HttpApi.make("github").add(GitHubReposApi) {}
48+
49+const githubOpenApiSpec = OpenApi.fromApi(GitHubApi);
50+
51+const jsonResponse = (res: ServerResponse, statusCode: number, body: unknown): void => {
52+ res.statusCode = statusCode;
53+ res.setHeader("content-type", "application/json");
54+ res.end(JSON.stringify(body));
55+};
56+
57+const getHeaderValue = (req: IncomingMessage, key: string): string | null => {
58+ const value = req.headers[key];
59+ if (typeof value === "string") {
60+ return value;
61+ }
62+
63+ if (Array.isArray(value)) {
64+ return value[0] ?? null;
65+ }
66+
67+ return null;
68+};
69+
70+const makeTestServer = Effect.acquireRelease(
71+ Effect.promise<TestServer>(
72+ () =>
73+ new Promise<TestServer>((resolve, reject) => {
74+ const requests: Array<string> = [];
75+
76+ const server = createServer((req, res) => {
77+ const host = getHeaderValue(req, "host") ?? "127.0.0.1";
78+ const url = new URL(req.url ?? "/", `http://${host}`);
79+
80+ if (url.pathname === "/repos/octocat/hello-world" && req.method === "GET") {
81+ requests.push(url.pathname);
82+ jsonResponse(res, 200, {
83+ full_name: "octocat/hello-world",
84+ stargazers_count: 42,
85+ });
86+ return;
87+ }
88+
89+ jsonResponse(res, 404, {
90+ error: "not found",
91+ });
92+ });
93+
94+ server.once("error", (error) => reject(error));
95+
96+ server.listen(0, "127.0.0.1", () => {
97+ const address = server.address();
98+ if (!address || typeof address === "string") {
99+ reject(new Error("failed to resolve test server address"));
100+ return;
101+ }
102+
103+ resolve({
104+ baseUrl: `http://127.0.0.1:${address.port}`,
105+ requests,
106+ close: () =>
107+ new Promise<void>((closeResolve, closeReject) => {
108+ server.close((error) => {
109+ if (error) {
110+ closeReject(error);
111+ return;
112+ }
113+
114+ closeResolve();
115+ });
116+ }),
117+ });
118+ });
119+ }),
120+ ),
121+ (server) =>
122+ Effect.tryPromise({
123+ try: () => server.close(),
124+ catch: (cause) =>
125+ new TestServerReleaseError({
126+ message: cause instanceof Error ? cause.message : String(cause),
127+ }),
128+ }).pipe(Effect.orDie),
129+);
130+
131+describe("source tool registry", () => {
132+ it.scoped("discovers and invokes source-backed tools", () =>
133+ Effect.gen(function* () {
134+ const server = yield* makeTestServer;
135+
136+ const source: Source = decodeSource({
137+ id: "src_github",
138+ workspaceId: "ws_local",
139+ name: "github",
140+ kind: "openapi",
141+ endpoint: server.baseUrl,
142+ status: "connected",
143+ enabled: true,
144+ configJson: "{}",
145+ sourceHash: null,
146+ lastError: null,
147+ createdAt: Date.now(),
148+ updatedAt: Date.now(),
149+ });
150+
151+ const sources: Array<Source> = [source];
152+
153+ const sourceStore: SourceStore = {
154+ getById: (workspaceId, sourceId) =>
155+ Effect.succeed(
156+ Option.fromNullable(
157+ sources.find(
158+ (candidate) =>
159+ candidate.workspaceId === workspaceId && candidate.id === sourceId,
160+ ),
161+ ),
162+ ),
163+ listByWorkspace: (workspaceId) =>
164+ Effect.succeed(
165+ sources.filter((candidate) => candidate.workspaceId === workspaceId),
166+ ),
167+ upsert: (nextSource) =>
168+ Effect.sync(() => {
169+ const index = sources.findIndex(
170+ (candidate) =>
171+ candidate.workspaceId === nextSource.workspaceId &&
172+ candidate.id === nextSource.id,
173+ );
174+
175+ if (index >= 0) {
176+ sources[index] = nextSource;
177+ return;
178+ }
179+
180+ sources.push(nextSource);
181+ }),
182+ removeById: (workspaceId, sourceId) =>
183+ Effect.sync(() => {
184+ const initialLength = sources.length;
185+ const nextSources = sources.filter(
186+ (candidate) =>
187+ !(candidate.workspaceId === workspaceId && candidate.id === sourceId),
188+ );
189+ sources.splice(0, sources.length, ...nextSources);
190+ return initialLength !== sources.length;
191+ }),
192+ };
193+
194+ const artifactsByKey = new Map<string, ToolArtifact>();
195+
196+ const toolArtifactStore: ToolArtifactStore = {
197+ getBySource: (workspaceId, sourceId) =>
198+ Effect.succeed(
199+ Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)),
200+ ),
201+ upsert: (artifact) =>
202+ Effect.sync(() => {
203+ artifactsByKey.set(`${artifact.workspaceId}:${artifact.sourceId}`, artifact);
204+ }),
205+ };
206+
207+ const sourceManager = makeSourceManagerService(toolArtifactStore);
208+ yield* sourceManager.refreshOpenApiArtifact({
209+ source,
210+ openApiSpec: githubOpenApiSpec,
211+ });
212+
213+ const toolProviderRegistry = makeToolProviderRegistry([makeOpenApiToolProvider()]);
214+
215+ const toolRegistry = createSourceToolRegistry({
216+ workspaceId: source.workspaceId,
217+ sourceStore,
218+ toolArtifactStore,
219+ toolProviderRegistry,
220+ });
221+
222+ const discovered = yield* toolRegistry.discover({
223+ query: "repo",
224+ limit: 5,
225+ });
226+
227+ expect(discovered.bestPath).not.toBeNull();
228+ expect(discovered.results.length).toBeGreaterThan(0);
229+
230+ const bestPath = discovered.bestPath;
231+ if (!bestPath) {
232+ throw new Error("expected discover to return bestPath");
233+ }
234+
235+ const invocationResult = yield* toolRegistry.callTool({
236+ runId: "run_source_registry_1",
237+ callId: "call_source_registry_1",
238+ toolPath: bestPath,
239+ input: {
240+ owner: "octocat",
241+ repo: "hello-world",
242+ },
243+ });
244+
245+ expect(invocationResult).toMatchObject({
246+ status: 200,
247+ body: {
248+ full_name: "octocat/hello-world",
249+ stargazers_count: 42,
250+ },
251+ });
252+
253+ const namespaces = yield* toolRegistry.catalogNamespaces({});
254+ expect(namespaces.total).toBeGreaterThan(0);
255+ expect(namespaces.namespaces[0]?.samplePaths.length).toBeGreaterThan(0);
256+
257+ const namespace = namespaces.namespaces[0]?.namespace;
258+ if (!namespace) {
259+ throw new Error("expected at least one namespace");
260+ }
261+
262+ const catalog = yield* toolRegistry.catalogTools({
263+ namespace,
264+ });
265+
266+ expect(catalog.results.length).toBeGreaterThan(0);
267+ expect(server.requests).toEqual(["/repos/octocat/hello-world"]);
268+ }),
269+ );
270+});
v2/packages/engine/src/source-tool-registry.ts+365−0
@@ -0,0 +1,365 @@
1+import {
2+ type SourceStore,
3+ type SourceStoreError,
4+ type ToolArtifactStore,
5+ type ToolArtifactStoreError,
6+} from "@executor-v2/persistence-ports";
7+import type { Source, WorkspaceId } from "@executor-v2/schema";
8+import * as Effect from "effect/Effect";
9+import * as Option from "effect/Option";
10+
11+import { openApiToolDescriptorsFromManifest } from "./openapi-provider";
12+import {
13+ RuntimeAdapterError,
14+ type RuntimeAdapterKind,
15+} from "./runtime-adapters";
16+import type {
17+ CanonicalToolDescriptor,
18+ ToolProviderError,
19+ ToolProviderRegistry,
20+ ToolProviderRegistryError,
21+} from "./tool-providers";
22+import type {
23+ ToolRegistry,
24+ ToolRegistryCatalogNamespacesInput,
25+ ToolRegistryCatalogNamespacesOutput,
26+ ToolRegistryCatalogToolsInput,
27+ ToolRegistryCatalogToolsOutput,
28+ ToolRegistryCallInput,
29+ ToolRegistryDiscoverInput,
30+ ToolRegistryDiscoverOutput,
31+ ToolRegistryToolSummary,
32+} from "./tool-registry";
33+
34+const sourceToolRegistryRuntimeKind: RuntimeAdapterKind = "source-tool-registry";
35+
36+type SourceToolRegistryOptions = {
37+ workspaceId: string;
38+ sourceStore: SourceStore;
39+ toolArtifactStore: ToolArtifactStore;
40+ toolProviderRegistry: ToolProviderRegistry;
41+};
42+
43+type SourceToolEntry = {
44+ path: string;
45+ namespace: string;
46+ source: Source;
47+ descriptor: CanonicalToolDescriptor;
48+};
49+
50+const toRuntimeAdapterError = (
51+ operation: string,
52+ message: string,
53+ details: string | null,
54+): RuntimeAdapterError =>
55+ new RuntimeAdapterError({
56+ operation,
57+ runtimeKind: sourceToolRegistryRuntimeKind,
58+ message,
59+ details,
60+ });
61+
62+const sourceStoreErrorToRuntimeAdapterError = (
63+ operation: string,
64+ cause: SourceStoreError,
65+): RuntimeAdapterError =>
66+ toRuntimeAdapterError(
67+ operation,
68+ cause.message,
69+ cause.details ?? cause.reason ?? cause.location,
70+ );
71+
72+const toolArtifactStoreErrorToRuntimeAdapterError = (
73+ operation: string,
74+ cause: ToolArtifactStoreError,
75+): RuntimeAdapterError =>
76+ toRuntimeAdapterError(
77+ operation,
78+ cause.message,
79+ cause.details ?? cause.reason ?? cause.location,
80+ );
81+
82+const toolProviderErrorToRuntimeAdapterError = (
83+ operation: string,
84+ cause: ToolProviderError,
85+): RuntimeAdapterError =>
86+ toRuntimeAdapterError(operation, cause.message, cause.details ?? null);
87+
88+const toolProviderRegistryErrorToRuntimeAdapterError = (
89+ operation: string,
90+ cause: ToolProviderRegistryError,
91+): RuntimeAdapterError =>
92+ toRuntimeAdapterError(operation, cause.message, null);
93+
94+const normalizeNamespacePart = (value: string): string => {
95+ const normalized = value
96+ .trim()
97+ .toLowerCase()
98+ .replace(/[^a-z0-9]+/g, "_")
99+ .replace(/^_+|_+$/g, "");
100+
101+ return normalized.length > 0 ? normalized : "source";
102+};
103+
104+const sourceNamespace = (source: Source): string => {
105+ const sourceIdSuffix = source.id.slice(-6).toLowerCase();
106+ return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`;
107+};
108+
109+const sourceToolPath = (
110+ source: Source,
111+ descriptor: CanonicalToolDescriptor,
112+): string => `${sourceNamespace(source)}.${descriptor.toolId}`;
113+
114+const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number => {
115+ if (query.length === 0) {
116+ return 1;
117+ }
118+
119+ const lowerQuery = query.toLowerCase();
120+ const lowerPath = summary.path.toLowerCase();
121+ const lowerSource = (summary.source ?? "").toLowerCase();
122+ const lowerDescription = (summary.description ?? "").toLowerCase();
123+
124+ if (lowerPath === lowerQuery) {
125+ return 100;
126+ }
127+
128+ if (lowerPath.startsWith(lowerQuery)) {
129+ return 80;
130+ }
131+
132+ if (lowerPath.includes(lowerQuery)) {
133+ return 60;
134+ }
135+
136+ if (lowerSource.includes(lowerQuery)) {
137+ return 40;
138+ }
139+
140+ if (lowerDescription.includes(lowerQuery)) {
141+ return 30;
142+ }
143+
144+ return 0;
145+};
146+
147+const summaryFromEntry = (entry: SourceToolEntry): ToolRegistryToolSummary => ({
148+ path: entry.path,
149+ source: entry.source.name,
150+ approval: "auto",
151+ description: entry.descriptor.description ?? undefined,
152+});
153+
154+const normalizeToolCallInput = (
155+ input: unknown,
156+): Record<string, unknown> => {
157+ if (!input || typeof input !== "object" || Array.isArray(input)) {
158+ return {};
159+ }
160+
161+ return input as Record<string, unknown>;
162+};
163+
164+const describeOutput = (value: unknown): string => {
165+ if (typeof value === "string") {
166+ return value;
167+ }
168+
169+ try {
170+ return JSON.stringify(value);
171+ } catch {
172+ return String(value);
173+ }
174+};
175+
176+const loadSourceEntries = (
177+ options: SourceToolRegistryOptions,
178+): Effect.Effect<ReadonlyArray<SourceToolEntry>, RuntimeAdapterError> =>
179+ Effect.gen(function* () {
180+ const workspaceSources = yield* options.sourceStore
181+ .listByWorkspace(options.workspaceId as WorkspaceId)
182+ .pipe(
183+ Effect.mapError((cause) =>
184+ sourceStoreErrorToRuntimeAdapterError("list_sources", cause),
185+ ),
186+ );
187+
188+ const enabledSources = workspaceSources.filter((source) => source.enabled);
189+
190+ const bySource = yield* Effect.forEach(enabledSources, (source) =>
191+ Effect.gen(function* () {
192+ if (source.kind !== "openapi") {
193+ return [] as Array<SourceToolEntry>;
194+ }
195+
196+ const artifactOption = yield* options.toolArtifactStore
197+ .getBySource(source.workspaceId, source.id)
198+ .pipe(
199+ Effect.mapError((cause) =>
200+ toolArtifactStoreErrorToRuntimeAdapterError("get_source_artifact", cause),
201+ ),
202+ );
203+
204+ if (Option.isNone(artifactOption)) {
205+ return [] as Array<SourceToolEntry>;
206+ }
207+
208+ const artifact = artifactOption.value;
209+ const descriptors = yield* openApiToolDescriptorsFromManifest(
210+ source,
211+ artifact.manifestJson,
212+ ).pipe(
213+ Effect.mapError((cause) =>
214+ toolProviderErrorToRuntimeAdapterError(
215+ "decode_source_manifest",
216+ cause,
217+ ),
218+ ),
219+ );
220+
221+ const namespace = sourceNamespace(source);
222+
223+ return descriptors.map((descriptor) => ({
224+ path: sourceToolPath(source, descriptor),
225+ namespace,
226+ source,
227+ descriptor,
228+ }));
229+ }),
230+ );
231+
232+ return bySource.flat();
233+ });
234+
235+const discoverTools = (
236+ entries: ReadonlyArray<SourceToolEntry>,
237+ input: ToolRegistryDiscoverInput,
238+): ToolRegistryDiscoverOutput => {
239+ const limit = Math.max(1, Math.min(50, input.limit ?? 8));
240+ const query = (input.query ?? "").trim().toLowerCase();
241+
242+ const ranked = entries
243+ .map((entry) => ({
244+ summary: summaryFromEntry(entry),
245+ score: scoreSummary(summaryFromEntry(entry), query),
246+ }))
247+ .filter((item) => item.score > 0)
248+ .sort((left, right) => right.score - left.score)
249+ .slice(0, limit)
250+ .map((item) => item.summary);
251+
252+ return {
253+ bestPath: ranked[0]?.path ?? null,
254+ results: ranked,
255+ total: ranked.length,
256+ };
257+};
258+
259+const catalogNamespaces = (
260+ entries: ReadonlyArray<SourceToolEntry>,
261+ input: ToolRegistryCatalogNamespacesInput,
262+): ToolRegistryCatalogNamespacesOutput => {
263+ const limit = Math.max(1, Math.min(200, input.limit ?? 50));
264+ const grouped = new Map<string, Array<string>>();
265+
266+ for (const entry of entries) {
267+ const paths = grouped.get(entry.namespace) ?? [];
268+ paths.push(entry.path);
269+ grouped.set(entry.namespace, paths);
270+ }
271+
272+ const namespaces = [...grouped.entries()]
273+ .sort((left, right) => left[0].localeCompare(right[0]))
274+ .map(([namespace, paths]) => ({
275+ namespace,
276+ toolCount: paths.length,
277+ samplePaths: [...paths]
278+ .sort((left, right) => left.localeCompare(right))
279+ .slice(0, 3),
280+ }));
281+
282+ return {
283+ namespaces: namespaces.slice(0, limit),
284+ total: namespaces.length,
285+ };
286+};
287+
288+const catalogTools = (
289+ entries: ReadonlyArray<SourceToolEntry>,
290+ input: ToolRegistryCatalogToolsInput,
291+): ToolRegistryCatalogToolsOutput => {
292+ const limit = Math.max(1, Math.min(200, input.limit ?? 50));
293+ const query = (input.query ?? "").trim().toLowerCase();
294+ const namespace = (input.namespace ?? "").trim().toLowerCase();
295+
296+ const filtered = entries
297+ .filter((entry) =>
298+ namespace.length === 0 ? true : entry.namespace.toLowerCase() === namespace,
299+ )
300+ .map(summaryFromEntry)
301+ .filter((summary) => scoreSummary(summary, query) > 0)
302+ .slice(0, limit);
303+
304+ return {
305+ results: filtered,
306+ total: filtered.length,
307+ };
308+};
309+
310+export const createSourceToolRegistry = (
311+ options: SourceToolRegistryOptions,
312+): ToolRegistry => ({
313+ callTool: (input: ToolRegistryCallInput) =>
314+ Effect.gen(function* () {
315+ const entries = yield* loadSourceEntries(options);
316+ const entry = entries.find((candidate) => candidate.path === input.toolPath);
317+
318+ if (!entry) {
319+ return yield* toRuntimeAdapterError(
320+ "call_tool",
321+ `Unknown tool path: ${input.toolPath}`,
322+ "Use tools.discover({ query }) or tools.catalog.tools({ namespace }) to find available tool paths.",
323+ );
324+ }
325+
326+ const invocation = yield* options.toolProviderRegistry
327+ .invoke({
328+ source: entry.source,
329+ tool: entry.descriptor,
330+ args: normalizeToolCallInput(input.input),
331+ })
332+ .pipe(
333+ Effect.mapError((cause) =>
334+ cause._tag === "ToolProviderError"
335+ ? toolProviderErrorToRuntimeAdapterError("invoke_tool", cause)
336+ : toolProviderRegistryErrorToRuntimeAdapterError("invoke_tool", cause),
337+ ),
338+ );
339+
340+ if (invocation.isError) {
341+ return yield* toRuntimeAdapterError(
342+ "call_tool",
343+ `Tool call returned provider error: ${input.toolPath}`,
344+ describeOutput(invocation.output),
345+ );
346+ }
347+
348+ return invocation.output;
349+ }),
350+
351+ discover: (input: ToolRegistryDiscoverInput) =>
352+ loadSourceEntries(options).pipe(
353+ Effect.map((entries) => discoverTools(entries, input)),
354+ ),
355+
356+ catalogNamespaces: (input: ToolRegistryCatalogNamespacesInput) =>
357+ loadSourceEntries(options).pipe(
358+ Effect.map((entries) => catalogNamespaces(entries, input)),
359+ ),
360+
361+ catalogTools: (input: ToolRegistryCatalogToolsInput) =>
362+ loadSourceEntries(options).pipe(
363+ Effect.map((entries) => catalogTools(entries, input)),
364+ ),
365+});
v2/packages/state-contracts/src/source-store.ts+2−1
@@ -6,7 +6,8 @@ import type * as Option from "effect/Option"; · 5 unmodified lines
66
77 export class SourceStoreError extends Data.TaggedError("SourceStoreError")<{
88 operation: string;
9− filePath: string;
9+ backend: string;
10+ location: string;
1011 message: string;
1112 reason: string | null;
1213 details: string | null;
v2/packages/state-contracts/src/tool-artifacts.ts+2−1
@@ -6,7 +6,8 @@ import type * as Option from "effect/Option"; · 5 unmodified lines
66
77 export class ToolArtifactStoreError extends Data.TaggedError("ToolArtifactStoreError")<{
88 operation: string;
9− filePath: string;
9+ backend: string;
10+ location: string;
1011 message: string;
1112 reason: string | null;
1213 details: string | null;
v2/packages/state-local-file/src/local-state-store.ts+2−1
@@ -8,7 +8,8 @@ import type { LocalStateSnapshot } from "./local-state-snapshot"; · 7 unmodified lines
88
99 export class LocalStateStoreError extends Data.TaggedError("LocalStateStoreError")<{
1010 operation: string;
11− filePath: string;
11+ backend: string;
12+ location: string;
1213 message: string;
1314 reason: string | null;
1415 details: string | null;
v2/packages/state-local-file/src/persistence-error.ts+17−8
@@ -5,7 +5,8 @@ import * as ParseResult from "effect/ParseResult"; · 4 unmodified lines
55
66 export type PersistenceErrorData = {
77 operation: string;
8− filePath: string;
8+ backend: string;
9+ location: string;
910 message: string;
1011 reason: string | null;
1112 details: string | null;
@@ -13,9 +14,11 @@ export type PersistenceErrorData = { · 1 unmodified line
1314
1415 type PersistenceErrorFactory<E> = (data: PersistenceErrorData) => E;
1516
17+const defaultPersistenceBackend = "local-file";
18+
1619 export const withPlatformPersistenceError =
1720 <E>(makeError: PersistenceErrorFactory<E>) =>
18− <A>(operation: string, filePath: string) =>
21+ <A>(operation: string, location: string, backend = defaultPersistenceBackend) =>
1922 (
2023 self: Effect.Effect<A, PlatformError.PlatformError>,
2124 ): Effect.Effect<A, E> =>
@@ -26,7 +29,8 @@ export const withPlatformPersistenceError = · 4 unmodified lines
2629 Effect.fail(
2730 makeError({
2831 operation,
29− filePath,
32+ backend,
33+ location,
3034 message: cause.message,
3135 reason: cause.reason,
3236 details: cause.description ?? null,
@@ -36,7 +40,8 @@ export const withPlatformPersistenceError = · 3 unmodified lines
3640 Effect.fail(
3741 makeError({
3842 operation,
39− filePath,
43+ backend,
44+ location,
4045 message: cause.message,
4146 reason: null,
4247 details: cause.description ?? null,
@@ -60,14 +65,16 @@ export const withNotFoundFallback = · 17 unmodified lines
6065 export const toSchemaPersistenceError = <E>(
6166 makeError: PersistenceErrorFactory<E>,
6267 operation: string,
63− filePath: string,
68+ location: string,
6469 message: string,
6570 cause: ParseResult.ParseError,
6671 details?: string,
72+ backend = defaultPersistenceBackend,
6773 ): E =>
6874 makeError({
6975 operation,
70− filePath,
76+ backend,
77+ location,
7178 message,
7279 reason: "InvalidData",
7380 details:
@@ -79,13 +86,15 @@ export const toSchemaPersistenceError = <E>( · 5 unmodified lines
7986 export const toInvalidDataError = <E>(
8087 makeError: PersistenceErrorFactory<E>,
8188 operation: string,
82− filePath: string,
89+ location: string,
8390 message: string,
8491 details: string,
92+ backend = defaultPersistenceBackend,
8593 ): E =>
8694 makeError({
8795 operation,
88− filePath,
96+ backend,
97+ location,
8998 message,
9099 reason: "InvalidData",
91100 details,