History

clean up

Rhys Sullivan committed 7 months ago

Commit 7832dec1 parent 7fda56016 files · +375 −3138Browse files

Unified · Split

16 files
  1. packages/kernel/core/src/discovery.ts +0 −486
  2. packages/kernel/core/src/http-request-placements.ts +0 −164
  3. packages/kernel/core/src/index.test.ts +0 −684
  4. packages/kernel/core/src/index.ts +2 −6
  5. packages/kernel/core/src/isomorphic-hash.ts +0 −132
  6. packages/kernel/core/src/schema-types.test.ts +0 −98
  7. packages/kernel/core/src/schema-types.ts +0 −249
  8. packages/kernel/core/src/system-tools.ts +0 −247
  9. packages/kernel/core/src/tool-map.ts +0 −549
  10. packages/kernel/core/src/types.ts +31 −261
  11. packages/kernel/core/src/validation.ts +89 −0
  12. packages/kernel/ir/package.json +1 −5
  13. packages/kernel/ir/src/index.ts +8 −0
  14. packages/kernel/ir/src/registry.ts +34 −0
  15. packages/kernel/ir/src/serialize.ts +87 −0
  16. packages/kernel/runtime-quickjs/src/index.test.ts +123 −257
packages/kernel/core/src/discovery.ts+0−486
@@ -1,486 +0,0 @@
1import * as Effect from "effect/Effect";
2
3import { toolDescriptorsFromTools } from "./tool-map";
4import type {
5 CatalogPrimitive,
6 DescribePrimitive,
7 DiscoverPrimitive,
8 DiscoveryPrimitives,
9 SearchHit,
10 ToolCatalog,
11 ToolCatalogEntry,
12 ToolDescriptor,
13 ToolMap,
14 ToolNamespace,
15 ToolPath,
16} from "./types";
17
18const tokenize = (value: string): string[] =>
19 value
20 .toLowerCase()
21 .split(/\W+/)
22 .map((token) => token.trim())
23 .filter(Boolean);
24
25const searchableTextForTool = (tool: ToolDescriptor): string =>
26 [
27 tool.path,
28 tool.sourceKey,
29 tool.description ?? "",
30 tool.contract?.inputTypePreview ?? "",
31 tool.contract?.outputTypePreview ?? "",
32 ]
33 .join(" ")
34 .toLowerCase();
35
36const descriptorContract = (
37 descriptor: ToolDescriptor,
38 includeSchemas: boolean,
39): ToolDescriptor["contract"] | undefined => {
40 const contract = descriptor.contract;
41 if (!contract) {
42 return undefined;
43 }
44
45 if (includeSchemas) {
46 return contract;
47 }
48
49 return {
50 ...(contract.inputTypePreview !== undefined
51 ? { inputTypePreview: contract.inputTypePreview }
52 : {}),
53 ...(contract.outputTypePreview !== undefined
54 ? { outputTypePreview: contract.outputTypePreview }
55 : {}),
56 ...(contract.exampleInput !== undefined
57 ? { exampleInput: contract.exampleInput }
58 : {}),
59 ...(contract.exampleOutput !== undefined
60 ? { exampleOutput: contract.exampleOutput }
61 : {}),
62 };
63};
64
65const projectDescriptor = (input: {
66 descriptor: ToolDescriptor;
67 includeSchemas: boolean;
68}): ToolDescriptor => {
69 const { descriptor, includeSchemas } = input;
70
71 if (includeSchemas) {
72 return descriptor;
73 }
74
75 return {
76 ...descriptor,
77 ...(descriptorContract(descriptor, false)
78 ? { contract: descriptorContract(descriptor, false) }
79 : {}),
80 };
81};
82
83const formatToolLine = (tool: ToolDescriptor): string =>
84 tool.description && tool.description.trim().length > 0
85 ? `- ${tool.path}: ${tool.description.trim()}`
86 : `- ${tool.path}`;
87
88const namespaceFromPath = (path: string): string => {
89 const [first, second] = path.split(".");
90 return second ? `${first}.${second}` : first;
91};
92
93const namespaceFromCatalogEntry = (entry: ToolCatalogEntry): string =>
94 entry.namespace ?? namespaceFromPath(entry.descriptor.path);
95
96const searchableTextForEntry = (entry: ToolCatalogEntry): string =>
97 entry.searchText?.trim().toLowerCase() || searchableTextForTool(entry.descriptor);
98
99const scoreCatalogEntry = (
100 queryTokens: readonly string[],
101 entry: ToolCatalogEntry,
102): number => {
103 if (entry.score) {
104 return entry.score(queryTokens);
105 }
106
107 const haystack = searchableTextForEntry(entry);
108 return queryTokens.reduce(
109 (total, token) => total + (haystack.includes(token) ? 1 : 0),
110 0,
111 );
112};
113
114const mergeToolNamespaces = (
115 groups: ReadonlyArray<readonly ToolNamespace[]>,
116): ToolNamespace[] => {
117 const merged = new Map<string, ToolNamespace>();
118
119 for (const group of groups) {
120 for (const namespace of group) {
121 const existing = merged.get(namespace.namespace);
122 merged.set(namespace.namespace, {
123 namespace: namespace.namespace,
124 displayName: existing?.displayName ?? namespace.displayName,
125 ...(existing?.toolCount !== undefined || namespace.toolCount !== undefined
126 ? {
127 toolCount:
128 (existing?.toolCount ?? 0) + (namespace.toolCount ?? 0),
129 }
130 : {}),
131 });
132 }
133 }
134
135 return [...merged.values()].sort((left, right) =>
136 left.namespace.localeCompare(right.namespace),
137 );
138};
139
140const dedupeToolDescriptors = (
141 groups: ReadonlyArray<readonly ToolDescriptor[]>,
142): ToolDescriptor[] => {
143 const merged = new Map<string, ToolDescriptor>();
144
145 for (const group of groups) {
146 for (const descriptor of group) {
147 if (!merged.has(descriptor.path)) {
148 merged.set(descriptor.path, descriptor);
149 }
150 }
151 }
152
153 return [...merged.values()].sort((left, right) =>
154 left.path.localeCompare(right.path),
155 );
156};
157
158const dedupeSearchHits = (
159 groups: ReadonlyArray<readonly SearchHit[]>,
160): SearchHit[] => {
161 const merged = new Map<string, SearchHit>();
162
163 for (const group of groups) {
164 for (const hit of group) {
165 if (!merged.has(hit.path)) {
166 merged.set(hit.path, hit);
167 }
168 }
169 }
170
171 return [...merged.values()].sort(
172 (left, right) => right.score - left.score || left.path.localeCompare(right.path),
173 );
174};
175
176export function createToolCatalogFromTools(input: {
177 tools: ToolMap;
178 defaultNamespace?: string;
179}): ToolCatalog {
180 return createToolCatalogFromEntries({
181 entries: toolDescriptorsFromTools({
182 tools: input.tools,
183 }).map((descriptor) => ({
184 descriptor,
185 ...(input.defaultNamespace !== undefined
186 ? { namespace: input.defaultNamespace }
187 : {}),
188 })),
189 });
190}
191
192export function createToolCatalogFromEntries(input: {
193 entries: ReadonlyArray<ToolCatalogEntry>;
194}): ToolCatalog {
195 const entries = [...input.entries];
196 const byPath = new Map(
197 entries.map((entry) => [entry.descriptor.path as string, entry]),
198 );
199 const namespaceCounts = new Map<string, number>();
200
201 for (const entry of entries) {
202 const namespace = namespaceFromCatalogEntry(entry);
203 namespaceCounts.set(namespace, (namespaceCounts.get(namespace) ?? 0) + 1);
204 }
205
206 return {
207 listNamespaces: ({ limit }) =>
208 Effect.succeed(
209 [...namespaceCounts.entries()]
210 .map(([namespace, toolCount]) => ({ namespace, toolCount }))
211 .slice(0, limit),
212 ),
213 listTools: ({ namespace, query, limit, includeSchemas = false }) =>
214 Effect.succeed(
215 entries
216 .filter((entry) =>
217 !namespace || namespaceFromCatalogEntry(entry) === namespace,
218 )
219 .filter((entry) => {
220 if (!query) {
221 return true;
222 }
223
224 const haystack = searchableTextForEntry(entry);
225 return tokenize(query).every((token) => haystack.includes(token));
226 })
227 .slice(0, limit)
228 .map((entry) =>
229 projectDescriptor({
230 descriptor: entry.descriptor,
231 includeSchemas,
232 })
233 ),
234 ),
235 getToolByPath: ({ path, includeSchemas }) =>
236 Effect.succeed(
237 byPath.get(path)
238 ? projectDescriptor({
239 descriptor: byPath.get(path)!.descriptor,
240 includeSchemas,
241 })
242 : null,
243 ),
244 searchTools: ({ query, namespace, limit }) => {
245 const queryTokens = tokenize(query);
246
247 return Effect.succeed(
248 entries
249 .filter((entry) =>
250 !namespace || namespaceFromCatalogEntry(entry) === namespace,
251 )
252 .map((entry) => ({
253 path: entry.descriptor.path,
254 score: scoreCatalogEntry(queryTokens, entry),
255 }))
256 .filter((hit) => hit.score > 0)
257 .sort((left, right) => right.score - left.score)
258 .slice(0, limit),
259 );
260 },
261 } satisfies ToolCatalog;
262}
263
264export function mergeToolCatalogs(input: {
265 catalogs: ReadonlyArray<ToolCatalog>;
266}): ToolCatalog {
267 const catalogs = [...input.catalogs];
268
269 return {
270 listNamespaces: ({ limit }) =>
271 Effect.gen(function* () {
272 const groups = yield* Effect.forEach(
273 catalogs,
274 (catalog) => catalog.listNamespaces({ limit: Math.max(limit, limit * catalogs.length) }),
275 { concurrency: "unbounded" },
276 );
277
278 return mergeToolNamespaces(groups).slice(0, limit);
279 }),
280
281 listTools: ({ namespace, query, limit, includeSchemas = false }) =>
282 Effect.gen(function* () {
283 const groups = yield* Effect.forEach(
284 catalogs,
285 (catalog) =>
286 catalog.listTools({
287 ...(namespace !== undefined ? { namespace } : {}),
288 ...(query !== undefined ? { query } : {}),
289 limit: Math.max(limit, limit * catalogs.length),
290 includeSchemas,
291 }),
292 { concurrency: "unbounded" },
293 );
294
295 return dedupeToolDescriptors(groups).slice(0, limit);
296 }),
297
298 getToolByPath: ({ path, includeSchemas }) =>
299 Effect.gen(function* () {
300 for (const catalog of catalogs) {
301 const descriptor = yield* catalog.getToolByPath({ path, includeSchemas });
302 if (descriptor) {
303 return descriptor;
304 }
305 }
306
307 return null;
308 }),
309
310 searchTools: ({ query, namespace, limit }) =>
311 Effect.gen(function* () {
312 const groups = yield* Effect.forEach(
313 catalogs,
314 (catalog) =>
315 catalog.searchTools({
316 query,
317 ...(namespace !== undefined ? { namespace } : {}),
318 limit: Math.max(limit, limit * catalogs.length),
319 }),
320 { concurrency: "unbounded" },
321 );
322
323 return dedupeSearchHits(groups).slice(0, limit);
324 }),
325 } satisfies ToolCatalog;
326}
327
328export function createStaticDiscoveryFromTools(input: {
329 tools: ToolMap;
330 sourceKey?: string;
331}): {
332 tools: ToolDescriptor[];
333 executeDescription: Effect.Effect<string, never>;
334} {
335 const tools = toolDescriptorsFromTools({
336 tools: input.tools,
337 sourceKey: input.sourceKey,
338 });
339
340 return {
341 tools,
342 executeDescription: Effect.succeed(buildStaticExecuteDescription({ tools })),
343 };
344}
345
346export function createToolCatalogDiscovery(input: {
347 catalog: ToolCatalog;
348}): {
349 primitives: DiscoveryPrimitives;
350 executeDescription: Effect.Effect<string, unknown>;
351} {
352 const primitives = createDiscoveryPrimitivesFromToolCatalog({
353 catalog: input.catalog,
354 });
355
356 return {
357 primitives,
358 executeDescription: buildDynamicExecuteDescription({
359 catalog: input.catalog,
360 }),
361 };
362}
363
364export function createDiscoveryPrimitivesFromToolCatalog(input: {
365 catalog: ToolCatalog;
366}): DiscoveryPrimitives {
367 const { catalog } = input;
368
369 const describe: DescribePrimitive = {
370 tool: ({ path, includeSchemas = false }) =>
371 catalog.getToolByPath({ path, includeSchemas }),
372 };
373
374 const discover: DiscoverPrimitive = ({
375 query,
376 sourceKey: _sourceKey,
377 limit = 12,
378 includeSchemas = false,
379 }) =>
380 Effect.gen(function* () {
381 const hits = yield* catalog.searchTools({
382 query,
383 limit,
384 });
385
386 if (hits.length === 0) {
387 return {
388 bestPath: null,
389 results: [],
390 total: 0,
391 };
392 }
393
394 const descriptors = yield* Effect.forEach(
395 hits,
396 (hit) =>
397 catalog.getToolByPath({
398 path: hit.path,
399 includeSchemas,
400 }),
401 { concurrency: "unbounded" },
402 );
403
404 const hydrated = hits
405 .map((hit, index) => {
406 const descriptor = descriptors[index];
407 if (!descriptor) {
408 return null;
409 }
410
411 return {
412 path: descriptor.path,
413 score: hit.score,
414 description: descriptor.description,
415 interaction: descriptor.interaction ?? "auto",
416 ...(descriptorContract(descriptor, includeSchemas)
417 ? { contract: descriptorContract(descriptor, includeSchemas) }
418 : {}),
419 };
420 })
421 .filter(Boolean) as Array<
422 Record<string, unknown> & { path: ToolPath; score: number }
423 >;
424
425 return {
426 bestPath: hydrated[0]?.path ?? null,
427 results: hydrated,
428 total: hydrated.length,
429 };
430 });
431
432 const catalogPrimitive: CatalogPrimitive = {
433 namespaces: ({ limit = 200 }) =>
434 catalog.listNamespaces({ limit }).pipe(
435 Effect.map((namespaces) => ({ namespaces })),
436 ),
437 tools: ({ namespace, query, limit = 200, includeSchemas = false }) =>
438 catalog.listTools({
439 ...(namespace !== undefined ? { namespace } : {}),
440 ...(query !== undefined ? { query } : {}),
441 limit,
442 includeSchemas,
443 }).pipe(
444 Effect.map((results) => ({ results })),
445 ),
446 };
447
448 return {
449 catalog: catalogPrimitive,
450 describe,
451 discover,
452 };
453}
454
455export function buildStaticExecuteDescription(input: {
456 tools: readonly ToolDescriptor[];
457}): string {
458 return [
459 "Execute TypeScript in sandbox; call tools directly.",
460 "Available tools:",
461 ...input.tools.map(formatToolLine),
462 "Do not use fetch; use tools.* only.",
463 ].join("\n");
464}
465
466export function buildDynamicExecuteDescription(input: {
467 catalog: ToolCatalog;
468}): Effect.Effect<string, unknown> {
469 return Effect.gen(function* () {
470 const namespaces = yield* input.catalog.listNamespaces({ limit: 200 });
471
472 return [
473 "Execute TypeScript in sandbox; call tools via discovery workflow.",
474 "Available namespaces:",
475 ...namespaces.map((namespace) => `- ${namespace.displayName ?? namespace.namespace}`),
476 "Workflow:",
477 '1) const { results, bestPath } = await tools.discover({ query: "<intent>", limit: 12 });',
478 '2) const path = bestPath ?? results[0]?.path; if (!path) return "No matching tools found.";',
479 "3) const details = await tools.describe.tool({ path, includeSchemas: true });",
480 "4) Read details.contract?.inputSchema/details.contract?.outputSchema when you need the projected shape.",
481 "5) Call selected tools.<path>(input).",
482 "The tools object is a lazy proxy, so Object.keys(tools) is not a useful way to discover capabilities.",
483 "Do not use fetch; use tools.* only.",
484 ].join("\n");
485 });
486}
packages/kernel/core/src/http-request-placements.ts+0−164
@@ -1,164 +0,0 @@
1export type HttpRequestPlacements = {
2 headers?: Readonly<Record<string, string>>;
3 queryParams?: Readonly<Record<string, string>>;
4 cookies?: Readonly<Record<string, string>>;
5 bodyValues?: Readonly<Record<string, string>>;
6};
7
8const isRecord = (value: unknown): value is Record<string, unknown> =>
9 typeof value === "object" && value !== null && !Array.isArray(value);
10
11const decodeJsonPointerSegment = (value: string): string =>
12 value.replaceAll("~1", "/").replaceAll("~0", "~");
13
14const pathSegmentsFromPlacementPath = (value: string): ReadonlyArray<string> => {
15 const trimmed = value.trim();
16 if (trimmed.length === 0) {
17 return [];
18 }
19
20 if (trimmed.startsWith("/")) {
21 return trimmed
22 .split("/")
23 .slice(1)
24 .map(decodeJsonPointerSegment)
25 .filter((segment) => segment.length > 0);
26 }
27
28 return trimmed.split(".").filter((segment) => segment.length > 0);
29};
30
31const findHeaderKey = (
32 headers: Readonly<Record<string, string>>,
33 name: string,
34): string | null => {
35 const lowered = name.toLowerCase();
36 for (const key of Object.keys(headers)) {
37 if (key.toLowerCase() === lowered) {
38 return key;
39 }
40 }
41
42 return null;
43};
44
45const parseCookieHeader = (value: string): Record<string, string> => {
46 const parsed: Record<string, string> = {};
47
48 for (const part of value.split(";")) {
49 const trimmed = part.trim();
50 if (trimmed.length === 0) {
51 continue;
52 }
53
54 const separatorIndex = trimmed.indexOf("=");
55 if (separatorIndex === -1) {
56 parsed[trimmed] = "";
57 continue;
58 }
59
60 const name = trimmed.slice(0, separatorIndex).trim();
61 const cookieValue = trimmed.slice(separatorIndex + 1).trim();
62 if (name.length > 0) {
63 parsed[name] = cookieValue;
64 }
65 }
66
67 return parsed;
68};
69
70const setNestedRecordValue = (
71 target: Record<string, unknown>,
72 path: ReadonlyArray<string>,
73 value: string,
74) => {
75 let current = target;
76
77 for (let index = 0; index < path.length - 1; index += 1) {
78 const segment = path[index]!;
79 const next = current[segment];
80 if (isRecord(next)) {
81 current = next;
82 continue;
83 }
84
85 const replacement: Record<string, unknown> = {};
86 current[segment] = replacement;
87 current = replacement;
88 }
89
90 current[path[path.length - 1]!] = value;
91};
92
93export const applyHttpQueryPlacementsToUrl = (input: {
94 url: string | URL;
95 queryParams?: Readonly<Record<string, string>>;
96}): URL => {
97 const url = input.url instanceof URL ? new URL(input.url.toString()) : new URL(input.url);
98
99 for (const [key, value] of Object.entries(input.queryParams ?? {})) {
100 url.searchParams.set(key, value);
101 }
102
103 return url;
104};
105
106export const applyCookiePlacementsToHeaders = (input: {
107 headers: Readonly<Record<string, string>>;
108 cookies?: Readonly<Record<string, string>>;
109}): Record<string, string> => {
110 const cookies = input.cookies ?? {};
111 if (Object.keys(cookies).length === 0) {
112 return { ...input.headers };
113 }
114
115 const cookieHeaderKey = findHeaderKey(input.headers, "cookie");
116 const cookieHeaderValue = cookieHeaderKey ? input.headers[cookieHeaderKey] : null;
117 const mergedCookies = {
118 ...(cookieHeaderValue ? parseCookieHeader(cookieHeaderValue) : {}),
119 ...cookies,
120 };
121 const headers = { ...input.headers };
122
123 headers[cookieHeaderKey ?? "cookie"] = Object.entries(mergedCookies)
124 .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
125 .join("; ");
126
127 return headers;
128};
129
130export const applyJsonBodyPlacements = (input: {
131 body: unknown;
132 bodyValues?: Readonly<Record<string, string>>;
133 label?: string;
134}): unknown => {
135 const bodyValues = input.bodyValues ?? {};
136 if (Object.keys(bodyValues).length === 0) {
137 return input.body;
138 }
139
140 const root =
141 input.body == null
142 ? {}
143 : isRecord(input.body)
144 ? structuredClone(input.body)
145 : null;
146
147 if (root === null) {
148 throw new Error(
149 `${input.label ?? "HTTP request"} auth body placements require an object JSON body`,
150 );
151 }
152
153 for (const [rawPath, value] of Object.entries(bodyValues)) {
154 const path = pathSegmentsFromPlacementPath(rawPath);
155 if (path.length === 0) {
156 throw new Error(
157 `${input.label ?? "HTTP request"} auth body placement path cannot be empty`,
158 );
159 }
160 setNestedRecordValue(root, path, value);
161 }
162
163 return root;
164};
packages/kernel/core/src/index.test.ts+0−684
@@ -1,684 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { assertInstanceOf, assertNone, assertTrue } from "@effect/vitest/utils";
3import * as Either from "effect/Either";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6
7import * as Schema from "effect/Schema";
8import * as TestClock from "effect/TestClock";
9
10import {
11 allowAllToolInteractions,
12 createToolCatalogDiscovery,
13 createToolCatalogFromEntries,
14 createToolCatalogFromTools,
15 createStaticDiscoveryFromTools,
16 createSystemToolMap,
17 makeToolInvokerFromTools,
18 mergeToolMaps,
19 standardSchemaFromJsonSchema,
20 ToolInteractionDeniedError,
21 ToolInteractionPendingError,
22 toTool,
23 type ToolCatalog,
24 type CodeExecutor,
25 type ToolDescriptor,
26 type ToolMap,
27 type ToolPath,
28} from "./index";
29
30const asToolPath = (value: string): ToolPath => value as ToolPath;
31
32const numberPairInputSchema = Schema.standardSchemaV1(
33 Schema.Struct({
34 a: Schema.Number,
35 b: Schema.Number,
36 }),
37);
38
39const titleInputSchema = Schema.standardSchemaV1(
40 Schema.Struct({
41 title: Schema.String,
42 }),
43);
44
45const messageInputSchema = Schema.standardSchemaV1(
46 Schema.Struct({
47 message: Schema.String,
48 }),
49);
50
51
52describe("codemode-core", () => {
53 it.effect("builds static discovery from tool map keys", () =>
54 Effect.gen(function* () {
55 const tools = {
56 "math.add": {
57 description: "Add two numbers",
58 inputSchema: numberPairInputSchema,
59 execute: async ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
60 },
61 "issues.create": toTool({
62 tool: {
63 description: "Create issue",
64 inputSchema: titleInputSchema,
65 execute: async ({ title }: { title: string }) => ({ id: "issue_1", title }),
66 },
67 metadata: {
68 interaction: "required",
69 },
70 }),
71 } satisfies ToolMap;
72
73 const staticDiscovery = createStaticDiscoveryFromTools({
74 tools,
75 sourceKey: "api.demo",
76 });
77
78 expect(staticDiscovery.tools.map((tool) => tool.path)).toEqual([
79 "issues.create",
80 "math.add",
81 ]);
82
83 const createIssueDescriptor = staticDiscovery.tools.find(
84 (tool) => tool.path === "issues.create",
85 );
86 expect(createIssueDescriptor?.interaction).toBe("required");
87 expect(createIssueDescriptor?.sourceKey).toBe("api.demo");
88 expect(yield* staticDiscovery.executeDescription).toBe(
89 [
90 "Execute TypeScript in sandbox; call tools directly.",
91 "Available tools:",
92 "- issues.create: Create issue",
93 "- math.add: Add two numbers",
94 "Do not use fetch; use tools.* only.",
95 ].join("\n"),
96 );
97 }),
98 );
99
100
101 it.effect("returns pending interaction error for required tools", () =>
102 Effect.gen(function* () {
103 const invoker = makeToolInvokerFromTools({
104 tools: {
105 "issues.create": toTool({
106 tool: {
107 inputSchema: titleInputSchema,
108 execute: ({ title }: { title: string }) => ({ id: "issue_1", title }),
109 },
110 metadata: {
111 interaction: "required",
112 },
113 }),
114 },
115 });
116
117 const pendingError = yield* Effect.flip(
118 invoker.invoke({
119 path: "issues.create",
120 args: { title: "hello" },
121 }),
122 );
123
124 assertInstanceOf(pendingError, ToolInteractionPendingError);
125 expect(pendingError.path).toBe("issues.create");
126 expect(pendingError.elicitation.mode).toBe("form");
127 }),
128 );
129
130 it.effect("waits for elicitation response and executes when accepted", () =>
131 Effect.gen(function* () {
132 const invoker = makeToolInvokerFromTools({
133 tools: {
134 "issues.create": toTool({
135 tool: {
136 inputSchema: titleInputSchema,
137 execute: ({ title }: { title: string }) => ({ id: "issue_1", title }),
138 },
139 metadata: {
140 interaction: "required",
141 },
142 }),
143 },
144 onElicitation: () =>
145 Effect.succeed({
146 action: "accept" as const,
147 }),
148 });
149
150 const output = yield* invoker.invoke({
151 path: "issues.create",
152 args: { title: "hello" },
153 context: { runId: "run_1", callId: "call_1" },
154 });
155
156 expect(output).toEqual({ id: "issue_1", title: "hello" });
157 }),
158 );
159
160 it.effect("waits through polling-style elicitation callback and then executes", () =>
161 Effect.gen(function* () {
162 let pollingSleeps = 0;
163
164 const invoker = makeToolInvokerFromTools({
165 tools: {
166 "issues.create": toTool({
167 tool: {
168 inputSchema: titleInputSchema,
169 execute: ({ title }: { title: string }) => ({ id: "issue_1", title }),
170 },
171 metadata: {
172 interaction: "required",
173 },
174 }),
175 },
176 onElicitation: () =>
177 Effect.gen(function* () {
178 for (let attempt = 0; attempt < 3; attempt += 1) {
179 pollingSleeps += 1;
180 yield* Effect.sleep("200 seconds");
181 }
182
183 return {
184 action: "accept" as const,
185 };
186 }),
187 });
188
189 const fiber = yield* invoker.invoke({
190 path: "issues.create",
191 args: { title: "hello" },
192 context: { runId: "run_poll", callId: "call_poll" },
193 }).pipe(Effect.fork);
194
195 yield* TestClock.adjust("200 seconds");
196 const firstPoll = yield* Fiber.poll(fiber);
197 assertNone(firstPoll);
198
199 yield* TestClock.adjust("200 seconds");
200 const secondPoll = yield* Fiber.poll(fiber);
201 assertNone(secondPoll);
202
203 yield* TestClock.adjust("200 seconds");
204 const output = yield* Fiber.join(fiber);
205
206 expect(output).toEqual({ id: "issue_1", title: "hello" });
207 expect(pollingSleeps).toBe(3);
208 }),
209 );
210
211 it.effect("fails when elicitation is declined", () =>
212 Effect.gen(function* () {
213 const invoker = makeToolInvokerFromTools({
214 tools: {
215 "issues.create": toTool({
216 tool: {
217 inputSchema: titleInputSchema,
218 execute: ({ title }: { title: string }) => ({ id: "issue_1", title }),
219 },
220 metadata: {
221 interaction: "required",
222 },
223 }),
224 },
225 onElicitation: () =>
226 Effect.succeed({
227 action: "decline" as const,
228 content: {
229 reason: "User declined tool execution",
230 },
231 }),
232 });
233
234 const declinedError = yield* Effect.flip(
235 invoker.invoke({
236 path: "issues.create",
237 args: { title: "hello" },
238 }),
239 );
240
241 assertInstanceOf(declinedError, ToolInteractionDeniedError);
242 expect(declinedError.reason).toContain("User declined");
243 }),
244 );
245
246 it.effect("builds Standard Schema validators from JSON Schema", () =>
247 Effect.gen(function* () {
248 const tools = {
249 "math.add": {
250 description: "Add two numbers",
251 inputSchema: standardSchemaFromJsonSchema({
252 type: "object",
253 required: ["a", "b"],
254 properties: {
255 a: { type: "number" },
256 b: { type: "number" },
257 },
258 additionalProperties: false,
259 }),
260 execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
261 },
262 } satisfies ToolMap;
263
264 const invoker = makeToolInvokerFromTools({ tools });
265
266 const success = yield* invoker.invoke({
267 path: "math.add",
268 args: { a: 1, b: 2 },
269 });
270 expect(success).toEqual({ sum: 3 });
271
272 const failure = yield* Effect.either(
273 invoker.invoke({
274 path: "math.add",
275 args: { a: "1", b: 2 },
276 }),
277 );
278
279 assertTrue(Either.isLeft(failure));
280 assertInstanceOf(failure.left, Error);
281 expect(failure.left.message).toContain("Input validation failed");
282 }),
283 );
284
285 it.effect("enforces standard JSON Schema string formats", () =>
286 Effect.gen(function* () {
287 const tools = {
288 "events.create": {
289 inputSchema: standardSchemaFromJsonSchema({
290 type: "object",
291 required: ["dateFrom", "issueId", "email", "documentationUrl"],
292 properties: {
293 dateFrom: { type: "string", format: "date-time" },
294 issueId: { type: "string", format: "uuid" },
295 email: { type: "string", format: "email" },
296 documentationUrl: { type: "string", format: "uri" },
297 },
298 additionalProperties: false,
299 }),
300 execute: (input: unknown) => input,
301 },
302 } satisfies ToolMap;
303
304 const invoker = makeToolInvokerFromTools({ tools });
305
306 const success = yield* invoker.invoke({
307 path: "events.create",
308 args: {
309 dateFrom: "2026-03-30T12:00:00Z",
310 issueId: "2c981f4a-7318-47a3-a3d3-8c145ee8ca1f",
311 email: "rhys@example.com",
312 documentationUrl: "https://example.com/docs",
313 },
314 });
315
316 expect(success).toEqual({
317 dateFrom: "2026-03-30T12:00:00Z",
318 issueId: "2c981f4a-7318-47a3-a3d3-8c145ee8ca1f",
319 email: "rhys@example.com",
320 documentationUrl: "https://example.com/docs",
321 });
322
323 const failure = yield* Effect.either(
324 invoker.invoke({
325 path: "events.create",
326 args: {
327 dateFrom: "not-a-date",
328 issueId: "not-a-uuid",
329 email: "not-an-email",
330 documentationUrl: "not-a-uri",
331 },
332 }),
333 );
334
335 assertTrue(Either.isLeft(failure));
336 assertInstanceOf(failure.left, Error);
337 expect(failure.left.message).toContain('dateFrom: format: must match format "date-time"');
338 expect(failure.left.message).toContain('issueId: format: must match format "uuid"');
339 expect(failure.left.message).toContain('email: format: must match format "email"');
340 expect(failure.left.message).toContain(
341 'documentationUrl: format: must match format "uri"',
342 );
343 }),
344 );
345
346 it.effect("hydrates dynamic discover results via tool catalog", () =>
347 Effect.gen(function* () {
348 const descriptors: Record<string, ToolDescriptor> = {
349 "source.docs.search": {
350 path: asToolPath("source.docs.search"),
351 sourceKey: "source.docs",
352 description: "Search docs",
353 contract: {
354 inputTypePreview: "object",
355 outputTypePreview: "object",
356 },
357 },
358 "source.issues.create": {
359 path: asToolPath("source.issues.create"),
360 sourceKey: "source.issues",
361 description: "Create issue",
362 interaction: "required",
363 contract: {
364 inputTypePreview: "object",
365 outputTypePreview: "object",
366 },
367 },
368 };
369
370 const catalog: ToolCatalog = {
371 listNamespaces: () =>
372 Effect.succeed([
373 {
374 namespace: "docs",
375 displayName: "Docs",
376 toolCount: 1,
377 },
378 {
379 namespace: "issues",
380 displayName: "Issues",
381 toolCount: 1,
382 },
383 ]),
384 listTools: ({ namespace, limit, includeSchemas = false }) =>
385 Effect.succeed(
386 Object.values(descriptors)
387 .filter((descriptor) =>
388 !namespace
389 || descriptor.path.startsWith(`${namespace}.`)
390 || descriptor.path.startsWith(`source.${namespace}.`)
391 )
392 .slice(0, limit)
393 .map((descriptor) => ({
394 ...descriptor,
395 ...(descriptor.contract
396 ? {
397 contract: {
398 ...(descriptor.contract.inputTypePreview !== undefined
399 ? { inputTypePreview: descriptor.contract.inputTypePreview }
400 : {}),
401 ...(descriptor.contract.outputTypePreview !== undefined
402 ? { outputTypePreview: descriptor.contract.outputTypePreview }
403 : {}),
404 ...(includeSchemas && descriptor.contract.inputSchema !== undefined
405 ? { inputSchema: descriptor.contract.inputSchema }
406 : {}),
407 ...(includeSchemas && descriptor.contract.outputSchema !== undefined
408 ? { outputSchema: descriptor.contract.outputSchema }
409 : {}),
410 },
411 }
412 : {}),
413 })),
414 ),
415 getToolByPath: ({ path }) =>
416 Effect.succeed(descriptors[path] ?? null),
417 searchTools: () =>
418 Effect.succeed([
419 { path: asToolPath("source.issues.create"), score: 0.93 },
420 { path: asToolPath("source.docs.search"), score: 0.72 },
421 ]),
422 };
423
424 const dynamic = createToolCatalogDiscovery({ catalog });
425
426 const namespaces = yield* dynamic.primitives.catalog!.namespaces({ limit: 10 });
427 expect(namespaces.namespaces).toHaveLength(2);
428
429 const discovered = yield* dynamic.primitives.discover!({
430 query: "create issue",
431 limit: 5,
432 });
433
434 expect(discovered.bestPath).toBe("source.issues.create");
435 expect(discovered.results[0]?.path).toBe("source.issues.create");
436 expect(discovered.results[0]?.interaction).toBe("required");
437
438 const executeDescription = yield* dynamic.executeDescription;
439 expect(executeDescription).toContain(
440 'const { results, bestPath } = await tools.discover({ query: "<intent>", limit: 12 });',
441 );
442 expect(executeDescription).toContain(
443 'const path = bestPath ?? results[0]?.path; if (!path) return "No matching tools found.";',
444 );
445 expect(executeDescription).toContain(
446 "Object.keys(tools) is not a useful way to discover capabilities.",
447 );
448 }),
449 );
450
451 it.effect("builds a tool catalog from descriptor entries", () =>
452 Effect.gen(function* () {
453 const catalog = createToolCatalogFromEntries({
454 entries: [
455 {
456 namespace: "github.repos",
457 searchText: "github repository details get repo",
458 descriptor: {
459 path: asToolPath("github.repos.getRepo"),
460 sourceKey: "source.github",
461 description: "Get repository details",
462 contract: {
463 inputTypePreview: "{ owner: string; repo: string }",
464 outputTypePreview: "Repository",
465 inputSchema: { type: "object" },
466 },
467 },
468 },
469 {
470 namespace: "github.issues",
471 searchText: "github issues list repository issues",
472 score: (queryTokens) =>
473 queryTokens.includes("issues") && queryTokens.includes("repository")
474 ? 50
475 : 0,
476 descriptor: {
477 path: asToolPath("github.issues.list"),
478 sourceKey: "source.github",
479 description: "List repository issues",
480 contract: {
481 inputTypePreview: "{ owner: string; repo: string }",
482 outputTypePreview: "Issue[]",
483 },
484 },
485 },
486 ],
487 });
488
489 const namespaces = yield* catalog.listNamespaces({ limit: 10 });
490 expect(namespaces).toEqual([
491 { namespace: "github.repos", toolCount: 1 },
492 { namespace: "github.issues", toolCount: 1 },
493 ]);
494
495 const listed = yield* catalog.listTools({
496 namespace: "github.repos",
497 query: "repository details",
498 limit: 10,
499 includeSchemas: false,
500 });
501 expect(listed[0]?.path).toBe("github.repos.getRepo");
502 expect(listed[0]?.contract?.inputSchema).toBeUndefined();
503
504 const discovered = yield* catalog.searchTools({
505 query: "repository issues",
506 limit: 10,
507 });
508 expect(discovered[0]?.path).toBe("github.issues.list");
509
510 const described = yield* catalog.getToolByPath({
511 path: asToolPath("github.repos.getRepo"),
512 includeSchemas: true,
513 });
514 expect(described?.contract?.inputSchema).toEqual({ type: "object" });
515 }),
516 );
517
518 it.effect("system tools can be composed as normal tools", () =>
519 Effect.gen(function* () {
520 const catalog = createToolCatalogFromTools({
521 tools: {
522 "source.issues.create": toTool({
523 tool: {
524 description: "Create issue",
525 inputSchema: titleInputSchema,
526 execute: ({ title }: { title: string }) => ({ id: "issue_1", title }),
527 },
528 metadata: {
529 interaction: "required",
530 sourceKey: "source.issues",
531 },
532 }),
533 },
534 defaultNamespace: "issues",
535 });
536
537 const systemTools = createSystemToolMap({ catalog });
538 const allTools = mergeToolMaps([
539 {
540 "math.add": {
541 inputSchema: numberPairInputSchema,
542 execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
543 },
544 },
545 systemTools,
546 ]);
547
548 const invoker = makeToolInvokerFromTools({ tools: allTools });
549 const discovered = yield* invoker.invoke({
550 path: "discover",
551 args: { query: "create issue", limit: 5 },
552 });
553
554 expect(discovered).toMatchObject({
555 bestPath: "source.issues.create",
556 total: 1,
557 });
558
559 const namespaces = yield* invoker.invoke({
560 path: "catalog.namespaces",
561 args: { limit: 10 },
562 });
563
564 expect(namespaces).toEqual({
565 namespaces: [
566 {
567 namespace: "issues",
568 toolCount: 1,
569 },
570 ],
571 });
572 }),
573 );
574
575 it.effect("executes code against tool map via executor contract", () =>
576 Effect.gen(function* () {
577 const tools = {
578 "math.add": {
579 inputSchema: numberPairInputSchema,
580 execute: async ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
581 },
582 "notifications.send": toTool({
583 tool: {
584 inputSchema: messageInputSchema,
585 execute: async ({ message }: { message: string }) => ({ delivered: true, message }),
586 },
587 metadata: { interaction: "required" },
588 }),
589 } satisfies ToolMap;
590
591 const executor: CodeExecutor = {
592 execute: (code, toolInvoker) =>
593 Effect.gen(function* () {
594 const math = yield* toolInvoker.invoke({
595 path: "math.add",
596 args: { a: 2, b: 3 },
597 });
598 const notification = yield* toolInvoker.invoke({
599 path: "notifications.send",
600 args: { message: "sum is 5" },
601 });
602 return {
603 result: { code, math, notification },
604 logs: ["executed"],
605 };
606 }),
607 };
608
609 const code = "return await tools.math.add({ a: 2, b: 3 });";
610 const output = yield* executor.execute(
611 code,
612 makeToolInvokerFromTools({
613 tools,
614 onToolInteraction: allowAllToolInteractions,
615 }),
616 );
617
618 expect(output.result).toEqual({
619 code,
620 math: { sum: 5 },
621 notification: { delivered: true, message: "sum is 5" },
622 });
623 expect(output.logs).toEqual(["executed"]);
624 }),
625 );
626
627 it.effect("supports lazy tool invoker without passing tools", () =>
628 Effect.gen(function* () {
629 let mathCalls = 0;
630
631 const toolInvoker = makeToolInvokerFromTools({
632 tools: {
633 "math.add": {
634 inputSchema: numberPairInputSchema,
635 execute: ({ a, b }: { a: number; b: number }) => {
636 mathCalls += 1;
637 return { sum: a + b };
638 },
639 },
640 },
641 });
642
643 const executor: CodeExecutor = {
644 execute: (code, invoker) =>
645 Effect.gen(function* () {
646 const math = yield* invoker.invoke({
647 path: "math.add",
648 args: { a: 20, b: 22 },
649 });
650 return {
651 result: { code, math },
652 logs: ["lazy"],
653 };
654 }),
655 };
656
657 const code = "return await tools.math.add({ a: 20, b: 22 });";
658 const output = yield* executor.execute(code, toolInvoker);
659
660 expect(mathCalls).toBe(1);
661 expect(output.result).toEqual({
662 code,
663 math: { sum: 42 },
664 });
665 expect(output.logs).toEqual(["lazy"]);
666 }),
667 );
668
669 it.effect("surfaces executor-reported errors", () =>
670 Effect.gen(function* () {
671 const executor: CodeExecutor = {
672 execute: (_code, _toolInvoker) =>
673 Effect.succeed({ result: null, error: "boom" }),
674 };
675
676 const output = yield* executor.execute(
677 "return 1",
678 makeToolInvokerFromTools({ tools: {} }),
679 );
680
681 expect(output.error).toBe("boom");
682 }),
683 );
684});
packages/kernel/core/src/index.ts+2−6
@@ -1,8 +1,4 @@
1export * from "./types";1export * from "./types";
2export * from "./validation";
2export * from "./json-schema";3export * from "./json-schema";
3export * from "./schema-types";4export * from "./effect-errors";
4export * from "./tool-map";
5export * from "./discovery";
6export * from "./system-tools";
7export * from "./http-request-placements";
8export * from "./isomorphic-hash";
packages/kernel/core/src/isomorphic-hash.ts+0−132
@@ -1,132 +0,0 @@
1/**
2 * Isomorphic SHA-256 implementation that works in both Node.js and the browser.
3 *
4 * Avoids importing `node:crypto`, which Vite externalizes for browser builds,
5 * causing runtime errors when this module is pulled into client bundles.
6 *
7 * Uses the same SHA-256 algorithm as `node:crypto`, producing identical hex output.
8 */
9
10// SHA-256 constants: first 32 bits of the fractional parts of the cube roots of the first 64 primes
11const K = new Uint32Array([
12 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
13 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
14 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
15 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
16 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
17 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
18 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
19 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
20]);
21
22const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
23
24const sha256Compress = (state: Uint32Array, block: Uint32Array): void => {
25 const w = new Uint32Array(64);
26 for (let i = 0; i < 16; i++) w[i] = block[i];
27 for (let i = 16; i < 64; i++) {
28 const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
29 const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
30 w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
31 }
32
33 let a = state[0], b = state[1], c = state[2], d = state[3];
34 let e = state[4], f = state[5], g = state[6], h = state[7];
35
36 for (let i = 0; i < 64; i++) {
37 const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
38 const ch = (e & f) ^ (~e & g);
39 const temp1 = (h + S1 + ch + K[i] + w[i]) | 0;
40 const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
41 const maj = (a & b) ^ (a & c) ^ (b & c);
42 const temp2 = (S0 + maj) | 0;
43
44 h = g; g = f; f = e; e = (d + temp1) | 0;
45 d = c; c = b; b = a; a = (temp1 + temp2) | 0;
46 }
47
48 state[0] = (state[0] + a) | 0;
49 state[1] = (state[1] + b) | 0;
50 state[2] = (state[2] + c) | 0;
51 state[3] = (state[3] + d) | 0;
52 state[4] = (state[4] + e) | 0;
53 state[5] = (state[5] + f) | 0;
54 state[6] = (state[6] + g) | 0;
55 state[7] = (state[7] + h) | 0;
56};
57
58const utf8Encode = (str: string): Uint8Array => {
59 if (typeof TextEncoder !== "undefined") {
60 return new TextEncoder().encode(str);
61 }
62
63 // Fallback for environments without TextEncoder
64 const bytes: number[] = [];
65 for (let i = 0; i < str.length; i++) {
66 let c = str.charCodeAt(i);
67 if (c < 0x80) {
68 bytes.push(c);
69 } else if (c < 0x800) {
70 bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
71 } else if (c >= 0xd800 && c < 0xdc00 && i + 1 < str.length) {
72 const next = str.charCodeAt(++i);
73 c = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00);
74 bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
75 } else {
76 bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
77 }
78 }
79 return new Uint8Array(bytes);
80};
81
82const sha256 = (data: Uint8Array): Uint8Array => {
83 const state = new Uint32Array([
84 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
85 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
86 ]);
87
88 const bitLen = data.length * 8;
89
90 // Padding: append 1 bit, then zeros, then 64-bit big-endian length
91 const padded = new Uint8Array(Math.ceil((data.length + 9) / 64) * 64);
92 padded.set(data);
93 padded[data.length] = 0x80;
94 // Write the 64-bit big-endian bit length at the end
95 const view = new DataView(padded.buffer);
96 // For strings up to ~256 MB, the high 32 bits of the bit length are zero
97 view.setUint32(padded.length - 4, bitLen, false);
98 if (bitLen > 0xffffffff) {
99 view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false);
100 }
101
102 // Process each 64-byte block
103 const block = new Uint32Array(16);
104 for (let offset = 0; offset < padded.length; offset += 64) {
105 for (let i = 0; i < 16; i++) {
106 block[i] = view.getUint32(offset + i * 4, false);
107 }
108 sha256Compress(state, block);
109 }
110
111 const result = new Uint8Array(32);
112 const resultView = new DataView(result.buffer);
113 for (let i = 0; i < 8; i++) {
114 resultView.setUint32(i * 4, state[i], false);
115 }
116 return result;
117};
118
119const hexFromBytes = (bytes: Uint8Array): string => {
120 let hex = "";
121 for (let i = 0; i < bytes.length; i++) {
122 hex += bytes[i].toString(16).padStart(2, "0");
123 }
124 return hex;
125};
126
127/**
128 * Compute a SHA-256 hex digest of a UTF-8 string.
129 * Drop-in replacement for `createHash("sha256").update(value).digest("hex")`.
130 */
131export const sha256Hex = (value: string): string =>
132 hexFromBytes(sha256(utf8Encode(value)));
packages/kernel/core/src/schema-types.test.ts+0−98
@@ -1,98 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { typeSignatureFromSchema } from "./schema-types";
4
5describe("schema-types", () => {
6 it("expands local $defs refs in type signatures", () => {
7 const schema = {
8 type: "object",
9 properties: {
10 args: {
11 type: "object",
12 properties: {
13 input: {
14 $ref: "#/$defs/input",
15 },
16 },
17 required: ["input"],
18 additionalProperties: false,
19 },
20 },
21 required: ["args"],
22 additionalProperties: false,
23 $defs: {
24 input: {
25 type: "object",
26 properties: {
27 issueId: {
28 type: "string",
29 },
30 externalLink: {
31 type: "string",
32 },
33 },
34 required: ["issueId"],
35 additionalProperties: false,
36 },
37 },
38 };
39
40 expect(typeSignatureFromSchema(schema, "unknown", Infinity)).toBe(
41 "{ args: { input: { issueId: string, externalLink?: string } } }",
42 );
43 });
44
45 it("renders GraphQL error arrays with object item shapes", () => {
46 const schema = {
47 type: "object",
48 properties: {
49 data: {
50 type: "object",
51 properties: {
52 success: {
53 type: "boolean",
54 },
55 },
56 required: ["success"],
57 additionalProperties: false,
58 },
59 errors: {
60 type: "array",
61 items: {
62 type: "object",
63 properties: {
64 message: {
65 type: "string",
66 },
67 },
68 required: ["message"],
69 additionalProperties: true,
70 },
71 },
72 },
73 required: ["data", "errors"],
74 additionalProperties: false,
75 };
76
77 expect(typeSignatureFromSchema(schema, "unknown", Infinity)).toContain(
78 "errors: { message: string }[]",
79 );
80 });
81
82 it("quotes invalid object property names", () => {
83 const schema = {
84 type: "object",
85 properties: {
86 "x-request-id": {
87 type: "string",
88 },
89 },
90 required: ["x-request-id"],
91 additionalProperties: false,
92 };
93
94 expect(typeSignatureFromSchema(schema, "unknown", Infinity)).toBe(
95 '{ "x-request-id": string }',
96 );
97 });
98});
packages/kernel/core/src/schema-types.ts+0−249
@@ -1,249 +0,0 @@
1type JsonSchemaRecord = Record<string, unknown>;
2
3export type TypeSignatureRenderOptions = {
4 maxLength?: number;
5 maxDepth?: number;
6 maxProperties?: number;
7};
8
9const asRecord = (value: unknown): JsonSchemaRecord =>
10 typeof value === "object" && value !== null && !Array.isArray(value)
11 ? (value as JsonSchemaRecord)
12 : {};
13
14const asStringArray = (value: unknown): Array<string> =>
15 Array.isArray(value)
16 ? value.filter((item): item is string => typeof item === "string")
17 : [];
18
19const truncate = (value: string, maxLength: number): string =>
20 value.length <= maxLength
21 ? value
22 : `${value.slice(0, Math.max(0, maxLength - 4))} ...`;
23
24const VALID_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
25
26const formatPropertyKey = (value: string): string =>
27 VALID_IDENTIFIER_PATTERN.test(value) ? value : JSON.stringify(value);
28
29const compositeLabel = (
30 key: "oneOf" | "anyOf" | "allOf",
31 schema: JsonSchemaRecord,
32 render: (input: unknown) => string,
33): string | null => {
34 const items = Array.isArray(schema[key]) ? schema[key].map(asRecord) : [];
35 if (items.length === 0) {
36 return null;
37 }
38
39 const labels = items
40 .map((item) => render(item))
41 .filter((label) => label.length > 0);
42
43 if (labels.length === 0) {
44 return null;
45 }
46
47 return labels.join(key === "allOf" ? " & " : " | ");
48};
49
50const refSegments = (ref: string): Array<string> | null => {
51 if (!ref.startsWith("#/")) {
52 return null;
53 }
54
55 return ref
56 .slice(2)
57 .split("/")
58 .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
59};
60
61const resolveLocalRef = (
62 root: JsonSchemaRecord,
63 ref: string,
64): JsonSchemaRecord | null => {
65 const segments = refSegments(ref);
66 if (!segments || segments.length === 0) {
67 return null;
68 }
69
70 let current: unknown = root;
71 for (const segment of segments) {
72 const record = asRecord(current);
73 if (!(segment in record)) {
74 return null;
75 }
76 current = record[segment];
77 }
78
79 const resolved = asRecord(current);
80 return Object.keys(resolved).length > 0 ? resolved : null;
81};
82
83const refFallbackLabel = (ref: string): string => {
84 const segments = refSegments(ref);
85 return segments?.at(-1) ?? ref;
86};
87
88export const schemaToTypeSignatureWithOptions = (
89 input: unknown,
90 options: TypeSignatureRenderOptions = {},
91): string => {
92 const root = asRecord(input);
93 const maxLength = options.maxLength ?? 220;
94 const boundedSummary = Number.isFinite(maxLength);
95 const maxDepth = options.maxDepth ?? (boundedSummary ? 4 : Number.POSITIVE_INFINITY);
96 const maxProperties = options.maxProperties ?? (boundedSummary ? 6 : Number.POSITIVE_INFINITY);
97
98 const render = (
99 currentInput: unknown,
100 seenRefs: ReadonlySet<string>,
101 depthRemaining: number,
102 ): string => {
103 const schema = asRecord(currentInput);
104
105 if (depthRemaining <= 0) {
106 if (typeof schema.title === "string" && schema.title.length > 0) {
107 return schema.title;
108 }
109
110 if (schema.type === "array") {
111 return "unknown[]";
112 }
113
114 if (schema.type === "object" || schema.properties) {
115 return schema.additionalProperties ? "Record<string, unknown>" : "object";
116 }
117 }
118
119 if (typeof schema.$ref === "string") {
120 const ref = schema.$ref.trim();
121 if (ref.length === 0) {
122 return "unknown";
123 }
124
125 if (seenRefs.has(ref)) {
126 return refFallbackLabel(ref);
127 }
128
129 const target = resolveLocalRef(root, ref);
130 if (!target) {
131 return refFallbackLabel(ref);
132 }
133
134 return render(target, new Set([...seenRefs, ref]), depthRemaining - 1);
135 }
136
137 if ("const" in schema) {
138 return JSON.stringify(schema.const);
139 }
140
141 const enumValues = Array.isArray(schema.enum) ? schema.enum : [];
142 if (enumValues.length > 0) {
143 return truncate(
144 enumValues.map((value) => JSON.stringify(value)).join(" | "),
145 maxLength,
146 );
147 }
148
149 const composite =
150 compositeLabel("oneOf", schema, (value) => render(value, seenRefs, depthRemaining - 1))
151 ?? compositeLabel("anyOf", schema, (value) => render(value, seenRefs, depthRemaining - 1))
152 ?? compositeLabel("allOf", schema, (value) => render(value, seenRefs, depthRemaining - 1));
153 if (composite) {
154 return truncate(composite, maxLength);
155 }
156
157 if (schema.type === "array") {
158 const itemLabel = schema.items
159 ? render(schema.items, seenRefs, depthRemaining - 1)
160 : "unknown";
161 return truncate(`${itemLabel}[]`, maxLength);
162 }
163
164 if (schema.type === "object" || schema.properties) {
165 const properties = asRecord(schema.properties);
166 const keys = Object.keys(properties);
167 if (keys.length === 0) {
168 return schema.additionalProperties ? "Record<string, unknown>" : "object";
169 }
170
171 const required = new Set(asStringArray(schema.required));
172 const visibleKeys = keys.slice(0, maxProperties);
173 const parts = visibleKeys.map((key) =>
174 `${formatPropertyKey(key)}${required.has(key) ? "" : "?"}: ${render(properties[key], seenRefs, depthRemaining - 1)}`
175 );
176 if (visibleKeys.length < keys.length) {
177 parts.push("...");
178 }
179
180 return truncate(`{ ${parts.join(", ")} }`, maxLength);
181 }
182
183 if (Array.isArray(schema.type)) {
184 return truncate(schema.type.join(" | "), maxLength);
185 }
186
187 if (typeof schema.type === "string") {
188 return schema.type;
189 }
190
191 return "unknown";
192 };
193
194 return render(root, new Set(), maxDepth);
195};
196
197export const schemaToTypeSignature = (
198 input: unknown,
199 maxLength: number = 220,
200): string =>
201 schemaToTypeSignatureWithOptions(input, { maxLength });
202
203export const typeSignatureFromSchemaJson = (
204 schemaJson: string | undefined,
205 fallback: string,
206 maxLength: number = 220,
207): string => {
208 if (!schemaJson) {
209 return fallback;
210 }
211
212 try {
213 return schemaToTypeSignature(JSON.parse(schemaJson) as unknown, maxLength);
214 } catch {
215 return fallback;
216 }
217};
218
219export const typeSignatureFromSchema = (
220 schema: unknown,
221 fallback: string,
222 maxLength: number = 220,
223): string => {
224 if (schema === undefined) {
225 return fallback;
226 }
227
228 try {
229 return schemaToTypeSignature(schema, maxLength);
230 } catch {
231 return fallback;
232 }
233};
234
235export const typeSignatureFromSchemaWithOptions = (
236 schema: unknown,
237 fallback: string,
238 options: TypeSignatureRenderOptions = {},
239): string => {
240 if (schema === undefined) {
241 return fallback;
242 }
243
244 try {
245 return schemaToTypeSignatureWithOptions(schema, options);
246 } catch {
247 return fallback;
248 }
249};
packages/kernel/core/src/system-tools.ts+0−247
@@ -1,247 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import { createDiscoveryPrimitivesFromToolCatalog } from "./discovery";
5import { toTool } from "./tool-map";
6import {
7 ToolDescriptorSchema,
8 ToolNamespaceSchema,
9 type ToolCatalog,
10 type ToolMap,
11 type ToolPath,
12} from "./types";
13
14const asToolPath = (value: string): ToolPath => value as ToolPath;
15
16const catalogNamespacesInputSchema = Schema.standardSchemaV1(
17 Schema.Struct({
18 limit: Schema.optional(Schema.Number),
19 }),
20);
21
22const catalogNamespacesOutputSchema = Schema.standardSchemaV1(
23 Schema.Struct({
24 namespaces: Schema.Array(ToolNamespaceSchema),
25 }),
26);
27
28const catalogToolsInputSchema = Schema.standardSchemaV1(
29 Schema.Struct({
30 namespace: Schema.optional(Schema.String),
31 query: Schema.optional(Schema.String),
32 limit: Schema.optional(Schema.Number),
33 includeSchemas: Schema.optional(Schema.Boolean),
34 }),
35);
36
37const catalogToolsOutputSchema = Schema.standardSchemaV1(
38 Schema.Struct({
39 results: Schema.Array(ToolDescriptorSchema),
40 }),
41);
42
43const describeToolInputSchema = Schema.standardSchemaV1(
44 Schema.Struct({
45 path: Schema.String,
46 includeSchemas: Schema.optional(Schema.Boolean),
47 }),
48);
49
50const describeToolOutputSchema = Schema.standardSchemaV1(
51 Schema.NullOr(ToolDescriptorSchema),
52);
53
54const discoverInputSchema = Schema.standardSchemaV1(
55 Schema.Struct({
56 query: Schema.String,
57 limit: Schema.optional(Schema.Number),
58 includeSchemas: Schema.optional(Schema.Boolean),
59 }),
60);
61
62const discoverResultItemSchema = Schema.extend(
63 ToolDescriptorSchema,
64 Schema.Struct({
65 score: Schema.Number,
66 }),
67);
68
69const discoverOutputSchema = Schema.standardSchemaV1(
70 Schema.Struct({
71 bestPath: Schema.NullOr(Schema.String),
72 results: Schema.Array(discoverResultItemSchema),
73 total: Schema.Number,
74 }),
75);
76
77export type DiscoverResultItem = Record<string, unknown> & {
78 path: ToolPath;
79 score: number;
80};
81
82export type CreateSystemToolMapInput = {
83 catalog?: ToolCatalog;
84 getCatalog?: () => ToolCatalog;
85 sourceKey?: string;
86 filterDiscoverResults?: (
87 results: readonly DiscoverResultItem[],
88 ) => Promise<readonly DiscoverResultItem[]> | readonly DiscoverResultItem[];
89};
90
91export const createSystemToolMap = (
92 input: CreateSystemToolMapInput,
93): ToolMap => {
94 const sourceKey = input.sourceKey ?? "system";
95 const getCatalog = (): ToolCatalog => {
96 if (input.catalog) {
97 return input.catalog;
98 }
99 if (input.getCatalog) {
100 return input.getCatalog();
101 }
102
103 throw new Error("createSystemToolMap requires a catalog or getCatalog");
104 };
105 const getPrimitives = () =>
106 createDiscoveryPrimitivesFromToolCatalog({
107 catalog: getCatalog(),
108 }) as Required<ReturnType<typeof createDiscoveryPrimitivesFromToolCatalog>>;
109
110 const tools: ToolMap = {};
111
112 tools["catalog.namespaces"] = toTool({
113 tool: {
114 description: "List available namespaces with display names and tool counts",
115 inputSchema: catalogNamespacesInputSchema,
116 outputSchema: catalogNamespacesOutputSchema,
117 execute: ({ limit }: { limit?: number }) =>
118 Effect.runPromise(
119 getPrimitives().catalog.namespaces((limit !== undefined ? { limit } : {})),
120 ),
121 },
122 metadata: {
123 sourceKey,
124 interaction: "auto",
125 },
126 });
127
128 tools["catalog.tools"] = toTool({
129 tool: {
130 description: "List tools with optional namespace and query filters",
131 inputSchema: catalogToolsInputSchema,
132 outputSchema: catalogToolsOutputSchema,
133 execute: (
134 toolInput: {
135 namespace?: string;
136 query?: string;
137 limit?: number;
138 includeSchemas?: boolean;
139 },
140 ) =>
141 Effect.runPromise(
142 getPrimitives().catalog.tools({
143 ...(toolInput.namespace !== undefined
144 ? { namespace: toolInput.namespace }
145 : {}),
146 ...(toolInput.query !== undefined ? { query: toolInput.query } : {}),
147 ...(toolInput.limit !== undefined ? { limit: toolInput.limit } : {}),
148 ...(toolInput.includeSchemas !== undefined
149 ? { includeSchemas: toolInput.includeSchemas }
150 : {}),
151 }),
152 ),
153 },
154 metadata: {
155 sourceKey,
156 interaction: "auto",
157 },
158 });
159
160 tools["describe.tool"] = toTool({
161 tool: {
162 description: "Get metadata and optional schemas for a tool path",
163 inputSchema: describeToolInputSchema,
164 outputSchema: describeToolOutputSchema,
165 execute: ({ path, includeSchemas }: { path: string; includeSchemas?: boolean }) =>
166 Effect.runPromise(
167 getPrimitives().describe.tool({
168 path: asToolPath(path),
169 ...(includeSchemas !== undefined ? { includeSchemas } : {}),
170 }),
171 ),
172 },
173 metadata: {
174 sourceKey,
175 interaction: "auto",
176 },
177 });
178
179 tools.discover = toTool({
180 tool: {
181 description: "Search tools by intent and return ranked matches",
182 inputSchema: discoverInputSchema,
183 outputSchema: discoverOutputSchema,
184 execute: async (
185 toolInput: {
186 query: string;
187 limit?: number;
188 includeSchemas?: boolean;
189 },
190 ) => {
191 const result = await Effect.runPromise(
192 getPrimitives().discover({
193 query: toolInput.query,
194 ...(toolInput.limit !== undefined ? { limit: toolInput.limit } : {}),
195 ...(toolInput.includeSchemas !== undefined
196 ? { includeSchemas: toolInput.includeSchemas }
197 : {}),
198 }),
199 );
200
201 if (!input.filterDiscoverResults) {
202 return result;
203 }
204
205 const filtered = await input.filterDiscoverResults(
206 result.results as DiscoverResultItem[],
207 );
208
209 return {
210 ...result,
211 results: filtered,
212 bestPath: filtered[0]?.path ?? null,
213 total: filtered.length,
214 };
215 },
216 },
217 metadata: {
218 sourceKey,
219 interaction: "auto",
220 },
221 });
222
223 return tools;
224};
225
226export type MergeToolMapsOptions = {
227 conflictMode?: "throw" | "override";
228};
229
230export const mergeToolMaps = (
231 maps: ReadonlyArray<ToolMap>,
232 options: MergeToolMapsOptions = {},
233): ToolMap => {
234 const conflictMode = options.conflictMode ?? "throw";
235 const merged: ToolMap = {};
236
237 for (const map of maps) {
238 for (const [path, tool] of Object.entries(map)) {
239 if (conflictMode === "throw" && path in merged) {
240 throw new Error(`Tool path conflict: ${path}`);
241 }
242 merged[path] = tool;
243 }
244 }
245
246 return merged;
247};
packages/kernel/core/src/tool-map.ts+0−549
@@ -1,549 +0,0 @@
1import type { StandardSchemaV1 } from "@standard-schema/spec";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as JSONSchema from "effect/JSONSchema";
5import { typeSignatureFromSchema } from "./schema-types";
6
7import type {
8 ElicitationRequest,
9 ElicitationResponse,
10 ExecutableTool,
11 OnElicitation,
12 OnToolInteraction,
13 ToolDefinition,
14 ToolDescriptor,
15 ToolElicitationRequest,
16 ToolInput,
17 ToolInteractionDecision,
18 ToolInteractionRequest,
19 ToolInvocationContext,
20 ToolInvoker,
21 ToolMap,
22 ToolMetadata,
23 ToolPath,
24} from "./types";
25import { kernelCoreEffectError } from "./effect-errors";
26
27type ResolvedTool = {
28 path: ToolPath;
29 tool: ExecutableTool;
30 metadata?: ToolMetadata;
31};
32
33const asToolPath = (value: string): ToolPath => value as ToolPath;
34
35const toError = (cause: unknown): Error =>
36 cause instanceof Error ? cause : new Error(String(cause));
37
38const getSchemaValidator = (schema: unknown):
39 | ((value: unknown, options?: StandardSchemaV1.Options) =>
40 | StandardSchemaV1.Result<unknown>
41 | Promise<StandardSchemaV1.Result<unknown>>)
42 | null => {
43 if (!schema || (typeof schema !== "object" && typeof schema !== "function")) {
44 return null;
45 }
46
47 const standard = (schema as { "~standard"?: unknown })["~standard"];
48 if (!standard || typeof standard !== "object") {
49 return null;
50 }
51
52 const validate = (standard as { validate?: unknown }).validate;
53 return typeof validate === "function"
54 ? (validate as (
55 value: unknown,
56 options?: StandardSchemaV1.Options,
57 ) =>
58 | StandardSchemaV1.Result<unknown>
59 | Promise<StandardSchemaV1.Result<unknown>>)
60 : null;
61};
62
63const formatIssuePath = (
64 path: ReadonlyArray<PropertyKey | StandardSchemaV1.PathSegment> | undefined,
65): string => {
66 if (!path || path.length === 0) {
67 return "$";
68 }
69
70 return path
71 .map((segment) =>
72 typeof segment === "object" && segment !== null && "key" in segment
73 ? String(segment.key)
74 : String(segment),
75 )
76 .join(".");
77};
78
79const formatIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string =>
80 issues
81 .map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`)
82 .join("; ");
83
84const parseInput = (input: {
85 schema: unknown;
86 value: unknown;
87 path: string;
88}): Effect.Effect<unknown, Error> => {
89 const validate = getSchemaValidator(input.schema);
90 if (!validate) {
91 return Effect.fail(
92 kernelCoreEffectError("tool-map", `Tool ${input.path} has no Standard Schema validator on inputSchema`),
93 );
94 }
95
96 return Effect.tryPromise({
97 try: () => Promise.resolve(validate(input.value)),
98 catch: (cause) => kernelCoreEffectError("tool-map", `Validation error for ${input.path}: ${String(cause)}`),
99 }).pipe(
100 Effect.flatMap((result) => {
101 if ("issues" in result && result.issues) {
102 return Effect.fail(
103 kernelCoreEffectError("tool-map",
104 `Input validation failed for ${input.path}: ${formatIssues(result.issues)}`,
105 ),
106 );
107 }
108 return Effect.succeed(result.value);
109 }),
110 );
111};
112
113const defaultRequiredElicitation = (path: string): ElicitationRequest => ({
114 mode: "form",
115 message: `Approval required before invoking ${path}`,
116 requestedSchema: {
117 type: "object",
118 properties: {},
119 additionalProperties: false,
120 },
121});
122
123const executeDecision: ToolInteractionDecision = { kind: "execute" };
124
125const toElicitationDecision = (input: {
126 metadata?: ToolMetadata;
127 path: string;
128}): ToolInteractionDecision | null => {
129 if (input.metadata?.elicitation) {
130 return {
131 kind: "elicit",
132 elicitation: input.metadata.elicitation,
133 };
134 }
135
136 if (input.metadata?.interaction === "required") {
137 return {
138 kind: "elicit",
139 elicitation: defaultRequiredElicitation(input.path),
140 };
141 }
142
143 return null;
144};
145
146const defaultInteractionDecision = (input: {
147 metadata?: ToolMetadata;
148 path: string;
149}): ToolInteractionDecision =>
150 toElicitationDecision(input) ?? executeDecision;
151
152export class ToolInteractionPendingError extends Data.TaggedError(
153 "ToolInteractionPendingError",
154)<{
155 readonly path: string;
156 readonly elicitation: ElicitationRequest;
157 readonly interactionId?: string;
158}> {}
159
160export class ToolInteractionDeniedError extends Data.TaggedError(
161 "ToolInteractionDeniedError",
162)<{
163 readonly path: string;
164 readonly reason: string;
165}> {}
166
167export const allowAllToolInteractions: OnToolInteraction = () =>
168 Effect.succeed(executeDecision);
169
170const evaluateInteractionDecision = (input: {
171 path: ToolPath;
172 args: unknown;
173 metadata?: ToolMetadata;
174 sourceKey: string;
175 context?: ToolInvocationContext;
176 onToolInteraction?: OnToolInteraction;
177}): Effect.Effect<ToolInteractionDecision, unknown> => {
178 const defaultDecision = defaultInteractionDecision({
179 metadata: input.metadata,
180 path: input.path,
181 });
182
183 if (!input.onToolInteraction) {
184 return Effect.succeed(defaultDecision);
185 }
186
187 const request: ToolInteractionRequest = {
188 path: input.path,
189 sourceKey: input.sourceKey,
190 args: input.args,
191 metadata: input.metadata,
192 context: input.context,
193 defaultElicitation:
194 defaultDecision.kind === "elicit"
195 ? defaultDecision.elicitation
196 : null,
197 };
198
199 return input.onToolInteraction(request);
200};
201
202const isRecord = (value: unknown): value is Record<string, unknown> =>
203 typeof value === "object" && value !== null && !Array.isArray(value);
204
205const mergeAcceptedElicitationContent = (input: {
206 path: ToolPath;
207 args: unknown;
208 response: ElicitationResponse;
209 inputSchema: unknown;
210}): Effect.Effect<unknown, Error> => {
211 if (!input.response.content) {
212 return Effect.succeed(input.args);
213 }
214
215 if (!isRecord(input.args)) {
216 return Effect.fail(
217 kernelCoreEffectError("tool-map",
218 `Tool ${input.path} cannot merge elicitation content into non-object arguments`,
219 ),
220 );
221 }
222
223 const merged = {
224 ...input.args,
225 ...input.response.content,
226 };
227
228 return parseInput({
229 schema: input.inputSchema,
230 value: merged,
231 path: input.path,
232 });
233};
234
235const declineReasonFromResponse = (input: {
236 path: ToolPath;
237 response: ElicitationResponse;
238}): string => {
239 const reason =
240 input.response.content
241 && typeof input.response.content.reason === "string"
242 && input.response.content.reason.trim().length > 0
243 ? input.response.content.reason.trim()
244 : null;
245
246 if (reason) {
247 return reason;
248 }
249
250 return input.response.action === "cancel"
251 ? `Interaction cancelled for ${input.path}`
252 : `Interaction declined for ${input.path}`;
253};
254
255const resolveInteractionDecision = (input: {
256 path: ToolPath;
257 args: unknown;
258 inputSchema: unknown;
259 metadata?: ToolMetadata;
260 sourceKey: string;
261 context?: ToolInvocationContext;
262 decision: ToolInteractionDecision;
263 interactionId: string;
264 onElicitation?: OnElicitation;
265}): Effect.Effect<unknown, ToolInteractionPendingError | ToolInteractionDeniedError | Error> => {
266 if (input.decision.kind === "execute") {
267 return Effect.succeed(input.args);
268 }
269
270 if (input.decision.kind === "decline") {
271 return Effect.fail(
272 new ToolInteractionDeniedError({
273 path: input.path,
274 reason: input.decision.reason,
275 }),
276 );
277 }
278
279 if (!input.onElicitation) {
280 return Effect.fail(
281 new ToolInteractionPendingError({
282 path: input.path,
283 elicitation: input.decision.elicitation,
284 interactionId: input.interactionId,
285 }),
286 );
287 }
288
289 const elicitationRequest: ToolElicitationRequest = {
290 interactionId: input.interactionId,
291 path: input.path,
292 sourceKey: input.sourceKey,
293 args: input.args,
294 metadata: input.metadata,
295 context: input.context,
296 elicitation: input.decision.elicitation,
297 };
298
299 return input.onElicitation(elicitationRequest).pipe(
300 Effect.mapError(toError),
301 Effect.flatMap((response) => {
302 if (response.action !== "accept") {
303 return Effect.fail(
304 new ToolInteractionDeniedError({
305 path: input.path,
306 reason: declineReasonFromResponse({
307 path: input.path,
308 response,
309 }),
310 }),
311 );
312 }
313
314 return mergeAcceptedElicitationContent({
315 path: input.path,
316 args: input.args,
317 response,
318 inputSchema: input.inputSchema,
319 });
320 }),
321 );
322};
323
324export function wrapTool(input: {
325 tool: ExecutableTool;
326 metadata?: ToolMetadata;
327}): ToolDefinition {
328 return {
329 tool: input.tool,
330 metadata: input.metadata,
331 };
332}
333
334export const toTool = wrapTool;
335export const toExecutorTool = wrapTool;
336
337const isToolDefinition = (value: ToolInput): value is ToolDefinition =>
338 typeof value === "object" && value !== null && "tool" in value;
339
340const deriveSchemaValue = (value: unknown): unknown | undefined => {
341 if (value === undefined || value === null) {
342 return undefined;
343 }
344
345 try {
346 if (
347 (typeof value === "object" || typeof value === "function")
348 && value !== null
349 && "~standard" in value
350 ) {
351 return JSONSchema.make(value as any);
352 }
353
354 return value;
355 } catch {
356 return undefined;
357 }
358};
359
360const inferTypeFromSchemaValue = (
361 schema: unknown,
362 fallback: string,
363 maxLength: number = 240,
364): string => typeSignatureFromSchema(schema, fallback, maxLength);
365
366export function createToolsFromRecord(input: {
367 tools: Record<string, ExecutableTool>;
368 sourceKey?: string;
369}): ToolMap {
370 const { tools, sourceKey = "in_memory.tools" } = input;
371
372 return Object.fromEntries(
373 Object.entries(tools)
374 .sort(([left], [right]) => left.localeCompare(right))
375 .map(([path, tool]) => [
376 path,
377 wrapTool({
378 tool,
379 metadata: { sourceKey },
380 }),
381 ]),
382 ) as ToolMap;
383}
384
385const resolveToolsFromMap = (input: {
386 tools: ToolMap;
387 sourceKey?: string;
388}): ResolvedTool[] => {
389 const defaultSourceKey = input.sourceKey ?? "in_memory.tools";
390
391 return Object.entries(input.tools)
392 .sort(([left], [right]) => left.localeCompare(right))
393 .map(([path, value]) => {
394 const entry = isToolDefinition(value) ? value : { tool: value };
395 const metadata = entry.metadata
396 ? {
397 sourceKey: defaultSourceKey,
398 ...entry.metadata,
399 }
400 : { sourceKey: defaultSourceKey };
401
402 return {
403 path: asToolPath(path),
404 tool: entry.tool,
405 metadata,
406 } satisfies ResolvedTool;
407 });
408};
409
410export function toolDescriptorsFromTools(input: {
411 tools: ToolMap;
412 sourceKey?: string;
413}): ToolDescriptor[] {
414 const resolvedTools = resolveToolsFromMap({
415 tools: input.tools,
416 sourceKey: input.sourceKey,
417 });
418
419 return resolvedTools.map((entry) => {
420 const metadata = entry.metadata;
421 const definition = entry.tool;
422 const inputSchema =
423 metadata?.contract?.inputSchema
424 ?? deriveSchemaValue(definition.inputSchema)
425 ?? deriveSchemaValue(definition.parameters);
426 const outputSchema =
427 metadata?.contract?.outputSchema
428 ?? deriveSchemaValue(definition.outputSchema);
429 const contract = {
430 inputTypePreview:
431 metadata?.contract?.inputTypePreview ?? inferTypeFromSchemaValue(inputSchema, "unknown"),
432 outputTypePreview:
433 metadata?.contract?.outputTypePreview ?? inferTypeFromSchemaValue(outputSchema, "unknown"),
434 ...(inputSchema !== undefined ? { inputSchema } : {}),
435 ...(outputSchema !== undefined ? { outputSchema } : {}),
436 ...(metadata?.contract?.exampleInput !== undefined
437 ? { exampleInput: metadata.contract.exampleInput }
438 : {}),
439 ...(metadata?.contract?.exampleOutput !== undefined
440 ? { exampleOutput: metadata.contract.exampleOutput }
441 : {}),
442 } satisfies NonNullable<ToolDescriptor["contract"]>;
443
444 return {
445 path: entry.path,
446 sourceKey: metadata?.sourceKey ?? "in_memory.tools",
447 description: definition.description,
448 interaction: metadata?.interaction,
449 elicitation: metadata?.elicitation,
450 contract,
451 ...(metadata?.pluginKind ? { pluginKind: metadata.pluginKind } : {}),
452 ...(metadata?.pluginData !== undefined
453 ? { pluginData: metadata.pluginData }
454 : {}),
455 } satisfies ToolDescriptor;
456 });
457}
458
459const asInteractionIdPart = (value: string): string =>
460 value.replace(/[^a-zA-Z0-9._-]/g, "_");
461
462const createInteractionIdFactory = () => {
463 let sequence = 0;
464
465 return (input: { path: ToolPath; context?: ToolInvocationContext }): string => {
466 sequence += 1;
467
468 const runId =
469 typeof input.context?.runId === "string" && input.context.runId.length > 0
470 ? input.context.runId
471 : "run";
472 const callId =
473 typeof input.context?.callId === "string" && input.context.callId.length > 0
474 ? input.context.callId
475 : `call_${String(sequence)}`;
476
477 return `${asInteractionIdPart(runId)}:${asInteractionIdPart(callId)}:${asInteractionIdPart(String(input.path))}:${String(sequence)}`;
478 };
479};
480
481export const makeToolInvokerFromTools = (input: {
482 tools: ToolMap;
483 sourceKey?: string;
484 onToolInteraction?: OnToolInteraction;
485 onElicitation?: OnElicitation;
486}): ToolInvoker => {
487 const resolvedTools = resolveToolsFromMap({
488 tools: input.tools,
489 sourceKey: input.sourceKey,
490 });
491 const byPath = new Map(resolvedTools.map((entry) => [entry.path as string, entry]));
492 const nextInteractionId = createInteractionIdFactory();
493
494 return {
495 invoke: ({ path, args, context }) =>
496 Effect.gen(function* () {
497 const entry = byPath.get(path);
498 if (!entry) {
499 return yield* kernelCoreEffectError("tool-map", `Unknown tool path: ${path}`);
500 }
501
502 const parsedInput = yield* parseInput({
503 schema: entry.tool.inputSchema,
504 value: args,
505 path,
506 });
507
508 const decision = yield* evaluateInteractionDecision({
509 path: entry.path,
510 args: parsedInput,
511 metadata: entry.metadata,
512 sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools",
513 context,
514 onToolInteraction: input.onToolInteraction,
515 });
516
517 const interactionId =
518 decision.kind === "elicit" && decision.interactionId
519 ? decision.interactionId
520 : nextInteractionId({ path: entry.path, context });
521
522 const executableInput = yield* resolveInteractionDecision({
523 path: entry.path,
524 args: parsedInput,
525 inputSchema: entry.tool.inputSchema,
526 metadata: entry.metadata,
527 sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools",
528 context,
529 decision,
530 interactionId,
531 onElicitation: input.onElicitation,
532 });
533
534 return yield* Effect.tryPromise({
535 try: () =>
536 Promise.resolve(
537 entry.tool.execute(executableInput, {
538 path: entry.path,
539 sourceKey: entry.metadata?.sourceKey ?? "in_memory.tools",
540 metadata: entry.metadata,
541 invocation: context,
542 onElicitation: input.onElicitation,
543 }),
544 ),
545 catch: (cause) => kernelCoreEffectError("tool-map", `Tool execution error for ${entry.path}: ${String(cause)}`),
546 });
547 }),
548 };
549};
packages/kernel/core/src/types.ts+31−261
@@ -1,277 +1,42 @@
1import type { StandardSchemaV1 } from "@standard-schema/spec";1import type { StandardSchemaV1 } from "@standard-schema/spec";
2import type * as Effect from "effect/Effect";2import type * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4 3
4/** Branded tool path */
5export type ToolPath = string & { readonly __toolPath: unique symbol };5export type ToolPath = string & { readonly __toolPath: unique symbol };
6 6
7export const asToolPath = (value: string): ToolPath => value as ToolPath;
8
9/** Standard Schema alias */
7export type StandardSchema<Input = unknown, Output = unknown> =10export type StandardSchema<Input = unknown, Output = unknown> =
8 StandardSchemaV1<Input, Output>;11 StandardSchemaV1<Input, Output>;
9 12
10export type ElicitationFormRequest = {13/** A tool that can be invoked */
11 mode?: "form";14export interface Tool {
12 message: string;15 readonly path: ToolPath;
13 requestedSchema: Record<string, unknown>;16 readonly description?: string;
14};17 readonly inputSchema: StandardSchema;
15 18 readonly outputSchema?: StandardSchema;
16export type ElicitationUrlRequest = {19 readonly execute: (
17 mode: "url";20 input: unknown,
18 message: string;21 ) => unknown | Promise<unknown>;
19 url: string;
20 elicitationId: string;
21};
22
23export type ElicitationRequest =
24 | ElicitationFormRequest
25 | ElicitationUrlRequest;
26
27export type ElicitationAction = "accept" | "decline" | "cancel";
28
29export type ElicitationResponse = {
30 action: ElicitationAction;
31 content?: Record<string, unknown>;
32};
33
34export type ToolInvocationContext = {
35 runId?: string;
36 callId?: string;
37 scope?: string;
38 actor?: string;
39 [key: string]: unknown;
40};
41
42export type ToolInteractionRequest = {
43 path: ToolPath;
44 sourceKey: string;
45 args: unknown;
46 metadata?: ToolMetadata;
47 context?: ToolInvocationContext;
48 defaultElicitation: ElicitationRequest | null;
49};
50
51export type ToolInteractionDecision =
52 | { kind: "execute" }
53 | { kind: "decline"; reason: string }
54 | {
55 kind: "elicit";
56 elicitation: ElicitationRequest;
57 interactionId?: string;
58 };
59
60export type ToolElicitationRequest = {
61 interactionId: string;
62 path: ToolPath;
63 sourceKey: string;
64 args: unknown;
65 metadata?: ToolMetadata;
66 context?: ToolInvocationContext;
67 elicitation: ElicitationRequest;
68};
69
70export type OnToolInteraction = (
71 input: ToolInteractionRequest,
72) => Effect.Effect<ToolInteractionDecision, unknown>;
73
74export type OnElicitation = (
75 input: ToolElicitationRequest,
76) => Effect.Effect<ElicitationResponse, unknown>;
77
78export type ToolContract = {
79 inputTypePreview?: string;
80 outputTypePreview?: string;
81 inputSchema?: unknown;
82 outputSchema?: unknown;
83 exampleInput?: unknown;
84 exampleOutput?: unknown;
85};
86
87export const ToolContractSchema = Schema.Struct({
88 inputTypePreview: Schema.optional(Schema.String),
89 outputTypePreview: Schema.optional(Schema.String),
90 inputSchema: Schema.optional(Schema.Unknown),
91 outputSchema: Schema.optional(Schema.Unknown),
92 exampleInput: Schema.optional(Schema.Unknown),
93 exampleOutput: Schema.optional(Schema.Unknown),
94});
95
96export type ToolMetadata = {
97 interaction?: "auto" | "required";
98 elicitation?: ElicitationRequest;
99 contract?: ToolContract;
100 sourceKey?: string;
101 pluginKind?: string;
102 pluginData?: unknown;
103};
104
105export type ToolExecutionContext = {
106 path: ToolPath;
107 sourceKey: string;
108 metadata?: ToolMetadata;
109 invocation?: ToolInvocationContext;
110 onElicitation?: OnElicitation;
111};
112
113export type ExecutableTool = {
114 description?: string;
115 inputSchema: StandardSchema;
116 outputSchema?: StandardSchema;
117 parameters?: StandardSchema;
118 execute: (...args: any[]) => unknown;
119};
120
121export const unknownInputSchema: StandardSchema = {
122 "~standard": {
123 version: 1,
124 vendor: "@executor/codemode-core",
125 validate: (value: unknown) => ({
126 value,
127 }),
128 },
129};
130
131
132export type ToolDefinition = {
133 tool: ExecutableTool;
134 metadata?: ToolMetadata;
135};
136
137export type ToolInput = ExecutableTool | ToolDefinition;
138
139export type ToolMap = Record<string, ToolInput>;
140
141export type ToolDescriptor = {
142 path: ToolPath;
143 sourceKey: string;
144 description?: string;
145 interaction?: "auto" | "required";
146 elicitation?: ElicitationRequest;
147 contract?: ToolContract;
148 pluginKind?: string;
149 pluginData?: unknown;
150};
151
152export const ToolDescriptorSchema = Schema.Struct({
153 path: Schema.String,
154 sourceKey: Schema.String,
155 description: Schema.optional(Schema.String),
156 interaction: Schema.optional(
157 Schema.Union(Schema.Literal("auto"), Schema.Literal("required")),
158 ),
159 elicitation: Schema.optional(Schema.Unknown),
160 contract: Schema.optional(ToolContractSchema),
161 pluginKind: Schema.optional(Schema.String),
162 pluginData: Schema.optional(Schema.Unknown),
163});
164
165export type ToolNamespace = {
166 namespace: string;
167 displayName?: string;
168 toolCount?: number;
169};
170
171export const ToolNamespaceSchema = Schema.Struct({
172 namespace: Schema.String,
173 displayName: Schema.optional(Schema.String),
174 toolCount: Schema.optional(Schema.Number),
175});
176
177export type SearchHit = {
178 path: ToolPath;
179 score: number;
180};
181
182export type ToolCatalogEntry = {
183 descriptor: ToolDescriptor;
184 namespace?: string;
185 searchText?: string;
186 score?: (queryTokens: readonly string[]) => number;
187};
188
189export interface ToolCatalog {
190 listNamespaces(input: {
191 limit: number;
192 }): Effect.Effect<readonly ToolNamespace[], unknown>;
193
194 listTools(input: {
195 namespace?: string;
196 query?: string;
197 limit: number;
198 includeSchemas?: boolean;
199 }): Effect.Effect<readonly ToolDescriptor[], unknown>;
200
201 getToolByPath(input: {
202 path: ToolPath;
203 includeSchemas: boolean;
204 }): Effect.Effect<ToolDescriptor | null, unknown>;
205
206 searchTools(input: {
207 query: string;
208 namespace?: string;
209 limit: number;
210 }): Effect.Effect<readonly SearchHit[], unknown>;
211}22}
212 23
213export type CatalogPrimitive = {24/** Invoke a tool by path */
214 namespaces(input: {25export interface ToolInvoker {
215 limit?: number;26 invoke(input: {
216 }): Effect.Effect<27 path: string;
217 { namespaces: readonly ToolNamespace[] },28 args: unknown;
218 unknown29 }): Effect.Effect<unknown, unknown>;
219 >;30}
220
221 tools(input: {
222 namespace?: string;
223 query?: string;
224 limit?: number;
225 includeSchemas?: boolean;
226 }): Effect.Effect<{ results: readonly ToolDescriptor[] }, unknown>;
227};
228
229export type DescribePrimitive = {
230 tool(input: {
231 path: ToolPath;
232 includeSchemas?: boolean;
233 }): Effect.Effect<ToolDescriptor | null, unknown>;
234};
235
236export type DiscoverPrimitive = (input: {
237 query: string;
238 sourceKey?: string;
239 limit?: number;
240 includeSchemas?: boolean;
241}) => Effect.Effect<
242 {
243 bestPath: ToolPath | null;
244 results: readonly (Record<string, unknown> & {
245 path: ToolPath;
246 score: number;
247 })[];
248 total: number;
249 },
250 unknown
251>;
252
253export type DiscoveryPrimitives = {
254 catalog?: CatalogPrimitive;
255 describe?: DescribePrimitive;
256 discover?: DiscoverPrimitive;
257};
258 31
32/** Result of executing code in a sandbox */
259export type ExecuteResult = {33export type ExecuteResult = {
260 result: unknown;34 result: unknown;
261 error?: string;35 error?: string;
262 logs?: string[];36 logs?: string[];
263};37};
264 38
265export type ToolInvocationInput = {39/** Executes code in a sandboxed runtime with tool access */
266 path: string;
267 args: unknown;
268 context?: ToolInvocationContext;
269};
270
271export interface ToolInvoker {
272 invoke(input: ToolInvocationInput): Effect.Effect<unknown, unknown>;
273}
274
275export interface CodeExecutor {40export interface CodeExecutor {
276 execute(41 execute(
277 code: string,42 code: string,
@@ -279,8 +44,13 @@ export interface CodeExecutor { · 1 unmodified line
279 ): Effect.Effect<ExecuteResult, unknown>;44 ): Effect.Effect<ExecuteResult, unknown>;
280}45}
281 46
282export type CodeToolOutput = {47/** Accept-anything schema for tools with no input validation */
283 code: string;48export const unknownInputSchema: StandardSchema = {
284 result: unknown;49 "~standard": {
285 logs?: string[];50 version: 1,
51 vendor: "@executor/codemode-core",
52 validate: (value: unknown) => ({
53 value,
54 }),
55 },
286};56};
packages/kernel/core/src/validation.ts+89−0
@@ -0,0 +1,89 @@
1import type { StandardSchemaV1 } from "@standard-schema/spec";
2import * as Effect from "effect/Effect";
3
4import { kernelCoreEffectError } from "./effect-errors";
5
6const getSchemaValidator = (
7 schema: unknown,
8): ((
9 value: unknown,
10) =>
11 | StandardSchemaV1.Result<unknown>
12 | Promise<StandardSchemaV1.Result<unknown>>) | null => {
13 if (!schema || (typeof schema !== "object" && typeof schema !== "function")) {
14 return null;
15 }
16
17 const standard = (schema as { "~standard"?: unknown })["~standard"];
18 if (!standard || typeof standard !== "object") {
19 return null;
20 }
21
22 const validate = (standard as { validate?: unknown }).validate;
23 return typeof validate === "function"
24 ? (validate as (
25 value: unknown,
26 ) =>
27 | StandardSchemaV1.Result<unknown>
28 | Promise<StandardSchemaV1.Result<unknown>>)
29 : null;
30};
31
32const formatIssuePath = (
33 path: ReadonlyArray<PropertyKey | StandardSchemaV1.PathSegment> | undefined,
34): string => {
35 if (!path || path.length === 0) {
36 return "$";
37 }
38
39 return path
40 .map((segment) =>
41 typeof segment === "object" && segment !== null && "key" in segment
42 ? String(segment.key)
43 : String(segment),
44 )
45 .join(".");
46};
47
48const formatIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string =>
49 issues
50 .map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`)
51 .join("; ");
52
53/** Validate a value against a Standard Schema */
54export const validateInput = (input: {
55 schema: unknown;
56 value: unknown;
57 path: string;
58}): Effect.Effect<unknown, Error> => {
59 const validate = getSchemaValidator(input.schema);
60 if (!validate) {
61 return Effect.fail(
62 kernelCoreEffectError(
63 "validation",
64 `Tool ${input.path} has no Standard Schema validator on inputSchema`,
65 ),
66 );
67 }
68
69 return Effect.tryPromise({
70 try: () => Promise.resolve(validate(input.value)),
71 catch: (cause) =>
72 kernelCoreEffectError(
73 "validation",
74 `Validation error for ${input.path}: ${String(cause)}`,
75 ),
76 }).pipe(
77 Effect.flatMap((result) => {
78 if ("issues" in result && result.issues) {
79 return Effect.fail(
80 kernelCoreEffectError(
81 "validation",
82 `Input validation failed for ${input.path}: ${formatIssues(result.issues)}`,
83 ),
84 );
85 }
86 return Effect.succeed(result.value);
87 }),
88 );
89};
packages/kernel/ir/package.json+1−5
@@ -4,10 +4,7 @@ · 3 unmodified lines
4 "type": "module",4 "type": "module",
5 "version": "0.0.0",5 "version": "0.0.0",
6 "exports": {6 "exports": {
7 ".": "./src/index.ts",7 ".": "./src/index.ts"
8 "./catalog": "./src/catalog.ts",
9 "./ids": "./src/ids.ts",
10 "./model": "./src/model.ts"
11 },8 },
12 "scripts": {9 "scripts": {
13 "typecheck": "bunx tsc --noEmit -p tsconfig.json",10 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
@@ -15,7 +12,6 @@ · 1 unmodified line
15 "test:watch": "vitest"12 "test:watch": "vitest"
16 },13 },
17 "dependencies": {14 "dependencies": {
18 "@executor/codemode-core": "workspace:*",
19 "effect": "catalog:"15 "effect": "catalog:"
20 },16 },
21 "devDependencies": {17 "devDependencies": {
packages/kernel/ir/src/index.ts+8−0
@@ -0,0 +1,8 @@
1export {
2 ToolRegistration,
3 SerializedCatalog,
4 type LiveToolRegistration,
5 type LiveCatalog,
6} from "./registry";
7
8export { serialize, deserializeToJsonSchema } from "./serialize";
packages/kernel/ir/src/registry.ts+34−0
@@ -0,0 +1,34 @@
1import { Schema } from "effect";
2import type { JsonSchema7Root } from "effect/JSONSchema";
3
4export class ToolRegistration extends Schema.Class<ToolRegistration>("ToolRegistration")({
5 path: Schema.String,
6 description: Schema.optional(Schema.String),
7 tags: Schema.optional(Schema.Array(Schema.String)),
8 namespace: Schema.optional(Schema.String),
9 input: Schema.optional(Schema.String),
10 output: Schema.optional(Schema.String),
11 error: Schema.optional(Schema.String),
12}) {}
13
14export class SerializedCatalog extends Schema.Class<SerializedCatalog>("SerializedCatalog")({
15 version: Schema.Literal("v4.1"),
16 types: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
17 tools: Schema.Array(ToolRegistration),
18}) {}
19
20export interface LiveToolRegistration {
21 readonly path: string;
22 readonly description?: string;
23 readonly tags?: readonly string[];
24 readonly namespace?: string;
25 readonly input?: Schema.Schema.AnyNoContext;
26 readonly output?: Schema.Schema.AnyNoContext;
27 readonly error?: Schema.Schema.AnyNoContext;
28}
29
30export interface LiveCatalog {
31 readonly version: "v4.1";
32 readonly types: Record<string, JsonSchema7Root>;
33 readonly tools: readonly LiveToolRegistration[];
34}
packages/kernel/ir/src/serialize.ts+87−0
@@ -0,0 +1,87 @@
1import { JSONSchema, SchemaAST } from "effect";
2import { Option } from "effect";
3import type { JsonSchema7, JsonSchema7Root } from "effect/JSONSchema";
4import type { LiveCatalog, SerializedCatalog } from "./registry";
5
6function getSchemaIdentifier(schema: { ast: SchemaAST.AST }): string | undefined {
7 return Option.getOrUndefined(SchemaAST.getJSONIdentifier(schema.ast));
8}
9
10export function serialize(catalog: LiveCatalog): typeof SerializedCatalog.Type {
11 const defs: Record<string, JsonSchema7> = {};
12
13 function schemaToRef(schema: { ast: SchemaAST.AST } | undefined): string | undefined {
14 if (!schema) return undefined;
15
16 const identifier = getSchemaIdentifier(schema);
17 const jsonSchema = JSONSchema.fromAST(schema.ast, {
18 definitions: defs,
19 target: "jsonSchema2020-12",
20 });
21
22 if (identifier) {
23 return identifier;
24 }
25
26 // Inline schema without an identifier — store under a generated key
27 const key = `__inline_${Object.keys(defs).length}`;
28 defs[key] = jsonSchema;
29 return key;
30 }
31
32 const tools = catalog.tools.map((tool) => ({
33 path: tool.path,
34 description: tool.description,
35 tags: tool.tags ? [...tool.tags] : undefined,
36 namespace: tool.namespace,
37 input: schemaToRef(tool.input),
38 output: schemaToRef(tool.output),
39 error: schemaToRef(tool.error),
40 }));
41
42 return {
43 version: "v4.1" as const,
44 types: defs as Record<string, unknown>,
45 tools,
46 };
47}
48
49export function deserializeToJsonSchema(
50 serialized: typeof SerializedCatalog.Type,
51): {
52 tools: ReadonlyArray<{
53 path: string;
54 description?: string;
55 tags?: ReadonlyArray<string>;
56 namespace?: string;
57 input?: JsonSchema7Root;
58 output?: JsonSchema7Root;
59 error?: JsonSchema7Root;
60 }>;
61 types: Record<string, unknown>;
62} {
63 const types = serialized.types;
64
65 function resolveRef(ref: string | undefined): JsonSchema7Root | undefined {
66 if (!ref) return undefined;
67 const schema = types[ref];
68 if (!schema) return undefined;
69 return {
70 ...(schema as JsonSchema7Root),
71 $defs: types as Record<string, JsonSchema7>,
72 };
73 }
74
75 return {
76 tools: serialized.tools.map((tool) => ({
77 path: tool.path,
78 description: tool.description,
79 tags: tool.tags,
80 namespace: tool.namespace,
81 input: resolveRef(tool.input),
82 output: resolveRef(tool.output),
83 error: resolveRef(tool.error),
84 })),
85 types,
86 };
87}
packages/kernel/runtime-quickjs/src/index.test.ts+123−257
@@ -1,314 +1,180 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import {
6 allowAllToolInteractions,
7 makeToolInvokerFromTools,
8 toExecutorTool,
9} from "@executor/codemode-core";
10 3
4import type { ToolInvoker } from "@executor/codemode-core";
11import { makeQuickJsExecutor } from "./index";5import { makeQuickJsExecutor } from "./index";
12 6
13const numberPairInputSchema = Schema.standardSchemaV1(7const makeTestInvoker = (
14 Schema.Struct({8 handlers: Record<string, (args: unknown) => unknown>,
15 a: Schema.Number,9): ToolInvoker => ({
16 b: Schema.Number,10 invoke: ({ path, args }) => {
17 }),11 const handler = handlers[path];
18);12 if (!handler) {
19 13 return Effect.fail(new Error(`Unknown tool: ${path}`));
20const messageInputSchema = Schema.standardSchemaV1(14 }
21 Schema.Struct({15 return Effect.try(() => handler(args));
22 message: Schema.String,
23 }),
24);
25
26const discoverInputSchema = Schema.standardSchemaV1(
27 Schema.Struct({
28 query: Schema.String,
29 limit: Schema.optional(Schema.Number),
30 }),
31);
32
33const describeToolInputSchema = Schema.standardSchemaV1(
34 Schema.Struct({
35 path: Schema.String,
36 includeSchemas: Schema.optional(Schema.Boolean),
37 }),
38);
39
40const repoInputSchema = Schema.standardSchemaV1(
41 Schema.Struct({
42 owner: Schema.String,
43 repo: Schema.String,
44 }),
45);
46
47const tools = {
48 "math.add": {
49 description: "Add two numbers",
50 inputSchema: numberPairInputSchema,
51 execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
52 },16 },
53 "notifications.send": toExecutorTool({17});
54 tool: {
55 description: "Send a message",
56 inputSchema: messageInputSchema,
57 execute: ({ message }: { message: string }) => ({
58 delivered: true,
59 message,
60 }),
61 },
62 metadata: {
63 interaction: "required",
64 },
65 }),
66};
67
68describe("runtime-quickjs", () => {
69 it.effect("executes simple code and returns result", () =>
70 Effect.gen(function* () {
71 const executor = makeQuickJsExecutor();
72 const toolInvoker = makeToolInvokerFromTools({ tools });
73
74 const output = yield* executor.execute(
75 "return 1 + 2;",
76 toolInvoker,
77 );
78
79 expect(output.result).toBe(3);
80 expect(output.error).toBeUndefined();
81 }),
82 );
83
84 it.effect("executes code with tool calls", () =>
85 Effect.gen(function* () {
86 const executor = makeQuickJsExecutor();
87 const toolInvoker = makeToolInvokerFromTools({
88 tools,
89 onToolInteraction: allowAllToolInteractions,
90 });
91
92 const output = yield* executor.execute(
93 [
94 "const math = await tools.math.add({ a: 19, b: 23 });",
95 "await tools.notifications.send({ message: `sum is ${math.sum}` });",
96 "return math;",
97 ].join("\n"),
98 toolInvoker,
99 );
100
101 expect(output.result).toEqual({ sum: 42 });
102 expect(output.error).toBeUndefined();
103 }),
104 );
105
106 it.effect("captures console output in logs", () =>
107 Effect.gen(function* () {
108 const executor = makeQuickJsExecutor();
109 const toolInvoker = makeToolInvokerFromTools({ tools });
110
111 const output = yield* executor.execute(
112 [
113 'console.log("hello from quickjs");',
114 'console.warn("a warning");',
115 'console.error("an error");',
116 "return 42;",
117 ].join("\n"),
118 toolInvoker,
119 );
120 18
121 expect(output.result).toBe(42);19const executor = makeQuickJsExecutor({ timeoutMs: 5_000 });
122 expect(output.logs).toContain("[log] hello from quickjs");
123 expect(output.logs).toContain("[warn] a warning");
124 expect(output.logs).toContain("[error] an error");
125 }),
126 );
127 20
128 it.effect("reports execution errors without crashing", () =>21describe("quickjs executor", () => {
22 it.effect("runs plain code", () =>
129 Effect.gen(function* () {23 Effect.gen(function* () {
130 const executor = makeQuickJsExecutor();24 const result = yield* executor.execute(
131 const toolInvoker = makeToolInvokerFromTools({ tools });25 `return 1 + 2`,
132 26 makeTestInvoker({}),
133 const output = yield* executor.execute(
134 'throw new Error("boom");',
135 toolInvoker,
136 );27 );
137 28 expect(result.result).toBe(3);
138 expect(output.result).toBeNull();29 expect(result.error).toBeUndefined();
139 expect(output.error).toContain("boom");
140 }),30 }),
141 );31 );
142 32
143 it.effect("handles tool call errors gracefully", () =>33 it.effect("invokes a tool and returns its result", () =>
144 Effect.gen(function* () {34 Effect.gen(function* () {
145 const failingTools = {35 const invoker = makeTestInvoker({
146 "broken.thing": {36 "math.add": (args: any) => ({
147 description: "Always fails",37 sum: args.a + args.b,
148 inputSchema: Schema.standardSchemaV1(Schema.Struct({})),38 }),
149 execute: () => {
150 throw new Error("tool is broken");
151 },
152 },
153 };
154
155 const executor = makeQuickJsExecutor();
156 const toolInvoker = makeToolInvokerFromTools({
157 tools: failingTools,
158 });39 });
159 40
160 const output = yield* executor.execute(41 const result = yield* executor.execute(
161 "return await tools.broken.thing({});",42 `
162 toolInvoker,43 const res = await tools.math.add({ a: 5, b: 3 });
44 return res.sum;
45 `,
46 invoker,
163 );47 );
164 48
165 expect(output.result).toBeNull();49 expect(result.error).toBeUndefined();
166 expect(output.error).toContain("tool is broken");50 expect(result.result).toBe(8);
167 }),51 }),
168 );52 );
169 53
170 it.effect("respects timeout", () =>54 it.effect("invokes multiple tools in sequence", () =>
171 Effect.gen(function* () {55 Effect.gen(function* () {
172 const executor = makeQuickJsExecutor({56 const invoker = makeTestInvoker({
173 timeoutMs: 250,57 "users.get": (args: any) => ({
58 id: args.id,
59 name: `User ${args.id}`,
60 }),
61 "users.greet": (args: any) => ({
62 message: `Hello, ${args.name}!`,
63 }),
174 });64 });
175 const toolInvoker = makeToolInvokerFromTools({ tools });
176 65
177 const output = yield* executor.execute(66 const result = yield* executor.execute(
178 "while (true) {}",67 `
179 toolInvoker,68 const user = await tools.users.get({ id: 42 });
69 const greeting = await tools.users.greet({ name: user.name });
70 return greeting.message;
71 `,
72 invoker,
180 );73 );
181 74
182 expect(output.result).toBeNull();75 expect(result.error).toBeUndefined();
183 expect(output.error).toContain("timed out");76 expect(result.result).toBe("Hello, User 42!");
184 }),77 }),
185 );78 );
186 79
187 it.effect("network access is denied by default", () =>80 it.effect("handles tool errors", () =>
188 Effect.gen(function* () {81 Effect.gen(function* () {
189 const executor = makeQuickJsExecutor();82 const invoker = makeTestInvoker({
190 const toolInvoker = makeToolInvokerFromTools({ tools });83 "db.query": () => {
84 throw new Error("connection refused");
85 },
86 });
191 87
192 const output = yield* executor.execute(88 const result = yield* executor.execute(
193 'await fetch("https://example.com"); return 1;',89 `
194 toolInvoker,90 try {
91 await tools.db.query({ sql: "SELECT 1" });
92 return "should not reach";
93 } catch (e) {
94 return "caught: " + e.message;
95 }
96 `,
97 invoker,
195 );98 );
196 99
197 expect(output.result).toBeNull();100 expect(result.error).toBeUndefined();
198 expect(output.error).toContain("fetch is disabled in QuickJS executor");101 expect(result.result).toContain("caught:");
199 }),102 }),
200 );103 );
201 104
202 it.effect("multiple sequential tool calls work correctly", () =>105 it.effect("handles unknown tool path", () =>
203 Effect.gen(function* () {106 Effect.gen(function* () {
204 const executor = makeQuickJsExecutor();107 const invoker = makeTestInvoker({});
205 const toolInvoker = makeToolInvokerFromTools({ tools });108
206 109 const result = yield* executor.execute(
207 const output = yield* executor.execute(110 `
208 [111 try {
209 "const r1 = await tools.math.add({ a: 1, b: 2 });",112 await tools.nonexistent.thing({ x: 1 });
210 "const r2 = await tools.math.add({ a: r1.sum, b: 10 });",113 return "should not reach";
211 "const r3 = await tools.math.add({ a: r2.sum, b: 100 });",114 } catch (e) {
212 "return r3;",115 return "caught: " + e.message;
213 ].join("\n"),116 }
214 toolInvoker,117 `,
118 invoker,
215 );119 );
216 120
217 expect(output.result).toEqual({ sum: 113 });121 expect(result.error).toBeUndefined();
218 expect(output.error).toBeUndefined();122 expect(result.result).toContain("caught:");
219 }),123 }),
220 );124 );
221 125
222 it.effect("does not expose internal executor bridge globals to user code", () =>126 it.effect("captures console.log output", () =>
223 Effect.gen(function* () {127 Effect.gen(function* () {
224 const executor = makeQuickJsExecutor();128 const result = yield* executor.execute(
225 const toolInvoker = makeToolInvokerFromTools({ tools });129 `
226 130 console.log("hello from sandbox");
227 const output = yield* executor.execute(131 console.warn("a warning");
228 [132 return "done";
229 "return {",133 `,
230 " hasInvokeTool: typeof globalThis.__executor_invokeTool !== 'undefined',",134 makeTestInvoker({}),
231 " hasLogBridge: typeof globalThis.__executor_log !== 'undefined',",
232 " globalKeys: Object.keys(globalThis).sort(),",
233 "};",
234 ].join("\n"),
235 toolInvoker,
236 );135 );
237 136
238 expect(output.result).toEqual({137 expect(result.error).toBeUndefined();
239 hasInvokeTool: false,138 expect(result.result).toBe("done");
240 hasLogBridge: false,139 expect(result.logs).toContainEqual("[log] hello from sandbox");
241 globalKeys: [],140 expect(result.logs).toContainEqual("[warn] a warning");
242 });
243 expect(output.error).toBeUndefined();
244 }),141 }),
245 );142 );
246 143
247 it.effect("supports the documented discovery workflow shape", () =>144 it.effect("passes tool result into next tool call", () =>
248 Effect.gen(function* () {145 Effect.gen(function* () {
249 const executor = makeQuickJsExecutor();146 const invoker = makeTestInvoker({
250 const toolInvoker = makeToolInvokerFromTools({147 "stripe.customers.list": () => ({
251 tools: {148 data: [
252 discover: {149 { id: "cus_1", email: "alice@example.com" },
253 inputSchema: discoverInputSchema,150 { id: "cus_2", email: "bob@example.com" },
254 execute: () => ({151 ],
255 bestPath: "github.issues.list",152 }),
256 results: [153 "stripe.invoices.create": (args: any) => ({
257 {154 id: "inv_1",
258 path: "github.issues.list",155 customer: args.customer,
259 score: 0.99,156 amount: args.amount,
260 },157 }),
261 ],
262 total: 1,
263 }),
264 },
265 "describe.tool": {
266 inputSchema: describeToolInputSchema,
267 execute: ({ path }: { path: string; includeSchemas?: boolean }) => ({
268 path,
269 contract: {
270 inputTypePreview: "{ owner: string; repo: string }",
271 },
272 }),
273 },
274 "github.issues.list": {
275 inputSchema: repoInputSchema,
276 execute: ({ owner, repo }: { owner: string; repo: string }) => ({
277 owner,
278 repo,
279 issues: [{ id: "issue_1" }],
280 }),
281 },
282 },
283 });158 });
284 159
285 const output = yield* executor.execute(160 const result = yield* executor.execute(
286 [161 `
287 'const { results, bestPath } = await tools.discover({ query: "github issues", limit: 5 });',162 const customers = await tools.stripe.customers.list();
288 "const path = bestPath ?? results[0]?.path;",163 const invoice = await tools.stripe.invoices.create({
289 'if (!path) throw new Error("No matching tools found.");',164 customer: customers.data[0].id,
290 "const detail = await tools.describe.tool({ path, includeSchemas: true });",165 amount: 5000,
291 'const issues = await tools.github.issues.list({ owner: "openai", repo: "codex" });',166 });
292 "return { path, detail, issues };",167 return invoice;
293 ].join("\n"),168 `,
294 toolInvoker,169 invoker,
295 );170 );
296 171
297 expect(output.result).toEqual({172 expect(result.error).toBeUndefined();
298 path: "github.issues.list",173 expect(result.result).toEqual({
299 detail: {174 id: "inv_1",
300 path: "github.issues.list",175 customer: "cus_1",
301 contract: {176 amount: 5000,
302 inputTypePreview: "{ owner: string; repo: string }",
303 },
304 },
305 issues: {
306 owner: "openai",
307 repo: "codex",
308 issues: [{ id: "issue_1" }],
309 },
310 });177 });
311 expect(output.error).toBeUndefined();
312 }),178 }),
313 );179 );
314});180});