@funkai/agents 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.
Files changed (153) hide show
  1. package/.generated/req.txt +1 -0
  2. package/.turbo/turbo-build.log +21 -0
  3. package/.turbo/turbo-test$colon$coverage.log +109 -0
  4. package/.turbo/turbo-test.log +141 -0
  5. package/.turbo/turbo-typecheck.log +4 -0
  6. package/CHANGELOG.md +16 -0
  7. package/ISSUES.md +540 -0
  8. package/LICENSE +21 -0
  9. package/README.md +128 -0
  10. package/banner.svg +97 -0
  11. package/coverage/lcov-report/base.css +224 -0
  12. package/coverage/lcov-report/block-navigation.js +87 -0
  13. package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
  14. package/coverage/lcov-report/core/agents/base/index.html +146 -0
  15. package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
  16. package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
  17. package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
  18. package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
  19. package/coverage/lcov-report/core/agents/flow/index.html +146 -0
  20. package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
  21. package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
  22. package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
  23. package/coverage/lcov-report/core/index.html +131 -0
  24. package/coverage/lcov-report/core/logger.ts.html +541 -0
  25. package/coverage/lcov-report/core/models/providers/index.html +116 -0
  26. package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
  27. package/coverage/lcov-report/core/provider/index.html +131 -0
  28. package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
  29. package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
  30. package/coverage/lcov-report/core/tool.ts.html +577 -0
  31. package/coverage/lcov-report/favicon.png +0 -0
  32. package/coverage/lcov-report/index.html +221 -0
  33. package/coverage/lcov-report/lib/hooks.ts.html +262 -0
  34. package/coverage/lcov-report/lib/index.html +161 -0
  35. package/coverage/lcov-report/lib/middleware.ts.html +274 -0
  36. package/coverage/lcov-report/lib/runnable.ts.html +151 -0
  37. package/coverage/lcov-report/lib/trace.ts.html +520 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/utils/attempt.ts.html +199 -0
  43. package/coverage/lcov-report/utils/error.ts.html +421 -0
  44. package/coverage/lcov-report/utils/index.html +176 -0
  45. package/coverage/lcov-report/utils/resolve.ts.html +208 -0
  46. package/coverage/lcov-report/utils/result.ts.html +538 -0
  47. package/coverage/lcov-report/utils/zod.ts.html +178 -0
  48. package/coverage/lcov.info +1566 -0
  49. package/dist/index.d.mts +2883 -0
  50. package/dist/index.d.mts.map +1 -0
  51. package/dist/index.mjs +2312 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/docs/core/agent.md +231 -0
  54. package/docs/core/hooks.md +95 -0
  55. package/docs/core/overview.md +87 -0
  56. package/docs/core/step.md +279 -0
  57. package/docs/core/tools.md +98 -0
  58. package/docs/core/workflow.md +235 -0
  59. package/docs/guides/create-agent.md +224 -0
  60. package/docs/guides/create-tool.md +137 -0
  61. package/docs/guides/create-workflow.md +374 -0
  62. package/docs/overview.md +244 -0
  63. package/docs/provider/models.md +55 -0
  64. package/docs/provider/overview.md +106 -0
  65. package/docs/provider/usage.md +100 -0
  66. package/docs/research/experimental-context.md +167 -0
  67. package/docs/research/gap-analysis.md +86 -0
  68. package/docs/research/prepare-step-and-active-tools.md +138 -0
  69. package/docs/research/sub-agent-model.md +249 -0
  70. package/docs/troubleshooting.md +60 -0
  71. package/logo.svg +17 -0
  72. package/models.config.json +18 -0
  73. package/package.json +60 -0
  74. package/scripts/generate-models.ts +324 -0
  75. package/src/core/agents/base/agent.test.ts +1522 -0
  76. package/src/core/agents/base/agent.ts +547 -0
  77. package/src/core/agents/base/output.test.ts +93 -0
  78. package/src/core/agents/base/output.ts +57 -0
  79. package/src/core/agents/base/types.test-d.ts +69 -0
  80. package/src/core/agents/base/types.ts +503 -0
  81. package/src/core/agents/base/utils.test.ts +397 -0
  82. package/src/core/agents/base/utils.ts +197 -0
  83. package/src/core/agents/flow/engine.test.ts +452 -0
  84. package/src/core/agents/flow/engine.ts +281 -0
  85. package/src/core/agents/flow/flow-agent.test.ts +1027 -0
  86. package/src/core/agents/flow/flow-agent.ts +473 -0
  87. package/src/core/agents/flow/messages.test.ts +198 -0
  88. package/src/core/agents/flow/messages.ts +141 -0
  89. package/src/core/agents/flow/steps/agent.test.ts +280 -0
  90. package/src/core/agents/flow/steps/agent.ts +87 -0
  91. package/src/core/agents/flow/steps/all.test.ts +300 -0
  92. package/src/core/agents/flow/steps/all.ts +73 -0
  93. package/src/core/agents/flow/steps/builder.ts +124 -0
  94. package/src/core/agents/flow/steps/each.test.ts +257 -0
  95. package/src/core/agents/flow/steps/each.ts +61 -0
  96. package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
  97. package/src/core/agents/flow/steps/factory.test.ts +1025 -0
  98. package/src/core/agents/flow/steps/factory.ts +645 -0
  99. package/src/core/agents/flow/steps/map.test.ts +273 -0
  100. package/src/core/agents/flow/steps/map.ts +75 -0
  101. package/src/core/agents/flow/steps/race.test.ts +290 -0
  102. package/src/core/agents/flow/steps/race.ts +59 -0
  103. package/src/core/agents/flow/steps/reduce.test.ts +310 -0
  104. package/src/core/agents/flow/steps/reduce.ts +73 -0
  105. package/src/core/agents/flow/steps/result.ts +27 -0
  106. package/src/core/agents/flow/steps/step.test.ts +402 -0
  107. package/src/core/agents/flow/steps/step.ts +51 -0
  108. package/src/core/agents/flow/steps/while.test.ts +283 -0
  109. package/src/core/agents/flow/steps/while.ts +75 -0
  110. package/src/core/agents/flow/types.ts +348 -0
  111. package/src/core/logger.test.ts +163 -0
  112. package/src/core/logger.ts +152 -0
  113. package/src/core/models/index.test.ts +137 -0
  114. package/src/core/models/index.ts +152 -0
  115. package/src/core/models/providers/openai.ts +84 -0
  116. package/src/core/provider/provider.test.ts +128 -0
  117. package/src/core/provider/provider.ts +99 -0
  118. package/src/core/provider/types.ts +98 -0
  119. package/src/core/provider/usage.test.ts +304 -0
  120. package/src/core/provider/usage.ts +97 -0
  121. package/src/core/tool.test.ts +65 -0
  122. package/src/core/tool.ts +164 -0
  123. package/src/core/types.ts +66 -0
  124. package/src/index.ts +95 -0
  125. package/src/lib/context.test.ts +86 -0
  126. package/src/lib/context.ts +49 -0
  127. package/src/lib/hooks.test.ts +102 -0
  128. package/src/lib/hooks.ts +59 -0
  129. package/src/lib/middleware.test.ts +122 -0
  130. package/src/lib/middleware.ts +63 -0
  131. package/src/lib/runnable.test.ts +41 -0
  132. package/src/lib/runnable.ts +22 -0
  133. package/src/lib/trace.test.ts +291 -0
  134. package/src/lib/trace.ts +145 -0
  135. package/src/models/index.ts +123 -0
  136. package/src/models/providers/index.ts +15 -0
  137. package/src/models/providers/openai.ts +84 -0
  138. package/src/testing/context.ts +32 -0
  139. package/src/testing/index.ts +2 -0
  140. package/src/testing/logger.ts +19 -0
  141. package/src/utils/attempt.test.ts +127 -0
  142. package/src/utils/attempt.ts +38 -0
  143. package/src/utils/error.test.ts +179 -0
  144. package/src/utils/error.ts +112 -0
  145. package/src/utils/resolve.test.ts +38 -0
  146. package/src/utils/resolve.ts +41 -0
  147. package/src/utils/result.test.ts +79 -0
  148. package/src/utils/result.ts +151 -0
  149. package/src/utils/zod.test.ts +69 -0
  150. package/src/utils/zod.ts +31 -0
  151. package/tsconfig.json +25 -0
  152. package/tsdown.config.ts +15 -0
  153. package/vitest.config.ts +46 -0
