@lota-sdk/core 0.4.11 → 0.4.12
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 +3 -3
- package/src/ai-gateway/ai-gateway.ts +65 -3
- package/src/config/model-constants.ts +1 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/live-turn-trace.ts +344 -0
- package/src/runtime/runtime-services.ts +3 -0
- package/src/services/thread/thread-turn-preparation.service.ts +45 -21
- package/src/services/thread/thread-turn-streaming.ts +41 -4
- package/src/services/thread/thread-turn.ts +188 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lota-sdk/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
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.
|
|
34
|
+
"@lota-sdk/shared": "0.4.12",
|
|
35
35
|
"@mendable/firecrawl-js": "^4.18.3",
|
|
36
36
|
"@surrealdb/node": "^3.0.3",
|
|
37
|
-
"ai": "^6.0.
|
|
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
|
|
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 (
|
|
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:
|
|
875
|
+
delta: reasoningDeltaState.delta,
|
|
814
876
|
} satisfies AiGatewayStreamPart)
|
|
815
877
|
}
|
|
816
878
|
return
|
package/src/runtime/index.ts
CHANGED
|
@@ -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,344 @@
|
|
|
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
|
+
function readLeadLineTitle(line: string): string | null {
|
|
60
|
+
const trimmed = stripMarkdownTitleDecorators(line)
|
|
61
|
+
if (!trimmed) return null
|
|
62
|
+
if (trimmed.length > 90) return null
|
|
63
|
+
if (/[.!?]$/.test(trimmed)) return null
|
|
64
|
+
return trimmed
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readFirstCompleteSentence(chunk: string): string | null {
|
|
68
|
+
const compact = normalizeWhitespace(chunk)
|
|
69
|
+
if (!compact) return null
|
|
70
|
+
const sentenceMatch = compact.match(/^(.+?[.!?])(?:\s|$)/)
|
|
71
|
+
if (sentenceMatch?.[1]) {
|
|
72
|
+
return sentenceMatch[1]
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isStableReasoningChunk(chunk: string, isLastChunk: boolean, isFinal: boolean): boolean {
|
|
78
|
+
if (!isLastChunk || isFinal) return true
|
|
79
|
+
if (chunk.includes('\n')) return true
|
|
80
|
+
return /[.!?:]\s*$/.test(chunk.trim())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function extractThinkingTitlesFromReasoning(params: { text: string; isFinal?: boolean }): string[] {
|
|
84
|
+
const cleaned = sanitizeReasoningText(params.text).trim()
|
|
85
|
+
if (cleaned.length === 0) return []
|
|
86
|
+
|
|
87
|
+
const chunks = cleaned
|
|
88
|
+
.split(/\n{2,}/)
|
|
89
|
+
.map((chunk) => chunk.trim())
|
|
90
|
+
.filter((chunk) => chunk.length > 0)
|
|
91
|
+
|
|
92
|
+
const titles: string[] = []
|
|
93
|
+
|
|
94
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
95
|
+
const isLastChunk = index === chunks.length - 1
|
|
96
|
+
if (!isStableReasoningChunk(chunk, isLastChunk, params.isFinal === true)) {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const lines = chunk
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map((line) => line.trim())
|
|
103
|
+
.filter((line) => line.length > 0)
|
|
104
|
+
if (lines.length === 0) continue
|
|
105
|
+
|
|
106
|
+
const headingTitle = readHeadingTitle(lines[0])
|
|
107
|
+
if (headingTitle) {
|
|
108
|
+
titles.push(normalizeThinkingTitle(headingTitle))
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (lines.length > 1 || params.isFinal === true) {
|
|
113
|
+
const leadLineTitle = readLeadLineTitle(lines[0])
|
|
114
|
+
if (leadLineTitle) {
|
|
115
|
+
titles.push(normalizeThinkingTitle(leadLineTitle))
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sentenceTitle = readFirstCompleteSentence(chunk)
|
|
121
|
+
if (sentenceTitle) {
|
|
122
|
+
titles.push(normalizeThinkingTitle(sentenceTitle))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return titles.filter((title) => title.length > 0)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function splitReasoningChunks(text: string, isFinal: boolean): { completedChunks: string[]; pendingChunk: string } {
|
|
130
|
+
const parts = text.split(/\n{2,}/)
|
|
131
|
+
if (isFinal) {
|
|
132
|
+
return { completedChunks: parts.map((part) => part.trim()).filter((part) => part.length > 0), pendingChunk: '' }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const completedChunks = parts
|
|
136
|
+
.slice(0, -1)
|
|
137
|
+
.map((part) => part.trim())
|
|
138
|
+
.filter((part) => part.length > 0)
|
|
139
|
+
const pendingChunk = parts.at(-1)?.trimStart() ?? ''
|
|
140
|
+
return { completedChunks, pendingChunk }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readChunkType<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
|
|
144
|
+
return readString(asRecord(chunk)?.type)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readChunkToolCallId<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
|
|
148
|
+
const record = asRecord(chunk)
|
|
149
|
+
return readString(record?.toolCallId) ?? readString(record?.id)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readChunkToolName<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
|
|
153
|
+
return readString(asRecord(chunk)?.toolName)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readChunkReasoningId<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
|
|
157
|
+
return readString(asRecord(chunk)?.id)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readChunkReasoningDelta<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
|
|
161
|
+
return readString(asRecord(chunk)?.delta)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readChunkErrorText<TMessage extends UIMessage>(chunk: StreamChunk<TMessage>): string | null {
|
|
165
|
+
const record = asRecord(chunk)
|
|
166
|
+
return readString(record?.errorText) ?? readString(record?.error)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createLiveTurnTraceStreamObserver<TMessage extends UIMessage>(params: {
|
|
170
|
+
traceId: string
|
|
171
|
+
writer: UIMessageStreamWriter<TMessage>
|
|
172
|
+
agentId?: string
|
|
173
|
+
agentName?: string
|
|
174
|
+
}) {
|
|
175
|
+
const toolNamesByCallId = new Map<string, string>()
|
|
176
|
+
const startedToolIds = new Set<string>()
|
|
177
|
+
const completedToolIds = new Set<string>()
|
|
178
|
+
const reasoningBlocks = new Map<string, ReasoningBlockState>()
|
|
179
|
+
const emittedThinkingTitles = new Set<string>()
|
|
180
|
+
let activeThinkingStep: ThinkingStepData | null = null
|
|
181
|
+
let nextThinkingStepIndex = 0
|
|
182
|
+
|
|
183
|
+
const writeActivity = (data: AgentActivityData) => {
|
|
184
|
+
const chunk = {
|
|
185
|
+
type: 'data-agent-activity',
|
|
186
|
+
id: `agent-activity:${data.activityId}`,
|
|
187
|
+
data,
|
|
188
|
+
transient: true,
|
|
189
|
+
} as unknown as StreamChunk<TMessage>
|
|
190
|
+
params.writer.write(chunk)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const writeThinkingStep = (data: ThinkingStepData) => {
|
|
194
|
+
const chunk = {
|
|
195
|
+
type: 'data-thinking-step',
|
|
196
|
+
id: `thinking-step:${data.stepId}`,
|
|
197
|
+
data,
|
|
198
|
+
transient: true,
|
|
199
|
+
} as unknown as StreamChunk<TMessage>
|
|
200
|
+
params.writer.write(chunk)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const markThinkingStepDone = () => {
|
|
204
|
+
if (!activeThinkingStep) return
|
|
205
|
+
if (activeThinkingStep.status === 'done') return
|
|
206
|
+
activeThinkingStep = { ...activeThinkingStep, status: 'done' }
|
|
207
|
+
writeThinkingStep(activeThinkingStep)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const emitThinkingTitles = (titles: string[]) => {
|
|
211
|
+
for (const title of titles) {
|
|
212
|
+
const normalizedTitle = title.toLocaleLowerCase()
|
|
213
|
+
if (emittedThinkingTitles.has(normalizedTitle)) continue
|
|
214
|
+
|
|
215
|
+
markThinkingStepDone()
|
|
216
|
+
|
|
217
|
+
const nextStep: ThinkingStepData = {
|
|
218
|
+
traceId: params.traceId,
|
|
219
|
+
stepId: `${params.traceId}:thinking:${nextThinkingStepIndex}`,
|
|
220
|
+
index: nextThinkingStepIndex,
|
|
221
|
+
title,
|
|
222
|
+
status: 'streaming',
|
|
223
|
+
}
|
|
224
|
+
nextThinkingStepIndex += 1
|
|
225
|
+
emittedThinkingTitles.add(normalizedTitle)
|
|
226
|
+
activeThinkingStep = nextStep
|
|
227
|
+
writeThinkingStep(nextStep)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const emitToolPhase = (
|
|
232
|
+
phase: AgentActivityData['phase'],
|
|
233
|
+
toolCallId: string,
|
|
234
|
+
toolName: string,
|
|
235
|
+
errorText?: string,
|
|
236
|
+
) => {
|
|
237
|
+
writeActivity({
|
|
238
|
+
traceId: params.traceId,
|
|
239
|
+
activityId: toolCallId,
|
|
240
|
+
kind: 'tool',
|
|
241
|
+
toolName,
|
|
242
|
+
toolCallId,
|
|
243
|
+
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
244
|
+
...(params.agentName ? { agentName: params.agentName } : {}),
|
|
245
|
+
phase,
|
|
246
|
+
...(errorText ? { errorText } : {}),
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const startTool = (toolCallId: string, toolName: string) => {
|
|
251
|
+
if (!toolNamesByCallId.has(toolCallId)) {
|
|
252
|
+
toolNamesByCallId.set(toolCallId, toolName)
|
|
253
|
+
}
|
|
254
|
+
if (startedToolIds.has(toolCallId) || completedToolIds.has(toolCallId)) return
|
|
255
|
+
startedToolIds.add(toolCallId)
|
|
256
|
+
emitToolPhase('started', toolCallId, toolName)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const completeTool = (toolCallId: string, phase: 'completed' | 'failed', errorText?: string) => {
|
|
260
|
+
const toolName = toolNamesByCallId.get(toolCallId)
|
|
261
|
+
if (!toolName || completedToolIds.has(toolCallId)) return
|
|
262
|
+
completedToolIds.add(toolCallId)
|
|
263
|
+
emitToolPhase(phase, toolCallId, toolName, errorText)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const processReasoningText = (reasoningId: string, delta: string, isFinal: boolean) => {
|
|
267
|
+
const state = reasoningBlocks.get(reasoningId) ?? { pendingChunk: '' }
|
|
268
|
+
const nextBuffer = state.pendingChunk + sanitizeReasoningText(delta)
|
|
269
|
+
const { completedChunks, pendingChunk } = splitReasoningChunks(nextBuffer, isFinal)
|
|
270
|
+
|
|
271
|
+
for (const completedChunk of completedChunks) {
|
|
272
|
+
emitThinkingTitles(extractThinkingTitlesFromReasoning({ text: completedChunk, isFinal: true }))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!isFinal && pendingChunk.length > 0) {
|
|
276
|
+
emitThinkingTitles(extractThinkingTitlesFromReasoning({ text: pendingChunk, isFinal: false }))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isFinal) {
|
|
280
|
+
reasoningBlocks.delete(reasoningId)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
reasoningBlocks.set(reasoningId, { pendingChunk })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
observeChunk(chunk: StreamChunk<TMessage>): void {
|
|
289
|
+
const chunkType = readChunkType(chunk)
|
|
290
|
+
if (!chunkType) return
|
|
291
|
+
|
|
292
|
+
switch (chunkType) {
|
|
293
|
+
case 'reasoning-delta': {
|
|
294
|
+
const reasoningId = readChunkReasoningId(chunk)
|
|
295
|
+
const delta = readChunkReasoningDelta(chunk)
|
|
296
|
+
if (!reasoningId || delta === null) return
|
|
297
|
+
|
|
298
|
+
processReasoningText(reasoningId, delta, false)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case 'reasoning-end': {
|
|
303
|
+
const reasoningId = readChunkReasoningId(chunk)
|
|
304
|
+
if (!reasoningId) return
|
|
305
|
+
processReasoningText(reasoningId, '', true)
|
|
306
|
+
markThinkingStepDone()
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case 'tool-input-start':
|
|
311
|
+
case 'tool-input-available':
|
|
312
|
+
case 'tool-call': {
|
|
313
|
+
const toolCallId = readChunkToolCallId(chunk)
|
|
314
|
+
const toolName = readChunkToolName(chunk)
|
|
315
|
+
if (!toolCallId || !toolName) return
|
|
316
|
+
startTool(toolCallId, toolName)
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'tool-output-available': {
|
|
321
|
+
const toolCallId = readChunkToolCallId(chunk)
|
|
322
|
+
if (!toolCallId) return
|
|
323
|
+
completeTool(toolCallId, 'completed')
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case 'tool-output-error':
|
|
328
|
+
case 'tool-error': {
|
|
329
|
+
const toolCallId = readChunkToolCallId(chunk)
|
|
330
|
+
if (!toolCallId) return
|
|
331
|
+
completeTool(toolCallId, 'failed', readChunkErrorText(chunk) ?? undefined)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
default:
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
finish(): void {
|
|
341
|
+
markThinkingStepDone()
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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({
|
|
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
|
) {
|