c0de-agent 1.13.1 → 1.14.0

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 (155) hide show
  1. package/dist/cli/commands/serve.js +5 -1
  2. package/dist/cli/deps.d.ts +2 -4
  3. package/dist/cli/deps.js +22 -36
  4. package/dist/cli/modes/print.js +27 -17
  5. package/dist/core/agent.js +59 -36
  6. package/dist/core/agents/parallel.js +4 -2
  7. package/dist/core/config-path.d.ts +3 -1
  8. package/dist/core/config-path.js +33 -3
  9. package/dist/core/config.js +81 -0
  10. package/dist/core/context.js +6 -3
  11. package/dist/core/loop/persist.js +4 -1
  12. package/dist/core/loop/subagent.d.ts +19 -0
  13. package/dist/core/loop/subagent.js +73 -7
  14. package/dist/core/loop.d.ts +4 -1
  15. package/dist/core/loop.js +22 -6
  16. package/dist/{server/registry-config.d.ts → core/provider-registry.d.ts} +10 -2
  17. package/dist/core/provider-registry.js +66 -0
  18. package/dist/core/redact.d.ts +2 -1
  19. package/dist/core/redact.js +4 -2
  20. package/dist/core/secret.d.ts +5 -0
  21. package/dist/core/secret.js +14 -0
  22. package/dist/core/skills.d.ts +2 -0
  23. package/dist/core/skills.js +61 -0
  24. package/dist/core/slash.js +31 -18
  25. package/dist/core/title.js +4 -2
  26. package/dist/core/todo-tags.d.ts +4 -3
  27. package/dist/core/todo-tags.js +20 -6
  28. package/dist/core/workflow.d.ts +4 -13
  29. package/dist/core/workflow.js +6 -6
  30. package/dist/core/workflows/context.js +31 -59
  31. package/dist/core/workflows/runtime.js +10 -8
  32. package/dist/dap/index.d.ts +1 -0
  33. package/dist/dap/index.js +1 -0
  34. package/dist/dap/protocol.d.ts +8 -3
  35. package/dist/dap/protocol.js +54 -18
  36. package/dist/dap/session.js +71 -12
  37. package/dist/dap/spawn.d.ts +11 -0
  38. package/dist/dap/spawn.js +48 -0
  39. package/dist/dap/transport.js +18 -0
  40. package/dist/kanban/index.d.ts +1 -1
  41. package/dist/kanban/index.js +1 -1
  42. package/dist/kanban/store.d.ts +8 -1
  43. package/dist/kanban/store.js +61 -13
  44. package/dist/llm/protocols/openai-compat.d.ts +7 -0
  45. package/dist/llm/protocols/openai-compat.js +19 -2
  46. package/dist/llm/protocols/utils/tool-stream.d.ts +12 -3
  47. package/dist/llm/protocols/utils/tool-stream.js +47 -23
  48. package/dist/llm/provider.js +18 -3
  49. package/dist/llm/retry.d.ts +1 -0
  50. package/dist/llm/retry.js +20 -7
  51. package/dist/llm/routing.js +14 -2
  52. package/dist/llm/schema/messages.d.ts +9 -2
  53. package/dist/llm/token.d.ts +9 -0
  54. package/dist/llm/token.js +10 -6
  55. package/dist/llm/transport.js +7 -2
  56. package/dist/mcp/client.d.ts +11 -0
  57. package/dist/mcp/client.js +70 -0
  58. package/dist/mcp/index.d.ts +46 -0
  59. package/dist/mcp/index.js +106 -0
  60. package/dist/mcp/tool-adapter.d.ts +14 -0
  61. package/dist/mcp/tool-adapter.js +65 -0
  62. package/dist/mcp/transport.d.ts +35 -0
  63. package/dist/mcp/transport.js +243 -0
  64. package/dist/mcp/types.d.ts +55 -0
  65. package/dist/mcp/types.js +3 -0
  66. package/dist/plugins/init.d.ts +2 -0
  67. package/dist/plugins/init.js +10 -1
  68. package/dist/project/project.js +50 -3
  69. package/dist/project/resolve.d.ts +7 -0
  70. package/dist/project/resolve.js +52 -5
  71. package/dist/server/permission/interactive.js +9 -4
  72. package/dist/server/permission/store.js +8 -2
  73. package/dist/server/routes/auth.js +28 -13
  74. package/dist/server/routes/chat.js +92 -26
  75. package/dist/server/routes/config.js +38 -10
  76. package/dist/server/routes/files.js +75 -16
  77. package/dist/server/routes/filesystem.js +9 -2
  78. package/dist/server/routes/kanban.js +146 -9
  79. package/dist/server/routes/permissions.js +32 -0
  80. package/dist/server/routes/project.js +15 -6
  81. package/dist/server/routes/provider.js +10 -5
  82. package/dist/server/routes/session.js +72 -26
  83. package/dist/server/routes/terminal.js +10 -2
  84. package/dist/server/routes/todo.js +18 -0
  85. package/dist/server/routes/tool.js +13 -2
  86. package/dist/server/routes/update.js +5 -1
  87. package/dist/server/routes/workflows.js +17 -6
  88. package/dist/server/server.d.ts +1 -1
  89. package/dist/server/server.js +31 -17
  90. package/dist/server/terminal/pty-manager.d.ts +3 -0
  91. package/dist/server/terminal/pty-manager.js +8 -3
  92. package/dist/server/types.d.ts +2 -0
  93. package/dist/server/util/json-body.d.ts +31 -0
  94. package/dist/server/util/json-body.js +38 -0
  95. package/dist/session/archive.js +4 -4
  96. package/dist/session/branch.d.ts +10 -1
  97. package/dist/session/branch.js +54 -9
  98. package/dist/session/compaction.d.ts +5 -0
  99. package/dist/session/compaction.js +15 -10
  100. package/dist/session/context.d.ts +7 -3
  101. package/dist/session/context.js +35 -9
  102. package/dist/session/import.js +53 -6
  103. package/dist/session/index.d.ts +2 -2
  104. package/dist/session/index.js +2 -2
  105. package/dist/session/message.d.ts +12 -3
  106. package/dist/session/message.js +44 -4
  107. package/dist/session/session.d.ts +11 -5
  108. package/dist/session/session.js +99 -88
  109. package/dist/session/shake.js +35 -25
  110. package/dist/session/squash.js +3 -4
  111. package/dist/session/token.d.ts +7 -8
  112. package/dist/session/token.js +19 -16
  113. package/dist/session/usage.js +4 -2
  114. package/dist/shared/index.d.ts +1 -0
  115. package/dist/shared/index.js +1 -0
  116. package/dist/shared/round.d.ts +12 -0
  117. package/dist/shared/round.js +18 -0
  118. package/dist/shared/timer.d.ts +23 -0
  119. package/dist/shared/timer.js +27 -0
  120. package/dist/shared/types/tool.d.ts +4 -0
  121. package/dist/shared/utils/markdown-code.d.ts +15 -0
  122. package/dist/shared/utils/markdown-code.js +44 -0
  123. package/dist/shared/utils/path.d.ts +4 -1
  124. package/dist/shared/utils/path.js +5 -2
  125. package/dist/shared/utils/string.d.ts +5 -0
  126. package/dist/shared/utils/string.js +52 -0
  127. package/dist/tools/builtin/bash.js +79 -10
  128. package/dist/tools/builtin/edit.js +67 -8
  129. package/dist/tools/builtin/glob.js +150 -14
  130. package/dist/tools/builtin/grep.js +53 -11
  131. package/dist/tools/builtin/kanban.d.ts +14 -0
  132. package/dist/tools/builtin/kanban.js +4 -1
  133. package/dist/tools/builtin/read.js +15 -2
  134. package/dist/tools/builtin/resolvers.d.ts +9 -2
  135. package/dist/tools/builtin/resolvers.js +20 -6
  136. package/dist/tools/builtin/task.js +107 -25
  137. package/dist/tools/builtin/todo.js +84 -9
  138. package/dist/tools/builtin/write.js +7 -1
  139. package/dist/tools/builtin/yield.js +9 -1
  140. package/dist/tools/executor.js +53 -1
  141. package/dist/tools/hashline/patch.js +114 -38
  142. package/dist/tools/truncate.js +45 -16
  143. package/dist/tools/validate.js +66 -20
  144. package/dist/tools/websearch/index.js +8 -4
  145. package/dist/update/index.d.ts +1 -1
  146. package/dist/update/index.js +1 -1
  147. package/dist/update/scheduler.js +9 -2
  148. package/dist/update/snapshot.d.ts +14 -4
  149. package/dist/update/snapshot.js +22 -22
  150. package/dist/update/updater.d.ts +16 -2
  151. package/dist/update/updater.js +39 -8
  152. package/dist/update/version.d.ts +3 -1
  153. package/dist/update/version.js +63 -9
  154. package/package.json +1 -1
  155. package/dist/server/registry-config.js +0 -44
