@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.
- package/CHANGELOG.md +125 -0
- package/LICENSE +26 -0
- package/README.md +223 -2
- package/client/index.js +1092 -0
- package/cordis.patch.yml +28 -0
- package/docs/PUBLISHING.md +87 -0
- package/docs/community-entry.yml +21 -0
- package/docs/models.md +74 -0
- package/docs/pill-composer.png +0 -0
- package/docs/protocol.md +403 -0
- package/docs/verify/live-check.mjs +406 -0
- package/icon-mask.png +0 -0
- package/icon.png +0 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/account/auth.js +297 -0
- package/src/account/credits.js +373 -0
- package/src/account/desktop-session.js +127 -0
- package/src/account/models.js +216 -0
- package/src/account/session.js +115 -0
- package/src/core/endpoints.js +78 -0
- package/src/core/http.js +124 -0
- package/src/core/paths.js +71 -0
- package/src/core/sign.js +166 -0
- package/src/index.js +429 -0
- package/src/llm/adapter.js +698 -0
- package/src/llm/endings.js +116 -0
- package/src/llm/sse.js +80 -0
- package/src/llm/wire.js +272 -0
- package/src/panel/api.js +661 -0
|
@@ -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
|
+
}
|
package/src/llm/wire.js
ADDED
|
@@ -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
|
+
}
|