From 6fb2b3a61fa21c6834908e4817a2efa13e118e76 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 14 May 2026 22:44:08 +0000 Subject: [PATCH] feat(agent): add streamed reasoning summary callbacks to runAgent --- changelog.md | 8 ++++++ readme.md | 5 +++- test/test.ts | 45 ++++++++++++++++++++++++++++++++++ ts/smartagent.classes.agent.ts | 34 +++++++++++++++++++++++-- ts/smartagent.interfaces.ts | 6 +++++ 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/changelog.md b/changelog.md index 703d604..b43eefa 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,14 @@ +### Features + +- add streamed reasoning summary callbacks to runAgent (agent) + - Introduces onReasoningStart, onReasoningDelta, and onReasoningEnd callbacks in the agent options interface + - Handles reasoning-start, reasoning-delta, and reasoning-end stream chunks while accumulating reasoning text by id + - Ensures incomplete reasoning streams are finalized after the response completes + - Adds tests for reasoning summary streaming and updates the README API documentation + ## 2026-05-14 - 3.3.0 ### Features diff --git a/readme.md b/readme.md index cee0ee6..d4791c5 100644 --- a/readme.md +++ b/readme.md @@ -76,7 +76,7 @@ console.log(result.usage); // { inputTokens, outputTokens, totalTokens, cacheR - ⚡ **Parallel tool execution** — multiple tool calls in a single step are executed concurrently - 🔧 **Auto-retry with backoff** — handles 429/529/503 errors with header-aware retry delays - 🩹 **Tool call repair** — case-insensitive name matching + invalid tool sink prevents crashes -- 📊 **Token streaming** — `onToken` and `onToolCall` callbacks for real-time progress +- 📊 **Token and reasoning streaming** — `onToken`, `onReasoning*`, and `onToolCall` callbacks for real-time progress - 💥 **Context overflow handling** — detects overflow and invokes your `onContextOverflow` callback ## Core API @@ -98,6 +98,9 @@ The single entry point. Options: | `messages` | `ModelMessage[]` | `[]` | Conversation history (for multi-turn) | | `maxRetries` | `number` | `5` | Max retries on rate-limit/server errors | | `onToken` | `(delta: string) => void` | — | Streaming token callback | +| `onReasoningStart` | `(id: string) => void` | — | Called when a reasoning summary starts | +| `onReasoningDelta` | `(id: string, delta: string) => void` | — | Called for streamed reasoning summary text | +| `onReasoningEnd` | `(id: string, text: string) => void` | — | Called when a reasoning summary completes | | `onToolCall` | `(name: string) => void` | — | Called when a tool is invoked | | `onToolResult` | `(name: string, result: unknown) => void` | — | Called when a tool finishes | | `validateCompletion` | `(result) => string \| void` | — | Return a string to reject and reprompt an incomplete run | diff --git a/test/test.ts b/test/test.ts index 2fe6bdb..f3ba3d2 100644 --- a/test/test.ts +++ b/test/test.ts @@ -33,6 +33,25 @@ const createTextStreamResult = (text: string) => ({ ] as any[]), }); +const createReasoningStreamResult = (reasoning: string, text: string) => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(0, 7) }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(7) }, + { type: 'reasoning-end', id: 'reasoning-1' }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: text }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: createUsage(2, 2), + }, + ] as any[]), +}); + const createToolCallStreamResult = (toolName: string, input: unknown) => ({ stream: convertArrayToReadableStream([ { type: 'stream-start', warnings: [] }, @@ -131,6 +150,32 @@ tap.test('runAgent should add OpenAI cache defaults when sessionId is provided', expect(openaiOptions.reasoningEffort).toEqual('high'); }); +tap.test('runAgent should stream reasoning summary callbacks', async () => { + const reasoningEvents: string[] = []; + const tokenDeltas: string[] = []; + const model = new MockLanguageModelV3({ + doStream: async () => createReasoningStreamResult('thinking through it', 'done') as any, + }); + + const result = await smartagent.runAgent({ + model, + prompt: 'hello', + onToken: (delta) => tokenDeltas.push(delta), + onReasoningStart: (id) => reasoningEvents.push('start:' + id), + onReasoningDelta: (id, delta) => reasoningEvents.push('delta:' + id + ':' + delta), + onReasoningEnd: (id, text) => reasoningEvents.push('end:' + id + ':' + text), + }); + + expect(result.text).toEqual('done'); + expect(tokenDeltas.join('')).toEqual('done'); + expect(reasoningEvents).toEqual([ + 'start:reasoning-1', + 'delta:reasoning-1:thinkin', + 'delta:reasoning-1:g through it', + 'end:reasoning-1:thinking through it', + ]); +}); + tap.test('runAgent should mark Anthropic prompt cache breakpoints by default', async () => { const model = new MockLanguageModelV3({ provider: 'anthropic', diff --git a/ts/smartagent.classes.agent.ts b/ts/smartagent.classes.agent.ts index a2eb8cf..ce14968 100644 --- a/ts/smartagent.classes.agent.ts +++ b/ts/smartagent.classes.agent.ts @@ -156,6 +156,7 @@ export async function runAgent(options: IAgentRunOptions): Promise(); + const reasoningTextById = new Map(); const tools = options.tools ?? {}; const cache = options.cache ?? 'auto'; @@ -227,8 +228,33 @@ export async function runAgent(options: IAgentRunOptions): Promise { - if (chunk.type === 'text-delta' && options.onToken) { - options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? ''); + const chunkType = String((chunk as any).type || ''); + if (chunkType === 'text-delta' && options.onToken) { + options.onToken((chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? ''); + return; + } + if (chunkType === 'reasoning-start') { + const id = (chunk as any).id || 'reasoning'; + reasoningTextById.set(id, ''); + options.onReasoningStart?.(id, (chunk as any).providerMetadata); + return; + } + if (chunkType === 'reasoning-delta') { + const id = (chunk as any).id || 'reasoning'; + const delta = (chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? ''; + if (!reasoningTextById.has(id)) { + reasoningTextById.set(id, ''); + options.onReasoningStart?.(id, (chunk as any).providerMetadata); + } + reasoningTextById.set(id, (reasoningTextById.get(id) ?? '') + delta); + options.onReasoningDelta?.(id, delta, (chunk as any).providerMetadata); + return; + } + if (chunkType === 'reasoning-end') { + const id = (chunk as any).id || 'reasoning'; + const text = reasoningTextById.get(id) ?? ''; + reasoningTextById.delete(id); + options.onReasoningEnd?.(id, text, (chunk as any).providerMetadata); } }, @@ -286,6 +312,10 @@ export async function runAgent(options: IAgentRunOptions): Promise void; + /** Called when the model starts a streamed reasoning summary */ + onReasoningStart?: (id: string, providerMetadata?: unknown) => void; + /** Called for each streamed reasoning summary delta */ + onReasoningDelta?: (id: string, delta: string, providerMetadata?: unknown) => void; + /** Called when a streamed reasoning summary completes */ + onReasoningEnd?: (id: string, text: string, providerMetadata?: unknown) => void; /** Called when a tool call starts */ onToolCall?: (toolName: string, input: unknown) => void; /** Called when a tool call completes */