@coralai/sps-cli 0.65.33 → 0.65.34
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/dist/chat-protocol/events.d.ts +131 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/events.js +134 -0
- package/dist/chat-protocol/events.js.map +1 -0
- package/dist/chat-protocol/form.d.ts +66 -0
- package/dist/chat-protocol/form.d.ts.map +1 -0
- package/dist/chat-protocol/form.js +25 -0
- package/dist/chat-protocol/form.js.map +1 -0
- package/dist/chat-protocol/index.d.ts +21 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +17 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/reduce.d.ts +33 -0
- package/dist/chat-protocol/reduce.d.ts.map +1 -0
- package/dist/chat-protocol/reduce.js +204 -0
- package/dist/chat-protocol/reduce.js.map +1 -0
- package/dist/chat-protocol/types.d.ts +85 -0
- package/dist/chat-protocol/types.d.ts.map +1 -0
- package/dist/chat-protocol/types.js +9 -0
- package/dist/chat-protocol/types.js.map +1 -0
- package/dist/chat-protocol/typewriter.d.ts +25 -0
- package/dist/chat-protocol/typewriter.d.ts.map +1 -0
- package/dist/chat-protocol/typewriter.js +36 -0
- package/dist/chat-protocol/typewriter.js.map +1 -0
- package/dist/console/chatEventLog.d.ts +47 -0
- package/dist/console/chatEventLog.d.ts.map +1 -0
- package/dist/console/chatEventLog.js +79 -0
- package/dist/console/chatEventLog.js.map +1 -0
- package/dist/console/routes/chat.d.ts +3 -0
- package/dist/console/routes/chat.d.ts.map +1 -1
- package/dist/console/routes/chat.js +173 -114
- package/dist/console/routes/chat.js.map +1 -1
- package/dist/console-assets/assets/{ShowcasePage-NmXybDqC.js → ShowcasePage-x-qRvSx_.js} +1 -1
- package/dist/console-assets/assets/{index-DCAnGMiQ.js → index-Ifjq4orx.js} +216 -216
- package/dist/console-assets/index.html +1 -1
- package/dist/core/activityStream.d.ts +1 -1
- package/dist/core/activityStream.d.ts.map +1 -1
- package/dist/core/activityStream.js +7 -2
- package/dist/core/activityStream.js.map +1 -1
- package/dist/core/agents/AgentBackend.d.ts +11 -1
- package/dist/core/agents/AgentBackend.d.ts.map +1 -1
- package/dist/core/agents/AgentBackend.js.map +1 -1
- package/dist/core/interactions/formSpec.d.ts +57 -0
- package/dist/core/interactions/formSpec.d.ts.map +1 -0
- package/dist/core/interactions/formSpec.js +123 -0
- package/dist/core/interactions/formSpec.js.map +1 -0
- package/dist/openai-agent/interactions/broker.d.ts.map +1 -1
- package/dist/openai-agent/interactions/broker.js +38 -77
- package/dist/openai-agent/interactions/broker.js.map +1 -1
- package/package.json +1 -1
|
@@ -17,23 +17,19 @@ import { uniqueTmpPath } from '../../infra/filesystem.js';
|
|
|
17
17
|
import { basename, dirname, resolve } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { spsMcpStdioConfig } from '../../providers/mcp/spsMcpStdioConfig.js';
|
|
20
|
-
import { Agent, run } from '@openai/agents';
|
|
21
20
|
import { Hono } from 'hono';
|
|
22
21
|
import { ensureDaemon } from '../../commands/agentDaemon.js';
|
|
23
22
|
import { jsonlExists, readSessionModel, recoverTrailingText, sanitizeToolInput } from '../../core/claudeJsonl.js';
|
|
24
|
-
import {
|
|
23
|
+
import { appendFrame, dropSessionLog, framesSince } from '../chatEventLog.js';
|
|
24
|
+
import { expandChoicesSpec, expandFormSpec, } from '../../core/interactions/formSpec.js';
|
|
25
|
+
import { INTERACTION_TOOL_NAMES } from '../../providers/mcp/interactionTools.js';
|
|
25
26
|
import { readProjectAgentConfig } from '../../core/agents/projectAgentConfig.js';
|
|
26
27
|
import { resolveWorkerModelEnv } from '../../core/agents/workerModelEnv.js';
|
|
27
28
|
import { readModelSettingsFile, writeModelSettingsFile } from '../../core/agents/modelSettingsFile.js';
|
|
28
29
|
import { loadAgentsConfig } from '../../core/intel/agentsConfig.js';
|
|
29
|
-
import { RuntimeSession } from '../../core/intel/RuntimeSession.js';
|
|
30
30
|
import { DaemonClient } from '../../daemon/daemonClient.js';
|
|
31
31
|
import { resolveEligibleSkillNames, SkillsManager } from '../../core/skills/index.js';
|
|
32
|
-
import { chatTools } from '../../providers/llm/chatTools.js';
|
|
33
|
-
import { skillTools } from '../../providers/llm/skillTools.js';
|
|
34
|
-
import { createReviewerModel } from '../../providers/llm/modelFactory.js';
|
|
35
32
|
import { resolveAgentProvider } from '../../providers/llm/providers.js';
|
|
36
|
-
import { workerTools } from '../../providers/llm/workerTools.js';
|
|
37
33
|
import { toHttpStatus, toProblemJson } from '../../shared/errors.js';
|
|
38
34
|
import { chatAttachmentsDirFor, chatSessionsDir, chatWorkspacesRoot } from '../../shared/runtimePaths.js';
|
|
39
35
|
import { workspacesRoot } from '../../core/workspacePaths.js';
|
|
@@ -106,6 +102,22 @@ function resolveChatModel(agentKind, cwd) {
|
|
|
106
102
|
*/
|
|
107
103
|
const chatBus = new EventEmitter();
|
|
108
104
|
chatBus.setMaxListeners(0);
|
|
105
|
+
/**
|
|
106
|
+
* 发布一条 chat 事件:**先记进会话事件日志拿到帧号,再广播**。
|
|
107
|
+
*
|
|
108
|
+
* 帧号是 SSE 的 `id:`,浏览器重连会自动带 `Last-Event-ID` 回来,服务端据此逐事件补发
|
|
109
|
+
* (见 `console/chatEventLog`)。所有对外事件都必须走这里 —— 绕过它的事件不会被记录,
|
|
110
|
+
* 重连时就是一个静默的洞。
|
|
111
|
+
*/
|
|
112
|
+
function publish(event, data) {
|
|
113
|
+
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
114
|
+
if (!sessionId) {
|
|
115
|
+
chatBus.emit(event, data);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const frameId = appendFrame(sessionId, event, data);
|
|
119
|
+
chatBus.emit(event, { ...data, frameId });
|
|
120
|
+
}
|
|
109
121
|
const activeStreams = new Map();
|
|
110
122
|
/**
|
|
111
123
|
* startRun 失败 → 用户可读文案。daemon 总闸拒绝(code='CAPACITY')带排队深度 + 建议重试秒数
|
|
@@ -183,7 +195,7 @@ function startTurn(sessionId, assistantId) {
|
|
|
183
195
|
const t = activeStreams.get(sessionId);
|
|
184
196
|
if (!t)
|
|
185
197
|
return;
|
|
186
|
-
|
|
198
|
+
publish('chat.message.heartbeat', { sessionId, assistantId, turnId: assistantId, seq: t.seq, ts: new Date().toISOString() });
|
|
187
199
|
}, HEARTBEAT_MS);
|
|
188
200
|
if (typeof heartbeat.unref === 'function')
|
|
189
201
|
heartbeat.unref();
|
|
@@ -209,11 +221,12 @@ function appendInteractionBlock(sessionId, assistantId, blocks, event) {
|
|
|
209
221
|
kind: event.kind,
|
|
210
222
|
title: event.title,
|
|
211
223
|
schema: event.schema,
|
|
224
|
+
respond: event.respond ?? 'endpoint',
|
|
212
225
|
revision: event.revision ?? 1,
|
|
213
226
|
status: 'pending',
|
|
214
227
|
};
|
|
215
228
|
blocks.push(block);
|
|
216
|
-
|
|
229
|
+
publish('chat.message.chunk.interaction', {
|
|
217
230
|
sessionId,
|
|
218
231
|
assistantId,
|
|
219
232
|
turnId: assistantId,
|
|
@@ -222,6 +235,85 @@ function appendInteractionBlock(sessionId, assistantId, blocks, event) {
|
|
|
222
235
|
});
|
|
223
236
|
return block;
|
|
224
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* 交互工具的**工具块抑制器**。
|
|
240
|
+
*
|
|
241
|
+
* 🔴 为什么要抑制:`present_form` / `present_choices` 的 UI **就是交互块本身**,再画一条
|
|
242
|
+
* 工具调用小条是多余的,而且有两个真实的误导:
|
|
243
|
+
* - openai 路上这次工具调用会**真的挂起**(run.waiting_for_input),挂起期间工具块是非终态,
|
|
244
|
+
* 前端按非终态渲染成转圈 —— 用户填表那几十秒里它一直在转,看着像 agent 还在想;
|
|
245
|
+
* - 恢复后该块落成 `interrupted`,前端显示【已中断】,而这其实是**正常**的挂起-恢复。
|
|
246
|
+
*
|
|
247
|
+
* 两条后端路径的接管方式不同:
|
|
248
|
+
* - claude(MCP):工具立即返回、回合不挂起 → 这里把 tool_use **转成**交互块,
|
|
249
|
+
* 答案走 `respond:'message'`(作为新的用户消息发回);
|
|
250
|
+
* - openai(broker):worker 自己会发权威的 `interaction` 事件 → 这里只**丢掉**工具块,
|
|
251
|
+
* 不重复造一个(此前两个块都发,就是截图里"一条转圈的工具条 + 一张面板")。
|
|
252
|
+
*/
|
|
253
|
+
function createInteractionToolSink(sessionId, assistantId, blocks, backend) {
|
|
254
|
+
/** toolCallId → 工具名;首帧参数常为空,要等 tool_update 才凑得出 spec。 */
|
|
255
|
+
const tracked = new Map();
|
|
256
|
+
const emitted = new Set();
|
|
257
|
+
const tryEmit = (id, title, input) => {
|
|
258
|
+
if (backend !== 'claude' || emitted.has(id))
|
|
259
|
+
return;
|
|
260
|
+
const spec = toInteractionSpec(title, input);
|
|
261
|
+
if (!spec)
|
|
262
|
+
return; // 参数还没到齐,等下一帧
|
|
263
|
+
emitted.add(id);
|
|
264
|
+
appendInteractionBlock(sessionId, assistantId, blocks, {
|
|
265
|
+
interactionId: id,
|
|
266
|
+
kind: baseToolName(title) === 'present_choices' ? 'choices' : 'form',
|
|
267
|
+
title: spec.title,
|
|
268
|
+
schema: spec,
|
|
269
|
+
respond: 'message',
|
|
270
|
+
revision: 1,
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
return {
|
|
274
|
+
onToolUse: (id, title, input) => {
|
|
275
|
+
if (!isInteractionTool(title))
|
|
276
|
+
return false;
|
|
277
|
+
tracked.set(id, title);
|
|
278
|
+
tryEmit(id, title, input);
|
|
279
|
+
return true;
|
|
280
|
+
},
|
|
281
|
+
onToolUpdate: (id, input) => {
|
|
282
|
+
const title = tracked.get(id);
|
|
283
|
+
if (!title)
|
|
284
|
+
return false;
|
|
285
|
+
tryEmit(id, title, input);
|
|
286
|
+
return true;
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/** 工具名去掉 MCP 前缀(claude 侧是 `mcp__sps__present_form`,openai broker 侧是裸名)。 */
|
|
291
|
+
function baseToolName(title) {
|
|
292
|
+
return title.startsWith('mcp__sps__') ? title.slice('mcp__sps__'.length) : title;
|
|
293
|
+
}
|
|
294
|
+
/** 这个工具是不是"要往返 chat UI"的交互工具(present_form / present_choices)。 */
|
|
295
|
+
function isInteractionTool(title) {
|
|
296
|
+
return INTERACTION_TOOL_NAMES.has(baseToolName(title));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 把交互工具的原始入参归一成规范 `FormSpec`(见 `core/interactions/formSpec`)。
|
|
300
|
+
*
|
|
301
|
+
* 🔴 此前 `template:'image'` 是客户端展开的 —— console 一份、openai broker 一份,注释互相
|
|
302
|
+
* 提醒"要手工同步";coral 与 gameforge 接生图表单就是第三、第四份。展开挪到这里之后,
|
|
303
|
+
* 发出去的事件永远携带完整字段表,产品端零模板知识。省 token 的初衷不变:模板名只在
|
|
304
|
+
* 模型 ↔ 工具之间传,展开发生在工具调用被透传给前端的那一刻。
|
|
305
|
+
*
|
|
306
|
+
* 选择器也走同一套 schema(只有 radio 的表单),让产品只维护一个渲染器。
|
|
307
|
+
* 展不开(参数还没到 / 无效)→ null,调用方等 tool_update 的完整参数。
|
|
308
|
+
*/
|
|
309
|
+
function toInteractionSpec(toolTitle, input) {
|
|
310
|
+
const base = baseToolName(toolTitle);
|
|
311
|
+
if (base === 'present_form')
|
|
312
|
+
return expandFormSpec(input);
|
|
313
|
+
if (base === 'present_choices')
|
|
314
|
+
return expandChoicesSpec(input);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
225
317
|
function findInteractionBlock(session, interactionId) {
|
|
226
318
|
for (let index = session.messages.length - 1; index >= 0; index--) {
|
|
227
319
|
const message = session.messages[index];
|
|
@@ -306,13 +398,6 @@ function reconcileStaleTurns(log) {
|
|
|
306
398
|
}
|
|
307
399
|
async function streamAssistantResponse(sessionId, assistantId, userContent, log, attachments, dependencies = {}) {
|
|
308
400
|
const selected = readSession(sessionId);
|
|
309
|
-
// 旧 OpenAI 会话没有 agentMode,继续走历史 SDK 会话文件;只有显式 daemon 的
|
|
310
|
-
// 新会话才迁入统一宿主,避免升级后丢掉既有上下文。
|
|
311
|
-
if (selected?.agentKind === 'openai' &&
|
|
312
|
-
(selected.agentMode ?? 'sdk') === 'sdk') {
|
|
313
|
-
await streamOpenaiChat(sessionId, assistantId, userContent, log);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
401
|
const backend = selected?.agentKind === 'openai' ? 'openai' : 'claude';
|
|
317
402
|
if (!(await (dependencies.ensureDaemon ?? ensureDaemon)())) {
|
|
318
403
|
persistAndEmitComplete(sessionId, {
|
|
@@ -571,6 +656,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
|
|
|
571
656
|
const subscription = client.subscribeRunResilient(slot);
|
|
572
657
|
// 复用 POST 时 startTurn 预登记的进行中回合(blocks 引用,后续就地追加);未预登记则退化自建(防御)。
|
|
573
658
|
const blocks = activeStreams.get(sessionId)?.blocks ?? startTurn(sessionId, assistantId);
|
|
659
|
+
const interactionSink = createInteractionToolSink(sessionId, assistantId, blocks, backend);
|
|
574
660
|
let accumulatedText = '';
|
|
575
661
|
let truncated = false;
|
|
576
662
|
const consume = (async () => {
|
|
@@ -614,7 +700,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
|
|
|
614
700
|
last.text += piece;
|
|
615
701
|
else
|
|
616
702
|
blocks.push({ type: 'text', text: piece });
|
|
617
|
-
|
|
703
|
+
publish('chat.message.chunk.text', {
|
|
618
704
|
sessionId,
|
|
619
705
|
assistantId,
|
|
620
706
|
turnId: assistantId,
|
|
@@ -628,6 +714,9 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
|
|
|
628
714
|
}
|
|
629
715
|
else if (evt.event === 'tool_use') {
|
|
630
716
|
const input = sanitizeToolInput(evt.input);
|
|
717
|
+
// 交互工具由 sink 接管(转成交互块 / 丢弃),不再画工具小条。
|
|
718
|
+
if (interactionSink.onToolUse(evt.id, evt.title, input))
|
|
719
|
+
continue;
|
|
631
720
|
blocks.push({
|
|
632
721
|
type: 'tool_use',
|
|
633
722
|
id: evt.id,
|
|
@@ -637,7 +726,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
|
|
|
637
726
|
input,
|
|
638
727
|
locations: evt.locations,
|
|
639
728
|
});
|
|
640
|
-
|
|
729
|
+
publish('chat.message.chunk.tool_use', {
|
|
641
730
|
sessionId,
|
|
642
731
|
assistantId,
|
|
643
732
|
turnId: assistantId,
|
|
@@ -651,8 +740,11 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
|
|
|
651
740
|
});
|
|
652
741
|
}
|
|
653
742
|
else if (evt.event === 'tool_update') {
|
|
654
|
-
const tool = blocks.find((b) => b.type === 'tool_use' && b.id === evt.id);
|
|
655
743
|
const lateInput = evt.input != null ? sanitizeToolInput(evt.input) : undefined;
|
|
744
|
+
// 交互工具的完整参数常常**迟到**(流式首帧 rawInput 为 {}),交互块可能要到这一帧才发得出。
|
|
745
|
+
if (interactionSink.onToolUpdate(evt.id, lateInput))
|
|
746
|
+
continue;
|
|
747
|
+
const tool = blocks.find((b) => b.type === 'tool_use' && b.id === evt.id);
|
|
656
748
|
if (tool && tool.type === 'tool_use') {
|
|
657
749
|
tool.status = evt.status;
|
|
658
750
|
// 完整参数迟到时补上:空对象 `{}` 也算"待补"(流式首帧 rawInput 为 {},完整值在 update 里)。
|
|
@@ -661,7 +753,7 @@ async function streamAssistantResponse(sessionId, assistantId, userContent, log,
|
|
|
661
753
|
if (evt.locations != null && tool.locations == null)
|
|
662
754
|
tool.locations = evt.locations;
|
|
663
755
|
}
|
|
664
|
-
|
|
756
|
+
publish('chat.message.chunk.tool_update', {
|
|
665
757
|
sessionId,
|
|
666
758
|
assistantId,
|
|
667
759
|
turnId: assistantId,
|
|
@@ -829,6 +921,8 @@ function openaiResumeEnsureArgs(sessionId, session) {
|
|
|
829
921
|
async function streamInteractionResponse(sessionId, assistantId, client, slot, afterSequence, log) {
|
|
830
922
|
const blocks = activeStreams.get(sessionId)?.blocks ??
|
|
831
923
|
startTurn(sessionId, assistantId);
|
|
924
|
+
// 交互恢复路只发生在 openai(durable interactions),sink 在这里只负责丢弃工具块。
|
|
925
|
+
const interactionSink = createInteractionToolSink(sessionId, assistantId, blocks, 'openai');
|
|
832
926
|
const subscription = client.subscribeRunResilient(slot, afterSequence);
|
|
833
927
|
let accumulatedText = '';
|
|
834
928
|
let truncated = false;
|
|
@@ -854,7 +948,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
|
|
|
854
948
|
last.text += piece;
|
|
855
949
|
else
|
|
856
950
|
blocks.push({ type: 'text', text: piece });
|
|
857
|
-
|
|
951
|
+
publish('chat.message.chunk.text', {
|
|
858
952
|
sessionId,
|
|
859
953
|
assistantId,
|
|
860
954
|
turnId: assistantId,
|
|
@@ -866,6 +960,9 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
|
|
|
866
960
|
}
|
|
867
961
|
else if (evt.event === 'tool_use') {
|
|
868
962
|
const input = sanitizeToolInput(evt.input);
|
|
963
|
+
// 交互工具由 sink 接管(本函数是 openai 的恢复路,sink 只做丢弃)。
|
|
964
|
+
if (interactionSink.onToolUse(evt.id, evt.title, input))
|
|
965
|
+
continue;
|
|
869
966
|
blocks.push({
|
|
870
967
|
type: 'tool_use',
|
|
871
968
|
id: evt.id,
|
|
@@ -875,7 +972,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
|
|
|
875
972
|
input,
|
|
876
973
|
locations: evt.locations,
|
|
877
974
|
});
|
|
878
|
-
|
|
975
|
+
publish('chat.message.chunk.tool_use', {
|
|
879
976
|
sessionId,
|
|
880
977
|
assistantId,
|
|
881
978
|
turnId: assistantId,
|
|
@@ -889,8 +986,10 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
|
|
|
889
986
|
});
|
|
890
987
|
}
|
|
891
988
|
else if (evt.event === 'tool_update') {
|
|
892
|
-
const tool = blocks.find((block) => block.type === 'tool_use' && block.id === evt.id);
|
|
893
989
|
const input = evt.input == null ? undefined : sanitizeToolInput(evt.input);
|
|
990
|
+
if (interactionSink.onToolUpdate(evt.id, input))
|
|
991
|
+
continue;
|
|
992
|
+
const tool = blocks.find((block) => block.type === 'tool_use' && block.id === evt.id);
|
|
894
993
|
if (tool?.type === 'tool_use') {
|
|
895
994
|
tool.status = evt.status;
|
|
896
995
|
if (input !== undefined &&
|
|
@@ -902,7 +1001,7 @@ async function streamInteractionResponse(sessionId, assistantId, client, slot, a
|
|
|
902
1001
|
tool.locations = evt.locations;
|
|
903
1002
|
}
|
|
904
1003
|
}
|
|
905
|
-
|
|
1004
|
+
publish('chat.message.chunk.tool_update', {
|
|
906
1005
|
sessionId,
|
|
907
1006
|
assistantId,
|
|
908
1007
|
turnId: assistantId,
|
|
@@ -1051,77 +1150,6 @@ function loadBaseSkill(baseSkill, cwd, log) {
|
|
|
1051
1150
|
log?.warn(`baseSkill "${chosen}" 加载失败 —— 本会话不注入基座 skill(检查 HQ/项目 skills 里是否存在)`);
|
|
1052
1151
|
return '';
|
|
1053
1152
|
}
|
|
1054
|
-
async function streamOpenaiChat(sessionId, assistantId, userContent, log) {
|
|
1055
|
-
// 复用 POST 时 startTurn 预登记的进行中回合(blocks 引用);未预登记则退化自建(防御)。
|
|
1056
|
-
const blocks = activeStreams.get(sessionId)?.blocks ?? startTurn(sessionId, assistantId);
|
|
1057
|
-
let acc = '';
|
|
1058
|
-
try {
|
|
1059
|
-
const model = createReviewerModel(loadAgentsConfig().reviewer);
|
|
1060
|
-
// 设了工作目录 → 额外挂工作区文件工具(读/写/列/bash),让它能在已有项目里直接干活。
|
|
1061
|
-
const sess = readSession(sessionId);
|
|
1062
|
-
const cwd = sess?.cwd ?? undefined;
|
|
1063
|
-
// per-session 注入角色:有工作目录就读它的 AGENTS.md 当 instructions(项目定义角色),否则用通用兜底。
|
|
1064
|
-
// pinnedSkills:开局强制预载的 skill 正文(确定性,不赌模型自觉),prepend 在角色指令前。
|
|
1065
|
-
const instructions = loadPinnedSkills(sess?.pinnedSkills, cwd) + (readAgentsMd(cwd) ?? loadBaseSkill(sess?.baseSkill, cwd, log));
|
|
1066
|
-
// skill 发现/加载(list/search/load,经 SkillsManager 读 HQ)始终在;有 cwd 再加工作区文件/bash。
|
|
1067
|
-
const tools = [...chatTools(), ...skillTools(cwd), ...(cwd ? workerTools(cwd) : [])];
|
|
1068
|
-
const agent = new Agent({ name: 'chat', instructions, model, tools });
|
|
1069
|
-
// 会话历史持久化(跨消息记忆),后端 = chat-sessions/<id>.openai-session.json。
|
|
1070
|
-
const session = new RuntimeSession(resolve(chatSessionsDir(), `${sessionId}.openai-session.json`), `chat-${sessionId}`);
|
|
1071
|
-
const r = await run(agent, userContent, { stream: true, session });
|
|
1072
|
-
// 迭代完整事件流:raw 文本 delta → 流式文本块;run_item(tool_called/tool_output)→ tool_use 块。
|
|
1073
|
-
for await (const ev of r) {
|
|
1074
|
-
if (ev.type === 'raw_model_stream_event') {
|
|
1075
|
-
const data = ev.data;
|
|
1076
|
-
if (typeof data?.delta === 'string' && data.delta && (data.type ?? '').includes('output_text')) {
|
|
1077
|
-
const piece = data.delta;
|
|
1078
|
-
acc += piece;
|
|
1079
|
-
const last = blocks[blocks.length - 1];
|
|
1080
|
-
if (last?.type === 'text')
|
|
1081
|
-
last.text += piece;
|
|
1082
|
-
else
|
|
1083
|
-
blocks.push({ type: 'text', text: piece });
|
|
1084
|
-
chatBus.emit('chat.message.chunk.text', { sessionId, assistantId, turnId: assistantId, seq: bumpSeq(sessionId), text: piece });
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
else if (ev.type === 'run_item_stream_event') {
|
|
1088
|
-
const evName = ev.name;
|
|
1089
|
-
const raw = ev.item?.rawItem;
|
|
1090
|
-
if (evName === 'tool_called' && raw?.type === 'function_call') {
|
|
1091
|
-
const id = raw.callId ?? `tool-${blocks.length}`;
|
|
1092
|
-
let parsedArgs;
|
|
1093
|
-
try {
|
|
1094
|
-
parsedArgs = raw.arguments ? JSON.parse(raw.arguments) : undefined;
|
|
1095
|
-
}
|
|
1096
|
-
catch {
|
|
1097
|
-
parsedArgs = raw.arguments;
|
|
1098
|
-
}
|
|
1099
|
-
const input = sanitizeToolInput(parsedArgs);
|
|
1100
|
-
blocks.push({ type: 'tool_use', id, title: raw.name ?? 'tool', kind: 'function', status: 'in_progress', input });
|
|
1101
|
-
chatBus.emit('chat.message.chunk.tool_use', { sessionId, assistantId, turnId: assistantId, seq: bumpSeq(sessionId), id, title: raw.name ?? 'tool', kind: 'function', status: 'in_progress', input });
|
|
1102
|
-
}
|
|
1103
|
-
else if (evName === 'tool_output') {
|
|
1104
|
-
const id = raw?.callId;
|
|
1105
|
-
const b = blocks.find((x) => x.type === 'tool_use' && (id ? x.id === id : x.status === 'in_progress'));
|
|
1106
|
-
if (b && b.type === 'tool_use') {
|
|
1107
|
-
b.status = 'completed';
|
|
1108
|
-
chatBus.emit('chat.message.chunk.tool_update', { sessionId, assistantId, turnId: assistantId, seq: bumpSeq(sessionId), id: b.id, status: 'completed' });
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
await r.completed;
|
|
1114
|
-
if (!acc.trim())
|
|
1115
|
-
acc = String(r.finalOutput ?? ''); // 兜底:无 raw 文本 delta 时
|
|
1116
|
-
persistAndEmitComplete(sessionId, { id: assistantId, role: 'assistant', content: acc.trim(), blocks: blocks.length ? blocks : undefined, ts: new Date().toISOString(), status: 'complete' }, log);
|
|
1117
|
-
}
|
|
1118
|
-
catch (err) {
|
|
1119
|
-
persistAndEmitComplete(sessionId, { id: assistantId, role: 'error', content: `openai chat failed: ${err instanceof Error ? err.message : String(err)}`, ts: new Date().toISOString(), status: 'error' }, log);
|
|
1120
|
-
}
|
|
1121
|
-
finally {
|
|
1122
|
-
endTurn(sessionId); // 安全网:complete/error path 已 endTurn;此处兜底停心跳/撤快照
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
1153
|
/**
|
|
1126
1154
|
* v0.51.8:在 user 消息末尾追加附件清单。Worker(Claude Code)能用 Read 工具
|
|
1127
1155
|
* 自己拉文件内容;图片 / PDF / 文本 / 等都原生支持。
|
|
@@ -1179,7 +1207,7 @@ function persistAndEmitComplete(sessionId, msg, log) {
|
|
|
1179
1207
|
writeSession(session);
|
|
1180
1208
|
}
|
|
1181
1209
|
// 明确的终止事件(渐进增强:老客户端仍靠 complete 落定;新字段 turnId/final/seq/text 供补偿与判序)。
|
|
1182
|
-
|
|
1210
|
+
publish('chat.message.complete', {
|
|
1183
1211
|
sessionId,
|
|
1184
1212
|
assistantId: turnId,
|
|
1185
1213
|
turnId,
|
|
@@ -1190,7 +1218,7 @@ function persistAndEmitComplete(sessionId, msg, log) {
|
|
|
1190
1218
|
});
|
|
1191
1219
|
// 回合异常:再发一个明确的 error 事件(带 turnId),不静默中断。
|
|
1192
1220
|
if (msg.role === 'error') {
|
|
1193
|
-
|
|
1221
|
+
publish('chat.message.error', { sessionId, assistantId: turnId, turnId, final: true, seq, message: msg });
|
|
1194
1222
|
log.warn(`chat[${sessionId}]: ${msg.content}`);
|
|
1195
1223
|
}
|
|
1196
1224
|
else {
|
|
@@ -1237,7 +1265,8 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1237
1265
|
const agentKind = body.agentKind === 'openai' ? 'openai' : 'claude';
|
|
1238
1266
|
const configuredWorker = loadAgentsConfig().worker;
|
|
1239
1267
|
// 新建会话默认 daemon:openai 走真正的独立进程 agent(sps openai-agent,带 sandbox + 工具 + 技能)。
|
|
1240
|
-
//
|
|
1268
|
+
// 注:chat 侧已不按 agentMode 分流(阶段 3 收敛,openai 只剩统一宿主一条路);
|
|
1269
|
+
// 该字段仅作为会话元数据保留,dispatch 侧仍按各自持久值走。
|
|
1241
1270
|
const agentMode = body.agentMode ??
|
|
1242
1271
|
(configuredWorker.agent === agentKind ? configuredWorker.mode : 'daemon');
|
|
1243
1272
|
const r = await chat.create({
|
|
@@ -1256,7 +1285,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1256
1285
|
});
|
|
1257
1286
|
if (!r.ok)
|
|
1258
1287
|
return c.json(toProblemJson(r.error), toHttpStatus(r.error));
|
|
1259
|
-
|
|
1288
|
+
publish('chat.session.created', { sessionId: r.value.id });
|
|
1260
1289
|
return c.json(r.value, 201);
|
|
1261
1290
|
});
|
|
1262
1291
|
app.get('/sessions/:id', async (c) => {
|
|
@@ -1297,7 +1326,8 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1297
1326
|
catch {
|
|
1298
1327
|
/* non-fatal */
|
|
1299
1328
|
}
|
|
1300
|
-
|
|
1329
|
+
publish('chat.session.deleted', { sessionId: id });
|
|
1330
|
+
dropSessionLog(id); // 事件日志跟着会话走,别留孤儿
|
|
1301
1331
|
return c.body(null, 204);
|
|
1302
1332
|
});
|
|
1303
1333
|
app.post('/sessions/:id/interactions/:interactionId', async (c) => {
|
|
@@ -1307,8 +1337,9 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1307
1337
|
if (!session) {
|
|
1308
1338
|
return c.json({ type: 'not-found', title: 'Session not found', status: 404 }, 404);
|
|
1309
1339
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1340
|
+
// 阶段 3 之后 openai chat 只有一条路(统一宿主),`agentMode` 不再选择代码路径,
|
|
1341
|
+
// 因此这里只看后端 —— 否则历史上 agentMode='sdk' 的会话会被挡在交互之外。
|
|
1342
|
+
if (session.agentKind !== 'openai') {
|
|
1312
1343
|
return c.json({
|
|
1313
1344
|
type: 'validation',
|
|
1314
1345
|
title: 'Session does not support durable interactions',
|
|
@@ -1385,7 +1416,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1385
1416
|
latestFound.block.status = settled;
|
|
1386
1417
|
latestFound.block.revision += 1;
|
|
1387
1418
|
writeSession(latest);
|
|
1388
|
-
|
|
1419
|
+
publish('chat.message.interaction.updated', {
|
|
1389
1420
|
sessionId: id,
|
|
1390
1421
|
interactionId,
|
|
1391
1422
|
status: settled,
|
|
@@ -1412,7 +1443,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1412
1443
|
latestFound.block.revision += 1;
|
|
1413
1444
|
writeSession(latest);
|
|
1414
1445
|
}
|
|
1415
|
-
|
|
1446
|
+
publish('chat.message.interaction.updated', {
|
|
1416
1447
|
sessionId: id,
|
|
1417
1448
|
interactionId,
|
|
1418
1449
|
status: 'answered',
|
|
@@ -1422,7 +1453,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1422
1453
|
const assistantId = randomUUID();
|
|
1423
1454
|
activeSessions.add(slot);
|
|
1424
1455
|
startTurn(id, assistantId);
|
|
1425
|
-
|
|
1456
|
+
publish('chat.message.pending', {
|
|
1426
1457
|
sessionId: id,
|
|
1427
1458
|
assistantId,
|
|
1428
1459
|
turnId: assistantId,
|
|
@@ -1480,7 +1511,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1480
1511
|
latestFound.block.status = 'answered';
|
|
1481
1512
|
latestFound.block.revision += 1;
|
|
1482
1513
|
writeSession(latest);
|
|
1483
|
-
|
|
1514
|
+
publish('chat.message.interaction.updated', {
|
|
1484
1515
|
sessionId: id,
|
|
1485
1516
|
interactionId,
|
|
1486
1517
|
status: 'answered',
|
|
@@ -1506,7 +1537,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1506
1537
|
latestFound.block.revision += 1;
|
|
1507
1538
|
writeSession(latest);
|
|
1508
1539
|
}
|
|
1509
|
-
|
|
1540
|
+
publish('chat.message.interaction.updated', {
|
|
1510
1541
|
sessionId: id,
|
|
1511
1542
|
interactionId,
|
|
1512
1543
|
status: 'cancelled',
|
|
@@ -1582,7 +1613,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1582
1613
|
// · form/choices → "以对话作答":这里把 UI 块落定为 cancelled(worker 侧会同步取消
|
|
1583
1614
|
// store 记录并把用户原话喂回 run,见 broker.resolveWithChatReply),表单按钮不再假活;
|
|
1584
1615
|
// · approval(危险操作审批/账本对账)→ 安全门槛不许绕,409 让用户先作答。
|
|
1585
|
-
if (session.agentKind === 'openai'
|
|
1616
|
+
if (session.agentKind === 'openai') {
|
|
1586
1617
|
for (const msg of session.messages) {
|
|
1587
1618
|
for (const block of msg.blocks ?? []) {
|
|
1588
1619
|
if (block.type !== 'interaction' || block.status !== 'pending')
|
|
@@ -1602,7 +1633,7 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1602
1633
|
}
|
|
1603
1634
|
block.status = 'cancelled';
|
|
1604
1635
|
block.revision += 1;
|
|
1605
|
-
|
|
1636
|
+
publish('chat.message.interaction.updated', {
|
|
1606
1637
|
sessionId: id,
|
|
1607
1638
|
interactionId: block.id,
|
|
1608
1639
|
status: 'cancelled',
|
|
@@ -1625,12 +1656,12 @@ export function createChatRoute(log, chat, dependencies = {}) {
|
|
|
1625
1656
|
if (session.messages.length === 1)
|
|
1626
1657
|
session.title = formatTitle(body.content);
|
|
1627
1658
|
writeSession(session);
|
|
1628
|
-
|
|
1659
|
+
publish('chat.message', { sessionId: id, message: userMsg });
|
|
1629
1660
|
const assistantId = randomUUID();
|
|
1630
1661
|
// v0.57:接受即预登记回合(activeStreams + 心跳 + 持久 lastTurn=running),消除
|
|
1631
1662
|
// "lastTurn=running 但无 activeStreams"的竞态,让断线补偿/轮询从此刻起就确定。
|
|
1632
1663
|
startTurn(id, assistantId);
|
|
1633
|
-
|
|
1664
|
+
publish('chat.message.pending', {
|
|
1634
1665
|
sessionId: id,
|
|
1635
1666
|
assistantId,
|
|
1636
1667
|
turnId: assistantId,
|
|
@@ -1656,11 +1687,17 @@ export function createChatStreamRoute() {
|
|
|
1656
1687
|
start(controller) {
|
|
1657
1688
|
const enc = new TextEncoder();
|
|
1658
1689
|
let closed = false;
|
|
1690
|
+
/**
|
|
1691
|
+
* 写一帧。带 `frameId` 的写 `id:` —— 浏览器会记住它,重连时自动带
|
|
1692
|
+
* `Last-Event-ID` 回来,服务端据此逐事件补发(见下方的续传段)。
|
|
1693
|
+
* 补偿类帧(snapshot/resume 等)不带帧号,不参与游标。
|
|
1694
|
+
*/
|
|
1659
1695
|
const send = (event, data) => {
|
|
1660
1696
|
if (closed)
|
|
1661
1697
|
return;
|
|
1698
|
+
const frameId = data && typeof data === 'object' ? data.frameId : undefined;
|
|
1662
1699
|
try {
|
|
1663
|
-
controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
1700
|
+
controller.enqueue(enc.encode(`${typeof frameId === 'number' ? `id: ${frameId}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
1664
1701
|
}
|
|
1665
1702
|
catch {
|
|
1666
1703
|
closed = true;
|
|
@@ -1676,6 +1713,7 @@ export function createChatStreamRoute() {
|
|
|
1676
1713
|
'chat.message.interaction.updated',
|
|
1677
1714
|
'chat.message.complete',
|
|
1678
1715
|
'chat.message.error', // v0.57:明确的回合异常终止(带 turnId)
|
|
1716
|
+
'chat.resync.required', // 阶段 2:游标失效,客户端须靠快照/补拉重建
|
|
1679
1717
|
'chat.message.heartbeat', // v0.57:流式进行中心跳(带 turnId),区分"还在生成"vs"卡死"
|
|
1680
1718
|
];
|
|
1681
1719
|
const handlers = [];
|
|
@@ -1690,18 +1728,39 @@ export function createChatStreamRoute() {
|
|
|
1690
1728
|
chatBus.on(event, fn);
|
|
1691
1729
|
handlers.push({ event, fn });
|
|
1692
1730
|
}
|
|
1731
|
+
// ── 逐事件续传(阶段 2)──
|
|
1732
|
+
// 浏览器重连自动带 Last-Event-ID;断点还在窗口内就精确补发,滚出窗口才退回快照重建。
|
|
1733
|
+
// 放在订阅之后:JS 单线程,这中间不会有事件被 emit,既不漏也不重。
|
|
1734
|
+
const lastEventHeader = c.req.header('Last-Event-ID') ?? c.req.query('lastEventId') ?? '';
|
|
1735
|
+
const lastEventId = Number.parseInt(lastEventHeader, 10);
|
|
1736
|
+
let resumed = false;
|
|
1737
|
+
if (Number.isFinite(lastEventId) && lastEventId > 0) {
|
|
1738
|
+
const replay = framesSince(sessionId, lastEventId);
|
|
1739
|
+
if (replay.kind === 'frames') {
|
|
1740
|
+
// 🔴 补发时必须带上帧号:日志里存的是**发布前**的 data(没有 frameId),
|
|
1741
|
+
// 直接发会让 `id:` 缺失、客户端游标停在断点不前进,下次重连又从头补一遍。
|
|
1742
|
+
for (const frame of replay.frames) {
|
|
1743
|
+
send(frame.event, { ...frame.data, frameId: frame.id });
|
|
1744
|
+
}
|
|
1745
|
+
resumed = true;
|
|
1746
|
+
}
|
|
1747
|
+
else {
|
|
1748
|
+
// 🔴 有洞的序列比没有更危险(客户端会以为自己是完整的)——明确要求重建。
|
|
1749
|
+
send('chat.resync.required', { sessionId, reason: 'cursor-out-of-window' });
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1693
1752
|
// ── 断线补偿(v0.57 核心)──
|
|
1694
1753
|
// 新连接(刷新/切会话/重连)时,后端据"进行中快照 + 持久 lastTurn"主动补偿,
|
|
1695
1754
|
// 让"回合已结束/仍在进行"成为确定事实,客户端无需超时猜测。同步执行(JS 单线程):
|
|
1696
1755
|
// 订阅 chatBus 与本段之间不会有事件被 emit → 既不漏也不重。
|
|
1697
|
-
const active = activeStreams.get(sessionId);
|
|
1756
|
+
const active = resumed ? null : activeStreams.get(sessionId);
|
|
1698
1757
|
if (active) {
|
|
1699
1758
|
// 回合仍进行中(本进程)→ pending + 旧 snapshot(兼容老客户端)+ 新 resume(带 turnId/seq/status)。
|
|
1700
1759
|
send('chat.message.pending', { sessionId, assistantId: active.assistantId, turnId: active.turnId, seq: active.seq, ts: active.ts });
|
|
1701
1760
|
send('chat.message.snapshot', { sessionId, assistantId: active.assistantId, ts: active.ts, blocks: active.blocks });
|
|
1702
1761
|
send('chat.message.resume', { sessionId, assistantId: active.assistantId, turnId: active.turnId, ts: active.ts, blocks: active.blocks, status: 'running', seq: active.seq });
|
|
1703
1762
|
}
|
|
1704
|
-
else {
|
|
1763
|
+
else if (!resumed) {
|
|
1705
1764
|
// 无在途回合 → 查持久 lastTurn(轮询/补偿权威),确定性补发终止信号。
|
|
1706
1765
|
const s = readSession(sessionId);
|
|
1707
1766
|
const lt = s ? deriveLastTurn(s) : null;
|