@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/adapter.js +69 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huai_xin/dsh-workbuddy",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "WorkBuddy model provider for DeepSeek Harness (international + China deployments)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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 = crypto.randomUUID().replace(/-/g, '')
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 = crypto.randomUUID().replace(/-/g, '')
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
- headers(token, route) {
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': crypto.randomUUID(),
710
- 'X-Conversation-Request-ID': crypto.randomUUID()
731
+ 'X-Conversation-Message-ID': messageId,
732
+ 'X-Conversation-Request-ID': messageId,
733
+ 'X-Request-ID': messageId
711
734
  }
712
735
  }
713
736
 
714
- async send(token, body, signal, route) {
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 step that ends with prose and no tool call *looks* like the model choosing to
979
- // stop, so nothing else records it — and "it stopped mid-answer" arrives as a bug
980
- // report with no evidence. These two shapes are the ones seen in practice:
981
- // an announcement that expects a next action ("……:" / "………") and a statement of
982
- // inability ("我需要先读取…" / "我无法…"). Logging them with the raw reason and the
983
- // text turns the next report into a fact.
984
- if (streamEnded === true && toolOrder.length === 0 && (finishReason === 'stop' || finishReason === undefined)) {
985
- const prose = blocks.find((block) => block.kind === 'text')?.text ?? ''
986
- const tail = prose.trim()
987
- const suspicious = /[::…]$/.test(tail) || /(我需要|我没有|我无法|先读取|无法直接|不能直接)/.test(tail)
988
- if (suspicious) {
989
- this.ctx.logger.warn(`[workbuddy] ${this.region.id}: ${options.model} 疑似提前收尾(finish_reason=${finishReason ?? '(无)'},输出 ${outputTokens ?? '未知'} tokens,无工具调用):「${tail.slice(0, 60)}」`)
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