diff --git a/.changeset/step-finish-ai-sdk-passthrough.md b/.changeset/step-finish-ai-sdk-passthrough.md new file mode 100644 index 0000000..f1d0076 --- /dev/null +++ b/.changeset/step-finish-ai-sdk-passthrough.md @@ -0,0 +1,7 @@ +--- +"@funkai/agents": major +--- + +Pass through full AI SDK `StepResult` fields in `onStepFinish` events instead of stripping tool calls/results to summary fields. `StepFinishEvent` is now a superset of the Vercel AI SDK's `StepResult` — all SDK fields (`text`, `toolCalls`, `toolResults`, `finishReason`, `usage`, `reasoning`, `sources`, `response`, etc.) are passed through unchanged, plus funkai-specific additions (`stepId`, `agentChain`). + +**Breaking:** `toolCalls` entries now contain full AI SDK `TypedToolCall` objects (with `input`) instead of `{ toolName, argsTextLength }`. `toolResults` entries now contain full `TypedToolResult` objects (with `output`) instead of `{ toolName, resultTextLength }`. `usage` is now the AI SDK's `LanguageModelUsage` type (with `undefined`-able fields) instead of a simplified `{ inputTokens: number; outputTokens: number; totalTokens: number }`. diff --git a/docs/concepts/flow-agents.md b/docs/concepts/flow-agents.md index 4ffcca6..7865b3b 100644 --- a/docs/concepts/flow-agents.md +++ b/docs/concepts/flow-agents.md @@ -31,7 +31,7 @@ const pipeline = flowAgent( execute: async () => input.topic.toLowerCase().replace(/\s+/g, "-"), }); - // $.agent — tracked agent call, returns StepResult + // $.agent — tracked agent call, returns FlowAgentStepResult const draft = await $.agent({ id: "write-draft", agent: writer, @@ -42,7 +42,7 @@ const pipeline = flowAgent( return { text: "Generation failed." }; } - return { text: draft.value.output }; + return { text: draft.output }; }, ); @@ -57,7 +57,7 @@ if (result.ok) { ## The $ step builder -`$` provides operations that are tracked in the execution trace. All return `Promise>` — check `.ok` before using `.value`. +`$` provides operations that are tracked in the execution trace. All return `Promise>` — check `.ok` before using `.output`. | Operation | Description | | ---------- | ------------------------------------------------------ | @@ -86,7 +86,7 @@ const result = await pipeline.stream({ input: { topic: "closures" } }); if (result.ok) { for await (const event of result.fullStream) { if (event.type === "step:finish") { - console.log(event.step.id, "done in", event.duration, "ms"); + console.log(event.stepId, "done in", event.duration, "ms"); } } } diff --git a/docs/introduction.md b/docs/introduction.md index 218a8b4..43e4ec8 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -53,14 +53,14 @@ const pipeline = flowAgent( execute: async ({ item, $ }) => { const result = await $.agent({ id: "write", agent: writer, input: item }); if (result.ok) { - return result.value.output; + return result.output; } return ""; }, concurrency: 3, }); if (docs.ok) { - return { docs: docs.value }; + return { docs: docs.output }; } return { docs: [] }; }, diff --git a/docs/principles.md b/docs/principles.md index 5a70b0c..c4ca058 100644 --- a/docs/principles.md +++ b/docs/principles.md @@ -56,7 +56,7 @@ const counter = flowAgent( input: input.prompt, }); if (result.ok) { - answer = result.value.output; + answer = result.output; } attempts += 1; } @@ -112,7 +112,7 @@ const pipeline = flowAgent( // Untraced -- plain function call, not in trace let analysisText; if (analysis.ok) { - analysisText = analysis.value.output; + analysisText = analysis.output; } else { analysisText = input.text; } @@ -122,7 +122,7 @@ const pipeline = flowAgent( const final = await $.step({ id: "format", execute: () => formatOutput(cleaned) }); if (final.ok) { - return { result: final.value }; + return { result: final.output }; } return { result: cleaned }; }, diff --git a/docs/quick-start.md b/docs/quick-start.md index 7c719d5..b1f7c3a 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -108,13 +108,13 @@ const pipeline = flowAgent( const reviewed = await $.agent({ id: "review-draft", agent: reviewer, - input: { draft: draft.value.output }, + input: { draft: draft.output }, }); if (reviewed.ok) { - return { final: reviewed.value.output }; + return { final: reviewed.output }; } - return { final: draft.value.output }; + return { final: draft.output }; }, ); diff --git a/docs/reference/flow-agent.md b/docs/reference/flow-agent.md index 33d2b8b..3e0c44c 100644 --- a/docs/reference/flow-agent.md +++ b/docs/reference/flow-agent.md @@ -32,7 +32,7 @@ function flowAgent( | `logger` | `Resolver` | No | default | Pino-compatible logger | | `onStart` | `(event: { input: TInput }) => void \| Promise` | No | — | Fires when flow starts | | `onError` | `(event: { input: TInput; error: Error }) => void \| Promise` | No | — | Fires on error | -| `onStepStart` | `(event: { step: StepInfo }) => void \| Promise` | No | — | Fires when a `$` step starts | +| `onStepStart` | `(event: StepStartEvent) => void \| Promise` | No | — | Fires when a `$` step starts | | `onStepFinish` | `(event: StepFinishEvent) => void \| Promise` | No | — | Fires when a `$` step finishes | ### With output (`FlowAgentConfigWithOutput`) @@ -98,16 +98,16 @@ interface FlowAgent { The `$` object provides tracked operations. Every call appears in the execution trace. `$` is passed into nested callbacks so operations can be composed. -| Method | Signature | Returns | Description | -| ---------- | -------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------ | -| `$.step` | `(config: StepConfig) => Promise>` | `StepResult` | Single unit of work | -| `$.agent` | `(config: AgentStepConfig) => Promise>` | `StepResult` | Agent call as tracked step | -| `$.map` | `(config: MapConfig) => Promise>` | `StepResult` | Parallel map with optional concurrency | -| `$.each` | `(config: EachConfig) => Promise>` | `StepResult` | Sequential side effects | -| `$.reduce` | `(config: ReduceConfig) => Promise>` | `StepResult` | Sequential accumulation | -| `$.while` | `(config: WhileConfig) => Promise>` | `StepResult` | Conditional loop | -| `$.all` | `(config: AllConfig) => Promise>` | `StepResult` | Concurrent heterogeneous ops (Promise.all) | -| `$.race` | `(config: RaceConfig) => Promise>` | `StepResult` | First-to-finish wins (Promise.race) | +| Method | Signature | Returns | Description | +| ---------- | --------------------------------------------------------------------- | -------------------------------- | ------------------------------------------ | +| `$.step` | `(config: StepConfig) => Promise>` | `FlowStepResult` | Single unit of work | +| `$.agent` | `(config: AgentStepConfig) => Promise` | `FlowAgentStepResult` | Agent call as tracked step | +| `$.map` | `(config: MapConfig) => Promise>` | `FlowStepResult` | Parallel map with optional concurrency | +| `$.each` | `(config: EachConfig) => Promise>` | `FlowStepResult` | Sequential side effects | +| `$.reduce` | `(config: ReduceConfig) => Promise>` | `FlowStepResult` | Sequential accumulation | +| `$.while` | `(config: WhileConfig) => Promise>` | `FlowStepResult` | Conditional loop | +| `$.all` | `(config: AllConfig) => Promise>` | `FlowStepResult` | Concurrent heterogeneous ops (Promise.all) | +| `$.race` | `(config: RaceConfig) => Promise>` | `FlowStepResult` | First-to-finish wins (Promise.race) | ### StepConfig @@ -220,12 +220,26 @@ interface RaceConfig { } ``` -## StepResult +## FlowStepResult ```typescript -type StepResult = - | { ok: true; value: T; step: StepInfo; duration: number } - | { ok: false; error: StepError; step: StepInfo; duration: number }; +type FlowStepResult = + | { + ok: true; + output: T; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + } + | { + ok: false; + error: StepError; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + }; interface StepError extends ResultError { stepId: string; // the id from the failed step config @@ -254,29 +268,36 @@ interface TraceEntry { type OperationType = "step" | "agent" | "map" | "each" | "reduce" | "while" | "all" | "race"; ``` -## StepInfo +## StepStartEvent ```typescript -interface StepInfo { - id: string; - index: number; // auto-incrementing, starts at 0 - type: OperationType; +interface StepStartEvent { + stepId: string; // from the $ config's `id` field + stepOperation: OperationType; // 'step' | 'agent' | 'map' | 'each' | 'reduce' | 'while' | 'all' | 'race' + agentChain?: AgentChainEntry[]; } ``` ## StepFinishEvent -Emitted by `onStepFinish`. Agent tool-loop steps populate the left columns; flow orchestration steps populate the right. - -| Field | Type | Present on | -| ------------- | -------------------------------------------------------------------- | ------------------------ | -| `stepId` | `string` | Agent tool-loop steps | -| `toolCalls` | `readonly { toolName: string; argsTextLength: number }[]` | Agent tool-loop steps | -| `toolResults` | `readonly { toolName: string; resultTextLength: number }[]` | Agent tool-loop steps | -| `usage` | `{ inputTokens: number; outputTokens: number; totalTokens: number }` | Agent tool-loop steps | -| `step` | `StepInfo` | Flow orchestration steps | -| `result` | `unknown` | Flow orchestration steps | -| `duration` | `number` | Flow orchestration steps | +Emitted by `onStepFinish`. For agent tool-loop steps, the event is a full superset of the Vercel AI SDK's `StepResult` — all SDK fields are passed through unchanged, plus funkai-specific additions. For flow `$.agent()` steps, the event carries both flow fields (`output`, `duration`) and the AI SDK fields from the last tool-loop step. Non-agent flow steps (`$.step()`, `$.map()`, etc.) only have the flow-specific fields. + +| Field | Type | Present on | Description | +| --------------- | ---------------------------------------------- | ---------------------------------- | ---------------------------------------------- | +| `stepId` | `string` | All steps | funkai addition: the `$` config `id` | +| `stepOperation` | `OperationType` | All steps | funkai addition: operation type | +| `agentChain` | `AgentChainEntry[]` | All steps | funkai addition: agent ancestry chain | +| `stepNumber` | `number` | Agent tool-loop + flow `$.agent()` | AI SDK: zero-based step index | +| `text` | `string` | Agent tool-loop + flow `$.agent()` | AI SDK: generated text | +| `toolCalls` | `TypedToolCall[]` | Agent tool-loop + flow `$.agent()` | AI SDK: full tool call objects with `input` | +| `toolResults` | `TypedToolResult[]` | Agent tool-loop + flow `$.agent()` | AI SDK: full tool result objects with `output` | +| `finishReason` | `FinishReason` | Agent tool-loop + flow `$.agent()` | AI SDK: why the step ended | +| `usage` | `LanguageModelUsage` | Agent tool-loop + flow `$.agent()` | AI SDK: token usage | +| `reasoning` | `ReasoningPart[]` | Agent tool-loop + flow `$.agent()` | AI SDK: reasoning content | +| `sources` | `Source[]` | Agent tool-loop + flow `$.agent()` | AI SDK: cited sources | +| `response` | `LanguageModelResponseMetadata & { messages }` | Agent tool-loop + flow `$.agent()` | AI SDK: response metadata | +| `output` | `unknown` | Flow orchestration steps | Flow step output value | +| `duration` | `number` | Flow orchestration steps | Flow step duration in ms | ## FlowAgentOverrides @@ -306,7 +327,7 @@ function createFlowEngine( | `onStart` | `(event: { input: unknown }) => void \| Promise` | Default start hook for all flow agents | | `onFinish` | `(event: { input: unknown; result: unknown; duration: number }) => void \| Promise` | Default finish hook | | `onError` | `(event: { input: unknown; error: Error }) => void \| Promise` | Default error hook | -| `onStepStart` | `(event: { step: StepInfo }) => void \| Promise` | Default step-start hook | +| `onStepStart` | `(event: StepStartEvent) => void \| Promise` | Default step-start hook | | `onStepFinish` | `(event: StepFinishEvent) => void \| Promise` | Default step-finish hook | ### CustomStepFactory diff --git a/examples/flow-agent-steps/src/index.ts b/examples/flow-agent-steps/src/index.ts index d093a67..ad66e8a 100644 --- a/examples/flow-agent-steps/src/index.ts +++ b/examples/flow-agent-steps/src/index.ts @@ -44,7 +44,7 @@ const stepsDemo = flowAgent( throw new Error(`Validation failed: ${stepResult.error.message}`); } - log.info({ count: stepResult.value.count }, "Input validated"); + log.info({ count: stepResult.output.count }, "Input validated"); // ----------------------------------------------------------------------- // $.map — parallel map over items @@ -64,7 +64,7 @@ const stepsDemo = flowAgent( throw new Error(`Map failed: ${mapResult.error.message}`); } - const doubled = mapResult.value; + const doubled = mapResult.output; // ----------------------------------------------------------------------- // $.each — sequential side effects (returns void) @@ -84,7 +84,7 @@ const stepsDemo = flowAgent( id: "sum-numbers", input: doubled, initial: 0, - execute: async ({ item, accumulator }) => { + execute: async ({ item, accumulator }: { item: number; accumulator: number }) => { return accumulator + item; }, }); @@ -153,11 +153,11 @@ const stepsDemo = flowAgent( return { doubled, - sum: reduceResult.value, + sum: reduceResult.output, logged: eachResult.ok, - countdown: whileResult.ok ? (whileResult.value ?? 0) : 0, - parallel: allResult.value, - fastest: raceResult.value, + countdown: whileResult.ok ? (whileResult.output ?? 0) : 0, + parallel: allResult.output, + fastest: raceResult.output, }; }, ); diff --git a/examples/flow-agent/src/index.ts b/examples/flow-agent/src/index.ts index a16cf3b..8464419 100644 --- a/examples/flow-agent/src/index.ts +++ b/examples/flow-agent/src/index.ts @@ -29,13 +29,11 @@ const summarizeAndTranslate = flowAgent( summary: z.string(), translation: z.string(), }), - onStepStart: ({ step }) => { - console.log(` → step started: ${step.id} (${step.type})`); + onStepStart: ({ stepId, stepOperation }) => { + console.log(` → step started: ${stepId} (${stepOperation})`); }, - onStepFinish: ({ step, duration }) => { - if (step) { - console.log(` ✓ step finished: ${step.id} (${duration}ms)`); - } + onStepFinish: ({ stepId, duration }) => { + console.log(` ✓ step finished: ${stepId} (${duration}ms)`); }, }, async ({ input, $ }) => { @@ -50,7 +48,7 @@ const summarizeAndTranslate = flowAgent( throw new Error(`Summarization failed: ${summaryResult.error.message}`); } - const summary = String(summaryResult.value.output); + const summary = String(summaryResult.output); // Step 2: Translate the summary const translationResult = await $.agent({ @@ -65,7 +63,7 @@ const summarizeAndTranslate = flowAgent( return { summary, - translation: String(translationResult.value.output), + translation: String(translationResult.output), }; }, ); diff --git a/examples/prompts-subagents/src/index.ts b/examples/prompts-subagents/src/index.ts index 93b9bbe..2d85f7f 100644 --- a/examples/prompts-subagents/src/index.ts +++ b/examples/prompts-subagents/src/index.ts @@ -53,7 +53,7 @@ const pipeline = flowAgent( const draft = await $.agent({ id: "draft", agent: writer, - input: `Write an article based on these findings:\n${research.value.output}`, + input: `Write an article based on these findings:\n${research.output}`, }); if (!draft.ok) { @@ -63,7 +63,7 @@ const pipeline = flowAgent( const review = await $.agent({ id: "review", agent: reviewer, - input: `Review this article:\n${draft.value.output}`, + input: `Review this article:\n${draft.output}`, }); if (!review.ok) { @@ -71,8 +71,8 @@ const pipeline = flowAgent( } return { - article: String(draft.value.output), - verdict: String(review.value.output), + article: String(draft.output), + verdict: String(review.output), }; }, ); diff --git a/examples/realworld-cli/api/pipeline.ts b/examples/realworld-cli/api/pipeline.ts index 15fba4d..4a9b934 100644 --- a/examples/realworld-cli/api/pipeline.ts +++ b/examples/realworld-cli/api/pipeline.ts @@ -42,12 +42,12 @@ export const createPipeline = ( targetDir: z.string().describe("The directory path being scanned"), }), output: pipelineOutputSchema, - onStepStart: ({ step }) => { - emit({ type: "step:start", stepId: step.id, stepType: step.type }); + onStepStart: ({ stepId, stepOperation }) => { + emit({ type: "step:start", stepId, stepType: stepOperation }); }, - onStepFinish: ({ step, duration }) => { - if (step && duration !== undefined) { - emit({ type: "step:finish", stepId: step.id, duration }); + onStepFinish: ({ stepId, duration }) => { + if (duration !== undefined) { + emit({ type: "step:finish", stepId, duration }); } }, }, @@ -71,7 +71,7 @@ export const createPipeline = ( throw new Error(`Scanner failed: ${scanResult.error.message}`); } - const scanOutput = scanResult.value.output as string; + const scanOutput = scanResult.output as string; const testFilePaths = extractTestFilePaths(scanOutput); emit({ type: "scan-complete", files: testFilePaths }); @@ -110,7 +110,7 @@ export const createPipeline = ( }); const summary = analysisResult.ok - ? (analysisResult.value.output as string) + ? (analysisResult.output as string) : `Analysis failed: ${analysisResult.error.message}`; analyses.push({ filePath: testFilePath, summary }); diff --git a/examples/streaming/src/index.ts b/examples/streaming/src/index.ts index 52952dd..56d1e1d 100644 --- a/examples/streaming/src/index.ts +++ b/examples/streaming/src/index.ts @@ -39,17 +39,27 @@ const geographyAgent = agent({ if (toolCalls && toolCalls.length > 0) { console.log(`\n[step ${stepId}] Tool calls:`); for (const tc of toolCalls) { - console.log(` → ${tc.toolName} (${tc.argsTextLength} chars args)`); + console.log(` → ${tc.toolName} (input: ${JSON.stringify(tc.input)})`); } } if (toolResults && toolResults.length > 0) { console.log(`[step ${stepId}] Tool results:`); for (const tr of toolResults) { - console.log(` ← ${tr.toolName} (${tr.resultTextLength} chars result)`); + console.log(` ← ${tr.toolName} (output: ${JSON.stringify(tr.output)})`); } } - if (usage && usage.totalTokens > 0) { - console.log(`[step ${stepId}] Tokens: ${usage.inputTokens} in / ${usage.outputTokens} out`); + if ( + usage && + ((usage.totalTokens ?? 0) > 0 || + (usage.inputTokens ?? 0) > 0 || + (usage.outputTokens ?? 0) > 0) + ) { + const inputTokens = usage.inputTokens ?? 0; + const outputTokens = usage.outputTokens ?? 0; + const totalTokens = usage.totalTokens ?? inputTokens + outputTokens; + console.log( + `[step ${stepId}] Tokens: ${inputTokens} in / ${outputTokens} out / ${totalTokens} total`, + ); } }, }); @@ -118,13 +128,11 @@ const researchFlow = flowAgent( }), // Observe each $ step in real time - onStepStart: ({ step }) => { - console.log(`[step:start] ${step.id} (type: ${step.type}, index: ${step.index})`); + onStepStart: ({ stepId, stepOperation }) => { + console.log(`[step:start] ${stepId} (type: ${stepOperation})`); }, - onStepFinish: ({ step, duration }) => { - if (step) { - console.log(`[step:finish] ${step.id} (${duration}ms)`); - } + onStepFinish: ({ stepId, duration }) => { + console.log(`[step:finish] ${stepId} (${duration}ms)`); }, }, async ({ input, $ }) => { @@ -145,7 +153,7 @@ const researchFlow = flowAgent( return { topic: item, answer: `Error: ${result.error.message}` }; } - return { topic: item, answer: String(result.value.output) }; + return { topic: item, answer: String(result.output) }; }, }); @@ -153,7 +161,7 @@ const researchFlow = flowAgent( throw new Error(`Research failed: ${mapResult.error.message}`); } - return { findings: mapResult.value }; + return { findings: mapResult.output }; }, ); diff --git a/packages/agents/ISSUES.md b/packages/agents/ISSUES.md deleted file mode 100644 index 5d9b66f..0000000 --- a/packages/agents/ISSUES.md +++ /dev/null @@ -1,540 +0,0 @@ -# Agent SDK — Issue Log - -Tracked issues discovered during code review. Each issue is tagged with severity, category, and acceptance criteria for resolution. - ---- - - - -## #1 — StepResult spread fails for non-object types - -**File:** `src/core/workflows/steps/factory.ts:152` -**Related:** `src/core/workflows/steps/result.ts:26-28` - -### Description - -`executeStep` constructs the success result as: - -```typescript -return { ok: true, ...(value as T), step: stepInfo, duration } as StepResult; -``` - -Spreading a primitive (`string`, `number`, `boolean`) produces garbage at runtime. `..."hello"` yields `{0:'h',1:'e',...}`. The `StepResult` type definition (`T & { ok: true; ... }`) is also unsound for primitives since `string & { ok: true }` is effectively `never`. - -Any step returning a non-object value (`$.step(...)`, `$.step(...)`) will produce broken results that the type system masks. - -### Acceptance Criteria - -- [ ] `StepResult` wraps the success value in a named field (e.g. `value: T`) instead of intersecting `T &` -- [ ] OR `T` is constrained to `Record` to enforce object-only step results -- [ ] Existing tests updated to reflect the new shape -- [ ] A test exists that verifies `$.step(...)` returns the correct value - - - ---- - - - -## #2 — Abort signal not propagated to agents in workflow steps - -**File:** `src/core/workflows/steps/factory.ts:182-186` - -### Description - -When `$.agent()` calls `config.agent.generate(config.input, agentConfig)`, the `agentConfig` merges user config and logger but never includes `ctx.signal`: - -```typescript -const agentConfig = { - ...config.config, - logger: ctx.log.child({ stepId: config.id }), - // Missing: signal: ctx.signal -}; -``` - -If the workflow is cancelled via abort signal, agents running inside `$.agent()` steps continue until completion. - -### Acceptance Criteria - -- [ ] `$.agent()` passes `ctx.signal` to the agent via `agentConfig.signal` -- [ ] User-provided `config.config.signal` takes precedence over `ctx.signal` if explicitly set -- [ ] A test verifies that aborting the workflow signal causes the agent call to receive the signal - - - ---- - - - -## #3 — Agent `stream()` eagerly consumes entire stream - -**File:** `src/core/agent/agent.ts:274-303` - -### Description - -The `stream()` method fully drains `aiResult.textStream` before returning: - -```typescript -const chunks: string[] = []; -for await (const chunk of aiResult.textStream) { - chunks.push(chunk); -} -``` - -Then creates a "replay stream" from collected chunks. The caller cannot consume text incrementally — `stream()` is functionally identical to `generate()` with extra overhead. - -### Acceptance Criteria - -- [ ] `stream()` returns before the full generation completes -- [ ] The returned stream emits chunks as they arrive from the model -- [ ] `output` and `messages` are resolved after the stream completes (e.g. via promises or post-stream access) -- [ ] Existing `stream()` tests updated to verify incremental delivery - - - ---- - - - -## #4 — Agent `stream()` ignores structured output - -**File:** `src/core/agent/agent.ts:280-282` - -### Description - -In `stream()`, the output is always set to raw text: - -```typescript -const finalText = await aiResult.text; -const finalOutput = finalText; -``` - -Compare with `generate()` which correctly branches: - -```typescript -output: (output ? aiResult.output : aiResult.text) as TOutput; -``` - -When an agent has structured output (e.g. `Output.object({ schema })`), `stream()` returns a raw string instead of the parsed object. The `TOutput` type assertion masks this at compile time. - -### Acceptance Criteria - -- [ ] `stream()` checks for structured output the same way `generate()` does -- [ ] When `output` is configured, `stream()` returns the parsed object, not raw text -- [ ] A test exists that verifies `stream()` with structured output returns the correct type - - - ---- - - - -## #5 — `$.race()` does not cancel losing entries - -**File:** `src/core/workflows/steps/factory.ts:331-343` -**Related:** `src/core/workflows/steps/race.ts:5` - -### Description - -`RaceConfig` docs say "Losers are cancelled via abort signal" but the implementation is just: - -```typescript -execute: async () => Promise.race(config.entries), -``` - -No abort controller is created. No signals are propagated. Losing entries continue running to completion. - -### Acceptance Criteria - -- [ ] `$.race()` cancels losing entries when the winner resolves -- [ ] OR the docs are updated to remove the cancellation claim and document actual behavior -- [ ] If cancellation is implemented, entries must accept an abort signal (API change to accept factories instead of pre-started promises) - - - ---- - - - -## #6 — `$.all()` / `$.race()` entries are pre-started promises - -**File:** `src/core/workflows/steps/factory.ts:313-329` -**Related:** `src/core/workflows/steps/all.ts`, `src/core/workflows/steps/race.ts` - -### Description - -`AllConfig.entries` and `RaceConfig.entries` are typed as `Promise[]`. By the time `executeStep` records `startedAt`, those promises are already executing. This means: - -1. Trace `startedAt` timestamp is too late (work already began) -2. If entries resolved before `$.all()` is called, hooks fire after the fact -3. `duration` measurement underestimates actual execution time - -The `AllConfig` JSDoc acknowledges this ("already tracked individually") but the `executeStep` wrapper's timing is still misleading. - -### Acceptance Criteria - -- [ ] Entries are changed to factory functions `(() => Promise)[]` so the framework controls start time -- [ ] OR the trace/timing for `$.all()` / `$.race()` is documented as "coordination overhead only, not total execution time" -- [ ] If factories are adopted, update `StepBuilder` types and all call sites - - - ---- - - - -## #7 — `onStepFinish` never fires on error - -**File:** `src/core/workflows/steps/factory.ts:167-168` - -### Description - -The catch block only fires step-level `onError`. The workflow-level `parentHooks.onStepFinish` is never called for failed steps. This is intentional per the comment, but asymmetric with `onStepStart` (which always fires). There is no `onStepError` hook at the workflow level. - -Consumers using `onStepFinish` for telemetry (e.g. recording step durations) will silently miss all failed steps. - -### Acceptance Criteria - -- [ ] `onStepFinish` fires for both success and error cases (with error info included in the event) -- [ ] OR a new workflow-level `onStepError` hook is added -- [ ] The chosen behavior is documented in the `WorkflowConfig` JSDoc - - - ---- - - - -## #8 — `finishReason` passed as `stepId` in agent `onStepFinish` hook - -**File:** `src/core/agent/agent.ts:144, 265` - -### Description - -Both `generate()` and `stream()` pass the AI SDK's `event.finishReason` as the `stepId`: - -```typescript -onStepFinish: async (event) => { - config.onStepFinish!({ stepId: event.finishReason }); -}; -``` - -`finishReason` is `"stop"`, `"length"`, `"tool-calls"`, etc. — not a step identifier. Consumers expecting a unique step ID will get the finish reason instead. - -### Acceptance Criteria - -- [ ] Pass a meaningful step identifier (e.g. counter-based ID, or `agentName:stepIndex`) -- [ ] OR rename the hook parameter from `stepId` to `finishReason` to match the actual value -- [ ] Update the `AgentConfig.onStepFinish` type signature accordingly - - - ---- - - - -## #9 — `withModelMiddleware` wraps model even with empty middleware - -**File:** `src/lib/middleware.ts:49-52` - -### Description - -When devtools is disabled (production) and no user middleware is provided, the function still calls `wrapLanguageModel({ model, middleware: [] })`. This creates an unnecessary wrapper layer. - -### Acceptance Criteria - -- [ ] Return the model directly when middleware array is empty -- [ ] Add a test verifying no wrapping occurs in production with no middleware - - - ---- - - - -## #10 — Middleware ordering contradicts documentation - -**File:** `src/lib/middleware.ts:13-14, 45-46` - -### Description - -JSDoc says "Additional middleware to apply **after** defaults" but the implementation puts user middleware first: - -```typescript -[...options.middleware, ...defaultMiddleware]; -``` - -User middleware is outermost (wraps around devtools), contradicting "after defaults." - -### Acceptance Criteria - -- [ ] Either swap the order to `[...defaultMiddleware, ...options.middleware]` -- [ ] OR update the JSDoc to say "before defaults" / "outermost" -- [ ] Add a test that verifies middleware execution order - - - ---- - - - -## #11 — `snapshotTrace` shallow-clones entries - -**File:** `src/lib/trace.ts:100` - -### Description - -`{ ...entry }` only clones the top-level `TraceEntry` fields. `entry.input`, `entry.output`, and `entry.error` are reference copies. Mutations to original objects after snapshot are visible through the "frozen" trace. - -### Acceptance Criteria - -- [ ] Use `structuredClone` for input/output fields (noting `Error` objects need special handling) -- [ ] OR document that input/output references are shared and only the trace structure is frozen - - - ---- - - - -## #12 — No abort signal checking in sequential step loops - -**File:** `src/core/workflows/steps/factory.ts:246-298` - -### Description - -`$.each()` (line 246), `$.reduce()` (line 266), and `$.while()` (line 293) all loop without checking `ctx.signal.aborted`. If a workflow is cancelled mid-loop, these operations continue iterating through all items. `$.while()` is especially vulnerable since it loops on an arbitrary condition. - -### Acceptance Criteria - -- [ ] Each sequential loop checks `ctx.signal.aborted` at the start of each iteration -- [ ] When aborted, the loop throws an appropriate error (e.g. `AbortError`) -- [ ] A test verifies that aborting mid-loop stops iteration - - - ---- - - - -## #13 — `poolMap` ignores abort signal - -**File:** `src/core/workflows/steps/factory.ts:371-376` - -### Description - -The `poolMap` worker loop (`while (nextIndex < items.length)`) never checks for abort signal cancellation. Workers continue processing items after the workflow has been aborted. - -### Acceptance Criteria - -- [ ] `poolMap` accepts the abort signal and checks it before starting each item -- [ ] When aborted, workers exit cleanly - - - ---- - - - -## #14 — `writer.write()` not awaited in workflow stream - -**File:** `src/core/workflows/workflow.ts:470-472` - -### Description - -The `emit` function calls `writer.write(event)` without awaiting the returned promise. If the readable side is cancelled or the internal queue is full, the rejection is unhandled. Additionally, `emit` is typed as synchronous `(event: StepEvent) => void` but `writer.write` is async. - -### Acceptance Criteria - -- [ ] Either await the write (and change `emit` to async) -- [ ] OR add `.catch(() => {})` to swallow write errors silently -- [ ] The `emit` type signature matches the actual behavior - - - ---- - - - -## #15 — `openrouter()` creates a new provider instance on every call - -**File:** `src/core/provider/provider.ts:47-51` - -### Description - -Every call to `openrouter(modelId)` creates a new `OpenRouterProvider` instance. If the provider maintains internal state, caching, or connection pooling, this is wasteful. - -### Acceptance Criteria - -- [ ] Cache the provider instance lazily at module scope -- [ ] Invalidation strategy if `OPENROUTER_API_KEY` changes at runtime (or document that it doesn't) - - - ---- - - - -## #16 — `createTool` adds unnecessary async wrapper around execute - -**File:** `src/core/tool.ts:109` - -### Description - -`execute: async (data: TInput) => config.execute(data)` wraps the user's execute function in an extra async layer, adding one unnecessary microtask per tool call. The wrapper exists so `assertTool` can validate the intermediate object, but `config.execute` could be passed directly. - -### Acceptance Criteria - -- [ ] Pass `config.execute` directly instead of wrapping -- [ ] OR document why the wrapper is intentional (if there's a reason beyond assertion) - - - ---- - - - -## #17 — Subagent tool calls do not propagate abort signal - -**File:** `src/core/agent/utils.ts:54-74` - -### Description - -When a subagent is wrapped as a tool via `buildAITools`, the `execute` function calls `runnable.generate(input)` without passing an abort signal. The AI SDK passes `{ abortSignal }` as the second parameter to tool execute functions, but this code ignores it. If the parent agent is cancelled, subagent tool calls continue running. - -### Acceptance Criteria - -- [ ] Accept the `abortSignal` from the AI SDK tool execution context -- [ ] Forward it as `{ signal: abortSignal }` to `runnable.generate()` -- [ ] A test verifies signal propagation from parent to subagent - - - ---- - - - -## #18 — No runtime guard for `input` schema without `prompt` function - -**File:** `src/core/agent/utils.ts:107` - -### Description - -If a user provides `input` schema but omits `prompt` (or vice versa), the code silently falls through to simple mode. Both fields are independently optional in `AgentConfig` — no type-level or runtime enforcement that they must be provided together. - -### Acceptance Criteria - -- [ ] Add a runtime warning or error when `input` is provided without `prompt` (and vice versa) -- [ ] OR use a discriminated union at the type level to enforce the constraint -- [ ] A test verifies the guard triggers correctly - - - ---- - - - -## #19 — `LanguageModel` type narrowed to `specificationVersion: 'v3'` only - -**File:** `src/core/provider/types.ts:11` - -### Description - -```typescript -export type LanguageModel = Extract; -``` - -This rejects models using future spec versions (v4, etc.). When the AI SDK introduces a new version, models using it won't be assignable. Forward-incompatible. - -### Acceptance Criteria - -- [ ] Use the base `LanguageModel` type directly -- [ ] OR use a union that accommodates known + future versions -- [ ] OR document this as intentional pinning with a note to update on AI SDK major bumps - - - ---- - - - -## #20 — Duck-typing for `OutputSpec` detection is fragile - -**File:** `src/core/agent/output.ts:37-40` - -### Description - -`resolveOutput` checks `'parseCompleteOutput' in output` to distinguish an `OutputSpec` from a Zod schema. If the AI SDK renames that method, or a Zod schema happens to have a property named `parseCompleteOutput`, detection breaks silently. - -### Acceptance Criteria - -- [ ] Use a more robust discriminant (e.g. `instanceof`, brand checking, or a symbol) -- [ ] OR document the fragility and add a test that catches regressions on AI SDK upgrades - - - ---- - - - -## #21 — Zod array element extraction uses private `_zod` internals - -**File:** `src/core/agent/output.ts:43-50` - -### Description - -The code accesses `(output as unknown as Record)._zod` to extract the element schema from a Zod array. This relies on Zod's internal `_zod` property which can change across versions. If the structure changes, array output detection silently falls back to `Output.object({ schema: output })`, producing incorrect parsing. - -### Acceptance Criteria - -- [ ] Use Zod's public API for array element introspection -- [ ] A test verifies correct array element extraction across the supported Zod version -- [ ] Add a regression test that catches breakage if Zod internals change - - - ---- - - - -## #22 — Orphaned AbortController in workflow `generate()` - -**File:** `src/core/workflows/workflow.ts:368` - -### Description - -```typescript -const signal = overrides?.signal ?? new AbortController().signal; -``` - -When no signal is provided, a new `AbortController` is created but its reference is immediately discarded. The signal can never fire. Harmless but wasteful. - -### Acceptance Criteria - -- [ ] Only create an AbortController when needed, or pass `undefined` and check `signal?.aborted` in loops -- [ ] OR keep the current behavior and document it as intentional (a never-firing signal simplifies downstream code that always expects a signal) - - - ---- - - - -## #23 — O(N^2) array copying in `attemptEachAsync` - -**File:** `src/utils/attempt.ts:34-36` - -### Description - -```typescript -async (acc, h) => [...(await acc), await attemptAsync(async () => h())]; -``` - -Each reduce iteration creates a new array via spread. For N handlers, this is O(N^2) element copies. N is typically 1-2 so the impact is negligible, but the pattern is unnecessarily wasteful. - -### Acceptance Criteria - -- [ ] Replace `reduce` with a simple `for` loop using `push` -- [ ] Behavior and return type remain identical - - diff --git a/packages/agents/docs/advanced/custom-steps.md b/packages/agents/docs/advanced/custom-steps.md index ddc4c34..d72bc96 100644 --- a/packages/agents/docs/advanced/custom-steps.md +++ b/packages/agents/docs/advanced/custom-steps.md @@ -150,7 +150,7 @@ const flow = engine( input: input.query, }); if (!res.ok) throw new Error(res.error.message); - return res.value.output; + return res.output; }, }); return { answer: result }; @@ -194,11 +194,11 @@ const engine = createFlowEngine({ onError: ({ error }) => { errorReporter.capture(error); }, - onStepStart: ({ step }) => { - telemetry.trackStepStart(step.id, step.type); + onStepStart: ({ stepId, stepOperation }) => { + telemetry.trackStepStart(stepId, stepOperation); }, - onStepFinish: ({ step, duration }) => { - telemetry.trackStepFinish(step.id, duration); + onStepFinish: ({ stepId, duration }) => { + telemetry.trackStepFinish(stepId, duration); }, }); ``` diff --git a/packages/agents/docs/advanced/streaming.md b/packages/agents/docs/advanced/streaming.md index ec94e97..50d4bba 100644 --- a/packages/agents/docs/advanced/streaming.md +++ b/packages/agents/docs/advanced/streaming.md @@ -169,7 +169,7 @@ const pipeline = flowAgent( if (!research.ok) throw new Error("Research failed"); - return { article: research.value.output }; + return { article: research.output }; }, ); diff --git a/packages/agents/docs/core/flow-agent.md b/packages/agents/docs/core/flow-agent.md index b65bf0d..0c75b03 100644 --- a/packages/agents/docs/core/flow-agent.md +++ b/packages/agents/docs/core/flow-agent.md @@ -22,8 +22,8 @@ function flowAgent( | `onStart` | No | `(event: { input }) => void \| Promise` | Hook: fires when the flow agent starts | | `onFinish` | No | `(event: { input, output, duration }) => void \| Promise` | Hook: fires on success | | `onError` | No | `(event: { input, error }) => void \| Promise` | Hook: fires on error | -| `onStepStart` | No | `(event: { step: StepInfo }) => void \| Promise` | Hook: fires when any `$` step starts | -| `onStepFinish` | No | `(event: { step, result, duration }) => void \| Promise` | Hook: fires when any `$` step finishes | +| `onStepStart` | No | `(event: StepStartEvent) => void \| Promise` | Hook: fires when any `$` step starts | +| `onStepFinish` | No | `(event: StepFinishEvent) => void \| Promise` | Hook: fires when any `$` step finishes | ## FlowAgentHandler @@ -92,12 +92,12 @@ Subscribe to `stream` for real-time step progress events. Events emitted on the flow agent stream: -| Type | Fields | Description | -| ------------- | ---------------------------- | ------------------------- | -| `step:start` | `step: StepInfo` | A `$` operation started | -| `step:finish` | `step`, `result`, `duration` | A `$` operation completed | -| `step:error` | `step`, `error` | A `$` operation failed | -| `flow:finish` | `output`, `duration` | The flow agent completed | +| Type | Fields | Description | +| ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | +| `step:start` | `stepId`, `stepOperation`, `agentChain?` | A `$` operation started | +| `step:finish` | `StepFinishEvent` | A `$` operation completed; `$.agent()` steps also include AI SDK `StepResult` fields | +| `step:error` | `stepId`, `stepOperation`, `error` | A `$` operation failed | +| `flow:finish` | `output`, `duration` | The flow agent completed | ### fn() @@ -224,12 +224,12 @@ const reporter = flowAgent( const analysis = await $.agent({ id: "analyze", agent: analyzeAgent, - input: { files: files.value }, + input: { files: files.output }, }); return { - report: analysis.ok ? analysis.value.output : "Analysis failed", - fileCount: files.value.length, + report: analysis.ok ? analysis.output : "Analysis failed", + fileCount: files.output.length, }; }, ); diff --git a/packages/agents/docs/core/hooks.md b/packages/agents/docs/core/hooks.md index 929c060..8cb4519 100644 --- a/packages/agents/docs/core/hooks.md +++ b/packages/agents/docs/core/hooks.md @@ -17,15 +17,15 @@ Set on `AgentConfig`: Set on `FlowAgentConfig`: -| Hook | Event fields | When | -| -------------- | -------------------------------------- | ----------------------------------------------------- | -| `onStart` | `{ input }` | After input validation, before handler runs | -| `onFinish` | `{ input, output, duration }` | After successful completion | -| `onError` | `{ input, error }` | On error, before Result is returned | -| `onStepStart` | `{ step: StepInfo }` | Before any `$` operation executes | -| `onStepFinish` | `{ step: StepInfo, result, duration }` | After any `$` operation completes (success AND error) | +| Hook | Event fields | When | +| -------------- | ----------------------------------------------------------- | ----------------------------------------------------- | +| `onStart` | `{ input }` | After input validation, before handler runs | +| `onFinish` | `{ input, output, duration }` | After successful completion | +| `onError` | `{ input, error }` | On error, before Result is returned | +| `onStepStart` | `StepStartEvent` (`{ stepId, stepOperation, agentChain? }`) | Before any `$` operation executes | +| `onStepFinish` | `StepFinishEvent` | After any `$` operation completes (success AND error) | -`onStepFinish` fires on both success and error. On error, `result` is `undefined`. +`onStepFinish` fires on both success and error. On error, `output` is `undefined`. `stepId` is always required (never optional). For `$.agent()` steps, `StepFinishEvent` also includes all AI SDK `StepResult` fields (`usage`, `toolCalls`, `toolResults`, `text`, `finishReason`, etc.). ## Step-Level Hooks @@ -87,7 +87,7 @@ When a parent agent has sub-agents (via the `agents` config), those sub-agents a | Hook | Event type | Why safe | | -------------- | ----------------- | -------------------------------------- | -| `onStepStart` | `StepInfo` | Fixed type, same shape for every agent | +| `onStepStart` | `StepStartEvent` | Fixed type, same shape for every agent | | `onStepFinish` | `StepFinishEvent` | Fixed type, same shape for every agent | | `logger` | `Logger` | No event type, just a logger instance | @@ -118,7 +118,7 @@ Parent.generate({ input, onStepFinish }) │ │ │ │ │ │ Passed into child.generate(): │ │ │ logger → parent's logger - │ │ │ onStepStart → parent's onStepStart (StepInfo — fixed type) + │ │ │ onStepStart → parent's onStepStart (StepStartEvent — fixed type) │ │ │ onStepFinish → parent's merged onStepFinish (StepFinishEvent — fixed type) │ │ │ │ │ │ NOT passed: diff --git a/packages/agents/docs/core/step.md b/packages/agents/docs/core/step.md index 77c519b..33c6b69 100644 --- a/packages/agents/docs/core/step.md +++ b/packages/agents/docs/core/step.md @@ -4,25 +4,35 @@ The `$` object is passed into every flow agent handler and step callback. It pro `$` is passed into every callback, enabling composition and nesting. You can always skip `$` and use plain imperative code -- it just will not appear in the trace. -## StepResult +## FlowStepResult -All `$` methods return `Promise>`: +All `$` methods return `Promise>`: ```ts -type StepResult = - | { ok: true; value: T; step: StepInfo; duration: number } - | { ok: false; error: StepError; step: StepInfo; duration: number }; +type FlowStepResult = + | { + ok: true; + output: T; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + } + | { + ok: false; + error: StepError; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + }; ``` -`StepInfo` identifies the step: +Step metadata is flat on the result object: -```ts -interface StepInfo { - id: string; // from the $ config's `id` field - index: number; // auto-incrementing within the flow agent - type: OperationType; // 'step' | 'agent' | 'map' | 'each' | 'reduce' | 'while' | 'all' | 'race' -} -``` +- `stepId` -- from the `$` config's `id` field +- `stepOperation` -- `'step' | 'agent' | 'map' | 'each' | 'reduce' | 'while' | 'all' | 'race'` +- `agentChain` -- optional agent ancestry chain `StepError` extends `ResultError` with `stepId: string`. @@ -31,7 +41,7 @@ interface StepInfo { Single unit of work. ```ts -$.step(config: StepConfig): Promise> +$.step(config: StepConfig): Promise> ``` | Field | Required | Type | Description | @@ -51,16 +61,16 @@ const data = await $.step({ }); if (data.ok) { - console.log(data.value); // T + console.log(data.output); // T } ``` ## $.agent -Agent call as a tracked operation. Calls `agent.generate()` internally and unwraps the result -- agent errors become `StepError`, agent success becomes `StepResult`. +Agent call as a tracked operation. Calls `agent.generate()` internally and unwraps the result -- agent errors become `StepError`, agent success becomes `FlowAgentStepResult`. Agent output fields are flat on the result (no double-wrapping). ```ts -$.agent(config: AgentStepConfig): Promise> +$.agent(config: AgentStepConfig): Promise ``` | Field | Required | Type | Description | @@ -83,8 +93,8 @@ const result = await $.agent({ }); if (result.ok) { - console.log(result.value.output); // the agent's output - console.log(result.value.messages); // full message history + console.log(result.output); // the agent's output + console.log(result.messages); // full message history } ``` @@ -93,7 +103,7 @@ if (result.ok) { Parallel map with optional concurrency limit. All items run concurrently (up to `concurrency` limit). Returns results in input order. ```ts -$.map(config: MapConfig): Promise> +$.map(config: MapConfig): Promise> ``` | Field | Required | Type | Description | @@ -115,6 +125,10 @@ const results = await $.map({ return await processFile(item); }, }); + +if (results.ok) { + console.log(results.output); // R[] +} ``` ## $.each @@ -122,7 +136,7 @@ const results = await $.map({ Sequential side effects. Runs items one at a time in order. Returns `void`. Checks abort signal before each iteration. ```ts -$.each(config: EachConfig): Promise> +$.each(config: EachConfig): Promise> ``` | Field | Required | Type | Description | @@ -135,13 +149,17 @@ $.each(config: EachConfig): Promise> | `onError` | No | hook | Hook: fires on error | ```ts -await $.each({ +const notifications = await $.each({ id: "notify-users", input: users, execute: async ({ item }) => { await sendNotification(item.email); }, }); + +if (!notifications.ok) { + console.error(notifications.error.message); +} ``` ## $.reduce @@ -149,7 +167,7 @@ await $.each({ Sequential accumulation. Each step depends on the previous result. Checks abort signal before each iteration. ```ts -$.reduce(config: ReduceConfig): Promise> +$.reduce(config: ReduceConfig): Promise> ``` | Field | Required | Type | Description | @@ -171,6 +189,10 @@ const total = await $.reduce({ return accumulator + item.score; }, }); + +if (total.ok) { + console.log(total.output); // R +} ``` ## $.while @@ -178,7 +200,7 @@ const total = await $.reduce({ Conditional loop. Runs while a condition holds. Returns the last value, or `undefined` if the condition was false on first check. Checks abort signal before each iteration. ```ts -$.while(config: WhileConfig): Promise> +$.while(config: WhileConfig): Promise> ``` | Field | Required | Type | Description | @@ -201,6 +223,10 @@ const result = await $.while({ return await checkStatus(); }, }); + +if (result.ok) { + console.log(result.output); // T | undefined +} ``` ## $.all @@ -208,7 +234,7 @@ const result = await $.while({ Concurrent heterogeneous operations -- like `Promise.all`. Entries are factory functions that receive an `AbortSignal` and return a promise. The framework creates an `AbortController`, links it to the parent signal, and starts all factories at the same time. ```ts -$.all(config: AllConfig): Promise> +$.all(config: AllConfig): Promise> ``` | Field | Required | Type | Description | @@ -222,10 +248,14 @@ $.all(config: AllConfig): Promise> Where `EntryFactory = (signal: AbortSignal) => Promise`. ```ts -const [users, repos] = await $.all({ +const result = await $.all({ id: "fetch-data", entries: [(signal) => fetchUsers(signal), (signal) => fetchRepos(signal)], }); + +if (result.ok) { + const [users, repos] = result.output; +} ``` ## $.race @@ -233,7 +263,7 @@ const [users, repos] = await $.all({ First-to-finish wins. Same `entries: EntryFactory[]` pattern as `$.all`. Losers are cancelled via abort signal when the winner resolves. ```ts -$.race(config: RaceConfig): Promise> +$.race(config: RaceConfig): Promise> ``` | Field | Required | Type | Description | @@ -251,7 +281,7 @@ const result = await $.race({ }); if (result.ok) { - const fastest = result.value; + const fastest = result.output; } ``` @@ -267,7 +297,7 @@ const result = await $.step({ id: "inner", execute: async () => "nested value", }); - return inner.ok ? inner.value : "fallback"; + return inner.ok ? inner.output : "fallback"; }, }); ``` diff --git a/packages/agents/docs/core/types.md b/packages/agents/docs/core/types.md index ab90346..644c373 100644 --- a/packages/agents/docs/core/types.md +++ b/packages/agents/docs/core/types.md @@ -1,6 +1,6 @@ # Core Types -Reference for the core types used across `@funkai/agents`: `Message`, `Result`, `GenerateResult`, `StreamResult`, `StepResult`, and related interfaces. +Reference for the core types used across `@funkai/agents`: `Message`, `Result`, `GenerateResult`, `StreamResult`, `FlowStepResult`, and related interfaces. ## Result\ @@ -138,26 +138,65 @@ Discriminated union of all stream event types. Re-exported from the AI SDK as `T | `"error"` | An error occurred | | `"step-finish"` | A tool-loop step completed | -## StepResult\ +## FlowStepResult\ -Discriminated union for flow agent step operation results. Includes `step` metadata and `duration` alongside the success/failure branches. +Discriminated union for flow agent step operation results. Includes flat step metadata and `duration` alongside the success/failure branches. ```ts -type StepResult = - | { ok: true; value: T; step: StepInfo; duration: number } - | { ok: false; error: StepError; step: StepInfo; duration: number }; +type FlowStepResult = + | { + ok: true; + output: T; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + } + | { + ok: false; + error: StepError; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + }; ``` -### StepInfo +### StepStartEvent ```ts -interface StepInfo { - id: string; // from the $ config - index: number; // auto-incrementing within the flow execution - type: OperationType; // what kind of $ call produced this step +interface StepStartEvent { + stepId: string; // from the $ config's `id` field + stepOperation: OperationType; // 'step' | 'agent' | 'map' | 'each' | 'reduce' | 'while' | 'all' | 'race' + agentChain?: AgentChainEntry[]; // agent ancestry chain } ``` +### FlowAgentStepResult + +Returned by `$.agent()`. A discriminated union where the success branch carries `GenerateResult` fields flat on the result (no double-wrapping), and the failure branch carries `StepError`. + +```ts +type FlowAgentStepResult = + | (GenerateResult & { + ok: true; + stepId: string; + stepOperation: "agent"; + duration: number; + agentChain?: readonly AgentChainEntry[]; + }) + | { + ok: false; + error: StepError; + stepId: string; + stepOperation: "agent"; + duration: number; + agentChain?: readonly AgentChainEntry[]; + }; +``` + +On success, `result.output` is the agent's `TOutput` directly, and `result.messages`, `result.usage`, `result.finishReason` are available alongside it. + ### StepError Extends `ResultError` with the step's `id`: @@ -168,7 +207,7 @@ interface StepError extends ResultError { } ``` -### Pattern Matching on StepResult +### Pattern Matching on FlowStepResult ```ts import { match } from "ts-pattern"; @@ -179,7 +218,7 @@ const step = await $.step({ }); const data = match(step) - .with({ ok: true }, (s) => s.value) + .with({ ok: true }, (s) => s.output) .with({ ok: false }, (s) => { console.error(`Step ${s.error.stepId} failed: ${s.error.code}`); return fallbackData; diff --git a/packages/agents/docs/cost-tracking.md b/packages/agents/docs/cost-tracking.md index 823f550..e213611 100644 --- a/packages/agents/docs/cost-tracking.md +++ b/packages/agents/docs/cost-tracking.md @@ -185,16 +185,16 @@ const pipeline = flowAgent( input: { text: item }, }); return { - analysis: result.ok ? result.value.output : "Analysis failed", - tokens: result.ok ? result.value.usage.totalTokens : 0, + analysis: result.ok ? result.output : "Analysis failed", + tokens: result.ok ? result.usage.totalTokens : 0, }; }, }); - const totalTokens = results.ok ? results.value.reduce((sum, r) => sum + r.tokens, 0) : 0; + const totalTokens = results.ok ? results.output.reduce((sum, r) => sum + r.tokens, 0) : 0; return { - analyses: results.ok ? results.value.map((r) => r.analysis) : [], + analyses: results.ok ? results.output.map((r) => r.analysis) : [], totalTokens, }; }, @@ -240,11 +240,11 @@ const traced = flowAgent( name: "cost-traced", input: z.object({ topics: z.array(z.string()) }), output: z.object({ articles: z.array(z.string()) }), - onStepFinish: ({ step, result, duration }) => { - if (result !== undefined && "usage" in result && result.usage) { - const cost = calculateCost(result.usage, modelDef.pricing); + onStepFinish: ({ stepId, output, duration }) => { + if (output !== undefined && "usage" in output && output.usage) { + const cost = calculateCost(output.usage, modelDef.pricing); console.log( - `[${step.id}] ${result.usage.totalTokens} tokens, $${cost.total.toFixed(6)}, ${duration}ms`, + `[${stepId}] ${output.usage.totalTokens} tokens, $${cost.total.toFixed(6)}, ${duration}ms`, ); } }, @@ -260,11 +260,11 @@ const traced = flowAgent( agent: writer, input: { topic: item }, }); - return result.ok ? result.value.output : ""; + return result.ok ? result.output : ""; }, }); - return { articles: articles.ok ? articles.value : [] }; + return { articles: articles.ok ? articles.output : [] }; }, ); ``` diff --git a/packages/agents/docs/create-flow-agent.md b/packages/agents/docs/create-flow-agent.md index 3556577..7663486 100644 --- a/packages/agents/docs/create-flow-agent.md +++ b/packages/agents/docs/create-flow-agent.md @@ -29,7 +29,7 @@ const myFlowAgent = flowAgent( return { title: input.url, - wordCount: page.value.split(/\s+/).length, + wordCount: page.output.split(/\s+/).length, }; }, ); @@ -45,11 +45,11 @@ if (result.ok) { ## `$` operations -Every `$` method is registered in the execution trace and returns a `StepResult`. Always check `.ok` before accessing `.value`. +Every `$` method is registered in the execution trace and returns a `FlowStepResult`. Always check `.ok` before accessing `.output`. ```ts if (result.ok) { - console.log(result.value); // the step's return value + console.log(result.output); // the step's return value console.log(result.duration); // wall-clock time in ms } else { console.error(result.error.message); @@ -69,7 +69,7 @@ const result = await $.step({ id: "sub-task", execute: async () => computeResult(), }); - return sub.ok ? sub.value : fallback; + return sub.ok ? sub.output : fallback; }, }); ``` @@ -105,7 +105,7 @@ const pipeline = flowAgent( if (!result.ok) throw new Error(result.error.message); - return { analysis: result.value.output }; + return { analysis: result.output }; }, ); ``` @@ -126,7 +126,7 @@ const pages = await $.map({ }); if (pages.ok) { - console.log(pages.value); // array of results in input order + console.log(pages.output); // array of results in input order } ``` @@ -149,7 +149,7 @@ const results = await $.all({ }); if (results.ok) { - const [metadata, content, computed] = results.value; + const [metadata, content, computed] = results.output; } ``` @@ -164,7 +164,7 @@ const fastest = await $.race({ }); if (fastest.ok) { - console.log(fastest.value); // result from whichever finished first + console.log(fastest.output); // result from whichever finished first } ``` @@ -294,13 +294,13 @@ if (result.ok) { for await (const event of result.fullStream) { switch (event.type) { case "step:start": - console.log(`Step started: ${event.step.id}`); + console.log(`Step started: ${event.stepId}`); break; case "step:finish": - console.log(`Step finished: ${event.step.id} (${event.duration}ms)`); + console.log(`Step finished: ${event.stepId} (${event.duration}ms)`); break; case "step:error": - console.error(`Step failed: ${event.step.id}`, event.error); + console.error(`Step failed: ${event.stepId}`, event.error); break; case "flow:finish": console.log(`Flow agent complete (${event.duration}ms)`); @@ -324,9 +324,9 @@ const wf = flowAgent( onStart: ({ input }) => console.log("Flow agent started"), onFinish: ({ input, result, duration }) => console.log(`Done in ${duration}ms`), onError: ({ input, error }) => console.error("Failed:", error.message), - onStepStart: ({ step }) => console.log(`Step ${step.id} started`), - onStepFinish: ({ step, result, duration }) => - console.log(`Step ${step.id} done in ${duration}ms`), + onStepStart: ({ stepId, stepOperation }) => console.log(`Step ${stepId} started`), + onStepFinish: ({ stepId, stepOperation, output, duration }) => + console.log(`Step ${stepId} done in ${duration}ms`), }, handler, ); @@ -382,7 +382,7 @@ const pipeline = flowAgent( // Summarize each page with the agent const summaries = await $.map({ id: "summarize-pages", - input: pages.value, + input: pages.output, concurrency: 3, execute: async ({ item: page, $ }) => { const result = await $.agent({ @@ -391,13 +391,13 @@ const pipeline = flowAgent( input: { text: page.body }, }); if (!result.ok) throw new Error(`Failed to summarize ${page.url}`); - return { url: page.url, summary: result.value.output }; + return { url: page.url, summary: result.output }; }, }); if (!summaries.ok) throw new Error("Failed to summarize"); - return { summaries: summaries.value }; + return { summaries: summaries.output }; }, ); @@ -434,8 +434,8 @@ function flowAgent( | `onStart` | No | `(event: { input }) => void \| Promise` | Hook: fires when the flow agent starts | | `onFinish` | No | `(event: { input, result, duration }) => void \| Promise` | Hook: fires on success | | `onError` | No | `(event: { input, error }) => void \| Promise` | Hook: fires on error | -| `onStepStart` | No | `(event: { step: StepInfo }) => void \| Promise` | Hook: fires when any `$` step starts | -| `onStepFinish` | No | `(event: { step, result, duration }) => void \| Promise` | Hook: fires when any `$` step finishes | +| `onStepStart` | No | `(event: StepStartEvent) => void \| Promise` | Hook: fires when any `$` step starts | +| `onStepFinish` | No | `(event: StepFinishEvent) => void \| Promise` | Hook: fires when any `$` step finishes | ## Reference: FlowAgentGenerateResult @@ -495,12 +495,12 @@ interface TraceEntry { Events emitted on the flow agent stream: -| Type | Fields | Description | -| ------------- | ---------------------------- | ------------------------- | -| `step:start` | `step: StepInfo` | A `$` operation started | -| `step:finish` | `step`, `result`, `duration` | A `$` operation completed | -| `step:error` | `step`, `error` | A `$` operation failed | -| `flow:finish` | `output`, `duration` | The flow agent completed | +| Type | Fields | Description | +| ------------- | ----------------------------------------------- | ------------------------- | +| `step:start` | `stepId`, `stepOperation`, `agentChain?` | A `$` operation started | +| `step:finish` | `stepId`, `stepOperation`, `output`, `duration` | A `$` operation completed | +| `step:error` | `stepId`, `stepOperation`, `error` | A `$` operation failed | +| `flow:finish` | `output`, `duration` | The flow agent completed | ## Reference: FlowAgentOverrides @@ -524,7 +524,7 @@ Ensure the handler returns an object matching the `output` Zod schema. ### Step result not checked -All `$` methods return `StepResult` — check `.ok` before accessing `.value`. +All `$` methods return `FlowStepResult` -- check `.ok` before accessing `.output`. ### `$.all`/`$.race` type error diff --git a/packages/agents/docs/custom-flow-engine.md b/packages/agents/docs/custom-flow-engine.md index 9cef1c1..c4b4825 100644 --- a/packages/agents/docs/custom-flow-engine.md +++ b/packages/agents/docs/custom-flow-engine.md @@ -119,7 +119,7 @@ const flow = engine( input: input.query, }); if (!res.ok) throw new Error(res.error.message); - return res.value.output; + return res.output; }, }); return { answer: result }; @@ -164,11 +164,11 @@ const engine = createFlowEngine({ onError: ({ error }) => { errorReporter.capture(error); }, - onStepStart: ({ step }) => { - telemetry.trackStepStart(step.id, step.type); + onStepStart: ({ stepId, stepOperation }) => { + telemetry.trackStepStart(stepId, stepOperation); }, - onStepFinish: ({ step, duration }) => { - telemetry.trackStepFinish(step.id, duration); + onStepFinish: ({ stepId, stepOperation, duration }) => { + telemetry.trackStepFinish(stepId, duration); }, }); ``` diff --git a/packages/agents/docs/error-recovery.md b/packages/agents/docs/error-recovery.md index 07f2823..56bcd54 100644 --- a/packages/agents/docs/error-recovery.md +++ b/packages/agents/docs/error-recovery.md @@ -6,13 +6,13 @@ Patterns for building resilient agents and flow agents that recover gracefully f - `@funkai/agents` installed - Familiarity with `flowAgent()`, `$.step`, `$.while`, `$.map`, and hooks -- Understanding of `StepResult` and `Result` types +- Understanding of `FlowStepResult` and `Result` types ## Steps ### 1. Use fallback values on step failure -Every `$` method returns `StepResult` with an `ok` field. Check it before accessing `.value` and provide a fallback when the step fails. +Every `$` method returns `FlowStepResult` with an `ok` field. Check it before accessing `.output` and provide a fallback when the step fails. ```ts import { flowAgent } from "@funkai/agents"; @@ -35,7 +35,7 @@ const resilient = flowAgent( }); if (primary.ok) { - return { body: primary.value, source: "primary" }; + return { body: primary.output, source: "primary" }; } // Fallback to a cached or default value @@ -48,7 +48,7 @@ const resilient = flowAgent( }); return { - body: fallback.ok ? fallback.value : "Service unavailable", + body: fallback.ok ? fallback.output : "Service unavailable", source: fallback.ok ? "fallback" : "default", }; }, @@ -88,7 +88,7 @@ const retryable = flowAgent( }, }); - const last = result.ok ? result.value : undefined; + const last = result.ok ? result.output : undefined; return { body: last?.ok ? last.body : "All retries failed", attempts: last?.attempt ?? 0, @@ -134,7 +134,7 @@ const batchSummarizer = flowAgent( }); return { index, - summary: result.ok ? result.value.output : "Failed to summarize", + summary: result.ok ? result.output : "Failed to summarize", ok: result.ok, }; }, @@ -142,7 +142,7 @@ const batchSummarizer = flowAgent( return { results: summaries.ok - ? summaries.value + ? summaries.output : input.texts.map((_, index) => ({ index, summary: "Batch processing failed", @@ -201,8 +201,8 @@ const circuitBreaker = flowAgent( }); return { - results: state.ok ? [...state.value.results] : [], - tripped: state.ok ? state.value.tripped : true, + results: state.ok ? [...state.output.results] : [], + tripped: state.ok ? state.output.tripped : true, }; }, ); @@ -224,9 +224,9 @@ const observed = flowAgent( onError: ({ input, error }) => { console.error(`Flow agent failed for input: ${JSON.stringify(input)}`, error.message); }, - onStepFinish: ({ step, result, duration }) => { - if (result === undefined) { - console.warn(`Step ${step.id} failed after ${duration}ms`); + onStepFinish: ({ stepId, stepOperation, output, duration }) => { + if (output === undefined) { + console.warn(`Step ${stepId} failed after ${duration}ms`); } }, }, @@ -239,7 +239,7 @@ const observed = flowAgent( execute: async () => input.data.toUpperCase(), }); - return { result: processed.ok ? processed.value : "fallback" }; + return { result: processed.ok ? processed.output : "fallback" }; }, ); ``` @@ -265,9 +265,9 @@ const robust = flowAgent( name: "robust-analysis", input: z.object({ url: z.string() }), output: z.object({ analysis: z.string(), source: z.string() }), - onStepFinish: ({ step, result, duration }) => { - const status = result !== undefined ? "ok" : "error"; - console.log(`[${step.id}] ${status} (${duration}ms)`); + onStepFinish: ({ stepId, stepOperation, output, duration }) => { + const status = output !== undefined ? "ok" : "error"; + console.log(`[${stepId}] ${status} (${duration}ms)`); }, }, async ({ input, $ }) => { @@ -288,7 +288,7 @@ const robust = flowAgent( }, }); - const fetchedBody = content.ok && content.value?.ok ? content.value.body : undefined; + const fetchedBody = content.ok && content.output?.ok ? content.output.body : undefined; if (!fetchedBody) { return { analysis: "Unable to fetch content", source: "none" }; @@ -301,7 +301,7 @@ const robust = flowAgent( }); return { - analysis: result.ok ? result.value.output : "Analysis unavailable", + analysis: result.ok ? result.output : "Analysis unavailable", source: result.ok ? "agent" : "fallback", }; }, @@ -310,7 +310,7 @@ const robust = flowAgent( ## Verification -- Failing steps return `StepResult` with `ok: false` instead of throwing +- Failing steps return `FlowStepResult` with `ok: false` instead of throwing - Retry loops terminate within the configured bounds - Partial success flow agents return results for both succeeded and failed items - Hook errors are caught, logged, and discarded — they never mask the original step error @@ -328,7 +328,7 @@ const robust = flowAgent( **Issue:** Expected error information is missing. -**Fix:** Hook errors are caught and discarded by design (via `attemptEachAsync`) so they never mask step errors. The original step error is always preserved in the `StepResult`. Check your logger output for hook error details. +**Fix:** Hook errors are caught and discarded by design (via `attemptEachAsync`) so they never mask step errors. The original step error is always preserved in the `FlowStepResult`. Check your logger output for hook error details. ### `$.map` fails on first error diff --git a/packages/agents/docs/guides/cost-aware-agents.md b/packages/agents/docs/guides/cost-aware-agents.md index 55d653c..13cfbf2 100644 --- a/packages/agents/docs/guides/cost-aware-agents.md +++ b/packages/agents/docs/guides/cost-aware-agents.md @@ -162,16 +162,16 @@ const pipeline = flowAgent( input: { text: item }, }); return { - analysis: result.ok ? result.value.output : "Analysis failed", - tokens: result.ok ? result.value.usage.totalTokens : 0, + analysis: result.ok ? result.output : "Analysis failed", + tokens: result.ok ? result.usage.totalTokens : 0, }; }, }); - const totalTokens = results.ok ? results.value.reduce((sum, r) => sum + r.tokens, 0) : 0; + const totalTokens = results.ok ? results.output.reduce((sum, r) => sum + r.tokens, 0) : 0; return { - analyses: results.ok ? results.value.map((r) => r.analysis) : [], + analyses: results.ok ? results.output.map((r) => r.analysis) : [], totalTokens, }; }, @@ -227,11 +227,11 @@ const traced = flowAgent( name: "cost-traced", input: z.object({ topics: z.array(z.string()) }), output: z.object({ articles: z.array(z.string()) }), - onStepFinish: ({ step, result, duration }) => { - if (result !== undefined && "usage" in result && result.usage) { - const cost = calculateCost(result.usage, modelDef.pricing); + onStepFinish: ({ stepId, output, duration }) => { + if (output !== undefined && "usage" in output && output.usage) { + const cost = calculateCost(output.usage, modelDef.pricing); console.log( - `[${step.id}] ${result.usage.totalTokens} tokens, $${cost.total.toFixed(6)}, ${duration}ms`, + `[${stepId}] ${output.usage.totalTokens} tokens, $${cost.total.toFixed(6)}, ${duration}ms`, ); } }, @@ -247,11 +247,11 @@ const traced = flowAgent( agent: writer, input: { topic: item }, }); - return result.ok ? result.value.output : ""; + return result.ok ? result.output : ""; }, }); - return { articles: articles.ok ? articles.value : [] }; + return { articles: articles.ok ? articles.output : [] }; }, ); ``` diff --git a/packages/agents/docs/guides/create-flow-agent.md b/packages/agents/docs/guides/create-flow-agent.md index 98222fe..b0aedd0 100644 --- a/packages/agents/docs/guides/create-flow-agent.md +++ b/packages/agents/docs/guides/create-flow-agent.md @@ -35,7 +35,7 @@ const myFlowAgent = flowAgent( return { title: input.url, - wordCount: page.value.split(/\s+/).length, + wordCount: page.output.split(/\s+/).length, }; }, ); @@ -54,16 +54,16 @@ const result = await $.step({ id: "sub-task", execute: async () => computeResult(), }); - return sub.ok ? sub.value : fallback; + return sub.ok ? sub.output : fallback; }, }); ``` -All `$` methods return `StepResult`. Check `.ok` and access `.value` on success. +All `$` methods return `FlowStepResult`. Check `.ok` and access `.output` on success. ```ts if (result.ok) { - console.log(result.value); // the step's return value + console.log(result.output); // the step's return value console.log(result.duration); // wall-clock time in ms } else { console.error(result.error.message); @@ -101,7 +101,7 @@ const wf = flowAgent( if (!result.ok) throw new Error(result.error.message); - return { analysis: result.value.output }; + return { analysis: result.output }; }, ); ``` @@ -122,7 +122,7 @@ const pages = await $.map({ }); if (pages.ok) { - console.log(pages.value); // array of results in input order + console.log(pages.output); // array of results in input order } ``` @@ -145,7 +145,7 @@ const results = await $.all({ }); if (results.ok) { - const [metadata, content, computed] = results.value; + const [metadata, content, computed] = results.output; } ``` @@ -162,7 +162,7 @@ const fastest = await $.race({ }); if (fastest.ok) { - console.log(fastest.value); // result from whichever finished first + console.log(fastest.output); // result from whichever finished first } ``` @@ -219,13 +219,13 @@ if (result.ok) { switch (event.type) { case "step:start": - console.log(`Step started: ${event.step.id}`); + console.log(`Step started: ${event.stepId}`); break; case "step:finish": - console.log(`Step finished: ${event.step.id} (${event.duration}ms)`); + console.log(`Step finished: ${event.stepId} (${event.duration}ms)`); break; case "step:error": - console.error(`Step failed: ${event.step.id}`, event.error); + console.error(`Step failed: ${event.stepId}`, event.error); break; case "flow:finish": console.log(`Flow agent complete (${event.duration}ms)`); @@ -261,9 +261,9 @@ const wf = flowAgent( onStart: ({ input }) => console.log("Flow agent started"), onFinish: ({ input, output, duration }) => console.log(`Done in ${duration}ms`), onError: ({ input, error }) => console.error("Failed:", error.message), - onStepStart: ({ step }) => console.log(`Step ${step.id} started`), - onStepFinish: ({ step, result, duration }) => - console.log(`Step ${step.id} done in ${duration}ms`), + onStepStart: ({ stepId }) => console.log(`Step ${stepId} started`), + onStepFinish: ({ stepId, output, duration }) => + console.log(`Step ${stepId} done in ${duration}ms`), }, handler, ); @@ -320,7 +320,7 @@ const pipeline = flowAgent( // Summarize each page with the agent const summaries = await $.map({ id: "summarize-pages", - input: pages.value, + input: pages.output, concurrency: 3, execute: async ({ item: page, $ }) => { const result = await $.agent({ @@ -329,13 +329,13 @@ const pipeline = flowAgent( input: { text: page.body }, }); if (!result.ok) throw new Error(`Failed to summarize ${page.url}`); - return { url: page.url, summary: result.value.output }; + return { url: page.url, summary: result.output }; }, }); if (!summaries.ok) throw new Error("Failed to summarize"); - return { summaries: summaries.value }; + return { summaries: summaries.output }; }, ); @@ -362,7 +362,7 @@ export const summarizePages = pipeline.fn(); ### Step result not checked -**Fix:** All `$` methods return `StepResult` -- check `.ok` before accessing `.value`. +**Fix:** All `$` methods return `FlowStepResult` -- check `.ok` before accessing `.output`. ### `$.all`/`$.race` type error diff --git a/packages/agents/docs/guides/error-recovery.md b/packages/agents/docs/guides/error-recovery.md index 5de5ed0..c68efe9 100644 --- a/packages/agents/docs/guides/error-recovery.md +++ b/packages/agents/docs/guides/error-recovery.md @@ -6,13 +6,13 @@ Patterns for building resilient agents and flow agents that recover gracefully f - `@funkai/agents` installed - Familiarity with `flowAgent()`, `$.step`, `$.while`, `$.map`, and hooks -- Understanding of `StepResult` and `Result` types +- Understanding of `FlowStepResult` and `Result` types ## Steps ### 1. Use fallback values on step failure -Every `$` method returns `StepResult` with an `ok` field. Check it before accessing `.value` and provide a fallback when the step fails. +Every `$` method returns `FlowStepResult` with an `ok` field. Check it before accessing `.output` and provide a fallback when the step fails. ```ts import { flowAgent } from "@funkai/agents"; @@ -35,7 +35,7 @@ const resilient = flowAgent( }); if (primary.ok) { - return { body: primary.value, source: "primary" }; + return { body: primary.output, source: "primary" }; } // Fallback to a cached or default value @@ -48,7 +48,7 @@ const resilient = flowAgent( }); return { - body: fallback.ok ? fallback.value : "Service unavailable", + body: fallback.ok ? fallback.output : "Service unavailable", source: fallback.ok ? "fallback" : "default", }; }, @@ -89,7 +89,7 @@ const retryable = flowAgent( }, }); - const last = result.ok ? result.value : undefined; + const last = result.ok ? result.output : undefined; return { body: last?.ok ? last.body : "All retries failed", attempts: last?.attempt ?? 0, @@ -141,7 +141,7 @@ const batchSummarizer = flowAgent( }); return { index, - summary: result.ok ? result.value.output : "Failed to summarize", + summary: result.ok ? result.output : "Failed to summarize", ok: result.ok, }; }, @@ -150,7 +150,7 @@ const batchSummarizer = flowAgent( // $.map itself can fail if an execute throws -- handle that too return { results: summaries.ok - ? summaries.value + ? summaries.output : input.texts.map((_, index) => ({ index, summary: "Batch processing failed", @@ -218,8 +218,8 @@ const circuitBreaker = flowAgent( }); return { - results: state.ok ? [...state.value.results] : [], - tripped: state.ok ? state.value.tripped : true, + results: state.ok ? [...state.output.results] : [], + tripped: state.ok ? state.output.tripped : true, }; }, ); @@ -241,10 +241,10 @@ const observed = flowAgent( onError: ({ input, error }) => { console.error(`Flow agent failed for input: ${JSON.stringify(input)}`, error.message); }, - onStepFinish: ({ step, result, duration }) => { - if (result === undefined) { - // result is undefined on step error - console.warn(`Step ${step.id} failed after ${duration}ms`); + onStepFinish: ({ stepId, stepOperation, output, duration }) => { + if (output === undefined) { + // output is undefined on step error + console.warn(`Step ${stepId} failed after ${duration}ms`); } }, }, @@ -259,7 +259,7 @@ const observed = flowAgent( }, }); - return { result: processed.ok ? processed.value : "fallback" }; + return { result: processed.ok ? processed.output : "fallback" }; }, ); ``` @@ -285,9 +285,9 @@ const robust = flowAgent( name: "robust-analysis", input: z.object({ url: z.string() }), output: z.object({ analysis: z.string(), source: z.string() }), - onStepFinish: ({ step, result, duration }) => { - const status = result !== undefined ? "ok" : "error"; - console.log(`[${step.id}] ${status} (${duration}ms)`); + onStepFinish: ({ stepId, stepOperation, output, duration }) => { + const status = output !== undefined ? "ok" : "error"; + console.log(`[${stepId}] ${status} (${duration}ms)`); }, }, async ({ input, $ }) => { @@ -309,7 +309,7 @@ const robust = flowAgent( }, }); - const fetchedBody = content.ok && content.value?.ok ? content.value.body : undefined; + const fetchedBody = content.ok && content.output?.ok ? content.output.body : undefined; if (!fetchedBody) { return { analysis: "Unable to fetch content", source: "none" }; @@ -323,7 +323,7 @@ const robust = flowAgent( }); return { - analysis: result.ok ? result.value.output : "Analysis unavailable", + analysis: result.ok ? result.output : "Analysis unavailable", source: result.ok ? "agent" : "fallback", }; }, @@ -332,7 +332,7 @@ const robust = flowAgent( ## Verification -- Failing steps return `StepResult` with `ok: false` instead of throwing +- Failing steps return `FlowStepResult` with `ok: false` instead of throwing - Retry loops terminate within the configured bounds - Partial success flow agents return results for both succeeded and failed items - Hook errors are swallowed and never mask the original error @@ -350,7 +350,7 @@ const robust = flowAgent( **Issue:** Expected error information is missing. -**Fix:** Hook errors are swallowed by design (via `attemptEachAsync`). The original step error is always preserved in the `StepResult`. Handle errors inside hooks if you need them to surface. +**Fix:** Hook errors are swallowed by design (via `attemptEachAsync`). The original step error is always preserved in the `FlowStepResult`. Handle errors inside hooks if you need them to surface. ### `$.map` fails on first error diff --git a/packages/agents/docs/guides/multi-agent-orchestration.md b/packages/agents/docs/guides/multi-agent-orchestration.md index b0483dd..3edfe6c 100644 --- a/packages/agents/docs/guides/multi-agent-orchestration.md +++ b/packages/agents/docs/guides/multi-agent-orchestration.md @@ -58,17 +58,17 @@ const pipeline = flowAgent( const draft = await $.agent({ id: "write", agent: writer, - input: { research: research.value.output, topic: input.topic }, + input: { research: research.output, topic: input.topic }, }); if (!draft.ok) return { article: "Writing failed" }; const edited = await $.agent({ id: "edit", agent: editor, - input: { draft: draft.value.output }, + input: { draft: draft.output }, }); - return { article: edited.ok ? edited.value.output : draft.value.output }; + return { article: edited.ok ? edited.output : draft.output }; }, ); ``` @@ -113,12 +113,12 @@ const batchTranslate = flowAgent( }); return { language, - text: result.ok ? result.value.output : `Translation to ${language} failed`, + text: result.ok ? result.output : `Translation to ${language} failed`, }; }, }); - return { translations: results.ok ? results.value : [] }; + return { translations: results.ok ? results.output : [] }; }, ); ``` @@ -177,7 +177,7 @@ const analyze = flowAgent( return { sentiment: "unknown", summary: "unavailable", keywords: "none" }; } - const [sentiment, summary, keywords] = results.value as readonly [ + const [sentiment, summary, keywords] = results.output as readonly [ Awaited>, Awaited>, Awaited>, @@ -305,11 +305,11 @@ const voter = flowAgent( agent: classifier, input: { text: input.text }, }); - return result.ok ? result.value.output : { category: "unknown" as const, confidence: 0 }; + return result.ok ? result.output : { category: "unknown" as const, confidence: 0 }; }, }); - const votes = results.ok ? results.value : []; + const votes = results.ok ? results.output : []; const validVotes = votes.filter((v) => v.category !== "unknown"); return { @@ -363,7 +363,7 @@ const racingFlowAgent = flowAgent( return { answer: "No response available", winner: "none" }; } - const winner = result.value as { ok: boolean; output: string; model: string }; + const winner = result.output as { ok: boolean; output: string; model: string }; return { answer: winner.ok ? winner.output : "Response failed", winner: winner.model, @@ -429,13 +429,13 @@ const project = flowAgent( id: "analysis-phase", agent: analysisLead, input: research.ok - ? `Analyze these findings: ${research.value.output}` + ? `Analyze these findings: ${research.output}` : `Analyze this question directly: ${input.question}`, }); return { - findings: research.ok ? research.value.output : "Research unavailable", - analysis: analysis.ok ? analysis.value.output : "Analysis unavailable", + findings: research.ok ? research.output : "Research unavailable", + analysis: analysis.ok ? analysis.output : "Analysis unavailable", }; }, ); @@ -460,9 +460,9 @@ const project = flowAgent( ### `$.all` returns wrong types -**Issue:** The `results.value` array has `unknown[]` type. +**Issue:** The `results.output` array has `unknown[]` type. -**Fix:** Cast the destructured values to the expected types. `$.all` returns `unknown[]` since entries can return different types. +**Fix:** Cast the destructured values to the expected types. `$.all` returns `unknown[]` in `.output` since entries can return different types. ### Race does not cancel losers diff --git a/packages/agents/docs/guides/test-agents.md b/packages/agents/docs/guides/test-agents.md index 169d4ea..75cf88b 100644 --- a/packages/agents/docs/guides/test-agents.md +++ b/packages/agents/docs/guides/test-agents.md @@ -186,7 +186,7 @@ const pipeline = flowAgent( if (!stats.ok) throw new Error(stats.error.message); - return stats.value; + return stats.output; }, ); @@ -234,7 +234,7 @@ const failingFlowAgent = flowAgent( }, }); - return { status: result.ok ? result.value : "failed" }; + return { status: result.ok ? result.output : "failed" }; }, ); @@ -308,11 +308,11 @@ describe("flow agent hooks", () => { onStart: () => { events.push("flow:start"); }, - onStepStart: ({ step }) => { - events.push(`step:start:${step.id}`); + onStepStart: ({ stepId }) => { + events.push(`step:start:${stepId}`); }, - onStepFinish: ({ step }) => { - events.push(`step:finish:${step.id}`); + onStepFinish: ({ stepId }) => { + events.push(`step:finish:${stepId}`); }, onFinish: () => { events.push("flow:finish"); diff --git a/packages/agents/docs/hooks.md b/packages/agents/docs/hooks.md index 9b94b16..1cd925b 100644 --- a/packages/agents/docs/hooks.md +++ b/packages/agents/docs/hooks.md @@ -17,15 +17,15 @@ Set on `AgentConfig`: Set on `FlowAgentConfig`: -| Hook | Event fields | When | -| -------------- | -------------------------------------- | ----------------------------------------------------- | -| `onStart` | `{ input }` | After input validation, before handler runs | -| `onFinish` | `{ input, result, duration }` | After successful completion | -| `onError` | `{ input, error }` | On error, before Result is returned | -| `onStepStart` | `{ step: StepInfo }` | Before any `$` operation executes | -| `onStepFinish` | `{ step: StepInfo, result, duration }` | After any `$` operation completes (success AND error) | +| Hook | Event fields | When | +| -------------- | ----------------------------------------------------------- | ----------------------------------------------------- | +| `onStart` | `{ input }` | After input validation, before handler runs | +| `onFinish` | `{ input, result, duration }` | After successful completion | +| `onError` | `{ input, error }` | On error, before Result is returned | +| `onStepStart` | `StepStartEvent` (`{ stepId, stepOperation, agentChain? }`) | Before any `$` operation executes | +| `onStepFinish` | `StepFinishEvent` | After any `$` operation completes (success AND error) | -`onStepFinish` fires on both success and error. On error, `result` is `undefined`. +`onStepFinish` fires on both success and error. On error, `output` is `undefined`. `stepId` is always required (never optional). For `$.agent()` steps, `StepFinishEvent` also includes all AI SDK `StepResult` fields (`usage`, `toolCalls`, `toolResults`, `text`, `finishReason`, etc.). ## Step-Level Hooks @@ -87,7 +87,7 @@ When a parent agent has sub-agents (via the `agents` config), those sub-agents a | Hook | Event type | Why safe | | -------------- | ----------------- | -------------------------------------- | -| `onStepStart` | `StepInfo` | Fixed type, same shape for every agent | +| `onStepStart` | `StepStartEvent` | Fixed type, same shape for every agent | | `onStepFinish` | `StepFinishEvent` | Fixed type, same shape for every agent | | `logger` | `Logger` | No event type, just a logger instance | @@ -118,7 +118,7 @@ Parent.generate({ input, onStepFinish }) | | | | | | Passed into child.generate(): | | | logger -> parent's logger - | | | onStepStart -> parent's onStepStart (StepInfo -- fixed type) + | | | onStepStart -> parent's onStepStart (StepStartEvent -- fixed type) | | | onStepFinish -> parent's merged onStepFinish (StepFinishEvent -- fixed type) | | | | | | NOT passed: diff --git a/packages/agents/docs/multi-agent-orchestration.md b/packages/agents/docs/multi-agent-orchestration.md index e823355..bb83301 100644 --- a/packages/agents/docs/multi-agent-orchestration.md +++ b/packages/agents/docs/multi-agent-orchestration.md @@ -58,17 +58,17 @@ const pipeline = flowAgent( const draft = await $.agent({ id: "write", agent: writer, - input: { research: research.value.output, topic: input.topic }, + input: { research: research.output, topic: input.topic }, }); if (!draft.ok) return { article: "Writing failed" }; const edited = await $.agent({ id: "edit", agent: editor, - input: { draft: draft.value.output }, + input: { draft: draft.output }, }); - return { article: edited.ok ? edited.value.output : draft.value.output }; + return { article: edited.ok ? edited.output : draft.output }; }, ); ``` @@ -110,12 +110,12 @@ const batchTranslate = flowAgent( }); return { language, - text: result.ok ? result.value.output : `Translation to ${language} failed`, + text: result.ok ? result.output : `Translation to ${language} failed`, }; }, }); - return { translations: results.ok ? results.value : [] }; + return { translations: results.ok ? results.output : [] }; }, ); ``` @@ -162,7 +162,7 @@ const analyze = flowAgent( return { sentiment: "unknown", summary: "unavailable" }; } - const [sentiment, summary] = results.value as readonly [ + const [sentiment, summary] = results.output as readonly [ Awaited>, Awaited>, ]; @@ -259,11 +259,11 @@ const voter = flowAgent( agent: classifier, input: { text: input.text }, }); - return result.ok ? result.value.output : { category: "unknown" as const, confidence: 0 }; + return result.ok ? result.output : { category: "unknown" as const, confidence: 0 }; }, }); - const votes = results.ok ? results.value : []; + const votes = results.ok ? results.output : []; const validVotes = votes.filter((v) => v.category !== "unknown"); const counts = new Map(); @@ -330,7 +330,7 @@ const racingFlowAgent = flowAgent( return { answer: "No response available", winner: "none" }; } - const winner = result.value as { ok: boolean; output: string; model: string }; + const winner = result.output as { ok: boolean; output: string; model: string }; return { answer: winner.ok ? winner.output : "Response failed", winner: winner.model, @@ -376,7 +376,7 @@ const project = flowAgent( }); return { - findings: research.ok ? research.value.output : "Research unavailable", + findings: research.ok ? research.output : "Research unavailable", }; }, ); @@ -401,7 +401,7 @@ const project = flowAgent( ### `$.all` returns wrong types -**Issue:** The `results.value` array has `unknown[]` type. +**Issue:** The `results.output` array has `unknown[]` type. **Fix:** Cast the destructured values to the expected types. diff --git a/packages/agents/docs/step-builder.md b/packages/agents/docs/step-builder.md index 87d2ad5..08141a3 100644 --- a/packages/agents/docs/step-builder.md +++ b/packages/agents/docs/step-builder.md @@ -4,25 +4,35 @@ The `$` object is passed into every flow agent handler and step callback. It pro `$` is passed into every callback, enabling composition and nesting. You can always skip `$` and use plain imperative code -- it just will not appear in the trace. -## StepResult +## FlowStepResult -All `$` methods return `Promise>`: +All `$` methods return `Promise>`: ```ts -type StepResult = - | { ok: true; value: T; step: StepInfo; duration: number } - | { ok: false; error: StepError; step: StepInfo; duration: number }; +type FlowStepResult = + | { + ok: true; + output: T; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + } + | { + ok: false; + error: StepError; + stepId: string; + stepOperation: OperationType; + agentChain?: AgentChainEntry[]; + duration: number; + }; ``` -`StepInfo` identifies the step: +Step metadata is flat on the result: -```ts -interface StepInfo { - id: string; // from the $ config's `id` field - index: number; // auto-incrementing within the flow agent - type: OperationType; // 'step' | 'agent' | 'map' | 'each' | 'reduce' | 'while' | 'all' | 'race' -} -``` +- `stepId` -- from the `$` config's `id` field +- `stepOperation` -- `'step' | 'agent' | 'map' | 'each' | 'reduce' | 'while' | 'all' | 'race'` +- `agentChain` -- optional agent ancestry chain `StepError` extends `ResultError` with `stepId: string`. @@ -31,7 +41,7 @@ interface StepInfo { Single unit of work. ```ts -$.step(config: StepConfig): Promise> +$.step(config: StepConfig): Promise> ``` | Field | Required | Type | Description | @@ -51,16 +61,16 @@ const data = await $.step({ }); if (data.ok) { - console.log(data.value); // T + console.log(data.output); // T } ``` ## $.agent -Agent call as a tracked operation. Calls `agent.generate()` internally and unwraps the result -- agent errors become `StepError`, agent success becomes `StepResult`. +Agent call as a tracked operation. Calls `agent.generate()` internally and unwraps the result -- agent errors become `StepError`, agent success becomes `FlowAgentStepResult`. Agent output fields are flat on the result (no double-wrapping). ```ts -$.agent(config: AgentStepConfig): Promise> +$.agent(config: AgentStepConfig): Promise ``` | Field | Required | Type | Description | @@ -83,8 +93,8 @@ const result = await $.agent({ }); if (result.ok) { - console.log(result.value.output); // the agent's output - console.log(result.value.messages); // full message history + console.log(result.output); // the agent's output + console.log(result.messages); // full message history } ``` @@ -93,7 +103,7 @@ if (result.ok) { Parallel map with optional concurrency limit. All items run concurrently (up to `concurrency` limit). Returns results in input order. ```ts -$.map(config: MapConfig): Promise> +$.map(config: MapConfig): Promise> ``` | Field | Required | Type | Description | @@ -122,7 +132,7 @@ const results = await $.map({ Sequential side effects. Runs items one at a time in order. Returns `void`. Checks abort signal before each iteration. ```ts -$.each(config: EachConfig): Promise> +$.each(config: EachConfig): Promise> ``` | Field | Required | Type | Description | @@ -149,7 +159,7 @@ await $.each({ Sequential accumulation. Each step depends on the previous result. Checks abort signal before each iteration. ```ts -$.reduce(config: ReduceConfig): Promise> +$.reduce(config: ReduceConfig): Promise> ``` | Field | Required | Type | Description | @@ -178,7 +188,7 @@ const total = await $.reduce({ Conditional loop. Runs while a condition holds. Returns the last value, or `undefined` if the condition was false on first check. Checks abort signal before each iteration. ```ts -$.while(config: WhileConfig): Promise> +$.while(config: WhileConfig): Promise> ``` | Field | Required | Type | Description | @@ -208,7 +218,7 @@ const result = await $.while({ Concurrent heterogeneous operations -- like `Promise.all`. Entries are factory functions that receive an `AbortSignal` and return a promise. The framework creates an `AbortController`, links it to the parent signal, and starts all factories at the same time. ```ts -$.all(config: AllConfig): Promise> +$.all(config: AllConfig): Promise> ``` | Field | Required | Type | Description | @@ -228,7 +238,7 @@ const result = await $.all({ }); if (result.ok) { - const [users, repos] = result.value; + const [users, repos] = result.output; } ``` @@ -237,7 +247,7 @@ if (result.ok) { First-to-finish wins. Same `entries: EntryFactory[]` pattern as `$.all`. Losers are cancelled via abort signal when the winner resolves. ```ts -$.race(config: RaceConfig): Promise> +$.race(config: RaceConfig): Promise> ``` | Field | Required | Type | Description | @@ -255,7 +265,7 @@ const result = await $.race({ }); if (result.ok) { - const fastest = result.value; + const fastest = result.output; } ``` @@ -271,7 +281,7 @@ const result = await $.step({ id: "inner", execute: async () => "nested value", }); - return inner.ok ? inner.value : "fallback"; + return inner.ok ? inner.output : "fallback"; }, }); ``` diff --git a/packages/agents/docs/streaming.md b/packages/agents/docs/streaming.md index 4634e78..255f459 100644 --- a/packages/agents/docs/streaming.md +++ b/packages/agents/docs/streaming.md @@ -169,7 +169,7 @@ const pipeline = flowAgent( if (!research.ok) throw new Error("Research failed"); - return { article: research.value.output }; + return { article: research.output }; }, ); diff --git a/packages/agents/docs/test-agents.md b/packages/agents/docs/test-agents.md index 239a42b..ada4f3d 100644 --- a/packages/agents/docs/test-agents.md +++ b/packages/agents/docs/test-agents.md @@ -188,7 +188,7 @@ const pipeline = flowAgent( if (!stats.ok) throw new Error(stats.error.message); - return stats.value; + return stats.output; }, ); @@ -236,7 +236,7 @@ const failingFlowAgent = flowAgent( }, }); - return { status: result.ok ? result.value : "failed" }; + return { status: result.ok ? result.output : "failed" }; }, ); @@ -310,11 +310,11 @@ describe("flow agent hooks", () => { onStart: () => { events.push("flow:start"); }, - onStepStart: ({ step }) => { - events.push(`step:start:${step.id}`); + onStepStart: ({ stepId }) => { + events.push(`step:start:${stepId}`); }, - onStepFinish: ({ step }) => { - events.push(`step:finish:${step.id}`); + onStepFinish: ({ stepId }) => { + events.push(`step:finish:${stepId}`); }, onFinish: () => { events.push("flow:finish"); diff --git a/packages/agents/docs/troubleshooting.md b/packages/agents/docs/troubleshooting.md index 835b315..42a1afa 100644 --- a/packages/agents/docs/troubleshooting.md +++ b/packages/agents/docs/troubleshooting.md @@ -20,9 +20,9 @@ **Fix:** Use `tryModel()` for safe lookup, or add the model to `models.config.json` and run `pnpm --filter=@funkai/agents generate:models`. -## StepResult access +## FlowStepResult access -**Fix:** Use `.value` on success, not direct property access. Always check `.ok` first. +**Fix:** Use `.output` on success, not direct property access. Always check `.ok` first. ## StreamResult output and messages are promises diff --git a/packages/agents/src/core/agents/base/agent.test.ts b/packages/agents/src/core/agents/base/agent.test.ts index ee02869..60f6e33 100644 --- a/packages/agents/src/core/agents/base/agent.test.ts +++ b/packages/agents/src/core/agents/base/agent.test.ts @@ -463,45 +463,56 @@ describe("generate() hooks", () => { expect(secondCall[0].stepId).toBe("test-agent:1"); }); - it("fires onStepFinish with mapped toolCalls and toolResults in generate", async () => { + it("passes through all AI SDK StepResult fields in onStepFinish", async () => { const onStepFinish = vi.fn(); - mockGenerateText.mockImplementation( - async (opts: { onStepFinish?: (step: Record) => Promise }) => { - if (opts.onStepFinish) { - await opts.onStepFinish({ - toolCalls: [{ toolName: "myTool", args: { foo: "bar" } }], - toolResults: [{ toolName: "myTool", result: { answer: 42 } }], - usage: { inputTokens: 10, outputTokens: 5 }, - }); - } - return createMockGenerateResult(); + const mockStepData = { + stepNumber: 0, + model: { provider: "openai", modelId: "gpt-4.1" }, + functionId: undefined, + metadata: undefined, + experimental_context: undefined, + content: [], + text: "hello world", + reasoning: [], + reasoningText: undefined, + files: [], + sources: [], + toolCalls: [ + { toolName: "myTool", input: { foo: "bar" }, type: "tool-call", toolCallId: "tc1" }, + ], + staticToolCalls: [], + dynamicToolCalls: [], + toolResults: [ + { toolName: "myTool", output: { answer: 42 }, type: "tool-result", toolCallId: "tc1" }, + ], + staticToolResults: [], + dynamicToolResults: [], + finishReason: "stop", + rawFinishReason: "stop", + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + inputTokenDetails: { noCacheTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }, + outputTokenDetails: { textTokens: 5, reasoningTokens: 0 }, }, - ); - - const a = createSimpleAgent({ onStepFinish }); - await a.generate({ prompt: "test" }); - - expect(onStepFinish).toHaveBeenCalledTimes(1); - const [firstCall] = onStepFinish.mock.calls; - if (!firstCall) { - throw new Error("Expected onStepFinish first call"); - } - expect(firstCall[0].toolCalls).toEqual([{ toolName: "myTool", argsTextLength: 13 }]); - expect(firstCall[0].toolResults).toEqual([{ toolName: "myTool", resultTextLength: 13 }]); - }); - - it("handles missing args/result properties in toolCalls/toolResults", async () => { - const onStepFinish = vi.fn(); + warnings: undefined, + request: { body: undefined }, + response: { + id: "resp-1", + timestamp: new Date(), + modelId: "gpt-4.1", + headers: {}, + messages: [{ role: "assistant", content: "hello world" }], + }, + providerMetadata: undefined, + }; mockGenerateText.mockImplementation( async (opts: { onStepFinish?: (step: Record) => Promise }) => { if (opts.onStepFinish) { - await opts.onStepFinish({ - toolCalls: [{ toolName: "t" }], - toolResults: [{ toolName: "t" }], - usage: { inputTokens: 0, outputTokens: 0 }, - }); + await opts.onStepFinish(mockStepData); } return createMockGenerateResult(); }, @@ -515,21 +526,69 @@ describe("generate() hooks", () => { if (!firstCall) { throw new Error("Expected onStepFinish first call"); } - // ExtractProperty returns {} when key is missing, safeSerializedLength({}) = 2 - expect(firstCall[0].toolCalls).toEqual([{ toolName: "t", argsTextLength: 2 }]); - expect(firstCall[0].toolResults).toEqual([{ toolName: "t", resultTextLength: 2 }]); - }); - - it("handles undefined usage in onStepFinish", async () => { + const event = firstCall[0]; + + // funkai additions + expect(event.stepId).toBe("test-agent:0"); + expect(event.stepOperation).toBe("agent"); + expect(event.agentChain).toEqual([{ id: "test-agent" }]); + + // AI SDK fields passed through unchanged + expect(event.stepNumber).toBe(0); + expect(event.model).toEqual({ provider: "openai", modelId: "gpt-4.1" }); + expect(event.text).toBe("hello world"); + expect(event.finishReason).toBe("stop"); + expect(event.rawFinishReason).toBe("stop"); + expect(event.toolCalls).toEqual(mockStepData.toolCalls); + expect(event.toolResults).toEqual(mockStepData.toolResults); + expect(event.usage).toEqual(mockStepData.usage); + expect(event.reasoning).toEqual([]); + expect(event.files).toEqual([]); + expect(event.sources).toEqual([]); + expect(event.content).toEqual([]); + expect(event.warnings).toBeUndefined(); + expect(event.request).toEqual(mockStepData.request); + expect(event.response).toEqual(mockStepData.response); + expect(event.providerMetadata).toBeUndefined(); + }); + + it("preserves tool call args and results without stripping", async () => { const onStepFinish = vi.fn(); + const mockStepData = { + stepNumber: 0, + text: "", + toolCalls: [ + { + toolName: "search", + input: { query: "typescript", limit: 10 }, + type: "tool-call", + toolCallId: "tc1", + }, + { + toolName: "fetch", + input: { url: "https://example.com" }, + type: "tool-call", + toolCallId: "tc2", + }, + ], + toolResults: [ + { + toolName: "search", + output: { items: [1, 2, 3] }, + type: "tool-result", + toolCallId: "tc1", + }, + { toolName: "fetch", output: { body: "" }, type: "tool-result", toolCallId: "tc2" }, + ], + usage: { inputTokens: 50, outputTokens: 25, totalTokens: 75 }, + finishReason: "tool-calls", + }; + mockGenerateText.mockImplementation( async (opts: { onStepFinish?: (step: Record) => Promise }) => { if (opts.onStepFinish) { - await opts.onStepFinish({ - toolCalls: [], - toolResults: [], - }); + await opts.onStepFinish(mockStepData); } return createMockGenerateResult(); }, @@ -543,42 +602,23 @@ describe("generate() hooks", () => { if (!firstCall) { throw new Error("Expected onStepFinish first call"); } - expect(firstCall[0].usage).toEqual({ - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }); - }); + const event = firstCall[0]; - it("handles circular reference in tool args gracefully", async () => { - const onStepFinish = vi.fn(); + // Full tool call objects preserved (not stripped to toolName + argsTextLength) + expect(event.toolCalls).toEqual(mockStepData.toolCalls); + expect(event.toolCalls[0].input).toEqual({ query: "typescript", limit: 10 }); + expect(event.toolCalls[1].input).toEqual({ url: "https://example.com" }); - mockGenerateText.mockImplementation( - async (opts: { onStepFinish?: (step: Record) => Promise }) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional circular ref for test - const circular: any = {}; - circular.self = circular; - if (opts.onStepFinish) { - await opts.onStepFinish({ - toolCalls: [{ toolName: "t", args: circular }], - toolResults: [], - usage: { inputTokens: 0, outputTokens: 0 }, - }); - } - return createMockGenerateResult(); - }, - ); + // Full tool result objects preserved (not stripped to toolName + resultTextLength) + expect(event.toolResults).toEqual(mockStepData.toolResults); + expect(event.toolResults[0].output).toEqual({ items: [1, 2, 3] }); + expect(event.toolResults[1].output).toEqual({ body: "" }); - const a = createSimpleAgent({ onStepFinish }); - await a.generate({ prompt: "test" }); + // Usage passed through as-is + expect(event.usage).toEqual(mockStepData.usage); - expect(onStepFinish).toHaveBeenCalledTimes(1); - const [firstCall] = onStepFinish.mock.calls; - if (!firstCall) { - throw new Error("Expected onStepFinish first call"); - } - // SafeSerializedLength returns 0 for circular references - expect(firstCall[0].toolCalls).toEqual([{ toolName: "t", argsTextLength: 0 }]); + // finishReason passed through (not as stepId) + expect(event.finishReason).toBe("tool-calls"); }); it("fires both config and override onStart hooks", async () => { @@ -1054,18 +1094,27 @@ describe("stream() hooks", () => { expect(onStepFinish).toHaveBeenCalled(); }); - it("fires onStepFinish with mapped toolCalls and toolResults", async () => { + it("passes through AI SDK StepResult fields in stream onStepFinish", async () => { const onStepFinish = vi.fn(); + const mockStepData = { + stepNumber: 0, + text: "streamed output", + toolCalls: [ + { toolName: "myTool", input: { foo: "bar" }, type: "tool-call", toolCallId: "tc1" }, + ], + toolResults: [ + { toolName: "myTool", output: { answer: 42 }, type: "tool-result", toolCallId: "tc1" }, + ], + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + finishReason: "stop", + }; + const streamResult = createMockStreamResult(); mockStreamText.mockImplementation( (opts: { onStepFinish?: (step: Record) => Promise }) => { if (opts.onStepFinish) { - void opts.onStepFinish({ - toolCalls: [{ toolName: "myTool", args: { foo: "bar" } }], - toolResults: [{ toolName: "myTool", result: { answer: 42 } }], - usage: { inputTokens: 10, outputTokens: 5 }, - }); + void opts.onStepFinish(mockStepData); } return streamResult; }, @@ -1099,9 +1148,20 @@ describe("stream() hooks", () => { if (!firstCall) { throw new Error("Expected onStepFinish first call"); } - expect(firstCall[0].stepId).toBe("test-agent:0"); - expect(firstCall[0].toolCalls).toEqual([{ toolName: "myTool", argsTextLength: 13 }]); - expect(firstCall[0].toolResults).toEqual([{ toolName: "myTool", resultTextLength: 13 }]); + const event = firstCall[0]; + + // funkai additions + expect(event.stepId).toBe("test-agent:0"); + expect(event.stepOperation).toBe("agent"); + expect(event.agentChain).toEqual([{ id: "test-agent" }]); + + // AI SDK fields passed through + expect(event.stepNumber).toBe(0); + expect(event.text).toBe("streamed output"); + expect(event.toolCalls).toEqual(mockStepData.toolCalls); + expect(event.toolResults).toEqual(mockStepData.toolResults); + expect(event.usage).toEqual(mockStepData.usage); + expect(event.finishReason).toBe("stop"); }); }); diff --git a/packages/agents/src/core/agents/base/agent.ts b/packages/agents/src/core/agents/base/agent.ts index 5ff729f..f9afb59 100644 --- a/packages/agents/src/core/agents/base/agent.ts +++ b/packages/agents/src/core/agents/base/agent.ts @@ -1,6 +1,6 @@ import { generateText, streamText, stepCountIs } from "ai"; import type { AsyncIterableStream } from "ai"; -import { isNil, isNotNil, isString } from "es-toolkit"; +import { isNil, isNotNil } from "es-toolkit"; import { resolveOutput } from "@/core/agents/base/output.js"; import type { OutputParam, OutputSpec } from "@/core/agents/base/output.js"; @@ -27,7 +27,13 @@ import { createDefaultLogger } from "@/core/logger.js"; import type { Logger } from "@/core/logger.js"; import type { LanguageModel } from "@/core/provider/types.js"; import type { Tool } from "@/core/tool.js"; -import type { AgentChainEntry, Model, StepFinishEvent, StreamPart } from "@/core/types.js"; +import type { + AIStepResult, + AgentChainEntry, + Model, + StepFinishEvent, + StreamPart, +} from "@/core/types.js"; import { fireHooks, wrapHook } from "@/lib/hooks.js"; import { withModelMiddleware } from "@/lib/middleware.js"; import { AGENT_CONFIG, RUNNABLE_META } from "@/lib/runnable.js"; @@ -160,11 +166,7 @@ export function agent< readonly output: OutputSpec | undefined; readonly maxSteps: number; readonly signal: AbortSignal | undefined; - readonly onStepFinish: (step: { - toolCalls?: readonly ({ toolName: string } & Record)[]; - toolResults?: readonly ({ toolName: string } & Record)[]; - usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; - }) => Promise; + readonly onStepFinish: (step: AIStepResult) => Promise; } /** @@ -257,26 +259,12 @@ export function agent< await fireHooks(log, wrapHook(config.onStart, { input }), wrapHook(params.onStart, { input })); const stepCounter = { value: 0 }; - const onStepFinish = async (step: { - toolCalls?: readonly ({ toolName: string } & Record)[]; - toolResults?: readonly ({ toolName: string } & Record)[]; - usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; - }) => { + const onStepFinish = async (step: AIStepResult) => { const stepId = `${config.name}:${stepCounter.value++}`; - const toolCalls = (step.toolCalls ?? []).map((tc) => { - const args = extractProperty(tc, "args"); - return { toolName: tc.toolName, argsTextLength: safeSerializedLength(args) }; - }); - const toolResults = (step.toolResults ?? []).map((tr) => { - const result = extractProperty(tr, "result"); - return { toolName: tr.toolName, resultTextLength: safeSerializedLength(result) }; - }); - const usage = extractUsage(step.usage); const event: StepFinishEvent = { + ...step, stepId, - toolCalls, - toolResults, - usage, + stepOperation: "agent", agentChain: currentChain, }; await fireHooks( @@ -624,24 +612,6 @@ export function agent< // Private // --------------------------------------------------------------------------- -/** - * Safely compute the JSON-serialized length of a value. - * Returns 0 if serialization fails (e.g. circular refs, BigInt). - * - * @private - */ -function safeSerializedLength(value: unknown): number { - try { - const json = JSON.stringify(value); - if (isString(json)) { - return json.length; - } - return 0; - } catch { - return 0; - } -} - /** * Return the value if the predicate is true, otherwise undefined. * Replaces `predicate ? value : undefined` ternary. @@ -668,45 +638,6 @@ function resolveOptionalOutput(param: OutputParam | undefined): OutputSpec | und return undefined; } -/** - * Safely extract a property from an object, returning `{}` if the - * property does not exist. Replaces `'key' in obj ? obj[key] : {}` ternary. - * - * @private - */ -function extractProperty(obj: Record, key: string): unknown { - if (Object.hasOwn(obj, key)) { - // eslint-disable-next-line security/detect-object-injection -- Key is a controlled function parameter, not user input - return obj[key]; - } - return {}; -} - -/** - * Extract token usage from a step's usage object, defaulting to 0 - * when usage is undefined. Replaces optional chaining on `step.usage`. - * - * @private - */ -function extractUsage( - usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | undefined, -): { - inputTokens: number; - outputTokens: number; - totalTokens: number; -} { - if (isNotNil(usage)) { - const inputTokens = usage.inputTokens ?? 0; - const outputTokens = usage.outputTokens ?? 0; - return { - inputTokens, - outputTokens, - totalTokens: usage.totalTokens ?? inputTokens + outputTokens, - }; - } - return { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; -} - /** * Return `ifOutput` when `output` is defined, `ifText` otherwise. * Replaces `output ? aiResult.output : aiResult.text` ternary. diff --git a/packages/agents/src/core/agents/base/utils.ts b/packages/agents/src/core/agents/base/utils.ts index b9e992b..5943ba1 100644 --- a/packages/agents/src/core/agents/base/utils.ts +++ b/packages/agents/src/core/agents/base/utils.ts @@ -10,7 +10,7 @@ import type { Agent, Message, Resolver } from "@/core/agents/types.js"; import type { Logger } from "@/core/logger.js"; import type { TokenUsage } from "@/core/provider/types.js"; import type { Tool } from "@/core/tool.js"; -import type { AgentChainEntry, StepFinishEvent, StepInfo } from "@/core/types.js"; +import type { AgentChainEntry, StepFinishEvent, StepStartEvent } from "@/core/types.js"; import { RUNNABLE_META } from "@/lib/runnable.js"; import type { RunnableMeta } from "@/lib/runnable.js"; @@ -22,7 +22,7 @@ import type { RunnableMeta } from "@/lib/runnable.js"; * * ## Why only step hooks are forwarded * - * `onStepStart` and `onStepFinish` use fixed event types (`StepInfo`, + * `onStepStart` and `onStepFinish` use fixed event types (`StepStartEvent`, * `StepFinishEvent`) that are the same for every agent — safe to pass * from parent to child with no type mismatch. * @@ -43,7 +43,7 @@ import type { RunnableMeta } from "@/lib/runnable.js"; * ``` * Passed into child.generate() — fixed types, safe: * log → child creates .child({ agentId }) from it - * onStepStart → StepInfo (same shape for all agents) + * onStepStart → StepStartEvent (same shape for all agents) * onStepFinish → StepFinishEvent (same shape for all agents) * * NOT passed down — generic types, would break type safety: @@ -59,9 +59,9 @@ export interface ParentAgentContext { /** * Fires when a sub-agent step starts. * - * Uses `StepInfo` — a fixed (non-generic) type, safe to forward. + * Uses `StepStartEvent` — a fixed (non-generic) type, safe to forward. */ - onStepStart?: ((event: { step: StepInfo }) => void | Promise) | undefined; + onStepStart?: ((event: StepStartEvent) => void | Promise) | undefined; /** * Fires when a sub-agent step finishes. diff --git a/packages/agents/src/core/agents/flow/engine.test.ts b/packages/agents/src/core/agents/flow/engine.test.ts index 1e3b20f..a7c72cd 100644 --- a/packages/agents/src/core/agents/flow/engine.test.ts +++ b/packages/agents/src/core/agents/flow/engine.test.ts @@ -148,7 +148,7 @@ describe(createFlowEngine, () => { if (!stepResult.ok) { return { y: 0 }; } - return { y: stepResult.value.v }; + return { y: stepResult.output.v }; }); const result = await fa.generate({ input: { x: 42 } }); diff --git a/packages/agents/src/core/agents/flow/engine.ts b/packages/agents/src/core/agents/flow/engine.ts index 1b07890..7aa7716 100644 --- a/packages/agents/src/core/agents/flow/engine.ts +++ b/packages/agents/src/core/agents/flow/engine.ts @@ -13,7 +13,7 @@ import type { } from "@/core/agents/flow/types.js"; import type { Logger } from "@/core/logger.js"; import { createDefaultLogger } from "@/core/logger.js"; -import type { StepFinishEvent, StepInfo } from "@/core/types.js"; +import type { StepFinishEvent, StepStartEvent } from "@/core/types.js"; import type { ExecutionContext } from "@/lib/context.js"; import { fireHooks } from "@/lib/hooks.js"; @@ -90,7 +90,7 @@ export interface FlowEngineConfig { /** * Default hook: fires when any step starts. */ - onStepStart?: (event: { step: StepInfo }) => void | Promise; + onStepStart?: (event: StepStartEvent) => void | Promise; /** * Default hook: fires when any step finishes. @@ -392,7 +392,7 @@ export function createFlowEngine< if (!result.ok) { throw result.error; } - return result.value; + return result.output; }, ]), ); diff --git a/packages/agents/src/core/agents/flow/flow-agent.test.ts b/packages/agents/src/core/agents/flow/flow-agent.test.ts index 218f47a..7d6b166 100644 --- a/packages/agents/src/core/agents/flow/flow-agent.test.ts +++ b/packages/agents/src/core/agents/flow/flow-agent.test.ts @@ -141,7 +141,7 @@ describe("generate() with steps", () => { }); if (result.ok) { - return { y: result.value }; + return { y: result.output }; } return { y: 0 }; }, @@ -423,9 +423,11 @@ describe("generate() hooks", () => { if (!overrideCall) { throw new Error("Expected overrideOnStepFinish first call"); } - expect(configCall[0]).toHaveProperty("step"); + expect(configCall[0]).toHaveProperty("stepId"); + expect(configCall[0]).toHaveProperty("stepOperation"); expect(configCall[0]).toHaveProperty("duration"); - expect(overrideCall[0]).toHaveProperty("step"); + expect(overrideCall[0]).toHaveProperty("stepId"); + expect(overrideCall[0]).toHaveProperty("stepOperation"); expect(overrideCall[0]).toHaveProperty("duration"); }); diff --git a/packages/agents/src/core/agents/flow/flow-agent.ts b/packages/agents/src/core/agents/flow/flow-agent.ts index 0fac2e5..cee213f 100644 --- a/packages/agents/src/core/agents/flow/flow-agent.ts +++ b/packages/agents/src/core/agents/flow/flow-agent.ts @@ -24,7 +24,7 @@ import type { GenerateParams, GenerateResult, Message, StreamResult } from "@/co import { createDefaultLogger } from "@/core/logger.js"; import type { Logger } from "@/core/logger.js"; import type { TokenUsage } from "@/core/provider/types.js"; -import type { AgentChainEntry, StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js"; +import type { AgentChainEntry, StepFinishEvent, StepStartEvent, StreamPart } from "@/core/types.js"; import type { Context } from "@/lib/context.js"; import { fireHooks, wrapHook } from "@/lib/hooks.js"; import { FLOW_AGENT_CONFIG, RUNNABLE_META } from "@/lib/runnable.js"; @@ -39,7 +39,7 @@ import type { Result } from "@/utils/result.js"; * * @private */ -type StepStartHook = (event: { step: StepInfo }) => void | Promise; +type StepStartHook = (event: StepStartEvent) => void | Promise; type StepFinishHook = (event: StepFinishEvent) => void | Promise; /** diff --git a/packages/agents/src/core/agents/flow/steps/agent.test.ts b/packages/agents/src/core/agents/flow/steps/agent.test.ts index 0dedefe..fe619f7 100644 --- a/packages/agents/src/core/agents/flow/steps/agent.test.ts +++ b/packages/agents/src/core/agents/flow/steps/agent.test.ts @@ -42,11 +42,11 @@ describe("agent()", () => { if (!result.ok) { return; } - expect(result.value.output).toBe("hello"); - expect(result.value.messages).toEqual([]); - expect(result.value.usage).toEqual(MOCK_USAGE); - expect(result.value.finishReason).toBe("stop"); - expect(result.step.type).toBe("agent"); + expect(result.output).toBe("hello"); + expect(result.messages).toEqual([]); + expect(result.usage).toEqual(MOCK_USAGE); + expect(result.finishReason).toBe("stop"); + expect(result.stepOperation).toBe("agent"); }); it("converts agent error result into StepError", async () => { @@ -261,7 +261,7 @@ describe("agent()", () => { if (!result.ok) { return; } - expect(result.value.messages).toEqual([]); + expect(result.messages).toEqual([]); }); it("records usage on trace entry", async () => { diff --git a/packages/agents/src/core/agents/flow/steps/all.test.ts b/packages/agents/src/core/agents/flow/steps/all.test.ts index 1584827..662a1e5 100644 --- a/packages/agents/src/core/agents/flow/steps/all.test.ts +++ b/packages/agents/src/core/agents/flow/steps/all.test.ts @@ -17,8 +17,8 @@ describe("all()", () => { if (!result.ok) { return; } - expect(result.value).toEqual(["a", "b", "c"]); - expect(result.step.type).toBe("all"); + expect(result.output).toEqual(["a", "b", "c"]); + expect(result.stepOperation).toBe("all"); }); it("fails fast on first error", async () => { @@ -105,7 +105,7 @@ describe("all()", () => { if (!result.ok) { return; } - expect(result.value).toEqual([]); + expect(result.output).toEqual([]); }); it("handles single entry", async () => { @@ -121,7 +121,7 @@ describe("all()", () => { if (!result.ok) { return; } - expect(result.value).toEqual([42]); + expect(result.output).toEqual([42]); }); it("returns results in entry order", async () => { @@ -151,7 +151,7 @@ describe("all()", () => { if (!result.ok) { return; } - expect(result.value).toEqual(["slow", "fast", "medium"]); + expect(result.output).toEqual(["slow", "fast", "medium"]); }); it("propagates parent abort signal to child abort controller", async () => { @@ -271,7 +271,7 @@ describe("all()", () => { if (!inner.ok) { throw new Error("inner failed"); } - return inner.value; + return inner.output; }, ], }); @@ -309,6 +309,6 @@ describe("all()", () => { if (!result.ok) { return; } - expect(result.value).toEqual(["string", 42, { key: "value" }, [1, 2, 3]]); + expect(result.output).toEqual(["string", 42, { key: "value" }, [1, 2, 3]]); }); }); diff --git a/packages/agents/src/core/agents/flow/steps/builder.ts b/packages/agents/src/core/agents/flow/steps/builder.ts index ecbe57b..17b50e5 100644 --- a/packages/agents/src/core/agents/flow/steps/builder.ts +++ b/packages/agents/src/core/agents/flow/steps/builder.ts @@ -4,10 +4,9 @@ import type { EachConfig } from "@/core/agents/flow/steps/each.js"; import type { MapConfig } from "@/core/agents/flow/steps/map.js"; import type { RaceConfig } from "@/core/agents/flow/steps/race.js"; import type { ReduceConfig } from "@/core/agents/flow/steps/reduce.js"; -import type { StepResult } from "@/core/agents/flow/steps/result.js"; +import type { FlowAgentStepResult, FlowStepResult } from "@/core/agents/flow/steps/result.js"; import type { StepConfig } from "@/core/agents/flow/steps/step.js"; import type { WhileConfig } from "@/core/agents/flow/steps/while.js"; -import type { GenerateResult } from "@/core/agents/types.js"; /** * The `$` object — composable step utilities. @@ -26,10 +25,10 @@ export interface StepBuilder { * @typeParam T - The return type of the step. * @param config - Step configuration with id, execute function, * and optional hooks. - * @returns A `StepResult` with `.value` containing the step's + * @returns A `FlowStepResult` with `.output` containing the step's * return value on success, or a `StepError` on failure. */ - step(config: StepConfig): Promise>; + step(config: StepConfig): Promise>; /** * Execute an agent call as a tracked operation. @@ -37,14 +36,15 @@ export interface StepBuilder { * The framework records the agent name, input, and output in the * trace. Calls `agent.generate()` internally and unwraps the result — * agent errors become `StepError`, agent success becomes - * `StepResult`. + * `FlowAgentStepResult` with `GenerateResult` fields flat on the result. * * @typeParam TInput - The agent's input type. * @param config - Agent step configuration with id, agent, input, * optional overrides, and optional hooks. - * @returns A `StepResult` wrapping the agent's `GenerateResult`. + * @returns A `FlowAgentStepResult` with `output`, `messages`, `usage`, + * `finishReason` flat on the result (no double-wrapping). */ - agent(config: AgentStepConfig): Promise>; + agent(config: AgentStepConfig): Promise; /** * Parallel map — each item is a tracked operation. @@ -56,9 +56,9 @@ export interface StepBuilder { * @typeParam R - Output item type. * @param config - Map configuration with id, input array, execute * function, optional concurrency, and optional hooks. - * @returns A `StepResult` wrapping the array of results in input order. + * @returns A `FlowStepResult` wrapping the array of results in input order. */ - map(config: MapConfig): Promise>; + map(config: MapConfig): Promise>; /** * Sequential side effects — runs one item at a time. @@ -68,9 +68,9 @@ export interface StepBuilder { * @typeParam T - Input item type. * @param config - Each configuration with id, input array, execute * function, and optional hooks. - * @returns A `StepResult` wrapping void when all items are processed. + * @returns A `FlowStepResult` wrapping void when all items are processed. */ - each(config: EachConfig): Promise>; + each(config: EachConfig): Promise>; /** * Sequential accumulation — each step depends on the previous result. @@ -79,9 +79,9 @@ export interface StepBuilder { * @typeParam R - Accumulator/result type. * @param config - Reduce configuration with id, input array, initial * value, execute function, and optional hooks. - * @returns A `StepResult` wrapping the final accumulated value. + * @returns A `FlowStepResult` wrapping the final accumulated value. */ - reduce(config: ReduceConfig): Promise>; + reduce(config: ReduceConfig): Promise>; /** * Conditional loop — runs while a condition holds. @@ -92,9 +92,9 @@ export interface StepBuilder { * @typeParam T - The value type produced by each iteration. * @param config - While configuration with id, condition, execute * function, and optional hooks. - * @returns A `StepResult` wrapping the last iteration's value, or `undefined`. + * @returns A `FlowStepResult` wrapping the last iteration's value, or `undefined`. */ - while(config: WhileConfig): Promise>; + while(config: WhileConfig): Promise>; /** * Run heterogeneous operations concurrently — like `Promise.all`. @@ -105,9 +105,9 @@ export interface StepBuilder { * * @param config - All configuration with id, entry factories, * and optional hooks. - * @returns A `StepResult` wrapping the array of results in entry order. + * @returns A `FlowStepResult` wrapping the array of results in entry order. */ - all(config: AllConfig): Promise>; + all(config: AllConfig): Promise>; /** * Run operations concurrently — first to finish wins. @@ -118,7 +118,7 @@ export interface StepBuilder { * * @param config - Race configuration with id, entry factories, * and optional hooks. - * @returns A `StepResult` wrapping the first resolved value. + * @returns A `FlowStepResult` wrapping the first resolved value. */ - race(config: RaceConfig): Promise>; + race(config: RaceConfig): Promise>; } diff --git a/packages/agents/src/core/agents/flow/steps/each.test.ts b/packages/agents/src/core/agents/flow/steps/each.test.ts index 2011389..655e4ec 100644 --- a/packages/agents/src/core/agents/flow/steps/each.test.ts +++ b/packages/agents/src/core/agents/flow/steps/each.test.ts @@ -19,7 +19,7 @@ describe("each()", () => { expect(result.ok).toBeTruthy(); expect(order).toEqual([1, 2, 3]); - expect(result.step.type).toBe("each"); + expect(result.stepOperation).toBe("each"); }); it("returns ok: true with void value on success", async () => { @@ -36,7 +36,7 @@ describe("each()", () => { if (!result.ok) { return; } - expect(result.value).toBeUndefined(); + expect(result.output).toBeUndefined(); }); it("propagates errors from execute", async () => { diff --git a/packages/agents/src/core/agents/flow/steps/factory.test-d.ts b/packages/agents/src/core/agents/flow/steps/factory.test-d.ts index 1164f28..a353615 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.test-d.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.test-d.ts @@ -2,7 +2,7 @@ import { describe, expectTypeOf, it } from "vitest"; import type { StepBuilder } from "@/core/agents/flow/steps/builder.js"; import { createStepBuilder } from "@/core/agents/flow/steps/factory.js"; -import type { StepResult, StepError } from "@/core/agents/flow/steps/result.js"; +import type { FlowStepResult, StepError } from "@/core/agents/flow/steps/result.js"; import type { ResultError } from "@/utils/result.js"; describe("stepError extends ResultError", () => { @@ -15,30 +15,30 @@ describe("stepError extends ResultError", () => { }); }); -describe("stepResult", () => { +describe("FlowStepResult", () => { it("success branch has ok: true", () => { - type Success = Extract, { ok: true }>; + type Success = Extract, { ok: true }>; expectTypeOf().toEqualTypeOf(); }); - it("success branch has value: T field", () => { - type Success = Extract, { ok: true }>; - expectTypeOf().toEqualTypeOf<{ value: number }>(); + it("success branch has output: T field", () => { + type Success = Extract, { ok: true }>; + expectTypeOf().toEqualTypeOf<{ value: number }>(); }); - it("success branch has step and duration", () => { - type Success = Extract, { ok: true }>; - expectTypeOf().toHaveProperty("id"); + it("success branch has stepId and duration", () => { + type Success = Extract, { ok: true }>; + expectTypeOf().toBeString(); expectTypeOf().toBeNumber(); }); it("failure branch has ok: false", () => { - type Failure = Extract, { ok: false }>; + type Failure = Extract, { ok: false }>; expectTypeOf().toEqualTypeOf(); }); it("failure branch has StepError", () => { - type Failure = Extract, { ok: false }>; + type Failure = Extract, { ok: false }>; expectTypeOf().toExtend(); }); }); diff --git a/packages/agents/src/core/agents/flow/steps/factory.test.ts b/packages/agents/src/core/agents/flow/steps/factory.test.ts index c27063b..2a3b1ea 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.test.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.test.ts @@ -21,10 +21,9 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toEqual({ greeting: "hello" }); - expect(result.step.id).toBe("greet"); - expect(result.step.type).toBe("step"); - expect(result.step.index).toBe(0); + expect(result.output).toEqual({ greeting: "hello" }); + expect(result.stepId).toBe("greet"); + expect(result.stepOperation).toBe("step"); expect(result.duration).toBeGreaterThanOrEqual(0); }); @@ -47,7 +46,7 @@ describe("step()", () => { expect(result.error.message).toBe("boom"); expect(result.error.stepId).toBe("fail"); expect(result.error.cause).toBeInstanceOf(Error); - expect(result.step.id).toBe("fail"); + expect(result.stepId).toBe("fail"); expect(result.duration).toBeGreaterThanOrEqual(0); }); @@ -150,8 +149,8 @@ describe("step()", () => { expect(parentFinish).toHaveBeenCalledTimes(1); expect(parentFinish).toHaveBeenCalledWith( expect.objectContaining({ - step: expect.objectContaining({ id: "parent-finish-on-error" }), - result: undefined, + stepId: "parent-finish-on-error", + output: undefined, }), ); }); @@ -172,7 +171,7 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toEqual({ value: 42 }); + expect(result.output).toEqual({ value: 42 }); expect(ctx.log.warn).toHaveBeenCalledWith("hook error", { error: "hook boom" }); }); @@ -218,19 +217,6 @@ describe("step()", () => { expect(traceEntry.error.message).toBe("trace-boom"); }); - it("increments step index across calls (shared ref)", async () => { - const ctx = createMockCtx(); - const $ = createStepBuilder({ ctx }); - - const r1 = await $.step({ id: "a", execute: async () => ({}) }); - const r2 = await $.step({ id: "b", execute: async () => ({}) }); - const r3 = await $.step({ id: "c", execute: async () => ({}) }); - - expect(r1.step.index).toBe(0); - expect(r2.step.index).toBe(1); - expect(r3.step.index).toBe(2); - }); - it("provides child $ for nested operations", async () => { const ctx = createMockCtx(); const $$ = createStepBuilder({ ctx }); @@ -272,7 +258,7 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toBe("hello"); + expect(result.output).toBe("hello"); }); it("handles primitive number return via value field", async () => { @@ -288,7 +274,7 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toBe(42); + expect(result.output).toBe(42); }); }); @@ -328,11 +314,11 @@ describe("agent()", () => { if (!result.ok) { return; } - expect(result.value.output).toBe("hello"); - expect(result.value.messages).toEqual([]); - expect(result.value.usage).toEqual(MOCK_USAGE); - expect(result.value.finishReason).toBe("stop"); - expect(result.step.type).toBe("agent"); + expect(result.output).toBe("hello"); + expect(result.messages).toEqual([]); + expect(result.usage).toEqual(MOCK_USAGE); + expect(result.finishReason).toBe("stop"); + expect(result.stepOperation).toBe("agent"); }); it("converts agent error result into StepError", async () => { @@ -466,7 +452,7 @@ describe("map()", () => { // StepResult spreads the array — it's on the result as the value // Since T = { doubled: number }[], the spread puts the array properties on result // Actually, for array types, `T & { ok: true, ... }` means array methods are available - expect(result.step.type).toBe("map"); + expect(result.stepOperation).toBe("map"); }); it("respects concurrency limit", async () => { @@ -538,7 +524,7 @@ describe("each()", () => { expect(result.ok).toBeTruthy(); expect(order).toEqual([1, 2, 3]); - expect(result.step.type).toBe("each"); + expect(result.stepOperation).toBe("each"); }); it("propagates errors from execute", async () => { @@ -587,7 +573,7 @@ describe("reduce()", () => { throw new Error("Expected trace entry"); } expect(traceEntry.output).toBe(10); - expect(result.step.type).toBe("reduce"); + expect(result.stepOperation).toBe("reduce"); }); it("uses initial value when input is empty", async () => { @@ -622,7 +608,7 @@ describe("while()", () => { }); expect(result.ok).toBeTruthy(); - expect(result.step.type).toBe("while"); + expect(result.stepOperation).toBe("while"); const [traceEntry] = ctx.trace; if (traceEntry === undefined) { throw new Error("Expected trace entry"); @@ -670,7 +656,7 @@ describe("all()", () => { } const output = traceEntry.output as string[]; expect(output).toEqual(["a", "b", "c"]); - expect(result.step.type).toBe("all"); + expect(result.stepOperation).toBe("all"); }); it("fails fast on first error", async () => { @@ -745,7 +731,7 @@ describe("race()", () => { throw new Error("Expected trace entry"); } expect(traceEntry.output).toBe("fast"); - expect(result.step.type).toBe("race"); + expect(result.stepOperation).toBe("race"); }); it("cancels losing entries via abort signal", async () => { @@ -772,7 +758,7 @@ describe("race()", () => { if (!result.ok) { return; } - expect(result.value).toBe("winner"); + expect(result.output).toBe("winner"); if (signals.loser === undefined) { throw new Error("Expected loser signal"); } @@ -867,11 +853,11 @@ describe("agent() streaming with writer", () => { if (!result.ok) { return; } - expect(result.step.type).toBe("agent"); - expect(result.value.output).toBe("hello world"); - expect(result.value.messages).toEqual([]); - expect(result.value.usage).toEqual(MOCK_USAGE); - expect(result.value.finishReason).toBe("stop"); + expect(result.stepOperation).toBe("agent"); + expect(result.output).toBe("hello world"); + expect(result.messages).toEqual([]); + expect(result.usage).toEqual(MOCK_USAGE); + expect(result.finishReason).toBe("stop"); expect(agent.stream).toHaveBeenCalled(); expect(agent.generate).not.toHaveBeenCalled(); diff --git a/packages/agents/src/core/agents/flow/steps/factory.ts b/packages/agents/src/core/agents/flow/steps/factory.ts index 00917fd..f029dfb 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.ts @@ -1,5 +1,6 @@ import { isNil, isNotNil } from "es-toolkit"; import { isObject } from "es-toolkit/compat"; +import { P, match } from "ts-pattern"; import { _agentChainField } from "@/core/agents/base/utils.js"; import { @@ -14,13 +15,17 @@ import type { EachConfig } from "@/core/agents/flow/steps/each.js"; import type { MapConfig } from "@/core/agents/flow/steps/map.js"; import type { RaceConfig } from "@/core/agents/flow/steps/race.js"; import type { ReduceConfig } from "@/core/agents/flow/steps/reduce.js"; -import type { StepResult, StepError } from "@/core/agents/flow/steps/result.js"; +import type { + FlowStepResult, + FlowAgentStepResult, + StepError, +} from "@/core/agents/flow/steps/result.js"; import type { StepConfig } from "@/core/agents/flow/steps/step.js"; import type { WhileConfig } from "@/core/agents/flow/steps/while.js"; /* oxlint-disable import/max-dependencies -- step factory requires many internal modules */ import type { GenerateResult, StreamResult } from "@/core/agents/types.js"; import type { TokenUsage } from "@/core/provider/types.js"; -import type { AgentChainEntry, StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js"; +import type { AgentChainEntry, StepFinishEvent, StepStartEvent, StreamPart } from "@/core/types.js"; import type { Context } from "@/lib/context.js"; import { fireHooks } from "@/lib/hooks.js"; import type { TraceEntry, OperationType } from "@/lib/trace.js"; @@ -45,7 +50,7 @@ export interface StepBuilderOptions { */ parentHooks?: | { - onStepStart?: ((event: { step: StepInfo }) => void | Promise) | undefined; + onStepStart?: ((event: StepStartEvent) => void | Promise) | undefined; onStepFinish?: ((event: StepFinishEvent) => void | Promise) | undefined; } | undefined; @@ -62,7 +67,7 @@ export interface StepBuilderOptions { /** * Agent ancestry chain from root to the current flow agent. * - * Attached to every `StepInfo` and `StepFinishEvent` so hook + * Attached to every `StepStartEvent` and `StepFinishEvent` so hook * consumers can trace which agent produced the event. Also * forwarded to sub-agents called via `$.agent()`. * @@ -73,6 +78,7 @@ export interface StepBuilderOptions { /** * Mutable ref for globally unique step indices within a flow agent. + * Kept internal — only used for `buildToolCallId`. */ interface IndexRef { current: number; @@ -84,7 +90,7 @@ interface IndexRef { * The returned builder is the `$` object passed into every flow agent * handler and step callback. It owns the full step lifecycle: * trace registration, hook firing, error wrapping, - * and `StepResult` construction. + * and `FlowStepResult` construction. * * @param options - Factory configuration with context, optional parent * hooks, and optional stream writer. @@ -107,7 +113,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR /** * Core step primitive — every other method delegates here. */ - async function step(config: StepConfig): Promise> { + async function step(config: StepConfig): Promise> { const onFinishHandler = buildOnFinishHandler(config.onFinish); return executeStep({ @@ -133,10 +139,11 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR | ((event: { id: string; result: unknown; duration: number }) => void | Promise) | undefined; onError?: ((event: { id: string; error: Error }) => void | Promise) | undefined; - }): Promise> { - const { id, type, execute, input, onStart, onFinish, onError } = params; + buildFinishEventExtras?: (value: T) => Partial; + }): Promise> { + const { id, type, execute, input, onStart, onFinish, onError, buildFinishEventExtras } = params; - const stepInfo: StepInfo = { id, index: indexRef.current++, type, agentChain }; + const stepIndex = indexRef.current++; const startedAt = Date.now(); const childTrace: TraceEntry[] = []; @@ -152,7 +159,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR ); // Build synthetic tool-call message and push to context - const toolCallId = buildToolCallId(id, stepInfo.index); + const toolCallId = buildToolCallId(id, stepIndex); ctx.messages.push(createToolCallMessage(toolCallId, id, input)); // Write tool-call event to stream if writer is available @@ -171,9 +178,11 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR /* V8 ignore stop */ } + const startEvent: StepStartEvent = { stepId: id, stepOperation: type, agentChain }; + const onStartHook = buildHookCallback(onStart, (fn) => fn({ id })); const parentOnStepStartHook = buildParentHookCallback(parentHooks, "onStepStart", (fn) => - fn({ step: stepInfo }), + fn(startEvent), ); await fireHooks(ctx.log, onStartHook, parentOnStepStartHook); @@ -220,13 +229,32 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR const onFinishHook = buildHookCallback(onFinish, (fn) => fn({ id, result: value as T, duration }), ); + + const extras = match(buildFinishEventExtras) + .with(P.not(P.nullish), (fn) => fn(value)) + .otherwise(() => ({})); + const finishEvent: StepFinishEvent = { + stepId: id, + stepOperation: type, + output: value, + duration, + agentChain, + ...extras, + }; const parentOnStepFinishHook = buildParentHookCallback(parentHooks, "onStepFinish", (fn) => - fn({ step: stepInfo, result: value, duration, agentChain }), + fn(finishEvent), ); await fireHooks(ctx.log, onFinishHook, parentOnStepFinishHook); - return { ok: true, value, step: stepInfo, duration } as StepResult; + return { + ok: true, + output: value, + stepId: id, + stepOperation: type, + duration, + agentChain, + } as FlowStepResult; } catch (caughtError) { const error = toError(caughtError); const finishedAt = Date.now(); @@ -273,22 +301,37 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR } const onErrorHook = buildHookCallback(onError, (fn) => fn({ id, error })); + const errorFinishEvent: StepFinishEvent = { + stepId: id, + stepOperation: type, + output: undefined, + duration, + agentChain, + }; const parentOnStepFinishHook = buildParentHookCallback(parentHooks, "onStepFinish", (fn) => - fn({ step: stepInfo, result: undefined, duration, agentChain }), + fn(errorFinishEvent), ); await fireHooks(ctx.log, onErrorHook, parentOnStepFinishHook); - return { ok: false, error: stepError, step: stepInfo, duration } as StepResult; + return { + ok: false, + error: stepError, + stepId: id, + stepOperation: type, + duration, + agentChain, + } as FlowStepResult; } } - async function agent( - config: AgentStepConfig, - ): Promise> { + async function agent(config: AgentStepConfig): Promise { const onFinishHandler = buildOnFinishHandler(config.onFinish); - return executeStep({ + // Capture the last AI SDK step result from the sub-agent's tool loop + const lastAIStep: { current: StepFinishEvent | undefined } = { current: undefined }; + + const result = await executeStep({ id: config.id, type: "agent", input: config.input, @@ -296,6 +339,18 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR // Forward fixed-type step hooks and agent chain to sub-agent. // Merge parent + child hooks so neither side is clobbered. const mergedHooks = mergeStepHooks(parentHooks, config.config); + + // Inject a capture hook to store the last AI SDK step event + const originalOnStepFinish = mergedHooks["onStepFinish"] as + | ((event: StepFinishEvent) => void | Promise) + | undefined; + mergedHooks["onStepFinish"] = async (event: StepFinishEvent) => { + lastAIStep.current = event; + if (isNotNil(originalOnStepFinish)) { + await originalOnStepFinish(event); + } + }; + const agentParams = { ...config.config, input: config.input, @@ -337,13 +392,13 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR }; } - const result = await config.agent.generate(agentParams); - if (!result.ok) { - throw result.error.cause ?? new Error(result.error.message); + const generateResult = await config.agent.generate(agentParams); + if (!generateResult.ok) { + throw generateResult.error.cause ?? new Error(generateResult.error.message); } // Runnable.generate() types only { output }, but Agent.generate() // Returns full GenerateResult at runtime including messages, usage, finishReason. - const full = result as unknown as GenerateResult & { + const full = generateResult as unknown as GenerateResult & { ok: true; }; return { @@ -356,10 +411,41 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR onStart: config.onStart, onFinish: onFinishHandler, onError: config.onError, + buildFinishEventExtras: () => { + // Spread AI SDK fields from the last agent tool-loop step + if (isNotNil(lastAIStep.current)) { + const { stepId: _sid, stepOperation: _sop, ...aiFields } = lastAIStep.current; + return aiFields; + } + return {}; + }, }); + + // Re-shape the FlowStepResult into FlowAgentStepResult + if (result.ok) { + return { + ok: true, + output: result.output.output, + messages: result.output.messages, + usage: result.output.usage, + finishReason: result.output.finishReason, + stepId: result.stepId, + stepOperation: "agent", + duration: result.duration, + agentChain: result.agentChain, + }; + } + return { + ok: false, + error: result.error, + stepId: result.stepId, + stepOperation: "agent", + duration: result.duration, + agentChain: result.agentChain, + }; } - async function map(config: MapConfig): Promise> { + async function map(config: MapConfig): Promise> { const onFinishHandler = buildOnFinishHandler(config.onFinish); return executeStep({ @@ -381,7 +467,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR }); } - async function each(config: EachConfig): Promise> { + async function each(config: EachConfig): Promise> { const onFinishHandler = buildOnFinishHandlerVoid(config.onFinish); return executeStep({ @@ -403,7 +489,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR }); } - async function reduce(config: ReduceConfig): Promise> { + async function reduce(config: ReduceConfig): Promise> { const onFinishHandler = buildOnFinishHandler(config.onFinish); return executeStep({ @@ -425,7 +511,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR }); } - async function whileStep(config: WhileConfig): Promise> { + async function whileStep(config: WhileConfig): Promise> { const onFinishHandler = buildOnFinishHandler(config.onFinish); return executeStep({ @@ -443,7 +529,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR }); } - async function all(config: AllConfig): Promise> { + async function all(config: AllConfig): Promise> { const onFinishHandler = buildOnFinishHandler(config.onFinish); return executeStep({ @@ -469,7 +555,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR }); } - async function race(config: RaceConfig): Promise> { + async function race(config: RaceConfig): Promise> { const onFinishHandler = buildOnFinishHandlerRace(config.onFinish); return executeStep({ @@ -626,7 +712,7 @@ function mergeStepHooks( parentHooks: StepBuilderOptions["parentHooks"], childConfig: Record | undefined, ): Record { - type StepStartHook = (event: { step: StepInfo }) => void | Promise; + type StepStartHook = (event: StepStartEvent) => void | Promise; type StepFinishHook = (event: StepFinishEvent) => void | Promise; let parentStart: StepStartHook | undefined; @@ -646,7 +732,7 @@ function mergeStepHooks( const result: Record = {}; if (isNotNil(parentStart) && isNotNil(childStart)) { - result["onStepStart"] = async (event: { step: StepInfo }) => { + result["onStepStart"] = async (event: StepStartEvent) => { await childStart(event); await parentStart(event); }; diff --git a/packages/agents/src/core/agents/flow/steps/map.test.ts b/packages/agents/src/core/agents/flow/steps/map.test.ts index 1ce0ed7..b44396e 100644 --- a/packages/agents/src/core/agents/flow/steps/map.test.ts +++ b/packages/agents/src/core/agents/flow/steps/map.test.ts @@ -18,8 +18,8 @@ describe("map()", () => { if (!result.ok) { return; } - expect(result.value).toEqual([2, 4, 6]); - expect(result.step.type).toBe("map"); + expect(result.output).toEqual([2, 4, 6]); + expect(result.stepOperation).toBe("map"); }); it("respects concurrency limit", async () => { @@ -65,7 +65,7 @@ describe("map()", () => { if (!result.ok) { return; } - expect(result.value).toEqual([30, 10, 20]); + expect(result.output).toEqual([30, 10, 20]); }); it("handles empty input array", async () => { @@ -82,7 +82,7 @@ describe("map()", () => { if (!result.ok) { return; } - expect(result.value).toEqual([]); + expect(result.output).toEqual([]); }); it("handles single-item input", async () => { @@ -99,7 +99,7 @@ describe("map()", () => { if (!result.ok) { return; } - expect(result.value).toEqual(["ONLY"]); + expect(result.output).toEqual(["ONLY"]); }); it("passes index to execute callback", async () => { @@ -245,7 +245,7 @@ describe("map()", () => { if (!inner.ok) { throw new Error("inner step failed"); } - return inner.value; + return inner.output; }, }); diff --git a/packages/agents/src/core/agents/flow/steps/race.test.ts b/packages/agents/src/core/agents/flow/steps/race.test.ts index 3a76e9a..814af1e 100644 --- a/packages/agents/src/core/agents/flow/steps/race.test.ts +++ b/packages/agents/src/core/agents/flow/steps/race.test.ts @@ -25,8 +25,8 @@ describe("race()", () => { if (!result.ok) { return; } - expect(result.value).toBe("fast"); - expect(result.step.type).toBe("race"); + expect(result.output).toBe("fast"); + expect(result.stepOperation).toBe("race"); }); it("cancels losing entries via abort signal", async () => { @@ -53,7 +53,7 @@ describe("race()", () => { if (!result.ok) { return; } - expect(result.value).toBe("winner"); + expect(result.output).toBe("winner"); if (signals.loser === undefined) { throw new Error("Expected loser signal"); } @@ -144,7 +144,7 @@ describe("race()", () => { if (!result.ok) { return; } - expect(result.value).toBe("only"); + expect(result.output).toBe("only"); }); it("fires onStart and onFinish hooks", async () => { @@ -239,7 +239,7 @@ describe("race()", () => { if (!inner.ok) { throw new Error("inner failed"); } - return inner.value; + return inner.output; }, ], }); @@ -312,6 +312,6 @@ describe("race()", () => { if (!result.ok) { return; } - expect(result.value).toBe("instant"); + expect(result.output).toBe("instant"); }); }); diff --git a/packages/agents/src/core/agents/flow/steps/reduce.test.ts b/packages/agents/src/core/agents/flow/steps/reduce.test.ts index 0257f6a..5e218bc 100644 --- a/packages/agents/src/core/agents/flow/steps/reduce.test.ts +++ b/packages/agents/src/core/agents/flow/steps/reduce.test.ts @@ -19,8 +19,8 @@ describe("reduce()", () => { if (!result.ok) { return; } - expect(result.value).toBe(10); - expect(result.step.type).toBe("reduce"); + expect(result.output).toBe(10); + expect(result.stepOperation).toBe("reduce"); }); it("uses initial value when input is empty", async () => { @@ -38,7 +38,7 @@ describe("reduce()", () => { if (!result.ok) { return; } - expect(result.value).toBe(42); + expect(result.output).toBe(42); }); it("handles single-item input", async () => { @@ -56,7 +56,7 @@ describe("reduce()", () => { if (!result.ok) { return; } - expect(result.value).toBe(15); + expect(result.output).toBe(15); }); it("processes items in order", async () => { @@ -78,7 +78,7 @@ describe("reduce()", () => { if (!result.ok) { return; } - expect(result.value).toBe("abc"); + expect(result.output).toBe("abc"); expect(order).toEqual(["a", "b", "c"]); }); @@ -187,7 +187,7 @@ describe("reduce()", () => { if (!result.ok) { return; } - expect(result.value).toEqual({ a: 1, b: 2 }); + expect(result.output).toEqual({ a: 1, b: 2 }); }); it("fires onStart and onFinish hooks", async () => { @@ -297,7 +297,7 @@ describe("reduce()", () => { if (!inner.ok) { throw new Error("inner failed"); } - return inner.value; + return inner.output; }, }); diff --git a/packages/agents/src/core/agents/flow/steps/result.ts b/packages/agents/src/core/agents/flow/steps/result.ts index a746590..344d7d7 100644 --- a/packages/agents/src/core/agents/flow/steps/result.ts +++ b/packages/agents/src/core/agents/flow/steps/result.ts @@ -1,4 +1,6 @@ -import type { StepInfo } from "@/core/types.js"; +import type { GenerateResult } from "@/core/agents/types.js"; +import type { AgentChainEntry } from "@/core/types.js"; +import type { OperationType } from "@/lib/trace.js"; import type { ResultError } from "@/utils/result.js"; /** @@ -15,13 +17,55 @@ export interface StepError extends ResultError { } /** - * Discriminated union for step operation results. + * Discriminated union for flow step operation results. * - * The success value is available via `.value`. Callers pattern-match + * The success value is available via `.output`. Callers pattern-match * on `ok` instead of using try/catch. * + * All step metadata fields are flat — no nested `step` object. + * * @typeParam T - The success payload type. */ -export type StepResult = - | { ok: true; value: T; step: StepInfo; duration: number } - | { ok: false; error: StepError; step: StepInfo; duration: number }; +export type FlowStepResult = + | { + readonly ok: true; + readonly output: T; + readonly stepId: string; + readonly stepOperation: OperationType; + readonly duration: number; + readonly agentChain?: readonly AgentChainEntry[] | undefined; + } + | { + readonly ok: false; + readonly error: StepError; + readonly stepId: string; + readonly stepOperation: OperationType; + readonly duration: number; + readonly agentChain?: readonly AgentChainEntry[] | undefined; + }; + +/** + * Flat result type for `$.agent()` flow steps. + * + * On success, the `GenerateResult` fields (`output`, `messages`, `usage`, + * `finishReason`) are spread directly onto the result — no double-wrapping. + * `result.output` is the agent's output directly. + * + * @typeParam TOutput - The agent's output type (default: `string`). + */ +export type FlowAgentStepResult = + | (GenerateResult & { + readonly ok: true; + readonly stepId: string; + readonly stepOperation: "agent"; + readonly duration: number; + readonly agentChain?: readonly AgentChainEntry[] | undefined; + }) + | { + readonly ok: false; + readonly error: StepError; + readonly stepId: string; + readonly stepOperation: "agent"; + readonly duration: number; + readonly agentChain?: readonly AgentChainEntry[] | undefined; + }; diff --git a/packages/agents/src/core/agents/flow/steps/step.test.ts b/packages/agents/src/core/agents/flow/steps/step.test.ts index ddc4050..70ddc6b 100644 --- a/packages/agents/src/core/agents/flow/steps/step.test.ts +++ b/packages/agents/src/core/agents/flow/steps/step.test.ts @@ -17,10 +17,9 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toEqual({ greeting: "hello" }); - expect(result.step.id).toBe("greet"); - expect(result.step.type).toBe("step"); - expect(result.step.index).toBe(0); + expect(result.output).toEqual({ greeting: "hello" }); + expect(result.stepId).toBe("greet"); + expect(result.stepOperation).toBe("step"); expect(result.duration).toBeGreaterThanOrEqual(0); }); @@ -43,7 +42,7 @@ describe("step()", () => { expect(result.error.message).toBe("boom"); expect(result.error.stepId).toBe("fail"); expect(result.error.cause).toBeInstanceOf(Error); - expect(result.step.id).toBe("fail"); + expect(result.stepId).toBe("fail"); expect(result.duration).toBeGreaterThanOrEqual(0); }); @@ -184,8 +183,8 @@ describe("step()", () => { expect(parentFinish).toHaveBeenCalledTimes(1); expect(parentFinish).toHaveBeenCalledWith( expect.objectContaining({ - step: expect.objectContaining({ id: "parent-finish-on-error" }), - result: undefined, + stepId: "parent-finish-on-error", + output: undefined, }), ); }); @@ -206,7 +205,7 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toEqual({ value: 42 }); + expect(result.output).toEqual({ value: 42 }); expect(ctx.log.warn).toHaveBeenCalledWith("hook error", { error: "hook boom" }); }); @@ -252,19 +251,6 @@ describe("step()", () => { expect(traceEntry.error.message).toBe("trace-boom"); }); - it("increments step index across calls", async () => { - const ctx = createMockCtx(); - const $ = createStepBuilder({ ctx }); - - const r1 = await $.step({ id: "a", execute: async () => ({}) }); - const r2 = await $.step({ id: "b", execute: async () => ({}) }); - const r3 = await $.step({ id: "c", execute: async () => ({}) }); - - expect(r1.step.index).toBe(0); - expect(r2.step.index).toBe(1); - expect(r3.step.index).toBe(2); - }); - it("provides child $ for nested operations", async () => { const ctx = createMockCtx(); const $$ = createStepBuilder({ ctx }); @@ -306,7 +292,7 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toBe("hello"); + expect(result.output).toBe("hello"); }); it("handles primitive number return", async () => { @@ -322,7 +308,7 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toBe(42); + expect(result.output).toBe(42); }); it("onFinish receives the result and duration", async () => { @@ -381,7 +367,7 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toBeNull(); + expect(result.output).toBeNull(); }); it("handles undefined return value", async () => { @@ -397,6 +383,6 @@ describe("step()", () => { if (!result.ok) { return; } - expect(result.value).toBeUndefined(); + expect(result.output).toBeUndefined(); }); }); diff --git a/packages/agents/src/core/agents/flow/steps/while.test.ts b/packages/agents/src/core/agents/flow/steps/while.test.ts index 81a65f2..ab79d9a 100644 --- a/packages/agents/src/core/agents/flow/steps/while.test.ts +++ b/packages/agents/src/core/agents/flow/steps/while.test.ts @@ -18,8 +18,8 @@ describe("while()", () => { if (!result.ok) { return; } - expect(result.value).toBe(2); - expect(result.step.type).toBe("while"); + expect(result.output).toBe(2); + expect(result.stepOperation).toBe("while"); }); it("returns undefined when condition is initially false", async () => { @@ -36,7 +36,7 @@ describe("while()", () => { if (!result.ok) { return; } - expect(result.value).toBeUndefined(); + expect(result.output).toBeUndefined(); }); it("does not call execute when condition is initially false", async () => { @@ -71,7 +71,7 @@ describe("while()", () => { if (!result.ok) { return; } - expect(result.value).toBe("iter-0"); + expect(result.output).toBe("iter-0"); expect(iterations).toEqual([0]); }); diff --git a/packages/agents/src/core/agents/flow/types.ts b/packages/agents/src/core/agents/flow/types.ts index 6ca81a6..a4c7c12 100644 --- a/packages/agents/src/core/agents/flow/types.ts +++ b/packages/agents/src/core/agents/flow/types.ts @@ -10,12 +10,12 @@ import type { } from "@/core/agents/types.js"; import type { Logger } from "@/core/logger.js"; import type { Tool } from "@/core/tool.js"; -import type { StepFinishEvent, StepInfo } from "@/core/types.js"; +import type { StepFinishEvent, StepStartEvent } from "@/core/types.js"; import type { Context } from "@/lib/context.js"; import type { TraceEntry } from "@/lib/trace.js"; import type { Result } from "@/utils/result.js"; -export type { StepInfo } from "@/core/types.js"; +export type { StepStartEvent } from "@/core/types.js"; /** * Record of named agent dependencies for a flow agent. @@ -144,7 +144,7 @@ export interface FlowAgentConfigBase { * * @param event - Event containing step info. */ - onStepStart?: (event: { step: StepInfo }) => void | Promise; + onStepStart?: (event: StepStartEvent) => void | Promise; /** * Hook: fires when any tracked `$` step finishes. diff --git a/packages/agents/src/core/agents/types.ts b/packages/agents/src/core/agents/types.ts index d91c466..214abc7 100644 --- a/packages/agents/src/core/agents/types.ts +++ b/packages/agents/src/core/agents/types.ts @@ -12,10 +12,10 @@ import type { OutputParam } from "@/core/agents/base/output.js"; import type { Logger } from "@/core/logger.js"; import type { TokenUsage } from "@/core/provider/types.js"; import type { Tool } from "@/core/tool.js"; -import type { Model, StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js"; +import type { Model, StepFinishEvent, StepStartEvent, StreamPart } from "@/core/types.js"; import type { Result } from "@/utils/result.js"; -export type { StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js"; +export type { StepFinishEvent, StepStartEvent, StreamPart } from "@/core/types.js"; /** * A value that can be static or dynamically resolved from the agent's input. @@ -366,7 +366,7 @@ export interface BaseGenerateParams { * Used by flow agents to receive step-start notifications. * Agents accept but ignore this field for type compatibility. */ - onStepStart?: (event: { step: StepInfo }) => void | Promise; + onStepStart?: (event: StepStartEvent) => void | Promise; /** * Per-call hook — fires after base `onStepFinish`. diff --git a/packages/agents/src/core/types.ts b/packages/agents/src/core/types.ts index 7a41130..16a0f58 100644 --- a/packages/agents/src/core/types.ts +++ b/packages/agents/src/core/types.ts @@ -1,9 +1,17 @@ -import type { AsyncIterableStream, ModelMessage, TextStreamPart, ToolSet } from "ai"; +import type { AsyncIterableStream, ModelMessage, StepResult, TextStreamPart, ToolSet } from "ai"; import type { LanguageModel } from "@/core/provider/types.js"; import type { OperationType } from "@/lib/trace.js"; import type { Result } from "@/utils/result.js"; +/** + * The AI SDK's step result type, unparameterized (uses `ToolSet`). + * + * Re-exported so consumers can reference the base shape without + * importing `ai` directly. + */ +export type AIStepResult = StepResult; + /** * A model reference — an AI SDK `LanguageModel` instance. * @@ -67,35 +75,27 @@ export interface AgentChainEntry { } /** - * Information about a step in execution. + * Event emitted when a step starts execution. * - * Passed to step-level hooks (`onStepStart`, `onStepFinish`) - * and included in step events. Used by both flow agent orchestration - * steps and agent tool-loop steps. + * Passed to `onStepStart` hooks. Used by both flow agent orchestration + * steps and agent tool-loop steps. All fields from the former `StepInfo` + * are inlined here. */ -export interface StepInfo { +export interface StepStartEvent { /** * The step identifier. * * For flow agents, matches the `id` field on the step config. * For agents, auto-generated as `agentName:stepIndex`. */ - readonly id: string; - - /** - * Auto-incrementing index within the execution. - * - * Starts at `0` for the first step and increments for each - * subsequent tracked operation. - */ - readonly index: number; + readonly stepId: string; /** * What kind of operation produced this step. * * Discriminant for filtering or grouping step events. */ - readonly type: OperationType; + readonly stepOperation: OperationType; /** * Agent ancestry chain from root to the agent that owns this step. @@ -106,7 +106,7 @@ export interface StepInfo { * @example * ```typescript * // Step inside a sub-agent called by a flow agent: - * event.step.agentChain + * event.agentChain * // → [{ id: 'pipeline' }, { id: 'writer' }] * ``` */ @@ -116,59 +116,46 @@ export interface StepInfo { /** * Unified event emitted when a step completes. * - * Used by both agents (tool-loop steps) and flow agents (orchestration - * steps). Agent steps populate the tool-loop fields (`stepId`, `toolCalls`, - * `toolResults`, `usage`); flow steps populate the orchestration fields - * (`step`, `result`, `duration`). Fields not relevant to the step type - * are `undefined`. + * For **agent tool-loop steps**, this is a full superset of the Vercel + * AI SDK's `StepResult` — every field from the SDK is passed + * through unchanged, plus funkai-specific additions (`stepId`, + * `stepOperation`, `agentChain`). + * + * For **flow orchestration steps**, the AI SDK fields are populated + * from the last agent step (for `$.agent()` steps) or absent (for + * non-agent steps like `$.step()`, `$.map()`, etc.). Flow-specific + * fields (`output`, `duration`) are always present. + * + * Fields not relevant to the step type are `undefined`. */ -export interface StepFinishEvent { - /** - * Agent tool-loop step ID (e.g. `"myAgent:0"`). - * - * Present on agent tool-loop steps. `undefined` on flow steps. - */ - readonly stepId?: string; - +export type StepFinishEvent = Partial & { /** - * Tool calls made in this step. + * Step ID — always present. * - * Present on agent tool-loop steps. `undefined` on flow steps. + * For agent tool-loop steps: e.g. `"myAgent:0"`. + * For flow steps: matches the `id` from the step config. */ - readonly toolCalls?: readonly { toolName: string; argsTextLength: number }[]; + readonly stepId: string; /** - * Tool results returned in this step. - * - * Present on agent tool-loop steps. `undefined` on flow steps. - */ - readonly toolResults?: readonly { toolName: string; resultTextLength: number }[]; - - /** - * Token usage for this step. - * - * Present on agent tool-loop steps. `undefined` on flow steps. - */ - readonly usage?: { inputTokens: number; outputTokens: number; totalTokens: number }; - - /** - * Flow step info (id, index, type). + * What kind of operation produced this step. * - * Present on flow orchestration steps. `undefined` on agent steps. + * Discriminant for filtering or grouping step events. + * e.g. `"agent"`, `"step"`, `"map"`, `"each"`, `"reduce"`, etc. */ - readonly step?: StepInfo; + readonly stepOperation: OperationType; /** - * Flow step result value. + * Flow step output value. * - * Present on flow orchestration steps. `undefined` on agent steps. + * Present on flow orchestration steps. `undefined` on agent tool-loop steps. */ - readonly result?: unknown; + readonly output?: unknown; /** * Flow step duration in milliseconds. * - * Present on flow orchestration steps. `undefined` on agent steps. + * Present on flow orchestration steps. `undefined` on agent tool-loop steps. */ readonly duration?: number; @@ -183,7 +170,7 @@ export interface StepFinishEvent { * agent as a single entry. */ readonly agentChain?: readonly AgentChainEntry[] | undefined; -} +}; /** * A value that can be generated against — the shared contract diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index b7ba1df..e9946f1 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -8,11 +8,12 @@ export { usage, usageByAgent, usageByModel } from "@/core/provider/usage.js"; export { collectUsages } from "@/lib/trace.js"; export type { + AIStepResult, Runnable, Model, AgentChainEntry, StepFinishEvent, - StepInfo, + StepStartEvent, StreamPart, } from "@/core/types.js"; export type { @@ -60,7 +61,11 @@ export type { FlowEngineConfig, FlowFactory, } from "@/core/agents/flow/engine.js"; -export type { StepResult, StepError } from "@/core/agents/flow/steps/result.js"; +export type { + FlowStepResult, + FlowAgentStepResult, + StepError, +} from "@/core/agents/flow/steps/result.js"; export type { StepBuilder } from "@/core/agents/flow/steps/builder.js"; export type { StepConfig } from "@/core/agents/flow/steps/step.js"; export type { AgentStepConfig } from "@/core/agents/flow/steps/agent.js"; diff --git a/packages/agents/src/integration/lifecycle.test.ts b/packages/agents/src/integration/lifecycle.test.ts index 7a28387..0de90c5 100644 --- a/packages/agents/src/integration/lifecycle.test.ts +++ b/packages/agents/src/integration/lifecycle.test.ts @@ -15,7 +15,7 @@ import { z } from "zod"; import { agent } from "@/core/agents/base/agent.js"; import { flowAgent } from "@/core/agents/flow/flow-agent.js"; -import type { StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js"; +import type { StepFinishEvent, StepStartEvent, StreamPart } from "@/core/types.js"; import { createMockLogger } from "@/testing/index.js"; // --------------------------------------------------------------------------- @@ -90,11 +90,11 @@ function createLifecycleTracker() { onError: vi.fn((_event: { input: unknown; error: Error }) => { events.push({ type: "onError" }); }), - onStepStart: vi.fn((event: { step: StepInfo }) => { - events.push({ type: "onStepStart", detail: event.step.id }); + onStepStart: vi.fn((event: StepStartEvent) => { + events.push({ type: "onStepStart", detail: event.stepId }); }), onStepFinish: vi.fn((event: StepFinishEvent) => { - const id = event.step?.id ?? event.stepId ?? "unknown"; + const id = event.stepId; events.push({ type: "onStepFinish", detail: id }); }), }; @@ -327,7 +327,7 @@ describe("FlowAgent lifecycle — direct steps (integration)", () => { execute: async () => item * input.x, }); if (r.ok) { - return r.value; + return r.output; } return 0; }, @@ -410,7 +410,7 @@ describe("FlowAgent lifecycle — direct steps (integration)", () => { execute: async ({ item, accumulator }) => accumulator + item * input.x, }); if (r.ok) { - return { y: r.value }; + return { y: r.output }; } return { y: 0 }; }, @@ -468,9 +468,9 @@ describe("FlowAgent lifecycle — direct steps (integration)", () => { { type: "onStepFinish", detail: "fail-step" }, ]); - // Verify the onStepFinish event has result: undefined on error + // Verify the onStepFinish event has output: undefined on error const finishEvent = tracker.onStepFinish.mock.calls[0]?.[0] as StepFinishEvent; - expect(finishEvent.result).toBeUndefined(); + expect(finishEvent.output).toBeUndefined(); }); }); @@ -514,7 +514,7 @@ describe("FlowAgent with $.agent() (integration)", () => { }); if (r.ok) { - return { summary: String(r.value.output) }; + return { summary: String(r.output) }; } return { summary: "failed" }; }, @@ -571,7 +571,7 @@ describe("FlowAgent with $.agent() (integration)", () => { let researchInput = ""; if (research.ok) { - researchInput = String(research.value.output); + researchInput = String(research.output); } const article = await $.agent({ id: "write", @@ -580,7 +580,7 @@ describe("FlowAgent with $.agent() (integration)", () => { }); if (article.ok) { - return { summary: String(article.value.output) }; + return { summary: String(article.output) }; } return { summary: "failed" }; }, @@ -628,7 +628,7 @@ describe("FlowAgent with $.agent() (integration)", () => { input: item, }); if (r.ok) { - return String(r.value.output); + return String(r.output); } return ""; }, @@ -691,7 +691,7 @@ describe("FlowAgent agents dependency lifecycle (integration)", () => { input: input.text, }); if (r.ok) { - return { result: String(r.value.output) }; + return { result: String(r.output) }; } return { result: "failed" }; }, @@ -760,20 +760,20 @@ describe("Deep nesting lifecycle (integration)", () => { input: item, }); if (agentResult.ok) { - return String(agentResult.value.output); + return String(agentResult.output); } return ""; }, }); if (mapResult.ok) { - return mapResult.value; + return mapResult.output; } return []; }, }); if (r.ok) { - return { results: r.value }; + return { results: r.output }; } return { results: [] }; }, @@ -1088,11 +1088,11 @@ describe("FlowAgent per-call hook merging (integration)", () => { onFinish: () => { order.push("config:onFinish"); }, - onStepStart: ({ step }) => { - order.push(`config:onStepStart:${step.id}`); + onStepStart: ({ stepId }) => { + order.push(`config:onStepStart:${stepId}`); }, - onStepFinish: ({ step }) => { - order.push(`config:onStepFinish:${step?.id}`); + onStepFinish: ({ stepId }) => { + order.push(`config:onStepFinish:${stepId}`); }, }, async ({ input, $ }) => { @@ -1109,11 +1109,11 @@ describe("FlowAgent per-call hook merging (integration)", () => { onFinish: () => { order.push("call:onFinish"); }, - onStepStart: ({ step }) => { - order.push(`call:onStepStart:${step.id}`); + onStepStart: ({ stepId }) => { + order.push(`call:onStepStart:${stepId}`); }, - onStepFinish: ({ step }) => { - order.push(`call:onStepFinish:${step?.id}`); + onStepFinish: ({ stepId }) => { + order.push(`call:onStepFinish:${stepId}`); }, }); @@ -1339,7 +1339,7 @@ describe("Value forwarding (integration)", () => { config: { model: overrideModel }, }); if (r.ok) { - return { result: String(r.value.output) }; + return { result: String(r.output) }; } return { result: "failed" }; }, @@ -1686,8 +1686,8 @@ describe("Step index uniqueness (integration)", () => { const Input = z.object({ n: z.number() }); const Output = z.object({ count: z.number() }); - it("step indices are globally unique across nested operations", async () => { - const indices: number[] = []; + it("step IDs are globally unique across nested operations", async () => { + const stepIds: string[] = []; const fa = flowAgent<{ n: number }, { count: number }>( { @@ -1695,8 +1695,8 @@ describe("Step index uniqueness (integration)", () => { input: Input, output: Output, logger: createMockLogger(), - onStepStart: ({ step }) => { - indices.push(step.index); + onStepStart: ({ stepId }) => { + stepIds.push(stepId); }, }, async ({ input, $: $outer }) => { @@ -1715,12 +1715,12 @@ describe("Step index uniqueness (integration)", () => { await fa.generate({ input: { n: 4 } }); - // All indices should be unique - const uniqueIndices = new Set(indices); - expect(uniqueIndices.size).toBe(indices.length); + // All step IDs should be unique + const uniqueIds = new Set(stepIds); + expect(uniqueIds.size).toBe(stepIds.length); - // Should be sequential: 0, 1, 2, 3 - expect(indices).toEqual([0, 1, 2, 3]); + // Should match the step IDs in execution order + expect(stepIds).toEqual(["a", "b", "c", "d"]); }); }); @@ -1777,7 +1777,7 @@ describe("Agent chain propagation (integration)", () => { output: Output, logger: createMockLogger(), onStepFinish: (event) => { - const id = event.step?.id ?? event.stepId ?? "unknown"; + const id = event.stepId; stepIds.push(id); chains.push(event.agentChain); }, @@ -1789,7 +1789,7 @@ describe("Agent chain propagation (integration)", () => { input: `Write about ${input.topic}`, }); if (r.ok) { - return { summary: String(r.value.output) }; + return { summary: String(r.output) }; } return { summary: "failed" }; }, @@ -1801,9 +1801,9 @@ describe("Agent chain propagation (integration)", () => { expect(stepIds[0]).toBe("writer:0"); expect(chains[0]).toEqual([{ id: "pipeline" }, { id: "writer" }]); - // Flow-level $.agent() step carries flow chain + // Flow-level $.agent() step carries the full chain (enriched from last AI step) expect(stepIds[1]).toBe("write"); - expect(chains[1]).toEqual([{ id: "pipeline" }]); + expect(chains[1]).toEqual([{ id: "pipeline" }, { id: "writer" }]); }); it("base agent step events carry agentChain", async () => {