@zmzai/agent-framework 0.9.0 → 0.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 (126) hide show
  1. package/dist/adapters/openai-provider.d.ts +6 -3
  2. package/dist/adapters/openai-provider.d.ts.map +1 -1
  3. package/dist/adapters/openai-provider.js +1 -1
  4. package/dist/adapters/openai-provider.js.map +1 -1
  5. package/dist/core/events/manifest.d.ts +2 -2
  6. package/dist/core/runtime/active-run-registry.d.ts +34 -0
  7. package/dist/core/runtime/active-run-registry.d.ts.map +1 -0
  8. package/dist/core/runtime/active-run-registry.js +44 -0
  9. package/dist/core/runtime/active-run-registry.js.map +1 -0
  10. package/dist/core/runtime/attempt-executor.d.ts +67 -0
  11. package/dist/core/runtime/attempt-executor.d.ts.map +1 -0
  12. package/dist/core/runtime/attempt-executor.js +683 -0
  13. package/dist/core/runtime/attempt-executor.js.map +1 -0
  14. package/dist/core/runtime/command-service.d.ts +48 -0
  15. package/dist/core/runtime/command-service.d.ts.map +1 -0
  16. package/dist/core/runtime/command-service.js +148 -0
  17. package/dist/core/runtime/command-service.js.map +1 -0
  18. package/dist/core/runtime/compaction.d.ts +30 -6
  19. package/dist/core/runtime/compaction.d.ts.map +1 -1
  20. package/dist/core/runtime/compaction.js +5 -4
  21. package/dist/core/runtime/compaction.js.map +1 -1
  22. package/dist/core/runtime/context-builder.d.ts +66 -0
  23. package/dist/core/runtime/context-builder.d.ts.map +1 -0
  24. package/dist/core/runtime/context-builder.js +187 -0
  25. package/dist/core/runtime/context-builder.js.map +1 -0
  26. package/dist/core/runtime/run-scheduler.d.ts +44 -0
  27. package/dist/core/runtime/run-scheduler.d.ts.map +1 -0
  28. package/dist/core/runtime/run-scheduler.js +136 -0
  29. package/dist/core/runtime/run-scheduler.js.map +1 -0
  30. package/dist/core/runtime/runner.d.ts +29 -178
  31. package/dist/core/runtime/runner.d.ts.map +1 -1
  32. package/dist/core/runtime/runner.js +111 -1457
  33. package/dist/core/runtime/runner.js.map +1 -1
  34. package/dist/core/runtime/task-lifecycle.d.ts +165 -0
  35. package/dist/core/runtime/task-lifecycle.d.ts.map +1 -0
  36. package/dist/core/runtime/task-lifecycle.js +424 -0
  37. package/dist/core/runtime/task-lifecycle.js.map +1 -0
  38. package/dist/core/session/sqlite-store.d.ts.map +1 -1
  39. package/dist/core/session/sqlite-store.js +130 -1
  40. package/dist/core/session/sqlite-store.js.map +1 -1
  41. package/dist/core/session/store.d.ts +9 -1
  42. package/dist/core/session/store.d.ts.map +1 -1
  43. package/dist/core/session/types.d.ts +16 -0
  44. package/dist/core/session/types.d.ts.map +1 -1
  45. package/dist/core/session/workflow.d.ts +49 -2
  46. package/dist/core/session/workflow.d.ts.map +1 -1
  47. package/dist/core/session/workflow.js.map +1 -1
  48. package/dist/core/subagents/coordinator.d.ts +210 -0
  49. package/dist/core/subagents/coordinator.d.ts.map +1 -0
  50. package/dist/core/subagents/coordinator.js +534 -0
  51. package/dist/core/subagents/coordinator.js.map +1 -0
  52. package/dist/core/subagents/parked.d.ts +55 -0
  53. package/dist/core/subagents/parked.d.ts.map +1 -0
  54. package/dist/core/subagents/parked.js +84 -0
  55. package/dist/core/subagents/parked.js.map +1 -0
  56. package/dist/core/subagents/tools.d.ts +29 -0
  57. package/dist/core/subagents/tools.d.ts.map +1 -0
  58. package/dist/core/subagents/tools.js +164 -0
  59. package/dist/core/subagents/tools.js.map +1 -0
  60. package/dist/core/subagents/types.d.ts +114 -0
  61. package/dist/core/subagents/types.d.ts.map +1 -0
  62. package/dist/core/subagents/types.js +44 -0
  63. package/dist/core/subagents/types.js.map +1 -0
  64. package/dist/core/task/completion.d.ts +3 -0
  65. package/dist/core/task/completion.d.ts.map +1 -1
  66. package/dist/core/task/completion.js +4 -0
  67. package/dist/core/task/completion.js.map +1 -1
  68. package/dist/core/task/types.d.ts +5 -0
  69. package/dist/core/task/types.d.ts.map +1 -1
  70. package/dist/core/task/types.js.map +1 -1
  71. package/dist/core/tools/adapter.d.ts.map +1 -1
  72. package/dist/core/tools/adapter.js +87 -61
  73. package/dist/core/tools/adapter.js.map +1 -1
  74. package/dist/core/tools/builtins.d.ts.map +1 -1
  75. package/dist/core/tools/builtins.js +7 -0
  76. package/dist/core/tools/builtins.js.map +1 -1
  77. package/dist/core/tools/context.d.ts +1 -0
  78. package/dist/core/tools/context.d.ts.map +1 -1
  79. package/dist/core/tools/def.d.ts +25 -0
  80. package/dist/core/tools/def.d.ts.map +1 -1
  81. package/dist/core/tools/def.js.map +1 -1
  82. package/dist/core/tools/git.d.ts.map +1 -1
  83. package/dist/core/tools/git.js +4 -0
  84. package/dist/core/tools/git.js.map +1 -1
  85. package/dist/core/tools/patch.d.ts.map +1 -1
  86. package/dist/core/tools/patch.js +1 -0
  87. package/dist/core/tools/patch.js.map +1 -1
  88. package/dist/core/tools/qa-check.d.ts +2 -2
  89. package/dist/core/tools/qa-check.d.ts.map +1 -1
  90. package/dist/core/tools/qa-check.js +1 -0
  91. package/dist/core/tools/qa-check.js.map +1 -1
  92. package/dist/core/tools/repomap.d.ts.map +1 -1
  93. package/dist/core/tools/repomap.js +1 -0
  94. package/dist/core/tools/repomap.js.map +1 -1
  95. package/dist/core/tools/task-block.d.ts.map +1 -1
  96. package/dist/core/tools/task-block.js +1 -0
  97. package/dist/core/tools/task-block.js.map +1 -1
  98. package/dist/core/tools/task-deliver.d.ts +1 -1
  99. package/dist/core/tools/task-deliver.d.ts.map +1 -1
  100. package/dist/core/tools/task-deliver.js +1 -0
  101. package/dist/core/tools/task-deliver.js.map +1 -1
  102. package/dist/core/tools/task.d.ts.map +1 -1
  103. package/dist/core/tools/task.js +3 -0
  104. package/dist/core/tools/task.js.map +1 -1
  105. package/dist/core/tools/terminal.d.ts.map +1 -1
  106. package/dist/core/tools/terminal.js +5 -0
  107. package/dist/core/tools/terminal.js.map +1 -1
  108. package/dist/core/tools/webfetch.d.ts.map +1 -1
  109. package/dist/core/tools/webfetch.js +1 -0
  110. package/dist/core/tools/webfetch.js.map +1 -1
  111. package/dist/core/tools/websearch.d.ts.map +1 -1
  112. package/dist/core/tools/websearch.js +1 -0
  113. package/dist/core/tools/websearch.js.map +1 -1
  114. package/dist/index.d.ts +5 -0
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +3 -0
  117. package/dist/index.js.map +1 -1
  118. package/dist/server/create-agent-runtime.d.ts +3 -1
  119. package/dist/server/create-agent-runtime.d.ts.map +1 -1
  120. package/dist/server/create-agent-runtime.js +8 -1
  121. package/dist/server/create-agent-runtime.js.map +1 -1
  122. package/dist/server/create-server.d.ts +6 -0
  123. package/dist/server/create-server.d.ts.map +1 -1
  124. package/dist/server/create-server.js +9 -0
  125. package/dist/server/create-server.js.map +1 -1
  126. package/package.json +12 -11
