mslxdff 0.1.119 → 0.1.121

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.119",
3
+ "version": "0.1.121",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@ import { joinUrl } from "../base.js";
2
2
  import { isAuthError, isInsufficientStatus } from "./auth.js";
3
3
  import { appendRotationLog as defaultAppend } from "./rotation-log.js";
4
4
  import { createTransport } from "../../transport/index.js";
5
+ import { dispatcherFetch } from "../../upstream-engine/sdk/attempt.js";
5
6
  import { reshapeWorkbuddySse } from "./reshape.js";
6
7
  import { attemptOnceSdk } from "./sdk-chat.js";
7
8
  import { sanitizeToolSequence } from "./sanitize-tools.js";
@@ -88,6 +89,9 @@ export function createChatService({
88
89
  try { defaultAppend(opts); } catch {}
89
90
  };
90
91
  const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
92
+ // SDK 通道与 legacy 同享 keep-alive 连接池(dispatcher → fetch 注入);同时该 fetch 被 attempt 层
93
+ // 包装用于错误路径捕获请求体(上游 4xx 时打印 tool 序列断裂诊断,见 sdk/attempt.js)。
94
+ const sdkFetch = dispatcher ? dispatcherFetch(dispatcher) : fetchImpl;
91
95
 
92
96
  function authForKey(key) {
93
97
  const idx = keys.indexOf(key);
@@ -100,17 +104,16 @@ export function createChatService({
100
104
  // (局部 MSLXDFF_WORKBUDDY_SDK,未设则继承全局 MSLXDFF_UPSTREAM_ENGINE)回退原生 transport;
101
105
  // 不可用(Node16/未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
102
106
  // Note: 为什么默认 SDK、翻译层代价与回退语义 — 见 .agents/notes/implemented/feature/2026-09-12-workbuddy-sdk-channel.md
103
- // workbuddy 缺省 native transport(legacy):上游对 DeepSeek 有非标字段要求
104
- // (thinking 注入 / reasoning_content 回填,见 payload.js),AI SDK 序列化层 providerOptions
105
- // 白名单无法承载这些字段——v0.1.111~118 的 SDK 缺省即 tool 校验 400 的温床。
106
- // 显式 MSLXDFF_WORKBUDDY_SDK=sdk(或全局 MSLXDFF_UPSTREAM_ENGINE=sdk)仍可切回 SDK 通道。
107
- const engineMode = resolveEngineMode(process.env, "MSLXDFF_WORKBUDDY_SDK", "legacy");
107
+ // 上游非标字段(thinking / reasoning_effort / reasoning_content / tool_choice 字符串)不依赖 SDK 白名单:
108
+ // payload.js 前置语义改写 + AI SDK providerOptions 透传(非白名单 key 原样 spread 进 body,2.0.41 实测)。
109
+ // 序列化/流式/错误映射仍全部交 AI SDK;原生 transport 仅作 SDK 不可用时的回退。v0.1.120 实测见 .scratch/probe-sdk-body.mjs。
110
+ const engineMode = resolveEngineMode(process.env, "MSLXDFF_WORKBUDDY_SDK", "sdk");
108
111
  let sdkFallbackLogged = false;
109
112
  async function fetchOnce(url, body, key, auth) {
110
113
  const payload = rewriteWorkbuddyPayload(body);
111
114
  if (engineMode === "sdk") {
112
115
  try {
113
- return await attemptOnceSdk({ url, body: payload, key, auth, buildHeaders: buildAuthHeaders });
116
+ return await attemptOnceSdk({ url, body: payload, key, auth, buildHeaders: buildAuthHeaders, ...(sdkFetch ? { fetchImpl: sdkFetch } : {}) });
114
117
  } catch (e) {
115
118
  if (!e || !e._sdkLoadFailed) throw e;
116
119
  if (!sdkFallbackLogged) {
@@ -158,8 +161,8 @@ export function createChatService({
158
161
  const url = joinUrl(baseUrl, chatPath);
159
162
  const t0 = nowMs(clock);
160
163
  const clean = sanitizeToolSequence(body?.messages);
161
- if (clean.droppedCalls || clean.droppedResults) {
162
- try { console.error(`[workbuddy] tool-sequence sanitized: dropped ${clean.droppedCalls} call(s), ${clean.droppedResults} result(s), model=${body?.model || ""}`); } catch {}
164
+ if (clean.droppedCalls || clean.droppedResults || clean.movedResults || clean.injectedHead) {
165
+ try { console.error(`[workbuddy] tool-sequence sanitized: dropped ${clean.droppedCalls} call(s), ${clean.droppedResults} result(s), moved ${clean.movedResults} result(s), head+${clean.injectedHead}, model=${body?.model || ""}`); } catch {}
163
166
  body = { ...body, messages: clean.messages };
164
167
  }
165
168
  _t0Val = t0;
@@ -1,55 +1,61 @@
1
- // workbuddy 上游对 tool 序列严格校验(400 code 11148 tool_call_sequence_broken):
2
- // assistant.tool_calls 与后续 tool 结果必须按 id 一一配对,否则整单拒绝并提示开新会话。
3
- // 历史上下文经压缩/编辑/跨模型切换后可能断裂(有调用无结果、结果孤立、id 漂移),
4
- // AI SDK 转换层对缺失字段用占位(toolName "tool" / toolCallId "")进一步错配。
5
- // 这里在 workbuddy 出口做防御性清洗:仅保留能成对出现的调用与结果,避免整单 400。
6
- // 其他上游(opencode 等)宽容,不受影响——只挂在 workbuddy 链路。
1
+ // workbuddy 上游对 tool 序列严格校验(400 code 11148 tool_call_sequence_broken),实测三条规则:
2
+ // ① assistant.tool_calls 与结果必须按 id 一一配对(孤立调用/结果均拒);
3
+ // ② 结果必须紧跟对应 assistant(配对不能被 user/assistant 消息打断);
4
+ // ③ 序列不能以"带 tool_calls 的 assistant"开头(tool call 无前置上下文即拒;compaction 裁剪点
5
+ // 落在工具链中间时最易触发)。
6
+ // 这里在 workbuddy 出口做防御性清洗:结果前移到对应调用之后(组内按 calls 顺序)、剔无法配对的
7
+ // 调用/结果、首条为 assistant/tool 时注入占位 user("continue")。其他上游宽容不受影响——只挂 workbuddy。
7
8
  export function sanitizeToolSequence(messages) {
8
9
  const list = Array.isArray(messages) ? messages : [];
9
10
 
10
- const callIds = new Set();
11
- for (const m of list) {
12
- if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
13
- for (const tc of m.tool_calls) {
14
- const id = String(tc?.id || "");
15
- if (id) callIds.add(id);
16
- }
17
- }
18
- }
19
-
20
- const resultIds = new Set();
21
- for (const m of list) {
22
- if (m?.role === "tool") {
23
- const id = String(m.tool_call_id || "");
24
- if (id) resultIds.add(id);
25
- }
26
- }
27
-
28
- const paired = new Set();
29
- for (const id of callIds) if (resultIds.has(id)) paired.add(id);
11
+ const resultById = new Map();
12
+ list.forEach((m, idx) => {
13
+ if (m?.role !== "tool") return;
14
+ const id = String(m.tool_call_id || "");
15
+ if (id && !resultById.has(id)) resultById.set(id, { msg: m, idx, used: false });
16
+ });
30
17
 
31
18
  let droppedCalls = 0;
32
19
  let droppedResults = 0;
20
+ let movedResults = 0;
33
21
  const out = [];
34
- for (const m of list) {
22
+ list.forEach((m, idx) => {
35
23
  if (m?.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) {
36
- const kept = m.tool_calls.filter((tc) => paired.has(String(tc?.id || "")));
37
- droppedCalls += m.tool_calls.length - kept.length;
24
+ const kept = [];
25
+ for (const tc of m.tool_calls) {
26
+ const r = resultById.get(String(tc?.id || ""));
27
+ if (r && !r.used) kept.push(tc);
28
+ else droppedCalls++;
29
+ }
38
30
  if (kept.length) {
39
31
  out.push({ ...m, tool_calls: kept });
32
+ for (const tc of kept) {
33
+ const r = resultById.get(String(tc.id));
34
+ r.used = true;
35
+ if (!(r.idx >= idx + 1 && r.idx <= idx + kept.length)) movedResults++;
36
+ out.push(r.msg);
37
+ }
40
38
  } else if (typeof m.content === "string" && m.content) {
41
39
  const { tool_calls: _dropped, ...rest } = m;
42
40
  out.push(rest);
43
41
  }
44
- continue;
42
+ return;
45
43
  }
46
44
  if (m?.role === "tool") {
47
- if (paired.has(String(m.tool_call_id || ""))) out.push(m);
48
- else droppedResults++;
49
- continue;
45
+ const id = String(m.tool_call_id || "");
46
+ const r = resultById.get(id);
47
+ if (r && r.idx === idx && r.used) return; // 已前移消费,跳过原位置
48
+ droppedResults++; // 孤儿或重复
49
+ return;
50
50
  }
51
51
  out.push(m);
52
+ });
53
+
54
+ let injectedHead = 0;
55
+ if (out.length && out[0]?.role !== "user" && out[0]?.role !== "system" && out[0]?.role !== "developer") {
56
+ out.unshift({ role: "user", content: "continue" });
57
+ injectedHead = 1;
52
58
  }
53
59
 
54
- return { messages: out, droppedCalls, droppedResults };
60
+ return { messages: out, droppedCalls, droppedResults, movedResults, injectedHead };
55
61
  }
@@ -8,7 +8,7 @@ export { sdkBaseFromUrl };
8
8
 
9
9
  let channelLogged = false;
10
10
 
11
- export async function attemptOnceSdk({ url, body, key, auth, buildHeaders, clock = Date.now } = {}) {
11
+ export async function attemptOnceSdk({ url, body, key, auth, buildHeaders, clock = Date.now, fetchImpl } = {}) {
12
12
  const out = await attemptGeneric({
13
13
  url,
14
14
  body,
@@ -16,6 +16,7 @@ export async function attemptOnceSdk({ url, body, key, auth, buildHeaders, clock
16
16
  providerName: "workbuddy",
17
17
  marker: { name: "x-mslxdff-workbuddy-channel", value: "sdk" },
18
18
  clock,
19
+ ...(fetchImpl ? { fetchImpl } : {}),
19
20
  });
20
21
  if (!channelLogged) {
21
22
  channelLogged = true;
@@ -1,7 +1,6 @@
1
1
  // 引擎模式解析(纯函数,零依赖):缺省 sdk,legacy/关闭词回退原实现。
2
2
  // 供应商级开关未设置时继承全局总闸,使 MSLXDFF_UPSTREAM_ENGINE=legacy 成为一键熔断。
3
- // defaultMode:调用方可自定缺省(workbuddy 因上游非标字段 thinking/reasoning_content
4
- // 无法经 AI SDK 序列化层注入,缺省 legacy);显式 env(供应商级或全局)始终优先。
3
+ // defaultMode:调用方可自定缺省(一般无需传,保持 sdk);显式 env(供应商级或全局)始终优先。
5
4
  // 供 upstream-engine/index.js(opencode 上游)与 providers/workbuddy/chat.js 共用。
6
5
  // 见 docs/adr/0017。
7
6
  const ENGINE_OFF_WORDS = new Set(["0", "off", "false", "no", "disable", "disabled"]);
@@ -6,6 +6,7 @@
6
6
  // 见 .scratch/ai-sdk-upstream/{SPEC.md,SPEC-p3-responses.md} 与 docs/adr/0017。
7
7
  import { toModelPrompt, toModelTools, toModelToolChoice, toModelParams } from "./convert.js";
8
8
  import { createSseSerializer } from "./sse.js";
9
+ import { diagnoseToolSequence, compactSequence } from "./diagnose.js";
9
10
  import { getUndici } from "../../compat.js";
10
11
 
11
12
  let sdkPromise = null;
@@ -44,6 +45,20 @@ export function markerHeaders(marker) {
44
45
  return marker && marker.name ? { [marker.name]: marker.value } : {};
45
46
  }
46
47
 
48
+ // 错误路径诊断:AI SDK 的 APICallError 对 HTTP 4xx 的 requestBodyValues 是空对象(provider-utils 实测),
49
+ // 故用包装 fetch 捕获真实发出的 body,在上游非 2xx 时打印 tool 序列断裂点与尾部序列。
50
+ function logRequestDiagnosis(bodyText, status) {
51
+ if (!bodyText || typeof bodyText !== "string") return;
52
+ let parsed = null;
53
+ try { parsed = JSON.parse(bodyText); } catch { return; }
54
+ const msgs = parsed?.messages;
55
+ if (!Array.isArray(msgs)) return;
56
+ const { issues, summary } = diagnoseToolSequence(msgs);
57
+ console.error(`[sdk-upstream] ${status} ${summary} issues=${issues.length ? issues.join(" | ") : "none"}`);
58
+ console.error(`[sdk-upstream] head: ${compactSequence(msgs, 0, 6)}`);
59
+ console.error(`[sdk-upstream] tail: ${compactSequence(msgs, Math.max(0, msgs.length - 40))}`);
60
+ }
61
+
47
62
  // SDK 抛出的 APICallError → 带状态码的 Response;非 HTTP 错误返回 null 由调用方 rethrow。
48
63
  export function errorResponseFromSdkError(e, { marker = null } = {}) {
49
64
  const status = Number(e?.statusCode ?? e?.status);
@@ -125,11 +140,19 @@ export async function attemptOnceSdk({
125
140
  err._sdkLoadFailed = true;
126
141
  throw err;
127
142
  }
143
+ // 包装注入的 fetch 以捕获真实请求体(诊断用):不改变调用语义,仅在非 2xx 时被读取。
144
+ let lastBodyText = null;
145
+ const capturedFetch = fetchImpl
146
+ ? async (u, init) => {
147
+ try { if (init && typeof init.body === "string") lastBodyText = init.body; } catch {}
148
+ return fetchImpl(u, init);
149
+ }
150
+ : undefined;
128
151
  const provider = createOpenAICompatible({
129
152
  name: providerName,
130
153
  baseURL,
131
154
  headers: sanitizeHeaders(headers),
132
- ...(fetchImpl ? { fetch: fetchImpl } : {}),
155
+ ...(capturedFetch ? { fetch: capturedFetch } : {}),
133
156
  });
134
157
  const model = provider.chatModel(String(body?.model || ""));
135
158
  let res;
@@ -142,7 +165,10 @@ export async function attemptOnceSdk({
142
165
  });
143
166
  } catch (e) {
144
167
  const mapped = errorResponseFromSdkError(e, { marker });
145
- if (mapped) return mapped;
168
+ if (mapped) {
169
+ try { logRequestDiagnosis(lastBodyText, mapped.status); } catch {}
170
+ return mapped;
171
+ }
146
172
  throw e;
147
173
  }
148
174
  return streamResponseFromParts(res.stream, { marker, clock, t0 });
@@ -52,8 +52,9 @@ export function toModelPrompt(messages) {
52
52
  const parts = [];
53
53
  const text = textOf(m.content);
54
54
  if (text) parts.push({ type: "text", text });
55
- // 带加密态的 reasoning items(responses 通道思考跨轮):AI SDK 会转成上游要的 encrypted reasoning item。
56
55
  // 无加密态时才退回纯文本 reasoning(chat 通道的 reasoning_content)。
56
+ // 空串是 payload 回填的有意标记(requiresReasoningContentOnAssistantMessages):所有 assistant
57
+ // 必须带该字段——AI SDK 以 length>0 判定是否输出,故用 " " 占位而非 ""。
57
58
  const items = Array.isArray(m.reasoning_items) ? m.reasoning_items : [];
58
59
  let pushedEncrypted = false;
59
60
  for (const r of items) {
@@ -66,7 +67,9 @@ export function toModelPrompt(messages) {
66
67
  });
67
68
  pushedEncrypted = true;
68
69
  }
69
- if (!pushedEncrypted && m.reasoning_content) parts.push({ type: "reasoning", text: String(m.reasoning_content) });
70
+ const rc = typeof m.reasoning_content === "string" ? m.reasoning_content
71
+ : (typeof m.reasoning === "string" ? m.reasoning : null);
72
+ if (!pushedEncrypted && rc !== null) parts.push({ type: "reasoning", text: rc || " " });
70
73
  for (const tc of Array.isArray(m.tool_calls) ? m.tool_calls : []) {
71
74
  if (!tc || typeof tc !== "object") continue;
72
75
  parts.push({
@@ -0,0 +1,61 @@
1
+ // 请求消息序列诊断(纯函数):上游 11148 tool_call_sequence_broken 时定位断裂点。
2
+ // 上游校验规则:assistant.tool_calls 必须与紧随其后的 tool 结果按 id 严格配对,
3
+ // 且配对过程不能被其他消息(user/assistant/system)打断——仅集合级 id 配对(sanitize-tools)不够。
4
+ // 在 SDK 错误路径对 AI SDK 实际发出的请求体做本诊断,异常位置用消息下标指认。
5
+ export function compactSequence(messages, from = 0, limit = Infinity) {
6
+ const list = Array.isArray(messages) ? messages : [];
7
+ const end = Number.isFinite(limit) ? Math.min(list.length, from + limit) : list.length;
8
+ const out = [];
9
+ for (let i = Math.max(0, from); i < end; i++) {
10
+ const m = list[i] || {};
11
+ const role = String(m.role || "?");
12
+ if (role === "assistant") {
13
+ const calls = Array.isArray(m.tool_calls) ? m.tool_calls : [];
14
+ out.push(calls.length ? `A{${calls.map((t) => String(t?.id || "empty")).join(",")}}` : "A");
15
+ } else if (role === "tool") out.push(`T{${String(m.tool_call_id || "empty")}}`);
16
+ else if (role === "user") out.push("U");
17
+ else if (role === "system" || role === "developer") out.push("S");
18
+ else out.push(String(role)[0] || "?");
19
+ }
20
+ return out.join(" ");
21
+ }
22
+
23
+ export function diagnoseToolSequence(messages) {
24
+ const list = Array.isArray(messages) ? messages : [];
25
+ const issues = [];
26
+ const open = new Map();
27
+ let calls = 0;
28
+ let results = 0;
29
+ const first = list[0];
30
+ if (first && first.role !== "user" && first.role !== "system" && first.role !== "developer") {
31
+ issues.push(`#0 首条为 ${String(first.role || "?")}(上游拒带工具链的 assistant/tool 开头)`);
32
+ }
33
+ list.forEach((m, i) => {
34
+ const role = m?.role;
35
+ if (role === "assistant") {
36
+ const tcs = Array.isArray(m.tool_calls) ? m.tool_calls : [];
37
+ if (tcs.length) {
38
+ if (open.size) issues.push(`#${i} assistant 打断未闭合{${[...open.keys()].join(",")}}`);
39
+ for (const tc of tcs) {
40
+ const id = String(tc?.id || "");
41
+ calls++;
42
+ if (!id) issues.push(`#${i} tool_call 空 id`);
43
+ else if (open.has(id)) issues.push(`#${i} call id 重复:${id}`);
44
+ else open.set(id, i);
45
+ }
46
+ } else if (open.size) {
47
+ issues.push(`#${i} assistant(无调用) 打断{${[...open.keys()].join(",")}}`);
48
+ }
49
+ } else if (role === "tool") {
50
+ results++;
51
+ const id = String(m.tool_call_id || "");
52
+ if (!id) issues.push(`#${i} tool 结果空 id`);
53
+ else if (!open.has(id)) issues.push(`#${i} 孤立结果:${id}`);
54
+ else open.delete(id);
55
+ } else if (open.size) {
56
+ issues.push(`#${i} ${role} 打断{${[...open.keys()].join(",")}}`);
57
+ }
58
+ });
59
+ for (const [id, at] of open) issues.push(`#${at} 悬空调用:${id}`);
60
+ return { issues, summary: `msgs=${list.length} calls=${calls} results=${results}` };
61
+ }