@tanstack/ai-devtools-core 0.5.17 → 0.5.20

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.
@@ -0,0 +1,387 @@
1
+ import type { Iteration, Message, ToolCall } from './ai-context'
2
+
3
+ /** One child agent from a chat snapshot. Nested children are listed too. */
4
+ export interface SubagentInfo {
5
+ id: string
6
+ name: string
7
+ /** Agent names from the root, for example `researcher > writer`. */
8
+ path: string
9
+ status?: string
10
+ error?: string
11
+ /**
12
+ * Zero-based index of the user turn this child belongs to (the last root
13
+ * user message before it). A nested child takes its parent's turn.
14
+ */
15
+ turn: number
16
+ messages: Array<unknown>
17
+ }
18
+
19
+ /** Child steps in the shape the iteration timeline renders. */
20
+ export interface SubagentSteps {
21
+ iterations: Array<Iteration>
22
+ messages: Array<Message>
23
+ }
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
27
+ }
28
+
29
+ function text(value: unknown): string {
30
+ return typeof value === 'string' ? value : ''
31
+ }
32
+
33
+ /**
34
+ * Every subagent in a list of snapshot messages, in the order they appear.
35
+ * A nested child comes right after its parent.
36
+ */
37
+ export function collectSubagents(
38
+ messages: Array<unknown>,
39
+ ): Array<SubagentInfo> {
40
+ const out: Array<SubagentInfo> = []
41
+ let turn = -1
42
+ const visit = (
43
+ list: Array<unknown>,
44
+ parentPath: string | undefined,
45
+ parentTurn: number | undefined,
46
+ ) => {
47
+ for (const message of list) {
48
+ if (!isRecord(message)) continue
49
+ if (parentTurn === undefined && message.role === 'user') turn += 1
50
+ if (!Array.isArray(message.parts)) continue
51
+ for (const part of message.parts) {
52
+ if (!isRecord(part) || part.type !== 'subagent') continue
53
+ const subagent = part.subagent
54
+ if (!isRecord(subagent) || typeof subagent.id !== 'string') continue
55
+ const name = text(subagent.name) || 'subagent'
56
+ const path = parentPath ? `${parentPath} > ${name}` : name
57
+ const error = isRecord(subagent.error)
58
+ ? text(subagent.error.message)
59
+ : ''
60
+ const childMessages = Array.isArray(subagent.messages)
61
+ ? subagent.messages
62
+ : []
63
+ const info: SubagentInfo = {
64
+ id: subagent.id,
65
+ name,
66
+ path,
67
+ ...(typeof subagent.status === 'string'
68
+ ? { status: subagent.status }
69
+ : {}),
70
+ ...(error ? { error } : {}),
71
+ turn: parentTurn ?? Math.max(turn, 0),
72
+ messages: childMessages,
73
+ }
74
+ out.push(info)
75
+ visit(childMessages, path, info.turn)
76
+ }
77
+ }
78
+ }
79
+ visit(messages, undefined, undefined)
80
+ return out
81
+ }
82
+
83
+ /**
84
+ * The subagent a server run belongs to. A child run id is
85
+ * `<parentRunId>:<subagentRunId>`, and a card id is the `subagentRunId`.
86
+ */
87
+ export function subagentIdForRunId(
88
+ runId: string | undefined,
89
+ ids: ReadonlySet<string>,
90
+ ): string | undefined {
91
+ if (!runId) return undefined
92
+ if (ids.has(runId)) return runId
93
+ const last = runId.slice(runId.lastIndexOf(':') + 1)
94
+ return last !== runId && ids.has(last) ? last : undefined
95
+ }
96
+
97
+ /** One child's steps: from server iterations when present, else the snapshot. */
98
+ export interface AgentStepGroup {
99
+ agent: SubagentInfo
100
+ iterations: Array<Iteration>
101
+ messages: Array<Message>
102
+ source: 'server' | 'snapshot'
103
+ }
104
+
105
+ /** A user message, the root steps it started, and its child agents. */
106
+ export interface TimelineGroup {
107
+ userMessage: Message | null
108
+ iterations: Array<Iteration>
109
+ agents: Array<AgentStepGroup>
110
+ }
111
+
112
+ /** Request id to agent path, for server runs that belong to a child. */
113
+ export function childRequestAgents(
114
+ iterations: Array<Iteration>,
115
+ subagents: Array<SubagentInfo>,
116
+ ): Map<string, string> {
117
+ const ids = new Set(subagents.map((agent) => agent.id))
118
+ const pathById = new Map(subagents.map((agent) => [agent.id, agent.path]))
119
+ const out = new Map<string, string>()
120
+ for (const iteration of iterations) {
121
+ const id = subagentIdForRunId(iteration.runId, ids)
122
+ const path = id ? pathById.get(id) : undefined
123
+ if (path && iteration.requestId) out.set(iteration.requestId, path)
124
+ }
125
+ return out
126
+ }
127
+
128
+ interface StepSource {
129
+ iterations: Array<Iteration>
130
+ messages: Array<Message>
131
+ }
132
+
133
+ /**
134
+ * The root conversation's steps plus each child's server steps. A child run
135
+ * may have its own thread id (`<threadId>:<agent>` unless `sandbox: 'inherit'`),
136
+ * so the store can file its steps under another conversation. They are found
137
+ * by run id.
138
+ */
139
+ export function collectServerSteps(
140
+ root: StepSource | undefined,
141
+ conversations: Array<StepSource>,
142
+ subagents: Array<SubagentInfo>,
143
+ ): StepSource {
144
+ const ids = new Set(subagents.map((agent) => agent.id))
145
+ const iterations: Array<Iteration> = [...(root?.iterations ?? [])]
146
+ const messages: Array<Message> = [...(root?.messages ?? [])]
147
+ const seenSteps = new Set(
148
+ iterations.map((iteration) => `${iteration.requestId}:${iteration.index}`),
149
+ )
150
+ const seenMessages = new Set(messages.map((message) => message.id))
151
+ for (const conversation of conversations) {
152
+ if (conversation === root) continue
153
+ const child = conversation.iterations.filter((iteration) =>
154
+ subagentIdForRunId(iteration.runId, ids),
155
+ )
156
+ if (child.length === 0) continue
157
+ for (const iteration of child) {
158
+ const key = `${iteration.requestId}:${iteration.index}`
159
+ if (seenSteps.has(key)) continue
160
+ seenSteps.add(key)
161
+ iterations.push(iteration)
162
+ }
163
+ for (const message of conversation.messages) {
164
+ if (seenMessages.has(message.id)) continue
165
+ seenMessages.add(message.id)
166
+ messages.push(message)
167
+ }
168
+ }
169
+ return { iterations, messages }
170
+ }
171
+
172
+ function timestampOf(value: unknown): number | undefined {
173
+ if (value instanceof Date) return value.getTime()
174
+ if (typeof value !== 'string' && typeof value !== 'number') return undefined
175
+ const time = new Date(value).getTime()
176
+ return Number.isNaN(time) ? undefined : time
177
+ }
178
+
179
+ /**
180
+ * The user messages of a chat snapshot, as the turns of the timeline. The
181
+ * snapshot is what the user sees, so it has every turn, also a routed turn
182
+ * with no parent server steps. Empty when a user message has no `createdAt`.
183
+ */
184
+ export function snapshotTurns(messages: Array<unknown>): Array<Message> {
185
+ const turns: Array<Message> = []
186
+ for (const message of messages) {
187
+ if (!isRecord(message) || message.role !== 'user') continue
188
+ const timestamp = timestampOf(message.createdAt)
189
+ if (timestamp === undefined || typeof message.id !== 'string') return []
190
+ const content = Array.isArray(message.parts)
191
+ ? message.parts
192
+ .map((part) =>
193
+ isRecord(part) && part.type === 'text' ? text(part.content) : '',
194
+ )
195
+ .join('')
196
+ : text(message.content)
197
+ turns.push({ id: message.id, role: 'user', content, timestamp })
198
+ }
199
+ return turns
200
+ }
201
+
202
+ /**
203
+ * Group steps under the user message that started them. Child server steps
204
+ * leave the root list and join their agent. A child without server steps gets
205
+ * steps built from the snapshot. A child `chat()` re-sends the history, so a
206
+ * user message from a child request is not a new turn. Pass `turns` (from
207
+ * {@link snapshotTurns}) to use the chat's own user messages as the turns.
208
+ */
209
+ export function groupTimeline(
210
+ iterations: Array<Iteration>,
211
+ messages: Array<Message>,
212
+ subagents: Array<SubagentInfo>,
213
+ turns: Array<Message> = [],
214
+ ): Array<TimelineGroup> {
215
+ const ids = new Set(subagents.map((agent) => agent.id))
216
+ const childRequests = childRequestAgents(iterations, subagents)
217
+ const rootIterations = iterations.filter(
218
+ (iteration) => !subagentIdForRunId(iteration.runId, ids),
219
+ )
220
+ const agents: Array<AgentStepGroup> = subagents.map((agent) => {
221
+ const server = iterations.filter(
222
+ (iteration) => subagentIdForRunId(iteration.runId, ids) === agent.id,
223
+ )
224
+ return server.length > 0
225
+ ? { agent, iterations: server, messages, source: 'server' }
226
+ : { agent, ...buildSubagentSteps(agent), source: 'snapshot' }
227
+ })
228
+
229
+ // ponytail: snapshot turns use the browser clock and server steps use the
230
+ // server clock. Fine for local dev; a large skew can move a step one turn.
231
+ const users = (
232
+ turns.length > 0
233
+ ? [...turns]
234
+ : messages.filter(
235
+ (message) =>
236
+ message.role === 'user' &&
237
+ !(message.requestId && childRequests.has(message.requestId)),
238
+ )
239
+ ).sort((a, b) => a.timestamp - b.timestamp)
240
+
241
+ if (users.length === 0) {
242
+ return rootIterations.length > 0 || agents.length > 0
243
+ ? [{ userMessage: null, iterations: rootIterations, agents }]
244
+ : []
245
+ }
246
+
247
+ const groups: Array<TimelineGroup> = []
248
+ const first = users[0]
249
+ const early = first
250
+ ? rootIterations.filter(
251
+ (iteration) => iteration.startedAt < first.timestamp,
252
+ )
253
+ : []
254
+ if (early.length > 0) {
255
+ groups.push({ userMessage: null, iterations: early, agents: [] })
256
+ }
257
+ users.forEach((user, turn) => {
258
+ const next = users[turn + 1]
259
+ const own = rootIterations.filter(
260
+ (iteration) =>
261
+ iteration.startedAt >= user.timestamp &&
262
+ (!next || iteration.startedAt < next.timestamp),
263
+ )
264
+ const isLast = turn === users.length - 1
265
+ const ownAgents = agents.filter((group) =>
266
+ isLast ? group.agent.turn >= turn : group.agent.turn === turn,
267
+ )
268
+ if (own.length > 0 || ownAgents.length > 0) {
269
+ groups.push({ userMessage: user, iterations: own, agents: ownAgents })
270
+ }
271
+ })
272
+ return groups
273
+ }
274
+
275
+ function resultText(value: unknown): string {
276
+ if (typeof value === 'string') return value
277
+ if (value === undefined) return ''
278
+ try {
279
+ return JSON.stringify(value)
280
+ } catch {
281
+ return String(value)
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Split one child's messages into steps. A step ends after its tool results,
287
+ * which is where the child's `chat()` starts its next iteration.
288
+ */
289
+ export function buildSubagentSteps(agent: SubagentInfo): SubagentSteps {
290
+ const iterations: Array<Iteration> = []
291
+ const messages: Array<Message> = []
292
+ let current:
293
+ | { message: Message; results: Array<Message>; hasResult: boolean }
294
+ | undefined
295
+
296
+ const close = () => {
297
+ if (!current) return
298
+ const index = iterations.length
299
+ const { message, results } = current
300
+ const hasContent =
301
+ message.content ||
302
+ message.thinkingContent ||
303
+ (message.toolCalls?.length ?? 0) > 0 ||
304
+ results.length > 0
305
+ if (hasContent) {
306
+ messages.push(message, ...results)
307
+ iterations.push({
308
+ requestId: `subagent:${agent.id}`,
309
+ index,
310
+ messageId: message.id,
311
+ // ponytail: the snapshot has no timestamps, so steps have no duration.
312
+ startedAt: 1,
313
+ completedAt: 1,
314
+ finishReason:
315
+ (message.toolCalls?.length ?? 0) > 0 ? 'tool_calls' : 'stop',
316
+ middlewareEvents: [],
317
+ messageIds: [message.id, ...results.map((result) => result.id)],
318
+ })
319
+ }
320
+ current = undefined
321
+ }
322
+
323
+ const open = () => {
324
+ const index = iterations.length
325
+ current = {
326
+ message: {
327
+ id: `${agent.id}:step-${index}`,
328
+ role: 'assistant',
329
+ content: '',
330
+ timestamp: 1,
331
+ toolCalls: [],
332
+ },
333
+ results: [],
334
+ hasResult: false,
335
+ }
336
+ return current
337
+ }
338
+
339
+ for (const message of agent.messages) {
340
+ if (!isRecord(message) || message.role !== 'assistant') continue
341
+ if (!Array.isArray(message.parts)) continue
342
+ for (const part of message.parts) {
343
+ if (!isRecord(part)) continue
344
+ // A tool result joins the step of its call. Any other part after a
345
+ // result starts the next step.
346
+ const step =
347
+ part.type === 'tool-result'
348
+ ? (current ?? open())
349
+ : current && !current.hasResult
350
+ ? current
351
+ : (close(), open())
352
+ if (part.type === 'thinking') {
353
+ step.message.thinkingContent =
354
+ (step.message.thinkingContent ?? '') + text(part.content)
355
+ } else if (part.type === 'text') {
356
+ step.message.content += text(part.content)
357
+ } else if (part.type === 'tool-call') {
358
+ const toolCall: ToolCall = {
359
+ id: text(part.id),
360
+ name: text(part.name) || 'tool',
361
+ arguments: text(part.arguments),
362
+ state: text(part.state),
363
+ ...(part.output !== undefined ? { result: part.output } : {}),
364
+ }
365
+ step.message.toolCalls?.push(toolCall)
366
+ } else if (part.type === 'tool-result') {
367
+ const toolCallId = text(part.toolCallId)
368
+ const content = resultText(part.content ?? part.error)
369
+ const call = step.message.toolCalls?.find((tc) => tc.id === toolCallId)
370
+ if (call && call.result === undefined) call.result = content
371
+ step.results.push({
372
+ id: `${agent.id}:result-${toolCallId || step.results.length}`,
373
+ role: 'tool',
374
+ content,
375
+ timestamp: 1,
376
+ })
377
+ step.hasResult = true
378
+ }
379
+ }
380
+ }
381
+ close()
382
+
383
+ const last = iterations.at(-1)
384
+ if (last && agent.status === 'running') delete last.completedAt
385
+ if (last && agent.status === 'error') last.finishReason = 'error'
386
+ return { iterations, messages }
387
+ }