@openshain/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ import type { ToolDefinition } from "../tool/types.ts";
2
+ import type { AssistantPart, ModelUsage, StopReason } from "../work/events.ts";
3
+
4
+ export type UserPart =
5
+ | { type: "text"; text: string }
6
+ | { type: "tool_result"; callId: string; content: string; isError?: boolean };
7
+
8
+ export type ModelMessage =
9
+ | { role: "user"; content: UserPart[] }
10
+ | { role: "assistant"; content: AssistantPart[] };
11
+
12
+ export interface ModelRequest {
13
+ system?: string;
14
+ messages: ModelMessage[];
15
+ tools?: ToolDefinition[];
16
+ maxOutputTokens?: number;
17
+ /** Passed to the provider as is. The contract does not interpret it. */
18
+ providerOptions?: Record<string, unknown>;
19
+ /** How many model and tool calls the work may still make. Providers may ignore it. */
20
+ budget?: { modelCallsLeft: number; toolCallsLeft: number };
21
+ /** How many leading messages will be sent unchanged next turn. A provider may anchor a prompt cache after them. */
22
+ stableMessages?: number;
23
+ }
24
+
25
+ export interface ModelResponse {
26
+ message: { role: "assistant"; content: AssistantPart[] };
27
+ stopReason: StopReason;
28
+ usage: ModelUsage;
29
+ /** The provider's native response, for debugging only. Not persisted by default. */
30
+ raw?: unknown;
31
+ }
32
+
33
+ export interface ModelDescription {
34
+ /** For display and error messages. The id the log records is ModelProvider.id. */
35
+ provider: string;
36
+ model: string;
37
+ capabilities: { tools: boolean };
38
+ }
39
+
40
+ export interface ModelProvider {
41
+ /** Recorded in the log and used to route opaque parts back. Stable across the provider's models. */
42
+ readonly id: string;
43
+ describe(): ModelDescription;
44
+ generate(request: ModelRequest, signal?: AbortSignal): Promise<ModelResponse>;
45
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,214 @@
1
+ import { loadConfig } from "./config/load.ts";
2
+ import type { Config } from "./config/schema.ts";
3
+ import { isOpenshainError, OpenshainError } from "./errors.ts";
4
+ import type { ModelProvider } from "./model/types.ts";
5
+ import { loadToolModule } from "./tool/load-module.ts";
6
+ import type { HiddenTool } from "./tool/registry.ts";
7
+ import { type RegisteredTool, ToolRegistry } from "./tool/registry.ts";
8
+ import type { ToolCall, ToolDefinition, ToolProvider, ToolResult } from "./tool/types.ts";
9
+ import type { ToolContent } from "./work/events.ts";
10
+ import { TOOL_REJECTION_CODES, type ToolRejectionCode } from "./work/events.ts";
11
+ import { type WorkHandle, WorkStore } from "./work/store.ts";
12
+
13
+ export interface RuntimeProviders {
14
+ /** Model providers by the id used in openshain.yaml. */
15
+ models: Record<string, (model: Config["model"]) => ModelProvider>;
16
+ /** Tool providers by the id used in openshain.yaml. Modules are loaded from the config directly. */
17
+ tools: Record<string, () => ToolProvider>;
18
+ }
19
+
20
+ export interface CreateRuntimeOptions {
21
+ workspaceRoot: string;
22
+ providers: RuntimeProviders;
23
+ }
24
+
25
+ /** What the outside world learns about a registered tool. Calls go through runtime.tools.call. */
26
+ export interface ToolSummary {
27
+ definition: ToolDefinition;
28
+ providerId: string;
29
+ }
30
+
31
+ /** Longer tool output is cut here so that one tool cannot flood the model's context. */
32
+ export const MAX_TOOL_TEXT_CHARS = 50_000;
33
+
34
+ export interface Runtime {
35
+ readonly workspaceRoot: string;
36
+ readonly config: Config;
37
+ readonly model: ModelProvider;
38
+ readonly works: WorkStore;
39
+ readonly tools: {
40
+ list(): ToolSummary[];
41
+ /** Tools the providers offer but the allow lists in the config left out. */
42
+ hidden(): HiddenTool[];
43
+ /** Validates, runs and records one tool call for the given work. Never throws for a tool's own failure. */
44
+ call(work: WorkHandle, call: ToolCall): Promise<ToolResult>;
45
+ };
46
+ }
47
+
48
+ /** Builds a runtime for one workspace from its config and the providers the caller knows. */
49
+ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runtime> {
50
+ const { workspaceRoot, providers } = options;
51
+ const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
52
+
53
+ const modelFactory = Object.hasOwn(providers.models, config.model.provider)
54
+ ? providers.models[config.model.provider]
55
+ : undefined;
56
+ if (!modelFactory) {
57
+ throw new OpenshainError("config", `unknown model provider "${config.model.provider}"`);
58
+ }
59
+ const model = modelFactory(config.model);
60
+ const description = model.describe();
61
+ if (!description.capabilities.tools) {
62
+ throw new OpenshainError(
63
+ "config",
64
+ `model ${description.provider}/${description.model} cannot call tools; openshain needs a model with tool support`,
65
+ );
66
+ }
67
+
68
+ const registry = await createToolRegistry(workspaceRoot, config, providers.tools);
69
+ const works = new WorkStore(workspaceRoot);
70
+ return {
71
+ workspaceRoot,
72
+ config,
73
+ model,
74
+ works,
75
+ tools: {
76
+ list: () => registry.list().map(({ definition, providerId }) => ({ definition, providerId })),
77
+ hidden: () => registry.hiddenTools(),
78
+ call: createToolCaller({ registry, config, workspaceRoot }),
79
+ },
80
+ };
81
+ }
82
+
83
+ /** The tool call pipeline on its own: authorize, validate, run, record. For callers that need no model, such as the MCP server. */
84
+ export function createToolCaller(input: {
85
+ registry: ToolRegistry;
86
+ config: Config;
87
+ workspaceRoot: string;
88
+ }): (work: WorkHandle, call: ToolCall) => Promise<ToolResult> {
89
+ return (work, call) => callTool({ ...input, work, call });
90
+ }
91
+
92
+ /** Registers the tool providers the config names: the caller's factories by id, and modules from the workspace. Needs no model. */
93
+ export async function createToolRegistry(
94
+ workspaceRoot: string,
95
+ config: Config,
96
+ tools: RuntimeProviders["tools"],
97
+ ): Promise<ToolRegistry> {
98
+ const registry = new ToolRegistry();
99
+ for (const entry of config.tools) {
100
+ const registerOptions = entry.allow ? { allow: entry.allow } : {};
101
+ if ("provider" in entry) {
102
+ const factory = Object.hasOwn(tools, entry.provider) ? tools[entry.provider] : undefined;
103
+ if (!factory) {
104
+ throw new OpenshainError(
105
+ "config",
106
+ `unknown tool provider "${entry.provider}"; known providers: ${Object.keys(tools).join(", ")}`,
107
+ );
108
+ }
109
+ await registry.register(factory(), registerOptions);
110
+ } else {
111
+ await registry.register(await loadToolModule(workspaceRoot, entry.module), registerOptions);
112
+ }
113
+ }
114
+ return registry;
115
+ }
116
+
117
+ /**
118
+ * The one place that allows or refuses a call before it runs. At this stage it knows only
119
+ * the allow lists; a later authority engine plugs in here.
120
+ */
121
+ function authorize(
122
+ registry: ToolRegistry,
123
+ call: ToolCall,
124
+ ): { ok: true; tool: RegisteredTool } | { ok: false; code: ToolRejectionCode; reason: string } {
125
+ const tool = registry.get(call.name);
126
+ if (tool) return { ok: true, tool };
127
+ return registry.isHidden(call.name)
128
+ ? {
129
+ ok: false,
130
+ code: "not_allowed",
131
+ reason: `tool "${call.name}" is not allowed in this workspace`,
132
+ }
133
+ : { ok: false, code: "unknown_tool", reason: `unknown tool "${call.name}"` };
134
+ }
135
+
136
+ async function callTool(input: {
137
+ registry: ToolRegistry;
138
+ config: Config;
139
+ workspaceRoot: string;
140
+ work: WorkHandle;
141
+ call: ToolCall;
142
+ }): Promise<ToolResult> {
143
+ const { registry, config, workspaceRoot, work, call } = input;
144
+ const reject = async (code: ToolRejectionCode, reason: string): Promise<ToolResult> => {
145
+ await work.append({
146
+ type: "tool.rejected",
147
+ payload: { callId: call.id, name: call.name, code, reason },
148
+ });
149
+ return { content: [{ type: "text", text: reason }], isError: true };
150
+ };
151
+
152
+ const decision = authorize(registry, call);
153
+ if (!decision.ok) return reject(decision.code, decision.reason);
154
+ const { tool } = decision;
155
+ const validation = tool.validate(call.input);
156
+ if (!validation.ok) {
157
+ return reject(
158
+ "schema_mismatch",
159
+ `input does not match the schema of ${call.name}: ${validation.reason}`,
160
+ );
161
+ }
162
+
163
+ await work.append({
164
+ type: "tool.called",
165
+ payload: { callId: call.id, provider: tool.providerId, name: call.name, input: call.input },
166
+ });
167
+ const started = performance.now();
168
+ let result: ToolResult;
169
+ try {
170
+ result = await tool.provider.call(call, {
171
+ workId: work.id,
172
+ principalId: config.principal.id,
173
+ workspaceRoot,
174
+ });
175
+ } catch (err) {
176
+ if (isOpenshainError(err) && isRejectionCode(err.code)) return reject(err.code, err.message);
177
+ const message = err instanceof Error ? err.message : String(err);
178
+ result = { content: [{ type: "text", text: message }], isError: true };
179
+ }
180
+ const durationMs = Math.max(0, Math.round(performance.now() - started));
181
+ result = { ...result, content: result.content.map(capContent) };
182
+
183
+ await work.append({
184
+ type: "tool.completed",
185
+ payload: {
186
+ callId: call.id,
187
+ content: result.content,
188
+ isError: result.isError ?? false,
189
+ ...(result.observation && { observation: result.observation }),
190
+ ...(result.after && { after: result.after }),
191
+ },
192
+ });
193
+ await work.append({
194
+ type: "usage.recorded",
195
+ payload: { kind: "tool_execution", provider: tool.providerId, usage: { durationMs } },
196
+ });
197
+ return result;
198
+ }
199
+
200
+ function isRejectionCode(code: string): code is ToolRejectionCode {
201
+ return (TOOL_REJECTION_CODES as readonly string[]).includes(code);
202
+ }
203
+
204
+ /** Cuts a content part down to MAX_TOOL_TEXT_CHARS and says so at the end. */
205
+ function capContent(part: ToolContent): ToolContent {
206
+ const text = part.type === "text" ? part.text : JSON.stringify(part.value);
207
+ const chars = [...text];
208
+ if (chars.length <= MAX_TOOL_TEXT_CHARS) return part;
209
+ const cut = chars.length - MAX_TOOL_TEXT_CHARS;
210
+ return {
211
+ type: "text",
212
+ text: `${chars.slice(0, MAX_TOOL_TEXT_CHARS).join("")}\n…[${cut} characters cut by the runtime]`,
213
+ };
214
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { ConfigFileSchema } from "./config/schema.ts";
3
+ import type { JsonSchema } from "./tool/types.ts";
4
+ import { EventFileSchema, payloadFileSchemas } from "./work/events.ts";
5
+ import { WorkFileSchema } from "./work/work.ts";
6
+
7
+ export type SchemaName = "config.v1" | "events.v1" | "work.v1";
8
+
9
+ /**
10
+ * The JSON Schemas (draft 2020-12) of the files openshain reads and writes, derived from the zod
11
+ * schemas that validate them. `spec/schemas/` holds this output; `bun run schemas` regenerates it.
12
+ * Conditions zod expresses as refinements, such as "provider or module, not both", have no JSON
13
+ * Schema form and are absent here.
14
+ */
15
+ export function jsonSchemas(): Record<SchemaName, JsonSchema> {
16
+ return {
17
+ "config.v1": describe(
18
+ ConfigFileSchema,
19
+ "openshain.yaml",
20
+ "The company workspace manifest as written on disk.",
21
+ ),
22
+ "events.v1": eventsSchema(),
23
+ "work.v1": describe(
24
+ WorkFileSchema,
25
+ "work.json",
26
+ "The state of a work as projected from its event log. Never the source of truth.",
27
+ ),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * One line of events.jsonl: the strict envelope with the payload of its type. A type this
33
+ * version does not know is accepted with any payload, as the runtime accepts it, so that a log
34
+ * written by a newer runtime still validates.
35
+ */
36
+ function eventsSchema(): JsonSchema {
37
+ const known = Object.keys(payloadFileSchemas);
38
+ const options = Object.entries(payloadFileSchemas).map(([type, payload]) =>
39
+ EventFileSchema.extend({ type: z.literal(type), payload }),
40
+ ) as unknown as [z.ZodObject, ...z.ZodObject[]];
41
+ const { $schema, oneOf } = toJsonSchema(z.discriminatedUnion("type", options)) as {
42
+ $schema: string;
43
+ oneOf: JsonSchema[];
44
+ };
45
+ const unknownType = toJsonSchema(EventFileSchema) as {
46
+ $schema?: string;
47
+ properties: Record<string, JsonSchema>;
48
+ };
49
+ delete unknownType.$schema;
50
+ unknownType.properties.type = { type: "string", not: { enum: known } };
51
+ return {
52
+ $schema,
53
+ title: "events.jsonl line",
54
+ description:
55
+ "One line of work/<id>/events.jsonl: the envelope, which is strict, and the payload of its type, which may carry fields this version does not know. An unknown type is accepted with any payload.",
56
+ oneOf: [...oneOf, unknownType],
57
+ };
58
+ }
59
+
60
+ function describe(schema: z.ZodType, title: string, description: string): JsonSchema {
61
+ const { $schema, ...rest } = toJsonSchema(schema);
62
+ return { $schema, title, description, ...rest };
63
+ }
64
+
65
+ function toJsonSchema(schema: z.ZodType): JsonSchema {
66
+ // The input shape: a field with a default is optional in the file, and the reader fills it in.
67
+ return z.toJSONSchema(schema, { target: "draft-2020-12", io: "input" }) as JsonSchema;
68
+ }
@@ -0,0 +1,50 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { OpenshainError } from "../errors.ts";
3
+ import { resolveWorkspacePath } from "./paths.ts";
4
+ import type { ToolProvider } from "./types.ts";
5
+
6
+ /**
7
+ * Loads a third-party tool provider from a module inside the workspace.
8
+ * The module's default export must be a ToolProvider.
9
+ */
10
+ export async function loadToolModule(
11
+ workspaceRoot: string,
12
+ modulePath: string,
13
+ ): Promise<ToolProvider> {
14
+ let file: string;
15
+ try {
16
+ file = await resolveWorkspacePath(workspaceRoot, modulePath);
17
+ } catch (cause) {
18
+ throw new OpenshainError(
19
+ "config",
20
+ `tool module "${modulePath}" must be inside the workspace: ${(cause as Error).message}`,
21
+ { cause },
22
+ );
23
+ }
24
+ let loaded: unknown;
25
+ try {
26
+ loaded = await import(pathToFileURL(file).href);
27
+ } catch (cause) {
28
+ throw new OpenshainError(
29
+ "config",
30
+ `cannot load tool module "${modulePath}": ${(cause as Error).message}`,
31
+ { cause },
32
+ );
33
+ }
34
+ const candidate = (loaded as { default?: unknown }).default;
35
+ if (!isToolProvider(candidate)) {
36
+ throw new OpenshainError(
37
+ "config",
38
+ `tool module "${modulePath}" must default-export a ToolProvider with id, listTools and call`,
39
+ );
40
+ }
41
+ return candidate;
42
+ }
43
+
44
+ function isToolProvider(value: unknown): value is ToolProvider {
45
+ if (typeof value !== "object" || value === null) return false;
46
+ const v = value as Record<string, unknown>;
47
+ return (
48
+ typeof v.id === "string" && typeof v.listTools === "function" && typeof v.call === "function"
49
+ );
50
+ }
@@ -0,0 +1,85 @@
1
+ import { lstat, readlink, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
3
+ import { OpenshainError } from "../errors.ts";
4
+
5
+ /** Paths the runtime keeps for itself. Tools may not read or write them. */
6
+ export const RESERVED_PATHS = ["openshain.yaml", "work"] as const;
7
+
8
+ const MAX_SYMLINK_HOPS = 32;
9
+
10
+ /**
11
+ * Turns a tool-supplied relative path into an absolute path inside the workspace.
12
+ *
13
+ * Rejects absolute paths, `..` escapes, the reserved paths, every hidden entry
14
+ * (a segment starting with `.`, which covers `.git`, `.github`, `.env` and the
15
+ * like) and symbolic links that lead outside. Links are followed one hop at a
16
+ * time by reading them, so a link whose target does not exist yet is judged by
17
+ * where it points, not by what happens to exist. The target itself may not
18
+ * exist yet. Every failure is an OpenshainError.
19
+ *
20
+ * The result is a string: nothing stops the filesystem from changing between
21
+ * this check and the file operation. Tools that write should open with
22
+ * O_NOFOLLOW where the platform allows it.
23
+ */
24
+ export async function resolveWorkspacePath(root: string, input: string): Promise<string> {
25
+ if (input === "") throw new OpenshainError("invalid_path", 'empty path: ""');
26
+ if (isAbsolute(input)) {
27
+ throw new OpenshainError("invalid_path", `absolute paths are not allowed: "${input}"`);
28
+ }
29
+ let rootReal: string;
30
+ try {
31
+ rootReal = await realpath(root);
32
+ } catch (cause) {
33
+ throw new OpenshainError("invalid_path", `workspace root is not accessible: "${root}"`, {
34
+ cause,
35
+ });
36
+ }
37
+ return walk(rootReal, normalize(input), input, 0);
38
+ }
39
+
40
+ async function walk(rootReal: string, rel: string, input: string, hops: number): Promise<string> {
41
+ if (rel === ".." || rel.startsWith(`..${sep}`)) {
42
+ throw new OpenshainError("outside_workspace", `path escapes the workspace: "${input}"`);
43
+ }
44
+ const segments = rel === "." ? [] : rel.split(sep);
45
+ for (const segment of segments) {
46
+ if (
47
+ segment === segments[0] &&
48
+ (RESERVED_PATHS as readonly string[]).includes(segment.toLowerCase())
49
+ ) {
50
+ throw new OpenshainError("reserved_path", `reserved path: "${input}"`);
51
+ }
52
+ if (segment.startsWith(".")) {
53
+ throw new OpenshainError("reserved_path", `hidden paths are reserved: "${input}"`);
54
+ }
55
+ }
56
+
57
+ let current = rootReal;
58
+ for (let i = 0; i < segments.length; i++) {
59
+ const candidate = join(current, segments[i] ?? "");
60
+ let stats: Awaited<ReturnType<typeof lstat>>;
61
+ try {
62
+ stats = await lstat(candidate);
63
+ } catch (err) {
64
+ const code = (err as NodeJS.ErrnoException).code;
65
+ if (code === "ENOENT") return join(candidate, ...segments.slice(i + 1));
66
+ throw new OpenshainError("invalid_path", `cannot resolve "${input}": ${code ?? "error"}`, {
67
+ cause: err,
68
+ });
69
+ }
70
+ if (stats.isSymbolicLink()) {
71
+ if (hops >= MAX_SYMLINK_HOPS) {
72
+ throw new OpenshainError("invalid_path", `too many symbolic links: "${input}"`);
73
+ }
74
+ const target = resolve(dirname(candidate), await readlink(candidate));
75
+ const rest = segments.slice(i + 1);
76
+ const next = normalize(join(relative(rootReal, target) || ".", ...rest));
77
+ return walk(rootReal, next, input, hops + 1);
78
+ }
79
+ if (i < segments.length - 1 && !stats.isDirectory()) {
80
+ throw new OpenshainError("invalid_path", `not a directory: "${input}"`);
81
+ }
82
+ current = candidate;
83
+ }
84
+ return current;
85
+ }
@@ -0,0 +1,114 @@
1
+ import { OpenshainError } from "../errors.ts";
2
+ import {
3
+ RESERVED_TOOL_NAMES,
4
+ TOOL_NAME_PATTERN,
5
+ type ToolDefinition,
6
+ type ToolEffect,
7
+ type ToolProvider,
8
+ } from "./types.ts";
9
+ import { compileInputValidator, type InputValidation } from "./validate.ts";
10
+
11
+ export interface RegisteredTool {
12
+ definition: ToolDefinition;
13
+ providerId: string;
14
+ provider: ToolProvider;
15
+ validate: (input: unknown) => InputValidation;
16
+ }
17
+
18
+ /** A tool a provider offers that an allow list left out. Not callable; shown so the person knows it exists. */
19
+ export interface HiddenTool {
20
+ name: string;
21
+ providerId: string;
22
+ effect: ToolEffect;
23
+ }
24
+
25
+ export interface RegisterOptions {
26
+ /** Only these tools of the provider are registered. Every name must exist. */
27
+ allow?: readonly string[];
28
+ }
29
+
30
+ /** Every tool the runtime can offer, across providers. Names are unique. */
31
+ export class ToolRegistry {
32
+ private readonly tools = new Map<string, RegisteredTool>();
33
+ private readonly hidden: HiddenTool[] = [];
34
+
35
+ async register(provider: ToolProvider, options: RegisterOptions = {}): Promise<void> {
36
+ const definitions = await provider.listTools();
37
+ const provided = new Set(definitions.map((d) => d.name));
38
+ for (const name of options.allow ?? []) {
39
+ if (!provided.has(name)) {
40
+ throw new OpenshainError(
41
+ "config",
42
+ `provider "${provider.id}" has no tool named "${name}"; it provides: ${[...provided].join(", ")}`,
43
+ );
44
+ }
45
+ }
46
+ const selected = options.allow
47
+ ? definitions.filter((d) => options.allow?.includes(d.name))
48
+ : definitions;
49
+
50
+ // Check everything before registering anything, so a bad provider changes nothing.
51
+ const prepared = new Map<string, RegisteredTool>();
52
+ for (const definition of selected) {
53
+ const { name } = definition;
54
+ if (!TOOL_NAME_PATTERN.test(name)) {
55
+ throw new OpenshainError(
56
+ "invalid_tool",
57
+ `tool name "${name}" from provider "${provider.id}" must match ${TOOL_NAME_PATTERN}`,
58
+ );
59
+ }
60
+ if (RESERVED_TOOL_NAMES.includes(name)) {
61
+ throw new OpenshainError(
62
+ "invalid_tool",
63
+ `tool name "${name}" from provider "${provider.id}" is reserved for the runtime`,
64
+ );
65
+ }
66
+ const existing = this.tools.get(name) ?? prepared.get(name);
67
+ if (existing) {
68
+ throw new OpenshainError(
69
+ "duplicate_tool",
70
+ `tool "${name}" is provided by both "${existing.providerId}" and "${provider.id}"`,
71
+ );
72
+ }
73
+ let validate: RegisteredTool["validate"];
74
+ try {
75
+ validate = compileInputValidator(definition.inputSchema);
76
+ } catch (cause) {
77
+ throw new OpenshainError(
78
+ "invalid_tool",
79
+ `tool "${name}" from provider "${provider.id}": ${(cause as Error).message}`,
80
+ { cause },
81
+ );
82
+ }
83
+ prepared.set(name, { definition, providerId: provider.id, provider, validate });
84
+ }
85
+ for (const [name, tool] of prepared) this.tools.set(name, tool);
86
+ for (const definition of definitions) {
87
+ if (!prepared.has(definition.name)) {
88
+ this.hidden.push({
89
+ name: definition.name,
90
+ providerId: provider.id,
91
+ effect: definition.effect,
92
+ });
93
+ }
94
+ }
95
+ }
96
+
97
+ /** True for a tool the provider offers but an allow list left out. */
98
+ isHidden(name: string): boolean {
99
+ return !this.tools.has(name) && this.hidden.some((h) => h.name === name);
100
+ }
101
+
102
+ /** Tools that providers offer but allow lists left out. */
103
+ hiddenTools(): HiddenTool[] {
104
+ return this.hidden.filter((h) => !this.tools.has(h.name)).map((h) => ({ ...h }));
105
+ }
106
+
107
+ list(): RegisteredTool[] {
108
+ return [...this.tools.values()];
109
+ }
110
+
111
+ get(name: string): RegisteredTool | undefined {
112
+ return this.tools.get(name);
113
+ }
114
+ }
@@ -0,0 +1,61 @@
1
+ import type { WorkId } from "../ids.ts";
2
+ import type { Artifact, ToolContent } from "../work/events.ts";
3
+
4
+ /** A JSON Schema (draft 2020-12) object. Validated by ajv at registration. */
5
+ export type JsonSchema = Record<string, unknown>;
6
+
7
+ export type ToolEffect = "observe" | "mutate";
8
+
9
+ export const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
10
+
11
+ /** The tool the runtime itself provides. No provider may define it. */
12
+ export const ASK_USER_TOOL_NAME = "ask_user";
13
+
14
+ /** Names no provider may use: the runtime's own tool and the MCP server's work tools. */
15
+ export const RESERVED_TOOL_NAMES: readonly string[] = [
16
+ ASK_USER_TOOL_NAME,
17
+ "work_create",
18
+ "work_select",
19
+ "work_get",
20
+ "work_list",
21
+ "work_complete",
22
+ "work_fail",
23
+ "work_run",
24
+ "work_show",
25
+ ];
26
+
27
+ export interface ToolDefinition {
28
+ /** Unique across all providers. Matches TOOL_NAME_PATTERN. */
29
+ name: string;
30
+ description: string;
31
+ inputSchema: JsonSchema;
32
+ effect: ToolEffect;
33
+ }
34
+
35
+ export interface ToolCall {
36
+ id: string;
37
+ name: string;
38
+ input: unknown;
39
+ }
40
+
41
+ export interface ToolContext {
42
+ workId: WorkId;
43
+ principalId: string;
44
+ workspaceRoot: string;
45
+ signal?: AbortSignal;
46
+ }
47
+
48
+ export interface ToolResult {
49
+ content: ToolContent[];
50
+ isError?: boolean;
51
+ /** Where the observation came from and when it was retrieved. */
52
+ observation?: { source: string; retrievedAt: string };
53
+ /** For mutate tools: the files as they are after the call. */
54
+ after?: Artifact[];
55
+ }
56
+
57
+ export interface ToolProvider {
58
+ readonly id: string;
59
+ listTools(): Promise<ToolDefinition[]>;
60
+ call(call: ToolCall, ctx: ToolContext): Promise<ToolResult>;
61
+ }