@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.
- package/.generated/req.txt +1 -0
- package/.turbo/turbo-build.log +21 -0
- package/.turbo/turbo-test$colon$coverage.log +109 -0
- package/.turbo/turbo-test.log +141 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +16 -0
- package/ISSUES.md +540 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/banner.svg +97 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
- package/coverage/lcov-report/core/agents/base/index.html +146 -0
- package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
- package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
- package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
- package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
- package/coverage/lcov-report/core/agents/flow/index.html +146 -0
- package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
- package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
- package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
- package/coverage/lcov-report/core/index.html +131 -0
- package/coverage/lcov-report/core/logger.ts.html +541 -0
- package/coverage/lcov-report/core/models/providers/index.html +116 -0
- package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
- package/coverage/lcov-report/core/provider/index.html +131 -0
- package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
- package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
- package/coverage/lcov-report/core/tool.ts.html +577 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +221 -0
- package/coverage/lcov-report/lib/hooks.ts.html +262 -0
- package/coverage/lcov-report/lib/index.html +161 -0
- package/coverage/lcov-report/lib/middleware.ts.html +274 -0
- package/coverage/lcov-report/lib/runnable.ts.html +151 -0
- package/coverage/lcov-report/lib/trace.ts.html +520 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/utils/attempt.ts.html +199 -0
- package/coverage/lcov-report/utils/error.ts.html +421 -0
- package/coverage/lcov-report/utils/index.html +176 -0
- package/coverage/lcov-report/utils/resolve.ts.html +208 -0
- package/coverage/lcov-report/utils/result.ts.html +538 -0
- package/coverage/lcov-report/utils/zod.ts.html +178 -0
- package/coverage/lcov.info +1566 -0
- package/dist/index.d.mts +2883 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2312 -0
- package/dist/index.mjs.map +1 -0
- package/docs/core/agent.md +231 -0
- package/docs/core/hooks.md +95 -0
- package/docs/core/overview.md +87 -0
- package/docs/core/step.md +279 -0
- package/docs/core/tools.md +98 -0
- package/docs/core/workflow.md +235 -0
- package/docs/guides/create-agent.md +224 -0
- package/docs/guides/create-tool.md +137 -0
- package/docs/guides/create-workflow.md +374 -0
- package/docs/overview.md +244 -0
- package/docs/provider/models.md +55 -0
- package/docs/provider/overview.md +106 -0
- package/docs/provider/usage.md +100 -0
- package/docs/research/experimental-context.md +167 -0
- package/docs/research/gap-analysis.md +86 -0
- package/docs/research/prepare-step-and-active-tools.md +138 -0
- package/docs/research/sub-agent-model.md +249 -0
- package/docs/troubleshooting.md +60 -0
- package/logo.svg +17 -0
- package/models.config.json +18 -0
- package/package.json +60 -0
- package/scripts/generate-models.ts +324 -0
- package/src/core/agents/base/agent.test.ts +1522 -0
- package/src/core/agents/base/agent.ts +547 -0
- package/src/core/agents/base/output.test.ts +93 -0
- package/src/core/agents/base/output.ts +57 -0
- package/src/core/agents/base/types.test-d.ts +69 -0
- package/src/core/agents/base/types.ts +503 -0
- package/src/core/agents/base/utils.test.ts +397 -0
- package/src/core/agents/base/utils.ts +197 -0
- package/src/core/agents/flow/engine.test.ts +452 -0
- package/src/core/agents/flow/engine.ts +281 -0
- package/src/core/agents/flow/flow-agent.test.ts +1027 -0
- package/src/core/agents/flow/flow-agent.ts +473 -0
- package/src/core/agents/flow/messages.test.ts +198 -0
- package/src/core/agents/flow/messages.ts +141 -0
- package/src/core/agents/flow/steps/agent.test.ts +280 -0
- package/src/core/agents/flow/steps/agent.ts +87 -0
- package/src/core/agents/flow/steps/all.test.ts +300 -0
- package/src/core/agents/flow/steps/all.ts +73 -0
- package/src/core/agents/flow/steps/builder.ts +124 -0
- package/src/core/agents/flow/steps/each.test.ts +257 -0
- package/src/core/agents/flow/steps/each.ts +61 -0
- package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
- package/src/core/agents/flow/steps/factory.test.ts +1025 -0
- package/src/core/agents/flow/steps/factory.ts +645 -0
- package/src/core/agents/flow/steps/map.test.ts +273 -0
- package/src/core/agents/flow/steps/map.ts +75 -0
- package/src/core/agents/flow/steps/race.test.ts +290 -0
- package/src/core/agents/flow/steps/race.ts +59 -0
- package/src/core/agents/flow/steps/reduce.test.ts +310 -0
- package/src/core/agents/flow/steps/reduce.ts +73 -0
- package/src/core/agents/flow/steps/result.ts +27 -0
- package/src/core/agents/flow/steps/step.test.ts +402 -0
- package/src/core/agents/flow/steps/step.ts +51 -0
- package/src/core/agents/flow/steps/while.test.ts +283 -0
- package/src/core/agents/flow/steps/while.ts +75 -0
- package/src/core/agents/flow/types.ts +348 -0
- package/src/core/logger.test.ts +163 -0
- package/src/core/logger.ts +152 -0
- package/src/core/models/index.test.ts +137 -0
- package/src/core/models/index.ts +152 -0
- package/src/core/models/providers/openai.ts +84 -0
- package/src/core/provider/provider.test.ts +128 -0
- package/src/core/provider/provider.ts +99 -0
- package/src/core/provider/types.ts +98 -0
- package/src/core/provider/usage.test.ts +304 -0
- package/src/core/provider/usage.ts +97 -0
- package/src/core/tool.test.ts +65 -0
- package/src/core/tool.ts +164 -0
- package/src/core/types.ts +66 -0
- package/src/index.ts +95 -0
- package/src/lib/context.test.ts +86 -0
- package/src/lib/context.ts +49 -0
- package/src/lib/hooks.test.ts +102 -0
- package/src/lib/hooks.ts +59 -0
- package/src/lib/middleware.test.ts +122 -0
- package/src/lib/middleware.ts +63 -0
- package/src/lib/runnable.test.ts +41 -0
- package/src/lib/runnable.ts +22 -0
- package/src/lib/trace.test.ts +291 -0
- package/src/lib/trace.ts +145 -0
- package/src/models/index.ts +123 -0
- package/src/models/providers/index.ts +15 -0
- package/src/models/providers/openai.ts +84 -0
- package/src/testing/context.ts +32 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/logger.ts +19 -0
- package/src/utils/attempt.test.ts +127 -0
- package/src/utils/attempt.ts +38 -0
- package/src/utils/error.test.ts +179 -0
- package/src/utils/error.ts +112 -0
- package/src/utils/resolve.test.ts +38 -0
- package/src/utils/resolve.ts +41 -0
- package/src/utils/result.test.ts +79 -0
- package/src/utils/result.ts +151 -0
- package/src/utils/zod.test.ts +69 -0
- package/src/utils/zod.ts +31 -0
- package/tsconfig.json +25 -0
- package/tsdown.config.ts +15 -0
- 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
|
+
});
|