@@ -0,0 +1,683 @@
1
+ import { Agent } from "@earendil-works/pi-agent-core";
2
+ import { attachmentContent, attachmentRefContent } from "./attachments.js";
3
+ import { PermissionEngine, RejectedError } from "../permission/engine.js";
4
+ import { confineWorkspaceFiles } from "../permission/write-path.js";
5
+ import { PartProjector, serializeEmit } from "./pi-bridge.js";
6
+ import { LoopGuard, REPEAT_EDIT_FAILURE_THRESHOLD } from "./loop-guard.js";
7
+ import { adaptAnyTool, permissionForCall } from "../tools/adapter.js";
8
+ import { builtinTools } from "../tools/builtins.js";
9
+ import { isExternalToolDef } from "../tools/def.js";
10
+ import { TASK_BLOCK_TOOL_ID, readTaskBlock } from "../tools/task-block.js";
11
+ import { TASK_DELIVER_TOOL_ID, readTaskDelivery } from "../tools/task-deliver.js";
12
+ import { fireRunStart, firstToolBlock, fireAfterToolCall, fireRunEnd } from "./lifecycle.js";
13
+ import { extractRunTranscript, RETRY_PLACEHOLDER_TEXT } from "./run-transcript.js";
14
+ import { noopSandboxExecutor } from "../../adapters/index.js";
15
+ import { isRetryableError } from "./task-lifecycle.js";
16
+ import { defaultActiveRunRegistry } from "./active-run-registry.js";
17
+ /** 活跃 run 表(registry 门面,与 runner 共享同一 globalThis 实例)。 */
18
+ const activeRuns = defaultActiveRunRegistry;
19
+ import { taskContractText } from "../task/contract.js";
20
+ import { evidenceKindForTool } from "../task/plan.js";
21
+ /** 人工放行时喂给模型的驱动文本(与 `continuation` 那条同源但不同话术)。
22
+ *
23
+ * 它同样**不落库**:`resumeTask` 不经过 `prompt()`,没有 message、没有 workflow
24
+ * receipt,`acceptedUserId` 为 undefined——但这里必须显式跳过投影,因为
25
+ * `acceptedUserId` 为空正是「投影一条用户消息」的默认路径(见 runLoop)。
26
+ * 用一个专门的标记区分,才不会把「继续」画成用户说过的话。 */
27
+ const RESUME_DRIVE_TEXT = "[继续执行任务] 用户已经核对过当前状态并授权继续。按上面的任务契约接着推进,不要从头再做一遍,也不要把控制权提前交回用户。";
28
+ /** Default ToolContext built from injected workspace + sandbox. emitX events
29
+ * are routed through the runner's eventLog at call time. */
30
+ function defaultToolContext(input) {
31
+ const { session, engine, workspace, sandbox, emit } = input;
32
+ return {
33
+ sessionId: session.id,
34
+ userId: session.userId,
35
+ workspaceId: session.workspaceId,
36
+ agent: session.agent,
37
+ abort: new AbortController().signal,
38
+ ask: engine.ask.bind(engine),
39
+ workspace,
40
+ buildSnapshot: async () => sandbox.buildSnapshot({ userId: session.userId, workspaceId: session.workspaceId, runId: session.id }),
41
+ runSandbox: async (execInput) => {
42
+ const result = await sandbox.run({
43
+ ...execInput,
44
+ userId: session.userId,
45
+ workspaceId: session.workspaceId,
46
+ runId: session.id,
47
+ });
48
+ return result;
49
+ },
50
+ setTodos: async (todos) => {
51
+ await emit({ type: "todo.updated", data: { todos } });
52
+ },
53
+ emitFileEdited: async (payload) => {
54
+ await emit({ type: "file.edited", data: payload });
55
+ },
56
+ emitArtifact: async (payload) => {
57
+ await emit({ type: "artifact.created", data: payload });
58
+ },
59
+ };
60
+ }
61
+ export class AttemptExecutor {
62
+ deps;
63
+ collabs;
64
+ #hooks = [];
65
+ /** 供 runLoop 取归一化钩子数组(lazy:constructor 后仍可由 deps 引用共享)。 */
66
+ get hooks() {
67
+ if (this.#hooks.length !== (this.deps.hooks?.length ?? 0))
68
+ this.#hooks = [...(this.deps.hooks ?? [])];
69
+ return this.#hooks;
70
+ }
71
+ constructor(deps, collabs) {
72
+ this.deps = deps;
73
+ this.collabs = collabs;
74
+ }
75
+ /** 会话级 registry 解析(public:createServer 给协调器绑定同源解析用,
76
+ * 保证子代理类型检查与默认工厂看到 workspace 自定义 Agent)。 */
77
+ async registryFor(session) {
78
+ const base = this.deps.registry;
79
+ if (!this.deps.loadWorkspaceAgents)
80
+ return base;
81
+ try {
82
+ const custom = await this.deps.loadWorkspaceAgents(session);
83
+ return base.derive(custom);
84
+ }
85
+ catch {
86
+ return base;
87
+ }
88
+ }
89
+ /** Versioned agents are resolved from the product control plane. A missing
90
+ * version intentionally falls back to the M1-M5 registry so old sessions
91
+ * and standalone consumers remain valid. */
92
+ async resolvedAgentFor(session) {
93
+ if (!this.deps.agentResolver)
94
+ return null;
95
+ try {
96
+ return await this.deps.agentResolver.resolve(session);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /** 一次 Attempt:从模型上下文构造到终态收尾的完整内部运行。
103
+ *
104
+ * `taskContext` 存在时,任务契约会注入 systemPrompt(**不落成消息**,
105
+ * 规格 §19 禁止伪用户消息)。它同时携带 continuation 的 advisory,
106
+ * 保证「续跑指令」与「任务契约」在同一条系统指令里,不会互相矛盾。
107
+ * 它还携带 `loopGuard`:任务层自己持有一个跨 Attempt 的实例,避免每轮
108
+ * 重置循环防护的计数(见下方注释)。 */
109
+ async runLoop(session, input, acceptedUserId, taskContext) {
110
+ const registry = await this.registryFor(session);
111
+ const resolved = await this.resolvedAgentFor(session);
112
+ const agentName = resolved ? resolved.agent.name : input.agent ?? session.agent;
113
+ const agentInfo = resolved?.agent ?? registry.get(agentName) ?? registry.get("default");
114
+ const model = input.model ?? agentInfo?.model ?? session.model;
115
+ // 回写当轮实际模型:session.model 是「会话当前模型」的持久来源,但
116
+ // prompt 传入的 model / agent 声明的 model 此前只用于当轮、从不落库,
117
+ // 于是所有读 session.model 的旁路(压缩阈值 contextWindowFor、总结
118
+ // 陈词 summarizeRun、子代理继承、宿主侧标题生成)拿到的都是建会话时
119
+ // 的旧模型甚至 env 兜底值。回写后这些旁路自动跟随当轮模型,无需各自
120
+ // 传参。必须在 buildCompaction 之前完成,否则压缩阈值仍按旧模型算。
121
+ if (model.providerId !== session.model?.providerId || model.modelId !== session.model?.modelId) {
122
+ await this.deps.store.updateSession(session.id, { model }).catch(() => undefined);
123
+ // 同步内存引用:本轮后续(buildCompaction/闭包捕获)都用新模型
124
+ session = { ...session, model };
125
+ }
126
+ const agentRulesets = resolved ? [registry.rulesetsFor("default")[0], resolved.agent.permission] : registry.rulesetsFor(agentInfo?.name ?? "default");
127
+ /** 本轮模型最后投递的 todo 列表(步骤投影的输入)。 */
128
+ let latestTodos = null;
129
+ const engine = new PermissionEngine(session.id, agentRulesets, session.permission, {
130
+ onAsked: async (request) => {
131
+ await this.collabs.publish({ type: "session.status", data: { status: "waiting_permission" } }, session.id);
132
+ await this.collabs.publish({ type: "permission.asked", data: { request } }, session.id);
133
+ // 权限等待必须让任务层可见(规格 §11.1)。否则用户在盯着授权卡的时候,
134
+ // 任务状态还是「运行中」——「在跑」和「在等你点授权」对用户是两件事,
135
+ // 而任务 API 会给出一个错的答案。
136
+ await this.collabs.lifecycle.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, true);
137
+ },
138
+ onReplied: async (request, reply) => {
139
+ await this.collabs.publish({ type: "permission.replied", data: { id: request.id, reply } }, session.id);
140
+ await this.collabs.publish({ type: "session.status", data: { status: "running" } }, session.id);
141
+ // 授权通过或拒绝都解除等待:规格 §11.3 要求把拒绝结果交回模型去试替代
142
+ // 方案,只有模型也没有替代方案时才判 blocked/failed。留在
143
+ // waiting_permission 会把「已经拒绝了」误报成「还在等你授权」。
144
+ await this.collabs.lifecycle.markPermissionWait(taskContext?.task.id, request.permission, request.patterns, false);
145
+ },
146
+ onSessionRuleAdded: async (sessionId, rule) => {
147
+ const latest = await this.deps.store.getSession(sessionId);
148
+ if (!latest)
149
+ return;
150
+ const expiresAt = this.deps.sessionRuleTtlMs && this.deps.sessionRuleTtlMs > 0
151
+ ? new Date(Date.now() + this.deps.sessionRuleTtlMs).toISOString()
152
+ : undefined;
153
+ await this.deps.store.updateSession(sessionId, { permission: [...latest.permission, { ...rule, ...(expiresAt ? { expiresAt } : {}) }] });
154
+ },
155
+ });
156
+ /** 本轮事件的公共漏斗——**模型流通路和工具通路都必须经过它**。
157
+ *
158
+ * 【为什么必须是一个共享函数】曾经把 todo 捕获写在下面 `serializeEmit` 的
159
+ * 包装器里,注释还写着「事件流是所有路径的漏斗,不会漏」。那是错的:默认
160
+ * tool context 的 `setTodos` 拿到的 emit 直接调 `publish`,根本不经过包装器。
161
+ * 结果是 `latestTodos` 永远是 null,步骤数组永远为空,任务于是按「没有步骤」
162
+ * 的分支判定——模型刚说完一句试点性的开场白就被判成「已交付」。这类 bug
163
+ * 的危险之处在于它不报错,只是悄悄把完成判定放宽到形同虚设。 */
164
+ const observeRunEvent = async (event) => {
165
+ if (event.type === "todo.updated") {
166
+ latestTodos = event.data.todos.map((todo) => ({ content: todo.content, status: todo.status }));
167
+ }
168
+ };
169
+ const { emit, settled } = serializeEmit(async (event) => {
170
+ await observeRunEvent(event);
171
+ await this.collabs.persist(event, session.id);
172
+ });
173
+ const projector = new PartProjector({ sessionId: session.id, agent: agentInfo?.name ?? "default", model });
174
+ if (acceptedUserId)
175
+ projector.restoreUserMessage(acceptedUserId);
176
+ let mandatorySkillContext = "";
177
+ if (input.skill) {
178
+ if (!this.deps.resolveMandatorySkill)
179
+ throw new Error("该运行环境不支持 Skill 加载");
180
+ const loaded = await this.deps.resolveMandatorySkill(session, input.skill);
181
+ mandatorySkillContext = loaded.context;
182
+ }
183
+ // Exclude task from contexts that can't nest; include for primary runs.
184
+ const baseTools = [...(this.deps.tools ?? builtinTools), ...(this.deps.localTools ?? []), ...(resolved?.tools ?? [])];
185
+ const toolList = session.parentId ? baseTools.filter((def) => def.id !== "task") : baseTools;
186
+ const toolDefs = new Map(toolList.map((def) => [def.id, def]));
187
+ const sandbox = this.deps.sandbox ?? noopSandboxExecutor();
188
+ // 子代理写路径隔离(07-subagent):会话声明了 writePaths 时把 workspace
189
+ // 门面包进结构层圈禁——越界 write/edit 直接抛错,不可绕过。
190
+ const rawWorkspace = this.deps.workspaceFor(session);
191
+ const workspace = session.writePaths?.length ? confineWorkspaceFiles(rawWorkspace, session.writePaths) : rawWorkspace;
192
+ const emitAsync = async (event) => {
193
+ await observeRunEvent(event);
194
+ await this.collabs.publish(event, session.id);
195
+ };
196
+ const toolContext = (this.deps.buildToolContext ?? defaultToolContext)({ session, engine, workspace, sandbox, emit: emitAsync });
197
+ // Subagent spawning is only available to primary (non-child) sessions, and
198
+ // only when the runner can host a nested run (spec §6.4).
199
+ if (!session.parentId) {
200
+ toolContext.spawnSubagent = (spawnInput) => this.collabs.spawnSubagent(session, spawnInput, registry, engine);
201
+ // M3-S21:协调器可用时注入 agent_* 工具族(宿主须先在 tools 里带上
202
+ // subagentTools——这里只注入运行期上下文:coordinator + 任务归属)
203
+ const coordinator = this.deps.subagentCoordinator;
204
+ if (coordinator) {
205
+ const activeTask = await this.deps.store.task?.getActiveTask(session.id).catch(() => null);
206
+ toolContext.subagents = {
207
+ coordinator,
208
+ rootTaskId: activeTask?.rootRequestId ?? activeTask?.id ?? "task_adhoc",
209
+ parentTaskId: activeTask?.id ?? "task_adhoc",
210
+ };
211
+ }
212
+ }
213
+ const piTools = [...toolDefs.values()].map((def) => adaptAnyTool(def, toolContext));
214
+ let unknownSideEffect = false;
215
+ let sideEffectDetail = null;
216
+ /** 以 error 结束的工具调用摘要(最多留最近三条)。
217
+ * 只用于续跑提示,**不构成阻塞**——工具失败后模型换条路走通是常态。 */
218
+ const toolErrors = [];
219
+ /** 本轮**自己的**最后一条 assistant 文本(交付文本的来源)。
220
+ *
221
+ * 【为什么不用「会话里最后一条」】续跑时那会取到上一轮的文本:一轮「我先看看
222
+ * 文件」之后,下一轮只用工具调完了全部步骤,交付卡上就会出现一句跟本次交付
223
+ * 无关、甚至自相矛盾的正文。连带 `finalTextPresent` 也会用陈旧文本为一次
224
+ * 没说话的运行开绿灯。这里只认本轮新增的消息。 */
225
+ let attemptFinalText = "";
226
+ /** 本轮的证据候选。在工具热路径上只累积内存,Attempt 结束时一次性投影进
227
+ * 任务并落库——否则每次工具调用都要 CAS 写一次任务表。 */
228
+ const evidenceCandidates = [];
229
+ /** 本轮模型用 `task_block` 声明的阻塞(规格 3 §11)。见 `RunOutcome.taskBlock`。 */
230
+ let taskBlock = null;
231
+ /** 本轮模型用 `task_deliver` 提交的交付声明(规格 3 §9 条件 6)。见 `RunOutcome.delivery`。 */
232
+ let delivery = null;
233
+ // 上下文组装唯一入口(W7-S6):压缩 transform、同拍快照重建、记忆召回。
234
+ const { compactionTransform, history, baseline } = await this.collabs.contextBuilder.buildAttemptContext(session, input, emit);
235
+ const agent = new Agent({
236
+ initialState: {
237
+ systemPrompt: [
238
+ agentInfo?.prompt ?? "",
239
+ mandatorySkillContext,
240
+ // 任务契约(规格 3 §6 / §8.2):每个 Attempt 重新注入一次,因此
241
+ // 上下文被压缩掉也不影响任务目标的存续。续跑指令与它同源。
242
+ taskContext ? taskContractText(taskContext.task, taskContext.advisory) : "",
243
+ input.references?.length ? `<attached-resources>\nThe user attached these workspace paths. Read the relevant ones before acting:\n${input.references.join("\n")}\n</attached-resources>` : "",
244
+ ].filter(Boolean).join("\n\n"),
245
+ model: this.deps.modelFor(model),
246
+ // 推理力度(P1-8 复活):relay 现已按模型白名单接受 reasoning_effort;
247
+ // 仅当调用方显式选择且非 off 时下发(默认不设 = 完全不带该字段)。
248
+ ...(input.effort && input.effort !== "off" ? { thinkingLevel: input.effort } : {}),
249
+ tools: piTools,
250
+ messages: history,
251
+ },
252
+ streamFn: this.deps.streamFnFor(session),
253
+ toolExecution: "sequential",
254
+ ...(compactionTransform ? { transformContext: compactionTransform } : {}),
255
+ shouldStopAfterTurn: ({ newMessages }) => newMessages.filter((message) => message.role === "assistant").length >= (agentInfo?.steps ?? 12),
256
+ });
257
+ const abortController = new AbortController();
258
+ const abort = () => {
259
+ abortController.abort();
260
+ agent.abort();
261
+ };
262
+ let resolveDone;
263
+ const done = new Promise((resolve) => { resolveDone = resolve; });
264
+ activeRuns.register(session.id, { agent, engine, settled, abort, done });
265
+ if (this.collabs.scheduler.isStopRequested(session.id))
266
+ abort();
267
+ await this.collabs.scheduler.stampLease(session.id);
268
+ // 循环防护在任务层跨 Attempt 复用(规格 3 §16 阶段 D):一条任务会自己续跑
269
+ // 多轮,而「同一个工具一直以同样的方式失败」是不会因为换了一轮就消失的事实。
270
+ // 每轮新建一个 guard,会让上一轮的两次失败在这一轮从未发生,模型于是有机会
271
+ // 把同一个错误再撞一遍——续跑越多,这个洞越大。
272
+ const loopGuard = taskContext?.loopGuard ?? new LoopGuard();
273
+ agent.beforeToolCall = async ({ toolCall, args }) => {
274
+ if (unknownSideEffect) {
275
+ return { block: true, reason: "上一个可能产生副作用的动作结果不确定,已暂停执行。请先确认外部系统状态,再决定继续或重试。", terminate: true };
276
+ }
277
+ // 重复失败守卫(edit):同一 (path, oldText) 已连续失败多次时,
278
+ // 重试前先复查文件状态——oldText 已唯一存在则放行清记录,
279
+ // 否则直接拦截(避免无意义空转烧步数)。
280
+ const editDef = toolCall.name === "edit" ? toolDefs.get("edit") : undefined;
281
+ const editArgs = editDef && !isExternalToolDef(editDef) ? editDef.parameters.safeParse(args) : undefined;
282
+ if (editDef && editArgs?.success && loopGuard.needsEditRecheck(editArgs.data.path, editArgs.data.oldText)) {
283
+ const file = await workspace.read(editArgs.data.path);
284
+ const occurrences = file ? file.content.split(editArgs.data.oldText).length - 1 : 0;
285
+ if (occurrences === 1)
286
+ loopGuard.clearEditFailure(editArgs.data.path, editArgs.data.oldText);
287
+ else {
288
+ return {
289
+ block: true,
290
+ reason: `[循环防护] 同一 edit(${editArgs.data.path})已连续失败 ${REPEAT_EDIT_FAILURE_THRESHOLD} 次以上,且文件内容没有变化。` +
291
+ `不要继续重试:先用 read 读取 ${editArgs.data.path} 的最新内容,按实际内容重新选择 oldText。`,
292
+ terminate: false,
293
+ };
294
+ }
295
+ }
296
+ // 生命周期钩子(P0):所有工具统一的第一道闸口(在权限评估之前,
297
+ // 便于宿主实现全量工具审计/拦截);reason 会反馈给模型
298
+ const hookBlock = await firstToolBlock(this.hooks, {
299
+ sessionId: session.id,
300
+ agent: session.agent,
301
+ tool: toolCall.name,
302
+ args,
303
+ });
304
+ if (hookBlock?.block) {
305
+ return { block: true, reason: String(hookBlock.reason ?? "被钩子拦截"), terminate: false };
306
+ }
307
+ const mapped = permissionForCall(toolDefs, toolCall.name, args);
308
+ if (!mapped)
309
+ return undefined;
310
+ try {
311
+ await engine.ask({
312
+ sessionId: session.id,
313
+ permission: mapped.permission,
314
+ patterns: mapped.patterns,
315
+ always: mapped.always,
316
+ metadata: mapped.metadata,
317
+ tool: { messageId: projector.currentAssistantMessageId ?? "", callId: toolCall.id },
318
+ });
319
+ return undefined;
320
+ }
321
+ catch (error) {
322
+ if (error instanceof RejectedError) {
323
+ // 连续被拒 streak:拼入改变策略指令,阻止模型硬闯不允许的操作
324
+ const advisory = loopGuard.onBlocked(toolCall.name);
325
+ return { block: true, reason: advisory ? `${error.message}\n\n${advisory}` : error.message, terminate: false };
326
+ }
327
+ throw error;
328
+ }
329
+ };
330
+ // storm 断路器:同一工具连续以相同响应失败 3 次(签名不含 args,
331
+ // 防"化妆参数"重试),在第 3 次的结果里注入改变策略指令。
332
+ // 只碰工具结果,不碰 F6 模型级重试路径。
333
+ agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
334
+ const details = typeof result.details === "object" && result.details !== null ? result.details : null;
335
+ if (details?.outcome === "unknown") {
336
+ unknownSideEffect = true;
337
+ sideEffectDetail ??= `${toolCall.name} 的结果不确定`;
338
+ }
339
+ const resultText = (result.content ?? [])
340
+ .filter((block) => block.type === "text")
341
+ .map((block) => block.text)
342
+ .join("\n");
343
+ // 证据采集(规格 3 §9 条件 3):只记**成功**的写 / 执行 / 外部核对类调用。
344
+ // 失败不是验证证据;读取类工具也不记(见 evidenceKindForTool 的说明)——
345
+ // 否则「有证据」会退化成「调过工具」。
346
+ if (isError) {
347
+ toolErrors.push(`${toolCall.name}: ${resultText.trim().slice(0, 160)}`);
348
+ if (toolErrors.length > 3)
349
+ toolErrors.shift();
350
+ }
351
+ else {
352
+ // 模型声明「需要用户介入」(规格 §11)。只认成功的调用:一次被拒绝或
353
+ // 崩溃的 task_block 没有资格把任务停下来等用户。
354
+ if (toolCall.name === TASK_BLOCK_TOOL_ID)
355
+ taskBlock = readTaskBlock(args);
356
+ // 模型声明「我交付了」(规格 §9 条件 6)。同样只认成功的调用——参数过不了
357
+ // schema 的交付声明必须当成「没有声明」,否则一次格式错误就能换来 delivered。
358
+ if (toolCall.name === TASK_DELIVER_TOOL_ID)
359
+ delivery = readTaskDelivery(args);
360
+ const kind = evidenceKindForTool(toolCall.name);
361
+ if (kind) {
362
+ const path = args?.path;
363
+ const program = args?.program;
364
+ const ref = typeof path === "string" ? path : typeof program === "string" ? program : undefined;
365
+ const title = typeof details?.title === "string" ? details.title : undefined;
366
+ evidenceCandidates.push({
367
+ kind,
368
+ summary: (title ?? resultText).trim().slice(0, 160) || toolCall.name,
369
+ ...(ref ? { ref } : {}),
370
+ });
371
+ }
372
+ }
373
+ let advisory = loopGuard.onToolResult({ toolName: toolCall.name, isError, errorText: resultText });
374
+ // 生命周期钩子(P0):只读观测,不阻塞结果路径
375
+ void Promise.all(fireAfterToolCall(this.hooks, {
376
+ sessionId: session.id,
377
+ agent: session.agent,
378
+ tool: toolCall.name,
379
+ isError,
380
+ title: typeof details?.title === "string" ? details.title : undefined,
381
+ }));
382
+ if (toolCall.name === "edit") {
383
+ const editAfterDef = toolDefs.get("edit");
384
+ const parsed = editAfterDef && !isExternalToolDef(editAfterDef) ? editAfterDef.parameters.safeParse(args) : undefined;
385
+ if (parsed?.success) {
386
+ if (isError) {
387
+ loopGuard.noteEditFailure(parsed.data.path, parsed.data.oldText);
388
+ if (!advisory && loopGuard.needsEditRecheck(parsed.data.path, parsed.data.oldText)) {
389
+ advisory = `[循环防护] 同一 edit 已连续失败 ${REPEAT_EDIT_FAILURE_THRESHOLD} 次(oldText 不存在或不唯一)。停止重试:先 read 该文件获取最新内容,再按实际内容重新选择 oldText。`;
390
+ }
391
+ }
392
+ else {
393
+ loopGuard.clearEditFailure(parsed.data.path, parsed.data.oldText);
394
+ }
395
+ }
396
+ }
397
+ if (!advisory)
398
+ return undefined;
399
+ return { content: [{ type: "text", text: `${advisory}\n\n--- 原始工具结果 ---\n${resultText}` }] };
400
+ };
401
+ // 流空闲看门狗:上游无响应时(模型不支持该输入如非视觉模型收图、网络挂起),
402
+ // runLoop 会无限挂起,用户端表现为「卡住」。任意 agent 事件喂狗;
403
+ // 超过阈值未喂则发布 session.error 并 abort,让 UI 得到明确反馈而非永久等待。
404
+ // P2 裁决:180s→300s——长工具调用(大文件检索、慢沙箱)+ 模型排队首 token
405
+ // 叠加起来 3 分钟都可能不够,5 分钟内不应限制;可用 ZMZAI_STREAM_IDLE_TIMEOUT_MS 覆盖。
406
+ const STREAM_IDLE_TIMEOUT_MS = Number(process.env.ZMZAI_STREAM_IDLE_TIMEOUT_MS ?? 300_000);
407
+ let lastStreamActivityAt = Date.now();
408
+ const feedStreamWatchdog = () => {
409
+ lastStreamActivityAt = Date.now();
410
+ };
411
+ // 任务终态小结统计(N5):工具调用数 + 编辑/写入文件去重集合。
412
+ // 在 tool_execution_start 累加(比事后解析 agent.state.messages 更稳——
413
+ // 那里 toolCall 是 AssistantMessage.content 里的块,结构复杂易漏)。
414
+ let summaryToolCalls = 0;
415
+ const summaryEditedFiles = new Set();
416
+ // N6 长任务 checkpoint:记录最后一个工具名(中途快照用)
417
+ let summaryLastTool;
418
+ agent.subscribe((event) => {
419
+ feedStreamWatchdog();
420
+ switch (event.type) {
421
+ case "message_start":
422
+ if (event.message.role === "assistant")
423
+ projector.onAssistantStart(emit);
424
+ break;
425
+ case "message_update": {
426
+ const streamEvent = event.assistantMessageEvent;
427
+ if (streamEvent.type === "text_delta")
428
+ projector.onTextDelta(emit, streamEvent.contentIndex, streamEvent.delta);
429
+ if (streamEvent.type === "thinking_delta")
430
+ projector.onThinkingDelta(emit, streamEvent.contentIndex, streamEvent.delta);
431
+ break;
432
+ }
433
+ case "message_end":
434
+ if (event.message.role === "assistant")
435
+ projector.onAssistantEnd(emit, event.message);
436
+ break;
437
+ case "tool_execution_start":
438
+ summaryToolCalls += 1;
439
+ summaryLastTool = event.toolName;
440
+ if (event.toolName === "edit" || event.toolName === "write") {
441
+ const path = event.args?.path;
442
+ if (typeof path === "string")
443
+ summaryEditedFiles.add(path);
444
+ }
445
+ projector.onToolExecutionStart(emit, event.toolCallId, event.toolName, event.args, toolDefs.get(event.toolName)?.label);
446
+ break;
447
+ case "tool_execution_update":
448
+ projector.onToolExecutionUpdate(emit, event.toolCallId, event.partialResult);
449
+ break;
450
+ case "tool_execution_end":
451
+ projector.onToolExecutionEnd(emit, event.toolCallId, event.result, event.isError);
452
+ break;
453
+ }
454
+ });
455
+ const streamWatchdog = setInterval(() => {
456
+ if (Date.now() - lastStreamActivityAt < STREAM_IDLE_TIMEOUT_MS)
457
+ return;
458
+ lastStreamActivityAt = Date.now(); // 只触发一次,错误路径会 abort 收尾
459
+ void this.collabs.publish({ type: "session.error", data: { name: "StreamIdleTimeout", message: `上游 ${STREAM_IDLE_TIMEOUT_MS / 1000}s 无响应,已中止本次运行(模型可能不支持该输入,如非视觉模型收到图片)。点「继续」可在同一会话续跑;若持续超时可切换模型重试。` } }, session.id);
460
+ abortController.abort();
461
+ }, 15_000);
462
+ let runErrored = false;
463
+ /** 本轮失败的错误消息。用来区分「上游抖动(可重试)」与「确定没救」——
464
+ * 两者在 Completion Gate 里走完全不同的分支(续跑 vs failed)。 */
465
+ let runErrorMessage = null;
466
+ const runStartedAt = Date.now();
467
+ // N6 长任务中途 checkpoint:运行超过阈值后周期性发布进度快照(已执行工具数 /
468
+ // 最后一步工具 / 耗时),崩溃/中断后前端据此提示「上次进行到哪」。终态收尾仍
469
+ // 由 session.summary 兜底;这里只在运行中给「中间落点」。可用
470
+ // ZMZAI_CHECKPOINT_INTERVAL_MS 覆盖间隔(默认 2 分钟),首次触发即从此刻起算。
471
+ const CHECKPOINT_INTERVAL_MS = Number(process.env.ZMZAI_CHECKPOINT_INTERVAL_MS ?? 120_000);
472
+ const checkpointTimer = setInterval(() => {
473
+ if (abortController.signal.aborted)
474
+ return;
475
+ const elapsedMs = Date.now() - runStartedAt;
476
+ // 只在确实有进展时发(有工具调用),避免空跑会话也刷 checkpoint
477
+ if (summaryToolCalls === 0)
478
+ return;
479
+ void this.collabs.publish({
480
+ type: "session.checkpoint",
481
+ data: { toolCalls: summaryToolCalls, lastTool: summaryLastTool, elapsedMs },
482
+ }, session.id).catch(() => undefined);
483
+ }, CHECKPOINT_INTERVAL_MS);
484
+ try {
485
+ await Promise.all(fireRunStart(this.hooks, { sessionId: session.id, agent: session.agent, text: input.text }));
486
+ await this.collabs.publish({ type: "session.status", data: { status: "running" } }, session.id);
487
+ if (abortController.signal.aborted)
488
+ throw new Error("Run cancelled during setup");
489
+ // `resume` 与 `acceptedUserId` 都表示「这一轮没有一个用户回合」:前者是用户
490
+ // 按了按钮,后者是任务自己接着跑。两条路径都不能投影用户消息(规格 §18.2)。
491
+ if (!acceptedUserId && !input.resume)
492
+ projector.onUserPrompt(emit, input.text, input.images, input.skill, input.references, input.attachments, input.attachmentRefs);
493
+ const piImages = input.images?.map((img) => {
494
+ const match = img.url.match(/^data:([^;]+);base64,(.+)$/);
495
+ return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
496
+ }).filter((img) => img !== null);
497
+ const attachmentParts = await attachmentRefContent(this.deps.attachments, input.attachmentRefs ?? [], { sessionId: session.id });
498
+ // 内部续跑喂给模型的驱动文本(规格 3 §8.2)。它**不落库**:上面那条
499
+ // `if (!acceptedUserId) projector.onUserPrompt(...)` 已经跳过,所以聊天
500
+ // 记录里看不到它(§18.2 验收要求「没有合成用户消息」)。但驱动模型本身
501
+ // 必须有个输入——规格 §19 禁止的是把「继续」做成一条**持久化的伪用户
502
+ // 消息**,不是禁止给模型一个继续的由头。
503
+ const driveText = input.resume
504
+ ? RESUME_DRIVE_TEXT
505
+ : taskContext?.continuation
506
+ ? "[继续执行任务] 上一轮结束时任务还没有完成。按上面的任务契约继续推进,不要中途把控制权交回用户。"
507
+ : input.text;
508
+ await agent.prompt({ role: "user", content: [{ type: "text", text: driveText }, ...attachmentContent(input.attachments ?? []), ...attachmentParts, ...(piImages ?? [])], timestamp: Date.now() });
509
+ await settled();
510
+ let failed = agent.state.errorMessage;
511
+ if (unknownSideEffect) {
512
+ await this.collabs.publish({ type: "session.status", data: { status: "waiting_input" } }, session.id);
513
+ }
514
+ else {
515
+ // 自动重试(F6):上游中断类错误(terminated/econnreset/timeout/429/5xx 等)
516
+ // 带退避重试,最多 5 次。PI 失败时会注入一条 assistant failure 占位
517
+ // 消息(last 是 assistant → continue() 被拒),换成合成 user 消息驱动
518
+ // continue 重新生成;store 里同步的 failure 消息保留展示(UI 显示
519
+ // "出错了"有诊断价值)。退避 500ms→1s→2s→4s→8s 避免对上游施压。
520
+ // P1:3→5 次——限流(429)场景退避窗口需要更长才有机会恢复。
521
+ const MAX_RETRIES = 5;
522
+ for (let attempt = 0; attempt < MAX_RETRIES && failed && isRetryableError(failed); attempt += 1) {
523
+ if (attempt > 0)
524
+ await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
525
+ const messages = agent.state.messages;
526
+ const last = messages[messages.length - 1];
527
+ if (last?.role === "assistant" && "errorMessage" in last && last.errorMessage) {
528
+ agent.state.messages = [
529
+ ...messages.slice(0, -1),
530
+ { role: "user", content: [{ type: "text", text: RETRY_PLACEHOLDER_TEXT }], timestamp: Date.now() },
531
+ ];
532
+ }
533
+ try {
534
+ await agent.continue();
535
+ await settled();
536
+ failed = agent.state.errorMessage;
537
+ }
538
+ catch (retryError) {
539
+ failed = retryError instanceof Error ? retryError.message : "Agent 重试失败";
540
+ }
541
+ }
542
+ if (failed) {
543
+ runErrored = true;
544
+ runErrorMessage = failed;
545
+ await this.collabs.publish({ type: "session.error", data: { name: "APIError", message: failed } }, session.id);
546
+ }
547
+ await this.collabs.publish({ type: "session.status", data: { status: "idle" } }, session.id);
548
+ }
549
+ }
550
+ catch (error) {
551
+ await settled();
552
+ const aborted = abortController.signal.aborted;
553
+ if (!aborted && !unknownSideEffect) {
554
+ runErrored = true;
555
+ runErrorMessage = error instanceof Error ? error.message : "Agent 运行失败";
556
+ }
557
+ await this.collabs.publish(unknownSideEffect
558
+ ? { type: "session.status", data: { status: "waiting_input" } }
559
+ : aborted
560
+ ? { type: "session.status", data: { status: "idle" } }
561
+ : { type: "session.error", data: { name: "AgentRuntimeError", message: error instanceof Error ? error.message : "Agent 运行失败" } }, session.id);
562
+ if (!aborted && !unknownSideEffect)
563
+ await this.collabs.publish({ type: "session.status", data: { status: "idle" } }, session.id);
564
+ }
565
+ finally {
566
+ clearInterval(streamWatchdog);
567
+ clearInterval(checkpointTimer);
568
+ defaultActiveRunRegistry.delete(session.id);
569
+ engine.dispose();
570
+ // Workflow settlement clears its lease atomically with the terminal run state.
571
+ if (!acceptedUserId)
572
+ await this.collabs.scheduler.clearLease(session.id);
573
+ const newMessages = extractRunTranscript(agent.state.messages, baseline);
574
+ attemptFinalText = [...newMessages].reverse().find((message) => message.role === "assistant")?.text ?? "";
575
+ // 任务终态小结(N5):终态 status 已发布后补一条 session.summary,
576
+ // 让「任务完成」有明确收尾(AI 一句总结 + 结构化统计)。await 保证
577
+ // 落库后再 resolveDone(中断/失败场景不阻塞——内部有静默降级)。
578
+ const summaryKind = abortController.signal.aborted ? "aborted" : runErrored ? "error" : "completed";
579
+ await this.summarizeRun(session, {
580
+ agent,
581
+ baseline,
582
+ startedAt: runStartedAt,
583
+ kind: summaryKind,
584
+ stats: { toolCalls: summaryToolCalls, editedFiles: [...summaryEditedFiles] },
585
+ });
586
+ void Promise.all(fireRunEnd(this.hooks, { sessionId: session.id, agent: session.agent, ok: !runErrored, aborted: abortController.signal.aborted, workspaceId: session.workspaceId, newMessages }));
587
+ resolveDone();
588
+ }
589
+ const aborted = abortController.signal.aborted;
590
+ const outcome = {
591
+ state: unknownSideEffect ? "recovery_required" : aborted ? "cancelled" : runErrored ? "failed" : "completed",
592
+ settled: !aborted && !runErrored && !unknownSideEffect,
593
+ aborted,
594
+ unknownSideEffect,
595
+ sideEffectDetail,
596
+ filesEdited: [...summaryEditedFiles],
597
+ toolCalls: summaryToolCalls,
598
+ durationMs: Date.now() - runStartedAt,
599
+ toolErrors: [...toolErrors],
600
+ finalText: attemptFinalText,
601
+ todos: latestTodos,
602
+ taskBlock,
603
+ delivery,
604
+ evidenceCandidates: [...evidenceCandidates],
605
+ errorMessage: runErrorMessage,
606
+ };
607
+ if (acceptedUserId || unknownSideEffect)
608
+ return outcome;
609
+ // FIFO queued prompts (spec §13.3): settle fully, then take the next one.
610
+ const next = await this.deps.store.dequeuePrompt(session.id);
611
+ if (next) {
612
+ const latest = await this.deps.store.getSession(session.id);
613
+ if (latest)
614
+ await this.runLoop(latest, {
615
+ text: next.text,
616
+ attachments: next.attachments,
617
+ images: next.images,
618
+ model: next.model,
619
+ ...(next.agent ? { agent: next.agent } : {}),
620
+ ...(next.effort ? { effort: next.effort } : {}),
621
+ ...(next.skill ? { skill: next.skill } : {}),
622
+ ...(next.references?.length ? { references: [...next.references] } : {}),
623
+ });
624
+ }
625
+ return outcome;
626
+ }
627
+ /** 任务终态小结(N5):run 收尾时用 summary 模型生成一句自然语言总结,
628
+ * 附本轮结构化统计,发布 session.summary 事件供前端渲染「任务完成卡」。
629
+ * 失败/中断也发(kind 区分),让 UI 的收尾永远有明确落点。生成失败或
630
+ * 无 summaryModel 时静默跳过(不阻塞 run 收尾)。 */
631
+ async summarizeRun(session, input) {
632
+ // 终态先落 session 记录(列表三态用,N5)——即使 summaryModel 缺失或生成
633
+ // 失败,lastOutcome 也要写回,保证列表能区分完成/中断/失败。
634
+ await this.deps.store.updateSession(session.id, { lastOutcome: input.kind }).catch(() => undefined);
635
+ const durationMs = Date.now() - input.startedAt;
636
+ const meta = { filesEdited: input.stats.editedFiles.length, toolCalls: input.stats.toolCalls, durationMs };
637
+ // 兜底文案:summary 生成失败 / 无模型 / 超时 / 空文本时,仍然发一条带
638
+ // meta 的 session.summary,保证前端「任务完成卡」三态必现,而非悄无声息结束。
639
+ const fallbackText = input.kind === "completed"
640
+ ? `本轮任务已完成,共 ${meta.toolCalls} 次工具调用${meta.filesEdited > 0 ? `,改动 ${meta.filesEdited} 个文件` : ""}。`
641
+ : input.kind === "aborted"
642
+ ? `本轮任务已中断,已完成 ${meta.toolCalls} 次工具调用${meta.filesEdited > 0 ? `,改动 ${meta.filesEdited} 个文件` : ""}。`
643
+ : `本轮任务执行出错,已完成 ${meta.toolCalls} 次工具调用${meta.filesEdited > 0 ? `,改动 ${meta.filesEdited} 个文件` : ""}。`;
644
+ // 总结模型沿用「会话实际模型」而非 compaction 专用 summaryModel,保证
645
+ // 总结卡文案由当前会话正在用的模型生成。仅当 compaction 启用时才做 AI
646
+ // 总结(否则落到兜底文案),但模型来源与会话模型解耦。modelFor 抛错
647
+ // (宿主 provider 不认识该 ref)时回落 compaction.summaryModel,再不行
648
+ // 落到下方兜底文案。
649
+ let summaryModel = null;
650
+ if (this.deps.compaction?.enabled) {
651
+ try {
652
+ summaryModel = this.deps.modelFor(session.model);
653
+ }
654
+ catch {
655
+ summaryModel = this.deps.compaction.summaryModel ?? null;
656
+ }
657
+ }
658
+ // 本轮新增消息(baseline 之后),只取 user/assistant 文本作总结素材
659
+ const messages = input.agent.state.messages.slice(input.baseline);
660
+ const textMessages = messages.filter((m) => m.role === "user" || m.role === "assistant");
661
+ let text = "";
662
+ if (summaryModel && textMessages.length > 0) {
663
+ // 总结提示词:只描述本轮做了什么,一句/几句自然语言,不续写
664
+ const systemPrompt = "你是任务收尾助手。根据用户本轮的任务与助手已完成的工作,写一句简短、克制的中文总结(1-2 句)," +
665
+ "说明「做了什么、结果如何、下一步建议」。不要客套、不要问句、不要续写任务,只输出总结正文。";
666
+ try {
667
+ const { streamOneText } = await import("./compaction.js");
668
+ const streamFn = this.deps.streamFnFor(session);
669
+ // 15s 超时兜底:summary 是收尾的锦上添花,不能因为上游慢而卡住 run 收尾。
670
+ text = await Promise.race([
671
+ streamOneText(async (m, ctx) => streamFn(m, ctx), summaryModel, systemPrompt, textMessages),
672
+ new Promise((resolve) => setTimeout(() => resolve(""), 15_000)),
673
+ ]);
674
+ }
675
+ catch {
676
+ /* 总结生成失败 → 落到下方兜底文案 */
677
+ }
678
+ }
679
+ const trimmed = text.trim();
680
+ await this.collabs.publish({ type: "session.summary", data: { text: trimmed || fallbackText, kind: input.kind, meta } }, session.id).catch(() => undefined);
681
+ }
682
+ }
683
+ //# sourceMappingURL=attempt-executor.js.map