@huai_xin/dsh-workbuddy 0.1.15 → 0.1.16
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/client/index.js +4 -1
- package/package.json +1 -1
- package/src/adapter.js +77 -16
- package/src/credits.js +4 -1
package/client/index.js
CHANGED
|
@@ -1378,7 +1378,10 @@ window.__ModuleLoader__.load({
|
|
|
1378
1378
|
value: query,
|
|
1379
1379
|
placeholder: t('search'),
|
|
1380
1380
|
'aria-label': t('search'),
|
|
1381
|
-
|
|
1381
|
+
// Keyed on what the region *has*, not on what the query matched: with
|
|
1382
|
+
// the filtered list a zero-match search disabled its own input and the
|
|
1383
|
+
// only way out was closing the panel.
|
|
1384
|
+
disabled: (models?.list ?? []).length === 0,
|
|
1382
1385
|
onChange: (event) => setSearches((current) => ({ ...current, [region.id]: event.target.value }))
|
|
1383
1386
|
})),
|
|
1384
1387
|
h('button', {
|
package/package.json
CHANGED
package/src/adapter.js
CHANGED
|
@@ -176,9 +176,25 @@ function isTruncatedArguments(raw) {
|
|
|
176
176
|
const hasUsableToolName = (name) => typeof name === 'string' && name.trim().length > 0
|
|
177
177
|
|
|
178
178
|
/** Keep only tool call/result pairs that are complete on both sides. */
|
|
179
|
+
/**
|
|
180
|
+
* The call id a result message answers.
|
|
181
|
+
*
|
|
182
|
+
* The harness answers a call with a message of its own: `{ role: 'tool', source: {
|
|
183
|
+
* kind: 'tool', callId } }`. Older transcripts put the result inside the content
|
|
184
|
+
* blocks as `{ type: 'tool-result', toolCallId }`; both are read here so neither
|
|
185
|
+
* shape loses its pairing.
|
|
186
|
+
*/
|
|
187
|
+
function toolResultCallId(message) {
|
|
188
|
+
if (message?.role !== 'tool') return undefined
|
|
189
|
+
const id = message.source?.callId ?? message.toolCallId ?? message.tool_call_id
|
|
190
|
+
return id === undefined || id === null ? undefined : String(id)
|
|
191
|
+
}
|
|
192
|
+
|
|
179
193
|
function resolveToolPairing(messages) {
|
|
180
194
|
const allResultIds = new Set()
|
|
181
195
|
for (const message of messages) {
|
|
196
|
+
const standalone = toolResultCallId(message)
|
|
197
|
+
if (standalone !== undefined) { allResultIds.add(standalone); continue }
|
|
182
198
|
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
183
199
|
if (block?.type === 'tool-result') allResultIds.add(String(block.toolCallId))
|
|
184
200
|
}
|
|
@@ -261,11 +277,32 @@ function toWireMessages(messages, imageUrls) {
|
|
|
261
277
|
})
|
|
262
278
|
continue
|
|
263
279
|
}
|
|
264
|
-
if (message.role === 'system') {
|
|
280
|
+
if (message.role === 'system' || message.role === 'developer') {
|
|
265
281
|
flushToolImages()
|
|
266
282
|
wire.push({ role: 'system', content: contentToText(message.content) })
|
|
267
283
|
continue
|
|
268
284
|
}
|
|
285
|
+
// A tool result arrives as a message of its own (see `toolResultCallId`); it must
|
|
286
|
+
// stay a `tool` message, or the model loses which call it answers.
|
|
287
|
+
const resultCallId = toolResultCallId(message)
|
|
288
|
+
if (resultCallId !== undefined) {
|
|
289
|
+
if (!keepResultIds.has(resultCallId)) continue
|
|
290
|
+
let resultText = '(no output)'
|
|
291
|
+
if (imageUrls !== undefined && Array.isArray(message.content)) {
|
|
292
|
+
const resultParts = userContentParts(message.content, imageUrls)
|
|
293
|
+
if (resultParts !== undefined) {
|
|
294
|
+
pendingToolImages.push(...resultParts.filter((part) => part.type !== 'text'))
|
|
295
|
+
const joined = resultParts.filter((part) => part.type === 'text').map((part) => String(part.text)).join('')
|
|
296
|
+
if (joined.length > 0) resultText = joined
|
|
297
|
+
} else {
|
|
298
|
+
resultText = contentToText(message.content) || '(no output)'
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
resultText = contentToText(message.content) || '(no output)'
|
|
302
|
+
}
|
|
303
|
+
wire.push({ role: 'tool', tool_call_id: resultCallId, content: resultText })
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
269
306
|
|
|
270
307
|
const content = Array.isArray(message.content) ? message.content : []
|
|
271
308
|
const toolResults = content.filter((block) => block?.type === 'tool-result')
|
|
@@ -1014,20 +1051,14 @@ export class WorkBuddyAdapter {
|
|
|
1014
1051
|
lastDelta = '工具调用'
|
|
1015
1052
|
const fragment = call.function?.arguments ?? ''
|
|
1016
1053
|
block.text += fragment
|
|
1054
|
+
// The arguments are collected, not streamed on: a call whose arguments are
|
|
1055
|
+
// cut short must not reach the harness at all, and once a delta has been
|
|
1056
|
+
// emitted there is no taking it back (the assembler keeps an open tool
|
|
1057
|
+
// block). Complete calls are handed over in the block assembly below.
|
|
1017
1058
|
if (!block.announced) {
|
|
1018
1059
|
if (!hasUsableToolName(block.name)) continue
|
|
1019
1060
|
block.announced = true
|
|
1020
1061
|
toolOrder.push(block.index)
|
|
1021
|
-
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
|
1022
|
-
yield { type: 'tool-call-delta', index: block.index, id: callId, name: block.name, argumentsDelta: block.text }
|
|
1023
|
-
continue
|
|
1024
|
-
}
|
|
1025
|
-
yield {
|
|
1026
|
-
type: 'tool-call-delta',
|
|
1027
|
-
index: block.index,
|
|
1028
|
-
id: callId,
|
|
1029
|
-
...(block.name === undefined ? {} : { name: block.name }),
|
|
1030
|
-
argumentsDelta: fragment
|
|
1031
1062
|
}
|
|
1032
1063
|
}
|
|
1033
1064
|
if (data.usage !== undefined && data.usage !== null) {
|
|
@@ -1047,6 +1078,22 @@ export class WorkBuddyAdapter {
|
|
|
1047
1078
|
}
|
|
1048
1079
|
}
|
|
1049
1080
|
}
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
// `reader.read()` throws on a real socket failure (undici reports
|
|
1083
|
+
// `TypeError: terminated`). Left alone the harness classifies that as UNKNOWN and
|
|
1084
|
+
// does not retry, so a connection reset mid-answer would fail instead of
|
|
1085
|
+
// recovering. A user abort is different and is rethrown untouched.
|
|
1086
|
+
if (options.signal?.aborted === true) throw error
|
|
1087
|
+
const detail = describeCut({ receivedBytes, startedAt, lastDataAt, lastDelta })
|
|
1088
|
+
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的响应在网络层中断(${detail};${error instanceof Error ? error.message : String(error)}),按传输错误上报,可自动重试`)
|
|
1089
|
+
yield finishChunk({
|
|
1090
|
+
kind: 'error',
|
|
1091
|
+
failure: {
|
|
1092
|
+
message: `${this.region.provider}: 响应流在网络层中断(${detail};可重试的传输错误)`,
|
|
1093
|
+
code: 'TRANSPORT'
|
|
1094
|
+
}
|
|
1095
|
+
})
|
|
1096
|
+
return
|
|
1050
1097
|
} finally {
|
|
1051
1098
|
reader.releaseLock()
|
|
1052
1099
|
}
|
|
@@ -1058,9 +1105,12 @@ export class WorkBuddyAdapter {
|
|
|
1058
1105
|
// A call whose arguments never finished arriving cannot be executed: handed
|
|
1059
1106
|
// over, it becomes a parse error in the transcript instead of the truncation
|
|
1060
1107
|
// that actually happened. It is reported as `max-tokens` below instead, so
|
|
1061
|
-
// the step can be continued rather than failed.
|
|
1108
|
+
// the step can be continued rather than failed. Its deltas were withheld above,
|
|
1109
|
+
// so nothing reaches the assembler for it.
|
|
1062
1110
|
if (isTruncatedArguments(block.text)) continue
|
|
1063
1111
|
blockCount += 1
|
|
1112
|
+
yield { type: 'block-start', index, blockType: 'tool-call' }
|
|
1113
|
+
yield { type: 'tool-call-delta', index, id: block.callId, name: block.name, argumentsDelta: block.text }
|
|
1064
1114
|
yield {
|
|
1065
1115
|
type: 'block-end',
|
|
1066
1116
|
index,
|
|
@@ -1138,15 +1188,15 @@ export class WorkBuddyAdapter {
|
|
|
1138
1188
|
yield finishChunk({ kind: 'max-tokens' })
|
|
1139
1189
|
return
|
|
1140
1190
|
}
|
|
1141
|
-
if (blockCount === 0) {
|
|
1142
|
-
yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
|
|
1143
|
-
return
|
|
1144
|
-
}
|
|
1145
1191
|
// `finish_reason` has more values than the three the OpenAI docs mention, and
|
|
1146
1192
|
// the official CodeBuddy CLI maps the whole set (`mapFinishReasonToStopReason`
|
|
1147
1193
|
// in its bundle): `refusal` / `content_filter` are their own outcome, not a
|
|
1148
1194
|
// stop, and `error` means the provider gave up mid-answer. Mapping those to
|
|
1149
1195
|
// `stop` is how a refusal and a real finish became indistinguishable here.
|
|
1196
|
+
//
|
|
1197
|
+
// These are checked *before* the empty-response rule: a refusal or a provider
|
|
1198
|
+
// error with no text is still a refusal or an error, and calling it
|
|
1199
|
+
// `EMPTY_RESPONSE` invites a retry that would be answered the same way.
|
|
1150
1200
|
const refusalReason = finishReason === 'refusal' || finishReason === 'content_filter'
|
|
1151
1201
|
if (refusalReason) {
|
|
1152
1202
|
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 的上游拒绝/过滤了本次回答(finish_reason=${finishReason}),不重试`)
|
|
@@ -1170,6 +1220,17 @@ export class WorkBuddyAdapter {
|
|
|
1170
1220
|
})
|
|
1171
1221
|
return
|
|
1172
1222
|
}
|
|
1223
|
+
// A budget end with no text and no usable tool call is a truncation, not an empty
|
|
1224
|
+
// answer: report `max-tokens` so the harness continues the step.
|
|
1225
|
+
if (blockCount === 0) {
|
|
1226
|
+
if (finishReason === 'length' || finishReason === 'max_tokens') {
|
|
1227
|
+
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 在输出上限处结束且没有可交付内容,本步按 max-tokens 上报(harness 可续跑)`)
|
|
1228
|
+
yield finishChunk({ kind: 'max-tokens' })
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
yield finishChunk({ kind: 'error', failure: { message: '模型返回了空响应', code: 'EMPTY_RESPONSE' } })
|
|
1232
|
+
return
|
|
1233
|
+
}
|
|
1173
1234
|
const toolReason = finishReason === 'tool_calls' || finishReason === 'tool_use' || finishReason === 'function_call'
|
|
1174
1235
|
const reason = toolReason || toolOrder.length > 0
|
|
1175
1236
|
? { kind: 'tool-calls' }
|
package/src/credits.js
CHANGED
|
@@ -122,7 +122,10 @@ export async function fetchCredits(ctx, region) {
|
|
|
122
122
|
for (const entry of accounts) {
|
|
123
123
|
if (typeof entry !== 'object' || entry === null) continue
|
|
124
124
|
const active = packageActive(entry, now)
|
|
125
|
-
|
|
125
|
+
// A remaining balance of 0 is a real value: a spent package must not fall through
|
|
126
|
+
// to a second field (or read as a missing one) and make the account look funded.
|
|
127
|
+
const cycleRemain = preciseNumber(entry, 'CycleCapacityRemain')
|
|
128
|
+
const remaining = cycleRemain ?? preciseNumber(entry, 'CapacityRemain') ?? 0
|
|
126
129
|
const grantedBy = attributeOf(entry, 'grantSource')
|
|
127
130
|
if (!active) continue
|
|
128
131
|
total += remaining
|