Skip to content

Commit e43d570

Browse files
romanonthegoclaude
andcommitted
Add @theseus.run/core package with Tool primitive
First public release of theseus-core. Ships the Tool module only: - Tool<I,O> — typed, all-Effect pipeline (decode → execute → retry → validate → encode) - defineTool / defineToolEffect — ergonomic and full-Effect authoring APIs - SchemaAdapter with fromZod and fromEffectSchema adapters - callTool execution pipeline with automatic retry on retriable errors - ToolSafety levels (readonly/write/destructive) and capability helpers - 4 tagged error types for structured error handling 65 tests, full typecheck, CI coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d7cad7 commit e43d570

12 files changed

Lines changed: 1659 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
runs-on: ubuntu-latest
1515
strategy:
1616
matrix:
17-
package: [jsx-md, jsx-md-beautiful-mermaid]
17+
package: [theseus-core, jsx-md, jsx-md-beautiful-mermaid]
1818
steps:
1919
- uses: actions/checkout@v6.0.2
2020
- uses: oven-sh/setup-bun@v2.2.0

bun.lock

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/theseus-core/package.json

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
{
2+
"name": "@theseus.run/core",
3+
"version": "0.1.0",
4+
"description": "Typed primitives for LLM agent systems. Start with Tool — the boundary between AI reasoning and the world.",
5+
"type": "module",
6+
"author": "Roman Dubinin <romanonthego@gmail.com> (https://romanonthego.com)",
7+
"license": "MIT",
8+
"repository": {
9+
"type": "git",
10+
"url": "git+https://github.com/theseus-run/theseus.git",
11+
"directory": "packages/theseus-core"
12+
},
13+
"bugs": {
14+
"url": "https://github.com/theseus-run/theseus/issues"
15+
},
16+
"homepage": "https://theseus.run",
17+
"keywords": [
18+
"llm",
19+
"tools",
20+
"agents",
21+
"effect",
22+
"zod",
23+
"schema",
24+
"ai",
25+
"function-calling",
26+
"tool-use"
27+
],
28+
"engines": {
29+
"bun": ">=1.0.0",
30+
"node": ">=18.0.0"
31+
},
32+
"publishConfig": {
33+
"access": "public"
34+
},
35+
"scripts": {
36+
"build": "bun build --target=node --sourcemap=linked --splitting --external=effect --external=zod --outdir=dist src/tool/index.ts src/tool/run.ts src/tool/zod.ts src/tool/effect-schema.ts",
37+
"test": "bun test",
38+
"typecheck": "tsc --noEmit"
39+
},
40+
"files": [
41+
"src/tool",
42+
"dist"
43+
],
44+
"sideEffects": false,
45+
"types": "./src/tool/index.ts",
46+
"exports": {
47+
"./Tool": {
48+
"bun": "./src/tool/index.ts",
49+
"types": "./src/tool/index.ts",
50+
"import": "./dist/index.js"
51+
},
52+
"./Tool/run": {
53+
"bun": "./src/tool/run.ts",
54+
"types": "./src/tool/run.ts",
55+
"import": "./dist/run.js"
56+
},
57+
"./Tool/zod": {
58+
"bun": "./src/tool/zod.ts",
59+
"types": "./src/tool/zod.ts",
60+
"import": "./dist/zod.js"
61+
},
62+
"./Tool/effect-schema": {
63+
"bun": "./src/tool/effect-schema.ts",
64+
"types": "./src/tool/effect-schema.ts",
65+
"import": "./dist/effect-schema.js"
66+
}
67+
},
68+
"dependencies": {
69+
"effect": "4.0.0-beta.33",
70+
"zod": "4.3.6"
71+
},
72+
"devDependencies": {
73+
"@types/bun": "1.3.10",
74+
"bun-types": "1.3.10",
75+
"typescript": "5.9.3"
76+
}
77+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Effect, Schema } from "effect";
3+
import { fromEffectSchema } from "./effect-schema.ts";
4+
import { defineTool } from "./index.ts";
5+
6+
describe("fromEffectSchema", () => {
7+
test("generates JSON schema from Effect Schema struct", () => {
8+
const adapter = fromEffectSchema(Schema.Struct({ path: Schema.String }));
9+
expect(adapter.json).toHaveProperty("type", "object");
10+
expect(adapter.json).toHaveProperty("properties");
11+
const props = adapter.json["properties"] as Record<string, unknown>;
12+
expect(props).toHaveProperty("path");
13+
});
14+
15+
test("decode parses valid input", () => {
16+
const adapter = fromEffectSchema(Schema.Struct({ a: Schema.Number, b: Schema.Number }));
17+
const result = adapter.decode({ a: 1, b: 2 });
18+
expect(result).toEqual({ a: 1, b: 2 });
19+
});
20+
21+
test("decode throws on invalid input", () => {
22+
const adapter = fromEffectSchema(Schema.Struct({ path: Schema.String }));
23+
expect(() => adapter.decode({ path: 123 })).toThrow();
24+
});
25+
26+
test("works end-to-end with defineTool", async () => {
27+
const tool = defineTool({
28+
name: "greet",
29+
description: "Greet someone",
30+
inputSchema: fromEffectSchema(Schema.Struct({ name: Schema.String })),
31+
safety: "readonly",
32+
capabilities: [],
33+
execute: ({ name }, _ctx) => Effect.succeed(`hello ${name}`),
34+
encode: (s) => s,
35+
});
36+
37+
expect(tool.name).toBe("greet");
38+
expect(tool.inputSchema).toHaveProperty("type", "object");
39+
const decoded = await Effect.runPromise(tool.decode({ name: "world" }));
40+
expect(decoded.name).toBe("world");
41+
const output = await Effect.runPromise(tool.execute(decoded));
42+
const encoded = await Effect.runPromise(tool.encode(output));
43+
expect(encoded).toBe("hello world");
44+
});
45+
46+
test("handles optional fields", () => {
47+
const adapter = fromEffectSchema(
48+
Schema.Struct({
49+
required: Schema.String,
50+
optional: Schema.optional(Schema.String),
51+
}),
52+
);
53+
const props = adapter.json["properties"] as Record<string, unknown>;
54+
expect(props).toHaveProperty("required");
55+
56+
expect(adapter.decode({ required: "yes" })).toHaveProperty("required", "yes");
57+
const both = adapter.decode({ required: "yes", optional: "also" });
58+
expect(both).toHaveProperty("required", "yes");
59+
expect(both).toHaveProperty("optional", "also");
60+
});
61+
62+
test("includes required array in json", () => {
63+
const adapter = fromEffectSchema(Schema.Struct({ name: Schema.String, age: Schema.Number }));
64+
expect(adapter.json).toHaveProperty("required");
65+
const req = adapter.json["required"] as string[];
66+
expect(req).toContain("name");
67+
expect(req).toContain("age");
68+
});
69+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Effect Schema adapter for Tool — converts an Effect Schema into
3+
* a SchemaAdapter for use with defineTool.
4+
*
5+
* const tool = defineTool({
6+
* name: "readFile",
7+
* description: "Read a file",
8+
* inputSchema: fromEffectSchema(Schema.Struct({ path: Schema.String })),
9+
* safety: "readonly",
10+
* capabilities: ["fs.read"],
11+
* execute: ({ path }, { fail }) =>
12+
* Effect.tryPromise(() => Bun.file(path).text()).pipe(
13+
* Effect.mapError((e) => fail(`Cannot read: ${path}`, e)),
14+
* ),
15+
* serialize: (s) => s,
16+
* })
17+
*/
18+
import { Schema } from "effect";
19+
import type { SchemaAdapter } from "./index.ts";
20+
21+
/** Convert an Effect Schema into a SchemaAdapter for defineTool. */
22+
export const fromEffectSchema = <I>(schema: Schema.Schema<I>): SchemaAdapter<I> => {
23+
// biome-ignore lint/suspicious/noExplicitAny: Effect v4 toJsonSchemaDocument needs Top constraint
24+
const doc = Schema.toJsonSchemaDocument(schema as any);
25+
const root = doc.schema as Record<string, unknown>;
26+
const defs = doc.definitions;
27+
28+
// Inline $ref if definitions exist — most LLM APIs don't resolve $ref
29+
let properties = (root["properties"] as Record<string, unknown>) ?? {};
30+
const required = (root["required"] as ReadonlyArray<string>) ?? [];
31+
32+
if (defs && Object.keys(defs).length > 0) {
33+
properties = inlineRefs(properties, defs);
34+
}
35+
36+
return {
37+
json: { type: "object", properties, required },
38+
// biome-ignore lint/suspicious/noExplicitAny: Effect v4 decodeUnknownSync needs Top constraint
39+
decode: (raw: unknown) => Schema.decodeUnknownSync(schema as any)(raw) as I,
40+
};
41+
};
42+
43+
/** Replace $ref pointers with inline definitions. */
44+
const inlineRefs = (
45+
properties: Record<string, unknown>,
46+
definitions: Record<string, unknown>,
47+
): Record<string, unknown> =>
48+
Object.fromEntries(
49+
Object.entries(properties).map(([key, value]) => [
50+
key,
51+
value && typeof value === "object" && "$ref" in value
52+
? definitions[(value as { $ref: string }).$ref.replace("#/$defs/", "")] ?? value
53+
: value,
54+
]),
55+
);

0 commit comments

Comments
 (0)