@kukonce/dsh-loomy 0.0.0-stage → 0.3.2

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,116 @@
1
+ /**
2
+ * Classify how a stream ended, and turn the uninteresting cases into one stable answer.
3
+ *
4
+ * The harness acts on the terminal `finish` chunk: `stop` ends the step, `error` fails it
5
+ * (and the retry package may rerun it), `max-tokens` continues it. The gateway is not
6
+ * always explicit about which it meant, so this module applies the rules once:
7
+ *
8
+ * - `finish_reason: stop|end_turn|stop_sequence` -> stop
9
+ * - `finish_reason: length` -> max-tokens
10
+ * - `finish_reason: content_filter` -> error, INVALID_REQUEST (do not retry)
11
+ * - `finish_reason: tool_calls` -> stop (the calls are the answer)
12
+ * - no `[DONE]` and no `finish_reason` -> error, TRANSPORT (retryable)
13
+ * - `[DONE]` with no reason at all -> stop
14
+ *
15
+ * The one judgement call is the third-from-last rule: a connection dropped mid-answer
16
+ * looks exactly like an empty answer otherwise, and silently accepting it would hand the
17
+ * user a truncated reply with no indication anything went wrong.
18
+ */
19
+
20
+ /**
21
+ * @param {object} facts
22
+ * @param {boolean} facts.streamEnded `[DONE]` was seen
23
+ * @param {string|undefined} facts.finishReason
24
+ * @param {boolean} facts.argsTruncated a tool call's arguments were cut mid-JSON
25
+ * @param {number} facts.orphanCalls fragments that never carried a tool name
26
+ * @param {number} facts.blockCount how many blocks were delivered
27
+ * @param {boolean} facts.hasTools at least one complete tool call was delivered
28
+ * @param {string} provider
29
+ * @returns {{ kind: 'stop'|'error'|'max-tokens', failure?: object, note?: string }}
30
+ */
31
+ export function classifyEnding(facts, provider) {
32
+ const { streamEnded, finishReason, argsTruncated, orphanCalls, blockCount, hasTools } = facts
33
+
34
+ // The arguments of a tool call were cut mid-JSON: the call cannot be executed, so this
35
+ // step is really a budget truncation (the harness can continue it) rather than an answer.
36
+ if (argsTruncated) {
37
+ return {
38
+ kind: 'max-tokens',
39
+ note: 'args-budget',
40
+ failure: undefined
41
+ }
42
+ }
43
+
44
+ if (finishReason === 'length') {
45
+ return { kind: 'max-tokens', note: blockCount === 0 ? 'empty-budget' : 'budget' }
46
+ }
47
+
48
+ if (finishReason === 'content_filter') {
49
+ return {
50
+ kind: 'error',
51
+ note: 'filtered',
52
+ failure: { message: `${provider}: 上游以内容审核中断了本次回答(finish_reason=content_filter)`, code: 'INVALID_REQUEST' }
53
+ }
54
+ }
55
+
56
+ if (finishReason === 'tool_calls' || hasTools) return { kind: 'stop', note: 'tools' }
57
+
58
+ if (finishReason === 'stop' || finishReason === 'end_turn' || finishReason === 'stop_sequence') {
59
+ return { kind: 'stop', note: 'stop' }
60
+ }
61
+
62
+ // A call fragment whose name never arrived cannot be announced to the harness, so the
63
+ // step would read as "the model chose to stop" while a call was in flight. That is a
64
+ // transport problem, not an answer.
65
+ if (orphanCalls > 0) {
66
+ return {
67
+ kind: 'error',
68
+ note: 'orphan-tool',
69
+ failure: { message: `${provider}: 收到工具调用分片但没有工具名(finish_reason=${finishReason ?? '(无)'}),可重试`, code: 'TRANSPORT' }
70
+ }
71
+ }
72
+
73
+ if (streamEnded) {
74
+ return { kind: 'stop', note: 'done-without-reason' }
75
+ }
76
+
77
+ return {
78
+ kind: 'error',
79
+ note: 'cut',
80
+ failure: { message: `${provider}: 响应在 finish_reason/[DONE] 之前中断,可重试`, code: 'TRANSPORT' }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Whether a tool call's accumulated argument text is syntactically incomplete.
86
+ *
87
+ * Checked by parsing rather than by brace counting: the arguments are a JSON object and
88
+ * nothing else, so "parses" is exactly the right predicate.
89
+ */
90
+ export function isTruncatedArguments(text) {
91
+ const trimmed = String(text ?? '').trim()
92
+ if (trimmed.length === 0) return false
93
+ try {
94
+ JSON.parse(trimmed)
95
+ return false
96
+ } catch {
97
+ return true
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Normalize the argument string handed to the harness.
103
+ *
104
+ * The harness expects the raw JSON string; an empty or unparseable one becomes '{}' so
105
+ * the tool sees an empty argument object instead of a parse error.
106
+ */
107
+ export function normalizeToolArguments(raw) {
108
+ const trimmed = String(raw ?? '').trim()
109
+ if (trimmed.length === 0) return '{}'
110
+ try {
111
+ const parsed = JSON.parse(trimmed)
112
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? JSON.stringify(parsed) : '{}'
113
+ } catch {
114
+ return '{}'
115
+ }
116
+ }
package/src/llm/sse.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Server-sent-events reader for the Loomy gateway.
3
+ *
4
+ * The gateway answers with the ordinary OpenAI shape -- `data: {json}` lines, `data:
5
+ * [DONE]` to terminate -- with one wrinkle that matters: it also sends blank separator
6
+ * lines and, on a proxy error, an HTML page where the stream should be. Neither may be
7
+ * allowed to throw, because a throw from the middle of a generator loses whatever had
8
+ * already been yielded.
9
+ */
10
+
11
+ export const SSE_DONE = Symbol('sse-done')
12
+
13
+ /**
14
+ * Yield each `data:` payload as a string, and {SSE_DONE} once `[DONE]` arrives.
15
+ *
16
+ * `state` accumulates byte and timing facts the adapter uses to describe a stream that
17
+ * was cut before its terminator -- `receivedBytes`, `lastDataAt`. Passing the object in
18
+ * rather than returning them keeps the yield type a plain string.
19
+ */
20
+ export async function* readSse(response, state = { receivedBytes: 0, lastDataAt: Date.now() }) {
21
+ if (response.body === null) return
22
+ const decoder = new TextDecoder('utf-8')
23
+ let buffer = ''
24
+ for await (const chunk of response.body) {
25
+ const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
26
+ state.receivedBytes += bytes.byteLength
27
+ state.lastDataAt = Date.now()
28
+ buffer += decoder.decode(bytes, { stream: true })
29
+ // Events are separated by a blank line; accept a bare newline too, because the
30
+ // gateway has emitted single-`\n` framing and a strict parser then never fires.
31
+ let boundary
32
+ while ((boundary = findEventBoundary(buffer)) !== -1) {
33
+ const event = buffer.slice(0, boundary.index)
34
+ buffer = buffer.slice(boundary.index + boundary.length)
35
+ const payload = eventPayload(event)
36
+ if (payload === undefined) continue
37
+ if (payload === '[DONE]') {
38
+ yield SSE_DONE
39
+ return
40
+ }
41
+ yield payload
42
+ }
43
+ }
44
+ // A stream that ends without its trailing blank line still has one final event.
45
+ const tail = buffer.trim()
46
+ if (tail.length > 0) {
47
+ const payload = eventPayload(tail)
48
+ if (payload === '[DONE]') yield SSE_DONE
49
+ else if (payload !== undefined) yield payload
50
+ }
51
+ }
52
+
53
+ /** Index just past the next event separator, or -1. */
54
+ function findEventBoundary(buffer) {
55
+ const doubleNewline = buffer.indexOf('\n\n')
56
+ const doubleCarriage = buffer.indexOf('\r\n\r\n')
57
+ const candidates = []
58
+ if (doubleNewline !== -1) candidates.push({ index: doubleNewline, length: 2 })
59
+ if (doubleCarriage !== -1) candidates.push({ index: doubleCarriage, length: 4 })
60
+ if (candidates.length === 0) return -1
61
+ candidates.sort((left, right) => left.index - right.index)
62
+ return candidates[0]
63
+ }
64
+
65
+ /**
66
+ * The concatenated `data:` payload of one event block, or undefined when it has none.
67
+ *
68
+ * Multi-line `data:` fields are joined with a newline, per the event-stream spec; the
69
+ * gateway does not currently split a JSON object across lines, but a proxy might.
70
+ */
71
+ function eventPayload(event) {
72
+ const dataLines = []
73
+ for (const rawLine of event.split('\n')) {
74
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
75
+ if (!line.startsWith('data:')) continue
76
+ dataLines.push(line.slice(5).replace(/^ /u, ''))
77
+ }
78
+ if (dataLines.length === 0) return undefined
79
+ return dataLines.join('\n')
80
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Harness messages -> OpenAI-compatible wire messages for the Loomy gateway.
3
+ *
4
+ * Split out of the adapter so the conversion can be read and tested on its own: the
5
+ * bugs that live here are always the same two -- a tool result that arrives as its own
6
+ * message gets folded into a user turn (and the model loses which call it answers), or
7
+ * a tool call whose result never came back gets sent anyway (and the gateway rejects
8
+ * the whole request as malformed).
9
+ *
10
+ * The harness's vocabulary is richer than OpenAI's; everything here is a lossy
11
+ * projection, and the places where it loses information are commented.
12
+ */
13
+
14
+ const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:'
15
+
16
+ /** Concatenated text of a content array, ignoring non-text blocks. */
17
+ export function contentToText(content) {
18
+ if (typeof content === 'string') return content
19
+ if (!Array.isArray(content)) return ''
20
+ return content
21
+ .filter((block) => typeof block === 'object' && block !== null && block.type === 'text')
22
+ .map((block) => String(block.text ?? ''))
23
+ .join('')
24
+ }
25
+
26
+ /**
27
+ * Tool arguments must reach the gateway as a JSON **string** that parses to an object.
28
+ * A malformed one is sent as '{}' rather than as the raw text: the gateway rejects the
29
+ * turn otherwise, and a rejected turn is a worse outcome than a call with no arguments
30
+ * (which the tool itself then reports).
31
+ */
32
+ export function normalizeToolArguments(raw) {
33
+ const trimmed = String(raw ?? '').trim()
34
+ if (trimmed.length === 0) return '{}'
35
+ try {
36
+ const parsed = JSON.parse(trimmed)
37
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? trimmed : '{}'
38
+ } catch {
39
+ return '{}'
40
+ }
41
+ }
42
+
43
+ export const hasUsableToolName = (name) => typeof name === 'string' && name.trim().length > 0
44
+
45
+ /**
46
+ * The call id a result message answers.
47
+ *
48
+ * The harness answers a call with a message of its own: role 'tool' plus
49
+ * source.callId. Older transcripts put the result inside the content blocks as a
50
+ * tool-result block carrying toolCallId. Both are read so neither shape loses its
51
+ * pairing.
52
+ */
53
+ export function toolResultCallId(message) {
54
+ if (message?.role !== 'tool') return undefined
55
+ const id = message.source?.callId ?? message.toolCallId ?? message.tool_call_id
56
+ return id === undefined || id === null ? undefined : String(id)
57
+ }
58
+
59
+ /**
60
+ * Keep only tool call/result pairs that are complete on **both** sides.
61
+ *
62
+ * A call with no result is a step that was interrupted; sending it makes the
63
+ * conversation invalid for every later turn, because OpenAI-compatible servers require
64
+ * every tool_calls entry to be answered before the next user message. Dropping the
65
+ * pair loses that one step and keeps the rest of the conversation usable.
66
+ */
67
+ export function resolveToolPairing(messages) {
68
+ const allResultIds = new Set()
69
+ for (const message of messages) {
70
+ const standalone = toolResultCallId(message)
71
+ if (standalone !== undefined) { allResultIds.add(standalone); continue }
72
+ for (const block of Array.isArray(message.content) ? message.content : []) {
73
+ if (block?.type === 'tool-result') allResultIds.add(String(block.toolCallId))
74
+ }
75
+ }
76
+ const keepCallIds = new Set()
77
+ for (const message of messages) {
78
+ if (message.role !== 'assistant') continue
79
+ const calls = (Array.isArray(message.content) ? message.content : [])
80
+ .filter((block) => block?.type === 'tool-call' && hasUsableToolName(block.name))
81
+ if (calls.length > 0 && calls.every((block) => allResultIds.has(String(block.id)))) {
82
+ for (const block of calls) keepCallIds.add(String(block.id))
83
+ }
84
+ }
85
+ return { keepCallIds, keepResultIds: new Set(keepCallIds) }
86
+ }
87
+
88
+ /**
89
+ * One user-side content array in OpenAI's shape, or undefined when it holds no image
90
+ * and should therefore be sent as a plain string.
91
+ *
92
+ * Images arrive as attachment references that imageUrls resolves to data URLs. A
93
+ * reference that failed to resolve becomes '[image unavailable]' text rather than a
94
+ * missing part -- the model then knows an image was meant to be there, which is better
95
+ * than silently answering as if the message were text-only.
96
+ */
97
+ function userContentParts(content, imageUrls) {
98
+ const parts = []
99
+ let hasImage = false
100
+ for (const block of content) {
101
+ if (typeof block !== 'object' || block === null) continue
102
+ if (block.type === 'text') {
103
+ parts.push({ type: 'text', text: String(block.text ?? '') })
104
+ continue
105
+ }
106
+ if (block.type === 'image') {
107
+ const attachmentId = block.attachment?.attachmentId
108
+ const url = attachmentId === undefined ? undefined : imageUrls?.get(String(attachmentId))
109
+ parts.push(url === undefined ? { type: 'text', text: '[image unavailable]' } : { type: 'image_url', image_url: { url } })
110
+ hasImage = true
111
+ continue
112
+ }
113
+ if (block.type === 'tool-result' && Array.isArray(block.content)) {
114
+ const inner = userContentParts(block.content, imageUrls)
115
+ if (inner !== undefined) {
116
+ parts.push(...inner)
117
+ hasImage = true
118
+ } else {
119
+ parts.push({ type: 'text', text: contentToText(block.content) })
120
+ }
121
+ }
122
+ }
123
+ return hasImage && parts.length > 0 ? parts : undefined
124
+ }
125
+
126
+ /**
127
+ * The reasoning_content one assistant turn should send back, if any.
128
+ *
129
+ * **Only the newest assistant turn qualifies.** Replaying older thinking is what made a
130
+ * long session unusable rather than what kept it coherent: the model's own prior
131
+ * reasoning is re-derivable from the conversation it is written into, while the payload
132
+ * cost grows linearly with the number of steps and is paid on *every* turn.
133
+ *
134
+ * Measured 2026-11-11 against the live gateway, replayed history with reasoning:
135
+ *
136
+ * 0 turns -> first token 1.5 s, total 4.2 s
137
+ * 30 turns -> first token 3.5 s, total 37.6 s
138
+ * 60 turns -> first token 3.1 s, total 48.4 s
139
+ * 100 turns -> first token 14.7 s, total 61.3 s (245 kB body)
140
+ *
141
+ * and an earlier 100-turn replay tripped `503 service is busy`. That curve is exactly
142
+ * the user-visible "message sent but nothing happens for a minute".
143
+ *
144
+ * The newest turn still matters: during a tool round-trip the model has just said "I am
145
+ * calling X to find Y" and needs to still be holding that plan when it reads the result.
146
+ * Dropping every turn would make a tool-using step restart its thinking mid-flight, so
147
+ * the newest one is kept and the cost stays flat instead of accumulating.
148
+ */
149
+ function reasoningOf(message, newestAssistantId) {
150
+ if (message.id !== newestAssistantId) return ''
151
+ const content = Array.isArray(message.content) ? message.content : []
152
+ return content
153
+ .filter((block) => block?.type === 'reasoning')
154
+ .map((block) => String(block.text ?? ''))
155
+ .join('')
156
+ }
157
+
158
+ /** The id (or index fallback) of the last assistant message in the request. */
159
+ function newestAssistantIdOf(messages) {
160
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
161
+ const message = messages[index]
162
+ if (message?.role === 'assistant') return message.id ?? `@index:${index}`
163
+ }
164
+ return ''
165
+ }
166
+
167
+ /**
168
+ * Convert harness messages into the wire array.
169
+ *
170
+ * Reasoning is carried back for the newest assistant turn only; see {@link reasoningOf}
171
+ * for the measurement behind that rule.
172
+ */
173
+ export function toWireMessages(messages, imageUrls) {
174
+ const wire = []
175
+ let pendingToolImages = []
176
+ const flushToolImages = () => {
177
+ if (pendingToolImages.length === 0) return
178
+ wire.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
179
+ pendingToolImages = []
180
+ }
181
+ const { keepCallIds, keepResultIds } = resolveToolPairing(messages)
182
+ const newestAssistantId = newestAssistantIdOf(messages)
183
+
184
+ // The identity used above falls back to a positional key when a synthetic message
185
+ // carries no id, so the same fallback has to be applied when reading it back.
186
+ const reasoningFor = (message, index) => reasoningOf(
187
+ { ...message, id: message.id ?? `@index:${index}` },
188
+ newestAssistantId
189
+ )
190
+
191
+ for (let index = 0; index < messages.length; index += 1) {
192
+ const message = messages[index]
193
+ if (message.role === 'assistant') {
194
+ const content = Array.isArray(message.content) ? message.content : []
195
+ const toolCalls = content
196
+ .filter((block) => block?.type === 'tool-call' && keepCallIds.has(String(block.id)))
197
+ .map((block) => ({
198
+ id: String(block.id),
199
+ type: 'function',
200
+ function: { name: String(block.name), arguments: normalizeToolArguments(block.arguments) }
201
+ }))
202
+ const textContent = contentToText(content)
203
+ if (textContent.trim().length === 0 && toolCalls.length === 0) continue
204
+ flushToolImages()
205
+ const reasoning = reasoningFor(message, index)
206
+ wire.push({
207
+ role: 'assistant',
208
+ content: textContent.length === 0 && toolCalls.length > 0 ? null : textContent,
209
+ ...(reasoning.length > 0 ? { reasoning_content: reasoning } : {}),
210
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {})
211
+ })
212
+ continue
213
+ }
214
+ if (message.role === 'system' || message.role === 'developer') {
215
+ flushToolImages()
216
+ wire.push({ role: 'system', content: contentToText(message.content) })
217
+ continue
218
+ }
219
+ // A tool result arrives as a message of its own and must stay a tool message.
220
+ const resultCallId = toolResultCallId(message)
221
+ if (resultCallId !== undefined) {
222
+ if (!keepResultIds.has(resultCallId)) continue
223
+ let resultText = '(no output)'
224
+ if (imageUrls !== undefined && Array.isArray(message.content)) {
225
+ const resultParts = userContentParts(message.content, imageUrls)
226
+ if (resultParts !== undefined) {
227
+ pendingToolImages.push(...resultParts.filter((part) => part.type !== 'text'))
228
+ const joined = resultParts.filter((part) => part.type === 'text').map((part) => String(part.text)).join('')
229
+ if (joined.length > 0) resultText = joined
230
+ } else {
231
+ resultText = contentToText(message.content) || '(no output)'
232
+ }
233
+ } else {
234
+ resultText = contentToText(message.content) || '(no output)'
235
+ }
236
+ wire.push({ role: 'tool', tool_call_id: resultCallId, content: resultText })
237
+ continue
238
+ }
239
+
240
+ const content = Array.isArray(message.content) ? message.content : []
241
+ const toolResults = content.filter((block) => block?.type === 'tool-result')
242
+ const regular = content.filter((block) => block?.type !== 'tool-result')
243
+ const text = contentToText(message.content)
244
+ const parts = userContentParts(regular, imageUrls)
245
+ if (parts !== undefined) {
246
+ flushToolImages()
247
+ wire.push({ role: 'user', content: parts })
248
+ } else if (text.trim().length > 0) {
249
+ flushToolImages()
250
+ wire.push({ role: 'user', content: text })
251
+ }
252
+ for (const result of toolResults) {
253
+ if (!keepResultIds.has(String(result.toolCallId))) continue
254
+ let resultText = '(no output)'
255
+ if (imageUrls !== undefined && Array.isArray(result.content)) {
256
+ const resultParts = userContentParts(result.content, imageUrls)
257
+ if (resultParts !== undefined) {
258
+ pendingToolImages.push(...resultParts.filter((part) => part.type !== 'text'))
259
+ const joined = resultParts.filter((part) => part.type === 'text').map((part) => String(part.text)).join('')
260
+ if (joined.length > 0) resultText = joined
261
+ } else {
262
+ resultText = contentToText(result.content) || '(no output)'
263
+ }
264
+ } else {
265
+ resultText = contentToText(result.content) || '(no output)'
266
+ }
267
+ wire.push({ role: 'tool', tool_call_id: String(result.toolCallId), content: resultText })
268
+ }
269
+ }
270
+ flushToolImages()
271
+ return wire
272
+ }