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.
Files changed (39) hide show
  1. package/dist/core/config.js +4 -1
  2. package/dist/core/loop/compaction.d.ts +31 -0
  3. package/dist/core/loop/compaction.js +137 -0
  4. package/dist/core/loop/persist.d.ts +11 -0
  5. package/dist/core/loop/persist.js +107 -0
  6. package/dist/core/loop/segment.d.ts +8 -0
  7. package/dist/core/loop/segment.js +72 -0
  8. package/dist/core/loop/stream-collect.d.ts +47 -0
  9. package/dist/core/loop/stream-collect.js +183 -0
  10. package/dist/core/loop/subagent.d.ts +12 -0
  11. package/dist/core/loop/subagent.js +171 -0
  12. package/dist/core/loop/todo.d.ts +5 -0
  13. package/dist/core/loop/todo.js +26 -0
  14. package/dist/core/loop.d.ts +2 -20
  15. package/dist/core/loop.js +12 -658
  16. package/dist/core/prompt-registry.d.ts +1 -1
  17. package/dist/core/prompt-registry.js +10 -0
  18. package/dist/core/slash.js +1 -1
  19. package/dist/core/todo-tags.d.ts +46 -0
  20. package/dist/core/todo-tags.js +192 -0
  21. package/dist/core/workflows/builtins.js +4 -4
  22. package/dist/core/workflows/discovery.js +4 -1
  23. package/dist/llm/registry.d.ts +30 -3
  24. package/dist/llm/registry.js +25 -7
  25. package/dist/llm/retry.d.ts +4 -2
  26. package/dist/llm/retry.js +7 -6
  27. package/dist/llm/schema/errors.d.ts +13 -3
  28. package/dist/llm/schema/errors.js +35 -3
  29. package/dist/llm/transport.js +5 -1
  30. package/dist/project/resolve.js +2 -2
  31. package/dist/server/routes/chat.js +8 -2
  32. package/dist/server/routes/todo.js +5 -2
  33. package/dist/server/server.d.ts +4 -2
  34. package/dist/server/server.js +20 -12
  35. package/dist/session/squash.js +31 -24
  36. package/dist/shared/types/agent.d.ts +11 -0
  37. package/dist/tools/builtin/todo.d.ts +7 -0
  38. package/dist/tools/builtin/todo.js +23 -16
  39. package/package.json +2 -1
@@ -16,7 +16,10 @@ const DEFAULT_CONFIG = {
16
16
  keepRecentTokens: 4000,
17
17
  midTurnEnabled: false,
18
18
  },
19
- tools: { enabled: ['read', 'write', 'edit', 'glob', 'grep', 'bash', 'todo', 'websearch'], disabled: [] },
19
+ tools: {
20
+ enabled: ['read', 'write', 'edit', 'glob', 'grep', 'bash', 'todo', 'websearch'],
21
+ disabled: [],
22
+ },
20
23
  plugins: { enabled: [] },
21
24
  mcpServers: [],
22
25
  slashCommands: { enabled: ['/compact', '/model', '/clear', '/help', '/fork', '/config'] },
