@tanstack/ai 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@ import {
25
25
  import { normalizeToolResult } from '../../utilities/tool-result'
26
26
  import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
27
27
  import { LazyToolManager } from './tools/lazy-tool-manager'
28
+ import { assertUniqueToolNames } from './tools/unique-tool-names'
28
29
  import {
29
30
  MiddlewareAbortError,
30
31
  ToolCallManager,
@@ -42,7 +43,11 @@ import {
42
43
  } from './tools/approval-schema'
43
44
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
44
45
  import { isCancelRequestedReason } from './cancel'
45
- import { convertMessagesToModelMessages, generateMessageId } from './messages'
46
+ import {
47
+ convertMessagesToModelMessages,
48
+ generateMessageId,
49
+ modelMessageToUIMessage,
50
+ } from './messages'
46
51
  import { MiddlewareRunner } from './middleware/compose'
47
52
  import { getRunDetached } from './middleware/run-store'
48
53
  import { publishRunDetachedSignal } from '../../delivery-detach'
@@ -738,9 +743,11 @@ class TextEngine<
738
743
  []
739
744
  private currentThinkingContent = ''
740
745
  private currentThinkingSignature = ''
746
+ private hasSeenReasoningEvents = false
741
747
  private eventOptions?: Record<string, unknown> | undefined
742
748
  private eventToolNames?: Array<string>
743
749
  private finishedEvent: RunFinishedEvent | null = null
750
+ private readonly streamedToolErrorResults = new Map<string, ToolResult>()
744
751
  private deferredToolCallRunFinishedChunks: Array<StreamChunk> = []
745
752
  private earlyTermination = false
746
753
  private toolPhase: ToolPhaseResult = 'continue'
@@ -840,6 +847,7 @@ class TextEngine<
840
847
  this.messages = convertMessagesToModelMessages(config.params.messages)
841
848
 
842
849
  // Initialize lazy tool manager after messages are converted (needs message history for scanning)
850
+ assertUniqueToolNames(config.params.tools || [])
843
851
  this.lazyToolManager = new LazyToolManager(
844
852
  config.params.tools || [],
845
853
  this.messages,
@@ -1151,6 +1159,7 @@ class TextEngine<
1151
1159
  duration: Date.now() - this.streamStartTime,
1152
1160
  })
1153
1161
  } else {
1162
+ this.addTerminalReasoningMessage()
1154
1163
  this.terminalHookCalled = true
1155
1164
  await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
1156
1165
  finishReason: this.lastFinishReason,
@@ -1281,7 +1290,9 @@ class TextEngine<
1281
1290
  this.accumulatedThinking = []
1282
1291
  this.currentThinkingContent = ''
1283
1292
  this.currentThinkingSignature = ''
1293
+ this.hasSeenReasoningEvents = false
1284
1294
  this.finishedEvent = null
1295
+ this.streamedToolErrorResults.clear()
1285
1296
 
1286
1297
  // Update mutable context fields
1287
1298
  this.middlewareCtx.currentMessageId = this.currentMessageId
@@ -1531,16 +1542,19 @@ class TextEngine<
1531
1542
  this.handleStepFinishedEvent(chunk)
1532
1543
  break
1533
1544
 
1545
+ case 'REASONING_MESSAGE_CONTENT':
1546
+ this.handleReasoningMessageContentEvent(chunk)
1547
+ break
1548
+
1534
1549
  case 'TOOL_CALL_RESULT':
1535
1550
  // Tool result is already added to messages in buildToolResultChunks
1536
1551
  break
1537
1552
 
1538
1553
  case 'REASONING_START':
1539
1554
  case 'REASONING_MESSAGE_START':
1540
- case 'REASONING_MESSAGE_CONTENT':
1541
1555
  case 'REASONING_MESSAGE_END':
1542
1556
  case 'REASONING_END':
1543
- // Reasoning events are handled by StreamProcessor
1557
+ // No special handling needed
1544
1558
  break
1545
1559
 
1546
1560
  default:
@@ -1588,6 +1602,20 @@ class TextEngine<
1588
1602
 
1589
1603
  private handleToolCallEndEvent(chunk: ToolCallEndEvent): void {
1590
1604
  this.toolCallManager.completeToolCall(chunk)
1605
+ if (chunk.state !== 'output-error' || chunk.result === undefined) return
1606
+
1607
+ const toolCall = this.toolCallManager
1608
+ .getToolCalls()
1609
+ .find((candidate) => candidate.id === chunk.toolCallId)
1610
+ if (!toolCall) return
1611
+
1612
+ this.streamedToolErrorResults.set(chunk.toolCallId, {
1613
+ toolCallId: chunk.toolCallId,
1614
+ toolName: toolCall.function.name,
1615
+ result: chunk.result,
1616
+ ...(chunk.input !== undefined && { input: chunk.input }),
1617
+ state: 'output-error',
1618
+ })
1591
1619
  }
1592
1620
 
1593
1621
  private handleRunFinishedEvent(chunk: RunFinishedEvent): void {
@@ -1635,14 +1663,29 @@ class TextEngine<
1635
1663
  private handleStepFinishedEvent(
1636
1664
  chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
1637
1665
  ): void {
1638
- if (chunk.delta) {
1639
- this.currentThinkingContent += chunk.delta
1666
+ if (!this.hasSeenReasoningEvents) {
1667
+ if (chunk.delta) {
1668
+ this.currentThinkingContent += chunk.delta
1669
+ } else if (chunk.content) {
1670
+ if (chunk.content.startsWith(this.currentThinkingContent)) {
1671
+ this.currentThinkingContent = chunk.content
1672
+ } else if (!this.currentThinkingContent.startsWith(chunk.content)) {
1673
+ this.currentThinkingContent += chunk.content
1674
+ }
1675
+ }
1640
1676
  }
1641
1677
  if (chunk.signature) {
1642
1678
  this.currentThinkingSignature = chunk.signature
1643
1679
  }
1644
1680
  }
1645
1681
 
1682
+ private handleReasoningMessageContentEvent(
1683
+ chunk: Extract<StreamChunk, { type: 'REASONING_MESSAGE_CONTENT' }>,
1684
+ ): void {
1685
+ this.hasSeenReasoningEvents = true
1686
+ this.currentThinkingContent += chunk.delta
1687
+ }
1688
+
1646
1689
  /**
1647
1690
  * Tools available for execution this turn. The discovery tool is dropped
1648
1691
  * from the advertised set (`this.tools`) once every lazy tool is discovered,
@@ -1843,6 +1886,9 @@ class TextEngine<
1843
1886
  // Handle undiscovered lazy tool calls with self-correcting error messages
1844
1887
  const undiscoveredLazyResults: Array<ToolResult> = []
1845
1888
  const executableToolCalls = toolCalls.filter((tc) => {
1889
+ if (this.streamedToolErrorResults.has(tc.id)) {
1890
+ return false
1891
+ }
1846
1892
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
1847
1893
  undiscoveredLazyResults.push({
1848
1894
  toolCallId: tc.id,
@@ -1859,14 +1905,17 @@ class TextEngine<
1859
1905
  return true
1860
1906
  })
1861
1907
 
1862
- // Non-executed outcomes (undiscovered lazy). Per-turn skips come from
1863
- // middleware and appear in execution results.
1864
- const deferredErrorResults = [...undiscoveredLazyResults]
1908
+ // Non-executed outcomes. Per-turn skips come from middleware and appear in
1909
+ // execution results.
1910
+ const deferredErrorResults = [
1911
+ ...this.streamedToolErrorResults.values(),
1912
+ ...undiscoveredLazyResults,
1913
+ ]
1865
1914
 
1866
1915
  if (executableToolCalls.length === 0) {
1867
1916
  yield* this.flushDeferredToolCallRunFinishedChunks()
1868
- // All tool calls were undiscovered lazy tools — errors emitted, continue
1869
- // loop (strategy / onShouldContinue may stop).
1917
+ // All tool calls already have error results — emit them, then continue
1918
+ // the loop (strategy / onShouldContinue may stop).
1870
1919
  if (deferredErrorResults.length > 0) {
1871
1920
  for (const chunk of this.buildToolResultChunks(
1872
1921
  deferredErrorResults,
@@ -2043,6 +2092,30 @@ class TextEngine<
2043
2092
  this.middlewareCtx.messages = this.messages
2044
2093
  }
2045
2094
 
2095
+ private addTerminalReasoningMessage(): void {
2096
+ this.finalizeCurrentThinkingStep()
2097
+ if (this.accumulatedThinking.length === 0) return
2098
+
2099
+ const messages = this.middlewareCtx.messages
2100
+ const alreadyPresent = messages.some(
2101
+ (message) =>
2102
+ message.role === 'assistant' && message.id === this.currentMessageId,
2103
+ )
2104
+ if (alreadyPresent) return
2105
+
2106
+ this.messages = [
2107
+ ...messages,
2108
+ {
2109
+ role: 'assistant',
2110
+ content: this.accumulatedContent || null,
2111
+ id: this.currentMessageId ?? undefined,
2112
+ createdAt: this.currentMessageCreatedAt ?? undefined,
2113
+ thinking: this.accumulatedThinking,
2114
+ },
2115
+ ]
2116
+ this.middlewareCtx.messages = this.messages
2117
+ }
2118
+
2046
2119
  /**
2047
2120
  * Extract client state (approvals and client tool results) from original messages.
2048
2121
  * This is called in the constructor BEFORE converting to ModelMessage format,
@@ -2232,12 +2305,18 @@ class TextEngine<
2232
2305
  : message.content === null
2233
2306
  ? undefined
2234
2307
  : JSON.stringify(message.content)
2308
+ const id =
2309
+ message.id ||
2310
+ `snapshot_${this.runIdOverride ?? this.requestId}_${index}`
2311
+ const parts =
2312
+ message.role === 'assistant' && message.thinking?.length
2313
+ ? modelMessageToUIMessage(message, id).parts
2314
+ : undefined
2235
2315
  return {
2236
- id:
2237
- message.id ||
2238
- `snapshot_${this.runIdOverride ?? this.requestId}_${index}`,
2316
+ id,
2239
2317
  role: message.role,
2240
2318
  ...(content !== undefined ? { content } : {}),
2319
+ ...(parts ? { parts } : {}),
2241
2320
  ...('toolCalls' in message && message.toolCalls
2242
2321
  ? { toolCalls: message.toolCalls }
2243
2322
  : {}),
@@ -3551,6 +3630,7 @@ class TextEngine<
3551
3630
  this.applyResumeToolState(config.resumeToolState)
3552
3631
  this.messages = config.messages
3553
3632
  this.systemPrompts = config.systemPrompts
3633
+ assertUniqueToolNames(config.tools)
3554
3634
  this.tools = config.tools
3555
3635
  this.params = {
3556
3636
  ...this.params,
@@ -3743,6 +3823,9 @@ export function chat<
3743
3823
  >,
3744
3824
  ): TextActivityResult<TSchema, TStream, TTools> {
3745
3825
  validateCapabilities(options.middleware ?? [], options.adapter)
3826
+ if (options.tools) {
3827
+ assertUniqueToolNames(options.tools)
3828
+ }
3746
3829
 
3747
3830
  const { outputSchema, stream } = options
3748
3831
 
@@ -0,0 +1,73 @@
1
+ import type { Tool } from '../../../types'
2
+
3
+ /**
4
+ * Thrown when `chat({ tools })` (or a provider converter) receives two tools
5
+ * with the same public `name`.
6
+ *
7
+ * The common case is a provider-native factory (`webSearchTool()`) next to an
8
+ * ordinary function that reused the reserved name (`web_search`). Providers
9
+ * reject that pair, so we fail before the request is built.
10
+ */
11
+ export class DuplicateToolNameError extends Error {
12
+ readonly toolName: string
13
+
14
+ constructor(toolName: string, message: string) {
15
+ super(message)
16
+ this.name = 'DuplicateToolNameError'
17
+ this.toolName = toolName
18
+ }
19
+ }
20
+
21
+ function isProviderNativeTool(tool: Tool): boolean {
22
+ const kind = tool.metadata?.['__kind']
23
+ return typeof kind === 'string' && kind.length > 0
24
+ }
25
+
26
+ function nativeAndCustomMessage(toolName: string) {
27
+ return [
28
+ `Cannot pass two tools named "${toolName}" in the same chat() call.`,
29
+ `One is the provider-native tool from a factory (for example webSearchTool()).`,
30
+ `The other is your own function with the same public name.`,
31
+ `Tool names in one tools array must be unique.`,
32
+ `Keep the factory for hosted search, or keep your function and give it a different name.`,
33
+ ].join(' ')
34
+ }
35
+
36
+ function duplicateNameMessage(toolName: string) {
37
+ return [
38
+ `Cannot pass two tools named "${toolName}" in the same chat() call.`,
39
+ `Tool names in one tools array must be unique.`,
40
+ ].join(' ')
41
+ }
42
+
43
+ /**
44
+ * Throws {@link DuplicateToolNameError} when two tools share a public name.
45
+ *
46
+ * The native-vs-custom message fires when one of the colliding tools carries
47
+ * adapter `metadata.__kind` (set by a provider factory) and another does not.
48
+ */
49
+ export function assertUniqueToolNames(tools: ReadonlyArray<Tool>): void {
50
+ const byName = new Map<string, Array<Tool>>()
51
+ for (const tool of tools) {
52
+ const group = byName.get(tool.name)
53
+ if (group) {
54
+ group.push(tool)
55
+ } else {
56
+ byName.set(tool.name, [tool])
57
+ }
58
+ }
59
+
60
+ for (const [name, group] of byName) {
61
+ if (group.length < 2) {
62
+ continue
63
+ }
64
+ const hasNative = group.some(isProviderNativeTool)
65
+ const hasCustom = group.some((tool) => !isProviderNativeTool(tool))
66
+ throw new DuplicateToolNameError(
67
+ name,
68
+ hasNative && hasCustom
69
+ ? nativeAndCustomMessage(name)
70
+ : duplicateNameMessage(name),
71
+ )
72
+ }
73
+ }
@@ -25,6 +25,10 @@ export {
25
25
  PendingTurnCapability,
26
26
  providePendingTurn,
27
27
  } from './activities/chat/middleware/pending-turn'
28
+ export {
29
+ assertUniqueToolNames,
30
+ DuplicateToolNameError,
31
+ } from './activities/chat/tools/unique-tool-names'
28
32
  export {
29
33
  appendOutputSchemaInstruction,
30
34
  parseJsonFromAssistantText,
package/src/index.ts CHANGED
@@ -103,6 +103,7 @@ export type {
103
103
 
104
104
  // MCP error classes (value exports — usable with instanceof)
105
105
  export { MCPDuplicateToolNameError } from './activities/chat/mcp/manager'
106
+ export { DuplicateToolNameError } from './activities/chat/tools/unique-tool-names'
106
107
 
107
108
  // Schema conversion (Standard JSON Schema compliant)
108
109
  export {
@@ -138,6 +139,22 @@ export type {
138
139
  UpsertableStreamDurability,
139
140
  } from './stream-durability'
140
141
 
142
+ // WebSocket transport utilities
143
+ export {
144
+ toWebSocketStream,
145
+ toWebSocketResponse,
146
+ resumeWebSocketStream,
147
+ resumeWebSocketResponse,
148
+ encodeWsFrame,
149
+ decodeWsFrame,
150
+ } from './stream-to-websocket'
151
+ export type {
152
+ WebSocketLike,
153
+ WsRunContext,
154
+ WebSocketStreamInit,
155
+ InboundFrame,
156
+ } from './stream-to-websocket'
157
+
141
158
  // Tool call management
142
159
  export { ToolCallManager } from './activities/chat/tools/tool-calls'
143
160
 
@@ -12,8 +12,8 @@ import type { TokenUsage } from '../types'
12
12
  * `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions
13
13
  * consumed by backends like PostHog (which otherwise re-derive cost from their
14
14
  * own price tables, losing cache discounts and gateway markup). Fields with no
15
- * semconv or de-facto convention (`costDetails`, `durationSeconds`,
16
- * `unitsBilled`) are TanStack-namespaced.
15
+ * semconv or de-facto convention (`billed`, `costDetails`, and the deprecated
16
+ * `durationSeconds`/`unitsBilled`) are TanStack-namespaced.
17
17
  *
18
18
  * Shared by `otelMiddleware` across every activity (chat and the media
19
19
  * activities) so usage lands identically whichever activity produced the span.
@@ -30,6 +30,16 @@ export function usageAttributes(
30
30
  'gen_ai.usage.input_tokens': usage.promptTokens,
31
31
  'gen_ai.usage.output_tokens': usage.completionTokens,
32
32
  }
33
+ // The self-describing billed quantity: the unit rides along as a string
34
+ // attribute so backends can label/aggregate non-token usage without
35
+ // out-of-band knowledge of the provider.
36
+ if (usage.billed !== undefined) {
37
+ const quantity = firstNumber(usage.billed.quantity)
38
+ if (quantity !== undefined) {
39
+ attrs['tanstack.ai.usage.billed_quantity'] = quantity
40
+ attrs['tanstack.ai.usage.billed_unit'] = usage.billed.unit
41
+ }
42
+ }
33
43
  const optional: Array<[key: string, value: unknown]> = [
34
44
  ['gen_ai.usage.total_tokens', usage.totalTokens],
35
45
  ['gen_ai.usage.cost', usage.cost],
@@ -78,7 +78,7 @@ function combineFailures(
78
78
  )
79
79
  }
80
80
 
81
- function runErrorChunk(
81
+ export function runErrorChunk(
82
82
  error: unknown,
83
83
  ): Extract<StreamChunk, { type: 'RUN_ERROR' }> {
84
84
  const payload = toRunErrorPayload(error)
@@ -366,7 +366,7 @@ export const RUN_ACCEPTED_EVENT = 'run.accepted'
366
366
  * The returned `getId` maps each forwarded chunk to the exact opaque offset
367
367
  * returned by the durability adapter for the SSE `id:` line.
368
368
  */
369
- function durableStreamSource<TOffset extends string>(
369
+ export function durableStreamSource<TOffset extends string>(
370
370
  stream: AsyncIterable<StreamChunk>,
371
371
  durability: StreamDurability<TOffset>,
372
372
  options: {