@tanstack/ai-client 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/esm/chat-client.d.ts +18 -2
- package/dist/esm/chat-client.js +69 -16
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/interrupt-manager.d.ts +3 -0
- package/dist/esm/interrupt-manager.js +62 -5
- package/dist/esm/interrupt-manager.js.map +1 -1
- package/dist/esm/types.d.ts +3 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/web-mcp-tools.d.ts +60 -0
- package/dist/esm/web-mcp-tools.js +92 -1
- package/dist/esm/web-mcp-tools.js.map +1 -1
- package/package.json +5 -5
- package/src/chat-client.ts +134 -38
- package/src/index.ts +8 -1
- package/src/interrupt-manager.ts +91 -6
- package/src/types.ts +3 -0
- package/src/web-mcp-tools.ts +181 -0
package/src/chat-client.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
EventType,
|
|
3
3
|
StreamProcessor,
|
|
4
|
+
cloneAndDeepFreezeJson,
|
|
4
5
|
convertSchemaToJsonSchema,
|
|
5
6
|
generateMessageId,
|
|
6
7
|
isStandardSchema,
|
|
7
8
|
mergeMetadata,
|
|
8
9
|
normalizeToUIMessage,
|
|
9
|
-
parseWithStandardSchema,
|
|
10
10
|
restoreInboundChunk,
|
|
11
11
|
tanstackMetadata,
|
|
12
|
+
validateWithStandardSchema,
|
|
12
13
|
} from '@tanstack/ai/client'
|
|
13
14
|
import {
|
|
14
15
|
ByokBlockedError,
|
|
@@ -88,6 +89,20 @@ interface InternalQueuedMessage extends QueuedMessage {
|
|
|
88
89
|
body?: Record<string, any>
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
const STREAM_PROCESSING_BUDGET_MS = 8
|
|
93
|
+
|
|
94
|
+
type SchedulerWithYield = {
|
|
95
|
+
yield?: () => Promise<void>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function yieldToHost(): Promise<void> {
|
|
99
|
+
const { scheduler } = globalThis as typeof globalThis & {
|
|
100
|
+
scheduler?: SchedulerWithYield
|
|
101
|
+
}
|
|
102
|
+
if (scheduler?.yield) return scheduler.yield()
|
|
103
|
+
return new Promise((resolve) => setTimeout(resolve, 0))
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
function assertUniqueInterruptDefinitions(
|
|
92
107
|
interrupts:
|
|
93
108
|
| ReadonlyArray<InterruptDefinition<any, any, any, any>>
|
|
@@ -330,6 +345,7 @@ const REJOIN_CONNECT_DEADLINE_MS = 2000
|
|
|
330
345
|
const REJOIN_REBUILD_TRIGGERS = new Set<string>([
|
|
331
346
|
'TEXT_MESSAGE_START',
|
|
332
347
|
'TEXT_MESSAGE_CONTENT',
|
|
348
|
+
'REASONING_MESSAGE_CONTENT',
|
|
333
349
|
'TOOL_CALL_START',
|
|
334
350
|
'MESSAGES_SNAPSHOT',
|
|
335
351
|
// Drop the hydrated card before this chunk creates it again. A subagent
|
|
@@ -337,6 +353,20 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
|
|
|
337
353
|
'SUBAGENT_STARTED',
|
|
338
354
|
])
|
|
339
355
|
|
|
356
|
+
function rebuildsAssistantMessage(chunk: StreamChunk): boolean {
|
|
357
|
+
if (chunk.type === 'REASONING_ENCRYPTED_VALUE') {
|
|
358
|
+
return (
|
|
359
|
+
chunk.subtype === 'message' &&
|
|
360
|
+
typeof chunk.encryptedValue === 'string' &&
|
|
361
|
+
chunk.encryptedValue.length > 0
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
if (chunk.type === 'STEP_FINISHED') {
|
|
365
|
+
return 'signature' in chunk && Boolean(chunk.signature)
|
|
366
|
+
}
|
|
367
|
+
return REJOIN_REBUILD_TRIGGERS.has(chunk.type)
|
|
368
|
+
}
|
|
369
|
+
|
|
340
370
|
type SubagentCard = Extract<UIMessage['parts'][number], { type: 'subagent' }>
|
|
341
371
|
|
|
342
372
|
/** Every subagent card in the messages, nested cards included. */
|
|
@@ -506,6 +536,8 @@ export class ChatClient<
|
|
|
506
536
|
private readonly activeRunIds = new Set<string>()
|
|
507
537
|
/** Latched by `dispose()`; stops any late async callback starting new work. */
|
|
508
538
|
private disposed = false
|
|
539
|
+
/** The error a failed mount hydration set, cleared by the next successful one. */
|
|
540
|
+
private hydrationError: Error | undefined
|
|
509
541
|
/** Whether a view is currently watching. See `attach` / `detach`. */
|
|
510
542
|
private tailing = false
|
|
511
543
|
/** Constructor inputs `attach()` needs on every re-attach, not just the first. */
|
|
@@ -1124,7 +1156,10 @@ export class ChatClient<
|
|
|
1124
1156
|
const generation = this.historyGeneration
|
|
1125
1157
|
try {
|
|
1126
1158
|
result = await hydrate(this.threadId, hydrateOptions)
|
|
1127
|
-
} catch {
|
|
1159
|
+
} catch (cause) {
|
|
1160
|
+
// Same staleness guard as the success path below: a failure from an
|
|
1161
|
+
// older attempt must not touch the state of a newer one.
|
|
1162
|
+
if (generation === this.historyGeneration) this.failHydration(cause)
|
|
1128
1163
|
return
|
|
1129
1164
|
}
|
|
1130
1165
|
if (generation !== this.historyGeneration) return
|
|
@@ -1140,13 +1175,25 @@ export class ChatClient<
|
|
|
1140
1175
|
if (this.disposed || !this.tailing) return
|
|
1141
1176
|
// A send may have started while the fetch was in flight — don't stomp it.
|
|
1142
1177
|
if (this.isLoading || this.abortController) return
|
|
1178
|
+
// A retry after a failed load succeeded: drop that failure, but not an
|
|
1179
|
+
// error something else set since.
|
|
1180
|
+
if (this.hydrationError && this.error === this.hydrationError) {
|
|
1181
|
+
this.setError(undefined)
|
|
1182
|
+
this.setStatus('ready')
|
|
1183
|
+
}
|
|
1184
|
+
this.hydrationError = undefined
|
|
1143
1185
|
this.applyHydrationPage(result.page)
|
|
1144
1186
|
if (result.messages.length > 0) {
|
|
1145
1187
|
const windowMessages = normalizeMessagesDates(result.messages)
|
|
1146
1188
|
this.processor.setMessages(windowMessages)
|
|
1147
1189
|
this.rememberServerMessageIds(windowMessages)
|
|
1148
1190
|
}
|
|
1149
|
-
if (
|
|
1191
|
+
if (
|
|
1192
|
+
result.interrupts &&
|
|
1193
|
+
result.interrupts.pending.length > 0 &&
|
|
1194
|
+
(!result.activeRun?.runId ||
|
|
1195
|
+
result.activeRun.runId === result.interrupts.runId)
|
|
1196
|
+
) {
|
|
1150
1197
|
// Pending interrupt = the thread is paused awaiting a human decision, so
|
|
1151
1198
|
// there is nothing to tail (no chunks stream until it resolves). Restore
|
|
1152
1199
|
// the approval/wait from the SERVER — identical to reconstructing it from
|
|
@@ -1156,7 +1203,9 @@ export class ChatClient<
|
|
|
1156
1203
|
// on the server, so a racing hydrate reports both an `activeRun` cursor
|
|
1157
1204
|
// AND the pending interrupt. Tailing that "active" run would drop the
|
|
1158
1205
|
// approval card (and hang on a stream that never comes), so the interrupt
|
|
1159
|
-
//
|
|
1206
|
+
// wins over that same run. A DIFFERENT active run is the continuation
|
|
1207
|
+
// that answered this interrupt: the server commits the answer only when
|
|
1208
|
+
// that run finishes, so join it (see the `else` branch below).
|
|
1160
1209
|
this.applyResumeSnapshot({
|
|
1161
1210
|
resumeState: {
|
|
1162
1211
|
threadId: this.threadId,
|
|
@@ -1170,6 +1219,36 @@ export class ChatClient<
|
|
|
1170
1219
|
})()
|
|
1171
1220
|
}
|
|
1172
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* Surface a mount-hydration failure (`persistence: true`) on the observable
|
|
1224
|
+
* fields, mirroring `GenerationClient.failHydration`, so "this thread failed
|
|
1225
|
+
* to load" is distinguishable from "this thread has no messages" and the app
|
|
1226
|
+
* can show an error / offer a retry. A genuine miss — the server having no
|
|
1227
|
+
* record for a fresh thread — resolves normally and never reaches here; only a
|
|
1228
|
+
* thrown transport / authorize-gate error does.
|
|
1229
|
+
*
|
|
1230
|
+
* Skipped when the view unmounted (`!tailing`) or a `sendMessage` took
|
|
1231
|
+
* ownership while the hydrate GET was in flight, so a live run's state always
|
|
1232
|
+
* wins over a stale mount-time failure — same guard as the success path above.
|
|
1233
|
+
*/
|
|
1234
|
+
private failHydration(cause: unknown): void {
|
|
1235
|
+
if (this.disposed || !this.tailing) return
|
|
1236
|
+
if (this.isLoading || this.abortController) return
|
|
1237
|
+
const error = cause instanceof Error ? cause : new Error(String(cause))
|
|
1238
|
+
// Mirror the send path: a BYOK key that is missing / locked must still
|
|
1239
|
+
// trigger the key-request flow on thread load, not just be reported.
|
|
1240
|
+
if (error instanceof ByokMissingError) {
|
|
1241
|
+
this.byok?.request(error.provider, 'missing')
|
|
1242
|
+
}
|
|
1243
|
+
if (error instanceof ByokBlockedError && error.reason === 'locked') {
|
|
1244
|
+
this.byok?.request(error.provider, 'locked')
|
|
1245
|
+
}
|
|
1246
|
+
this.hydrationError = error
|
|
1247
|
+
this.setStatus('error')
|
|
1248
|
+
this.setError(error)
|
|
1249
|
+
this.callbacksRef.current.onError(error)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1173
1252
|
mountDevtools(): void {
|
|
1174
1253
|
this.ensureThreadId()
|
|
1175
1254
|
if (this.devtoolsMounted) {
|
|
@@ -1857,13 +1936,23 @@ export class ChatClient<
|
|
|
1857
1936
|
}
|
|
1858
1937
|
|
|
1859
1938
|
/**
|
|
1860
|
-
* Consume chunks from the connection subscription.
|
|
1939
|
+
* Consume chunks from the connection subscription. Chunks are processed in
|
|
1940
|
+
* order; the loop yields to the host after each processing budget.
|
|
1861
1941
|
*/
|
|
1862
1942
|
private async consumeSubscription(signal: AbortSignal): Promise<void> {
|
|
1863
1943
|
const stream = this.connection.subscribe(signal)
|
|
1944
|
+
let chunkProcessingTime = 0
|
|
1864
1945
|
for await (const chunk of stream) {
|
|
1865
1946
|
if (signal.aborted) break
|
|
1866
|
-
|
|
1947
|
+
const startedAt = performance.now()
|
|
1948
|
+
this.processIncomingChunk(chunk)
|
|
1949
|
+
chunkProcessingTime += performance.now() - startedAt
|
|
1950
|
+
if (chunkProcessingTime < STREAM_PROCESSING_BUDGET_MS) continue
|
|
1951
|
+
chunkProcessingTime = 0
|
|
1952
|
+
// Skip the yield when the page is hidden. Browsers clamp timers there,
|
|
1953
|
+
// and that wait paces stream pull.
|
|
1954
|
+
if (typeof document !== 'undefined' && document.hidden) continue
|
|
1955
|
+
await yieldToHost()
|
|
1867
1956
|
}
|
|
1868
1957
|
}
|
|
1869
1958
|
|
|
@@ -1886,7 +1975,7 @@ export class ChatClient<
|
|
|
1886
1975
|
* give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and
|
|
1887
1976
|
* clear the dead pointer so it does not retry on the next load.
|
|
1888
1977
|
*
|
|
1889
|
-
* Replay chunks are processed WITHOUT the
|
|
1978
|
+
* Replay chunks are processed WITHOUT the time-slice yield the live path uses,
|
|
1890
1979
|
* so the buffered prefix snaps in and only the genuinely-live tail streams at
|
|
1891
1980
|
* network speed — a reload looks like the run continued, not like it re-typed.
|
|
1892
1981
|
*/
|
|
@@ -1923,11 +2012,11 @@ export class ChatClient<
|
|
|
1923
2012
|
attached = true
|
|
1924
2013
|
clearTimeout(connectTimer)
|
|
1925
2014
|
}
|
|
1926
|
-
if (!rebuilt &&
|
|
2015
|
+
if (!rebuilt && rebuildsAssistantMessage(chunk)) {
|
|
1927
2016
|
rebuilt = true
|
|
1928
2017
|
this.dropTrailingInFlightAssistant()
|
|
1929
2018
|
}
|
|
1930
|
-
|
|
2019
|
+
this.processIncomingChunk(chunk)
|
|
1931
2020
|
}
|
|
1932
2021
|
// Same contract as `streamResponse`: client tools may finish (and
|
|
1933
2022
|
// queue a resume) while `isLoading` is still true. Wait for them
|
|
@@ -1996,10 +2085,7 @@ export class ChatClient<
|
|
|
1996
2085
|
}
|
|
1997
2086
|
}
|
|
1998
2087
|
|
|
1999
|
-
private
|
|
2000
|
-
chunk: StreamChunk,
|
|
2001
|
-
options?: { defer?: boolean },
|
|
2002
|
-
): Promise<void> {
|
|
2088
|
+
private processIncomingChunk(chunk: StreamChunk): void {
|
|
2003
2089
|
chunk = restoreInboundChunk(chunk)
|
|
2004
2090
|
if (
|
|
2005
2091
|
chunk.type === 'RUN_ERROR' &&
|
|
@@ -2042,15 +2128,6 @@ export class ChatClient<
|
|
|
2042
2128
|
this.syncSubagentHandles()
|
|
2043
2129
|
this.updateRunLifecycle(chunk)
|
|
2044
2130
|
this.observeInterruptState(chunk)
|
|
2045
|
-
// Live path: yield a macrotask so the UI can paint. Skip when the page is
|
|
2046
|
-
// hidden. Browsers clamp setTimeout there, and that wait paces stream pull.
|
|
2047
|
-
// Replay passes defer: false so a backlog applies in one batch.
|
|
2048
|
-
if (
|
|
2049
|
-
options?.defer !== false &&
|
|
2050
|
-
(typeof document === 'undefined' || !document.hidden)
|
|
2051
|
-
) {
|
|
2052
|
-
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
2053
|
-
}
|
|
2054
2131
|
this.resolveJoinedRun(chunk)
|
|
2055
2132
|
}
|
|
2056
2133
|
|
|
@@ -2813,12 +2890,22 @@ export class ChatClient<
|
|
|
2813
2890
|
continuationGeneration: number,
|
|
2814
2891
|
context?: ChatClientRunEventContext,
|
|
2815
2892
|
): Promise<void> {
|
|
2816
|
-
if (
|
|
2893
|
+
if (result.state !== 'output-error') {
|
|
2817
2894
|
try {
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2895
|
+
let output =
|
|
2896
|
+
clientTool?.outputSchema && isStandardSchema(clientTool.outputSchema)
|
|
2897
|
+
? await this.validateClientToolOutput(clientTool, result.output)
|
|
2898
|
+
: result.output
|
|
2899
|
+
// Only an interrupt resume needs JSON output. The legacy continuation
|
|
2900
|
+
// keeps the raw value.
|
|
2901
|
+
if (
|
|
2902
|
+
this.interruptManager
|
|
2903
|
+
.getDescriptors()
|
|
2904
|
+
.some((interrupt) => interrupt.toolCallId === result.toolCallId)
|
|
2905
|
+
) {
|
|
2906
|
+
output = cloneAndDeepFreezeJson(output)
|
|
2821
2907
|
}
|
|
2908
|
+
result = { ...result, output }
|
|
2822
2909
|
} catch (error: any) {
|
|
2823
2910
|
result = {
|
|
2824
2911
|
...result,
|
|
@@ -2850,12 +2937,16 @@ export class ChatClient<
|
|
|
2850
2937
|
)
|
|
2851
2938
|
this.devtoolsBridge.emitSnapshot()
|
|
2852
2939
|
|
|
2853
|
-
const resolvedViaInterrupt =
|
|
2854
|
-
result.toolCallId,
|
|
2940
|
+
const resolvedViaInterrupt =
|
|
2855
2941
|
result.state === 'output-error'
|
|
2856
|
-
?
|
|
2857
|
-
|
|
2858
|
-
|
|
2942
|
+
? this.interruptManager.resolveClientToolError(
|
|
2943
|
+
result.toolCallId,
|
|
2944
|
+
result.errorText || 'Tool execution failed',
|
|
2945
|
+
)
|
|
2946
|
+
: this.interruptManager.resolveClientToolOutput(
|
|
2947
|
+
result.toolCallId,
|
|
2948
|
+
result.output,
|
|
2949
|
+
)
|
|
2859
2950
|
if (resolvedViaInterrupt) {
|
|
2860
2951
|
// Interrupt manager stages/submits the resume batch (deferred until the
|
|
2861
2952
|
// parent stream settles when still loading). Skip legacy continuation.
|
|
@@ -2875,15 +2966,20 @@ export class ChatClient<
|
|
|
2875
2966
|
await this.checkForContinuation()
|
|
2876
2967
|
}
|
|
2877
2968
|
|
|
2878
|
-
private validateClientToolOutput(
|
|
2969
|
+
private async validateClientToolOutput(
|
|
2879
2970
|
clientTool: AnyClientTool,
|
|
2880
|
-
output:
|
|
2881
|
-
):
|
|
2882
|
-
|
|
2883
|
-
|
|
2971
|
+
output: unknown,
|
|
2972
|
+
): Promise<unknown> {
|
|
2973
|
+
const validation = await validateWithStandardSchema<unknown>(
|
|
2974
|
+
clientTool.outputSchema,
|
|
2975
|
+
output,
|
|
2976
|
+
)
|
|
2977
|
+
if (!validation.success) {
|
|
2978
|
+
throw new Error(
|
|
2979
|
+
validation.issues.map((issue) => issue.message).join(', '),
|
|
2980
|
+
)
|
|
2884
2981
|
}
|
|
2885
|
-
|
|
2886
|
-
return output
|
|
2982
|
+
return validation.data
|
|
2887
2983
|
}
|
|
2888
2984
|
|
|
2889
2985
|
/**
|
package/src/index.ts
CHANGED
|
@@ -14,9 +14,16 @@ export type {
|
|
|
14
14
|
} from './interrupt-manager'
|
|
15
15
|
export { createMcpAppBridge } from './mcp-app-bridge'
|
|
16
16
|
export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge'
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
getWebMCPTools,
|
|
19
|
+
registerWebMCPTools,
|
|
20
|
+
subscribeWebMCPTools,
|
|
21
|
+
} from './web-mcp-tools'
|
|
18
22
|
export type {
|
|
23
|
+
GetWebMCPToolsOptions,
|
|
19
24
|
RegisterWebMCPToolsOptions,
|
|
25
|
+
SubscribeWebMCPToolsOptions,
|
|
26
|
+
WebMCPPageTool,
|
|
20
27
|
WebMCPToolAnnotations,
|
|
21
28
|
WebMCPToolOptions,
|
|
22
29
|
WebMCPToolOptionsByName,
|
package/src/interrupt-manager.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isStandardSchema,
|
|
12
12
|
normalizeApprovalSchema,
|
|
13
13
|
readInterruptBinding,
|
|
14
|
+
withTanstackMetadata,
|
|
14
15
|
wrapGenericInterruptContinuation,
|
|
15
16
|
} from '@tanstack/ai/client'
|
|
16
17
|
import type {
|
|
@@ -124,11 +125,13 @@ function resolutionWithContinuation(
|
|
|
124
125
|
const continuation = genericInterruptContinuationFromDescriptor(
|
|
125
126
|
item.descriptor,
|
|
126
127
|
)
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
if (continuation) {
|
|
129
|
+
return {
|
|
130
|
+
...resolution,
|
|
131
|
+
metadata: wrapGenericInterruptContinuation(continuation),
|
|
132
|
+
}
|
|
131
133
|
}
|
|
134
|
+
return resolution
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
function isRootResolvableInterrupt<
|
|
@@ -684,6 +687,25 @@ export class InterruptManager<
|
|
|
684
687
|
}
|
|
685
688
|
|
|
686
689
|
resolveClientToolOutput(toolCallId: string, output: unknown): boolean {
|
|
690
|
+
return this.resolveClientToolResult(toolCallId, {
|
|
691
|
+
state: 'output-available',
|
|
692
|
+
output,
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
resolveClientToolError(toolCallId: string, errorText: string): boolean {
|
|
697
|
+
return this.resolveClientToolResult(toolCallId, {
|
|
698
|
+
state: 'output-error',
|
|
699
|
+
errorText,
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private resolveClientToolResult(
|
|
704
|
+
toolCallId: string,
|
|
705
|
+
result:
|
|
706
|
+
| { state: 'output-available'; output: unknown }
|
|
707
|
+
| { state: 'output-error'; errorText: string },
|
|
708
|
+
): boolean {
|
|
687
709
|
const item = this.items.find(
|
|
688
710
|
(candidate) =>
|
|
689
711
|
(candidate.kind === 'client-tool-execution' &&
|
|
@@ -695,10 +717,56 @@ export class InterruptManager<
|
|
|
695
717
|
isLegacyClientToolMetadata(candidate.descriptor.metadata)),
|
|
696
718
|
)
|
|
697
719
|
if (!item) return false
|
|
698
|
-
|
|
720
|
+
if (item.kind !== 'client-tool-execution') {
|
|
721
|
+
this.resolveItem(
|
|
722
|
+
item.descriptor.id,
|
|
723
|
+
result.state === 'output-error'
|
|
724
|
+
? { error: result.errorText }
|
|
725
|
+
: result.output,
|
|
726
|
+
)
|
|
727
|
+
return true
|
|
728
|
+
}
|
|
729
|
+
if (result.state === 'output-available') {
|
|
730
|
+
this.resolveItem(item.descriptor.id, result.output)
|
|
731
|
+
return true
|
|
732
|
+
}
|
|
733
|
+
this.stageClientToolError(item, result.errorText)
|
|
699
734
|
return true
|
|
700
735
|
}
|
|
701
736
|
|
|
737
|
+
private stageClientToolError(
|
|
738
|
+
item: RuntimeInterrupt,
|
|
739
|
+
errorText: string,
|
|
740
|
+
): void {
|
|
741
|
+
this.assertItemMutable()
|
|
742
|
+
this.invalidateRetry()
|
|
743
|
+
if (!item.canResolve) {
|
|
744
|
+
item.status = 'error'
|
|
745
|
+
item.error = this.itemError(
|
|
746
|
+
item.descriptor.id,
|
|
747
|
+
'invalid-response-schema',
|
|
748
|
+
'The interrupt response schema is invalid and cannot be resolved.',
|
|
749
|
+
)
|
|
750
|
+
this.publish()
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
item.validationGeneration++
|
|
754
|
+
item.resolution = cloneAndDeepFreezeJson(
|
|
755
|
+
withTanstackMetadata(
|
|
756
|
+
resolutionWithContinuation(item, {
|
|
757
|
+
interruptId: item.descriptor.id,
|
|
758
|
+
status: 'resolved',
|
|
759
|
+
payload: { error: errorText },
|
|
760
|
+
}),
|
|
761
|
+
{ state: 'output-error' },
|
|
762
|
+
),
|
|
763
|
+
)
|
|
764
|
+
item.status = 'staged'
|
|
765
|
+
item.error = undefined
|
|
766
|
+
this.publish()
|
|
767
|
+
this.maybeSubmit()
|
|
768
|
+
}
|
|
769
|
+
|
|
702
770
|
resolveToolApprovalDecision(interruptId: string, approved: boolean): boolean {
|
|
703
771
|
const item = this.items.find(
|
|
704
772
|
(candidate) =>
|
|
@@ -1279,11 +1347,28 @@ export class InterruptManager<
|
|
|
1279
1347
|
: preserveInput(validation)
|
|
1280
1348
|
}
|
|
1281
1349
|
if (item.kind === 'client-tool-execution') {
|
|
1282
|
-
|
|
1350
|
+
const validation = validateWithSchema(
|
|
1283
1351
|
item.tool?.outputSchema,
|
|
1284
1352
|
payload,
|
|
1285
1353
|
'invalid-tool-output',
|
|
1286
1354
|
)
|
|
1355
|
+
const canonicalize = (result: ValidationResult): ValidationResult => {
|
|
1356
|
+
if (!('valid' in result)) return result
|
|
1357
|
+
try {
|
|
1358
|
+
return {
|
|
1359
|
+
valid: true,
|
|
1360
|
+
payload: cloneAndDeepFreezeJson(result.payload),
|
|
1361
|
+
}
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
return {
|
|
1364
|
+
code: 'invalid-tool-output',
|
|
1365
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return isPromiseLike(validation)
|
|
1370
|
+
? Promise.resolve(validation).then(canonicalize)
|
|
1371
|
+
: canonicalize(validation)
|
|
1287
1372
|
}
|
|
1288
1373
|
return this.validateApprovalCandidate(item, payload)
|
|
1289
1374
|
}
|
package/src/types.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
StructuredOutputPart,
|
|
25
25
|
SubagentHandleData,
|
|
26
26
|
SubagentStatus,
|
|
27
|
+
ToolResultOutcome,
|
|
27
28
|
UIResourcePart,
|
|
28
29
|
VideoPart,
|
|
29
30
|
} from '@tanstack/ai/client'
|
|
@@ -618,6 +619,8 @@ export interface ToolResultPart {
|
|
|
618
619
|
toolCallId: string
|
|
619
620
|
content: string | Array<ContentPart>
|
|
620
621
|
state: ToolResultState
|
|
622
|
+
/** Set when the user or middleware cancelled or denied the tool call; state remains `error`. */
|
|
623
|
+
outcome?: ToolResultOutcome
|
|
621
624
|
error?: string // Error message if state is "error"
|
|
622
625
|
metadata?: Record<string, unknown>
|
|
623
626
|
createdAt?: Date
|
package/src/web-mcp-tools.ts
CHANGED
|
@@ -209,3 +209,184 @@ export async function registerWebMCPTools<
|
|
|
209
209
|
throw error
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/** A tool that a page registered with WebMCP, as `getTools()` returns it. */
|
|
214
|
+
export interface WebMCPPageTool {
|
|
215
|
+
name: string
|
|
216
|
+
title?: string
|
|
217
|
+
description: string
|
|
218
|
+
/** A JSON Schema object for the tool input. */
|
|
219
|
+
inputSchema?: object
|
|
220
|
+
/** The origin of the document that registered the tool. */
|
|
221
|
+
origin: string
|
|
222
|
+
annotations?: WebMCPToolAnnotations
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface WebMCPToolReader {
|
|
226
|
+
getTools: () => Promise<Array<WebMCPPageTool>>
|
|
227
|
+
executeTool: (
|
|
228
|
+
tool: WebMCPPageTool,
|
|
229
|
+
input: unknown,
|
|
230
|
+
options: { signal?: AbortSignal },
|
|
231
|
+
) => Promise<string>
|
|
232
|
+
addEventListener: EventTarget['addEventListener']
|
|
233
|
+
removeEventListener: EventTarget['removeEventListener']
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Options for {@link getWebMCPTools}. */
|
|
237
|
+
export interface GetWebMCPToolsOptions {
|
|
238
|
+
/** Return `false` to skip a tool. */
|
|
239
|
+
filter?: (tool: WebMCPPageTool) => boolean
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Options for {@link subscribeWebMCPTools}. */
|
|
243
|
+
export interface SubscribeWebMCPToolsOptions extends GetWebMCPToolsOptions {
|
|
244
|
+
/** Stops the subscription when it aborts. */
|
|
245
|
+
signal: AbortSignal
|
|
246
|
+
/** Receives a failure from the WebMCP `getTools()` call. */
|
|
247
|
+
onError?: (error: unknown) => void
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isWebMCPToolReader(value: unknown): value is WebMCPToolReader {
|
|
251
|
+
return (
|
|
252
|
+
value !== null &&
|
|
253
|
+
typeof value === 'object' &&
|
|
254
|
+
'getTools' in value &&
|
|
255
|
+
typeof value.getTools === 'function' &&
|
|
256
|
+
'executeTool' in value &&
|
|
257
|
+
typeof value.executeTool === 'function' &&
|
|
258
|
+
'addEventListener' in value &&
|
|
259
|
+
typeof value.addEventListener === 'function' &&
|
|
260
|
+
'removeEventListener' in value &&
|
|
261
|
+
typeof value.removeEventListener === 'function'
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getWebMCPToolReader() {
|
|
266
|
+
if (
|
|
267
|
+
typeof document === 'undefined' ||
|
|
268
|
+
(typeof isSecureContext !== 'undefined' && !isSecureContext) ||
|
|
269
|
+
!('modelContext' in document) ||
|
|
270
|
+
!isWebMCPToolReader(document.modelContext)
|
|
271
|
+
) {
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
return document.modelContext
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseToolResult(result: string): unknown {
|
|
278
|
+
try {
|
|
279
|
+
return JSON.parse(result)
|
|
280
|
+
} catch {
|
|
281
|
+
return result
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function readWebMCPTools(
|
|
286
|
+
reader: WebMCPToolReader,
|
|
287
|
+
options: GetWebMCPToolsOptions | undefined,
|
|
288
|
+
): Promise<Array<AnyClientTool>> {
|
|
289
|
+
const pageTools = await reader.getTools()
|
|
290
|
+
const names = new Set<string>()
|
|
291
|
+
return pageTools
|
|
292
|
+
.filter((tool) => options?.filter?.(tool) ?? true)
|
|
293
|
+
.map((tool) => {
|
|
294
|
+
if (names.has(tool.name)) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Duplicate WebMCP tool name "${tool.name}". Use a filter or register tools with unique names.`,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
names.add(tool.name)
|
|
300
|
+
return {
|
|
301
|
+
__toolSide: 'client' as const,
|
|
302
|
+
name: tool.name,
|
|
303
|
+
description: tool.description,
|
|
304
|
+
inputSchema: tool.inputSchema ?? { type: 'object' },
|
|
305
|
+
async execute(input: unknown, context?: { abortSignal?: AbortSignal }) {
|
|
306
|
+
const result = await reader.executeTool(
|
|
307
|
+
tool,
|
|
308
|
+
input,
|
|
309
|
+
context?.abortSignal ? { signal: context.abortSignal } : {},
|
|
310
|
+
)
|
|
311
|
+
return parseToolResult(result)
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Reads the WebMCP tools on the page and returns them as client tools.
|
|
319
|
+
*
|
|
320
|
+
* Pass the result to a chat as `tools`. Each tool runs through the WebMCP
|
|
321
|
+
* `executeTool()` call. Unsupported browsers and server environments return
|
|
322
|
+
* an empty array.
|
|
323
|
+
*
|
|
324
|
+
* @param options - A filter that skips tools.
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* ```ts
|
|
328
|
+
* const tools = await getWebMCPTools({
|
|
329
|
+
* filter: (tool) => tool.origin === location.origin,
|
|
330
|
+
* })
|
|
331
|
+
* ```
|
|
332
|
+
*/
|
|
333
|
+
export async function getWebMCPTools(
|
|
334
|
+
options?: GetWebMCPToolsOptions,
|
|
335
|
+
): Promise<Array<AnyClientTool>> {
|
|
336
|
+
const reader = getWebMCPToolReader()
|
|
337
|
+
return reader ? readWebMCPTools(reader, options) : []
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Calls `listener` with the page WebMCP tools now and after each
|
|
342
|
+
* `toolchange` event, until `options.signal` aborts.
|
|
343
|
+
*
|
|
344
|
+
* Unsupported browsers and server environments call `listener` once with an
|
|
345
|
+
* empty array. When a read fails, `options.onError` gets the error and the
|
|
346
|
+
* listener keeps the last list.
|
|
347
|
+
*
|
|
348
|
+
* @param listener - Receives the current client tools.
|
|
349
|
+
* @param options - The subscription signal, a filter, and an error callback.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```ts
|
|
353
|
+
* const controller = new AbortController()
|
|
354
|
+
* subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {
|
|
355
|
+
* signal: controller.signal,
|
|
356
|
+
* })
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
export function subscribeWebMCPTools(
|
|
360
|
+
listener: (tools: Array<AnyClientTool>) => void,
|
|
361
|
+
options: SubscribeWebMCPToolsOptions,
|
|
362
|
+
): void {
|
|
363
|
+
if (options.signal.aborted) return
|
|
364
|
+
const reader = getWebMCPToolReader()
|
|
365
|
+
if (!reader) {
|
|
366
|
+
listener([])
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let latestRead = 0
|
|
371
|
+
const refresh = () => {
|
|
372
|
+
const read = ++latestRead
|
|
373
|
+
const isCurrent = () => read === latestRead && !options.signal.aborted
|
|
374
|
+
readWebMCPTools(reader, options).then(
|
|
375
|
+
(tools) => {
|
|
376
|
+
if (isCurrent()) listener(tools)
|
|
377
|
+
},
|
|
378
|
+
(error: unknown) => {
|
|
379
|
+
if (isCurrent()) options.onError?.(error)
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Remove the listener by hand: Zone.js breaks the `signal` listener option.
|
|
385
|
+
reader.addEventListener('toolchange', refresh)
|
|
386
|
+
options.signal.addEventListener(
|
|
387
|
+
'abort',
|
|
388
|
+
() => reader.removeEventListener('toolchange', refresh),
|
|
389
|
+
{ once: true },
|
|
390
|
+
)
|
|
391
|
+
refresh()
|
|
392
|
+
}
|