@@ -0,0 +1,31 @@
1
+ import type { AgentEvent, AgentState } from '../../shared/types/agent.js';
2
+ import type { Message } from '../../shared/types/message.js';
3
+ import type { LoopDeps } from '../loop.js';
4
+ /**
5
+ * 执行会话压缩并刷新 token 预算。
6
+ *
7
+ * 自动压缩(agentLoop 阈值触发)与手动 /compact 共用此逻辑:复用
8
+ * createSummarizer + runCompaction,压缩改写消息历史后标记下一段 trigger='compaction'。
9
+ *
10
+ * 成功时 yield 一条 text_delta 通知:手动 /compact 透传给用户;自动压缩由调用方
11
+ * 静默消费。失败时抛错,由调用方决定是否记录(自动压缩非致命,仅 console.warn)。
12
+ */
13
+ export declare function compactContext(state: AgentState, deps: LoopDeps): AsyncGenerator<AgentEvent>;
14
+ /**
15
+ * 响应式溢出恢复(reactive overflow compaction):context-overflow 时压缩
16
+ * 历史并刷新内存消息,使下一轮重建出更小的上下文。仅在 assistant 尚未开始
17
+ * 输出(无 text delta、无 tool_call)时由调用方判定后调用。
18
+ *
19
+ * 成功返回 true(已压缩、已重载 messages,调用方 `continue` 重试本轮);
20
+ * 压缩失败返回 false,由调用方回退到原始错误处理(透传 error 并停止)。
21
+ */
22
+ export declare function recoverFromOverflow(state: AgentState, deps: LoopDeps): Promise<boolean>;
23
+ /** 轮末压缩:turn-end 自动压缩(含死锁检测)+ mid-turn 压缩,复用 compactContext。
24
+ *
25
+ * - turn-end:shouldCompact 触发时静默压缩;压缩后仍超阈值(典型成因 keepRecentTokens
26
+ * 本身已超 historyBudget)则标记死锁暂停后续自动压缩,并发出非致命警告(不中断循环)。
27
+ * 死锁在下一轮用户输入(agentLoop 重入)时重置。
28
+ * - mid-turn:midTurnEnabled 单独 opt-in(默认关闭)时按 enabled:true 阈值静默压缩,
29
+ * 复用 shouldCompact 逻辑但不发 error/warning,压缩后刷新内存消息视图。
30
+ * 两者都静默消费 compactContext 的 text_delta 通知(不透传给用户)。 */
31
+ export declare function runCompactionIfNeeded(state: AgentState, deps: LoopDeps, latestMessages: Message[]): AsyncGenerator<AgentEvent>;
@@ -0,0 +1,137 @@
1
+ import { getMessages } from '../../session/message.js';
2
+ import { createSummarizer, runCompaction } from '../compact.js';
3
+ import { estimateBudget, shouldCompact } from '../context.js';
4
+ /**
5
+ * 执行会话压缩并刷新 token 预算。
6
+ *
7
+ * 自动压缩(agentLoop 阈值触发)与手动 /compact 共用此逻辑:复用
8
+ * createSummarizer + runCompaction,压缩改写消息历史后标记下一段 trigger='compaction'。
9
+ *
10
+ * 成功时 yield 一条 text_delta 通知:手动 /compact 透传给用户;自动压缩由调用方
11
+ * 静默消费。失败时抛错,由调用方决定是否记录(自动压缩非致命,仅 console.warn)。
12
+ */
13
+ export async function* compactContext(state, deps) {
14
+ // summarizer 优先用 compactionModel 覆盖,否则回退当前会话 provider/model。
15
+ const cm = state.compactionModel;
16
+ const summarizer = createSummarizer(deps.llmRegistry, cm ? cm.provider : state.config.provider, cm ? cm.model : state.config.model, { signal: state.abortController.signal });
17
+ const result = await runCompaction(deps.db, state.session.id, summarizer, {
18
+ keepRecentTokens: deps.config.compaction.keepRecentTokens,
19
+ });
20
+ // 压缩改写了消息历史 → 标记下一轮强制开新段(段边界)
21
+ state.pendingSegmentTrigger = 'compaction';
22
+ state.tokenBudget.used = estimateBudget(await getMessages(deps.db, state.session.id), state.calibrationFactor);
23
+ yield {
24
+ _tag: 'text_delta',
25
+ text: result.compacted
26
+ ? `Context compacted: ${result.compactedCount} messages summarized.`
27
+ : 'Nothing to compact yet.',
28
+ };
29
+ // 压缩成功后分发事件(spec: plugin-hooks `session:compact`):
30
+ // - session:compact hook(broadcast,插件可订阅;before=压缩条数,after=保留条数)
31
+ // - compaction_done AgentEvent(前端/UI 可观测,携带 summary/archiveId)
32
+ // 仅在实际发生压缩时触发(nothing_to_compact 无 before/after 语义)。
33
+ if (result.compacted) {
34
+ if (deps.hookRunner) {
35
+ await deps.hookRunner.fireHooks('session:compact', {
36
+ before: result.compactedCount,
37
+ after: result.keptCount,
38
+ });
39
+ }
40
+ yield {
41
+ _tag: 'compaction_done',
42
+ summary: result.summary,
43
+ archiveId: result.archiveId,
44
+ compactedCount: result.compactedCount,
45
+ keptCount: result.keptCount,
46
+ };
47
+ // 压缩成功后初始化退化监测器:监测接下来 5 轮 assistant 回复,
48
+ // 若连续产生空回复(无实质文本且无 tool_call)则发出警告(不中断循环)。
49
+ state.postCompactionMonitor = { remaining: 5, noTextStreak: 0 };
50
+ }
51
+ }
52
+ /**
53
+ * 响应式溢出恢复(reactive overflow compaction):context-overflow 时压缩
54
+ * 历史并刷新内存消息,使下一轮重建出更小的上下文。仅在 assistant 尚未开始
55
+ * 输出(无 text delta、无 tool_call)时由调用方判定后调用。
56
+ *
57
+ * 成功返回 true(已压缩、已重载 messages,调用方 `continue` 重试本轮);
58
+ * 压缩失败返回 false,由调用方回退到原始错误处理(透传 error 并停止)。
59
+ */
60
+ export async function recoverFromOverflow(state, deps) {
61
+ try {
62
+ for await (const _ev of compactContext(state, deps)) {
63
+ // 静默消费压缩通知(与自动压缩一致,不透传给用户)
64
+ }
65
+ state.messages = await getMessages(deps.db, state.session.id);
66
+ return true;
67
+ }
68
+ catch (e) {
69
+ console.warn('[overflow-recovery] compaction failed:', e instanceof Error ? e.message : String(e));
70
+ return false;
71
+ }
72
+ }
73
+ /** 轮末压缩:turn-end 自动压缩(含死锁检测)+ mid-turn 压缩,复用 compactContext。
74
+ *
75
+ * - turn-end:shouldCompact 触发时静默压缩;压缩后仍超阈值(典型成因 keepRecentTokens
76
+ * 本身已超 historyBudget)则标记死锁暂停后续自动压缩,并发出非致命警告(不中断循环)。
77
+ * 死锁在下一轮用户输入(agentLoop 重入)时重置。
78
+ * - mid-turn:midTurnEnabled 单独 opt-in(默认关闭)时按 enabled:true 阈值静默压缩,
79
+ * 复用 shouldCompact 逻辑但不发 error/warning,压缩后刷新内存消息视图。
80
+ * 两者都静默消费 compactContext 的 text_delta 通知(不透传给用户)。 */
81
+ export async function* runCompactionIfNeeded(state, deps, latestMessages) {
82
+ // 死锁时跳过自动压缩:上一轮压缩已证明无法释放足够空间,重试只会无限循环。
83
+ if (!state.compactionDeadEnd &&
84
+ shouldCompact(latestMessages, state.tokenBudget, deps.config.compaction)) {
85
+ // 自动压缩:复用 compactContext;失败非致命,仅记录警告不中断主循环。
86
+ try {
87
+ for await (const _ev of compactContext(state, deps)) {
88
+ // 静默消费压缩通知事件,不透传给用户(保持自动压缩的原有静默语义)
89
+ }
90
+ // 进度保护:compactContext 已重算 state.tokenBudget.used。若压缩后仍超阈值
91
+ // (典型成因:keepRecentTokens 本身已超 historyBudget),压缩无法再释放足够
92
+ // 空间 → 标记死锁暂停后续自动压缩,并发出非致命警告(不中断循环)。
93
+ // 死锁在下一轮用户输入(agentLoop 重入)时重置。
94
+ const postMessages = await getMessages(deps.db, state.session.id);
95
+ if (shouldCompact(postMessages, state.tokenBudget, deps.config.compaction)) {
96
+ state.compactionDeadEnd = true;
97
+ yield {
98
+ _tag: 'error',
99
+ error: {
100
+ _tag: 'unexpected',
101
+ message: 'Compaction freed too little context to make progress (keepRecentTokens may exceed the threshold). Auto-compaction paused until the next user message.',
102
+ },
103
+ };
104
+ }
105
+ }
106
+ catch (e) {
107
+ console.warn('[compaction] failed:', e instanceof Error ? e.message : String(e));
108
+ }
109
+ }
110
+ // —— 中轮压缩(mid-run compaction)——
111
+ // 单个 turn 内工具结果可能剧增 token(如长 bash 输出),在下一次 LLM 请求前
112
+ // 按 midTurnEnabled 检查阈值并静默压缩。与上方 turn-end 自动压缩独立:
113
+ // midTurnEnabled 是单独的 opt-in 闸门(默认关闭),以它为条件复用 shouldCompact
114
+ // 的阈值逻辑(传入 enabled:true 使阈值判定不受 compaction.enabled 影响),
115
+ // 便于在保留 turn-end 自动压缩行为的同时精确控制中轮压缩。
116
+ // 此时 state.tokenBudget.used 已是最新值(上方已重算;若 turn-end 压缩已触发,
117
+ // compactContext 内部也已重算),shouldCompact 只读取 budget,不依赖 messages。
118
+ if (deps.config.compaction.midTurnEnabled === true &&
119
+ !state.compactionDeadEnd &&
120
+ shouldCompact(latestMessages, state.tokenBudget, {
121
+ ...deps.config.compaction,
122
+ enabled: true,
123
+ })) {
124
+ // 中轮压缩静默执行:compactContext 内部已发 text_delta 通知,
125
+ // 不再 yield error/warning(与 turn-end 自动压缩的进度保护语义不同)。
126
+ try {
127
+ for await (const _ev of compactContext(state, deps)) {
128
+ // 静默消费压缩通知,不透传给用户
129
+ }
130
+ // 压缩改写了消息历史 → 刷新内存视图,供下一轮上下文重建使用
131
+ state.messages = await getMessages(deps.db, state.session.id);
132
+ }
133
+ catch (e) {
134
+ console.warn('[mid-turn-compaction] failed:', e instanceof Error ? e.message : String(e));
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,11 @@
1
+ import type { AgentEvent, AgentState } from '../../shared/types/agent.js';
2
+ import type { LoopDeps } from '../loop.js';
3
+ import type { CollectedToolCall } from '../tool-exec.js';
4
+ /** 持久化 assistant 消息 + 执行工具调用 + 持久化 tool result + 记录 metrics,
5
+ * 并透传 tool_call_start/tool_call_end/subagent 事件。
6
+ *
7
+ * - 仅持久化/执行解析成功的调用(isParseErrorInput 过滤容错标记,避免 _raw/_parseError 落库)。
8
+ * - tool_call_start 仅在入参解析完成后、携带真实入参时发射(前端拿到的第一帧即完整入参)。
9
+ * - subagent 事件缓冲(runSubAgent → eventSink)在工具执行后 yield 出去(spec §4.5 step 7)。
10
+ * - metrics 记录为 fire-and-forget,失败绝不阻塞 agent 主流程。 */
11
+ export declare function persistAssistantAndTools(state: AgentState, deps: LoopDeps, collectedText: string[], validCalls: CollectedToolCall[]): AsyncGenerator<AgentEvent>;
@@ -0,0 +1,107 @@
1
+ import { createKanbanStore } from '../../kanban/index.js';
2
+ import { appendMessage } from '../../session/message.js';
3
+ import { inferToolMode, recordToolMetrics } from '../metrics.js';
4
+ import { executeToolCalls } from '../tool-exec.js';
5
+ import { runSubAgent } from './subagent.js';
6
+ function toolResultToContent(toolCallId, toolName, result) {
7
+ return [{ _tag: 'tool_result', id: toolCallId, tool: toolName, output: result }];
8
+ }
9
+ /** 持久化 assistant 消息 + 执行工具调用 + 持久化 tool result + 记录 metrics,
10
+ * 并透传 tool_call_start/tool_call_end/subagent 事件。
11
+ *
12
+ * - 仅持久化/执行解析成功的调用(isParseErrorInput 过滤容错标记,避免 _raw/_parseError 落库)。
13
+ * - tool_call_start 仅在入参解析完成后、携带真实入参时发射(前端拿到的第一帧即完整入参)。
14
+ * - subagent 事件缓冲(runSubAgent → eventSink)在工具执行后 yield 出去(spec §4.5 step 7)。
15
+ * - metrics 记录为 fire-and-forget,失败绝不阻塞 agent 主流程。 */
16
+ export async function* persistAssistantAndTools(state, deps, collectedText, validCalls) {
17
+ // subagent 事件缓冲:runSubAgent 通过 sink 推入事件,executeToolCalls 后 yield 出去
18
+ const subagentEvents = [];
19
+ const eventSink = (ev) => {
20
+ subagentEvents.push(ev);
21
+ };
22
+ const assistantContent = [];
23
+ if (collectedText.length > 0) {
24
+ assistantContent.push({ _tag: 'text', text: collectedText.join('') });
25
+ }
26
+ // 仅持久化解析成功的调用(携带真实入参)。解析失败的入参是容错标记,不能落库。
27
+ for (const tc of validCalls) {
28
+ assistantContent.push({
29
+ _tag: 'tool_call',
30
+ id: tc.id,
31
+ tool: tc.tool,
32
+ input: tc.input,
33
+ });
34
+ }
35
+ if (assistantContent.length > 0) {
36
+ const savedMsg = await appendMessage(deps.db, state.session.id, {
37
+ role: 'assistant',
38
+ content: assistantContent,
39
+ });
40
+ if (deps.hookRunner) {
41
+ await deps.hookRunner.fireHooks('message:after', { message: savedMsg });
42
+ }
43
+ }
44
+ // 工具调用卡只在入参解析完成后、携带真实入参时才向前端发射 tool_call_start。
45
+ // 这样前端拿到的第一帧就是可渲染的完整入参,不再出现空 pattern 半成品卡。
46
+ // 解析失败的调用不发射 start,其 tool_call_end 在前端无匹配 part 会被忽略,
47
+ // 因此解析失败在 UI 中不可见(模型会立即重试),错误仅反馈给模型并落库。
48
+ for (const tc of validCalls) {
49
+ yield { _tag: 'tool_call_start', id: tc.id, tool: tc.tool, input: tc.input };
50
+ }
51
+ // 仅执行解析成功的工具调用。解析失败的调用(isParseErrorInput 为真)对系统完全透明:
52
+ // 不执行、不持久化 tool result、不发 tool_call_start/end。其入参是 _parseError/_raw
53
+ // 容错标记,既不能执行也不能落库;若持久化为 orphan tool 消息(无对应 assistant
54
+ // tool_call),context.ts 的 sanitizeToolPairs 会在重建上下文时将其丢弃——即模型
55
+ // 永远收不到这个"错误反馈",徒增一次注定被忽略的 DB 写。故让 parse-error 对模型
56
+ // 不可见:模型下轮基于已持久化的 assistant 文本/有效 tool_call 重新生成,通常能
57
+ // 修正一次性的流截断错误。
58
+ if (validCalls.length > 0) {
59
+ const toolExecStart = Date.now();
60
+ const results = await executeToolCalls(deps.toolRegistry, deps.permission, {
61
+ cwd: deps.cwd,
62
+ session: { id: state.session.id, cwd: deps.cwd },
63
+ abort: state.abortController.signal,
64
+ ...(deps.urlRegistry ? { urlRegistry: deps.urlRegistry } : {}),
65
+ ...(deps.debugSpawn ? { debugSpawn: deps.debugSpawn } : {}),
66
+ runSubAgent: (req) => runSubAgent({ ...deps, _subagentEventSink: eventSink }, state, req),
67
+ ...(deps._subagentYieldCollector ? { collectYield: deps._subagentYieldCollector } : {}),
68
+ // todo 工具状态通过 dependency-reversal hook 注入:get/set 直接读写
69
+ // state.todoPhases(in-memory),tool result 的 metadata.phases 充当
70
+ // 持久化层——createAgent 时从历史消息恢复。
71
+ todoState: {
72
+ get: () => state.todoPhases,
73
+ set: (phases) => {
74
+ state.todoPhases = phases;
75
+ },
76
+ },
77
+ // kanban 工具通过 dependency-reversal 注入:per-project 的 db-backed store。
78
+ // 仅当 session 有 projectId 时启用(子 session 无 project 时不可用)。
79
+ ...(state.session.projectId
80
+ ? { kanbanStore: createKanbanStore(deps.db, state.session.projectId) }
81
+ : {}),
82
+ }, validCalls, deps.hookRunner);
83
+ const toolLatency = Date.now() - toolExecStart;
84
+ const metricsEnabled = deps.config.toolMetrics.enabled;
85
+ for (const { id, result } of results) {
86
+ yield { _tag: 'tool_call_end', id, result };
87
+ const tc = validCalls.find((c) => c.id === id);
88
+ if (tc) {
89
+ await appendMessage(deps.db, state.session.id, {
90
+ role: 'tool',
91
+ content: toolResultToContent(id, tc.tool, result),
92
+ });
93
+ // spec §16.5:记录工具执行结果供后续模式评估。
94
+ // fire-and-forget:记录失败绝不阻塞 agent 主流程。
95
+ if (metricsEnabled) {
96
+ const mode = inferToolMode(tc.tool, tc.input);
97
+ const success = result._tag === 'success' || result._tag === 'truncated';
98
+ void recordToolMetrics(deps.db, state.config.model, tc.tool, mode, success, toolLatency).catch(() => { });
99
+ }
100
+ }
101
+ }
102
+ }
103
+ // yield 在本轮工具执行中收集的 subagent 事件(subagent_start/end)
104
+ for (const ev of subagentEvents) {
105
+ yield ev;
106
+ }
107
+ }
@@ -0,0 +1,8 @@
1
+ import type { AgentState } from '../../shared/types/agent.js';
2
+ import type { ChatTool, FinishReason } from '../../shared/types/llm.js';
3
+ import type { LoopDeps } from '../loop.js';
4
+ import type { StreamCollectResult } from './stream-collect.js';
5
+ /** 段管理:判断段边界(pending trigger / model / systemPrompt / tools 变化),
6
+ * 构造本轮 LLMCall 并 push 到当前段,持久化 segments。直接修改 state.segments(引用语义)。
7
+ * trigger 判定用于新段元数据;needSegment 决定是否真正开新段(否则复用 activeSeg)。 */
8
+ export declare function manageSegment(state: AgentState, deps: LoopDeps, systemPrompt: string, tools: ChatTool[], requestStartTime: number, totalLatency: number, collectedUsage: StreamCollectResult['usage'], collectedText: string[], collectedThinking: string[], truncated: FinishReason | null, firstTokenTime: number | null, contextWindow: number | undefined, computedCost: number): Promise<void>;
@@ -0,0 +1,72 @@
1
+ import { saveLLMSegments, segmentFingerprint } from '../../session/session.js';
2
+ import { generateId } from '../../shared/index.js';
3
+ /** 段管理:判断段边界(pending trigger / model / systemPrompt / tools 变化),
4
+ * 构造本轮 LLMCall 并 push 到当前段,持久化 segments。直接修改 state.segments(引用语义)。
5
+ * trigger 判定用于新段元数据;needSegment 决定是否真正开新段(否则复用 activeSeg)。 */
6
+ export async function manageSegment(state, deps, systemPrompt, tools, requestStartTime, totalLatency, collectedUsage, collectedText, collectedThinking, truncated, firstTokenTime, contextWindow, computedCost) {
7
+ // —— 段管理:判断是否开新段 ——
8
+ const fp = segmentFingerprint(systemPrompt, tools);
9
+ const activeSeg = state.segments[state.segments.length - 1];
10
+ const pendingTrigger = state.pendingSegmentTrigger;
11
+ let trigger;
12
+ if (pendingTrigger) {
13
+ trigger = pendingTrigger;
14
+ state.pendingSegmentTrigger = undefined;
15
+ }
16
+ else if (!activeSeg) {
17
+ trigger = 'initial';
18
+ }
19
+ else if (activeSeg.model !== state.config.model) {
20
+ trigger = 'model_change';
21
+ }
22
+ else if (activeSeg.fingerprint !== fp) {
23
+ trigger = activeSeg.systemPrompt !== systemPrompt ? 'system_prompt_change' : 'tools_change';
24
+ }
25
+ else {
26
+ trigger = 'user_confirmed'; // 占位,实际不会开段
27
+ }
28
+ const needSegment = !!pendingTrigger ||
29
+ !activeSeg ||
30
+ activeSeg.model !== state.config.model ||
31
+ activeSeg.fingerprint !== fp;
32
+ let currentSeg;
33
+ if (!needSegment && activeSeg) {
34
+ currentSeg = activeSeg;
35
+ }
36
+ else {
37
+ currentSeg = {
38
+ id: generateId(),
39
+ fingerprint: fp,
40
+ provider: state.config.provider,
41
+ model: state.config.model,
42
+ systemPrompt,
43
+ tools,
44
+ startedAt: requestStartTime,
45
+ trigger,
46
+ agentName: state.config.agentName,
47
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
48
+ calls: [],
49
+ };
50
+ state.segments.push(currentSeg);
51
+ }
52
+ // —— 段内轻量 call ——
53
+ const call = {
54
+ id: generateId(),
55
+ timestamp: requestStartTime,
56
+ usage: {
57
+ input: collectedUsage?.inputTokens ?? 0,
58
+ output: collectedUsage?.outputTokens ?? 0,
59
+ ...(collectedUsage?.cacheRead !== undefined ? { cacheRead: collectedUsage.cacheRead } : {}),
60
+ },
61
+ latency: {
62
+ firstToken: firstTokenTime ? firstTokenTime - requestStartTime : totalLatency,
63
+ total: totalLatency,
64
+ },
65
+ cost: computedCost,
66
+ ...(collectedThinking.length > 0 ? { thinking: collectedThinking.join('') } : {}),
67
+ responseText: collectedText.join(''),
68
+ ...(truncated ? { finishReason: truncated } : {}),
69
+ };
70
+ currentSeg.calls.push(call);
71
+ await saveLLMSegments(deps.db, state.session.id, state.segments);
72
+ }
@@ -0,0 +1,47 @@
1
+ import type { chatStream as llmChatStream } from '../../llm/provider.js';
2
+ import type { AgentEvent, AgentState } from '../../shared/types/agent.js';
3
+ import type { ChatRequest, FinishReason, StreamChunk } from '../../shared/types/llm.js';
4
+ import type { LoopDeps } from '../loop.js';
5
+ import type { CollectedToolCall } from '../tool-exec.js';
6
+ /** 入参是否为协议层/loop 标记的解析失败(携带 _parseError / _raw 容错标记)。
7
+ * 这类入参是后端专用、只反馈给模型重试的,绝不能进入持久化消息或渲染层。 */
8
+ export declare function isParseErrorInput(input: unknown): boolean;
9
+ /** collectStreamChunks 的收集结果:单轮 LLM 流的全部产出与状态标记。 */
10
+ export type StreamCollectResult = {
11
+ /** 完整 chunk 序列(供 provider:after hook 与 LLMDetail.responseChunks) */
12
+ chunks: StreamChunk[];
13
+ /** 文本片段累积 */
14
+ text: string[];
15
+ /** thinking 片段累积 */
16
+ thinking: string[];
17
+ /** 解析后的 tool call(含容错标记的入参) */
18
+ toolCalls: Map<string, CollectedToolCall>;
19
+ /** usage 统计(input/output/cacheRead) */
20
+ usage: {
21
+ inputTokens: number;
22
+ outputTokens: number;
23
+ cacheRead?: number;
24
+ } | null;
25
+ /** 非正常停止原因(length / content_filter),完成分支据此判断是否被截断 */
26
+ truncated: FinishReason | null;
27
+ /** 流中出现过 error chunk */
28
+ hadError: boolean;
29
+ /** 首个非 done chunk 到达时间(latency.firstToken 用) */
30
+ firstTokenTime: number | null;
31
+ /** 更新后的溢出恢复标记:true 表示本轮已尝试过一次恢复(防无限循环) */
32
+ overflowCompacted: boolean;
33
+ /** 溢出恢复成功 → 调用方需 `continue turnLoop` 重试本轮 */
34
+ overflowRecovered: boolean;
35
+ /** 致命错误(abort / provider 抛错)→ 调用方应直接 return 终止 loop */
36
+ fatalError: boolean;
37
+ };
38
+ /** 流式收集 LLM 输出:遍历 streamFn 的 chunk,分类收集 text/thinking/tool_call/usage/done,
39
+ * 实时透传 text_delta/thinking/usage/error 给 agentLoop 的消费者(通过 yield)。
40
+ *
41
+ * 内部处理响应式溢出恢复:context-overflow 且尚未开始输出(无 text、无 tool_call)
42
+ * 时压缩历史,成功则置 overflowRecovered=true 由调用方 `continue turnLoop` 重试本轮。
43
+ * 致命错误(abort / provider 抛错)置 fatalError=true 由调用方终止 loop。
44
+ *
45
+ * 注意:tool_call_start 仅登记、不发射 AgentEvent——入参尚未到达,过早发射空入参
46
+ * 会让前端渲染半成品卡。tool_call_start 由 persistAssistantAndTools 在入参解析完成后统一发射。 */
47
+ export declare function collectStreamChunks(state: AgentState, deps: LoopDeps, streamFn: typeof llmChatStream, request: ChatRequest, overflowCompacted: boolean): AsyncGenerator<AgentEvent, StreamCollectResult>;
@@ -0,0 +1,183 @@
1
+ import { isContextOverflowFailure } from '../../llm/provider-error.js';
2
+ import { isLLMError } from '../../llm/schema/errors.js';
3
+ import { recoverFromOverflow } from './compaction.js';
4
+ /** 入参是否为协议层/loop 标记的解析失败(携带 _parseError / _raw 容错标记)。
5
+ * 这类入参是后端专用、只反馈给模型重试的,绝不能进入持久化消息或渲染层。 */
6
+ export function isParseErrorInput(input) {
7
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
8
+ return false;
9
+ const obj = input;
10
+ return '_parseError' in obj || '_raw' in obj;
11
+ }
12
+ /** 解析 tool_call_end 的入参:JSON.parse + 容错标记。
13
+ * 解析成功返回原值;失败返回 { _parseError, _raw } 容错标记(与协议层 finishAll 一致),
14
+ * 供 isParseErrorInput 识别——这类入参只反馈给模型重试,绝不持久化或渲染。 */
15
+ function parseToolCallArgs(finalArgs) {
16
+ try {
17
+ return JSON.parse(finalArgs);
18
+ }
19
+ catch (e) {
20
+ return {
21
+ _parseError: e instanceof Error ? e.message : String(e),
22
+ _raw: finalArgs,
23
+ };
24
+ }
25
+ }
26
+ /** 流式收集 LLM 输出:遍历 streamFn 的 chunk,分类收集 text/thinking/tool_call/usage/done,
27
+ * 实时透传 text_delta/thinking/usage/error 给 agentLoop 的消费者(通过 yield)。
28
+ *
29
+ * 内部处理响应式溢出恢复:context-overflow 且尚未开始输出(无 text、无 tool_call)
30
+ * 时压缩历史,成功则置 overflowRecovered=true 由调用方 `continue turnLoop` 重试本轮。
31
+ * 致命错误(abort / provider 抛错)置 fatalError=true 由调用方终止 loop。
32
+ *
33
+ * 注意:tool_call_start 仅登记、不发射 AgentEvent——入参尚未到达,过早发射空入参
34
+ * 会让前端渲染半成品卡。tool_call_start 由 persistAssistantAndTools 在入参解析完成后统一发射。 */
35
+ export async function* collectStreamChunks(state, deps, streamFn, request, overflowCompacted) {
36
+ const chunks = [];
37
+ const text = [];
38
+ const thinking = [];
39
+ const toolCalls = new Map();
40
+ const toolCallArgs = new Map();
41
+ let usage = null;
42
+ let hadError = false;
43
+ // 非正常停止原因(length=被 max_tokens 截断, content_filter=被内容过滤)。
44
+ // 若在无 tool_call 的完成分支里仍非 null,说明回答被截断/过滤而非正常说完。
45
+ let truncated = null;
46
+ let firstTokenTime = null;
47
+ let recovered = overflowCompacted;
48
+ const finish = (over) => ({
49
+ chunks,
50
+ text,
51
+ thinking,
52
+ toolCalls,
53
+ usage,
54
+ truncated,
55
+ hadError,
56
+ firstTokenTime,
57
+ overflowCompacted: recovered,
58
+ overflowRecovered: false,
59
+ fatalError: false,
60
+ ...over,
61
+ });
62
+ try {
63
+ for await (const chunk of streamFn({
64
+ registry: deps.llmRegistry,
65
+ signal: state.abortController.signal,
66
+ }, request, { provider: state.config.provider, model: state.config.model })) {
67
+ if (firstTokenTime === null && chunk._tag !== 'done') {
68
+ firstTokenTime = Date.now();
69
+ }
70
+ chunks.push(chunk);
71
+ if (state.abortController.signal.aborted) {
72
+ yield { _tag: 'error', error: { _tag: 'aborted' } };
73
+ return finish({ fatalError: true });
74
+ }
75
+ switch (chunk._tag) {
76
+ case 'text':
77
+ text.push(chunk.text);
78
+ yield { _tag: 'text_delta', text: chunk.text };
79
+ break;
80
+ case 'tool_call_start':
81
+ // 仅登记,不立即发射 AgentEvent。此时入参尚未到达(流式 delta 累积中),
82
+ // 过早发射空入参的 tool_call_start 会让前端渲染 "Glob · " 等半成品卡,
83
+ // 且该 part 的入参之后也不会被纠正。tool_call_start 改在入参解析完成后、
84
+ // 携带真实入参时统一发射(见 persistAssistantAndTools)。
85
+ toolCalls.set(chunk.id, {
86
+ id: chunk.id,
87
+ tool: chunk.name,
88
+ input: {},
89
+ });
90
+ toolCallArgs.set(chunk.id, '');
91
+ break;
92
+ case 'tool_call_delta': {
93
+ const existing = toolCallArgs.get(chunk.id) ?? '';
94
+ toolCallArgs.set(chunk.id, existing + chunk.argumentsDelta);
95
+ break;
96
+ }
97
+ case 'tool_call_end': {
98
+ const finalArgs = chunk.argumentsFinal ?? toolCallArgs.get(chunk.id) ?? '{}';
99
+ const tc = toolCalls.get(chunk.id);
100
+ if (tc)
101
+ tc.input = parseToolCallArgs(finalArgs);
102
+ break;
103
+ }
104
+ case 'thinking':
105
+ thinking.push(chunk.text);
106
+ yield { _tag: 'thinking', text: chunk.text };
107
+ break;
108
+ case 'usage':
109
+ usage = {
110
+ inputTokens: chunk.inputTokens,
111
+ outputTokens: chunk.outputTokens,
112
+ cacheRead: chunk.cacheRead,
113
+ };
114
+ yield {
115
+ _tag: 'usage',
116
+ input: chunk.inputTokens,
117
+ output: chunk.outputTokens,
118
+ cacheRead: chunk.cacheRead,
119
+ };
120
+ break;
121
+ case 'done':
122
+ if (chunk.finishReason === 'length' || chunk.finishReason === 'content-filter') {
123
+ truncated = chunk.finishReason;
124
+ }
125
+ break;
126
+ case 'error': {
127
+ // 响应式溢出恢复:仅当尚未开始输出且未重试过时,压缩历史并重试本轮
128
+ const isOverflow = chunk.error.classification === 'context-overflow';
129
+ if (isOverflow && !recovered && text.length === 0 && toolCalls.size === 0) {
130
+ recovered = true;
131
+ if (await recoverFromOverflow(state, deps)) {
132
+ return finish({ overflowRecovered: true });
133
+ }
134
+ }
135
+ yield {
136
+ _tag: 'error',
137
+ error: {
138
+ _tag: 'provider',
139
+ message: chunk.error.message,
140
+ retryable: chunk.error.retryable ?? false,
141
+ },
142
+ };
143
+ hadError = true;
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ }
149
+ catch (err) {
150
+ // 响应式溢出恢复:provider 抛出的 context-overflow 是生产环境的实际路径
151
+ // (httpPost 在非 2xx 时抛出已分类的 LLMError),与上方 error chunk 分支同理。
152
+ if (isContextOverflowFailure(err) && !recovered && text.length === 0 && toolCalls.size === 0) {
153
+ recovered = true;
154
+ if (await recoverFromOverflow(state, deps)) {
155
+ return finish({ overflowRecovered: true });
156
+ }
157
+ }
158
+ const message = err instanceof Error
159
+ ? err.message
160
+ : isLLMError(err)
161
+ ? err.message
162
+ : typeof err === 'object' && err !== null && 'message' in err
163
+ ? String(err.message)
164
+ : String(err);
165
+ yield {
166
+ _tag: 'error',
167
+ error: {
168
+ _tag: 'unexpected',
169
+ message,
170
+ },
171
+ };
172
+ state.status = {
173
+ _tag: 'stopped',
174
+ reason: 'error',
175
+ error: {
176
+ _tag: 'unexpected',
177
+ message,
178
+ },
179
+ };
180
+ return finish({ fatalError: true });
181
+ }
182
+ return finish({});
183
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentState } from '../../shared/types/agent.js';
2
+ import type { SubAgentRequest, SubAgentResult } from '../../shared/types/tool.js';
3
+ import type { LoopDeps } from '../loop.js';
4
+ /** 运行一个按类型派发的子 agent(spec: multi-agent-design §4.5)。
5
+ *
6
+ * Host 端实现:查 agentRegistry 获取 AgentDefinition → 创建隔离子 session(agentType 记录)
7
+ * → 构建子 agent(专属 prompt + 受限工具集 + yield)→ 运行到 yield 或完成 → 返回结果。
8
+ * 发射 subagent_start/subagent_end 事件供父 agent 转发(spec §4.5 step 7)。
9
+ * abort 链接父→子。maxRecursion 控制子 agent 能否再递归派生 task(spec §4.5 step 4)。
10
+ * def.isolated 时在 git worktree 中运行,结束后把 delta 自动 apply 回父仓库(spec §4.6)。
11
+ * request.background 时 fork 异步运行,立即返回 running(spec §4.7)。 */
12
+ export declare function runSubAgent(deps: LoopDeps, parent: AgentState, request: SubAgentRequest): Promise<SubAgentResult>;