@huai_xin/dsh-workbuddy 0.1.11 → 0.1.13
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/README.md +0 -10
- package/docs/workbuddy-protocol.md +18 -2
- package/package.json +1 -1
- package/src/adapter.js +70 -11
- package/src/catalog.js +1 -1
package/README.md
CHANGED
|
@@ -306,15 +306,6 @@ node test/live/policy-probe.mjs # 两版白名单与准入结果对照
|
|
|
306
306
|
- **思考档位只认服务端声明的 `reasoning.supportedEfforts`**(两个源合并后国内 15/34、国际 21/28);只声明了默认档的模型不给档位列表 —— 与网页端行为一致。合并后仍无档位的,请求里不带 `reasoning_effort`,由服务端取默认。
|
|
307
307
|
- 实时**繁忙程度**只有大陆版有(`POST /v2/chat/busy`,海外版 404,`regions.js` 的 `hasBusy` 决定那一列是否显示)。它**不是可用性信号**:只对白名单内的活跃模型返回 `idle/normal/busy/congested`,其余一律 `unknown`(面板显示「未知」,不当成故障)。插件侧 50 秒缓存,与官方前端一致。
|
|
308
308
|
- 两版**条目数会变**:2026-10-22 实测大陆版 30 个(`ok` 22 / `dead` 8)、海外版 25 个(`ok` 13 / `denied` 12)。`gpt-5.3-codex` 只在账号名单里,两个 `/v3/config` 都没有它,倍率因此是「未知」。
|
|
309
|
-
- **长上下文下模型会"说话说一半就收尾"(上游行为,非插件问题)**:海外版 `deepseek-v4.1-flash` 在上下文很长时会不调用工具、只用一句话叙述下一步然后 `finish_reason: "stop"`(例如「我需要先读取该文件,才能准确修改。」)。实测(2026-10-22,`node test/live/long-context-probe.mjs`):
|
|
310
|
-
|
|
311
|
-
| 输入规模 | 正确调用工具 | 提前 `stop` |
|
|
312
|
-
| --- | --- | --- |
|
|
313
|
-
| 0k(1 条消息) | 2/2 | 0/2 |
|
|
314
|
-
| 30k 字符(613 条) | 2/2 | 0/2 |
|
|
315
|
-
| 89k 字符(1825 条) | 1/2 | **1/2** |
|
|
316
|
-
|
|
317
|
-
用户侧复现阈值约 **120k 上下文**起明显(其它模型未测)。判据是**换了上下文长度、其它一切不变**即可复现/消失,且流里**一个 tool-call 分片都没有**、`finish_reason` 就是 `stop` —— 所以既不是适配器丢帧(丢帧会看到 `finish_reason: "tool_calls"`),也不是采样(四档温度 16/16 正常)、不是工具 schema(70 个工具 / 单个 / 简化 schema 9/9 正常)。`stop` 是模型自己采到 EOS,属于上游质量问题;缓解手段只有换模型、把会话压短(harness 的压缩),或回一句"继续"。
|
|
318
309
|
|
|
319
310
|
## 上游行为与错误映射
|
|
320
311
|
|
|
@@ -359,7 +350,6 @@ node test/live/stream-cut-probe.mjs cn --selftest # 离线验证"断流"分
|
|
|
359
350
|
node test/live/stream-cut-probe.mjs intl 10 --long # 真实压测断流率(免费模型零消耗)
|
|
360
351
|
node test/live/find-message.mjs "关键词" <会话前缀> # 按文本定位某一步,并打出前后事件
|
|
361
352
|
node test/live/dump-stream.mjs <会话前缀> <seq,…> # 打印那一步存下来的原始流帧
|
|
362
|
-
node test/live/sampling-probe.mjs intl 4 # 同一提示在各 temperature 下的"提前 stop"率
|
|
363
353
|
```
|
|
364
354
|
|
|
365
355
|
## 许可
|
|
@@ -394,7 +394,7 @@ data: [DONE]
|
|
|
394
394
|
| INTL scoped → `500`(openresty/APISIX,HTML 错误页) | 2026-10-21 起持续;`/v3/config` 正常 ✅ |
|
|
395
395
|
| `glm-5.1` 图片能力两个端点矛盾 | scoped 报 `false`、`/v3/config` 报 `true`,实测能看图(📖 参考实现据此写了覆盖白名单) |
|
|
396
396
|
| 目录包含渠道不服务的模型 | 见 `11102`(§6)✅ |
|
|
397
|
-
| 长上下文下"说话说一半就收尾"(`finish_reason: stop`,无工具调用) | 上游/模型行为,**不是插件问题**。实测:同一提示、同一套工具、只改前面的对话长度 —— 0k 2/2 正常、30k 1/2(一次 181 字后 `stop`)、89k 1/2;每次 finish 诊断块都标 `workbuddy-intl/deepseek-v4.1-flash`。判据:流里**没有任何 tool-call 分片**、`finish_reason` 就是 `stop`(若丢帧会是 `tool_calls` 且能看到分片)。已排除:采样(四档温度 16/16 正常)、工具 schema/数量(70 个 / 单个 / 简化 schema 9/9 正常)、提示词校验(海外版无此错误,请求体按原样转发)。复现:`node test/live/long-context-probe.mjs 2`;排查:`node test/live/toolcall-audit.mjs <会话前缀>`、`
|
|
397
|
+
| 长上下文下"说话说一半就收尾"(`finish_reason: stop`,无工具调用) | 上游/模型行为,**不是插件问题**。实测:同一提示、同一套工具、只改前面的对话长度 —— 0k 2/2 正常、30k 1/2(一次 181 字后 `stop`)、89k 1/2;每次 finish 诊断块都标 `workbuddy-intl/deepseek-v4.1-flash`。判据:流里**没有任何 tool-call 分片**、`finish_reason` 就是 `stop`(若丢帧会是 `tool_calls` 且能看到分片)。已排除:采样(四档温度 16/16 正常)、工具 schema/数量(70 个 / 单个 / 简化 schema 9/9 正常)、提示词校验(海外版无此错误,请求体按原样转发)。复现:`node test/live/long-context-probe.mjs 2`;排查:`node test/live/toolcall-audit.mjs <会话前缀>`、`long-context-probe.mjs`、`repro-probe.mjs`。缓解只有换模型/压缩上下文/回一句"继续" —— 合法的 `stop` 不能当错误重试。 |
|
|
398
398
|
|
|
399
399
|
### 4.7 模型选择器里每一项元数据的来源
|
|
400
400
|
|
|
@@ -876,7 +876,7 @@ provider 与全部 HTTP 路由都没注册,设置面板只剩一个 `HTTP 404`
|
|
|
876
876
|
| `prompt_cache_key` | 发(会话级) | **不发** | 反向差异;实测对本问题无影响(去掉后 1/8 vs 2/8),保留以提高前缀缓存命中 ✓ |
|
|
877
877
|
| `stop` | 仅调用方给 | 不发 | 等价 ✓ |
|
|
878
878
|
| `store` / `prompt_cache_retention` | 不发 | 发(配置值,通常未设置) | 等价 ✓ |
|
|
879
|
-
| 请求头 | `Authorization` / `Accept` / `X-Domain` / `X-Product` / `X-Product-Code` / `User-Agent` | 同类内部头(`X-User-Id`/`X-Enterprise-Id`/`X-Tenant-Id`/`X-Department-Info`/`X-Domain`/`User-Agent
|
|
879
|
+
| 请求头 | `Authorization` / `Accept` / `X-Domain` / `X-Product` / `X-Product-Code` / `X-Agent-Purpose` / **`X-Agent-Intent: craft`** / `X-Agent-Runtime` / `X-IDE-Name` / `X-IDE-Type` / `X-IDE-Version` / `X-Session-ID` / `X-Model-ID` / `X-Conversation-ID` / **`X-Conversation-Message-ID`** / `X-Conversation-Request-ID` / **`X-Request-ID`** / `User-Agent` | 同类内部头(`X-User-Id`/`X-Enterprise-Id`/`X-Tenant-Id`/`X-Department-Info`/`X-Domain`/`User-Agent`),转发前还会**剔除**内部头 | **网关会校验**:2026-10-23 实测,裸请求缺 `X-Agent-Purpose`/`X-Conversation-*`/`X-IDE-*` → `400 code 11128 request illegal`。已按官方常量对齐(`X-Agent-Intent` 默认 `craft`;`X-Conversation-Message-ID`/`X-Request-ID` 标识"被回答的那条消息",用最后一条 user 消息的哈希,重试时保持不变) |
|
|
880
880
|
|
|
881
881
|
**结论:结构上不是"完全一样",但没有一处会造成"提前收尾"** —— 唯一的实质差异是官方总带采样参数,
|
|
882
882
|
而实测它对这个问题无效。`refusal` 的含义见 §12.2。
|
|
@@ -916,6 +916,22 @@ provider 与全部 HTTP 路由都没注册,设置面板只剩一个 `HTTP 404`
|
|
|
916
916
|
| `test/live/picker-contract-check.mjs` | 适配器实际给模型选择器什么(`providerInfo` / `listModels` / `resolveModel`) |
|
|
917
917
|
| `test/live/harness-contract-replay.mjs` | 复刻 harness 校验规则,定位「选择器整组消失」 |
|
|
918
918
|
| `test/live/dead-code-report.mjs` | 死代码审计:未使用的 CSS 类、词典键、导出 |
|
|
919
|
+
| `test/live/installed-package-check.mjs [dir]` | 检查**已安装副本**(真目录、依赖为空、provider 注册、选择器可选项、资源齐全) |
|
|
920
|
+
| `test/live/leak-scan.mjs [ref]` | 发布前必跑:工作区 + git 历史的账户信息/凭据泄露扫描 |
|
|
921
|
+
| **会话取证("回答为什么中断")** | |
|
|
922
|
+
| `test/live/session-tail-live.mjs [前缀] [步数] [项目过滤]` | 读最新会话的末尾几步;容残帧(`ZSTD_e_flush`),带 `stopReason`/`outputTokens`/正文 |
|
|
923
|
+
| `test/live/last-finishes.mjs [前缀] [步数] [项目过滤]` | 只看每步的 `upstream.stopReason` 与"stop 且无工具调用"的步,附分布统计 |
|
|
924
|
+
| `test/live/find-message.mjs "关键词" [会话前缀]` | 按正文定位某一步,并打出前后事件 |
|
|
925
|
+
| `test/live/dump-stream.mjs [会话前缀] <seq,…>` | 打印某一步存下来的原始流帧(含 finish 的 `response` 诊断块) |
|
|
926
|
+
| `test/live/toolcall-audit.mjs [会话前缀] [步数]` | 上游宣告了工具调用、但一个分片都没交付的"丢失指纹" |
|
|
927
|
+
| **流与请求结构实验(改协议相关代码前跑)** | |
|
|
928
|
+
| `test/live/stream-cut-probe.mjs [cn\|intl] [次数] [模型] [--selftest]` | 断流分类:`--selftest` 离线自检,长回答模式可压测断流率 |
|
|
929
|
+
| `test/live/repro-probe.mjs [次数] [模型] [填充字符]` | 长上下文 + 真实 70 工具集下"是否调用工具",每行打印原始 `finish_reason` |
|
|
930
|
+
| `test/live/long-context-probe.mjs [次数] [模型]` | 只改上下文长度,看提前收尾率(可复现的对照实验) |
|
|
931
|
+
| `test/live/context-size-probe.mjs [字符数…]` | 填充上下文实际值多少 token(网关 `inputTokens` 为准) |
|
|
932
|
+
| `test/live/thinking-field-probe.mjs [次数] [模型] [部署]` | 带/不带 `thinking` 字段时思考量对比(档位才是开关) |
|
|
933
|
+
| `test/live/request-structure.mjs` / `test/live/free-models.mjs` | 各厂商请求结构、免费档清单的逐字段试探 |
|
|
934
|
+
| `test/live/verify-policy.mjs` | 实时目录扣掉台账里 `dead` 的判定后,两版的条目数对照 |
|
|
919
935
|
| **契约与 UI 参考(写 UI 插件用)** | |
|
|
920
936
|
| `test/live/slot-inventory.mjs [outFile]` | 列出客户端实际渲染的所有插槽 |
|
|
921
937
|
| `test/live/model-seat-kind-probe.mjs` | 模型席位的 kind 与占用者(决定能不能接管) |
|
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,6 +1017,23 @@ export class WorkBuddyAdapter {
|
|
|
975
1017
|
}
|
|
976
1018
|
|
|
977
1019
|
const argsTruncated = [...toolCalls.values()].some((block) => isTruncatedArguments(block.text))
|
|
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
|
|
1036
|
+
}
|
|
978
1037
|
// A stream that stops without the `[DONE]` sentinel was cut, not finished:
|
|
979
1038
|
// upstream closed the connection mid-answer. Reporting that as a normal stop
|
|
980
1039
|
// ends the turn silently — the model looks like it simply chose to stop — and
|
package/src/catalog.js
CHANGED
|
@@ -224,7 +224,7 @@ export function entryFromMeta(id, meta) {
|
|
|
224
224
|
* normally. So this is a preference order and a "officially sanctioned" marker,
|
|
225
225
|
* never a filter.
|
|
226
226
|
*/
|
|
227
|
-
|
|
227
|
+
function whitelistedIds(agents) {
|
|
228
228
|
if (!Array.isArray(agents)) return []
|
|
229
229
|
for (const agentName of ['cli', 'craft']) {
|
|
230
230
|
const agent = agents.find((entry) => typeof entry === 'object' && entry !== null && entry.name === agentName)
|