mslxdff 0.1.120 → 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.120",
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);
@@ -109,7 +113,7 @@ export function createChatService({
109
113
  const payload = rewriteWorkbuddyPayload(body);
110
114
  if (engineMode === "sdk") {
111
115
  try {
112
- return await attemptOnceSdk({ url, body: payload, key, auth, buildHeaders: buildAuthHeaders });
116
+ return await attemptOnceSdk({ url, body: payload, key, auth, buildHeaders: buildAuthHeaders, ...(sdkFetch ? { fetchImpl: sdkFetch } : {}) });
113
117
  } catch (e) {
114
118
  if (!e || !e._sdkLoadFailed) throw e;
115
119
  if (!sdkFallbackLogged) {
@@ -157,8 +161,8 @@ export function createChatService({
157
161
  const url = joinUrl(baseUrl, chatPath);
158
162
  const t0 = nowMs(clock);
159
163
  const clean = sanitizeToolSequence(body?.messages);
160
- if (clean.droppedCalls || clean.droppedResults) {
161
- 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 {}
162
166
  body = { ...body, messages: clean.messages };
163
167
  }
164
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;
@@ -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 });
@@ -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
+ }