@@ -40,6 +40,16 @@ class KanbanInvalidPriorityError extends Error {
40
40
  this.value = value;
41
41
  }
42
42
  }
43
+ /** 非有限 position(如 JSON 1e999 → Infinity)。
44
+ * 写入 position 列后 max(position) 恒 Infinity,后续追加卡片全部同值并列、
45
+ * 排序静默失效且无法自愈——入口显式拒绝。 */
46
+ class KanbanInvalidPositionError extends Error {
47
+ value;
48
+ constructor(value) {
49
+ super(`position 必须为有限数字,收到 ${value}`);
50
+ this.value = value;
51
+ }
52
+ }
43
53
  /** 卡片不存在(update/move 目标 id 无效)。 */
44
54
  class KanbanCardNotFoundError extends Error {
45
55
  cardId;
@@ -120,6 +130,32 @@ function createKanbanStore(handle, projectId) {
120
130
  .limit(1);
121
131
  return (row?.columns ?? []);
122
132
  }
133
+ /** 本项目的活动板 id(不懒建):变更操作的作用域基准。无板 = 本项目无卡片。 */
134
+ async function currentBoardId() {
135
+ const [row] = await db
136
+ .select({ id: kanbanBoards.id })
137
+ .from(kanbanBoards)
138
+ .where(and(eq(kanbanBoards.projectId, projectId), isNull(kanbanBoards.deletedAt)))
139
+ .limit(1);
140
+ return row?.id ?? null;
141
+ }
142
+ /** 取本板卡片;不属于本板(其他项目的卡片 id / 不存在)抛 KanbanCardNotFoundError。
143
+ * store 以 projectId 构造,但 update/move/delete 按卡片 id 寻址——不校验作用域时
144
+ * `PATCH /api/kanban/<项目B>/cards/<项目A 的卡片 id>` 会静默改写 A 的看板
145
+ * (REST 路径里的 projectId 形同虚设),删除更会直接物理删除他板卡片。 */
146
+ async function cardInThisBoard(id) {
147
+ const boardId = await currentBoardId();
148
+ if (!boardId)
149
+ throw new KanbanCardNotFoundError(id);
150
+ const [row] = await db
151
+ .select()
152
+ .from(kanbanCards)
153
+ .where(and(eq(kanbanCards.id, id), eq(kanbanCards.boardId, boardId)))
154
+ .limit(1);
155
+ if (!row)
156
+ throw new KanbanCardNotFoundError(id);
157
+ return row;
158
+ }
123
159
  /** 校验列存在;不存在抛 KanbanColumnNotFoundError(消息含可用列清单)。 */
124
160
  function assertColumn(columns, columnId, op) {
125
161
  if (!columns.some((c) => c.id === columnId)) {
@@ -191,6 +227,8 @@ function createKanbanStore(handle, projectId) {
191
227
  },
192
228
  async updateCard(id, patch) {
193
229
  assertPriority(patch.priority);
230
+ // 作用域校验:卡片必须属于本项目的板(他板卡片 id → 404 语义)。
231
+ const existing = await cardInThisBoard(id);
194
232
  const [row] = await db
195
233
  .update(kanbanCards)
196
234
  .set({
@@ -200,20 +238,18 @@ function createKanbanStore(handle, projectId) {
200
238
  ...(patch.labels !== undefined && { labels: patch.labels }),
201
239
  updatedAt: new Date(),
202
240
  })
203
- .where(eq(kanbanCards.id, id))
241
+ .where(and(eq(kanbanCards.id, id), eq(kanbanCards.boardId, existing.boardId)))
204
242
  .returning();
205
243
  if (!row)
206
244
  throw new KanbanCardNotFoundError(id);
207
245
  return rowToCard(row);
208
246
  },
209
247
  async moveCard(id, columnId, position) {
210
- const [card] = await db
211
- .select({ boardId: kanbanCards.boardId })
212
- .from(kanbanCards)
213
- .where(eq(kanbanCards.id, id))
214
- .limit(1);
215
- if (!card)
216
- throw new KanbanCardNotFoundError(id);
248
+ if (position !== undefined && !Number.isFinite(position)) {
249
+ throw new KanbanInvalidPositionError(position);
250
+ }
251
+ // 作用域校验(同时取回 boardId):他板卡片 id 不得经本项目路径移动。
252
+ const card = await cardInThisBoard(id);
217
253
  // 悬空列校验:目标列必须存在于当前列配置,否则卡片静默不可见。
218
254
  const columns = await boardColumns(card.boardId);
219
255
  assertColumn(columns, columnId, 'move');
@@ -222,13 +258,25 @@ function createKanbanStore(handle, projectId) {
222
258
  const [row] = await db
223
259
  .update(kanbanCards)
224
260
  .set({ columnId, position: newPos, updatedAt: new Date() })
225
- .where(eq(kanbanCards.id, id))
261
+ .where(and(eq(kanbanCards.id, id), eq(kanbanCards.boardId, card.boardId)))
226
262
  .returning();
227
- const moved = row;
228
- return rowToCard(moved);
263
+ if (!row)
264
+ throw new KanbanCardNotFoundError(id);
265
+ return rowToCard(row);
229
266
  },
230
267
  async deleteCard(id) {
231
- await db.delete(kanbanCards).where(eq(kanbanCards.id, id));
268
+ // 作用域校验 + 删除影响行数校验:他板卡片 id 与不存在的 id 都必须报错——
269
+ // 此前无条件 `delete where id` 恒返回成功,REST 对任何 id 都回 ok:true,
270
+ // 删除失败(拼错 id / 卡片已被 agent 删除)静默无反馈。
271
+ const boardId = await currentBoardId();
272
+ if (!boardId)
273
+ throw new KanbanCardNotFoundError(id);
274
+ const rows = await db
275
+ .delete(kanbanCards)
276
+ .where(and(eq(kanbanCards.id, id), eq(kanbanCards.boardId, boardId)))
277
+ .returning({ id: kanbanCards.id });
278
+ if (rows.length === 0)
279
+ throw new KanbanCardNotFoundError(id);
232
280
  },
233
281
  async updateBoard(patch) {
234
282
  const boardId = await getOrCreateBoardId();
@@ -315,7 +363,7 @@ function createKanbanStore(handle, projectId) {
315
363
  },
316
364
  };
317
365
  }
318
- export { createKanbanStore, KanbanCardNotFoundError, KanbanColumnInUseError, KanbanColumnNotFoundError, KanbanInvalidPriorityError, };
366
+ export { createKanbanStore, KanbanCardNotFoundError, KanbanColumnInUseError, KanbanColumnNotFoundError, KanbanInvalidPositionError, KanbanInvalidPriorityError, };
319
367
  /** 把活动看板软删除进回收站(记录原项目名与工作目录)。项目行删除后 FK 将 projectId 置 null。 */
320
368
  export async function softDeleteKanbanBoard(handle, projectId, projectName, worktree) {
321
369
  const rows = await handle.db
@@ -89,6 +89,13 @@ type StepState = {
89
89
  tools: ToolStreamState;
90
90
  /** Finish reason seen but not yet finalized (waiting for trailing usage chunk). */
91
91
  pendingFinish: FinishReason | null;
92
+ /** 已终结标记:finalize 后所有后续 chunk 一律忽略。
93
+ * 此前终结后未设标记——finish_reason+usage 同 chunk 完成终结后,再收到
94
+ * usage-only 尾 chunk(部分 provider 在 combined chunk 之后仍补发 usage 帧)
95
+ * 会再次走终结分支:lifecycle 已复位,重发 step-start/step-finish/finish
96
+ * (下游拿到重复 done + 重复 usage);携带非空 content 的尾 chunk 更会
97
+ * 产出「完成后幻影文本」,追加进已终结的轮次。 */
98
+ finalized: boolean;
92
99
  };
93
100
  declare const initialStepState: () => StepState;
94
101
  /**
@@ -6,6 +6,14 @@ const joinSystem = (request) => request.system.map((p) => p.text).join('\n\n');
6
6
  const toOpenAIContent = (part) => {
7
7
  if (part.type === 'text')
8
8
  return { type: 'text', text: part.text };
9
+ if (part.type === 'image') {
10
+ // OpenAI 多模态契约:dataURL 形式的 image_url。mediaType/data 来自用户
11
+ // 附件(data 为无前缀 base64)——此处拼出完整 dataURL 送达模型。
12
+ return {
13
+ type: 'image_url',
14
+ image_url: { url: `data:${part.mediaType};base64,${part.data}` },
15
+ };
16
+ }
9
17
  return { type: 'text', text: '' };
10
18
  };
11
19
  const messageToOpenAI = (msg) => {
@@ -28,7 +36,7 @@ const messageToOpenAI = (msg) => {
28
36
  const textParts = msg.content.filter((p) => p.type === 'text');
29
37
  const toolCalls = msg.content.filter((p) => p.type === 'tool-call');
30
38
  const content = msg.role === 'user'
31
- ? textParts.map(toOpenAIContent)
39
+ ? msg.content.filter((p) => p.type === 'text' || p.type === 'image').map(toOpenAIContent)
32
40
  : textParts.map((p) => (p.type === 'text' ? p.text : '')).join('');
33
41
  const message = {
34
42
  role: msg.role,
@@ -104,6 +112,7 @@ const initialStepState = () => ({
104
112
  lifecycle: lifecycleInitial(),
105
113
  tools: emptyTools(),
106
114
  pendingFinish: null,
115
+ finalized: false,
107
116
  });
108
117
  /** Map an OpenAI usage object to the internal Usage fields. */
109
118
  const mapUsage = (raw) => ({
@@ -124,11 +133,17 @@ const finalize = (lifecycle, events, reason, usage) => lifecycleFinish(lifecycle
124
133
  * trailing usage chunk triggers the finish. `done` is only true once finalized.
125
134
  */
126
135
  const step = (state, chunk) => {
136
+ // 终结后的 chunk 一律忽略:终结是不可逆状态,后续帧(重复 usage、
137
+ // 迟到 delta)不得重发 finish 事件或把文本追加进已终结的轮次。
138
+ if (state.finalized) {
139
+ return { state, events: [], done: true };
140
+ }
127
141
  const events = [];
128
142
  let lifecycle = state.lifecycle;
129
143
  let tools = state.tools;
130
144
  let pendingFinish = state.pendingFinish;
131
145
  let done = false;
146
+ let finalized = false;
132
147
  const choice = chunk.choices?.[0];
133
148
  if (choice !== undefined && choice.delta !== undefined) {
134
149
  const delta = choice.delta;
@@ -165,6 +180,7 @@ const step = (state, chunk) => {
165
180
  if (chunk.usage !== undefined) {
166
181
  lifecycle = finalize(lifecycle, events, pendingFinish, mapUsage(chunk.usage));
167
182
  done = true;
183
+ finalized = true;
168
184
  pendingFinish = null;
169
185
  }
170
186
  }
@@ -174,9 +190,10 @@ const step = (state, chunk) => {
174
190
  const reason = pendingFinish ?? 'stop';
175
191
  lifecycle = finalize(lifecycle, events, reason, mapUsage(chunk.usage));
176
192
  done = true;
193
+ finalized = true;
177
194
  pendingFinish = null;
178
195
  }
179
- return { state: { lifecycle, tools, pendingFinish }, events, done };
196
+ return { state: { lifecycle, tools, pendingFinish, finalized }, events, done };
180
197
  };
181
198
  /** Finalize a stream that ended without a trailing usage chunk (no-op if already finished). */
182
199
  const finalizeStream = (state) => {
@@ -20,9 +20,18 @@ type AppendOutcome = {
20
20
  };
21
21
  declare const empty: () => ToolStreamState;
22
22
  /**
23
- * Append a tool-call delta. If the index is new, emit a `tool-input-start`
24
- * (using the delta's id/name if present, else synthesized). Then emit a
25
- * `tool-input-delta` when an argument fragment is present.
23
+ * Append a tool-call delta.
24
+ *
25
+ * Identity(id/name)可能在后续 delta 才到达:部分兼容 provider 的首个
26
+ * tool_calls 片段只带 index 与 arguments,id/name 缺席。此前对无 id/name 的
27
+ * delta 整体丢弃——领先的 arguments 片段静默蒸发,工具入参被截断/损坏
28
+ * (JSON 解析失败或语义漂移的「执行了错误参数」)。正确语义:
29
+ * 1. 身份未齐时创建占位条目,片段只累积、不发事件(无 id 无法发事件);
30
+ * 2. 身份到达时补发 tool-input-start + 已缓冲的 tool-input-delta,再追加
31
+ * 当前片段——事件顺序与参数内容完整;
32
+ * 3. 身份始终缺失的条目在 finishAll 静默跳过(无法映射为工具调用,
33
+ * 与旧的「整体丢弃」等价——空 id 的 tool call 会在下一轮触发
34
+ * invalid tool_call_id)。
26
35
  */
27
36
  declare const appendOrStart: (state: ToolStreamState, delta: DeltaInput) => AppendOutcome;
28
37
  type FinishedTool = {
@@ -1,41 +1,61 @@
1
1
  import { isLLMError, llmError, reasonMessage } from '../../schema/errors.js';
2
2
  const empty = () => ({});
3
3
  /**
4
- * Append a tool-call delta. If the index is new, emit a `tool-input-start`
5
- * (using the delta's id/name if present, else synthesized). Then emit a
6
- * `tool-input-delta` when an argument fragment is present.
4
+ * Append a tool-call delta.
5
+ *
6
+ * Identity(id/name)可能在后续 delta 才到达:部分兼容 provider 的首个
7
+ * tool_calls 片段只带 index 与 arguments,id/name 缺席。此前对无 id/name 的
8
+ * delta 整体丢弃——领先的 arguments 片段静默蒸发,工具入参被截断/损坏
9
+ * (JSON 解析失败或语义漂移的「执行了错误参数」)。正确语义:
10
+ * 1. 身份未齐时创建占位条目,片段只累积、不发事件(无 id 无法发事件);
11
+ * 2. 身份到达时补发 tool-input-start + 已缓冲的 tool-input-delta,再追加
12
+ * 当前片段——事件顺序与参数内容完整;
13
+ * 3. 身份始终缺失的条目在 finishAll 静默跳过(无法映射为工具调用,
14
+ * 与旧的「整体丢弃」等价——空 id 的 tool call 会在下一轮触发
15
+ * invalid tool_call_id)。
7
16
  */
8
17
  const appendOrStart = (state, delta) => {
9
18
  const current = state[delta.index];
10
19
  const events = [];
11
20
  let next;
12
21
  if (current === undefined) {
13
- const id = delta.id;
14
- const name = delta.name;
15
- // 无效 id/name(缺失或空字符串):丢弃该 delta 而非抛错。部分兼容
16
- // provider 把 tool call 的 arguments 流式片段拆成多个独立 delta,每片
17
- // id/name 为空。接受空值会产生空 id 的 tool call,导致下一轮
18
- // invalid tool_call_id;抛错则让整个流崩溃。跳过最稳妥。
19
- if (!id || !name) {
20
- return { state, events };
21
- }
22
- next = { id, name, input: '', started: false };
23
- const start = { type: 'tool-input-start', id, name };
24
- events.push(start);
25
- next.started = true;
22
+ next = { id: delta.id ?? '', name: delta.name ?? '', input: '', started: false };
26
23
  }
27
24
  else {
28
25
  next = { ...current };
29
26
  }
27
+ // 回填迟到的身份字段(已就位的字段不受后续 delta 的空值影响)
28
+ if (next.id === '' && delta.id)
29
+ next.id = delta.id;
30
+ if (next.name === '' && delta.name)
31
+ next.name = delta.name;
32
+ const identityReady = next.id !== '' && next.name !== '';
33
+ if (identityReady && !next.started) {
34
+ const start = { type: 'tool-input-start', id: next.id, name: next.name };
35
+ events.push(start);
36
+ next.started = true;
37
+ // 身份到达前已缓冲的片段按到达顺序补发
38
+ if (next.input.length > 0) {
39
+ const buffered = {
40
+ type: 'tool-input-delta',
41
+ id: next.id,
42
+ name: next.name,
43
+ text: next.input,
44
+ };
45
+ events.push(buffered);
46
+ }
47
+ }
30
48
  if (delta.argumentsDelta !== undefined && delta.argumentsDelta.length > 0) {
31
49
  next.input += delta.argumentsDelta;
32
- const d = {
33
- type: 'tool-input-delta',
34
- id: next.id,
35
- name: next.name,
36
- text: delta.argumentsDelta,
37
- };
38
- events.push(d);
50
+ if (next.started) {
51
+ const d = {
52
+ type: 'tool-input-delta',
53
+ id: next.id,
54
+ name: next.name,
55
+ text: delta.argumentsDelta,
56
+ };
57
+ events.push(d);
58
+ }
39
59
  }
40
60
  return { state: { ...state, [delta.index]: next }, events };
41
61
  };
@@ -64,6 +84,10 @@ const finishAll = (state) => {
64
84
  const tool = state[Number(key)];
65
85
  if (tool === undefined)
66
86
  continue;
87
+ // 身份始终缺失的占位条目(appendOrStart 缓冲后 id/name 从未到达)无法
88
+ // 映射为工具调用——静默跳过,与旧的「整体丢弃」口径一致。
89
+ if (!tool.started)
90
+ continue;
67
91
  const end = { type: 'tool-input-end', id: tool.id, name: tool.name };
68
92
  events.push(end);
69
93
  // 解析失败不抛错:模型可能输出不完整 JSON(流被截断/提前结束)。
@@ -41,7 +41,10 @@ const toInternalMessage = (msg) => {
41
41
  const parts = msg.content.map((p) => {
42
42
  if (p.type === 'text')
43
43
  return { type: 'text', text: p.text };
44
- return { type: 'text', text: `[image: ${p.mediaType}]` };
44
+ // 图片数据必须送达 provider:此前降级为 `[image: <mediaType>]` 文本占位符,
45
+ // base64 静默丢弃——模型收到的只是一行文字,多模态能力在最后一公里失效
46
+ // (前端附件/持久化/token 估算全部就绪,唯独此处丢数据)。
47
+ return { type: 'image', mediaType: p.mediaType, data: p.data };
45
48
  });
46
49
  // assistant messages may carry tool_calls
47
50
  if (msg.role === 'assistant' && msg.toolCalls !== undefined) {
@@ -164,10 +167,22 @@ const chatStream = async function* (ctx, request, options) {
164
167
  const targets = [chain.primary, ...chain.fallbacks];
165
168
  let started = false;
166
169
  let lastError;
167
- for (const target of targets) {
170
+ for (let i = 0; i < targets.length; i++) {
171
+ const target = targets[i];
168
172
  if (target === undefined)
169
173
  continue;
170
- resolveRoute(ctx.registry, target.provider, target.model); // fail-fast NoRoute
174
+ try {
175
+ resolveRoute(ctx.registry, target.provider, target.model);
176
+ }
177
+ catch (error) {
178
+ // 主路由未注册 = 配置错误 → 快速失败;回退目标未注册(provider 被跳过注册:
179
+ // apiKey 跨机不可解/缺 baseURL/畸形条目)只跳过该目标——此前整条链被
180
+ // NoRoute 击穿,可用回退永不尝试,原始错误也被顶替。
181
+ if (i === 0)
182
+ throw error;
183
+ lastError = error;
184
+ continue;
185
+ }
171
186
  try {
172
187
  for await (const sc of streamTarget(ctx, request, target, chain)) {
173
188
  started = true;
@@ -1,6 +1,7 @@
1
1
  import type { LLMErrorReason, RetryPolicy } from './schema/errors.js';
2
2
  declare const RETRY_INITIAL_DELAY = 2000;
3
3
  declare const RETRY_MAX_DELAY_NO_HEADERS = 30000;
4
+ /** 32 位 timer 安全上限(> 此值 setTimeout 钳到 1ms);与 shared MAX_TIMER_MS 单一来源。 */
4
5
  declare const RETRY_MAX_DELAY = 2147483647;
5
6
  /**
6
7
  * Compute the delay before the next retry attempt (ms).
package/dist/llm/retry.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { MAX_TIMER_MS } from '../shared/timer.js';
1
2
  import { isLLMError, reasonRetryAfterMs, retryPolicy } from './schema/errors.js';
2
3
  const RETRY_INITIAL_DELAY = 2_000;
3
4
  const RETRY_BACKOFF_FACTOR = 2;
4
5
  const RETRY_MAX_DELAY_NO_HEADERS = 30_000;
5
- const RETRY_MAX_DELAY = 2_147_483_647;
6
+ /** 32 位 timer 安全上限(> 此值 setTimeout 钳到 1ms);与 shared MAX_TIMER_MS 单一来源。 */
7
+ const RETRY_MAX_DELAY = MAX_TIMER_MS;
6
8
  /** Extract response headers from an LLMError's http context (if any). */
7
9
  const errorHeaders = (error) => {
8
10
  if (!isLLMError(error))
@@ -15,6 +17,14 @@ const errorHeaders = (error) => {
15
17
  };
16
18
  /** Cap a delay to the 32-bit safe ceiling. */
17
19
  const capDelay = (ms) => Math.min(ms, RETRY_MAX_DELAY);
20
+ /** 严格解析「正有限毫秒数」:Infinity('1e999')/NaN/负数/零一律拒绝。
21
+ * parseFloat('1e999') = Infinity 能穿过 Number.isNaN 检查——capDelay(Infinity)
22
+ * 后单次重试延迟 2^31ms ≈ 24.8 天,且 RateLimit 的 policy maxDelay=Infinity
23
+ * 不拦截,agent run 实际挂死。 */
24
+ const parsePositiveFinite = (s) => {
25
+ const n = Number.parseFloat(s);
26
+ return Number.isFinite(n) && n > 0 ? n : null;
27
+ };
18
28
  /**
19
29
  * Compute the delay before the next retry attempt (ms).
20
30
  * Honors retry-after / retry-after-ms headers when present, else exponential backoff
@@ -22,25 +32,28 @@ const capDelay = (ms) => Math.min(ms, RETRY_MAX_DELAY);
22
32
  */
23
33
  const delay = (attempt, error, initialDelayMs = RETRY_INITIAL_DELAY) => {
24
34
  const headers = errorHeaders(error);
35
+ // 无可用重试指示时统一 30s 上限退避。此前仅无 headers 路径 cap 30s——headers
36
+ // 存在但缺/坏 retry-after(如 500 错误携带其他头)时退避无上限,attempt=10
37
+ // 即膨胀到 2^9*2000 ≈ 17 分钟。
38
+ const backoff = () => capDelay(Math.min(initialDelayMs * RETRY_BACKOFF_FACTOR ** (attempt - 1), RETRY_MAX_DELAY_NO_HEADERS));
25
39
  if (headers) {
26
40
  const retryAfterMs = headers['retry-after-ms'];
27
41
  if (retryAfterMs !== undefined) {
28
- const parsedMs = Number.parseFloat(retryAfterMs);
29
- if (!Number.isNaN(parsedMs))
42
+ const parsedMs = parsePositiveFinite(retryAfterMs);
43
+ if (parsedMs !== null)
30
44
  return capDelay(parsedMs);
31
45
  }
32
46
  const retryAfter = headers['retry-after'];
33
47
  if (retryAfter !== undefined) {
34
- const parsedSeconds = Number.parseFloat(retryAfter);
35
- if (!Number.isNaN(parsedSeconds))
48
+ const parsedSeconds = parsePositiveFinite(retryAfter);
49
+ if (parsedSeconds !== null)
36
50
  return capDelay(Math.ceil(parsedSeconds * 1000));
37
51
  const parsed = Date.parse(retryAfter) - Date.now();
38
52
  if (!Number.isNaN(parsed) && parsed > 0)
39
53
  return capDelay(Math.ceil(parsed));
40
54
  }
41
- return capDelay(initialDelayMs * RETRY_BACKOFF_FACTOR ** (attempt - 1));
42
55
  }
43
- return capDelay(Math.min(initialDelayMs * RETRY_BACKOFF_FACTOR ** (attempt - 1), RETRY_MAX_DELAY_NO_HEADERS));
56
+ return backoff();
44
57
  };
45
58
  /**
46
59
  * Decide whether a thrown error is retryable. Returns undefined when not retryable
@@ -14,8 +14,20 @@ const runWithFallback = async (registry, chain, run) => {
14
14
  const target = targets[i];
15
15
  if (target === undefined)
16
16
  continue;
17
- // Validate the route exists before attempting (fails fast with NoRoute).
18
- resolveRoute(registry, target.provider, target.model);
17
+ try {
18
+ // Validate the route exists before attempting.
19
+ resolveRoute(registry, target.provider, target.model);
20
+ }
21
+ catch (error) {
22
+ // 主路由未注册 = 配置错误 → 快速失败(不静默改用回退 provider)。
23
+ if (i === 0)
24
+ throw error;
25
+ // 回退目标未注册(provider 因 apiKey 跨机不可解/缺 baseURL/畸形条目被跳过
26
+ // 注册,但 config.providers 里仍在、buildFallbackChain 照样选中)只应跳过
27
+ // 该目标——此前整条链被 NoRoute 击穿:可用回退永不尝试,原始错误也被顶替。
28
+ lastError = error;
29
+ continue;
30
+ }
19
31
  try {
20
32
  const result = await withRetry(() => run(target.provider, target.model), {
21
33
  maxRetries: chain.maxRetries,
@@ -45,7 +45,14 @@ type ToolResultPart = {
45
45
  cache?: CacheHint;
46
46
  providerMetadata?: ProviderMetadata;
47
47
  };
48
- type ContentPart = TextPart | ReasoningPart | ToolCallPart | ToolResultPart;
48
+ type ImagePart = {
49
+ type: 'image';
50
+ mediaType: string;
51
+ /** base64 编码(不含 `data:` 前缀)。 */
52
+ data: string;
53
+ providerMetadata?: ProviderMetadata;
54
+ };
55
+ type ContentPart = TextPart | ReasoningPart | ToolCallPart | ToolResultPart | ImagePart;
49
56
  type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
50
57
  type Message = {
51
58
  id?: string;
@@ -93,5 +100,5 @@ type InternalRequest = {
93
100
  };
94
101
  /** Immutable patch of an InternalRequest. */
95
102
  declare const requestUpdate: (req: InternalRequest, patch: Partial<InternalRequest>) => InternalRequest;
96
- export type { ContentPart, InternalRequest, Message, MessageRole, ReasoningPart, ResponseFormat, SystemPart, TextPart, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart, ToolResultValue, };
103
+ export type { ContentPart, ImagePart, InternalRequest, Message, MessageRole, ReasoningPart, ResponseFormat, SystemPart, TextPart, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart, ToolResultValue, };
97
104
  export { messageAssistant, messageSystem, messageUser, requestUpdate };
@@ -1,2 +1,11 @@
1
+ /**
2
+ * CJK-aware token estimate.
3
+ * Chinese/CJK characters ≈ 2 tokens each (denser encoding).
4
+ * Other characters ≈ 4 chars/token (standard heuristic).
5
+ *
6
+ * 单一权威实现:session/token 此前持有一套 CJK 感知实现、本模块持有一套
7
+ * 「chars/4」朴素实现,同一字符串在两处给出不同估算(中文低估 ~8 倍),
8
+ * 预算/压缩口径随调用点漂移——已收敛至此。
9
+ */
1
10
  declare const estimateTokens: (text: string) => number;
2
11
  export { estimateTokens };
package/dist/llm/token.js CHANGED
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Heuristic token estimate: ~4 characters per token for English/code text.
3
- * This mirrors the common "chars / 4" approximation used by tiktoken's
4
- * rough estimate mode. A real BPE tokenizer can be plugged in later without
5
- * changing this signature.
2
+ * CJK-aware token estimate.
3
+ * Chinese/CJK characters ≈ 2 tokens each (denser encoding).
4
+ * Other characters ≈ 4 chars/token (standard heuristic).
5
+ *
6
+ * 单一权威实现:session/token 此前持有一套 CJK 感知实现、本模块持有一套
7
+ * 「chars/4」朴素实现,同一字符串在两处给出不同估算(中文低估 ~8 倍),
8
+ * 预算/压缩口径随调用点漂移——已收敛至此。
6
9
  */
7
- const TOKEN_CHARS_RATIO = 4;
8
10
  const estimateTokens = (text) => {
9
11
  if (text.length === 0)
10
12
  return 0;
11
- return Math.ceil(text.length / TOKEN_CHARS_RATIO);
13
+ const cjkCount = text.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g)?.length ?? 0;
14
+ const otherCount = text.length - cjkCount;
15
+ return Math.ceil(cjkCount * 2 + otherCount / 4);
12
16
  };
13
17
  export { estimateTokens };
@@ -16,6 +16,9 @@ const sseFraming = async function* (stream) {
16
16
  if (done)
17
17
  break;
18
18
  buffer += decoder.decode(value, { stream: true });
19
+ // SSE 规范允许 CRLF 行结束(部分网关/代理改写行结束符)——归一化为 LF
20
+ // 再分帧,否则 \r\n\r\n 分隔的事件永远匹配不到 \n\n 而滞留缓冲区。
21
+ buffer = buffer.replace(/\r\n/g, '\n');
19
22
  let sepIndex = buffer.indexOf('\n\n');
20
23
  while (sepIndex !== -1) {
21
24
  const block = buffer.slice(0, sepIndex);
@@ -154,14 +157,16 @@ const classifyHttpError = (status, body, url, headers) => {
154
157
  });
155
158
  };
156
159
  const parseRetryAfterMs = (retryAfterMs, retryAfter) => {
160
+ // 与 retry.ts delay() 同口径:仅正有限数('1e999' → Infinity 必须拒绝,
161
+ // 否则塞进 reason 后重试层延迟 2^31ms ≈ 24.8 天挂死 agent run)。
157
162
  if (retryAfterMs !== null && retryAfterMs !== undefined) {
158
163
  const parsed = Number.parseFloat(retryAfterMs);
159
- if (!Number.isNaN(parsed))
164
+ if (Number.isFinite(parsed) && parsed > 0)
160
165
  return Math.ceil(parsed);
161
166
  }
162
167
  if (retryAfter !== null && retryAfter !== undefined) {
163
168
  const parsed = Number.parseFloat(retryAfter);
164
- if (!Number.isNaN(parsed))
169
+ if (Number.isFinite(parsed) && parsed > 0)
165
170
  return Math.ceil(parsed * 1000);
166
171
  }
167
172
  return undefined;
@@ -0,0 +1,11 @@
1
+ import type { MCPTransport } from './transport.js';
2
+ type MCPClient = {
3
+ request: (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
4
+ notify: (method: string, params?: unknown) => void;
5
+ close: () => void;
6
+ };
7
+ /** 默认单请求超时(initialize/tools/list 用);工具调用在调用点传更长的值。 */
8
+ declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
9
+ declare function createMCPClient(transport: MCPTransport): MCPClient;
10
+ export type { MCPClient };
11
+ export { createMCPClient, DEFAULT_REQUEST_TIMEOUT_MS };
@@ -0,0 +1,70 @@
1
+ // MCP JSON-RPC 客户端(spec §6.1):request/response 配对(seq id)、超时、
2
+ // notification 无响应、连接关闭时 reject 全部 pending。协议纯逻辑,transport 注入。
3
+ /** 默认单请求超时(initialize/tools/list 用);工具调用在调用点传更长的值。 */
4
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
5
+ function createMCPClient(transport) {
6
+ let nextId = 1;
7
+ const pending = new Map();
8
+ const failAll = (message) => {
9
+ for (const p of pending.values()) {
10
+ if (p.timer)
11
+ clearTimeout(p.timer);
12
+ p.reject(new Error(message));
13
+ }
14
+ pending.clear();
15
+ };
16
+ transport.onMessage((msg) => {
17
+ if (!('id' in msg))
18
+ return; // 服务端通知:当前无订阅方,忽略
19
+ const p = pending.get(msg.id);
20
+ if (!p)
21
+ return; // 已超时/已响应/未知 id
22
+ pending.delete(msg.id);
23
+ if (p.timer)
24
+ clearTimeout(p.timer);
25
+ if (msg.error) {
26
+ p.reject(new Error(`MCP ${msg.error.message} (code ${msg.error.code}${msg.error.data ? `, data: ${JSON.stringify(msg.error.data)}` : ''})`));
27
+ }
28
+ else {
29
+ p.resolve(msg.result);
30
+ }
31
+ });
32
+ transport.onClose(() => {
33
+ failAll('MCP transport closed');
34
+ });
35
+ return {
36
+ request(method, params, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
37
+ const id = nextId;
38
+ nextId += 1;
39
+ const msg = {
40
+ jsonrpc: '2.0',
41
+ id,
42
+ method,
43
+ ...(params !== undefined ? { params } : {}),
44
+ };
45
+ return new Promise((resolve, reject) => {
46
+ const timer = timeoutMs > 0
47
+ ? setTimeout(() => {
48
+ pending.delete(id);
49
+ reject(new Error(`MCP request "${method}" timed out after ${timeoutMs}ms`));
50
+ }, timeoutMs)
51
+ : undefined;
52
+ pending.set(id, { resolve, reject, timer });
53
+ transport.send(msg);
54
+ });
55
+ },
56
+ notify(method, params) {
57
+ const msg = {
58
+ jsonrpc: '2.0',
59
+ method,
60
+ ...(params !== undefined ? { params } : {}),
61
+ };
62
+ transport.send(msg);
63
+ },
64
+ close() {
65
+ failAll('MCP client closed');
66
+ transport.close();
67
+ },
68
+ };
69
+ }
70
+ export { createMCPClient, DEFAULT_REQUEST_TIMEOUT_MS };