@crewhaus/tool-catalog 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.
- package/package.json +42 -0
- package/src/index.test.ts +98 -0
- package/src/index.ts +188 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-catalog",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "ToolDefinition interface, RegisteredTool, and ToolCatalog registry",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0",
|
|
16
|
+
"zod": "^3.23.8"
|
|
17
|
+
},
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"author": {
|
|
20
|
+
"name": "Max Meier",
|
|
21
|
+
"email": "max@studiomax.io",
|
|
22
|
+
"url": "https://studiomax.io"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
27
|
+
"directory": "packages/tool-catalog"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-catalog#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "restricted"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"NOTICE"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type RegisteredTool, ToolCatalog, ToolCatalogError, defaultCatalog } from "./index";
|
|
5
|
+
|
|
6
|
+
function makeTool(name: string): RegisteredTool {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
description: `${name} tool`,
|
|
10
|
+
inputSchema: z.object({ value: z.string() }) as RegisteredTool["inputSchema"],
|
|
11
|
+
execute: async (_input) => "ok",
|
|
12
|
+
concurrencySafe: false,
|
|
13
|
+
readOnly: false,
|
|
14
|
+
destructive: false,
|
|
15
|
+
requiresSandbox: false,
|
|
16
|
+
classifyOutput: true,
|
|
17
|
+
scope: "internal",
|
|
18
|
+
requireJustification: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("ToolCatalog", () => {
|
|
23
|
+
let catalog: ToolCatalog;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
catalog = new ToolCatalog();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("register and get round-trip", () => {
|
|
30
|
+
const tool = makeTool("Bash");
|
|
31
|
+
catalog.register(tool);
|
|
32
|
+
expect(catalog.get("Bash")).toBe(tool);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("has returns true after register, false before", () => {
|
|
36
|
+
expect(catalog.has("Read")).toBe(false);
|
|
37
|
+
catalog.register(makeTool("Read"));
|
|
38
|
+
expect(catalog.has("Read")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("list returns tools in insertion order", () => {
|
|
42
|
+
const bash = makeTool("Bash");
|
|
43
|
+
const read = makeTool("Read");
|
|
44
|
+
const write = makeTool("Write");
|
|
45
|
+
catalog.register(bash);
|
|
46
|
+
catalog.register(read);
|
|
47
|
+
catalog.register(write);
|
|
48
|
+
expect(catalog.list()).toEqual([bash, read, write]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("list returns empty array when catalog is empty", () => {
|
|
52
|
+
expect(catalog.list()).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("get returns undefined for unknown tool", () => {
|
|
56
|
+
expect(catalog.get("Unknown")).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("duplicate name throws ToolCatalogError", () => {
|
|
60
|
+
catalog.register(makeTool("Bash"));
|
|
61
|
+
expect(() => catalog.register(makeTool("Bash"))).toThrow(ToolCatalogError);
|
|
62
|
+
expect(() => catalog.register(makeTool("Bash"))).toThrow(/already registered/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("ToolCatalogError is instanceof CrewhausError", () => {
|
|
66
|
+
expect(new ToolCatalogError("x")).toBeInstanceOf(CrewhausError);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("ToolCatalogError has code 'tool'", () => {
|
|
70
|
+
expect(new ToolCatalogError("x").code).toBe("tool");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("defaultCatalog", () => {
|
|
75
|
+
test("is a ToolCatalog instance", () => {
|
|
76
|
+
expect(defaultCatalog).toBeInstanceOf(ToolCatalog);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("RegisteredTool jsonSchema field", () => {
|
|
81
|
+
test("optional jsonSchema is preserved when set", () => {
|
|
82
|
+
const catalog = new ToolCatalog();
|
|
83
|
+
const tool: RegisteredTool = {
|
|
84
|
+
...makeTool("Mcp"),
|
|
85
|
+
jsonSchema: { type: "object", properties: { x: { type: "number" } } },
|
|
86
|
+
};
|
|
87
|
+
catalog.register(tool);
|
|
88
|
+
const got = catalog.get("Mcp");
|
|
89
|
+
expect(got?.jsonSchema).toEqual({ type: "object", properties: { x: { type: "number" } } });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("absent jsonSchema is undefined", () => {
|
|
93
|
+
const catalog = new ToolCatalog();
|
|
94
|
+
const tool = makeTool("Plain");
|
|
95
|
+
catalog.register(tool);
|
|
96
|
+
expect(catalog.get("Plain")?.jsonSchema).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-call context passed as the second argument to `execute`. Tools that
|
|
6
|
+
* support cooperative cancellation (e.g. tool-bash forwarding to Bun.spawn)
|
|
7
|
+
* read `signal` from here; tools that don't care can ignore it entirely.
|
|
8
|
+
*
|
|
9
|
+
* `bridge` (Section 13) is an opaque payload runtime-core stuffs in once per
|
|
10
|
+
* run. Framework-aware tools — today only the `Task` tool — cast it back to
|
|
11
|
+
* the typed `RuntimeBridge` from `@crewhaus/agent-context-isolation`.
|
|
12
|
+
* Ordinary tools ignore it.
|
|
13
|
+
*
|
|
14
|
+
* Section 18 — `onStreamChunk` is invoked by streaming tools (e.g.
|
|
15
|
+
* tool-code-execution piping container stdout/stderr) so runtime-core can
|
|
16
|
+
* publish `tool_stream_chunk` trace events. The callback is fire-and-forget
|
|
17
|
+
* — tools must not block on it. Optional; tools that don't stream skip it.
|
|
18
|
+
*/
|
|
19
|
+
export interface ToolExecuteContext {
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
21
|
+
readonly bridge?: unknown;
|
|
22
|
+
readonly onStreamChunk?: (stream: "stdout" | "stderr", chunk: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Section 14 — non-string tool result content. Mirrors the subset of
|
|
27
|
+
* Anthropic's `ToolResultBlockParam.content` we use today: text + base64
|
|
28
|
+
* image blocks. `runtime-core` forwards arrays of these verbatim into the
|
|
29
|
+
* API's `tool_result.content` field, so the model sees them as image
|
|
30
|
+
* inputs rather than as base64 text.
|
|
31
|
+
*
|
|
32
|
+
* Tools that don't return rich content (the majority — fs, bash, todo,
|
|
33
|
+
* mcp, channel, task) keep returning `string` and never construct these.
|
|
34
|
+
*/
|
|
35
|
+
export interface ToolResultTextBlock {
|
|
36
|
+
readonly type: "text";
|
|
37
|
+
readonly text: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ToolResultImageBlock {
|
|
41
|
+
readonly type: "image";
|
|
42
|
+
readonly source: {
|
|
43
|
+
readonly type: "base64";
|
|
44
|
+
readonly media_type: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
|
|
45
|
+
readonly data: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ToolResultContentBlock = ToolResultTextBlock | ToolResultImageBlock;
|
|
50
|
+
export type ToolResultContent = ReadonlyArray<ToolResultContentBlock>;
|
|
51
|
+
export type ToolExecuteResult = string | ToolResultContent;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pillar 3 sink-side fabric — where a tool's effect lands.
|
|
55
|
+
*
|
|
56
|
+
* - `"internal"`: the tool reads/writes process-local state only
|
|
57
|
+
* (filesystem, sandboxed code execution, memory, todo, code-graph
|
|
58
|
+
* index). Egress classifier skips internal tools.
|
|
59
|
+
* - `"external"`: the tool transmits data to a sink the runtime cannot
|
|
60
|
+
* re-classify after the fact — a URL fetched, a channel message sent,
|
|
61
|
+
* a federation outbound payload, an MCP tool invocation, an EVM tx
|
|
62
|
+
* broadcast, an image upload. Every such call routes through
|
|
63
|
+
* `egress-classifier` first.
|
|
64
|
+
*
|
|
65
|
+
* Default at normalization is `"internal"` (fails closed). Tools that
|
|
66
|
+
* cross a process or network boundary MUST set `"external"` explicitly
|
|
67
|
+
* in their `ToolDefinition`.
|
|
68
|
+
*/
|
|
69
|
+
export type ToolScope = "internal" | "external";
|
|
70
|
+
|
|
71
|
+
export interface ToolDefinition<TInput = unknown> {
|
|
72
|
+
name: string;
|
|
73
|
+
description: string;
|
|
74
|
+
inputSchema: ZodType<TInput>;
|
|
75
|
+
execute: (input: TInput, ctx?: ToolExecuteContext) => Promise<ToolExecuteResult>;
|
|
76
|
+
concurrencySafe?: boolean;
|
|
77
|
+
readOnly?: boolean;
|
|
78
|
+
destructive?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Section 18 — declares that the tool MUST run inside a sandbox. The
|
|
81
|
+
* permission engine refuses to grant `allow` in default mode unless an
|
|
82
|
+
* `alwaysAllow` rule matches AND a real sandbox backend is available
|
|
83
|
+
* (see `permission-engine.evaluate`). Tool implementations are
|
|
84
|
+
* responsible for actually using the sandbox; the flag is the policy
|
|
85
|
+
* declaration that the floor enforces.
|
|
86
|
+
*/
|
|
87
|
+
requiresSandbox?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Section 18 — when explicitly false, the post-tool prompt-injection
|
|
90
|
+
* classifier in runtime-core is skipped for this tool. Default is true
|
|
91
|
+
* (run the classifier on every output). Set to false ONLY for tools whose
|
|
92
|
+
* output is structurally guaranteed not to be attacker-controlled (e.g.
|
|
93
|
+
* the in-process `Task` sub-agent tool wrapper).
|
|
94
|
+
*/
|
|
95
|
+
classifyOutput?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Pillar 3 sink-side — see `ToolScope`. Default `"internal"` at
|
|
98
|
+
* normalization. Set `"external"` for any tool whose effect leaves the
|
|
99
|
+
* process boundary unmonitored.
|
|
100
|
+
*/
|
|
101
|
+
scope?: ToolScope;
|
|
102
|
+
/**
|
|
103
|
+
* Pillar 3 intent gate — when true, runtime-core demands the model
|
|
104
|
+
* supply a `justification` string in the tool's input alongside the
|
|
105
|
+
* declared schema, and `permission-engine` evaluates the justification
|
|
106
|
+
* against the session's stated goal via an LLM-as-judge. Failures emit
|
|
107
|
+
* `permission_justification_evaluated` audit events and deny the call.
|
|
108
|
+
*
|
|
109
|
+
* Default at normalization is `false`. Recommended `true` for any tool
|
|
110
|
+
* with destructive or external side effects (evm-tx, message-channel,
|
|
111
|
+
* federation outbound). Independent of `scope` — a tool can be
|
|
112
|
+
* `internal` and still require justification (e.g. a destructive fs
|
|
113
|
+
* delete), and a tool can be `external` without requiring justification
|
|
114
|
+
* (e.g. a read-only public-data fetch).
|
|
115
|
+
*/
|
|
116
|
+
requireJustification?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Authoritative JSON Schema for the tool's input. When set, runtime-core
|
|
119
|
+
* forwards this verbatim to the model instead of running
|
|
120
|
+
* `zodToJsonSchema(inputSchema)`. Used by tools whose canonical schema is
|
|
121
|
+
* already JSON Schema (e.g. MCP tools), where the Zod round-trip would be
|
|
122
|
+
* lossy. The `inputSchema` slot is still required for the validator path
|
|
123
|
+
* (typically `z.unknown()` for MCP).
|
|
124
|
+
*/
|
|
125
|
+
jsonSchema?: unknown;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Normalized form stored in the catalog. All flags are required booleans and
|
|
129
|
+
* execute is type-erased so the registry can hold a homogeneous map. */
|
|
130
|
+
export interface RegisteredTool {
|
|
131
|
+
name: string;
|
|
132
|
+
description: string;
|
|
133
|
+
inputSchema: ZodType<unknown>;
|
|
134
|
+
execute: (input: unknown, ctx?: ToolExecuteContext) => Promise<ToolExecuteResult>;
|
|
135
|
+
concurrencySafe: boolean;
|
|
136
|
+
readOnly: boolean;
|
|
137
|
+
destructive: boolean;
|
|
138
|
+
/** Section 18 — fails closed (false) when omitted by `buildTool`. */
|
|
139
|
+
requiresSandbox: boolean;
|
|
140
|
+
/** Section 18 — defaults to true so post-tool classification runs. */
|
|
141
|
+
classifyOutput: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Pillar 3 sink-side — fails closed (`"internal"`) when omitted. Tools
|
|
144
|
+
* that cross a process or network boundary MUST set `"external"`
|
|
145
|
+
* explicitly in their `ToolDefinition`.
|
|
146
|
+
*/
|
|
147
|
+
scope: ToolScope;
|
|
148
|
+
/**
|
|
149
|
+
* Pillar 3 intent gate — fails closed (false) when omitted. See
|
|
150
|
+
* `ToolDefinition.requireJustification`.
|
|
151
|
+
*/
|
|
152
|
+
requireJustification: boolean;
|
|
153
|
+
/** See ToolDefinition.jsonSchema. Optional; runtime-core falls back to
|
|
154
|
+
* zodToJsonSchema(inputSchema) when absent. */
|
|
155
|
+
jsonSchema?: unknown;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class ToolCatalogError extends CrewhausError {
|
|
159
|
+
override readonly name = "ToolCatalogError";
|
|
160
|
+
constructor(message: string, cause?: unknown) {
|
|
161
|
+
super("tool", message, cause);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class ToolCatalog {
|
|
166
|
+
private readonly _tools = new Map<string, RegisteredTool>();
|
|
167
|
+
|
|
168
|
+
register(tool: RegisteredTool): void {
|
|
169
|
+
if (this._tools.has(tool.name)) {
|
|
170
|
+
throw new ToolCatalogError(`tool "${tool.name}" is already registered`);
|
|
171
|
+
}
|
|
172
|
+
this._tools.set(tool.name, tool);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get(name: string): RegisteredTool | undefined {
|
|
176
|
+
return this._tools.get(name);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
has(name: string): boolean {
|
|
180
|
+
return this._tools.has(name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
list(): ReadonlyArray<RegisteredTool> {
|
|
184
|
+
return [...this._tools.values()];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const defaultCatalog = new ToolCatalog();
|