@intx/agent 0.1.2

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,217 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import type { ToolDefinition } from "@intx/types/runtime";
4
+
5
+ import {
6
+ createToolRunner,
7
+ DuplicateToolError,
8
+ fromToolRunner,
9
+ stringTool,
10
+ tool,
11
+ } from "./tool";
12
+
13
+ const DEF_A: ToolDefinition = {
14
+ name: "a",
15
+ description: "tool a",
16
+ inputSchema: { type: "object" },
17
+ };
18
+
19
+ const DEF_B: ToolDefinition = {
20
+ name: "b",
21
+ description: "tool b",
22
+ inputSchema: { type: "object" },
23
+ };
24
+
25
+ describe("createToolRunner", () => {
26
+ test("dispatches to a full handler by tool name", async () => {
27
+ const runner = createToolRunner([
28
+ tool({
29
+ definition: DEF_A,
30
+ handler: async (call) => ({
31
+ callId: call.id,
32
+ content: `got ${String(call.arguments.x)}`,
33
+ }),
34
+ }),
35
+ ]);
36
+
37
+ const result = await runner.run(
38
+ { id: "c1", name: "a", arguments: { x: 5 } },
39
+ new AbortController().signal,
40
+ );
41
+
42
+ expect(result).toEqual({ callId: "c1", content: "got 5" });
43
+ });
44
+
45
+ test("lifts the string handler return into a ToolResult", async () => {
46
+ const runner = createToolRunner([
47
+ stringTool({
48
+ definition: DEF_A,
49
+ handler: async (args) => `hello ${String(args.name)}`,
50
+ }),
51
+ ]);
52
+
53
+ const result = await runner.run(
54
+ { id: "c2", name: "a", arguments: { name: "world" } },
55
+ new AbortController().signal,
56
+ );
57
+
58
+ expect(result).toEqual({ callId: "c2", content: "hello world" });
59
+ });
60
+
61
+ test("returns an error ToolResult for an unknown tool name", async () => {
62
+ const runner = createToolRunner([
63
+ tool({
64
+ definition: DEF_A,
65
+ handler: async (call) => ({ callId: call.id, content: "" }),
66
+ }),
67
+ ]);
68
+
69
+ const result = await runner.run(
70
+ { id: "c3", name: "nope", arguments: {} },
71
+ new AbortController().signal,
72
+ );
73
+
74
+ expect(result.isError).toBe(true);
75
+ expect(result.callId).toBe("c3");
76
+ expect(result.content).toBe("unknown tool: nope");
77
+ });
78
+
79
+ test("wraps a thrown error from a full handler into an error ToolResult", async () => {
80
+ const runner = createToolRunner([
81
+ tool({
82
+ definition: DEF_A,
83
+ handler: async () => {
84
+ throw new Error("boom");
85
+ },
86
+ }),
87
+ ]);
88
+
89
+ const result = await runner.run(
90
+ { id: "c4", name: "a", arguments: {} },
91
+ new AbortController().signal,
92
+ );
93
+
94
+ expect(result).toEqual({ callId: "c4", content: "boom", isError: true });
95
+ });
96
+
97
+ test("wraps a thrown error from a string handler into an error ToolResult", async () => {
98
+ const runner = createToolRunner([
99
+ stringTool({
100
+ definition: DEF_A,
101
+ handler: async () => {
102
+ throw new Error("bad input");
103
+ },
104
+ }),
105
+ ]);
106
+
107
+ const result = await runner.run(
108
+ { id: "c5", name: "a", arguments: {} },
109
+ new AbortController().signal,
110
+ );
111
+
112
+ expect(result).toEqual({
113
+ callId: "c5",
114
+ content: "bad input",
115
+ isError: true,
116
+ });
117
+ });
118
+
119
+ test("exposes definitions in registration order", () => {
120
+ const runner = createToolRunner([
121
+ tool({
122
+ definition: DEF_A,
123
+ handler: async (call) => ({ callId: call.id, content: "" }),
124
+ }),
125
+ stringTool({ definition: DEF_B, handler: async () => "x" }),
126
+ ]);
127
+
128
+ expect(runner.definitions.map((d) => d.name)).toEqual(["a", "b"]);
129
+ });
130
+
131
+ test("throws DuplicateToolError at construction on duplicate names", () => {
132
+ expect(() =>
133
+ createToolRunner([
134
+ tool({
135
+ definition: DEF_A,
136
+ handler: async (call) => ({ callId: call.id, content: "" }),
137
+ }),
138
+ stringTool({ definition: DEF_A, handler: async () => "x" }),
139
+ ]),
140
+ ).toThrow(DuplicateToolError);
141
+ });
142
+
143
+ test("propagates the AbortSignal to the handler", async () => {
144
+ let received: AbortSignal | undefined;
145
+ const runner = createToolRunner([
146
+ tool({
147
+ definition: DEF_A,
148
+ handler: async (call, signal) => {
149
+ received = signal;
150
+ return { callId: call.id, content: "ok" };
151
+ },
152
+ }),
153
+ ]);
154
+
155
+ const ctl = new AbortController();
156
+ await runner.run({ id: "c6", name: "a", arguments: {} }, ctl.signal);
157
+
158
+ expect(received).toBe(ctl.signal);
159
+ });
160
+
161
+ test("fromToolRunner wraps each definition as a full-handler AgentTool", async () => {
162
+ const calls: string[] = [];
163
+ const stubRunner = {
164
+ definitions: [DEF_A, DEF_B] as const,
165
+ run: async (call: {
166
+ id: string;
167
+ name: string;
168
+ arguments: Record<string, unknown>;
169
+ }) => {
170
+ calls.push(call.name);
171
+ return Promise.resolve({
172
+ callId: call.id,
173
+ content: `ran ${call.name}`,
174
+ });
175
+ },
176
+ };
177
+
178
+ const tools = fromToolRunner(stubRunner);
179
+ const runner = createToolRunner(tools);
180
+
181
+ expect(runner.definitions.map((d) => d.name)).toEqual(["a", "b"]);
182
+
183
+ const ra = await runner.run(
184
+ { id: "c8", name: "a", arguments: {} },
185
+ new AbortController().signal,
186
+ );
187
+ expect(ra).toEqual({ callId: "c8", content: "ran a" });
188
+
189
+ const rb = await runner.run(
190
+ { id: "c9", name: "b", arguments: {} },
191
+ new AbortController().signal,
192
+ );
193
+ expect(rb).toEqual({ callId: "c9", content: "ran b" });
194
+
195
+ expect(calls).toEqual(["a", "b"]);
196
+ });
197
+
198
+ test("string handler receives the parsed arguments object", async () => {
199
+ let received: Record<string, unknown> | undefined;
200
+ const runner = createToolRunner([
201
+ stringTool({
202
+ definition: DEF_A,
203
+ handler: async (args) => {
204
+ received = args;
205
+ return "ok";
206
+ },
207
+ }),
208
+ ]);
209
+
210
+ await runner.run(
211
+ { id: "c7", name: "a", arguments: { foo: 1, bar: "x" } },
212
+ new AbortController().signal,
213
+ );
214
+
215
+ expect(received).toEqual({ foo: 1, bar: "x" });
216
+ });
217
+ });
package/src/tool.ts ADDED
@@ -0,0 +1,148 @@
1
+ // Tool registration and dispatch.
2
+ //
3
+ // Two registration shapes are supported:
4
+ //
5
+ // `tool({ definition, handler })` — handler receives the full
6
+ // ToolCall and returns the full
7
+ // ToolResult. Use when the
8
+ // handler needs the callId or
9
+ // wants to set isError/detail/
10
+ // pendingMarker.
11
+ //
12
+ // `stringTool({ definition, handler })` — sugar for the common case of
13
+ // "compute a string from the
14
+ // parsed arguments." The callId
15
+ // is filled in from the
16
+ // surrounding ToolCall, and
17
+ // isError is false unless the
18
+ // handler throws.
19
+ //
20
+ // `createToolRunner(tools)` builds a `ToolRunner` that dispatches by tool
21
+ // name. Per the ToolRunner contract (packages/types/src/runtime.ts), `run`
22
+ // must not throw — unknown tool names and handler exceptions are surfaced
23
+ // as `ToolResult` with `isError: true` so the model sees them and can
24
+ // recover.
25
+
26
+ import type {
27
+ ToolCall,
28
+ ToolDefinition,
29
+ ToolResult,
30
+ ToolRunner,
31
+ } from "@intx/types/runtime";
32
+
33
+ export type ToolHandler = (
34
+ call: ToolCall,
35
+ signal: AbortSignal,
36
+ ) => Promise<ToolResult>;
37
+
38
+ export type StringToolHandler = (
39
+ args: Record<string, unknown>,
40
+ signal: AbortSignal,
41
+ ) => Promise<string>;
42
+
43
+ export type AgentTool =
44
+ | { kind: "full"; definition: ToolDefinition; handler: ToolHandler }
45
+ | {
46
+ kind: "string";
47
+ definition: ToolDefinition;
48
+ handler: StringToolHandler;
49
+ };
50
+
51
+ export function tool(args: {
52
+ definition: ToolDefinition;
53
+ handler: ToolHandler;
54
+ }): AgentTool {
55
+ return { kind: "full", definition: args.definition, handler: args.handler };
56
+ }
57
+
58
+ export function stringTool(args: {
59
+ definition: ToolDefinition;
60
+ handler: StringToolHandler;
61
+ }): AgentTool {
62
+ return {
63
+ kind: "string",
64
+ definition: args.definition,
65
+ handler: args.handler,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Adapt a pre-built ToolRunner (e.g. the one returned by
71
+ * `createPosixTools`) into a list of AgentTools that can be passed to
72
+ * `createAgent({ tools })`. Each definition becomes a full-handler
73
+ * AgentTool that delegates to the runner's `run`.
74
+ *
75
+ * Use this when integrating tool packages whose public surface is a
76
+ * single ToolRunner rather than individual handlers.
77
+ */
78
+ export function fromToolRunner(runner: {
79
+ readonly definitions: readonly ToolDefinition[];
80
+ run: ToolRunner["run"];
81
+ }): AgentTool[] {
82
+ return runner.definitions.map((definition) => ({
83
+ kind: "full",
84
+ definition,
85
+ handler: (call, signal) => runner.run(call, signal),
86
+ }));
87
+ }
88
+
89
+ export class DuplicateToolError extends Error {
90
+ readonly toolName: string;
91
+
92
+ constructor(toolName: string) {
93
+ super(`duplicate tool name: ${toolName}`);
94
+ this.name = "DuplicateToolError";
95
+ this.toolName = toolName;
96
+ }
97
+ }
98
+
99
+ export type AgentToolRunner = ToolRunner & {
100
+ readonly definitions: readonly ToolDefinition[];
101
+ };
102
+
103
+ /**
104
+ * Build a `ToolRunner` that dispatches by tool name. Throws
105
+ * `DuplicateToolError` at construction if any two tools share a name.
106
+ *
107
+ * At call time, unknown tool names and exceptions from handlers are
108
+ * converted to `ToolResult { isError: true }` so the contract on
109
+ * `ToolRunner.run` ("must not throw") is upheld.
110
+ */
111
+ export function createToolRunner(tools: AgentTool[]): AgentToolRunner {
112
+ const byName = new Map<string, AgentTool>();
113
+ for (const t of tools) {
114
+ if (byName.has(t.definition.name)) {
115
+ throw new DuplicateToolError(t.definition.name);
116
+ }
117
+ byName.set(t.definition.name, t);
118
+ }
119
+
120
+ const definitions: readonly ToolDefinition[] = tools.map((t) => t.definition);
121
+
122
+ return {
123
+ definitions,
124
+ async run(call, signal): Promise<ToolResult> {
125
+ const found = byName.get(call.name);
126
+ if (found === undefined) {
127
+ return {
128
+ callId: call.id,
129
+ content: `unknown tool: ${call.name}`,
130
+ isError: true,
131
+ };
132
+ }
133
+ try {
134
+ if (found.kind === "full") {
135
+ return await found.handler(call, signal);
136
+ }
137
+ const text = await found.handler(call.arguments, signal);
138
+ return { callId: call.id, content: text };
139
+ } catch (err) {
140
+ return {
141
+ callId: call.id,
142
+ content: err instanceof Error ? err.message : String(err),
143
+ isError: true,
144
+ };
145
+ }
146
+ },
147
+ };
148
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*.ts"]
4
+ }