@lota-sdk/core 0.4.11 → 0.4.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -31,10 +31,10 @@
31
31
  "@ai-sdk/openai": "^3.0.53",
32
32
  "@chat-adapter/slack": "^4.26.0",
33
33
  "@chat-adapter/state-ioredis": "^4.26.0",
34
- "@lota-sdk/shared": "0.4.11",
34
+ "@lota-sdk/shared": "0.4.13",
35
35
  "@mendable/firecrawl-js": "^4.18.3",
36
36
  "@surrealdb/node": "^3.0.3",
37
- "ai": "^6.0.167",
37
+ "ai": "^6.0.168",
38
38
  "bullmq": "^5.74.1",
39
39
  "chat": "^4.26.0",
40
40
  "effect": "^4.0.0-beta.50",
@@ -595,6 +595,52 @@ export function extractAiGatewayChatReasoningDeltaText(rawChunk: unknown): strin
595
595
  return null
596
596
  }
597
597
 
598
+ function findAiGatewayChatReasoningOverlap(previousReasoningText: string, nextReasoningText: string): number {
599
+ const maxOverlap = Math.min(previousReasoningText.length, nextReasoningText.length)
600
+
601
+ for (let overlapLength = maxOverlap; overlapLength > 0; overlapLength -= 1) {
602
+ if (previousReasoningText.slice(-overlapLength) === nextReasoningText.slice(0, overlapLength)) {
603
+ return overlapLength
604
+ }
605
+ }
606
+
607
+ return 0
608
+ }
609
+
610
+ function deriveAiGatewayChatReasoningDeltaText(params: { previousReasoningText: string; rawChunk: unknown }): {
611
+ delta: string | null
612
+ nextReasoningText: string
613
+ } {
614
+ const extractedText = extractAiGatewayChatReasoningDeltaText(params.rawChunk)
615
+ if (!extractedText) {
616
+ return { delta: null, nextReasoningText: params.previousReasoningText }
617
+ }
618
+
619
+ if (params.previousReasoningText.length === 0) {
620
+ return { delta: extractedText, nextReasoningText: extractedText }
621
+ }
622
+
623
+ if (extractedText === params.previousReasoningText) {
624
+ return { delta: null, nextReasoningText: params.previousReasoningText }
625
+ }
626
+
627
+ if (extractedText.startsWith(params.previousReasoningText)) {
628
+ const delta = extractedText.slice(params.previousReasoningText.length)
629
+ return { delta: delta.length > 0 ? delta : null, nextReasoningText: extractedText }
630
+ }
631
+
632
+ const overlapLength = findAiGatewayChatReasoningOverlap(params.previousReasoningText, extractedText)
633
+ if (overlapLength > 0) {
634
+ const delta = extractedText.slice(overlapLength)
635
+ return { delta: delta.length > 0 ? delta : null, nextReasoningText: `${params.previousReasoningText}${delta}` }
636
+ }
637
+
638
+ // Some providers emit true deltas, others resend the full reasoning-so-far.
639
+ // If the chunk is not a prefix extension, treat it as a standalone delta and
640
+ // append it to the accumulated reasoning text.
641
+ return { delta: extractedText, nextReasoningText: `${params.previousReasoningText}${extractedText}` }
642
+ }
643
+
598
644
  export function injectAiGatewayChatReasoningContent(
599
645
  content: readonly AiGatewayGeneratedContent[],
600
646
  response?: AiGatewayChatResponse,
@@ -786,6 +832,8 @@ export function injectAiGatewayChatReasoningStream(
786
832
  const reasoningId = 'ai-gateway-reasoning-0'
787
833
  let reasoningOpen = false
788
834
  let reasoningClosed = false
835
+ let reasoningText = ''
836
+ let nativeReasoningSeen = false
789
837
 
790
838
  return stream.pipeThrough(
791
839
  new TransformStream<AiGatewayStreamPart, AiGatewayStreamPart>({
@@ -797,11 +845,25 @@ export function injectAiGatewayChatReasoningStream(
797
845
  reasoningClosed = true
798
846
  }
799
847
 
848
+ if (chunk.type === 'reasoning-start' || chunk.type === 'reasoning-delta' || chunk.type === 'reasoning-end') {
849
+ nativeReasoningSeen = true
850
+ closeReasoning()
851
+ controller.enqueue(chunk)
852
+ return
853
+ }
854
+
800
855
  if (chunk.type === 'raw') {
801
- const reasoningDelta = reasoningClosed ? null : extractAiGatewayChatReasoningDeltaText(chunk.rawValue)
856
+ const reasoningDeltaState =
857
+ reasoningClosed || nativeReasoningSeen
858
+ ? null
859
+ : deriveAiGatewayChatReasoningDeltaText({
860
+ previousReasoningText: reasoningText,
861
+ rawChunk: chunk.rawValue,
862
+ })
802
863
  controller.enqueue(chunk)
803
864
 
804
- if (reasoningDelta) {
865
+ if (reasoningDeltaState?.delta) {
866
+ reasoningText = reasoningDeltaState.nextReasoningText
805
867
  if (!reasoningOpen) {
806
868
  controller.enqueue({ type: 'reasoning-start', id: reasoningId } satisfies AiGatewayStreamPart)
807
869
  reasoningOpen = true
@@ -810,7 +872,7 @@ export function injectAiGatewayChatReasoningStream(
810
872
  controller.enqueue({
811
873
  type: 'reasoning-delta',
812
874
  id: reasoningId,
813
- delta: reasoningDelta,
875
+ delta: reasoningDeltaState.delta,
814
876
  } satisfies AiGatewayStreamPart)
815
877
  }
816
878
  return
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  AI_GATEWAY_REASONING_SUMMARY_LEVEL,
3
+ OPENROUTER_GEMINI_PRO_MODEL_ID,
3
4
  OPENAI_HIGH_REASONING_PROVIDER_OPTIONS,
4
5
  OPENAI_REASONING_MODEL_ID,
5
6
  OPENROUTER_FAST_RERANK_MODEL_ID,
@@ -13,6 +13,7 @@ export * from './instruction-sections'
13
13
  export * from './memory/memory-block'
14
14
  export * from './memory/memory-digest-policy'
15
15
  export * from './memory/memory-scope'
16
+ export * from './live-turn-trace'
16
17
  export * from './llm-content'
17
18
  export * from './plugin-resolution'
18
19
  export * from './plugin-types'
@@ -0,0 +1,301 @@
1
+ import type { AgentActivityData, ThinkingStepData } from '@lota-sdk/shared'
2
+ import type { UIMessage, UIMessageStreamWriter } from 'ai'
3
+
4
+ type StreamChunk<TMessage extends UIMessage> = Parameters<UIMessageStreamWriter<TMessage>['write']>[0]
5
+
6
+ interface ReasoningBlockState {
7
+ pendingChunk: string
8
+ }
9
+
10
+ function asRecord(value: unknown): Record<string, unknown> | null {
11
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
12
+ }
13
+
14
+ function readString(value: unknown): string | null {
15
+ return typeof value === 'string' && value.length > 0 ? value : null
16
+ }
17
+
18
+ function normalizeWhitespace(value: string): string {
19
+ return value.replace(/\s+/g, ' ').trim()
20
+ }
21
+
22
+ function clipThinkingTitle(value: string): string {
23
+ if (value.length <= 120) return value
24
+ return `${value.slice(0, 117).trimEnd()}...`
25
+ }
26
+
27
+ function normalizeThinkingTitle(value: string): string {
28
+ return clipThinkingTitle(normalizeWhitespace(value))
29
+ }
30
+
31
+ function sanitizeReasoningText(value: string): string {
32
+ return value.replace(/\[REDACTED\]/gi, '').replace(/\r\n/g, '\n')
33
+ }
34
+
35
+ function stripMarkdownTitleDecorators(line: string): string {
36
+ let value = line.trim()
37
+ value = value.replace(/^#{1,6}\s+/, '')
38
+ value = value.replace(/^\*\*(.+?)\*\*[:.]?$/, '$1')
39
+ value = value.replace(/^__(.+?)__[:.]?$/, '$1')
40
+ value = value.replace(/^`(.+?)`[:.]?$/, '$1')
41
+ value = value.replace(/[:\s]+$/, '')
42
+ return value.trim()
43
+ }
44
+
45
+ function readHeadingTitle(line: string): string | null {
46
+ const trimmed = line.trim()
47
+ if (/^#{1,6}\s+/.test(trimmed)) {
48
+ return stripMarkdownTitleDecorators(trimmed)
49
+ }
50
+ if (/^\*\*.+\*\*[:.]?$/.test(trimmed) || /^__.+__[:.]?$/.test(trimmed) || /^`.+`[:.]?$/.test(trimmed)) {
51
+ return stripMarkdownTitleDecorators(trimmed)
52
+ }
53
+ if (trimmed.length <= 90 && /^[A-Z0-9].*:\s*$/.test(trimmed)) {
54
+ return stripMarkdownTitleDecorators(trimmed)
55
+ }
56
+ return null
57
+ }
58
+
59
+ export function extractThinkingTitlesFromReasoning(params: { text: string; isFinal?: boolean }): string[] {
60
+ const cleaned = sanitizeReasoningText(params.text).trim()
61
+ if (cleaned.length === 0) return []
62
+
63
+ const chunks = cleaned
64
+ .split(/\n{2,}/)
65
+ .map((chunk) => chunk.trim())
66
+ .filter((chunk) => chunk.length > 0)
67
+
68
+ const titles: string[] = []
69
+
70
+ for (const chunk of chunks) {
71
+ const firstLine = chunk
72
+ .split('\n')
73
+ .find((line) => line.trim().length > 0)
74
+ ?.trim()
75
+ if (!firstLine) continue
76
+
77
+ const headingTitle = readHeadingTitle(firstLine)
78
+ if (headingTitle) {
79
+ titles.push(normalizeThinkingTitle(headingTitle))
80
+ }
81
+ }
82
+
83
+ return titles.filter((title) => title.length > 0)
84
+ }
85
+
86
+ function splitReasoningChunks(text: string, isFinal: boolean): { completedChunks: string[]; pendingChunk: string } {
87
+ const parts = text.split(/\n{2,}/)
88
+ if (isFinal) {
89
+ return { completedChunks: parts.map((part) => part.trim()).filter((part) => part.length > 0), pendingChunk: '' }
90
+ }
91
+
92
+ const completedChunks = parts
93
+ .slice(0, -1)
94
+ .map((part) => part.trim())
95
+ .filter((part) => part.length > 0)
96
+ const pendingChunk = parts.at(-1)?.trimStart() ?? ''
97
+ return { completedChunks, pendingChunk }
98
+ }
99
+
100
+ function readChunkType<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
101
+ return readString(asRecord(chunk)?.type)
102
+ }
103
+
104
+ function readChunkToolCallId<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
105
+ const record = asRecord(chunk)
106
+ return readString(record?.toolCallId) ?? readString(record?.id)
107
+ }
108
+
109
+ function readChunkToolName<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
110
+ return readString(asRecord(chunk)?.toolName)
111
+ }
112
+
113
+ function readChunkReasoningId<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
114
+ return readString(asRecord(chunk)?.id)
115
+ }
116
+
117
+ function readChunkReasoningDelta<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
118
+ return readString(asRecord(chunk)?.delta)
119
+ }
120
+
121
+ function readChunkErrorText<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
122
+ const record = asRecord(chunk)
123
+ return readString(record?.errorText) ?? readString(record?.error)
124
+ }
125
+
126
+ export function createLiveTurnTraceStreamObserver<TMessage extends UIMessage>(params: {
127
+ traceId: string
128
+ writer: UIMessageStreamWriter<TMessage>
129
+ agentId?: string
130
+ agentName?: string
131
+ }) {
132
+ const toolNamesByCallId = new Map<string, string>()
133
+ const startedToolIds = new Set<string>()
134
+ const completedToolIds = new Set<string>()
135
+ const reasoningBlocks = new Map<string, ReasoningBlockState>()
136
+ const emittedThinkingTitles = new Set<string>()
137
+ let activeThinkingStep: ThinkingStepData | null = null
138
+ let nextThinkingStepIndex = 0
139
+
140
+ const writeActivity = (data: AgentActivityData) => {
141
+ const chunk = {
142
+ type: 'data-agent-activity',
143
+ id: `agent-activity:${data.activityId}`,
144
+ data,
145
+ transient: true,
146
+ } as unknown as StreamChunk<TMessage>
147
+ params.writer.write(chunk)
148
+ }
149
+
150
+ const writeThinkingStep = (data: ThinkingStepData) => {
151
+ const chunk = {
152
+ type: 'data-thinking-step',
153
+ id: `thinking-step:${data.stepId}`,
154
+ data,
155
+ transient: true,
156
+ } as unknown as StreamChunk<TMessage>
157
+ params.writer.write(chunk)
158
+ }
159
+
160
+ const markThinkingStepDone = () => {
161
+ if (!activeThinkingStep) return
162
+ if (activeThinkingStep.status === 'done') return
163
+ activeThinkingStep = { ...activeThinkingStep, status: 'done' }
164
+ writeThinkingStep(activeThinkingStep)
165
+ }
166
+
167
+ const emitThinkingTitles = (titles: string[]) => {
168
+ for (const title of titles) {
169
+ const normalizedTitle = title.toLocaleLowerCase()
170
+ if (emittedThinkingTitles.has(normalizedTitle)) continue
171
+
172
+ markThinkingStepDone()
173
+
174
+ const nextStep: ThinkingStepData = {
175
+ traceId: params.traceId,
176
+ stepId: `${params.traceId}:thinking:${nextThinkingStepIndex}`,
177
+ index: nextThinkingStepIndex,
178
+ title,
179
+ status: 'streaming',
180
+ }
181
+ nextThinkingStepIndex += 1
182
+ emittedThinkingTitles.add(normalizedTitle)
183
+ activeThinkingStep = nextStep
184
+ writeThinkingStep(nextStep)
185
+ }
186
+ }
187
+
188
+ const emitToolPhase = (
189
+ phase: AgentActivityData['phase'],
190
+ toolCallId: string,
191
+ toolName: string,
192
+ errorText?: string,
193
+ ) => {
194
+ writeActivity({
195
+ traceId: params.traceId,
196
+ activityId: toolCallId,
197
+ kind: 'tool',
198
+ toolName,
199
+ toolCallId,
200
+ ...(params.agentId ? { agentId: params.agentId } : {}),
201
+ ...(params.agentName ? { agentName: params.agentName } : {}),
202
+ phase,
203
+ ...(errorText ? { errorText } : {}),
204
+ })
205
+ }
206
+
207
+ const startTool = (toolCallId: string, toolName: string) => {
208
+ if (!toolNamesByCallId.has(toolCallId)) {
209
+ toolNamesByCallId.set(toolCallId, toolName)
210
+ }
211
+ if (startedToolIds.has(toolCallId) || completedToolIds.has(toolCallId)) return
212
+ startedToolIds.add(toolCallId)
213
+ emitToolPhase('started', toolCallId, toolName)
214
+ }
215
+
216
+ const completeTool = (toolCallId: string, phase: 'completed' | 'failed', errorText?: string) => {
217
+ const toolName = toolNamesByCallId.get(toolCallId)
218
+ if (!toolName || completedToolIds.has(toolCallId)) return
219
+ completedToolIds.add(toolCallId)
220
+ emitToolPhase(phase, toolCallId, toolName, errorText)
221
+ }
222
+
223
+ const processReasoningText = (reasoningId: string, delta: string, isFinal: boolean) => {
224
+ const state = reasoningBlocks.get(reasoningId) ?? { pendingChunk: '' }
225
+ const nextBuffer = state.pendingChunk + sanitizeReasoningText(delta)
226
+ const { completedChunks, pendingChunk } = splitReasoningChunks(nextBuffer, isFinal)
227
+
228
+ for (const completedChunk of completedChunks) {
229
+ emitThinkingTitles(extractThinkingTitlesFromReasoning({ text: completedChunk, isFinal: true }))
230
+ }
231
+
232
+ if (!isFinal && pendingChunk.length > 0) {
233
+ emitThinkingTitles(extractThinkingTitlesFromReasoning({ text: pendingChunk, isFinal: false }))
234
+ }
235
+
236
+ if (isFinal) {
237
+ reasoningBlocks.delete(reasoningId)
238
+ return
239
+ }
240
+
241
+ reasoningBlocks.set(reasoningId, { pendingChunk })
242
+ }
243
+
244
+ return {
245
+ observeChunk(chunk: StreamChunk<TMessage>): void {
246
+ const chunkType = readChunkType(chunk)
247
+ if (!chunkType) return
248
+
249
+ switch (chunkType) {
250
+ case 'reasoning-delta': {
251
+ const reasoningId = readChunkReasoningId(chunk)
252
+ const delta = readChunkReasoningDelta(chunk)
253
+ if (!reasoningId || delta === null) return
254
+
255
+ processReasoningText(reasoningId, delta, false)
256
+ return
257
+ }
258
+
259
+ case 'reasoning-end': {
260
+ const reasoningId = readChunkReasoningId(chunk)
261
+ if (!reasoningId) return
262
+ processReasoningText(reasoningId, '', true)
263
+ markThinkingStepDone()
264
+ return
265
+ }
266
+
267
+ case 'tool-input-start':
268
+ case 'tool-input-available':
269
+ case 'tool-call': {
270
+ const toolCallId = readChunkToolCallId(chunk)
271
+ const toolName = readChunkToolName(chunk)
272
+ if (!toolCallId || !toolName) return
273
+ startTool(toolCallId, toolName)
274
+ return
275
+ }
276
+
277
+ case 'tool-output-available': {
278
+ const toolCallId = readChunkToolCallId(chunk)
279
+ if (!toolCallId) return
280
+ completeTool(toolCallId, 'completed')
281
+ return
282
+ }
283
+
284
+ case 'tool-output-error':
285
+ case 'tool-error': {
286
+ const toolCallId = readChunkToolCallId(chunk)
287
+ if (!toolCallId) return
288
+ completeTool(toolCallId, 'failed', readChunkErrorText(chunk) ?? undefined)
289
+ return
290
+ }
291
+
292
+ default:
293
+ return
294
+ }
295
+ },
296
+
297
+ finish(): void {
298
+ markThinkingStepDone()
299
+ },
300
+ }
301
+ }
@@ -52,6 +52,7 @@ import type {
52
52
  createThreadApprovalContinuationStream,
53
53
  createThreadNativeToolApprovalStream,
54
54
  createThreadTurnStream,
55
+ launchBackgroundThreadWork,
55
56
  runThreadTurnInBackground,
56
57
  ThreadTurnServiceTag,
57
58
  triggerPlanNodeTurn,
@@ -136,6 +137,7 @@ export interface LotaRuntimeServices {
136
137
  createThreadNativeToolApprovalStream: typeof createThreadNativeToolApprovalStream
137
138
  createThreadTurnStream: typeof createThreadTurnStream
138
139
  isApprovalContinuationRequest: typeof isApprovalContinuationRequestFn
140
+ launchBackgroundThreadWork: typeof launchBackgroundThreadWork
139
141
  runThreadTurnInBackground: typeof runThreadTurnInBackground
140
142
  triggerPlanNodeTurn: typeof triggerPlanNodeTurn
141
143
  }
@@ -350,6 +352,7 @@ export function buildRuntimeServiceSurface(input: BuildRuntimeServiceSurfaceInpu
350
352
  createThreadNativeToolApprovalStream: (...args) => threadTurnService.createThreadNativeToolApprovalStream(...args),
351
353
  createThreadTurnStream: (...args) => threadTurnService.createThreadTurnStream(...args),
352
354
  isApprovalContinuationRequest: isApprovalContinuationRequestFn,
355
+ launchBackgroundThreadWork: (...args) => threadTurnService.launchBackgroundThreadWork(...args),
353
356
  runThreadTurnInBackground: (...args) => threadTurnService.runThreadTurnInBackground(...args),
354
357
  triggerPlanNodeTurn: (...args) => threadTurnService.triggerPlanNodeTurn(...args),
355
358
  }
@@ -993,27 +993,9 @@ const prepareThreadRunCoreEffect = Effect.fn('ThreadTurnPreparation.prepareThrea
993
993
  ).pipe(Effect.withSpan('ThreadTurnPreparation.runPostTurnSideEffects'))
994
994
  }
995
995
 
996
- if (allAssistantMessages.length > 0 && params.kind !== 'planTurn') {
997
- const afterTurn = turnHooks.afterTurn
998
- if (afterTurn) {
999
- yield* effectTryPromise(
1000
- () =>
1001
- afterTurn({
1002
- thread,
1003
- threadRef,
1004
- orgRef,
1005
- userRef,
1006
- userName,
1007
- onboardingActive,
1008
- referenceUserMessage,
1009
- assistantMessages: allAssistantMessages,
1010
- latestThreadRecord,
1011
- context: buildContextResult,
1012
- }),
1013
- 'Failed to run afterTurn hook.',
1014
- ).pipe(Effect.withSpan('ThreadTurnPreparation.afterTurnHook'))
1015
- }
1016
- }
996
+ yield* Effect.sync(() => {
997
+ launchAfterTurnHook(latestThreadRecord)
998
+ })
1017
999
  }).pipe(
1018
1000
  Effect.catch((postRunError) =>
1019
1001
  Effect.sync(() => {
@@ -1034,6 +1016,48 @@ const prepareThreadRunCoreEffect = Effect.fn('ThreadTurnPreparation.prepareThrea
1034
1016
  assistantMessages: [...allAssistantMessages],
1035
1017
  })
1036
1018
 
1019
+ const launchAfterTurnHook = (latestThreadRecord: typeof threadRecord) => {
1020
+ if (allAssistantMessages.length === 0 || params.kind === 'planTurn') {
1021
+ return
1022
+ }
1023
+
1024
+ const afterTurn = turnHooks.afterTurn
1025
+ if (!afterTurn) {
1026
+ return
1027
+ }
1028
+
1029
+ // `afterTurn` is host-owned follow-up work. Launch it detached so the
1030
+ // streamed turn closes after persistence/finalization instead of waiting on
1031
+ // onboarding/map side effects in the request path.
1032
+ void runPromiseWithCurrentContext(
1033
+ effectTryPromise(
1034
+ () =>
1035
+ afterTurn({
1036
+ thread,
1037
+ threadRef,
1038
+ orgRef,
1039
+ userRef,
1040
+ userName,
1041
+ onboardingActive,
1042
+ referenceUserMessage,
1043
+ assistantMessages: allAssistantMessages,
1044
+ latestThreadRecord,
1045
+ context: buildContextResult,
1046
+ }),
1047
+ 'Failed to run afterTurn hook.',
1048
+ ).pipe(
1049
+ Effect.withSpan('ThreadTurnPreparation.afterTurnHook'),
1050
+ Effect.catch((error) =>
1051
+ Effect.sync(() => {
1052
+ aiLogger.error`Thread afterTurn hook failed: ${error}`
1053
+ }),
1054
+ ),
1055
+ ),
1056
+ ).catch((error) => {
1057
+ aiLogger.error`Thread afterTurn hook scheduling failed: ${error}`
1058
+ })
1059
+ }
1060
+
1037
1061
  const run = (writer?: UIMessageStreamWriter<ChatMessage>) => {
1038
1062
  const serverRunId = Bun.randomUUIDv7()
1039
1063
 
@@ -14,6 +14,7 @@ import {
14
14
  } from '../../runtime/agent-identity-overrides'
15
15
  import { createAgentMessageMetadata } from '../../runtime/agent-stream-helpers'
16
16
  import { mergeInstructionSections } from '../../runtime/instruction-sections'
17
+ import { createLiveTurnTraceStreamObserver } from '../../runtime/live-turn-trace'
17
18
  import type { LotaRuntimeTurnHooks } from '../../runtime/runtime-extensions'
18
19
  import {
19
20
  asRecord,
@@ -277,6 +278,7 @@ const streamAgentResponseEffect = Effect.fn('ThreadTurnStreaming.streamAgentResp
277
278
  prepareStep: (agentResolution?.prepareStep as PrepareStepFunction<ToolSet> | undefined) ?? streamParams.prepareStep,
278
279
  })
279
280
  const agentAbortSignal = streamParams.abortSignal ?? ctx.runAbortSignal
281
+ const resolvedAgentName = resolveRuntimeAgentDisplayName(agentConfig, agentIdentityOverrides, resolvedAgentId)
280
282
 
281
283
  const generateFallback = (cause: ThreadTurnStreamingError) =>
282
284
  effectTryPromise(
@@ -291,6 +293,34 @@ const streamAgentResponseEffect = Effect.fn('ThreadTurnStreaming.streamAgentResp
291
293
  Effect.flatMap((result) => buildFallbackResponseMessage(result as ToolLoopGenerateResult)),
292
294
  )
293
295
 
296
+ const generateWithoutUiStream = effectTryPromise(
297
+ () => streamParams.observer.run(() => agent.generate({ messages: modelMessages, abortSignal: agentAbortSignal })),
298
+ `Agent generate failed for ${resolvedAgentId}.`,
299
+ ).pipe(
300
+ Effect.tapError((error) =>
301
+ Effect.sync(() => {
302
+ if (agentAbortSignal.aborted) {
303
+ streamParams.observer.recordAbort(error)
304
+ return
305
+ }
306
+
307
+ streamParams.observer.recordError(error)
308
+ }),
309
+ ),
310
+ Effect.withSpan('ThreadTurnStreaming.startAgentGenerate'),
311
+ Effect.flatMap((result) => buildFallbackResponseMessage(result as ToolLoopGenerateResult)),
312
+ )
313
+
314
+ if (!streamParams.writer) {
315
+ const generatedResponse = yield* generateWithoutUiStream
316
+
317
+ for (const toolError of collectToolOutputErrors({ responseMessage: generatedResponse })) {
318
+ aiLogger.error`Tool execution failed (agent=${resolvedAgentId}, tool=${toolError.toolName}, toolCallId=${toolError.toolCallId}): ${toolError.errorText}`
319
+ }
320
+
321
+ return generatedResponse
322
+ }
323
+
294
324
  const result = yield* effectTryPromise(
295
325
  () => streamParams.observer.run(() => agent.stream({ messages: modelMessages, abortSignal: agentAbortSignal })),
296
326
  `Agent stream failed for ${resolvedAgentId}.`,
@@ -320,14 +350,19 @@ const streamAgentResponseEffect = Effect.fn('ThreadTurnStreaming.streamAgentResp
320
350
  originalMessages: streamParams.messages,
321
351
  sendReasoning: true,
322
352
  sendSources: true,
323
- messageMetadata: createAgentMessageMetadata({
324
- agentId: resolvedAgentId,
325
- agentName: resolveRuntimeAgentDisplayName(agentConfig, agentIdentityOverrides, resolvedAgentId),
326
- }),
353
+ messageMetadata: createAgentMessageMetadata({ agentId: resolvedAgentId, agentName: resolvedAgentName }),
327
354
  onFinish: ({ responseMessage: finishedResponseMessage }: { responseMessage: ChatMessage }) => {
328
355
  resolveFinishedStream(withMessageCreatedAt(finishedResponseMessage, nowEpochMillis()))
329
356
  },
330
357
  }) as ReadableStream<ChatStreamChunk>
358
+ const liveTurnTrace = streamParams.writer
359
+ ? createLiveTurnTraceStreamObserver({
360
+ traceId: `trace:${Bun.randomUUIDv7()}`,
361
+ writer: streamParams.writer,
362
+ agentId: resolvedAgentId,
363
+ agentName: resolvedAgentName,
364
+ })
365
+ : null
331
366
  const streamStartedAt = performance.now()
332
367
  const firstVisibleOutputRecorded = yield* Ref.make(false)
333
368
  const firstTextTokenRecorded = yield* Ref.make(false)
@@ -361,6 +396,7 @@ const streamAgentResponseEffect = Effect.fn('ThreadTurnStreaming.streamAgentResp
361
396
  if (streamParams.writer) {
362
397
  yield* Effect.sync(() => {
363
398
  streamParams.writer?.write(value)
399
+ liveTurnTrace?.observeChunk(value)
364
400
  })
365
401
  }
366
402
  }),
@@ -371,6 +407,7 @@ const streamAgentResponseEffect = Effect.fn('ThreadTurnStreaming.streamAgentResp
371
407
  ),
372
408
  Effect.catchTag('ThreadTurnStreamingError', generateFallback),
373
409
  )
410
+ liveTurnTrace?.finish()
374
411
 
375
412
  for (const toolError of collectToolOutputErrors({ responseMessage: streamedResponse })) {
376
413
  aiLogger.error`Tool execution failed (agent=${resolvedAgentId}, tool=${toolError.toolName}, toolCallId=${toolError.toolCallId}): ${toolError.errorText}`
@@ -5,34 +5,68 @@ import { Context, Schema, Effect, Layer } from 'effect'
5
5
  import type { ResolvedAgentConfig } from '../../config/agent-defaults'
6
6
  import { ensureRecordId, recordIdToString } from '../../db/record-id'
7
7
  import { TABLES } from '../../db/tables'
8
- import { BadRequestError } from '../../effect/errors'
8
+ import { BadRequestError, ForbiddenError } from '../../effect/errors'
9
9
  import { AgentConfigServiceTag } from '../../effect/services'
10
10
  import { hasApprovalRespondedParts, isApprovalContinuationRequest } from '../../runtime/approval-continuation'
11
11
  import { shouldPlanNodeUseVisibleTurn } from '../../runtime/execution-plan-visibility'
12
12
  import { wrapResponseWithKeepalive } from '../../utils/sse-keepalive'
13
+ import { BackgroundWorkService } from '../background-work.service'
13
14
  import type { makePlanExecutorService } from '../plan/plan-executor.service'
14
15
  import { PlanExecutorServiceTag } from '../plan/plan-executor.service'
15
16
  import type { makePlanRunService } from '../plan/plan-run.service'
16
17
  import { PlanRunServiceTag } from '../plan/plan-run.service'
17
18
  import type { makeUserService } from '../user.service'
18
19
  import { UserServiceTag } from '../user.service'
20
+ import type { makeThreadMessageService } from './thread-message.service'
21
+ import { ThreadMessageServiceTag } from './thread-message.service'
19
22
  import type {
20
23
  PreparedThreadTurnResult,
21
24
  ThreadApprovalContinuationParams,
22
- ThreadPlanTurnParams,
23
25
  ThreadTurnParams,
26
+ ThreadPlanTurnParams,
24
27
  makeThreadTurnPreparationService,
25
28
  } from './thread-turn-preparation.service'
26
29
  import { ThreadTurnPreparationServiceTag } from './thread-turn-preparation.service'
27
30
  import { buildThreadTurnSpanAttributes, compactSpanAttributes } from './thread-turn-tracing'
28
31
  import type { makeThreadService } from './thread.service'
29
32
  import { ThreadServiceTag } from './thread.service'
33
+ import type { NormalizedThread } from './thread.types'
30
34
 
31
35
  export { hasApprovalRespondedParts, isApprovalContinuationRequest }
32
36
  export { wrapResponseWithKeepalive }
33
37
  export type { PreparedThreadTurnResult }
34
38
  export type { ThreadPlanTurnParams }
35
39
 
40
+ export interface BackgroundThreadLaunchMessage {
41
+ parts: ChatMessage['parts']
42
+ metadata?: ChatMessage['metadata']
43
+ }
44
+
45
+ export interface LaunchBackgroundThreadWorkParams {
46
+ sourceThreadId: Parameters<typeof ensureRecordId>[0]
47
+ orgRef: Parameters<typeof ensureRecordId>[0]
48
+ userRef: Parameters<typeof ensureRecordId>[0]
49
+ userName?: string | null
50
+ targetThreadId?: Parameters<typeof ensureRecordId>[0]
51
+ projectTitle?: string
52
+ targetAgentId?: string
53
+ handoff: BackgroundThreadLaunchMessage
54
+ input: BackgroundThreadLaunchMessage
55
+ abortSignal?: AbortSignal
56
+ streamId?: string
57
+ }
58
+
59
+ export interface LaunchBackgroundThreadWorkResult {
60
+ launched: boolean
61
+ threadId: string
62
+ threadTitle: string
63
+ sourceThreadId: string
64
+ targetAgentId?: string
65
+ handoffMessageId: string
66
+ createdThread: boolean
67
+ message: string
68
+ }
69
+
36
70
  class ThreadTurnServiceError extends Schema.TaggedErrorClass<ThreadTurnServiceError>()('ThreadTurnServiceError', {
37
71
  message: Schema.String,
38
72
  cause: Schema.optional(Schema.Defect),
@@ -40,9 +74,12 @@ class ThreadTurnServiceError extends Schema.TaggedErrorClass<ThreadTurnServiceEr
40
74
 
41
75
  interface ThreadTurnDeps {
42
76
  agentConfig: ResolvedAgentConfig
77
+ background: Context.Service.Shape<typeof BackgroundWorkService>
43
78
  planExecutor: ReturnType<typeof makePlanExecutorService>
44
79
  planRun: ReturnType<typeof makePlanRunService>
80
+ provideCurrentContext: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, never>
45
81
  thread: ReturnType<typeof makeThreadService>
82
+ threadMessage: ReturnType<typeof makeThreadMessageService>
46
83
  threadTurnPreparation: ReturnType<typeof makeThreadTurnPreparationService>
47
84
  user: ReturnType<typeof makeUserService>
48
85
  }
@@ -172,6 +209,129 @@ function runThreadTurnInBackgroundWith(deps: ThreadTurnDeps, params: ThreadTurnP
172
209
  )
173
210
  }
174
211
 
212
+ function buildBackgroundLaunchMessage(params: { createdThread: boolean; threadTitle: string }) {
213
+ return params.createdThread
214
+ ? `Background work launched in "${params.threadTitle}".`
215
+ : `Background work launched in existing thread "${params.threadTitle}".`
216
+ }
217
+
218
+ const launchBackgroundThreadWorkEffect = Effect.fn('ThreadTurn.launchBackgroundThreadWork')(function* (
219
+ deps: ThreadTurnDeps,
220
+ params: LaunchBackgroundThreadWorkParams,
221
+ ) {
222
+ const orgIdString = recordIdToString(params.orgRef, TABLES.ORGANIZATION)
223
+ const userIdString = recordIdToString(params.userRef, TABLES.USER)
224
+ const sourceThreadId = recordIdToString(params.sourceThreadId, TABLES.THREAD)
225
+
226
+ const resolveTargetThread = (): Effect.Effect<
227
+ { thread: NormalizedThread; createdThread: boolean },
228
+ BadRequestError | ForbiddenError | ThreadTurnServiceError
229
+ > =>
230
+ Effect.gen(function* () {
231
+ if (params.targetThreadId) {
232
+ const existingThread = yield* deps.thread
233
+ .getThread(params.targetThreadId)
234
+ .pipe(
235
+ Effect.mapError((cause) => new ThreadTurnServiceError({ message: 'Failed to load target thread.', cause })),
236
+ )
237
+ if (existingThread.organizationId !== orgIdString) {
238
+ return yield* new ForbiddenError({ message: 'Target thread belongs to a different organization.' })
239
+ }
240
+ if (existingThread.userId !== userIdString) {
241
+ return yield* new ForbiddenError({ message: 'Target thread belongs to a different user.' })
242
+ }
243
+ if (existingThread.status !== 'active') {
244
+ return yield* new BadRequestError({ message: 'Target thread must be active.' })
245
+ }
246
+ return { thread: existingThread, createdThread: false }
247
+ }
248
+
249
+ const projectTitle = params.projectTitle?.trim()
250
+ if (!projectTitle) {
251
+ return yield* new BadRequestError({
252
+ message: 'projectTitle is required when launching background work without targetThreadId.',
253
+ })
254
+ }
255
+
256
+ const createdThread = yield* deps.thread
257
+ .createThread({ userId: params.userRef, organizationId: params.orgRef, title: projectTitle, type: 'group' })
258
+ .pipe(
259
+ Effect.mapError((cause) => new ThreadTurnServiceError({ message: 'Failed to create target thread.', cause })),
260
+ )
261
+ return { thread: createdThread, createdThread: true }
262
+ })
263
+
264
+ let createdThreadId: string | null = null
265
+ const cleanupCreatedThread = () =>
266
+ createdThreadId ? deps.thread.deleteThread(createdThreadId).pipe(Effect.catch(() => Effect.void)) : Effect.void
267
+
268
+ return yield* Effect.gen(function* () {
269
+ const { thread: targetThread, createdThread } = yield* resolveTargetThread()
270
+ if (createdThread) {
271
+ createdThreadId = targetThread.id
272
+ }
273
+
274
+ const handoffMessage = yield* deps.threadMessage.addAgentMessage({
275
+ messageId: { tb: TABLES.THREAD_MESSAGE, id: Bun.randomUUIDv7() },
276
+ threadId: ensureRecordId(targetThread.id, TABLES.THREAD),
277
+ parts: params.handoff.parts,
278
+ metadata: params.handoff.metadata,
279
+ })
280
+
281
+ yield* deps.background.run(
282
+ deps.provideCurrentContext(
283
+ runThreadTurnInBackgroundWith(deps, {
284
+ thread: targetThread,
285
+ threadRef: ensureRecordId(targetThread.id, TABLES.THREAD),
286
+ orgRef: params.orgRef,
287
+ userRef: params.userRef,
288
+ userName: params.userName,
289
+ agentIdOverride: params.targetAgentId,
290
+ inputMessage: {
291
+ id: Bun.randomUUIDv7(),
292
+ role: 'user',
293
+ parts: params.input.parts,
294
+ metadata: params.input.metadata,
295
+ },
296
+ skipInputMessagePersistence: true,
297
+ abortSignal: params.abortSignal,
298
+ streamId: params.streamId,
299
+ }),
300
+ ),
301
+ 'thread.launchBackgroundThreadWork',
302
+ )
303
+
304
+ return {
305
+ launched: true,
306
+ threadId: targetThread.id,
307
+ threadTitle: targetThread.title,
308
+ sourceThreadId,
309
+ ...(params.targetAgentId ? { targetAgentId: params.targetAgentId } : {}),
310
+ handoffMessageId: handoffMessage.id,
311
+ createdThread,
312
+ message: buildBackgroundLaunchMessage({ createdThread, threadTitle: targetThread.title }),
313
+ } satisfies LaunchBackgroundThreadWorkResult
314
+ }).pipe(Effect.catch((error) => cleanupCreatedThread().pipe(Effect.andThen(Effect.fail(error)))))
315
+ })
316
+
317
+ function launchBackgroundThreadWorkWith(deps: ThreadTurnDeps, params: LaunchBackgroundThreadWorkParams) {
318
+ return launchBackgroundThreadWorkEffect(deps, params).pipe(
319
+ Effect.annotateSpans(
320
+ compactSpanAttributes({
321
+ ...buildThreadTurnSpanAttributes({
322
+ threadRef: params.sourceThreadId,
323
+ orgRef: params.orgRef,
324
+ userRef: params.userRef,
325
+ kind: 'background-launch',
326
+ streamId: params.streamId,
327
+ agentId: params.targetAgentId,
328
+ }),
329
+ targetThreadId: params.targetThreadId ? recordIdToString(params.targetThreadId, TABLES.THREAD) : undefined,
330
+ }),
331
+ ),
332
+ )
333
+ }
334
+
175
335
  const triggerPlanNodeTurnEffect = Effect.fn('ThreadTurn.triggerPlanNodeTurn')(function* (
176
336
  deps: ThreadTurnDeps,
177
337
  params: { runId: string; nodeId: string; abortSignal?: AbortSignal; streamId?: string },
@@ -291,6 +451,9 @@ export function makeThreadTurnService(deps: ThreadTurnDeps) {
291
451
  createThreadTurnStream(params: ThreadTurnParams) {
292
452
  return createThreadTurnStreamWith(deps, params)
293
453
  },
454
+ launchBackgroundThreadWork(params: LaunchBackgroundThreadWorkParams) {
455
+ return launchBackgroundThreadWorkWith(deps, params)
456
+ },
294
457
  runThreadTurnInBackground(params: ThreadTurnParams) {
295
458
  return runThreadTurnInBackgroundWith(deps, params)
296
459
  },
@@ -308,13 +471,28 @@ export class ThreadTurnServiceTag extends Context.Service<
308
471
  export const ThreadTurnServiceLive = Layer.effect(
309
472
  ThreadTurnServiceTag,
310
473
  Effect.gen(function* () {
474
+ const currentContext = yield* Effect.context()
475
+ const provideCurrentContext = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, never> =>
476
+ effect.pipe(Effect.provide(currentContext)) as Effect.Effect<A, E, never>
311
477
  const agentConfig = yield* AgentConfigServiceTag
478
+ const background = yield* BackgroundWorkService
312
479
  const planExecutor = yield* PlanExecutorServiceTag
313
480
  const planRun = yield* PlanRunServiceTag
314
481
  const thread = yield* ThreadServiceTag
482
+ const threadMessage = yield* ThreadMessageServiceTag
315
483
  const threadTurnPreparation = yield* ThreadTurnPreparationServiceTag
316
484
  const user = yield* UserServiceTag
317
- return makeThreadTurnService({ agentConfig, planExecutor, planRun, thread, threadTurnPreparation, user })
485
+ return makeThreadTurnService({
486
+ agentConfig,
487
+ background,
488
+ planExecutor,
489
+ planRun,
490
+ provideCurrentContext,
491
+ thread,
492
+ threadMessage,
493
+ threadTurnPreparation,
494
+ user,
495
+ })
318
496
  }),
319
497
  )
320
498
 
@@ -339,6 +517,13 @@ export const createThreadTurnStream = Effect.fn('ThreadTurn.createThreadTurnStre
339
517
  return yield* threadTurnService.createThreadTurnStream(params)
340
518
  })
341
519
 
520
+ export const launchBackgroundThreadWork = Effect.fn('ThreadTurn.launchBackgroundThreadWork')(function* (
521
+ params: LaunchBackgroundThreadWorkParams,
522
+ ) {
523
+ const threadTurnService = yield* ThreadTurnServiceTag
524
+ return yield* threadTurnService.launchBackgroundThreadWork(params)
525
+ })
526
+
342
527
  export const runThreadTurnInBackground = Effect.fn('ThreadTurn.runThreadTurnInBackground')(function* (
343
528
  params: ThreadTurnParams,
344
529
  ) {