c0de-agent 1.7.0 → 1.9.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/dist/core/config.js +4 -1
- package/dist/core/loop/compaction.d.ts +31 -0
- package/dist/core/loop/compaction.js +137 -0
- package/dist/core/loop/persist.d.ts +11 -0
- package/dist/core/loop/persist.js +107 -0
- package/dist/core/loop/segment.d.ts +8 -0
- package/dist/core/loop/segment.js +72 -0
- package/dist/core/loop/stream-collect.d.ts +47 -0
- package/dist/core/loop/stream-collect.js +183 -0
- package/dist/core/loop/subagent.d.ts +12 -0
- package/dist/core/loop/subagent.js +171 -0
- package/dist/core/loop/todo.d.ts +5 -0
- package/dist/core/loop/todo.js +26 -0
- package/dist/core/loop.d.ts +2 -20
- package/dist/core/loop.js +12 -658
- package/dist/core/prompt-registry.d.ts +1 -1
- package/dist/core/prompt-registry.js +10 -0
- package/dist/core/slash.js +1 -1
- package/dist/core/todo-tags.d.ts +46 -0
- package/dist/core/todo-tags.js +192 -0
- package/dist/core/workflows/builtins.js +4 -4
- package/dist/core/workflows/discovery.js +4 -1
- package/dist/llm/registry.d.ts +30 -3
- package/dist/llm/registry.js +25 -7
- package/dist/llm/retry.d.ts +4 -2
- package/dist/llm/retry.js +7 -6
- package/dist/llm/schema/errors.d.ts +13 -3
- package/dist/llm/schema/errors.js +35 -3
- package/dist/llm/transport.js +5 -1
- package/dist/project/resolve.js +2 -2
- package/dist/server/routes/chat.js +8 -2
- package/dist/server/routes/todo.js +5 -2
- package/dist/server/server.d.ts +4 -2
- package/dist/server/server.js +20 -12
- package/dist/session/squash.js +31 -24
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/tools/builtin/todo.d.ts +7 -0
- package/dist/tools/builtin/todo.js +23 -16
- package/package.json +2 -1
package/dist/core/loop.js
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
import { chatStream as llmChatStream } from '../llm/provider.js';
|
|
2
|
-
import { createKanbanStore } from '../kanban/index.js';
|
|
3
|
-
import { isContextOverflowFailure } from '../llm/provider-error.js';
|
|
4
2
|
import { resolveRoute } from '../llm/registry.js';
|
|
5
|
-
import { isLLMError } from '../llm/schema/errors.js';
|
|
6
3
|
import { detectProjectInfo } from '../project/detect.js';
|
|
7
4
|
import { entriesToChatMessages, getSessionContext } from '../session/context.js';
|
|
8
|
-
import {
|
|
9
|
-
import { createSession, saveLLMSegments, segmentFingerprint } from '../session/session.js';
|
|
5
|
+
import { getMessages } from '../session/message.js';
|
|
10
6
|
import { estimateTokens } from '../session/token.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
7
|
+
import { calibrateEstimate, createTokenBudget, estimateBudget } from './context.js';
|
|
8
|
+
import { runCompactionIfNeeded } from './loop/compaction.js';
|
|
9
|
+
import { persistAssistantAndTools } from './loop/persist.js';
|
|
10
|
+
import { manageSegment } from './loop/segment.js';
|
|
11
|
+
import { collectStreamChunks, isParseErrorInput, } from './loop/stream-collect.js';
|
|
12
|
+
import { processTodoTags } from './loop/todo.js';
|
|
13
|
+
import { DEFAULT_EDIT_MODE, getToolMetrics, selectBestMode } from './metrics.js';
|
|
16
14
|
import { buildDynamicPrompt, createPromptRegistry, registerPromptSection, } from './prompt-registry.js';
|
|
17
15
|
import { drainSteering } from './steering.js';
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
export { compactContext } from './loop/compaction.js';
|
|
17
|
+
export { runSubAgent } from './loop/subagent.js';
|
|
20
18
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
19
|
/** provider 未声明 contextWindow 时回填的保守下限(避免估计过大不压缩、过小过度压缩)。 */
|
|
22
20
|
const FALLBACK_CONTEXT_WINDOW = 32_000;
|
|
@@ -58,652 +56,6 @@ async function waitForResume(state) {
|
|
|
58
56
|
return;
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
|
-
/** 运行一个按类型派发的子 agent(spec: multi-agent-design §4.5)。
|
|
62
|
-
*
|
|
63
|
-
* Host 端实现:查 agentRegistry 获取 AgentDefinition → 创建隔离子 session(agentType 记录)
|
|
64
|
-
* → 构建子 agent(专属 prompt + 受限工具集 + yield)→ 运行到 yield 或完成 → 返回结果。
|
|
65
|
-
* 发射 subagent_start/subagent_end 事件供父 agent 转发(spec §4.5 step 7)。
|
|
66
|
-
* abort 链接父→子。maxRecursion 控制子 agent 能否再递归派生 task(spec §4.5 step 4)。
|
|
67
|
-
* def.isolated 时在 git worktree 中运行,结束后把 delta 自动 apply 回父仓库(spec §4.6)。
|
|
68
|
-
* request.background 时 fork 异步运行,立即返回 running(spec §4.7)。 */
|
|
69
|
-
export async function runSubAgent(deps, parent, request) {
|
|
70
|
-
// 1. 查 agent 类型
|
|
71
|
-
if (!deps.agentRegistry) {
|
|
72
|
-
return { _tag: 'error', error: 'task tool unavailable: no agent registry is wired' };
|
|
73
|
-
}
|
|
74
|
-
const def = deps.agentRegistry.get(request.agentType);
|
|
75
|
-
if (!def) {
|
|
76
|
-
return {
|
|
77
|
-
_tag: 'error',
|
|
78
|
-
error: `Unknown agent type: ${request.agentType} is not a valid agent type`,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
const title = request.description?.trim() ||
|
|
82
|
-
`Sub-agent (${request.agentType}): ${request.prompt.slice(0, 60)}`;
|
|
83
|
-
const childId = generateId();
|
|
84
|
-
const yielded = [];
|
|
85
|
-
// 发射 subagent_start 事件(spec §4.5 step 7)
|
|
86
|
-
deps._subagentEventSink?.({
|
|
87
|
-
_tag: 'subagent_start',
|
|
88
|
-
childId,
|
|
89
|
-
agentType: request.agentType,
|
|
90
|
-
description: request.description ?? '',
|
|
91
|
-
background: request.background ?? false,
|
|
92
|
-
});
|
|
93
|
-
// 2. 创建子 session(记录 agentType)
|
|
94
|
-
let childSession;
|
|
95
|
-
try {
|
|
96
|
-
childSession = await createSession(deps.db, title, parent.session.projectId ?? undefined, request.agentType);
|
|
97
|
-
}
|
|
98
|
-
catch (e) {
|
|
99
|
-
return { _tag: 'error', error: e instanceof Error ? e.message : String(e) };
|
|
100
|
-
}
|
|
101
|
-
// 3. worktree 隔离(isolated agent):失败回退共享 cwd
|
|
102
|
-
let worktreePath;
|
|
103
|
-
let baseline;
|
|
104
|
-
if (def.isolated) {
|
|
105
|
-
try {
|
|
106
|
-
baseline = await captureBaseline(deps.cwd);
|
|
107
|
-
worktreePath = await createWorktree(deps.cwd, `subagent-${childSession.id}`);
|
|
108
|
-
}
|
|
109
|
-
catch (e) {
|
|
110
|
-
console.warn(`[subagent] worktree creation failed, falling back to shared cwd: ${e instanceof Error ? e.message : e}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
const childCwd = worktreePath ?? deps.cwd;
|
|
114
|
-
// 实际运行子 agent 的内部函数(sync 与 background 共用)
|
|
115
|
-
const runBody = async () => {
|
|
116
|
-
// 4. 构建子 agent 配置:工具集隔离 + 模型覆盖 + 递归限制 + yield
|
|
117
|
-
const parentDepth = deps._subagentDepth ?? 0;
|
|
118
|
-
const childDepth = parentDepth + 1;
|
|
119
|
-
const declaredTools = def.tools ?? parent.config.tools;
|
|
120
|
-
const maxRec = def.maxRecursion ?? 0;
|
|
121
|
-
const baseTools = childDepth > maxRec ? declaredTools.filter((t) => t !== 'task') : declaredTools;
|
|
122
|
-
const childTools = Array.from(new Set([...baseTools, 'yield']));
|
|
123
|
-
const childConfig = {
|
|
124
|
-
...parent.config,
|
|
125
|
-
systemPrompt: def.systemPrompt,
|
|
126
|
-
// 子 agent 走整段 systemPrompt 替换,清除父的 role override 避免干扰
|
|
127
|
-
agentRolePrompt: undefined,
|
|
128
|
-
tools: childTools,
|
|
129
|
-
...(def.model ? { model: def.model } : {}),
|
|
130
|
-
...(request.model ? { model: request.model } : {}),
|
|
131
|
-
};
|
|
132
|
-
// 子 agent 的 deps:覆盖 cwd(worktree)+ 注入 yield 收集器 + 递归深度
|
|
133
|
-
const childDeps = {
|
|
134
|
-
...deps,
|
|
135
|
-
cwd: childCwd,
|
|
136
|
-
_subagentYieldCollector: (data) => {
|
|
137
|
-
yielded.push(data);
|
|
138
|
-
},
|
|
139
|
-
_subagentDepth: childDepth,
|
|
140
|
-
};
|
|
141
|
-
const childState = await createAgent(childSession, childConfig, childDeps);
|
|
142
|
-
// abort 链接:父 abort 则子 abort
|
|
143
|
-
if (parent.abortController.signal.aborted) {
|
|
144
|
-
childState.abortController.abort();
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
parent.abortController.signal.addEventListener('abort', () => childState.abortController.abort(), { once: true });
|
|
148
|
-
}
|
|
149
|
-
// 运行子 agent loop
|
|
150
|
-
const childPrompt = request.context
|
|
151
|
-
? `CONTEXT\n${request.context}\n\nASSIGNMENT\n${request.prompt}`
|
|
152
|
-
: request.prompt;
|
|
153
|
-
const text = [];
|
|
154
|
-
let errMsg = null;
|
|
155
|
-
try {
|
|
156
|
-
for await (const ev of runAgent(childState, [{ _tag: 'text', text: childPrompt }], childDeps)) {
|
|
157
|
-
if (ev._tag === 'text_delta') {
|
|
158
|
-
text.push(ev.text);
|
|
159
|
-
}
|
|
160
|
-
else if (ev._tag === 'error') {
|
|
161
|
-
const e = ev.error;
|
|
162
|
-
errMsg = e._tag === 'unexpected' || e._tag === 'provider' ? e.message : e._tag;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
catch (e) {
|
|
167
|
-
errMsg = e instanceof Error ? e.message : String(e);
|
|
168
|
-
}
|
|
169
|
-
// 5. worktree 回传:仅成功时把 delta apply 回父仓库(spec §4.6);无论成败都清理 worktree
|
|
170
|
-
if (baseline && worktreePath) {
|
|
171
|
-
if (errMsg === null) {
|
|
172
|
-
try {
|
|
173
|
-
const patch = await captureDeltaPatch(worktreePath, baseline);
|
|
174
|
-
await applyPatchToParent(deps.cwd, patch, `agent(isolated): ${title}`);
|
|
175
|
-
}
|
|
176
|
-
catch (e) {
|
|
177
|
-
console.warn(`[subagent] worktree apply failed: ${e instanceof Error ? e.message : e}`);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
removeWorktree(deps.cwd, worktreePath);
|
|
181
|
-
}
|
|
182
|
-
const success = errMsg === null;
|
|
183
|
-
// 发射 subagent_end 事件(spec §4.5 step 7)
|
|
184
|
-
deps._subagentEventSink?.({
|
|
185
|
-
_tag: 'subagent_end',
|
|
186
|
-
childId,
|
|
187
|
-
agentType: request.agentType,
|
|
188
|
-
success,
|
|
189
|
-
...(success ? { output: text.join('') } : {}),
|
|
190
|
-
});
|
|
191
|
-
if (errMsg !== null) {
|
|
192
|
-
return { _tag: 'error', error: errMsg, sessionId: childSession.id };
|
|
193
|
-
}
|
|
194
|
-
const data = yielded.length > 0 ? (yielded.length === 1 ? yielded[0] : yielded) : undefined;
|
|
195
|
-
return {
|
|
196
|
-
_tag: 'success',
|
|
197
|
-
output: text.join(''),
|
|
198
|
-
sessionId: childSession.id,
|
|
199
|
-
...(data !== undefined ? { data } : {}),
|
|
200
|
-
};
|
|
201
|
-
};
|
|
202
|
-
// 6. background 模式:fork 异步运行,立即返回 running;完成时向父 session 注入合成通知
|
|
203
|
-
if (request.background) {
|
|
204
|
-
const jobId = childSession.id;
|
|
205
|
-
void runBody()
|
|
206
|
-
.then((result) => {
|
|
207
|
-
const success = result._tag === 'success';
|
|
208
|
-
const output = success ? result.output : result.error;
|
|
209
|
-
const tag = success ? 'task_result' : 'task_error';
|
|
210
|
-
const synthetic = `<task id="${childSession.id}" state="${success ? 'completed' : 'failed'}">\n<${tag}>\n${output}\n</${tag}>\n</task>`;
|
|
211
|
-
void appendMessage(deps.db, parent.session.id, {
|
|
212
|
-
role: 'user',
|
|
213
|
-
content: [{ _tag: 'text', text: synthetic }],
|
|
214
|
-
}).catch(() => { });
|
|
215
|
-
})
|
|
216
|
-
.catch(() => { });
|
|
217
|
-
return { _tag: 'running', jobId, sessionId: childSession.id };
|
|
218
|
-
}
|
|
219
|
-
return runBody();
|
|
220
|
-
}
|
|
221
|
-
function toolResultToContent(toolCallId, toolName, result) {
|
|
222
|
-
return [{ _tag: 'tool_result', id: toolCallId, tool: toolName, output: result }];
|
|
223
|
-
}
|
|
224
|
-
/** 入参是否为协议层/loop 标记的解析失败(携带 _parseError / _raw 容错标记)。
|
|
225
|
-
* 这类入参是后端专用、只反馈给模型重试的,绝不能进入持久化消息或渲染层。 */
|
|
226
|
-
function isParseErrorInput(input) {
|
|
227
|
-
if (input === null || typeof input !== 'object' || Array.isArray(input))
|
|
228
|
-
return false;
|
|
229
|
-
const obj = input;
|
|
230
|
-
return '_parseError' in obj || '_raw' in obj;
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* 执行会话压缩并刷新 token 预算。
|
|
234
|
-
*
|
|
235
|
-
* 自动压缩(agentLoop 阈值触发)与手动 /compact 共用此逻辑:复用
|
|
236
|
-
* createSummarizer + runCompaction,压缩改写消息历史后标记下一段 trigger='compaction'。
|
|
237
|
-
*
|
|
238
|
-
* 成功时 yield 一条 text_delta 通知:手动 /compact 透传给用户;自动压缩由调用方
|
|
239
|
-
* 静默消费。失败时抛错,由调用方决定是否记录(自动压缩非致命,仅 console.warn)。
|
|
240
|
-
*/
|
|
241
|
-
export async function* compactContext(state, deps) {
|
|
242
|
-
// summarizer 优先用 compactionModel 覆盖,否则回退当前会话 provider/model。
|
|
243
|
-
const cm = state.compactionModel;
|
|
244
|
-
const summarizer = createSummarizer(deps.llmRegistry, cm ? cm.provider : state.config.provider, cm ? cm.model : state.config.model, { signal: state.abortController.signal });
|
|
245
|
-
const result = await runCompaction(deps.db, state.session.id, summarizer, {
|
|
246
|
-
keepRecentTokens: deps.config.compaction.keepRecentTokens,
|
|
247
|
-
});
|
|
248
|
-
// 压缩改写了消息历史 → 标记下一轮强制开新段(段边界)
|
|
249
|
-
state.pendingSegmentTrigger = 'compaction';
|
|
250
|
-
state.tokenBudget.used = estimateBudget(await getMessages(deps.db, state.session.id), state.calibrationFactor);
|
|
251
|
-
yield {
|
|
252
|
-
_tag: 'text_delta',
|
|
253
|
-
text: result.compacted
|
|
254
|
-
? `Context compacted: ${result.compactedCount} messages summarized.`
|
|
255
|
-
: 'Nothing to compact yet.',
|
|
256
|
-
};
|
|
257
|
-
// 压缩成功后分发事件(spec: plugin-hooks `session:compact`):
|
|
258
|
-
// - session:compact hook(broadcast,插件可订阅;before=压缩条数,after=保留条数)
|
|
259
|
-
// - compaction_done AgentEvent(前端/UI 可观测,携带 summary/archiveId)
|
|
260
|
-
// 仅在实际发生压缩时触发(nothing_to_compact 无 before/after 语义)。
|
|
261
|
-
if (result.compacted) {
|
|
262
|
-
if (deps.hookRunner) {
|
|
263
|
-
await deps.hookRunner.fireHooks('session:compact', {
|
|
264
|
-
before: result.compactedCount,
|
|
265
|
-
after: result.keptCount,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
yield {
|
|
269
|
-
_tag: 'compaction_done',
|
|
270
|
-
summary: result.summary,
|
|
271
|
-
archiveId: result.archiveId,
|
|
272
|
-
compactedCount: result.compactedCount,
|
|
273
|
-
keptCount: result.keptCount,
|
|
274
|
-
};
|
|
275
|
-
// 压缩成功后初始化退化监测器:监测接下来 5 轮 assistant 回复,
|
|
276
|
-
// 若连续产生空回复(无实质文本且无 tool_call)则发出警告(不中断循环)。
|
|
277
|
-
state.postCompactionMonitor = { remaining: 5, noTextStreak: 0 };
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* 响应式溢出恢复(reactive overflow compaction):context-overflow 时压缩
|
|
282
|
-
* 历史并刷新内存消息,使下一轮重建出更小的上下文。仅在 assistant 尚未开始
|
|
283
|
-
* 输出(无 text delta、无 tool_call)时由调用方判定后调用。
|
|
284
|
-
*
|
|
285
|
-
* 成功返回 true(已压缩、已重载 messages,调用方 `continue` 重试本轮);
|
|
286
|
-
* 压缩失败返回 false,由调用方回退到原始错误处理(透传 error 并停止)。
|
|
287
|
-
*/
|
|
288
|
-
async function recoverFromOverflow(state, deps) {
|
|
289
|
-
try {
|
|
290
|
-
for await (const _ev of compactContext(state, deps)) {
|
|
291
|
-
// 静默消费压缩通知(与自动压缩一致,不透传给用户)
|
|
292
|
-
}
|
|
293
|
-
state.messages = await getMessages(deps.db, state.session.id);
|
|
294
|
-
return true;
|
|
295
|
-
}
|
|
296
|
-
catch (e) {
|
|
297
|
-
console.warn('[overflow-recovery] compaction failed:', e instanceof Error ? e.message : String(e));
|
|
298
|
-
return false;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
/** 解析 tool_call_end 的入参:JSON.parse + 容错标记。
|
|
302
|
-
* 解析成功返回原值;失败返回 { _parseError, _raw } 容错标记(与协议层 finishAll 一致),
|
|
303
|
-
* 供 isParseErrorInput 识别——这类入参只反馈给模型重试,绝不持久化或渲染。 */
|
|
304
|
-
function parseToolCallArgs(finalArgs) {
|
|
305
|
-
try {
|
|
306
|
-
return JSON.parse(finalArgs);
|
|
307
|
-
}
|
|
308
|
-
catch (e) {
|
|
309
|
-
return {
|
|
310
|
-
_parseError: e instanceof Error ? e.message : String(e),
|
|
311
|
-
_raw: finalArgs,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
/** 流式收集 LLM 输出:遍历 streamFn 的 chunk,分类收集 text/thinking/tool_call/usage/done,
|
|
316
|
-
* 实时透传 text_delta/thinking/usage/error 给 agentLoop 的消费者(通过 yield)。
|
|
317
|
-
*
|
|
318
|
-
* 内部处理响应式溢出恢复:context-overflow 且尚未开始输出(无 text、无 tool_call)
|
|
319
|
-
* 时压缩历史,成功则置 overflowRecovered=true 由调用方 `continue turnLoop` 重试本轮。
|
|
320
|
-
* 致命错误(abort / provider 抛错)置 fatalError=true 由调用方终止 loop。
|
|
321
|
-
*
|
|
322
|
-
* 注意:tool_call_start 仅登记、不发射 AgentEvent——入参尚未到达,过早发射空入参
|
|
323
|
-
* 会让前端渲染半成品卡。tool_call_start 由 persistAssistantAndTools 在入参解析完成后统一发射。 */
|
|
324
|
-
async function* collectStreamChunks(state, deps, streamFn, request, overflowCompacted) {
|
|
325
|
-
const chunks = [];
|
|
326
|
-
const text = [];
|
|
327
|
-
const thinking = [];
|
|
328
|
-
const toolCalls = new Map();
|
|
329
|
-
const toolCallArgs = new Map();
|
|
330
|
-
let usage = null;
|
|
331
|
-
let hadError = false;
|
|
332
|
-
// 非正常停止原因(length=被 max_tokens 截断, content_filter=被内容过滤)。
|
|
333
|
-
// 若在无 tool_call 的完成分支里仍非 null,说明回答被截断/过滤而非正常说完。
|
|
334
|
-
let truncated = null;
|
|
335
|
-
let firstTokenTime = null;
|
|
336
|
-
let recovered = overflowCompacted;
|
|
337
|
-
const finish = (over) => ({
|
|
338
|
-
chunks,
|
|
339
|
-
text,
|
|
340
|
-
thinking,
|
|
341
|
-
toolCalls,
|
|
342
|
-
usage,
|
|
343
|
-
truncated,
|
|
344
|
-
hadError,
|
|
345
|
-
firstTokenTime,
|
|
346
|
-
overflowCompacted: recovered,
|
|
347
|
-
overflowRecovered: false,
|
|
348
|
-
fatalError: false,
|
|
349
|
-
...over,
|
|
350
|
-
});
|
|
351
|
-
try {
|
|
352
|
-
for await (const chunk of streamFn({
|
|
353
|
-
registry: deps.llmRegistry,
|
|
354
|
-
signal: state.abortController.signal,
|
|
355
|
-
}, request, { provider: state.config.provider, model: state.config.model })) {
|
|
356
|
-
if (firstTokenTime === null && chunk._tag !== 'done') {
|
|
357
|
-
firstTokenTime = Date.now();
|
|
358
|
-
}
|
|
359
|
-
chunks.push(chunk);
|
|
360
|
-
if (state.abortController.signal.aborted) {
|
|
361
|
-
yield { _tag: 'error', error: { _tag: 'aborted' } };
|
|
362
|
-
return finish({ fatalError: true });
|
|
363
|
-
}
|
|
364
|
-
switch (chunk._tag) {
|
|
365
|
-
case 'text':
|
|
366
|
-
text.push(chunk.text);
|
|
367
|
-
yield { _tag: 'text_delta', text: chunk.text };
|
|
368
|
-
break;
|
|
369
|
-
case 'tool_call_start':
|
|
370
|
-
// 仅登记,不立即发射 AgentEvent。此时入参尚未到达(流式 delta 累积中),
|
|
371
|
-
// 过早发射空入参的 tool_call_start 会让前端渲染 "Glob · " 等半成品卡,
|
|
372
|
-
// 且该 part 的入参之后也不会被纠正。tool_call_start 改在入参解析完成后、
|
|
373
|
-
// 携带真实入参时统一发射(见 persistAssistantAndTools)。
|
|
374
|
-
toolCalls.set(chunk.id, {
|
|
375
|
-
id: chunk.id,
|
|
376
|
-
tool: chunk.name,
|
|
377
|
-
input: {},
|
|
378
|
-
});
|
|
379
|
-
toolCallArgs.set(chunk.id, '');
|
|
380
|
-
break;
|
|
381
|
-
case 'tool_call_delta': {
|
|
382
|
-
const existing = toolCallArgs.get(chunk.id) ?? '';
|
|
383
|
-
toolCallArgs.set(chunk.id, existing + chunk.argumentsDelta);
|
|
384
|
-
break;
|
|
385
|
-
}
|
|
386
|
-
case 'tool_call_end': {
|
|
387
|
-
const finalArgs = chunk.argumentsFinal ?? toolCallArgs.get(chunk.id) ?? '{}';
|
|
388
|
-
const tc = toolCalls.get(chunk.id);
|
|
389
|
-
if (tc)
|
|
390
|
-
tc.input = parseToolCallArgs(finalArgs);
|
|
391
|
-
break;
|
|
392
|
-
}
|
|
393
|
-
case 'thinking':
|
|
394
|
-
thinking.push(chunk.text);
|
|
395
|
-
yield { _tag: 'thinking', text: chunk.text };
|
|
396
|
-
break;
|
|
397
|
-
case 'usage':
|
|
398
|
-
usage = {
|
|
399
|
-
inputTokens: chunk.inputTokens,
|
|
400
|
-
outputTokens: chunk.outputTokens,
|
|
401
|
-
cacheRead: chunk.cacheRead,
|
|
402
|
-
};
|
|
403
|
-
yield {
|
|
404
|
-
_tag: 'usage',
|
|
405
|
-
input: chunk.inputTokens,
|
|
406
|
-
output: chunk.outputTokens,
|
|
407
|
-
cacheRead: chunk.cacheRead,
|
|
408
|
-
};
|
|
409
|
-
break;
|
|
410
|
-
case 'done':
|
|
411
|
-
if (chunk.finishReason === 'length' || chunk.finishReason === 'content-filter') {
|
|
412
|
-
truncated = chunk.finishReason;
|
|
413
|
-
}
|
|
414
|
-
break;
|
|
415
|
-
case 'error': {
|
|
416
|
-
// 响应式溢出恢复:仅当尚未开始输出且未重试过时,压缩历史并重试本轮
|
|
417
|
-
const isOverflow = chunk.error.classification === 'context-overflow';
|
|
418
|
-
if (isOverflow && !recovered && text.length === 0 && toolCalls.size === 0) {
|
|
419
|
-
recovered = true;
|
|
420
|
-
if (await recoverFromOverflow(state, deps)) {
|
|
421
|
-
return finish({ overflowRecovered: true });
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
yield {
|
|
425
|
-
_tag: 'error',
|
|
426
|
-
error: {
|
|
427
|
-
_tag: 'provider',
|
|
428
|
-
message: chunk.error.message,
|
|
429
|
-
retryable: chunk.error.retryable ?? false,
|
|
430
|
-
},
|
|
431
|
-
};
|
|
432
|
-
hadError = true;
|
|
433
|
-
break;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
catch (err) {
|
|
439
|
-
// 响应式溢出恢复:provider 抛出的 context-overflow 是生产环境的实际路径
|
|
440
|
-
// (httpPost 在非 2xx 时抛出已分类的 LLMError),与上方 error chunk 分支同理。
|
|
441
|
-
if (isContextOverflowFailure(err) && !recovered && text.length === 0 && toolCalls.size === 0) {
|
|
442
|
-
recovered = true;
|
|
443
|
-
if (await recoverFromOverflow(state, deps)) {
|
|
444
|
-
return finish({ overflowRecovered: true });
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
const message = err instanceof Error
|
|
448
|
-
? err.message
|
|
449
|
-
: isLLMError(err)
|
|
450
|
-
? err.message
|
|
451
|
-
: typeof err === 'object' && err !== null && 'message' in err
|
|
452
|
-
? String(err.message)
|
|
453
|
-
: String(err);
|
|
454
|
-
yield {
|
|
455
|
-
_tag: 'error',
|
|
456
|
-
error: {
|
|
457
|
-
_tag: 'unexpected',
|
|
458
|
-
message,
|
|
459
|
-
},
|
|
460
|
-
};
|
|
461
|
-
state.status = {
|
|
462
|
-
_tag: 'stopped',
|
|
463
|
-
reason: 'error',
|
|
464
|
-
error: {
|
|
465
|
-
_tag: 'unexpected',
|
|
466
|
-
message,
|
|
467
|
-
},
|
|
468
|
-
};
|
|
469
|
-
return finish({ fatalError: true });
|
|
470
|
-
}
|
|
471
|
-
return finish({});
|
|
472
|
-
}
|
|
473
|
-
/** 段管理:判断段边界(pending trigger / model / systemPrompt / tools 变化),
|
|
474
|
-
* 构造本轮 LLMCall 并 push 到当前段,持久化 segments。直接修改 state.segments(引用语义)。
|
|
475
|
-
* trigger 判定用于新段元数据;needSegment 决定是否真正开新段(否则复用 activeSeg)。 */
|
|
476
|
-
async function manageSegment(state, deps, systemPrompt, tools, requestStartTime, totalLatency, collectedUsage, collectedText, collectedThinking, truncated, firstTokenTime, contextWindow, computedCost) {
|
|
477
|
-
// —— 段管理:判断是否开新段 ——
|
|
478
|
-
const fp = segmentFingerprint(systemPrompt, tools);
|
|
479
|
-
const activeSeg = state.segments[state.segments.length - 1];
|
|
480
|
-
const pendingTrigger = state.pendingSegmentTrigger;
|
|
481
|
-
let trigger;
|
|
482
|
-
if (pendingTrigger) {
|
|
483
|
-
trigger = pendingTrigger;
|
|
484
|
-
state.pendingSegmentTrigger = undefined;
|
|
485
|
-
}
|
|
486
|
-
else if (!activeSeg) {
|
|
487
|
-
trigger = 'initial';
|
|
488
|
-
}
|
|
489
|
-
else if (activeSeg.model !== state.config.model) {
|
|
490
|
-
trigger = 'model_change';
|
|
491
|
-
}
|
|
492
|
-
else if (activeSeg.fingerprint !== fp) {
|
|
493
|
-
trigger = activeSeg.systemPrompt !== systemPrompt ? 'system_prompt_change' : 'tools_change';
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
trigger = 'user_confirmed'; // 占位,实际不会开段
|
|
497
|
-
}
|
|
498
|
-
const needSegment = !!pendingTrigger ||
|
|
499
|
-
!activeSeg ||
|
|
500
|
-
activeSeg.model !== state.config.model ||
|
|
501
|
-
activeSeg.fingerprint !== fp;
|
|
502
|
-
let currentSeg;
|
|
503
|
-
if (!needSegment && activeSeg) {
|
|
504
|
-
currentSeg = activeSeg;
|
|
505
|
-
}
|
|
506
|
-
else {
|
|
507
|
-
currentSeg = {
|
|
508
|
-
id: generateId(),
|
|
509
|
-
fingerprint: fp,
|
|
510
|
-
provider: state.config.provider,
|
|
511
|
-
model: state.config.model,
|
|
512
|
-
systemPrompt,
|
|
513
|
-
tools,
|
|
514
|
-
startedAt: requestStartTime,
|
|
515
|
-
trigger,
|
|
516
|
-
agentName: state.config.agentName,
|
|
517
|
-
...(contextWindow !== undefined ? { contextWindow } : {}),
|
|
518
|
-
calls: [],
|
|
519
|
-
};
|
|
520
|
-
state.segments.push(currentSeg);
|
|
521
|
-
}
|
|
522
|
-
// —— 段内轻量 call ——
|
|
523
|
-
const call = {
|
|
524
|
-
id: generateId(),
|
|
525
|
-
timestamp: requestStartTime,
|
|
526
|
-
usage: {
|
|
527
|
-
input: collectedUsage?.inputTokens ?? 0,
|
|
528
|
-
output: collectedUsage?.outputTokens ?? 0,
|
|
529
|
-
...(collectedUsage?.cacheRead !== undefined ? { cacheRead: collectedUsage.cacheRead } : {}),
|
|
530
|
-
},
|
|
531
|
-
latency: {
|
|
532
|
-
firstToken: firstTokenTime ? firstTokenTime - requestStartTime : totalLatency,
|
|
533
|
-
total: totalLatency,
|
|
534
|
-
},
|
|
535
|
-
cost: computedCost,
|
|
536
|
-
...(collectedThinking.length > 0 ? { thinking: collectedThinking.join('') } : {}),
|
|
537
|
-
responseText: collectedText.join(''),
|
|
538
|
-
...(truncated ? { finishReason: truncated } : {}),
|
|
539
|
-
};
|
|
540
|
-
currentSeg.calls.push(call);
|
|
541
|
-
await saveLLMSegments(deps.db, state.session.id, state.segments);
|
|
542
|
-
}
|
|
543
|
-
/** 持久化 assistant 消息 + 执行工具调用 + 持久化 tool result + 记录 metrics,
|
|
544
|
-
* 并透传 tool_call_start/tool_call_end/subagent 事件。
|
|
545
|
-
*
|
|
546
|
-
* - 仅持久化/执行解析成功的调用(isParseErrorInput 过滤容错标记,避免 _raw/_parseError 落库)。
|
|
547
|
-
* - tool_call_start 仅在入参解析完成后、携带真实入参时发射(前端拿到的第一帧即完整入参)。
|
|
548
|
-
* - subagent 事件缓冲(runSubAgent → eventSink)在工具执行后 yield 出去(spec §4.5 step 7)。
|
|
549
|
-
* - metrics 记录为 fire-and-forget,失败绝不阻塞 agent 主流程。 */
|
|
550
|
-
async function* persistAssistantAndTools(state, deps, collectedText, validCalls) {
|
|
551
|
-
// subagent 事件缓冲:runSubAgent 通过 sink 推入事件,executeToolCalls 后 yield 出去
|
|
552
|
-
const subagentEvents = [];
|
|
553
|
-
const eventSink = (ev) => {
|
|
554
|
-
subagentEvents.push(ev);
|
|
555
|
-
};
|
|
556
|
-
const assistantContent = [];
|
|
557
|
-
if (collectedText.length > 0) {
|
|
558
|
-
assistantContent.push({ _tag: 'text', text: collectedText.join('') });
|
|
559
|
-
}
|
|
560
|
-
// 仅持久化解析成功的调用(携带真实入参)。解析失败的入参是容错标记,不能落库。
|
|
561
|
-
for (const tc of validCalls) {
|
|
562
|
-
assistantContent.push({
|
|
563
|
-
_tag: 'tool_call',
|
|
564
|
-
id: tc.id,
|
|
565
|
-
tool: tc.tool,
|
|
566
|
-
input: tc.input,
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
if (assistantContent.length > 0) {
|
|
570
|
-
const savedMsg = await appendMessage(deps.db, state.session.id, {
|
|
571
|
-
role: 'assistant',
|
|
572
|
-
content: assistantContent,
|
|
573
|
-
});
|
|
574
|
-
if (deps.hookRunner) {
|
|
575
|
-
await deps.hookRunner.fireHooks('message:after', { message: savedMsg });
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
// 工具调用卡只在入参解析完成后、携带真实入参时才向前端发射 tool_call_start。
|
|
579
|
-
// 这样前端拿到的第一帧就是可渲染的完整入参,不再出现空 pattern 半成品卡。
|
|
580
|
-
// 解析失败的调用不发射 start,其 tool_call_end 在前端无匹配 part 会被忽略,
|
|
581
|
-
// 因此解析失败在 UI 中不可见(模型会立即重试),错误仅反馈给模型并落库。
|
|
582
|
-
for (const tc of validCalls) {
|
|
583
|
-
yield { _tag: 'tool_call_start', id: tc.id, tool: tc.tool, input: tc.input };
|
|
584
|
-
}
|
|
585
|
-
// 仅执行解析成功的工具调用。解析失败的调用(isParseErrorInput 为真)对系统完全透明:
|
|
586
|
-
// 不执行、不持久化 tool result、不发 tool_call_start/end。其入参是 _parseError/_raw
|
|
587
|
-
// 容错标记,既不能执行也不能落库;若持久化为 orphan tool 消息(无对应 assistant
|
|
588
|
-
// tool_call),context.ts 的 sanitizeToolPairs 会在重建上下文时将其丢弃——即模型
|
|
589
|
-
// 永远收不到这个"错误反馈",徒增一次注定被忽略的 DB 写。故让 parse-error 对模型
|
|
590
|
-
// 不可见:模型下轮基于已持久化的 assistant 文本/有效 tool_call 重新生成,通常能
|
|
591
|
-
// 修正一次性的流截断错误。
|
|
592
|
-
if (validCalls.length > 0) {
|
|
593
|
-
const toolExecStart = Date.now();
|
|
594
|
-
const results = await executeToolCalls(deps.toolRegistry, deps.permission, {
|
|
595
|
-
cwd: deps.cwd,
|
|
596
|
-
session: { id: state.session.id, cwd: deps.cwd },
|
|
597
|
-
abort: state.abortController.signal,
|
|
598
|
-
...(deps.urlRegistry ? { urlRegistry: deps.urlRegistry } : {}),
|
|
599
|
-
...(deps.debugSpawn ? { debugSpawn: deps.debugSpawn } : {}),
|
|
600
|
-
runSubAgent: (req) => runSubAgent({ ...deps, _subagentEventSink: eventSink }, state, req),
|
|
601
|
-
...(deps._subagentYieldCollector ? { collectYield: deps._subagentYieldCollector } : {}),
|
|
602
|
-
// todo 工具状态通过 dependency-reversal hook 注入:get/set 直接读写
|
|
603
|
-
// state.todoPhases(in-memory),tool result 的 metadata.phases 充当
|
|
604
|
-
// 持久化层——createAgent 时从历史消息恢复。
|
|
605
|
-
todoState: {
|
|
606
|
-
get: () => state.todoPhases,
|
|
607
|
-
set: (phases) => {
|
|
608
|
-
state.todoPhases = phases;
|
|
609
|
-
},
|
|
610
|
-
},
|
|
611
|
-
// kanban 工具通过 dependency-reversal 注入:per-project 的 db-backed store。
|
|
612
|
-
// 仅当 session 有 projectId 时启用(子 session 无 project 时不可用)。
|
|
613
|
-
...(state.session.projectId
|
|
614
|
-
? { kanbanStore: createKanbanStore(deps.db, state.session.projectId) }
|
|
615
|
-
: {}),
|
|
616
|
-
}, validCalls, deps.hookRunner);
|
|
617
|
-
const toolLatency = Date.now() - toolExecStart;
|
|
618
|
-
const metricsEnabled = deps.config.toolMetrics.enabled;
|
|
619
|
-
for (const { id, result } of results) {
|
|
620
|
-
yield { _tag: 'tool_call_end', id, result };
|
|
621
|
-
const tc = validCalls.find((c) => c.id === id);
|
|
622
|
-
if (tc) {
|
|
623
|
-
await appendMessage(deps.db, state.session.id, {
|
|
624
|
-
role: 'tool',
|
|
625
|
-
content: toolResultToContent(id, tc.tool, result),
|
|
626
|
-
});
|
|
627
|
-
// spec §16.5:记录工具执行结果供后续模式评估。
|
|
628
|
-
// fire-and-forget:记录失败绝不阻塞 agent 主流程。
|
|
629
|
-
if (metricsEnabled) {
|
|
630
|
-
const mode = inferToolMode(tc.tool, tc.input);
|
|
631
|
-
const success = result._tag === 'success' || result._tag === 'truncated';
|
|
632
|
-
void recordToolMetrics(deps.db, state.config.model, tc.tool, mode, success, toolLatency).catch(() => { });
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
// yield 在本轮工具执行中收集的 subagent 事件(subagent_start/end)
|
|
638
|
-
for (const ev of subagentEvents) {
|
|
639
|
-
yield ev;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
/** 轮末压缩:turn-end 自动压缩(含死锁检测)+ mid-turn 压缩,复用 compactContext。
|
|
643
|
-
*
|
|
644
|
-
* - turn-end:shouldCompact 触发时静默压缩;压缩后仍超阈值(典型成因 keepRecentTokens
|
|
645
|
-
* 本身已超 historyBudget)则标记死锁暂停后续自动压缩,并发出非致命警告(不中断循环)。
|
|
646
|
-
* 死锁在下一轮用户输入(agentLoop 重入)时重置。
|
|
647
|
-
* - mid-turn:midTurnEnabled 单独 opt-in(默认关闭)时按 enabled:true 阈值静默压缩,
|
|
648
|
-
* 复用 shouldCompact 逻辑但不发 error/warning,压缩后刷新内存消息视图。
|
|
649
|
-
* 两者都静默消费 compactContext 的 text_delta 通知(不透传给用户)。 */
|
|
650
|
-
async function* runCompactionIfNeeded(state, deps, latestMessages) {
|
|
651
|
-
// 死锁时跳过自动压缩:上一轮压缩已证明无法释放足够空间,重试只会无限循环。
|
|
652
|
-
if (!state.compactionDeadEnd &&
|
|
653
|
-
shouldCompact(latestMessages, state.tokenBudget, deps.config.compaction)) {
|
|
654
|
-
// 自动压缩:复用 compactContext;失败非致命,仅记录警告不中断主循环。
|
|
655
|
-
try {
|
|
656
|
-
for await (const _ev of compactContext(state, deps)) {
|
|
657
|
-
// 静默消费压缩通知事件,不透传给用户(保持自动压缩的原有静默语义)
|
|
658
|
-
}
|
|
659
|
-
// 进度保护:compactContext 已重算 state.tokenBudget.used。若压缩后仍超阈值
|
|
660
|
-
// (典型成因:keepRecentTokens 本身已超 historyBudget),压缩无法再释放足够
|
|
661
|
-
// 空间 → 标记死锁暂停后续自动压缩,并发出非致命警告(不中断循环)。
|
|
662
|
-
// 死锁在下一轮用户输入(agentLoop 重入)时重置。
|
|
663
|
-
const postMessages = await getMessages(deps.db, state.session.id);
|
|
664
|
-
if (shouldCompact(postMessages, state.tokenBudget, deps.config.compaction)) {
|
|
665
|
-
state.compactionDeadEnd = true;
|
|
666
|
-
yield {
|
|
667
|
-
_tag: 'error',
|
|
668
|
-
error: {
|
|
669
|
-
_tag: 'unexpected',
|
|
670
|
-
message: 'Compaction freed too little context to make progress (keepRecentTokens may exceed the threshold). Auto-compaction paused until the next user message.',
|
|
671
|
-
},
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
catch (e) {
|
|
676
|
-
console.warn('[compaction] failed:', e instanceof Error ? e.message : String(e));
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
// —— 中轮压缩(mid-run compaction)——
|
|
680
|
-
// 单个 turn 内工具结果可能剧增 token(如长 bash 输出),在下一次 LLM 请求前
|
|
681
|
-
// 按 midTurnEnabled 检查阈值并静默压缩。与上方 turn-end 自动压缩独立:
|
|
682
|
-
// midTurnEnabled 是单独的 opt-in 闸门(默认关闭),以它为条件复用 shouldCompact
|
|
683
|
-
// 的阈值逻辑(传入 enabled:true 使阈值判定不受 compaction.enabled 影响),
|
|
684
|
-
// 便于在保留 turn-end 自动压缩行为的同时精确控制中轮压缩。
|
|
685
|
-
// 此时 state.tokenBudget.used 已是最新值(上方已重算;若 turn-end 压缩已触发,
|
|
686
|
-
// compactContext 内部也已重算),shouldCompact 只读取 budget,不依赖 messages。
|
|
687
|
-
if (deps.config.compaction.midTurnEnabled === true &&
|
|
688
|
-
!state.compactionDeadEnd &&
|
|
689
|
-
shouldCompact(latestMessages, state.tokenBudget, {
|
|
690
|
-
...deps.config.compaction,
|
|
691
|
-
enabled: true,
|
|
692
|
-
})) {
|
|
693
|
-
// 中轮压缩静默执行:compactContext 内部已发 text_delta 通知,
|
|
694
|
-
// 不再 yield error/warning(与 turn-end 自动压缩的进度保护语义不同)。
|
|
695
|
-
try {
|
|
696
|
-
for await (const _ev of compactContext(state, deps)) {
|
|
697
|
-
// 静默消费压缩通知,不透传给用户
|
|
698
|
-
}
|
|
699
|
-
// 压缩改写了消息历史 → 刷新内存视图,供下一轮上下文重建使用
|
|
700
|
-
state.messages = await getMessages(deps.db, state.session.id);
|
|
701
|
-
}
|
|
702
|
-
catch (e) {
|
|
703
|
-
console.warn('[mid-turn-compaction] failed:', e instanceof Error ? e.message : String(e));
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
59
|
export async function* agentLoop(state, deps) {
|
|
708
60
|
const maxTurns = state.config.maxTurns ?? 50;
|
|
709
61
|
const streamFn = deps.chatStream ?? llmChatStream;
|
|
@@ -904,6 +256,8 @@ export async function* agentLoop(state, deps) {
|
|
|
904
256
|
}
|
|
905
257
|
// —— 持久化 assistant + 执行工具 + 透传 tool_call/subagent 事件 ——
|
|
906
258
|
yield* persistAssistantAndTools(state, deps, collected.text, validCalls);
|
|
259
|
+
// —— 处理 assistant 文本中的 <todo:*> 标签 ——
|
|
260
|
+
yield* processTodoTags(state, collected.text.join(''));
|
|
907
261
|
if (validToolCalls.length === 0) {
|
|
908
262
|
// finish_reason=length/content_filter 表示响应被截断或被内容过滤,而非正常说完。
|
|
909
263
|
// 若当作 completed,被截断的半截回答会静默成功(用户看到"中断但无报错")。
|