c0de-agent 1.9.0 → 1.10.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.
- package/README.md +21 -7
- package/dist/cli/commands/acp.js +2 -0
- package/dist/cli/commands/chat.js +2 -0
- package/dist/cli/commands/config.js +8 -4
- package/dist/cli/commands/serve.d.ts +1 -0
- package/dist/cli/commands/serve.js +4 -1
- package/dist/cli/deps.d.ts +7 -5
- package/dist/cli/deps.js +23 -7
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +68 -37
- package/dist/cli/modes/print.d.ts +3 -1
- package/dist/cli/modes/print.js +21 -4
- package/dist/core/agent.js +3 -4
- package/dist/core/config.d.ts +18 -1
- package/dist/core/config.js +55 -7
- package/dist/core/loop.js +1 -1
- package/dist/core/workflows/discovery.d.ts +1 -1
- package/dist/core/workflows/index.d.ts +1 -1
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.js +3 -1
- package/dist/server/agent-manager.d.ts +15 -0
- package/dist/server/agent-manager.js +35 -11
- package/dist/server/app.js +2 -1
- package/dist/server/context.js +2 -0
- package/dist/server/middleware/cors.d.ts +11 -2
- package/dist/server/middleware/cors.js +15 -12
- package/dist/server/routes/chat.js +211 -150
- package/dist/server/routes/config.js +7 -3
- package/dist/server/routes/session.js +6 -2
- package/dist/server/routes/terminal.js +12 -0
- package/dist/server/routes/update.d.ts +1 -0
- package/dist/server/routes/update.js +11 -4
- package/dist/server/routes/workflows.js +2 -2
- package/dist/server/server.d.ts +24 -1
- package/dist/server/server.js +107 -26
- package/dist/server/types.d.ts +9 -1
- package/dist/session/branch.d.ts +10 -2
- package/dist/session/branch.js +98 -29
- package/dist/session/compaction.js +1 -0
- package/dist/session/context.d.ts +3 -2
- package/dist/session/context.js +36 -3
- package/dist/session/snapshot.d.ts +9 -3
- package/dist/session/snapshot.js +45 -4
- package/dist/session/squash.js +1 -0
- package/dist/session/types.d.ts +2 -0
- package/dist/shared/types/config.d.ts +7 -6
- package/dist/shared/utils/path.d.ts +2 -3
- package/dist/shared/utils/path.js +20 -4
- package/dist/tools/builtin/todo.js +5 -4
- package/dist/tools/index.d.ts +14 -3
- package/dist/tools/index.js +36 -14
- package/dist/update/index.d.ts +1 -1
- package/dist/update/index.js +1 -1
- package/dist/update/ipc.d.ts +32 -5
- package/dist/update/ipc.js +53 -7
- package/dist/update/updater.d.ts +7 -3
- package/dist/update/updater.js +14 -6
- package/dist/update/version.d.ts +6 -1
- package/dist/update/version.js +18 -3
- package/drizzle/0005_snapshot_mtime.sql +1 -0
- package/drizzle/meta/0005_snapshot.json +855 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -7
- package/dist/mcp/index.d.ts +0 -1
- package/dist/mcp/index.js +0 -3
|
@@ -14,9 +14,24 @@ type ActiveRun = {
|
|
|
14
14
|
};
|
|
15
15
|
/** Agent run 跟踪器 + 控制操作。 */
|
|
16
16
|
type AgentManager = {
|
|
17
|
+
/**
|
|
18
|
+
* 同步原子占位(P0-4 POST /api/chat 并发守卫):sessionId 无条目时立即占位并返回
|
|
19
|
+
* true;已有条目(占位或活跃 run)时返回 false。has+set 一气呵成、全程无 await,
|
|
20
|
+
* 杜绝 check-then-act 竞态——此前守卫用 get 检查、真正注册在 SSE 回调内,中间隔
|
|
21
|
+
* 多个 await,双发 POST 均通过守卫后后注册覆盖前者(runs.set),且先结束者在
|
|
22
|
+
* finally unregister 时误删仍在跑的 run,abort/steer/pause 全部失效。
|
|
23
|
+
* 占位由 register 填充、unregister 释放,二者均幂等。
|
|
24
|
+
*/
|
|
25
|
+
tryAcquire(sessionId: string): boolean;
|
|
26
|
+
/** 注册活跃 run;若该 sessionId 已被 tryAcquire 占位则填充该占位。 */
|
|
17
27
|
register(run: ActiveRun): void;
|
|
28
|
+
/** 取活跃 run;占位期间(register 前)返回 undefined,视为无活跃 run。 */
|
|
18
29
|
get(sessionId: string): ActiveRun | undefined;
|
|
30
|
+
/** 该 sessionId 是否处于占位状态(tryAcquire 后、register 前)。 */
|
|
31
|
+
isStarting(sessionId: string): boolean;
|
|
32
|
+
/** 释放条目(占位或活跃 run);幂等。 */
|
|
19
33
|
unregister(sessionId: string): void;
|
|
34
|
+
/** 活跃 run 数(不含占位)。 */
|
|
20
35
|
size(): number;
|
|
21
36
|
abort(sessionId: string): boolean;
|
|
22
37
|
pause(sessionId: string): boolean;
|
|
@@ -1,59 +1,83 @@
|
|
|
1
1
|
// src/server/agent-manager.ts
|
|
2
2
|
import { abortAgent, injectSteering, pauseAgent, resumeAgent } from '../core/index.js';
|
|
3
|
+
/** 取 slot 内的活跃 run;占位('starting')或不存在返回 undefined。 */
|
|
4
|
+
function slotRun(slot) {
|
|
5
|
+
return slot?.kind === 'run' ? slot.run : undefined;
|
|
6
|
+
}
|
|
3
7
|
function createAgentManager() {
|
|
4
8
|
const runs = new Map();
|
|
5
9
|
return {
|
|
10
|
+
tryAcquire(sessionId) {
|
|
11
|
+
if (runs.has(sessionId))
|
|
12
|
+
return false;
|
|
13
|
+
runs.set(sessionId, { kind: 'starting' });
|
|
14
|
+
return true;
|
|
15
|
+
},
|
|
6
16
|
register(run) {
|
|
7
|
-
runs.set(run.sessionId, run);
|
|
17
|
+
runs.set(run.sessionId, { kind: 'run', run });
|
|
8
18
|
},
|
|
9
19
|
get(sessionId) {
|
|
10
|
-
return runs.get(sessionId);
|
|
20
|
+
return slotRun(runs.get(sessionId));
|
|
21
|
+
},
|
|
22
|
+
isStarting(sessionId) {
|
|
23
|
+
return runs.get(sessionId)?.kind === 'starting';
|
|
11
24
|
},
|
|
12
25
|
unregister(sessionId) {
|
|
13
26
|
runs.delete(sessionId);
|
|
14
27
|
},
|
|
15
28
|
size() {
|
|
16
|
-
|
|
29
|
+
let n = 0;
|
|
30
|
+
for (const slot of runs.values()) {
|
|
31
|
+
if (slot.kind === 'run')
|
|
32
|
+
n += 1;
|
|
33
|
+
}
|
|
34
|
+
return n;
|
|
17
35
|
},
|
|
18
36
|
abort(sessionId) {
|
|
19
|
-
const run = runs.get(sessionId);
|
|
37
|
+
const run = slotRun(runs.get(sessionId));
|
|
20
38
|
if (!run)
|
|
21
39
|
return false;
|
|
22
40
|
abortAgent(run.state);
|
|
23
41
|
return true;
|
|
24
42
|
},
|
|
25
43
|
pause(sessionId) {
|
|
26
|
-
const run = runs.get(sessionId);
|
|
44
|
+
const run = slotRun(runs.get(sessionId));
|
|
27
45
|
if (!run)
|
|
28
46
|
return false;
|
|
29
47
|
pauseAgent(run.state);
|
|
30
48
|
return true;
|
|
31
49
|
},
|
|
32
50
|
resume(sessionId) {
|
|
33
|
-
const run = runs.get(sessionId);
|
|
51
|
+
const run = slotRun(runs.get(sessionId));
|
|
34
52
|
if (!run)
|
|
35
53
|
return false;
|
|
36
54
|
resumeAgent(run.state);
|
|
37
55
|
return true;
|
|
38
56
|
},
|
|
39
57
|
steer(sessionId, message) {
|
|
40
|
-
const run = runs.get(sessionId);
|
|
58
|
+
const run = slotRun(runs.get(sessionId));
|
|
41
59
|
if (!run)
|
|
42
60
|
return false;
|
|
43
61
|
injectSteering(run.state, message);
|
|
44
62
|
return true;
|
|
45
63
|
},
|
|
46
64
|
children(parentSessionId) {
|
|
47
|
-
return Array.from(runs.values())
|
|
65
|
+
return Array.from(runs.values())
|
|
66
|
+
.map((slot) => slotRun(slot))
|
|
67
|
+
.filter((r) => r?.parentSessionId === parentSessionId);
|
|
48
68
|
},
|
|
49
69
|
backgroundJobs() {
|
|
50
|
-
return Array.from(runs.values())
|
|
70
|
+
return Array.from(runs.values())
|
|
71
|
+
.map((slot) => slotRun(slot))
|
|
72
|
+
.filter((r) => r?.jobId !== undefined);
|
|
51
73
|
},
|
|
52
74
|
dispose() {
|
|
53
75
|
// dev 热重载重建前调用:中止所有活跃 run(loop 在 turn/流边界检测 signal
|
|
54
76
|
// → unwind → 调用方 finally 持久化 + unregister),然后清空 Map。
|
|
55
|
-
for (const
|
|
56
|
-
|
|
77
|
+
for (const slot of runs.values()) {
|
|
78
|
+
const run = slotRun(slot);
|
|
79
|
+
if (run)
|
|
80
|
+
abortAgent(run.state);
|
|
57
81
|
}
|
|
58
82
|
runs.clear();
|
|
59
83
|
},
|
package/dist/server/app.js
CHANGED
|
@@ -32,7 +32,8 @@ function createApp(ctx) {
|
|
|
32
32
|
// spec §24.2:仅允许本地/可信 origin 跨域读,拒绝外部网页。
|
|
33
33
|
app.use('*', createCORSMiddleware({ allowedOrigins: ctx.config.security.allowedOrigins }));
|
|
34
34
|
// spec §24.2:Bearer token 认证(配置了 token 时生效;/api/health 探活放行)。
|
|
35
|
-
|
|
35
|
+
// token 来源:用户配置 security.token,或启动时自动生成(ctx.authToken)。
|
|
36
|
+
app.use('/api/*', createAuthMiddleware(ctx.authToken));
|
|
36
37
|
// 路由
|
|
37
38
|
app.route('/api/health', createHealthRoute());
|
|
38
39
|
app.route('/api/agents', createAgentRoute(ctx));
|
package/dist/server/context.js
CHANGED
|
@@ -35,6 +35,8 @@ function createServerContext(opts) {
|
|
|
35
35
|
agentManager: createAgentManager(),
|
|
36
36
|
permissionStore: createPermissionStore(),
|
|
37
37
|
permissionMode: config.permission.defaultMode,
|
|
38
|
+
// 测试工厂不解析/生成 token(不触碰用户全局数据目录)。
|
|
39
|
+
authToken: undefined,
|
|
38
40
|
agentRegistry,
|
|
39
41
|
// 工作流注册表:惰性初始化,只含内置(测试工厂;生产路径走 buildServerContext)。
|
|
40
42
|
get workflowRegistry() {
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from 'hono';
|
|
2
2
|
/**
|
|
3
3
|
* 判断 origin 是否为本地/可信来源(spec §24.2「CORS 限制本地 origin」)。
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
*
|
|
5
|
+
* 仅允许本机回环任意端口 + 显式配置的 allowedOrigins。
|
|
6
|
+
* 显式不放行 'null'(sandboxed iframe)、file:// 与浏览器扩展前缀——
|
|
7
|
+
* 任意网页/扩展均可伪造这些来源,等价于完全关闭 CORS 防护。
|
|
8
|
+
* 认证未启用时(authEnabled=false 的显式选择),本函数仍是唯一的跨域读取防线。
|
|
6
9
|
*/
|
|
7
10
|
export declare function isLocalOrigin(origin: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Origin 是否被允许(本地回环 + 配置的额外 origin)。
|
|
13
|
+
* 供 CORS 中间件与 WebSocket 升级路径共用——浏览器对 WS 不实施 CORS,
|
|
14
|
+
* 但会携带 Origin 头,必须在服务端显式校验。
|
|
15
|
+
*/
|
|
16
|
+
export declare function isAllowedOrigin(origin: string, allowedOrigins?: string[]): boolean;
|
|
8
17
|
export type CORSOptions = {
|
|
9
18
|
/** 额外允许的 origin(如远程访问场景配置的域名)。 */
|
|
10
19
|
allowedOrigins?: string[];
|
|
@@ -2,23 +2,26 @@
|
|
|
2
2
|
const LOCAL_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
3
3
|
/**
|
|
4
4
|
* 判断 origin 是否为本地/可信来源(spec §24.2「CORS 限制本地 origin」)。
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* 仅允许本机回环任意端口 + 显式配置的 allowedOrigins。
|
|
7
|
+
* 显式不放行 'null'(sandboxed iframe)、file:// 与浏览器扩展前缀——
|
|
8
|
+
* 任意网页/扩展均可伪造这些来源,等价于完全关闭 CORS 防护。
|
|
9
|
+
* 认证未启用时(authEnabled=false 的显式选择),本函数仍是唯一的跨域读取防线。
|
|
7
10
|
*/
|
|
8
11
|
export function isLocalOrigin(origin) {
|
|
9
12
|
if (!origin)
|
|
10
13
|
return false;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (origin
|
|
14
|
+
return LOCAL_ORIGIN.test(origin);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Origin 是否被允许(本地回环 + 配置的额外 origin)。
|
|
18
|
+
* 供 CORS 中间件与 WebSocket 升级路径共用——浏览器对 WS 不实施 CORS,
|
|
19
|
+
* 但会携带 Origin 头,必须在服务端显式校验。
|
|
20
|
+
*/
|
|
21
|
+
export function isAllowedOrigin(origin, allowedOrigins) {
|
|
22
|
+
if (isLocalOrigin(origin))
|
|
20
23
|
return true;
|
|
21
|
-
return false;
|
|
24
|
+
return allowedOrigins?.includes(origin) ?? false;
|
|
22
25
|
}
|
|
23
26
|
/**
|
|
24
27
|
* CORS 中间件:仅对本地/可信 origin 回显 Access-Control-Allow-Origin,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { streamSSE } from 'hono/streaming';
|
|
4
4
|
import { createAgent, runAgent } from '../../core/agent.js';
|
|
@@ -9,8 +9,8 @@ import { buildWorkflowNotice, containsWorkflow } from '../../core/workflow.js';
|
|
|
9
9
|
import { getProject } from '../../project/project.js';
|
|
10
10
|
import { getLLMSegments, getSession, updateSessionLastRun } from '../../session/session.js';
|
|
11
11
|
import { upsertFileSnapshot } from '../../session/snapshot.js';
|
|
12
|
+
import { resolveEnabledToolNames } from '../../tools/index.js';
|
|
12
13
|
import { autoAllowChecker } from '../../tools/permission.js';
|
|
13
|
-
import { listTools } from '../../tools/registry.js';
|
|
14
14
|
import { apiError } from '../middleware/error.js';
|
|
15
15
|
import { createInteractivePermissionChecker } from '../permission/interactive.js';
|
|
16
16
|
import { safeResolve } from '../util/safe-path.js';
|
|
@@ -160,6 +160,7 @@ function createChatRoute(ctx) {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
// @文件上下文:读取文件内容写入快照,后续 getSessionContext→injectSnapshots 自动注入。
|
|
163
|
+
// 快照记录文件 mtime——注入前比对磁盘,过期自动刷新(P1-5)。
|
|
163
164
|
// 路径越界或读取失败静默跳过,不阻塞主对话流。
|
|
164
165
|
const files = body.files;
|
|
165
166
|
if (files?.length) {
|
|
@@ -168,8 +169,13 @@ function createChatRoute(ctx) {
|
|
|
168
169
|
if (!resolved)
|
|
169
170
|
continue;
|
|
170
171
|
try {
|
|
172
|
+
// 先 stat 再 readFile(与 session/context.ts 刷新路径一致):若先读后
|
|
173
|
+
// stat,文件恰在两次调用间被写会存「旧内容 + 新 mtime」,注入前的
|
|
174
|
+
// mtime 相等判定使旧内容被永久钉住;先 stat 后读最坏存「新内容 +
|
|
175
|
+
// 旧 mtime」,下次注入仍会触发刷新,可自愈。
|
|
176
|
+
const st = await stat(resolved);
|
|
171
177
|
const content = await readFile(resolved, 'utf-8');
|
|
172
|
-
await upsertFileSnapshot(ctx.db, sessionId, p, content);
|
|
178
|
+
await upsertFileSnapshot(ctx.db, sessionId, p, content, st.mtimeMs);
|
|
173
179
|
}
|
|
174
180
|
catch {
|
|
175
181
|
// 文件读取失败静默跳过
|
|
@@ -178,175 +184,230 @@ function createChatRoute(ctx) {
|
|
|
178
184
|
}
|
|
179
185
|
const provider = body.provider ?? ctx.config.defaultProvider;
|
|
180
186
|
const model = body.model ?? ctx.config.defaultModel;
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!agentDef || agentDef.mode === 'subagent') {
|
|
190
|
-
return apiError(c, 400, 'INVALID_AGENT', `Unknown or non-primary agent: ${agentName}`);
|
|
187
|
+
// 并发守卫(P0-4):同步原子占位 tryAcquire(Map has+set 一气呵成、无 await)。
|
|
188
|
+
// 旧实现先 get 检查、真正 register 在 SSE 回调内,两者之间隔多个 await
|
|
189
|
+
// (getLLMSegments、createAgent 等):双发 POST 在窗口内均通过守卫,后注册
|
|
190
|
+
// 覆盖前者(agentManager runs.set),且 A 结束时 unregister 把仍在跑的 B
|
|
191
|
+
// 删掉,abort/steer/pause 对 B 全部失效。占位失败说明该会话已被占用
|
|
192
|
+
// (活跃 run 或另一请求的占位),立即 409。
|
|
193
|
+
if (!ctx.agentManager.tryAcquire(sessionId)) {
|
|
194
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话');
|
|
191
195
|
}
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// 旧段 agentName undefined 视为 'default'
|
|
206
|
-
const agentDiffer = (active.agentName ?? 'default') !== agentName;
|
|
207
|
-
const confirmed = body.confirmSegmentBreak === true;
|
|
208
|
-
if ((modelDiffer || toolsDiffer || agentDiffer) && !confirmed) {
|
|
209
|
-
return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '切换模型/工具将开始新的上下文段(缓存失效),需用户确认', {
|
|
210
|
-
activeSegment: {
|
|
211
|
-
provider: active.provider,
|
|
212
|
-
model: active.model,
|
|
213
|
-
tools: active.tools.map((t) => t.name),
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return streamSSE(c, async (stream) => {
|
|
219
|
-
// 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认
|
|
220
|
-
const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
|
|
221
|
-
getMode: () => ctx.permissionMode,
|
|
222
|
-
onPermissionRequired: async (req) => {
|
|
223
|
-
await stream.writeSSE({
|
|
224
|
-
event: 'permission_required',
|
|
225
|
-
data: JSON.stringify({ _tag: 'permission_required', ...req }),
|
|
226
|
-
});
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
// cwd 已在斜杠拦截前解析,此处直接复用
|
|
230
|
-
// 构建 agent 依赖(注入测试用 chatStream)
|
|
231
|
-
const deps = {
|
|
232
|
-
db: ctx.db,
|
|
233
|
-
llmRegistry: ctx.llmRegistry,
|
|
234
|
-
toolRegistry: ctx.toolRegistry,
|
|
235
|
-
urlRegistry: ctx.urlRegistry,
|
|
236
|
-
hookRunner: ctx.hookRunner,
|
|
237
|
-
permission: permissionChecker,
|
|
238
|
-
config: ctx.config,
|
|
239
|
-
agentRegistry: ctx.agentRegistry,
|
|
240
|
-
cwd,
|
|
241
|
-
...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
|
|
242
|
-
};
|
|
243
|
-
const agentConfig = {
|
|
244
|
-
provider,
|
|
245
|
-
model: resolvedModel,
|
|
246
|
-
tools: resolvedTools,
|
|
247
|
-
plugins: ctx.config.plugins.enabled,
|
|
248
|
-
agentName,
|
|
249
|
-
...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
|
|
250
|
-
};
|
|
251
|
-
const state = await createAgent(session, agentConfig, deps);
|
|
252
|
-
// 持久化 run 状态:status='running' 写入 DB。服务重启后此字段仍为 'running'
|
|
253
|
-
// 而进程无活跃 run → 检测为 interrupted。finally 中标记 'completed'。
|
|
254
|
-
const runStartedAt = Date.now();
|
|
255
|
-
await updateSessionLastRun(ctx.db, sessionId, {
|
|
256
|
-
status: 'running',
|
|
257
|
-
...(agentName !== 'default' ? { agentName } : {}),
|
|
258
|
-
provider,
|
|
259
|
-
model: resolvedModel,
|
|
260
|
-
startedAt: runStartedAt,
|
|
261
|
-
});
|
|
262
|
-
ctx.agentManager.register({ sessionId, state, deps });
|
|
263
|
-
// workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
|
|
264
|
-
// 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
|
|
265
|
-
if (containsWorkflow(message)) {
|
|
266
|
-
const wfList = ctx.workflowRegistry
|
|
267
|
-
? ctx.workflowRegistry.list().map((w) => ({
|
|
268
|
-
name: w.meta.name,
|
|
269
|
-
description: w.meta.description,
|
|
270
|
-
}))
|
|
271
|
-
: [];
|
|
272
|
-
injectSteering(state, buildWorkflowNotice(wfList));
|
|
196
|
+
// 占位持有期(tryAcquire → SSE 回调 register)内的任何提前返回或抛错都必须
|
|
197
|
+
// 释放占位,否则该会话被永久 409 锁死。handedOff 标记所有权已移交 SSE 回调
|
|
198
|
+
// (其 finally 负责幂等释放),移交前的全部路径在 finally 统一兜底释放。
|
|
199
|
+
let handedOff = false;
|
|
200
|
+
try {
|
|
201
|
+
// 工具解析(P1-1):config.tools.enabled 非空时作为默认集;空 = 全部注册工具。
|
|
202
|
+
// disabled 已在 registry 层过滤(createDefaultRegistry),此处兜底。
|
|
203
|
+
const tools = resolveEnabledToolNames(ctx.toolRegistry, ctx.config, body.tools);
|
|
204
|
+
// primary agent 解析(spec: agent-frontend-switching §4.3)
|
|
205
|
+
const agentName = body.agent ?? 'default';
|
|
206
|
+
const agentDef = ctx.agentRegistry.get(agentName);
|
|
207
|
+
if (!agentDef || agentDef.mode === 'subagent') {
|
|
208
|
+
return apiError(c, 400, 'INVALID_AGENT', `Unknown or non-primary agent: ${agentName}`);
|
|
273
209
|
}
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
210
|
+
// agent def 覆盖:tools(plan 限只读)、model(可选)
|
|
211
|
+
const resolvedTools = agentDef.tools ?? tools;
|
|
212
|
+
const resolvedModel = agentDef.model ?? model;
|
|
213
|
+
// 分段预检:切换 provider/model/tools 将开新段(前缀失效→缓存 miss),
|
|
214
|
+
// 需用户显式确认(confirmSegmentBreak)。首轮无活跃段时跳过。
|
|
215
|
+
// 守卫 409 短路后 agentManager.get 恒为 undefined,原 existingRun 三元
|
|
216
|
+
// 恒走 else 分支(死代码,已删除)。
|
|
217
|
+
const segs = await getLLMSegments(ctx.db, sessionId);
|
|
218
|
+
const active = segs[segs.length - 1];
|
|
219
|
+
if (active) {
|
|
220
|
+
const reqTools = new Set(resolvedTools);
|
|
221
|
+
const segTools = new Set(active.tools.map((t) => t.name));
|
|
222
|
+
const toolsDiffer = reqTools.size !== segTools.size || [...reqTools].some((t) => !segTools.has(t));
|
|
223
|
+
const modelDiffer = active.provider !== provider || active.model !== resolvedModel;
|
|
224
|
+
// 旧段 agentName undefined 视为 'default'
|
|
225
|
+
const agentDiffer = (active.agentName ?? 'default') !== agentName;
|
|
226
|
+
const confirmed = body.confirmSegmentBreak === true;
|
|
227
|
+
if ((modelDiffer || toolsDiffer || agentDiffer) && !confirmed) {
|
|
228
|
+
return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '切换模型/工具将开始新的上下文段(缓存失效),需用户确认', {
|
|
229
|
+
activeSegment: {
|
|
230
|
+
provider: active.provider,
|
|
231
|
+
model: active.model,
|
|
232
|
+
tools: active.tools.map((t) => t.name),
|
|
233
|
+
},
|
|
289
234
|
});
|
|
290
235
|
}
|
|
291
236
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
237
|
+
const response = streamSSE(c, async (stream) => {
|
|
238
|
+
// P0-4:回调从首行起全部纳入 try/finally——createAgent、updateSessionLastRun
|
|
239
|
+
// 等启动阶段失败同样必须释放占位并补发 error/done(hono 的 stream 包装器
|
|
240
|
+
// 只 console.error 不善后),不得泄漏占位或让前端卡在 streaming 态。
|
|
241
|
+
let doneSent = false;
|
|
242
|
+
let runStartedAt;
|
|
243
|
+
try {
|
|
244
|
+
// 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认
|
|
245
|
+
const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
|
|
246
|
+
getMode: () => ctx.permissionMode,
|
|
247
|
+
onPermissionRequired: async (req) => {
|
|
248
|
+
await stream.writeSSE({
|
|
249
|
+
event: 'permission_required',
|
|
250
|
+
data: JSON.stringify({ _tag: 'permission_required', ...req }),
|
|
251
|
+
});
|
|
305
252
|
},
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
253
|
+
});
|
|
254
|
+
// cwd 已在斜杠拦截前解析,此处直接复用
|
|
255
|
+
// 构建 agent 依赖(注入测试用 chatStream)
|
|
256
|
+
const deps = {
|
|
257
|
+
db: ctx.db,
|
|
258
|
+
llmRegistry: ctx.llmRegistry,
|
|
259
|
+
toolRegistry: ctx.toolRegistry,
|
|
260
|
+
urlRegistry: ctx.urlRegistry,
|
|
261
|
+
hookRunner: ctx.hookRunner,
|
|
262
|
+
permission: permissionChecker,
|
|
263
|
+
config: ctx.config,
|
|
264
|
+
agentRegistry: ctx.agentRegistry,
|
|
265
|
+
cwd,
|
|
266
|
+
...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
|
|
267
|
+
};
|
|
268
|
+
const agentConfig = {
|
|
269
|
+
provider,
|
|
270
|
+
model: resolvedModel,
|
|
271
|
+
tools: resolvedTools,
|
|
272
|
+
plugins: ctx.config.plugins.enabled,
|
|
273
|
+
agentName,
|
|
274
|
+
...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
|
|
275
|
+
};
|
|
276
|
+
const state = await createAgent(session, agentConfig, deps);
|
|
277
|
+
// 持久化 run 状态:status='running' 写入 DB。服务重启后此字段仍为 'running'
|
|
278
|
+
// 而进程无活跃 run → 检测为 interrupted。finally 中标记 'completed'。
|
|
279
|
+
runStartedAt = Date.now();
|
|
280
|
+
await updateSessionLastRun(ctx.db, sessionId, {
|
|
281
|
+
status: 'running',
|
|
282
|
+
...(agentName !== 'default' ? { agentName } : {}),
|
|
283
|
+
provider,
|
|
284
|
+
model: resolvedModel,
|
|
285
|
+
startedAt: runStartedAt,
|
|
286
|
+
});
|
|
287
|
+
// 填充 tryAcquire 留下的占位(原 register 语义:按 sessionId 覆盖写入)
|
|
288
|
+
ctx.agentManager.register({ sessionId, state, deps });
|
|
289
|
+
// workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
|
|
290
|
+
// 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
|
|
291
|
+
if (containsWorkflow(message)) {
|
|
292
|
+
const wfList = ctx.workflowRegistry
|
|
293
|
+
? ctx.workflowRegistry.list().map((w) => ({
|
|
294
|
+
name: w.meta.name,
|
|
295
|
+
description: w.meta.description,
|
|
296
|
+
}))
|
|
297
|
+
: [];
|
|
298
|
+
injectSteering(state, buildWorkflowNotice(wfList));
|
|
299
|
+
}
|
|
300
|
+
// 客户端断开时中止 agent
|
|
301
|
+
stream.onAbort(() => {
|
|
302
|
+
ctx.agentManager.abort(sessionId);
|
|
303
|
+
});
|
|
304
|
+
// 跟踪 done 事件是否已发送:agentLoop 正常完成时 yield done;
|
|
305
|
+
// 但 error/abort/max_turns 等路径不 yield done,需在 finally 补发。
|
|
306
|
+
// 否则前端 isStreaming 永远不会变 false,按钮卡在「终止」态。
|
|
307
|
+
try {
|
|
308
|
+
for await (const event of runAgent(state, userContent, deps)) {
|
|
309
|
+
if (event._tag === 'done')
|
|
310
|
+
doneSent = true;
|
|
311
|
+
await stream.writeSSE({
|
|
312
|
+
event: event._tag,
|
|
313
|
+
data: JSON.stringify(event),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
const message = err instanceof Error
|
|
319
|
+
? err.message
|
|
320
|
+
: typeof err === 'object' && err !== null && 'message' in err
|
|
321
|
+
? String(err.message)
|
|
322
|
+
: String(err);
|
|
323
|
+
await stream.writeSSE({
|
|
324
|
+
event: 'error',
|
|
325
|
+
data: JSON.stringify({
|
|
326
|
+
_tag: 'error',
|
|
327
|
+
error: {
|
|
328
|
+
_tag: 'unexpected',
|
|
329
|
+
message,
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
// 启动阶段(createAgent/updateSessionLastRun 等)失败:补发 error 事件。
|
|
337
|
+
// 客户端已断开时 writeSSE 会 reject,吞掉以保证 finally 善后执行。
|
|
318
338
|
await stream
|
|
319
|
-
.writeSSE({
|
|
339
|
+
.writeSSE({
|
|
340
|
+
event: 'error',
|
|
341
|
+
data: JSON.stringify({
|
|
342
|
+
_tag: 'error',
|
|
343
|
+
error: {
|
|
344
|
+
_tag: 'unexpected',
|
|
345
|
+
message: err instanceof Error ? err.message : String(err),
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
})
|
|
320
349
|
.catch(() => { });
|
|
321
350
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
351
|
+
finally {
|
|
352
|
+
// 先从 agentManager 释放(占位与活跃 run 均幂等):必须最先执行,确保
|
|
353
|
+
// 客户端断开使 writeSSE reject 时 agent 状态(state/deps/AbortController)
|
|
354
|
+
// 不会泄漏到 Map(unregister 仅做 Map.delete、不依赖 state,放最前安全)。
|
|
355
|
+
ctx.agentManager.unregister(sessionId);
|
|
356
|
+
// agentLoop 的 error/abort/max_turns 路径不 yield done,在此补发。
|
|
357
|
+
// 正常完成路径已在循环中 yield done,doneSent=true 时跳过避免重复。
|
|
358
|
+
// 客户端已断开时 writeSSE 会 reject,吞掉以保证后续 updateSessionLastRun 执行。
|
|
359
|
+
if (!doneSent) {
|
|
360
|
+
await stream
|
|
361
|
+
.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) })
|
|
362
|
+
.catch(() => { });
|
|
363
|
+
}
|
|
364
|
+
// 无论正常完成、错误还是 abort,只要服务还活着就标记 completed。
|
|
365
|
+
// 只有服务崩溃/重启才会留下 status='running' → 下次加载检测为 interrupted。
|
|
366
|
+
// runStartedAt 未定义说明 run 从未真正启动(createAgent 即失败),DB 中
|
|
367
|
+
// 无 'running' 标记需要清理,跳过写入。
|
|
368
|
+
if (runStartedAt !== undefined) {
|
|
369
|
+
await updateSessionLastRun(ctx.db, sessionId, {
|
|
370
|
+
status: 'completed',
|
|
371
|
+
...(agentName !== 'default' ? { agentName } : {}),
|
|
372
|
+
provider,
|
|
373
|
+
model: resolvedModel,
|
|
374
|
+
startedAt: runStartedAt,
|
|
375
|
+
}).catch(() => { });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
// streamSSE 已同步启动回调(回调的 register/finally 从此接管占位填充与
|
|
380
|
+
// 释放),所有权移交:本层 finally 不再兜底释放。
|
|
381
|
+
handedOff = true;
|
|
382
|
+
return response;
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
if (!handedOff)
|
|
386
|
+
ctx.agentManager.unregister(sessionId);
|
|
387
|
+
}
|
|
333
388
|
});
|
|
334
389
|
// 控制端点
|
|
390
|
+
// P0-4:占位中的会话(tryAcquire 后、register 前)run 尚无 state 可供
|
|
391
|
+
// abort/pause/resume/steer 操作——返回明确 409 而非静默 false 或崩溃。
|
|
392
|
+
const runStarting = (c, sessionId) => ctx.agentManager.isStarting(sessionId)
|
|
393
|
+
? apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试')
|
|
394
|
+
: undefined;
|
|
335
395
|
app.post('/abort', async (c) => {
|
|
336
396
|
const { sessionId } = await c.req.json();
|
|
337
|
-
return c.json({ aborted: ctx.agentManager.abort(sessionId) });
|
|
397
|
+
return runStarting(c, sessionId) ?? c.json({ aborted: ctx.agentManager.abort(sessionId) });
|
|
338
398
|
});
|
|
339
399
|
app.post('/pause', async (c) => {
|
|
340
400
|
const { sessionId } = await c.req.json();
|
|
341
|
-
return c.json({ paused: ctx.agentManager.pause(sessionId) });
|
|
401
|
+
return runStarting(c, sessionId) ?? c.json({ paused: ctx.agentManager.pause(sessionId) });
|
|
342
402
|
});
|
|
343
403
|
app.post('/resume', async (c) => {
|
|
344
404
|
const { sessionId } = await c.req.json();
|
|
345
|
-
return c.json({ resumed: ctx.agentManager.resume(sessionId) });
|
|
405
|
+
return runStarting(c, sessionId) ?? c.json({ resumed: ctx.agentManager.resume(sessionId) });
|
|
346
406
|
});
|
|
347
407
|
app.post('/steer', async (c) => {
|
|
348
408
|
const body = await c.req.json();
|
|
349
|
-
return
|
|
409
|
+
return (runStarting(c, body.sessionId) ??
|
|
410
|
+
c.json({ steered: ctx.agentManager.steer(body.sessionId, body.message) }));
|
|
350
411
|
});
|
|
351
412
|
return app;
|
|
352
413
|
}
|