@@ -0,0 +1,397 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ import type { Message } from "@/core/agents/base/types.js";
5
+ import {
6
+ buildAITools,
7
+ buildPrompt,
8
+ resolveModel,
9
+ resolveSystem,
10
+ toTokenUsage,
11
+ } from "@/core/agents/base/utils.js";
12
+ import { RUNNABLE_META } from "@/lib/runnable.js";
13
+
14
+ vi.mock("@/core/provider/provider.js", () => ({
15
+ openrouter: vi.fn((id: string) => ({ modelId: id })),
16
+ }));
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // resolveModel
20
+ // ---------------------------------------------------------------------------
21
+
22
+ describe("resolveModel", () => {
23
+ it("calls openrouter for a string model ID", () => {
24
+ const result = resolveModel("openai/gpt-4.1");
25
+ expect(result).toEqual({ modelId: "openai/gpt-4.1" });
26
+ });
27
+
28
+ it("returns a LanguageModel object as-is", () => {
29
+ const model = { modelId: "custom-model" } as never;
30
+ const result = resolveModel(model);
31
+ expect(result).toBe(model);
32
+ });
33
+ });
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // resolveSystem
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe("resolveSystem", () => {
40
+ it("returns undefined when system is undefined", () => {
41
+ expect(resolveSystem(undefined, "input")).toBeUndefined();
42
+ });
43
+
44
+ it("returns undefined when system is null", () => {
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ expect(resolveSystem(null as any, "input")).toBeUndefined();
47
+ });
48
+
49
+ it("returns a static string as-is", () => {
50
+ expect(resolveSystem("You are helpful", "input")).toBe("You are helpful");
51
+ });
52
+
53
+ it("calls function system with input and returns the result", () => {
54
+ // eslint-disable-next-line unicorn/consistent-function-scoping -- Test helper intentionally scoped within test case for locality
55
+ const system = ({ input }: { input: string }) => `System for ${input}`;
56
+ expect(resolveSystem(system, "topic")).toBe("System for topic");
57
+ });
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // buildPrompt
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe("buildPrompt", () => {
65
+ it("returns { prompt } for a simple string input", () => {
66
+ const result = buildPrompt("hello", {});
67
+ expect(result).toEqual({ prompt: "hello" });
68
+ });
69
+
70
+ it("returns { messages } for a non-string input without typed config", () => {
71
+ const messages: Message[] = [{ role: "user", content: "hi" }];
72
+ const result = buildPrompt(messages, {});
73
+ expect(result).toEqual({ messages });
74
+ });
75
+
76
+ it("returns { prompt } for typed mode returning a string", () => {
77
+ const result = buildPrompt(
78
+ { topic: "AI" },
79
+ {
80
+ input: z.object({ topic: z.string() }),
81
+ prompt: ({ input }) => `Tell me about ${input.topic}`,
82
+ },
83
+ );
84
+ expect(result).toEqual({ prompt: "Tell me about AI" });
85
+ });
86
+
87
+ it("returns { messages } for typed mode returning messages array", () => {
88
+ const messages: Message[] = [{ role: "user", content: "hello" }];
89
+ const result = buildPrompt(
90
+ { topic: "AI" },
91
+ {
92
+ input: z.object({ topic: z.string() }),
93
+ prompt: () => messages,
94
+ },
95
+ );
96
+ expect(result).toEqual({ messages });
97
+ });
98
+
99
+ it("throws when input schema is provided without prompt function", () => {
100
+ expect(() => buildPrompt("test", { input: z.string() })).toThrow(
101
+ "Agent has `input` schema but no `prompt` function",
102
+ );
103
+ });
104
+
105
+ it("throws when prompt function is provided without input schema", () => {
106
+ expect(() => buildPrompt("test", { prompt: ({ input }) => `${input}` })).toThrow(
107
+ "Agent has `prompt` function but no `input` schema",
108
+ );
109
+ });
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // toTokenUsage
114
+ // ---------------------------------------------------------------------------
115
+
116
+ describe("toTokenUsage", () => {
117
+ it("converts a fully populated LanguageModelUsage to TokenUsage", () => {
118
+ const result = toTokenUsage({
119
+ inputTokens: 100,
120
+ outputTokens: 50,
121
+ totalTokens: 150,
122
+ inputTokenDetails: {
123
+ noCacheTokens: 85,
124
+ cacheReadTokens: 10,
125
+ cacheWriteTokens: 5,
126
+ },
127
+ outputTokenDetails: {
128
+ textTokens: 47,
129
+ reasoningTokens: 3,
130
+ },
131
+ });
132
+
133
+ expect(result).toEqual({
134
+ inputTokens: 100,
135
+ outputTokens: 50,
136
+ totalTokens: 150,
137
+ cacheReadTokens: 10,
138
+ cacheWriteTokens: 5,
139
+ reasoningTokens: 3,
140
+ });
141
+ });
142
+
143
+ it("defaults undefined top-level fields to 0", () => {
144
+ const result = toTokenUsage({
145
+ inputTokens: undefined,
146
+ outputTokens: undefined,
147
+ totalTokens: undefined,
148
+ inputTokenDetails: {
149
+ noCacheTokens: undefined,
150
+ cacheReadTokens: undefined,
151
+ cacheWriteTokens: undefined,
152
+ },
153
+ outputTokenDetails: {
154
+ textTokens: undefined,
155
+ reasoningTokens: undefined,
156
+ },
157
+ });
158
+
159
+ expect(result.inputTokens).toBe(0);
160
+ expect(result.outputTokens).toBe(0);
161
+ expect(result.totalTokens).toBe(0);
162
+ });
163
+
164
+ it("defaults undefined detail fields to 0", () => {
165
+ const result = toTokenUsage({
166
+ inputTokens: 100,
167
+ outputTokens: 50,
168
+ totalTokens: 150,
169
+ inputTokenDetails: {
170
+ noCacheTokens: undefined,
171
+ cacheReadTokens: undefined,
172
+ cacheWriteTokens: undefined,
173
+ },
174
+ outputTokenDetails: {
175
+ textTokens: undefined,
176
+ reasoningTokens: undefined,
177
+ },
178
+ });
179
+
180
+ expect(result.cacheReadTokens).toBe(0);
181
+ expect(result.cacheWriteTokens).toBe(0);
182
+ expect(result.reasoningTokens).toBe(0);
183
+ });
184
+
185
+ it("extracts cache tokens from inputTokenDetails", () => {
186
+ const result = toTokenUsage({
187
+ inputTokens: 100,
188
+ outputTokens: 50,
189
+ totalTokens: 150,
190
+ inputTokenDetails: {
191
+ noCacheTokens: 90,
192
+ cacheReadTokens: 10,
193
+ cacheWriteTokens: undefined,
194
+ },
195
+ outputTokenDetails: {
196
+ textTokens: 50,
197
+ reasoningTokens: undefined,
198
+ },
199
+ });
200
+
201
+ expect(result.cacheReadTokens).toBe(10);
202
+ expect(result.cacheWriteTokens).toBe(0);
203
+ });
204
+
205
+ it("extracts reasoning tokens from outputTokenDetails", () => {
206
+ const result = toTokenUsage({
207
+ inputTokens: 100,
208
+ outputTokens: 50,
209
+ totalTokens: 150,
210
+ inputTokenDetails: {
211
+ noCacheTokens: 100,
212
+ cacheReadTokens: undefined,
213
+ cacheWriteTokens: undefined,
214
+ },
215
+ outputTokenDetails: {
216
+ textTokens: 42,
217
+ reasoningTokens: 8,
218
+ },
219
+ });
220
+
221
+ expect(result.reasoningTokens).toBe(8);
222
+ });
223
+
224
+ it("defaults cache tokens to 0 when inputTokenDetails is undefined", () => {
225
+ const result = toTokenUsage({
226
+ inputTokens: 100,
227
+ outputTokens: 50,
228
+ totalTokens: 150,
229
+ inputTokenDetails: undefined as never,
230
+ outputTokenDetails: {
231
+ textTokens: 50,
232
+ reasoningTokens: 0,
233
+ },
234
+ });
235
+
236
+ expect(result.cacheReadTokens).toBe(0);
237
+ expect(result.cacheWriteTokens).toBe(0);
238
+ });
239
+
240
+ it("defaults reasoning tokens to 0 when outputTokenDetails is undefined", () => {
241
+ const result = toTokenUsage({
242
+ inputTokens: 100,
243
+ outputTokens: 50,
244
+ totalTokens: 150,
245
+ inputTokenDetails: {
246
+ noCacheTokens: 100,
247
+ cacheReadTokens: 0,
248
+ cacheWriteTokens: 0,
249
+ },
250
+ outputTokenDetails: undefined as never,
251
+ });
252
+
253
+ expect(result.reasoningTokens).toBe(0);
254
+ });
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // buildAITools
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe("buildAITools", () => {
262
+ it("returns undefined when no tools or agents are provided", () => {
263
+ expect(buildAITools()).toBeUndefined();
264
+ expect(buildAITools(undefined, undefined)).toBeUndefined();
265
+ });
266
+
267
+ it("returns undefined for empty tool and agent records", () => {
268
+ expect(buildAITools({}, {})).toBeUndefined();
269
+ });
270
+
271
+ it("returns tools when only tools are provided", () => {
272
+ const tools = { myTool: { description: "test" } as never };
273
+ const result = buildAITools(tools);
274
+ expect(result).toBeDefined();
275
+ expect(result).toHaveProperty("myTool");
276
+ });
277
+
278
+ it("wraps agents without inputSchema into prompt-based tools", () => {
279
+ const mockAgent = {
280
+ generate: vi.fn().mockResolvedValue({ ok: true, output: "result" }),
281
+ };
282
+ const result = buildAITools(undefined, { sub: mockAgent as never });
283
+
284
+ expect(result).toBeDefined();
285
+ expect(result).toHaveProperty("agent:sub");
286
+ const tools = result as Record<string, Record<string, unknown>>;
287
+ expect(tools["agent:sub"]).toHaveProperty("description");
288
+ expect(tools["agent:sub"]).toHaveProperty("execute");
289
+ });
290
+
291
+ it("wraps agents with inputSchema using the schema", () => {
292
+ const mockAgent = {
293
+ generate: vi.fn().mockResolvedValue({ ok: true, output: "result" }),
294
+ [RUNNABLE_META]: {
295
+ name: "custom-name",
296
+ inputSchema: z.object({ query: z.string() }),
297
+ },
298
+ };
299
+ const result = buildAITools(undefined, { sub: mockAgent as never });
300
+
301
+ expect(result).toBeDefined();
302
+ expect(result).toHaveProperty("agent:sub");
303
+ const tools = result as Record<string, { description: string; execute: Function }>;
304
+ expect(tools["agent:sub"]).toHaveProperty("description");
305
+ // Description should use meta name
306
+ expect(tools["agent:sub"].description).toContain("custom-name");
307
+ });
308
+
309
+ it("uses fallback name when agent has no RUNNABLE_META name", () => {
310
+ const mockAgent = {
311
+ generate: vi.fn().mockResolvedValue({ ok: true, output: "result" }),
312
+ };
313
+ const result = buildAITools(undefined, { fallbackKey: mockAgent as never });
314
+
315
+ expect(result).toBeDefined();
316
+ const tools = result as Record<string, { description: string; execute: Function }>;
317
+ expect(tools["agent:fallbackKey"].description).toContain("fallbackKey");
318
+ });
319
+
320
+ it("merges tools and agents together", () => {
321
+ const tools = { myTool: { description: "test" } as never };
322
+ const mockAgent = {
323
+ generate: vi.fn().mockResolvedValue({ ok: true, output: "result" }),
324
+ };
325
+ const result = buildAITools(tools, { sub: mockAgent as never });
326
+
327
+ expect(result).toBeDefined();
328
+ expect(result).toHaveProperty("myTool");
329
+ expect(result).toHaveProperty("agent:sub");
330
+ });
331
+
332
+ it("execute calls generate on prompt-based agent and returns output", async () => {
333
+ const mockAgent = {
334
+ generate: vi.fn().mockResolvedValue({ ok: true, output: "agent-output" }),
335
+ };
336
+ const result = buildAITools(undefined, { sub: mockAgent as never });
337
+ expect(result).toBeDefined();
338
+
339
+ const tools = result as Record<string, { description: string; execute: Function }>;
340
+ const output = await tools["agent:sub"].execute(
341
+ { prompt: "hello" },
342
+ { toolCallId: "tc-1", messages: [] },
343
+ );
344
+ expect(output).toBe("agent-output");
345
+ expect(mockAgent.generate).toHaveBeenCalledWith("hello", { signal: undefined });
346
+ });
347
+
348
+ it("execute throws when prompt-based agent returns error", async () => {
349
+ const mockAgent = {
350
+ generate: vi.fn().mockResolvedValue({ ok: false, error: { message: "agent failed" } }),
351
+ };
352
+ const result = buildAITools(undefined, { sub: mockAgent as never });
353
+ expect(result).toBeDefined();
354
+
355
+ const tools = result as Record<string, { description: string; execute: Function }>;
356
+ await expect(
357
+ tools["agent:sub"].execute({ prompt: "hello" }, { toolCallId: "tc-1", messages: [] }),
358
+ ).rejects.toThrow("agent failed");
359
+ });
360
+
361
+ it("execute calls generate on typed agent with inputSchema and returns output", async () => {
362
+ const mockAgent = {
363
+ generate: vi.fn().mockResolvedValue({ ok: true, output: "typed-output" }),
364
+ [RUNNABLE_META]: {
365
+ name: "typed-agent",
366
+ inputSchema: z.object({ query: z.string() }),
367
+ },
368
+ };
369
+ const result = buildAITools(undefined, { sub: mockAgent as never });
370
+ expect(result).toBeDefined();
371
+
372
+ const tools = result as Record<string, { description: string; execute: Function }>;
373
+ const output = await tools["agent:sub"].execute(
374
+ { query: "test" },
375
+ { toolCallId: "tc-1", messages: [] },
376
+ );
377
+ expect(output).toBe("typed-output");
378
+ expect(mockAgent.generate).toHaveBeenCalledWith({ query: "test" }, { signal: undefined });
379
+ });
380
+
381
+ it("execute throws when typed agent returns error", async () => {
382
+ const mockAgent = {
383
+ generate: vi.fn().mockResolvedValue({ ok: false, error: { message: "typed failed" } }),
384
+ [RUNNABLE_META]: {
385
+ name: "typed-agent",
386
+ inputSchema: z.object({ query: z.string() }),
387
+ },
388
+ };
389
+ const result = buildAITools(undefined, { sub: mockAgent as never });
390
+ expect(result).toBeDefined();
391
+
392
+ const tools = result as Record<string, { description: string; execute: Function }>;
393
+ await expect(
394
+ tools["agent:sub"].execute({ query: "test" }, { toolCallId: "tc-1", messages: [] }),
395
+ ).rejects.toThrow("typed failed");
396
+ });
397
+ });
@@ -0,0 +1,197 @@
1
+ import type { LanguageModelUsage } from "ai";
2
+ import { tool } from "ai";
3
+ import { match, P } from "ts-pattern";
4
+ import type { ZodType } from "zod";
5
+ import { z } from "zod";
6
+
7
+ import type { Agent, Message } from "@/core/agents/base/types.js";
8
+ import { openrouter } from "@/core/provider/provider.js";
9
+ import type { LanguageModel, TokenUsage } from "@/core/provider/types.js";
10
+ import type { Tool } from "@/core/tool.js";
11
+ import type { Model } from "@/core/types.js";
12
+ import { RUNNABLE_META, type RunnableMeta } from "@/lib/runnable.js";
13
+
14
+ /**
15
+ * Resolve a display name for a sub-agent tool from its runnable
16
+ * metadata, falling back to the provided name.
17
+ *
18
+ * @param meta - The runnable metadata, or undefined if not available.
19
+ * @param fallback - The fallback name to use if metadata is missing.
20
+ * @returns The resolved tool name.
21
+ *
22
+ * @private
23
+ */
24
+ function resolveToolName(meta: RunnableMeta | undefined, fallback: string): string {
25
+ if (meta != null && meta.name != null) {
26
+ return meta.name;
27
+ }
28
+ return fallback;
29
+ }
30
+
31
+ /**
32
+ * Resolve a {@link Model} to an AI SDK `LanguageModel`.
33
+ */
34
+ export function resolveModel(ref: Model): LanguageModel {
35
+ if (typeof ref === "string") {
36
+ return openrouter(ref);
37
+ }
38
+ return ref as LanguageModel;
39
+ }
40
+
41
+ /**
42
+ * Merge `Tool` records and wrap subagent `Runnable` objects into AI SDK
43
+ * tool format for `generateText` / `streamText`.
44
+ *
45
+ * Tools created via `tool()` are already AI SDK tools and are
46
+ * passed through directly. Only subagents need wrapping.
47
+ *
48
+ * Parent tools are automatically forwarded to sub-agents so they
49
+ * can access the same capabilities (e.g. sandbox filesystem tools)
50
+ * without explicit injection at each call site.
51
+ */
52
+ export function buildAITools(
53
+ tools?: Record<string, Tool>,
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent generic params are contravariant; `unknown` breaks assignability
55
+ agents?: Record<string, Agent<any, any, any, any>>,
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ToolSet requires `any` values; `unknown` breaks assignability with AI SDK
57
+ ): Record<string, any> | undefined {
58
+ const hasTools = tools != null && Object.keys(tools).length > 0;
59
+ const hasAgents = agents != null && Object.keys(agents).length > 0;
60
+
61
+ if (!hasTools && !hasAgents) {
62
+ return undefined;
63
+ }
64
+
65
+ const agentTools = agents
66
+ ? Object.fromEntries(
67
+ Object.entries(agents).map(([name, runnable]) => {
68
+ // eslint-disable-next-line security/detect-object-injection -- Symbol-keyed property access; symbols cannot be user-controlled
69
+ const meta = (runnable as unknown as Record<symbol, unknown>)[RUNNABLE_META] as
70
+ | RunnableMeta
71
+ | undefined;
72
+ const toolName = resolveToolName(meta, name);
73
+ const agentToolName = `agent:${name}`;
74
+
75
+ const agentTool =
76
+ meta != null && meta.inputSchema != null
77
+ ? tool({
78
+ description: `Delegate to ${toolName}`,
79
+ inputSchema: meta.inputSchema,
80
+ execute: async (input, { abortSignal }) => {
81
+ const r = await runnable.generate(input, { signal: abortSignal, tools });
82
+ if (!r.ok) {
83
+ throw new Error(r.error.message);
84
+ }
85
+ return r.output;
86
+ },
87
+ })
88
+ : tool({
89
+ description: `Delegate to ${toolName}`,
90
+ inputSchema: z.object({ prompt: z.string().describe("The prompt to send") }),
91
+ execute: async (input: { prompt: string }, { abortSignal }) => {
92
+ const r = await runnable.generate(input.prompt, { signal: abortSignal, tools });
93
+ if (!r.ok) {
94
+ throw new Error(r.error.message);
95
+ }
96
+ return r.output;
97
+ },
98
+ });
99
+
100
+ return [agentToolName, agentTool];
101
+ }),
102
+ )
103
+ : {};
104
+
105
+ return { ...tools, ...agentTools };
106
+ }
107
+
108
+ /**
109
+ * Resolve the system prompt from config or override.
110
+ */
111
+ export function resolveSystem<TInput>(
112
+ system: string | ((params: { input: TInput }) => string) | undefined,
113
+ input: TInput,
114
+ ): string | undefined {
115
+ if (system == null) {
116
+ return undefined;
117
+ }
118
+ if (typeof system === "function") {
119
+ return system({ input });
120
+ }
121
+ return system;
122
+ }
123
+
124
+ /**
125
+ * Build the prompt/messages from input based on mode (typed vs simple).
126
+ *
127
+ * Returns a discriminated object: either `{ prompt }` or `{ messages }`,
128
+ * never both — matching the AI SDK's `Prompt` union type.
129
+ */
130
+ export function buildPrompt<TInput>(
131
+ input: TInput,
132
+ config: {
133
+ input?: ZodType<TInput>;
134
+ prompt?: (params: { input: TInput }) => string | Message[];
135
+ },
136
+ ): { prompt: string } | { messages: Message[] } {
137
+ const hasInput = Boolean(config.input);
138
+ const hasPrompt = Boolean(config.prompt);
139
+
140
+ return match({ hasInput, hasPrompt })
141
+ .with({ hasInput: true, hasPrompt: false }, () => {
142
+ throw new Error(
143
+ "Agent has `input` schema but no `prompt` function — both are required for typed mode",
144
+ );
145
+ })
146
+ .with({ hasInput: false, hasPrompt: true }, () => {
147
+ throw new Error(
148
+ "Agent has `prompt` function but no `input` schema — both are required for typed mode",
149
+ );
150
+ })
151
+ .with({ hasInput: true, hasPrompt: true }, () => {
152
+ // config.prompt is guaranteed non-null by the match
153
+ const promptFn = config.prompt as NonNullable<typeof config.prompt>;
154
+ const built = promptFn({ input });
155
+ return match(typeof built === "string")
156
+ .with(true, () => ({ prompt: built as string }))
157
+ .otherwise(() => ({ messages: built as Message[] }));
158
+ })
159
+ .otherwise(() =>
160
+ match(typeof input === "string")
161
+ .with(true, () => ({ prompt: input as string }))
162
+ .otherwise(() => ({ messages: input as Message[] })),
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Convert AI SDK's `LanguageModelUsage` to our flat `TokenUsage`.
168
+ *
169
+ * Maps nested `inputTokenDetails` / `outputTokenDetails` to flat
170
+ * fields, resolving `undefined` → `0`.
171
+ *
172
+ * @param usage - The AI SDK usage object (from `totalUsage`).
173
+ * @returns A resolved {@link TokenUsage} with all fields as numbers.
174
+ */
175
+ export function toTokenUsage(usage: LanguageModelUsage): TokenUsage {
176
+ const inputDetails = match(usage.inputTokenDetails)
177
+ .with(P.nonNullable, (d) => ({
178
+ cacheReadTokens: d.cacheReadTokens ?? 0,
179
+ cacheWriteTokens: d.cacheWriteTokens ?? 0,
180
+ }))
181
+ .otherwise(() => ({ cacheReadTokens: 0, cacheWriteTokens: 0 }));
182
+
183
+ const outputDetails = match(usage.outputTokenDetails)
184
+ .with(P.nonNullable, (d) => ({
185
+ reasoningTokens: d.reasoningTokens ?? 0,
186
+ }))
187
+ .otherwise(() => ({ reasoningTokens: 0 }));
188
+
189
+ return {
190
+ inputTokens: usage.inputTokens ?? 0,
191
+ outputTokens: usage.outputTokens ?? 0,
192
+ totalTokens: usage.totalTokens ?? 0,
193
+ cacheReadTokens: inputDetails.cacheReadTokens,
194
+ cacheWriteTokens: inputDetails.cacheWriteTokens,
195
+ reasoningTokens: outputDetails.reasoningTokens,
196
+ };
197
+ }