@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,473 @@
1
+ import type { AsyncIterableStream } from "ai";
2
+
3
+ import type { Message, StreamPart } from "@/core/agents/base/types.js";
4
+ import {
5
+ collectTextFromMessages,
6
+ createAssistantMessage,
7
+ createUserMessage,
8
+ } from "@/core/agents/flow/messages.js";
9
+ import type { StepBuilder } from "@/core/agents/flow/steps/builder.js";
10
+ import { createStepBuilder } from "@/core/agents/flow/steps/factory.js";
11
+ import type {
12
+ FlowAgent,
13
+ FlowAgentConfig,
14
+ FlowAgentConfigWithOutput,
15
+ FlowAgentConfigWithoutOutput,
16
+ FlowAgentGenerateResult,
17
+ FlowAgentHandler,
18
+ FlowAgentOverrides,
19
+ InternalFlowAgentOptions,
20
+ } from "@/core/agents/flow/types.js";
21
+ import { createDefaultLogger } from "@/core/logger.js";
22
+ import type { Logger } from "@/core/logger.js";
23
+ import { sumTokenUsage } from "@/core/provider/usage.js";
24
+ import type { Context } from "@/lib/context.js";
25
+ import { fireHooks, wrapHook } from "@/lib/hooks.js";
26
+ import { RUNNABLE_META, type RunnableMeta } from "@/lib/runnable.js";
27
+ import type { TraceEntry } from "@/lib/trace.js";
28
+ import { collectUsages, snapshotTrace } from "@/lib/trace.js";
29
+ import { toError } from "@/utils/error.js";
30
+ import type { Result } from "@/utils/result.js";
31
+
32
+ /**
33
+ * Resolve the logger for a single flow agent execution.
34
+ *
35
+ * @private
36
+ */
37
+ function resolveFlowAgentLogger(
38
+ base: Logger,
39
+ flowAgentId: string,
40
+ overrides?: FlowAgentOverrides,
41
+ ): Logger {
42
+ const override = overrides && overrides.logger;
43
+ return (override ?? base).child({ flowAgentId });
44
+ }
45
+
46
+ /**
47
+ * Augment the step builder with custom steps from the engine.
48
+ *
49
+ * @private
50
+ */
51
+ function augmentStepBuilder(
52
+ base: StepBuilder,
53
+ ctx: Context,
54
+ internal: InternalFlowAgentOptions | undefined,
55
+ ): StepBuilder {
56
+ if (internal && internal.augment$) {
57
+ return internal.augment$(base, ctx);
58
+ }
59
+ return base;
60
+ }
61
+
62
+ /**
63
+ * Create a flow agent with typed input/output, tracked steps, and hooks.
64
+ *
65
+ * A flow agent is an agent whose behavior is defined by code, not by an LLM.
66
+ * You write the orchestration logic — calling sub-agents, running steps,
67
+ * using concurrency primitives — and the framework wraps it in the same
68
+ * API surface as a regular `agent`.
69
+ *
70
+ * To consumers, a `FlowAgent` IS an `Agent`. Same `.generate()`, same
71
+ * `.stream()`, same `.fn()`. Same `GenerateResult` return type. Same
72
+ * `messages` array. The only difference is internal: an `agent` runs
73
+ * an LLM tool loop, a `flowAgent` runs your handler function.
74
+ *
75
+ * Each `$` step is modeled as a synthetic tool call in the message history.
76
+ *
77
+ * @typeParam TInput - Input type, inferred from the `input` Zod schema.
78
+ * @typeParam TOutput - Output type, inferred from the `output` Zod schema.
79
+ * @param config - Flow agent configuration including name, schemas,
80
+ * hooks, and logger.
81
+ * @param handler - The flow agent handler function that receives
82
+ * validated input and the `$` step builder.
83
+ * @param _internal - Internal options used by the engine. Not public API.
84
+ * @returns A `FlowAgent` instance with `.generate()`, `.stream()`,
85
+ * and `.fn()`.
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * const pipeline = flowAgent({
90
+ * name: 'doc-pipeline',
91
+ * input: z.object({ repo: z.string() }),
92
+ * output: z.object({ docs: z.array(z.string()) }),
93
+ * }, async ({ input, $ }) => {
94
+ * const files = await $.step({
95
+ * id: 'scan-repo',
96
+ * execute: () => scanRepo(input.repo),
97
+ * })
98
+ *
99
+ * if (!files.ok) throw files.error
100
+ *
101
+ * const docs = await $.map({
102
+ * id: 'generate-docs',
103
+ * input: files.value,
104
+ * execute: async ({ item, $ }) => {
105
+ * const result = await $.agent({
106
+ * id: 'write-doc',
107
+ * agent: writerAgent,
108
+ * input: item,
109
+ * })
110
+ * return result.ok ? result.value.output : ''
111
+ * },
112
+ * concurrency: 3,
113
+ * })
114
+ *
115
+ * return { docs: docs.ok ? docs.value : [] }
116
+ * })
117
+ * ```
118
+ */
119
+ /**
120
+ * Create a flow agent with structured output.
121
+ *
122
+ * @typeParam TInput - Input type, inferred from the `input` Zod schema.
123
+ * @typeParam TOutput - Output type, inferred from the `output` Zod schema.
124
+ */
125
+ export function flowAgent<TInput, TOutput>(
126
+ config: FlowAgentConfigWithOutput<TInput, TOutput>,
127
+ handler: FlowAgentHandler<TInput, TOutput>,
128
+ _internal?: InternalFlowAgentOptions,
129
+ ): FlowAgent<TInput, TOutput>;
130
+
131
+ /**
132
+ * Create a flow agent without structured output.
133
+ *
134
+ * The handler returns `void` — sub-agent text is collected as the
135
+ * `string` output. Ideal for orchestration-only flows where the
136
+ * sub-agents produce the final text.
137
+ *
138
+ * @typeParam TInput - Input type, inferred from the `input` Zod schema.
139
+ */
140
+ export function flowAgent<TInput>(
141
+ config: FlowAgentConfigWithoutOutput<TInput>,
142
+ handler: FlowAgentHandler<TInput, void>,
143
+ _internal?: InternalFlowAgentOptions,
144
+ ): FlowAgent<TInput, string>;
145
+
146
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation signature must accept both overloads
147
+ export function flowAgent<TInput, TOutput = any>(
148
+ config: FlowAgentConfig<TInput, TOutput>,
149
+ handler: FlowAgentHandler<TInput, TOutput> | FlowAgentHandler<TInput, void>,
150
+ _internal?: InternalFlowAgentOptions,
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- widened return to satisfy both overloads
152
+ ): FlowAgent<TInput, any> {
153
+ const baseLogger = config.logger ?? createDefaultLogger();
154
+
155
+ /**
156
+ * Resolve the handler output into a final value, validating against
157
+ * the output schema when present. Also pushes the assistant message.
158
+ *
159
+ * Returns `{ ok: true, value }` on success, or `{ ok: false, message }`
160
+ * when output validation fails.
161
+ *
162
+ * @private
163
+ */
164
+ function resolveFlowOutput(
165
+ output: unknown,
166
+ messages: Message[],
167
+ ): { ok: true; value: unknown } | { ok: false; message: string } {
168
+ if (config.output !== undefined) {
169
+ const outputParsed = config.output.safeParse(output);
170
+ if (!outputParsed.success) {
171
+ return { ok: false, message: `Output validation failed: ${outputParsed.error.message}` };
172
+ }
173
+ messages.push(createAssistantMessage(outputParsed.data));
174
+ return { ok: true, value: outputParsed.data };
175
+ }
176
+ const text = collectTextFromMessages(messages);
177
+ messages.push(createAssistantMessage(text));
178
+ return { ok: true, value: text };
179
+ }
180
+
181
+ async function generate(
182
+ input: TInput,
183
+ overrides?: FlowAgentOverrides,
184
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- widened to satisfy both overloads
185
+ ): Promise<Result<FlowAgentGenerateResult<any>>> {
186
+ const inputParsed = config.input.safeParse(input);
187
+ if (!inputParsed.success) {
188
+ return {
189
+ ok: false,
190
+ error: {
191
+ code: "VALIDATION_ERROR",
192
+ message: `Input validation failed: ${inputParsed.error.message}`,
193
+ },
194
+ };
195
+ }
196
+ const parsedInput = inputParsed.data as TInput;
197
+
198
+ const startedAt = Date.now();
199
+ const log = resolveFlowAgentLogger(baseLogger, config.name, overrides);
200
+
201
+ const signal = (overrides && overrides.signal) || new AbortController().signal;
202
+ const trace: TraceEntry[] = [];
203
+ const messages: Message[] = [];
204
+ const ctx: Context = { signal, log, trace, messages };
205
+
206
+ const base$ = createStepBuilder({
207
+ ctx,
208
+ parentHooks: {
209
+ onStepStart: config.onStepStart,
210
+ onStepFinish: config.onStepFinish,
211
+ },
212
+ });
213
+
214
+ const $ = augmentStepBuilder(base$, ctx, _internal);
215
+
216
+ // Push user message
217
+ messages.push(createUserMessage(parsedInput));
218
+
219
+ await fireHooks(
220
+ log,
221
+ wrapHook(config.onStart, { input: parsedInput }),
222
+ wrapHook(overrides && overrides.onStart, { input: parsedInput }),
223
+ );
224
+
225
+ log.debug("flowAgent.generate start", { name: config.name });
226
+
227
+ try {
228
+ const output = await (handler as FlowAgentHandler<TInput, TOutput>)({
229
+ input: parsedInput,
230
+ $,
231
+ log,
232
+ });
233
+
234
+ const outputResult = resolveFlowOutput(output, messages);
235
+ if (!outputResult.ok) {
236
+ return {
237
+ ok: false,
238
+ error: {
239
+ code: "VALIDATION_ERROR",
240
+ message: outputResult.message,
241
+ },
242
+ };
243
+ }
244
+ const resolvedOutput = outputResult.value;
245
+
246
+ const duration = Date.now() - startedAt;
247
+
248
+ const usage = sumTokenUsage(collectUsages(trace));
249
+ const frozenTrace = snapshotTrace(trace);
250
+
251
+ const result: FlowAgentGenerateResult<unknown> = {
252
+ output: resolvedOutput,
253
+ messages: [...messages],
254
+ usage,
255
+ finishReason: "stop",
256
+ trace: frozenTrace,
257
+ duration,
258
+ };
259
+
260
+ await fireHooks(
261
+ log,
262
+ wrapHook(
263
+ config.onFinish as
264
+ | ((event: {
265
+ input: TInput;
266
+ result: FlowAgentGenerateResult<unknown>;
267
+ duration: number;
268
+ }) => void | Promise<void>)
269
+ | undefined,
270
+ { input: parsedInput, result, duration },
271
+ ),
272
+ wrapHook(overrides && overrides.onFinish, {
273
+ input: parsedInput,
274
+ result: result as import("@/core/agents/base/types.js").GenerateResult,
275
+ duration,
276
+ }),
277
+ );
278
+
279
+ log.debug("flowAgent.generate finish", { name: config.name, duration });
280
+
281
+ return { ok: true, ...result };
282
+ } catch (thrown) {
283
+ const error = toError(thrown);
284
+ const duration = Date.now() - startedAt;
285
+
286
+ log.error("flowAgent.generate error", { name: config.name, error: error.message, duration });
287
+
288
+ await fireHooks(
289
+ log,
290
+ wrapHook(config.onError, { input: parsedInput, error }),
291
+ wrapHook(overrides && overrides.onError, { input: parsedInput, error }),
292
+ );
293
+
294
+ return {
295
+ ok: false,
296
+ error: {
297
+ code: "FLOW_AGENT_ERROR",
298
+ message: error.message,
299
+ cause: error,
300
+ },
301
+ };
302
+ }
303
+ }
304
+
305
+ async function stream(
306
+ input: TInput,
307
+ overrides?: FlowAgentOverrides,
308
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- widened to satisfy both overloads
309
+ ): Promise<Result<import("@/core/agents/base/types.js").StreamResult<any>>> {
310
+ const inputParsed = config.input.safeParse(input);
311
+ if (!inputParsed.success) {
312
+ return {
313
+ ok: false,
314
+ error: {
315
+ code: "VALIDATION_ERROR",
316
+ message: `Input validation failed: ${inputParsed.error.message}`,
317
+ },
318
+ };
319
+ }
320
+ const parsedInput = inputParsed.data as TInput;
321
+
322
+ const startedAt = Date.now();
323
+ const log = resolveFlowAgentLogger(baseLogger, config.name, overrides);
324
+
325
+ const signal = (overrides && overrides.signal) || new AbortController().signal;
326
+ const trace: TraceEntry[] = [];
327
+ const messages: Message[] = [];
328
+ const ctx: Context = { signal, log, trace, messages };
329
+
330
+ const { readable, writable } = new TransformStream<StreamPart, StreamPart>();
331
+ const writer = writable.getWriter();
332
+
333
+ const base$ = createStepBuilder({
334
+ ctx,
335
+ parentHooks: {
336
+ onStepStart: config.onStepStart,
337
+ onStepFinish: config.onStepFinish,
338
+ },
339
+ writer,
340
+ });
341
+
342
+ const $ = augmentStepBuilder(base$, ctx, _internal);
343
+
344
+ // Push user message
345
+ messages.push(createUserMessage(parsedInput));
346
+
347
+ await fireHooks(
348
+ log,
349
+ wrapHook(config.onStart, { input: parsedInput }),
350
+ wrapHook(overrides && overrides.onStart, { input: parsedInput }),
351
+ );
352
+
353
+ log.debug("flowAgent.stream start", { name: config.name });
354
+
355
+ // Run handler in background, piping results through stream
356
+ const done = (async () => {
357
+ try {
358
+ const output = await (handler as FlowAgentHandler<TInput, TOutput>)({
359
+ input: parsedInput,
360
+ $,
361
+ log,
362
+ });
363
+
364
+ const outputResult = resolveFlowOutput(output, messages);
365
+ if (!outputResult.ok) {
366
+ throw new Error(outputResult.message);
367
+ }
368
+ const resolvedOutput = outputResult.value;
369
+
370
+ const duration = Date.now() - startedAt;
371
+
372
+ const usage = sumTokenUsage(collectUsages(trace));
373
+
374
+ const result: FlowAgentGenerateResult<unknown> = {
375
+ output: resolvedOutput,
376
+ messages: [...messages],
377
+ usage,
378
+ finishReason: "stop",
379
+ trace: snapshotTrace(trace),
380
+ duration,
381
+ };
382
+
383
+ await fireHooks(
384
+ log,
385
+ wrapHook(
386
+ config.onFinish as
387
+ | ((event: {
388
+ input: TInput;
389
+ result: FlowAgentGenerateResult<unknown>;
390
+ duration: number;
391
+ }) => void | Promise<void>)
392
+ | undefined,
393
+ { input: parsedInput, result, duration },
394
+ ),
395
+ wrapHook(overrides && overrides.onFinish, {
396
+ input: parsedInput,
397
+ result: result as import("@/core/agents/base/types.js").GenerateResult,
398
+ duration,
399
+ }),
400
+ );
401
+
402
+ log.debug("flowAgent.stream finish", { name: config.name, duration });
403
+
404
+ // Emit finish event and close the stream
405
+ await writer.write({
406
+ type: "finish",
407
+ finishReason: "stop",
408
+ rawFinishReason: undefined,
409
+ totalUsage: {
410
+ inputTokens: usage.inputTokens,
411
+ outputTokens: usage.outputTokens,
412
+ totalTokens: usage.totalTokens,
413
+ },
414
+ } as StreamPart);
415
+ await writer.close();
416
+
417
+ return result;
418
+ } catch (thrown) {
419
+ const error = toError(thrown);
420
+ const duration = Date.now() - startedAt;
421
+
422
+ log.error("flowAgent.stream error", { name: config.name, error: error.message, duration });
423
+
424
+ // Emit error event and close the stream
425
+ /* v8 ignore start -- defensive; writer rarely rejects in practice */
426
+ await writer.write({ type: "error", error } as StreamPart).catch((err) => {
427
+ log.debug("failed to write error event to stream", { err });
428
+ });
429
+ await writer.close().catch((err) => {
430
+ log.debug("failed to close stream writer", { err });
431
+ });
432
+ /* v8 ignore stop */
433
+
434
+ await fireHooks(
435
+ log,
436
+ wrapHook(config.onError, { input: parsedInput, error }),
437
+ wrapHook(overrides && overrides.onError, { input: parsedInput, error }),
438
+ );
439
+
440
+ throw error;
441
+ }
442
+ })();
443
+
444
+ // Catch stream errors to prevent unhandled rejections
445
+ done.catch(() => {});
446
+
447
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- widened to satisfy both overloads
448
+ const streamResult: import("@/core/agents/base/types.js").StreamResult<any> = {
449
+ output: done.then((r) => r.output),
450
+ messages: done.then((r) => r.messages),
451
+ usage: done.then((r) => r.usage),
452
+ finishReason: done.then((r) => r.finishReason),
453
+ fullStream: readable as AsyncIterableStream<StreamPart>,
454
+ };
455
+
456
+ return { ok: true, ...streamResult };
457
+ }
458
+
459
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- widened to satisfy both overloads
460
+ const agent: FlowAgent<TInput, any> = {
461
+ generate,
462
+ stream,
463
+ fn: () => generate,
464
+ };
465
+
466
+ // eslint-disable-next-line security/detect-object-injection -- Symbol-keyed property access; symbols cannot be user-controlled
467
+ (agent as unknown as Record<symbol, unknown>)[RUNNABLE_META] = {
468
+ name: config.name,
469
+ inputSchema: config.input,
470
+ } satisfies RunnableMeta;
471
+
472
+ return agent;
473
+ }
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ buildToolCallId,
5
+ collectTextFromMessages,
6
+ createAssistantMessage,
7
+ createToolCallMessage,
8
+ createToolResultMessage,
9
+ createUserMessage,
10
+ } from "@/core/agents/flow/messages.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // buildToolCallId
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe("buildToolCallId", () => {
17
+ it("concatenates stepId and index with a dash", () => {
18
+ expect(buildToolCallId("scan-repo", 0)).toBe("scan-repo-0");
19
+ expect(buildToolCallId("write-doc", 3)).toBe("write-doc-3");
20
+ });
21
+ });
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // createToolCallMessage
25
+ // ---------------------------------------------------------------------------
26
+
27
+ describe("createToolCallMessage", () => {
28
+ it("returns an assistant message with a tool-call content part", () => {
29
+ const msg = createToolCallMessage("call-1", "my-step", { x: 42 });
30
+
31
+ expect(msg.role).toBe("assistant");
32
+ expect(msg.content).toEqual([
33
+ {
34
+ type: "tool-call",
35
+ toolCallId: "call-1",
36
+ toolName: "my-step",
37
+ input: { x: 42 },
38
+ },
39
+ ]);
40
+ });
41
+
42
+ it("defaults input to {} when null/undefined", () => {
43
+ const msg = createToolCallMessage("call-1", "step", undefined);
44
+
45
+ const part = (msg.content as Array<{ input: unknown }>)[0];
46
+ expect(part?.input).toEqual({});
47
+ });
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // createToolResultMessage
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe("createToolResultMessage", () => {
55
+ it("returns a tool message with a tool-result content part", () => {
56
+ const msg = createToolResultMessage("call-1", "my-step", { result: "ok" });
57
+
58
+ expect(msg.role).toBe("tool");
59
+ expect(msg.content).toEqual([
60
+ {
61
+ type: "tool-result",
62
+ toolCallId: "call-1",
63
+ toolName: "my-step",
64
+ output: { result: "ok" },
65
+ },
66
+ ]);
67
+ });
68
+
69
+ it("includes isError when true", () => {
70
+ const msg = createToolResultMessage("call-1", "step", "failed", true);
71
+
72
+ const part = (msg.content as Array<Record<string, unknown>>)[0];
73
+ expect(part?.isError).toBe(true);
74
+ });
75
+
76
+ it("omits isError when falsy", () => {
77
+ const msg = createToolResultMessage("call-1", "step", "ok");
78
+
79
+ const part = (msg.content as Array<Record<string, unknown>>)[0];
80
+ expect(part?.isError).toBeUndefined();
81
+ });
82
+
83
+ it("defaults output to empty object when undefined", () => {
84
+ const msg = createToolResultMessage("call-1", "step", undefined);
85
+
86
+ const part = (msg.content as Array<{ output: unknown }>)[0];
87
+ expect(part?.output).toEqual({});
88
+ });
89
+ });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // createUserMessage
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe("createUserMessage", () => {
96
+ it("creates a user message from a string input", () => {
97
+ const msg = createUserMessage("hello world");
98
+
99
+ expect(msg.role).toBe("user");
100
+ expect(msg.content).toBe("hello world");
101
+ });
102
+
103
+ it("JSON-stringifies non-string input", () => {
104
+ const msg = createUserMessage({ topic: "TypeScript" });
105
+
106
+ expect(msg.role).toBe("user");
107
+ expect(msg.content).toBe('{"topic":"TypeScript"}');
108
+ });
109
+
110
+ it("does not throw for non-serializable input", () => {
111
+ const circular: Record<string, unknown> = {};
112
+ circular.self = circular;
113
+
114
+ expect(() => createUserMessage(circular)).not.toThrow();
115
+ expect(createUserMessage(circular).role).toBe("user");
116
+ });
117
+
118
+ it('serializes undefined input as "null"', () => {
119
+ const msg = createUserMessage(undefined);
120
+
121
+ expect(msg.role).toBe("user");
122
+ expect(msg.content).toBe("null");
123
+ });
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // createAssistantMessage
128
+ // ---------------------------------------------------------------------------
129
+
130
+ describe("createAssistantMessage", () => {
131
+ it("creates an assistant message from a string output", () => {
132
+ const msg = createAssistantMessage("response text");
133
+
134
+ expect(msg.role).toBe("assistant");
135
+ expect(msg.content).toBe("response text");
136
+ });
137
+
138
+ it("JSON-stringifies non-string output", () => {
139
+ const msg = createAssistantMessage({ docs: ["a", "b"] });
140
+
141
+ expect(msg.role).toBe("assistant");
142
+ expect(msg.content).toBe('{"docs":["a","b"]}');
143
+ });
144
+
145
+ it("does not throw for non-serializable output", () => {
146
+ const circular: Record<string, unknown> = {};
147
+ circular.self = circular;
148
+
149
+ expect(() => createAssistantMessage(circular)).not.toThrow();
150
+ expect(createAssistantMessage(circular).role).toBe("assistant");
151
+ });
152
+ });
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // collectTextFromMessages
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("collectTextFromMessages", () => {
159
+ it("collects text from assistant messages with string content", () => {
160
+ const messages = [
161
+ { role: "user" as const, content: "hello" },
162
+ { role: "assistant" as const, content: "response 1" },
163
+ { role: "assistant" as const, content: "response 2" },
164
+ ];
165
+
166
+ expect(collectTextFromMessages(messages)).toBe("response 1\nresponse 2");
167
+ });
168
+
169
+ it("ignores non-assistant messages", () => {
170
+ const messages = [
171
+ { role: "user" as const, content: "hello" },
172
+ {
173
+ role: "tool" as const,
174
+ content: [{ type: "tool-result" as const, toolCallId: "1", toolName: "t", output: "ok" }],
175
+ } as never,
176
+ ];
177
+
178
+ expect(collectTextFromMessages(messages)).toBe("");
179
+ });
180
+
181
+ it("ignores assistant messages with non-string content", () => {
182
+ const messages = [
183
+ {
184
+ role: "assistant" as const,
185
+ content: [
186
+ { type: "tool-call" as const, toolCallId: "1", toolName: "t", input: {} },
187
+ ] as never,
188
+ },
189
+ { role: "assistant" as const, content: "text part" },
190
+ ];
191
+
192
+ expect(collectTextFromMessages(messages)).toBe("text part");
193
+ });
194
+
195
+ it("returns empty string for empty messages array", () => {
196
+ expect(collectTextFromMessages([])).toBe("");
197
+ });
198
+ });