@huai_xin/dsh-workbuddy 0.1.10 → 0.1.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 +1 -1
- package/src/adapter.js +69 -24
package/package.json
CHANGED
package/src/adapter.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* X-Product-Code: workbuddy, X-Agent-Purpose/Name/Type/Version, UA.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
16
17
|
import { writeFileSync } from 'node:fs'
|
|
17
18
|
import { tmpdir } from 'node:os'
|
|
18
19
|
import { join } from 'node:path'
|
|
@@ -321,7 +322,7 @@ export class WorkBuddyAdapter {
|
|
|
321
322
|
constructor(ctx, region) {
|
|
322
323
|
this.ctx = ctx
|
|
323
324
|
this.region = region
|
|
324
|
-
this.sessionId =
|
|
325
|
+
this.sessionId = randomUUID().replace(/-/g, '')
|
|
325
326
|
this.catalog = undefined
|
|
326
327
|
this.catalogAt = 0
|
|
327
328
|
/** Where the current catalog came from: 'scoped' | 'config' | 'cache' | 'none'. */
|
|
@@ -358,7 +359,7 @@ export class WorkBuddyAdapter {
|
|
|
358
359
|
|
|
359
360
|
/** A fresh conversation identity, used when the gateway rejects the old one. */
|
|
360
361
|
rotateIdentity() {
|
|
361
|
-
this.sessionId =
|
|
362
|
+
this.sessionId = randomUUID().replace(/-/g, '')
|
|
362
363
|
}
|
|
363
364
|
|
|
364
365
|
/**
|
|
@@ -692,7 +693,24 @@ export class WorkBuddyAdapter {
|
|
|
692
693
|
return chatRoutes(this.region)
|
|
693
694
|
}
|
|
694
695
|
|
|
695
|
-
|
|
696
|
+
/**
|
|
697
|
+
* Request headers, aligned with the official CodeBuddy CLI.
|
|
698
|
+
*
|
|
699
|
+
* The gateway validates them: a request without `X-Agent-Purpose` /
|
|
700
|
+
* `X-Conversation-*` / `X-IDE-*` is rejected with `400 code 11128 request illegal`
|
|
701
|
+
* (measured 2026-10-23), so these are not decoration. Three details come from the
|
|
702
|
+
* official bundle and were previously missing or wrong here:
|
|
703
|
+
*
|
|
704
|
+
* - `X-Agent-Intent` defaults to `craft` ("do the work" mode) there; omitting it
|
|
705
|
+
* leaves the deployment to choose, which is the difference between an agent that
|
|
706
|
+
* acts and one that only narrates.
|
|
707
|
+
* - `X-Conversation-Message-ID` / `X-Request-ID` identify *the message being
|
|
708
|
+
* answered*, so they are derived from that message and stay stable when the same
|
|
709
|
+
* step is retried, instead of a fresh random UUID per attempt.
|
|
710
|
+
* - `X-Agent-Runtime` / `X-Session-ID` / `X-Model-ID` say which runtime and model
|
|
711
|
+
* the route is on.
|
|
712
|
+
*/
|
|
713
|
+
headers(token, route, model, messageId) {
|
|
696
714
|
return {
|
|
697
715
|
Authorization: `Bearer ${token}`,
|
|
698
716
|
Accept: 'text/event-stream',
|
|
@@ -701,21 +719,42 @@ export class WorkBuddyAdapter {
|
|
|
701
719
|
'X-Product': 'SaaS',
|
|
702
720
|
'X-Product-Code': route.productCode,
|
|
703
721
|
'X-Agent-Purpose': 'conversation',
|
|
722
|
+
'X-Agent-Intent': 'craft',
|
|
723
|
+
'X-Agent-Runtime': 'local-agent',
|
|
704
724
|
'X-IDE-Name': route.ideName,
|
|
705
725
|
'X-IDE-Type': route.ideName,
|
|
706
726
|
'X-IDE-Version': route.ideVersion,
|
|
707
727
|
'User-Agent': route.userAgent,
|
|
728
|
+
'X-Session-ID': this.sessionId,
|
|
729
|
+
'X-Model-ID': model,
|
|
708
730
|
'X-Conversation-ID': this.sessionId,
|
|
709
|
-
'X-Conversation-Message-ID':
|
|
710
|
-
'X-Conversation-Request-ID':
|
|
731
|
+
'X-Conversation-Message-ID': messageId,
|
|
732
|
+
'X-Conversation-Request-ID': messageId,
|
|
733
|
+
'X-Request-ID': messageId
|
|
711
734
|
}
|
|
712
735
|
}
|
|
713
736
|
|
|
714
|
-
|
|
737
|
+
/**
|
|
738
|
+
* A stable id for the message being answered.
|
|
739
|
+
*
|
|
740
|
+
* The official client sends the message's own id in `X-Conversation-Message-ID` /
|
|
741
|
+
* `X-Request-ID`; a fresh random UUID per attempt (what this did before) tells the
|
|
742
|
+
* gateway every retry is a different message. Hashing the last user turn keeps the
|
|
743
|
+
* id stable across retries of one step and changes when the conversation moves on.
|
|
744
|
+
*/
|
|
745
|
+
messageIdFor(options) {
|
|
746
|
+
const last = [...(options.messages ?? [])].reverse().find((message) => message.role === 'user')
|
|
747
|
+
const text = typeof last?.content === 'string'
|
|
748
|
+
? last.content
|
|
749
|
+
: (Array.isArray(last?.content) ? last.content.map((block) => String(block?.text ?? '')).join('') : '')
|
|
750
|
+
return createHash('sha1').update(`${this.sessionId}:${text}`).digest('hex').slice(0, 32)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async send(token, body, signal, route, model, messageId) {
|
|
715
754
|
try {
|
|
716
755
|
return await fetch(`${route.endpoint}/v2/chat/completions`, {
|
|
717
756
|
method: 'POST',
|
|
718
|
-
headers: this.headers(token, route),
|
|
757
|
+
headers: this.headers(token, route, model, messageId),
|
|
719
758
|
body,
|
|
720
759
|
signal
|
|
721
760
|
})
|
|
@@ -734,10 +773,13 @@ export class WorkBuddyAdapter {
|
|
|
734
773
|
}
|
|
735
774
|
const imageUrls = await this.resolveImages(options.messages)
|
|
736
775
|
const payload = () => JSON.stringify(this.buildBody(options, model, entry, imageUrls))
|
|
776
|
+
// The message being answered, hashed so every retry of the same step carries the
|
|
777
|
+
// same id (the official client uses the message's own id here).
|
|
778
|
+
const messageId = this.messageIdFor(options)
|
|
737
779
|
const routes = this.routes()
|
|
738
780
|
let route = routes[0]
|
|
739
781
|
|
|
740
|
-
let response = await this.send(token, payload(), options.signal, route)
|
|
782
|
+
let response = await this.send(token, payload(), options.signal, route, model, messageId)
|
|
741
783
|
let rejection = ''
|
|
742
784
|
if (!response.ok) {
|
|
743
785
|
rejection = await response.text().catch(() => '')
|
|
@@ -750,12 +792,12 @@ export class WorkBuddyAdapter {
|
|
|
750
792
|
dumpRejectedRequest(this.region, model, route, payload(), rejection)
|
|
751
793
|
this.rotateIdentity()
|
|
752
794
|
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: 网关判为未获批渠道,已换会话标识重试一次`)
|
|
753
|
-
response = await this.send(token, payload(), options.signal, route)
|
|
795
|
+
response = await this.send(token, payload(), options.signal, route, model, messageId)
|
|
754
796
|
rejection = response.ok ? '' : await response.text().catch(() => '')
|
|
755
797
|
if (!response.ok && routes[1] !== undefined && isUnapprovedChannel(rejection)) {
|
|
756
798
|
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: 主通道仍被拒,改走 ${routes[1].endpoint}`)
|
|
757
799
|
route = routes[1]
|
|
758
|
-
response = await this.send(token, payload(), options.signal, route)
|
|
800
|
+
response = await this.send(token, payload(), options.signal, route, model, messageId)
|
|
759
801
|
rejection = response.ok ? '' : await response.text().catch(() => '')
|
|
760
802
|
}
|
|
761
803
|
} else if (response.status === 401 || response.status === 403) {
|
|
@@ -763,7 +805,7 @@ export class WorkBuddyAdapter {
|
|
|
763
805
|
if (rotated.refreshed) {
|
|
764
806
|
token = await readToken(this.ctx, this.region)
|
|
765
807
|
if (token.length > 0) {
|
|
766
|
-
response = await this.send(token, payload(), options.signal, route)
|
|
808
|
+
response = await this.send(token, payload(), options.signal, route, model, messageId)
|
|
767
809
|
rejection = response.ok ? '' : await response.text().catch(() => '')
|
|
768
810
|
}
|
|
769
811
|
}
|
|
@@ -975,19 +1017,22 @@ export class WorkBuddyAdapter {
|
|
|
975
1017
|
}
|
|
976
1018
|
|
|
977
1019
|
const argsTruncated = [...toolCalls.values()].some((block) => isTruncatedArguments(block.text))
|
|
978
|
-
// A
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
|
|
984
|
-
if (
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1020
|
+
// A tool-call fragment whose name never arrived cannot be announced, so it never
|
|
1021
|
+
// reaches `toolOrder` — and when upstream then ends with `stop`, the step reads as
|
|
1022
|
+
// "the model chose to stop" while a call was in fact in flight. That is a silent
|
|
1023
|
+
// drop, so it is reported instead: the harness keeps an honest failure, and the
|
|
1024
|
+
// log names what happened.
|
|
1025
|
+
const orphanCalls = [...toolCalls.values()].filter((block) => !hasUsableToolName(block.name) && block.text.trim().length > 0)
|
|
1026
|
+
if (orphanCalls.length > 0) {
|
|
1027
|
+
this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 收到 ${orphanCalls.length} 个工具调用分片但始终没有工具名(finish_reason=${finishReason ?? '(无)'}),按传输错误上报,可自动重试`)
|
|
1028
|
+
yield finishChunk({
|
|
1029
|
+
kind: 'error',
|
|
1030
|
+
failure: {
|
|
1031
|
+
message: `${this.region.provider}: 上游发送了工具调用但没有给出工具名(finish_reason=${finishReason ?? '(无)'};可重试的传输错误)`,
|
|
1032
|
+
code: 'TRANSPORT'
|
|
1033
|
+
}
|
|
1034
|
+
})
|
|
1035
|
+
return
|
|
991
1036
|
}
|
|
992
1037
|
// A stream that stops without the `[DONE]` sentinel was cut, not finished:
|
|
993
1038
|
// upstream closed the connection mid-answer. Reporting that as a normal stop
|