@kkelly-offical/kkcode 0.1.6 → 0.2.1

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 (163) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +452 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +19 -2
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +90 -0
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/config/defaults.mjs +280 -260
  29. package/src/config/import-config.mjs +1 -1
  30. package/src/config/load-config.mjs +61 -4
  31. package/src/config/schema.mjs +591 -574
  32. package/src/context.mjs +4 -1
  33. package/src/core/constants.mjs +97 -91
  34. package/src/core/types.mjs +1 -1
  35. package/src/github/api.mjs +78 -78
  36. package/src/github/auth.mjs +294 -286
  37. package/src/github/flow.mjs +298 -298
  38. package/src/github/workspace.mjs +225 -212
  39. package/src/index.mjs +84 -82
  40. package/src/knowledge/frontend-aesthetics.txt +38 -38
  41. package/src/mcp/client-http.mjs +139 -141
  42. package/src/mcp/client-sse.mjs +297 -288
  43. package/src/mcp/client-stdio.mjs +534 -533
  44. package/src/mcp/constants.mjs +2 -2
  45. package/src/mcp/registry.mjs +498 -479
  46. package/src/mcp/stdio-framing.mjs +135 -133
  47. package/src/mcp/tool-result.mjs +24 -24
  48. package/src/observability/edit-diagnostics.mjs +449 -0
  49. package/src/observability/index.mjs +42 -42
  50. package/src/observability/metrics.mjs +165 -137
  51. package/src/observability/tracer.mjs +137 -137
  52. package/src/onboarding.mjs +209 -0
  53. package/src/orchestration/background-manager.mjs +567 -372
  54. package/src/orchestration/background-worker.mjs +419 -305
  55. package/src/orchestration/interruption-reason.mjs +21 -0
  56. package/src/orchestration/longagent-manager.mjs +197 -171
  57. package/src/orchestration/stage-scheduler.mjs +733 -728
  58. package/src/orchestration/subagent-router.mjs +7 -1
  59. package/src/orchestration/task-scheduler.mjs +219 -7
  60. package/src/permission/engine.mjs +1 -1
  61. package/src/permission/exec-policy.mjs +370 -370
  62. package/src/permission/file-edit-policy.mjs +108 -0
  63. package/src/permission/prompt.mjs +1 -1
  64. package/src/permission/rules.mjs +116 -7
  65. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  66. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  67. package/src/plugin/hook-bus.mjs +19 -5
  68. package/src/plugin/manifest-loader.mjs +222 -0
  69. package/src/provider/anthropic.mjs +396 -390
  70. package/src/provider/ollama.mjs +7 -1
  71. package/src/provider/openai.mjs +382 -340
  72. package/src/provider/retry-policy.mjs +74 -68
  73. package/src/provider/router.mjs +242 -241
  74. package/src/provider/sse.mjs +104 -104
  75. package/src/provider/wizard.mjs +556 -0
  76. package/src/repl/capability-facade.mjs +30 -0
  77. package/src/repl/command-surface.mjs +23 -0
  78. package/src/repl/controller-entry.mjs +40 -0
  79. package/src/repl/core-shell.mjs +208 -0
  80. package/src/repl/dialog-router.mjs +87 -0
  81. package/src/repl/input-engine.mjs +76 -0
  82. package/src/repl/keymap.mjs +7 -0
  83. package/src/repl/operator-surface.mjs +15 -0
  84. package/src/repl/permission-flow.mjs +49 -0
  85. package/src/repl/runtime-facade.mjs +36 -0
  86. package/src/repl/slash-router.mjs +62 -0
  87. package/src/repl/state-store.mjs +29 -0
  88. package/src/repl/turn-controller.mjs +58 -0
  89. package/src/repl/verification.mjs +23 -0
  90. package/src/repl.mjs +3368 -2929
  91. package/src/rules/load-rules.mjs +3 -3
  92. package/src/runtime.mjs +1 -1
  93. package/src/session/agent-transaction.mjs +86 -0
  94. package/src/session/checkpoint.mjs +302 -302
  95. package/src/session/compaction.mjs +36 -14
  96. package/src/session/engine.mjs +417 -227
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1081
  99. package/src/session/longagent-plan.mjs +376 -365
  100. package/src/session/longagent-project-memory.mjs +53 -53
  101. package/src/session/longagent-scaffold.mjs +291 -291
  102. package/src/session/longagent-task-bus.mjs +138 -54
  103. package/src/session/longagent-utils.mjs +828 -472
  104. package/src/session/longagent.mjs +911 -884
  105. package/src/session/loop.mjs +1005 -905
  106. package/src/session/prompt/agent.txt +25 -0
  107. package/src/session/prompt/anthropic.txt +150 -150
  108. package/src/session/prompt/beast.txt +1 -1
  109. package/src/session/prompt/plan.txt +28 -6
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +197 -0
  113. package/src/session/routing-observability.mjs +72 -0
  114. package/src/session/runtime-state.mjs +47 -0
  115. package/src/session/store.mjs +523 -510
  116. package/src/session/system-prompt.mjs +56 -8
  117. package/src/session/task-validator.mjs +267 -267
  118. package/src/session/usability-gates.mjs +2 -2
  119. package/src/skill/builtin/commit.mjs +64 -64
  120. package/src/skill/builtin/design.mjs +76 -76
  121. package/src/skill/generator.mjs +18 -2
  122. package/src/skill/registry.mjs +642 -390
  123. package/src/storage/audit-store.mjs +18 -11
  124. package/src/storage/event-log.mjs +7 -1
  125. package/src/storage/ghost-commit-store.mjs +243 -245
  126. package/src/storage/paths.mjs +13 -0
  127. package/src/theme/default-theme.mjs +1 -1
  128. package/src/theme/markdown.mjs +4 -0
  129. package/src/theme/schema.mjs +1 -1
  130. package/src/theme/status-bar.mjs +162 -158
  131. package/src/tool/audit-wrapper.mjs +18 -2
  132. package/src/tool/edit-transaction.mjs +23 -0
  133. package/src/tool/executor.mjs +26 -1
  134. package/src/tool/file-read-state.mjs +65 -0
  135. package/src/tool/git-auto.mjs +526 -526
  136. package/src/tool/git-full-auto.mjs +487 -478
  137. package/src/tool/mutation-guard.mjs +54 -0
  138. package/src/tool/prompt/edit.txt +3 -3
  139. package/src/tool/prompt/multiedit.txt +1 -0
  140. package/src/tool/prompt/notebookedit.txt +2 -1
  141. package/src/tool/prompt/patch.txt +25 -24
  142. package/src/tool/prompt/read.txt +3 -3
  143. package/src/tool/prompt/sysinfo.txt +29 -0
  144. package/src/tool/prompt/task.txt +66 -4
  145. package/src/tool/prompt/write.txt +2 -2
  146. package/src/tool/question-prompt.mjs +17 -4
  147. package/src/tool/registry.mjs +1701 -1343
  148. package/src/tool/task-tool.mjs +14 -6
  149. package/src/ui/activity-renderer.mjs +667 -664
  150. package/src/ui/repl-background-panel.mjs +7 -0
  151. package/src/ui/repl-capability-panel.mjs +9 -0
  152. package/src/ui/repl-dashboard.mjs +54 -4
  153. package/src/ui/repl-help.mjs +110 -0
  154. package/src/ui/repl-operator-panel.mjs +12 -0
  155. package/src/ui/repl-route-feedback.mjs +35 -0
  156. package/src/ui/repl-status-view.mjs +76 -0
  157. package/src/ui/repl-task-panel.mjs +5 -0
  158. package/src/ui/repl-transcript-panel.mjs +56 -0
  159. package/src/ui/repl-turn-summary.mjs +135 -0
  160. package/src/usage/pricing.mjs +122 -121
  161. package/src/usage/usage-meter.mjs +1 -0
  162. package/src/util/git.mjs +562 -519
  163. package/src/util/template.mjs +6 -1
@@ -1,905 +1,1005 @@
1
- import { newId } from "../core/types.mjs"
2
- import { EventBus } from "../core/events.mjs"
3
- import { EVENT_TYPES } from "../core/constants.mjs"
4
- import { requestProviderStream, countTokensProvider } from "../provider/router.mjs"
5
- import { ToolRegistry } from "../tool/registry.mjs"
6
- import { executeTool } from "../tool/executor.mjs"
7
- import { PermissionEngine } from "../permission/engine.mjs"
8
- import { createTaskDelegate } from "../orchestration/task-scheduler.mjs"
9
- import { loadInstructions } from "./instruction-loader.mjs"
10
- import { buildSystemPromptBlocks } from "./system-prompt.mjs"
11
- import { detectProjectContext } from "./project-context.mjs"
12
- import { renderRulesPrompt } from "../rules/load-rules.mjs"
13
- import { SkillRegistry } from "../skill/registry.mjs"
14
- import {
15
- touchSession,
16
- appendMessage,
17
- appendPart,
18
- getConversationHistory,
19
- markSessionStatus,
20
- updateSession
21
- } from "./store.mjs"
22
- import { pendingRejections, markRejectionsConsumed } from "../review/rejection-queue.mjs"
23
- import { isRecoveryEnabled, markTurnFinished, markTurnInProgress } from "./recovery.mjs"
24
- import { HookBus, initHookBus } from "../plugin/hook-bus.mjs"
25
- import { shouldCompact, compactSession, estimateTokenCount, modelContextLimit, contextUtilization, supportsNativeCompaction } from "./compaction.mjs"
26
- import { createStreamRenderer } from "../theme/markdown.mjs"
27
- import { paint } from "../theme/color.mjs"
28
- import { saveCheckpoint } from "./checkpoint.mjs"
29
- import { askPlanApproval } from "../tool/question-prompt.mjs"
30
- import { createValidator } from "./task-validator.mjs"
31
-
32
- const READ_ONLY_TOOLS = new Set([
33
- "read", "glob", "grep", "list", "webfetch", "websearch", "codesearch", "background_output", "todowrite", "enter_plan", "exit_plan"
34
- ])
35
-
36
- function addUsage(target, delta) {
37
- target.input += delta.input || 0
38
- target.output += delta.output || 0
39
- target.cacheRead += delta.cacheRead || 0
40
- target.cacheWrite += delta.cacheWrite || 0
41
- }
42
-
43
-
44
- async function buildSystemPrompt({ mode, model, cwd, agent = null, tools = [], skills = [], language = "en" }) {
45
- // Assemble user instructions + rules (Layer 6)
46
- const instructions = await loadInstructions(cwd)
47
- const rules = await renderRulesPrompt(cwd)
48
- const userInstructions = [...instructions, rules].filter(Boolean).join("\n\n")
49
-
50
- // Detect project context (framework, language, build tool, etc.)
51
- const projectContext = await detectProjectContext(cwd)
52
-
53
- // Build structured blocks for provider-level cache optimization
54
- const result = await buildSystemPromptBlocks({ mode, model, cwd, agent, tools, skills, userInstructions, projectContext, language })
55
- return result
56
- }
57
-
58
- function toolPatternFromArgs(args) {
59
- if (!args || typeof args !== "object") return "*"
60
- return String(args.path || args.command || args.pattern || args.task_id || "*")
61
- }
62
-
63
- function normalizeMessageForCache(msg) {
64
- const content = msg?.content
65
- // For array content (image blocks, tool_use, tool_result), serialize to a stable string
66
- if (Array.isArray(content)) {
67
- const textParts = content
68
- .filter((b) => b.type === "text")
69
- .map((b) => b.text || "")
70
- .join("\n")
71
- const imageParts = content
72
- .filter((b) => b.type === "image")
73
- .map((b) => `[image:${b.path || "inline"}]`)
74
- .join(" ")
75
- const toolUseParts = content
76
- .filter((b) => b.type === "tool_use")
77
- .map((b) => `[tool_use:${b.name}:${b.id}]`)
78
- .join(" ")
79
- const toolResultParts = content
80
- .filter((b) => b.type === "tool_result")
81
- .map((b) => `[tool_result:${b.tool_use_id}:${String(b.content || "").slice(0, 100)}]`)
82
- .join(" ")
83
- const extras = [imageParts, toolUseParts, toolResultParts].filter(Boolean).join("\n")
84
- return {
85
- role: String(msg?.role || ""),
86
- content: `${textParts}${extras ? "\n" + extras : ""}`
87
- }
88
- }
89
- return {
90
- role: String(msg?.role || ""),
91
- content: String(content || "")
92
- }
93
- }
94
-
95
- function isPrefixMessages(prefix, full) {
96
- if (!Array.isArray(prefix) || !Array.isArray(full)) return false
97
- if (prefix.length > full.length) return false
98
- for (let i = 0; i < prefix.length; i++) {
99
- if (prefix[i].role !== full[i].role || prefix[i].content !== full[i].content) return false
100
- }
101
- return true
102
- }
103
-
104
- export async function processTurnLoop({
105
- prompt,
106
- contentBlocks = null,
107
- mode,
108
- model,
109
- providerType,
110
- sessionId,
111
- configState,
112
- baseUrl = null,
113
- apiKeyEnv = null,
114
- depth = 0,
115
- signal = null,
116
- output = null,
117
- subagent = null,
118
- agent = null,
119
- allowQuestion = true,
120
- toolContext = {}
121
- }) {
122
- await initHookBus()
123
-
124
- if (depth > 8) {
125
- return {
126
- sessionId,
127
- turnId: newId("turn"),
128
- reply: "task delegation depth exceeded",
129
- emittedText: false,
130
- context: null,
131
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
132
- toolEvents: []
133
- }
134
- }
135
-
136
- const cwd = process.cwd()
137
- const turnId = newId("turn")
138
- const configMaxSteps = Math.max(1, Number(configState.config.agent.max_steps || 128))
139
- const maxSteps = (subagent?.maxTurns > 0) ? Math.min(configMaxSteps, subagent.maxTurns) : configMaxSteps
140
- const verifyCompletion = configState.config.agent?.verify_completion !== false
141
- const recoveryEnabled = isRecoveryEnabled(configState.config)
142
- const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
143
- const toolEvents = []
144
- const doomTracker = [] // recent tool call signatures for doom loop detection
145
- let emittedAnyText = false
146
- let lastContextMeter = null
147
- let contextCachePoint = null
148
- const thresholdRatio = Number(configState.config.session?.compaction_threshold_ratio ?? 0.7)
149
- const thresholdMessages = Number(configState.config.session?.compaction_threshold_messages ?? 50)
150
- const cachePointsEnabled = configState.config.session?.context_cache_points !== false
151
- const useNativeCompaction = supportsNativeCompaction(providerType, model)
152
- const nativeCompactionTrigger = useNativeCompaction ? Math.floor(modelContextLimit(model, configState) * thresholdRatio) : 0
153
-
154
- await touchSession({
155
- sessionId,
156
- mode,
157
- model,
158
- providerType,
159
- cwd,
160
- status: "active",
161
- title: subagent ? `${subagent.name}: ${prompt.slice(0, 60)}` : null
162
- })
163
-
164
- await EventBus.emit({
165
- type: EVENT_TYPES.TURN_START,
166
- sessionId,
167
- turnId,
168
- payload: { mode, model, providerType, prompt }
169
- })
170
-
171
- const queue = await pendingRejections(cwd)
172
- const rejectionText = queue.length
173
- ? [
174
- "<review-rejections>",
175
- ...queue.map((entry, index) => `${index + 1}. file=${entry.file} reason=${entry.reason} risk=${entry.riskScore ?? "unknown"}`),
176
- "</review-rejections>",
177
- "Address these rejected changes before introducing new risky edits."
178
- ].join("\n")
179
- : ""
180
- const effectivePrompt = rejectionText ? `${prompt}\n\n${rejectionText}` : prompt
181
-
182
- // If contentBlocks provided (e.g. images), build array content for the message.
183
- // Prepend rejection text as a text block if needed.
184
- let messageContent
185
- if (contentBlocks && Array.isArray(contentBlocks)) {
186
- const blocks = [...contentBlocks]
187
- if (rejectionText) {
188
- // Find the first text block and prepend rejection text
189
- const textIdx = blocks.findIndex((b) => b.type === "text")
190
- if (textIdx >= 0) {
191
- blocks[textIdx] = { type: "text", text: `${blocks[textIdx].text}\n\n${rejectionText}` }
192
- } else {
193
- blocks.unshift({ type: "text", text: rejectionText })
194
- }
195
- }
196
- messageContent = blocks
197
- } else {
198
- messageContent = effectivePrompt
199
- }
200
-
201
- const userMessage = await appendMessage(sessionId, "user", messageContent, {
202
- mode,
203
- model,
204
- providerType,
205
- turnId
206
- })
207
-
208
- await appendPart(sessionId, {
209
- type: "turn-start",
210
- messageId: userMessage.id,
211
- turnId,
212
- mode,
213
- model,
214
- providerType
215
- })
216
-
217
- let systemTools = await ToolRegistry.list({ mode, config: configState.config, cwd })
218
- if (agent?.tools) {
219
- systemTools = systemTools.filter((t) => agent.tools.includes(t.name))
220
- }
221
- const skills = SkillRegistry.isReady() ? SkillRegistry.listForSystemPrompt() : []
222
- const language = configState.config.language || "en"
223
- const systemPrompt = await buildSystemPrompt({ mode, model, cwd, agent, tools: systemTools, skills, language })
224
- // systemPrompt = { text, blocks } providers use blocks for cache optimization
225
- const delegateTask = createTaskDelegate({
226
- config: configState.config,
227
- parentSessionId: sessionId,
228
- model,
229
- providerType,
230
- runSubtask: async ({
231
- prompt: subPrompt,
232
- sessionId: subSessionId,
233
- model: subModel,
234
- providerType: subProvider,
235
- subagent: resolvedSubagent,
236
- allowQuestion: subAllowQuestion = false
237
- }) => {
238
- return processTurnLoop({
239
- prompt: subPrompt,
240
- mode: "agent",
241
- model: subModel,
242
- providerType: subProvider,
243
- sessionId: subSessionId,
244
- configState,
245
- baseUrl,
246
- apiKeyEnv,
247
- depth: depth + 1,
248
- signal,
249
- subagent: resolvedSubagent,
250
- allowQuestion: subAllowQuestion,
251
- toolContext
252
- })
253
- }
254
- })
255
-
256
- const MAX_CONTINUES = 8
257
- let continueCount = 0
258
- let nudgeCount = 0
259
- let finalReply = ""
260
- const sinkWrite = typeof output?.write === "function"
261
- ? output.write
262
- : () => {}
263
- try {
264
- for (let step = 1; step <= maxSteps; step++) {
265
- await markTurnInProgress(sessionId, turnId, step, recoveryEnabled)
266
- await EventBus.emit({
267
- type: EVENT_TYPES.TURN_STEP_START,
268
- sessionId,
269
- turnId,
270
- payload: { step }
271
- })
272
-
273
- let tools = await ToolRegistry.list({ mode, config: configState.config, cwd })
274
- if (agent?.tools) {
275
- tools = tools.filter((t) => agent.tools.includes(t.name))
276
- }
277
- let history = await getConversationHistory(sessionId, Number(configState.config.session.max_history || 30))
278
-
279
- const normalizedHistory = history.map(normalizeMessageForCache)
280
- let contextTokens = estimateTokenCount(normalizedHistory)
281
- let contextFromCache = false
282
-
283
- // Use real token counting API when available (includes system + tools + messages)
284
- const realCount = await countTokensProvider({
285
- configState, providerType, model,
286
- system: systemPrompt, messages: history, tools,
287
- baseUrl, apiKeyEnv
288
- })
289
- if (realCount != null) {
290
- contextTokens = realCount
291
- } else if (contextCachePoint && isPrefixMessages(contextCachePoint.messages, normalizedHistory)) {
292
- const delta = normalizedHistory.slice(contextCachePoint.messages.length)
293
- contextTokens = contextCachePoint.tokens + estimateTokenCount(delta)
294
- contextFromCache = true
295
- } else if (contextCachePoint) {
296
- contextCachePoint = null
297
- }
298
- const contextLimit = modelContextLimit(model, configState)
299
- const contextRatio = contextLimit > 0 ? Math.min(1, contextTokens / contextLimit) : 0
300
- lastContextMeter = {
301
- tokens: contextTokens,
302
- limit: contextLimit,
303
- ratio: contextRatio,
304
- percent: Math.round(contextRatio * 100),
305
- fromCache: contextFromCache
306
- }
307
-
308
- if (cachePointsEnabled && (step === 1 || contextRatio >= thresholdRatio)) {
309
- contextCachePoint = {
310
- messages: normalizedHistory,
311
- tokens: contextTokens
312
- }
313
- await appendPart(sessionId, {
314
- type: "context-cache-point",
315
- turnId,
316
- step,
317
- tokenEstimate: contextTokens,
318
- contextLimit,
319
- contextRatio
320
- })
321
- await saveCheckpoint(sessionId, {
322
- kind: "context-cache-point",
323
- iteration: step,
324
- turnId,
325
- step,
326
- tokenEstimate: contextTokens,
327
- contextLimit,
328
- contextRatio,
329
- messageCount: normalizedHistory.length,
330
- fromCache: contextFromCache
331
- })
332
- }
333
-
334
- if (!useNativeCompaction && shouldCompact({
335
- messages: normalizedHistory,
336
- model,
337
- thresholdMessages,
338
- thresholdRatio,
339
- configState,
340
- realTokenCount: realCount != null ? contextTokens : null
341
- })) {
342
- const compactResult = await compactSession({
343
- sessionId, model, providerType, configState, baseUrl, apiKeyEnv
344
- })
345
- if (compactResult.compacted) {
346
- await EventBus.emit({ type: EVENT_TYPES.SESSION_COMPACTED, sessionId, turnId, payload: compactResult })
347
- history = await getConversationHistory(sessionId, Number(configState.config.session.max_history || 30))
348
- const compactedMeter = contextUtilization(history.map(normalizeMessageForCache), model, configState)
349
- lastContextMeter = { ...compactedMeter, fromCache: false }
350
- contextCachePoint = {
351
- messages: history.map(normalizeMessageForCache),
352
- tokens: compactedMeter.tokens
353
- }
354
- }
355
- }
356
-
357
- const messages = await HookBus.messagesTransform([...history])
358
-
359
- let response
360
- try {
361
- const chunks = requestProviderStream({
362
- configState,
363
- providerType,
364
- model,
365
- system: systemPrompt,
366
- messages,
367
- tools,
368
- baseUrl,
369
- apiKeyEnv,
370
- signal,
371
- compaction: useNativeCompaction ? { trigger: nativeCompactionTrigger } : null
372
- })
373
- const textParts = []
374
- const streamToolCalls = []
375
- let streamUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
376
- let streamStopReason = "end_turn"
377
- const mdEnabled = configState.config.ui?.markdown_render !== false
378
- const streamRenderer = mdEnabled ? createStreamRenderer() : null
379
- let inThinking = false
380
-
381
- for await (const chunk of chunks) {
382
- if (chunk.type === "thinking") {
383
- const text = chunk.content || ""
384
- if (!inThinking) {
385
- sinkWrite(paint("●", "#666666") + " " + paint("Thinking", null, { dim: true }) + " " + paint("∨", null, { dim: true }) + "\n")
386
- inThinking = true
387
- await EventBus.emit({ type: EVENT_TYPES.STREAM_THINKING_START, sessionId, turnId, payload: { step } })
388
- }
389
- sinkWrite(paint(" " + text, null, { dim: true }))
390
- } else if (chunk.type === "text") {
391
- if (inThinking) {
392
- sinkWrite("\n")
393
- inThinking = false
394
- }
395
- if (textParts.length === 0) {
396
- await EventBus.emit({ type: EVENT_TYPES.STREAM_TEXT_START, sessionId, turnId, payload: { step } })
397
- }
398
- if (streamRenderer) {
399
- const rendered = streamRenderer.push(chunk.content)
400
- if (rendered) sinkWrite(rendered)
401
- } else {
402
- sinkWrite(chunk.content)
403
- }
404
- textParts.push(chunk.content)
405
- } else if (chunk.type === "tool_call") {
406
- if (inThinking) {
407
- sinkWrite("\n")
408
- inThinking = false
409
- }
410
- streamToolCalls.push(chunk.call)
411
- } else if (chunk.type === "usage") {
412
- streamUsage = chunk.usage
413
- } else if (chunk.type === "compaction") {
414
- sinkWrite(paint("\n ↻ context compacted by provider\n", "cyan", { dim: true }))
415
- } else if (chunk.type === "stop") {
416
- streamStopReason = chunk.reason || "end_turn"
417
- }
418
- }
419
- if (inThinking) {
420
- sinkWrite("\n")
421
- }
422
- if (streamRenderer) {
423
- const tail = streamRenderer.flush()
424
- if (tail) sinkWrite(tail)
425
- }
426
- if (textParts.length) {
427
- sinkWrite("\n")
428
- emittedAnyText = true
429
- }
430
-
431
- response = {
432
- text: textParts.join(""),
433
- toolCalls: streamToolCalls,
434
- usage: streamUsage,
435
- stopReason: streamStopReason
436
- }
437
- } catch (error) {
438
- if (error.needsCompaction) {
439
- const compactResult = await compactSession({
440
- sessionId, model, providerType, configState, baseUrl, apiKeyEnv
441
- })
442
- if (compactResult.compacted) {
443
- await EventBus.emit({ type: EVENT_TYPES.SESSION_COMPACTED, sessionId, turnId, payload: compactResult })
444
- continue
445
- }
446
- }
447
- await appendPart(sessionId, {
448
- type: "provider-error",
449
- messageId: userMessage.id,
450
- step,
451
- turnId,
452
- error: error.message,
453
- errorClass: error.errorClass || "unknown",
454
- needsCompaction: Boolean(error.needsCompaction)
455
- })
456
- throw error
457
- }
458
-
459
- addUsage(usage, response.usage || {})
460
-
461
- // Update context meter with real API total input tokens
462
- // Anthropic: input_tokens is only non-cached portion; total = input + cacheRead + cacheWrite
463
- // OpenAI: prompt_tokens is already the total
464
- const u = response.usage || {}
465
- const totalInput = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0)
466
- if (totalInput > 0) {
467
- const contextLimit = modelContextLimit(model, configState)
468
- const contextRatio = contextLimit > 0 ? Math.min(1, totalInput / contextLimit) : 0
469
- lastContextMeter = {
470
- tokens: totalInput,
471
- limit: contextLimit,
472
- ratio: contextRatio,
473
- percent: Math.round(contextRatio * 100),
474
- fromCache: false,
475
- cacheRead: u.cacheRead || 0,
476
- cacheWrite: u.cacheWrite || 0,
477
- inputUncached: u.input || 0
478
- }
479
- }
480
-
481
- // Emit cumulative usage so status bar can update in real-time
482
- await EventBus.emit({
483
- type: EVENT_TYPES.TURN_USAGE_UPDATE,
484
- sessionId,
485
- turnId,
486
- payload: { usage: { ...usage }, step, model, context: lastContextMeter }
487
- })
488
-
489
- // --- Auto-continue on output truncation (max_tokens) ---
490
- if (response.stopReason === "max_tokens" && continueCount < MAX_CONTINUES) {
491
- continueCount++
492
- sinkWrite(paint(`\n ↳ output truncated, auto-continuing (${continueCount}/${MAX_CONTINUES})...\n`, "yellow", { dim: true }))
493
-
494
- // Drop any tool calls with parse errors (truncated JSON from cutoff)
495
- const validToolCalls = (response.toolCalls || []).filter(tc => !tc.args?.__parse_error)
496
-
497
- // Save partial output as assistant message
498
- const partialContent = []
499
- if (response.text) {
500
- partialContent.push({ type: "text", text: response.text })
501
- }
502
- for (const call of validToolCalls) {
503
- partialContent.push({ type: "tool_use", id: call.id, name: call.name, input: call.args || {} })
504
- }
505
- if (partialContent.length) {
506
- await appendMessage(sessionId, "assistant", partialContent.length === 1 && partialContent[0].type === "text"
507
- ? partialContent[0].text
508
- : partialContent, {
509
- mode, model, providerType, step, turnId, truncated: true
510
- })
511
- }
512
-
513
- // If there were valid tool calls, execute them and add results before continuing
514
- if (validToolCalls.length) {
515
- const resultContent = []
516
- for (const call of validToolCalls) {
517
- resultContent.push({
518
- type: "tool_result",
519
- tool_use_id: call.id,
520
- content: "[truncated response tool call acknowledged but output was cut off]",
521
- is_error: true
522
- })
523
- }
524
- await appendMessage(sessionId, "user", resultContent, {
525
- mode, model, providerType, step, turnId, synthetic: true
526
- })
527
- }
528
-
529
- // Inject continue prompt (localized) — include info about what was truncated
530
- const hadTruncatedToolCalls = (response.toolCalls || []).some(tc => tc.args?.__parse_error)
531
- const truncatedToolNames = (response.toolCalls || []).filter(tc => tc.args?.__parse_error).map(tc => tc.name).join(", ")
532
- const toolHint = hadTruncatedToolCalls
533
- ? (language === "zh"
534
- ? `\n被截断的工具调用: ${truncatedToolNames}。请完整重新发起这些工具调用。如果是创建大文件,使用 write(mode="append") 分段追加;如果是修改已有文件的局部内容,使用 patch 按行号范围替换。`
535
- : `\nTruncated tool calls: ${truncatedToolNames}. Re-issue these tool calls completely. For large file creation, use write(mode="append") to append in chunks. For modifying sections of existing files, use patch to replace by line range.`)
536
- : ""
537
- const continuePrompt = language === "zh"
538
- ? `[输出被截断 ${continueCount}/${MAX_CONTINUES}] 你的上一条回复在输出 token 上限处被截断。请从你停止的地方精确继续,不要重复已经写过的内容。如果你正在执行工具调用,请完整重新发起。${toolHint}`
539
- : `[OUTPUT TRUNCATED ${continueCount}/${MAX_CONTINUES}] Your previous response was cut off at the output token limit. Continue EXACTLY from where you stopped. Do not repeat any content you already wrote. If you were in the middle of a tool call, re-issue it completely.${toolHint}`
540
- await appendMessage(sessionId, "user", continuePrompt,
541
- { mode, model, providerType, step, turnId, synthetic: true }
542
- )
543
-
544
- // Don't consume a step for auto-continue
545
- step--
546
- continue
547
- }
548
- // Reset continue count on successful non-truncated response
549
- continueCount = 0
550
-
551
- if (!response.toolCalls?.length) {
552
- // Enhanced task completion verification
553
- if (verifyCompletion && nudgeCount < 2) {
554
- try {
555
- const validator = await createValidator({ cwd, configState })
556
- const validationResult = await validator.validate({
557
- todoState: toolContext._todoState
558
- })
559
-
560
- if (!validationResult.passed) {
561
- nudgeCount++
562
- const validationPrompt = language === "zh"
563
- ? `[任务验证失败] 您报告任务已完成,但以下验证失败:\n\n${validationResult.message}\n\n请修复问题后再报告完成。`
564
- : `[TASK VERIFICATION FAILED] You indicated completion, but verification failed:\n\n${validationResult.message}\n\nPlease fix the issues before declaring completion.`
565
-
566
- await appendMessage(sessionId, "user", validationPrompt,
567
- { mode, model, providerType, step, turnId, synthetic: true }
568
- )
569
- continue
570
- }
571
- } catch (validationError) {
572
- sinkWrite(paint(`\n ⚠ Task validation skipped: ${validationError.message}\n`, "yellow", { dim: true }))
573
- }
574
- }
575
-
576
- finalReply = (response.text || "").trim() || "No content returned from provider."
577
- const assistant = await appendMessage(sessionId, "assistant", finalReply, {
578
- mode,
579
- model,
580
- providerType,
581
- step,
582
- turnId
583
- })
584
- await appendPart(sessionId, {
585
- type: "assistant-response",
586
- messageId: assistant.id,
587
- step,
588
- turnId,
589
- hasText: Boolean(finalReply)
590
- })
591
- await markSessionStatus(sessionId, "active")
592
- if (queue.length) {
593
- await markRejectionsConsumed(
594
- queue.map((entry) => entry.id),
595
- sessionId,
596
- cwd
597
- )
598
- }
599
- await markTurnFinished(sessionId, recoveryEnabled)
600
- await EventBus.emit({
601
- type: EVENT_TYPES.TURN_FINISH,
602
- sessionId,
603
- turnId,
604
- payload: { step, reply: finalReply }
605
- })
606
- return {
607
- sessionId,
608
- turnId,
609
- reply: finalReply,
610
- emittedText: emittedAnyText,
611
- context: lastContextMeter,
612
- usage,
613
- toolEvents
614
- }
615
- }
616
-
617
- // --- Execute tool calls (read-only in parallel, write tools serially) ---
618
- async function executeOneCall(call) {
619
- const runningPart = await appendPart(sessionId, {
620
- type: "tool-call",
621
- messageId: userMessage.id,
622
- step,
623
- turnId,
624
- tool: call.name,
625
- args: call.args,
626
- status: "running",
627
- output: ""
628
- })
629
-
630
- const pattern = toolPatternFromArgs(call.args)
631
- const command = call.name === "bash" ? String(call.args?.command || "") : ""
632
- const risk = ["bash", "write", "edit", "task"].includes(call.name) ? 9 : 1
633
- let result
634
- try {
635
- const hookTransformed = await HookBus.toolBefore({ tool: call.name, args: call.args, sessionId, step })
636
- if (hookTransformed?.args) call.args = hookTransformed.args
637
-
638
- if (call.name === "question" && !allowQuestion) {
639
- call.args = {
640
- ...(call.args || {}),
641
- _allowQuestion: false
642
- }
643
- }
644
-
645
- await PermissionEngine.check({
646
- config: configState.config,
647
- sessionId,
648
- tool: call.name,
649
- mode,
650
- pattern,
651
- command,
652
- risk,
653
- reason: `tool call from model at step ${step}`
654
- })
655
-
656
- const tool = await ToolRegistry.get(call.name)
657
- result = !tool
658
- ? {
659
- name: call.name,
660
- status: "error",
661
- output: `unknown tool: ${call.name}`,
662
- error: `unknown tool: ${call.name}`
663
- }
664
- : await executeTool({
665
- tool,
666
- args: call.args,
667
- sessionId,
668
- turnId,
669
- context: {
670
- cwd,
671
- mode,
672
- delegateTask,
673
- signal,
674
- sessionId,
675
- turnId,
676
- config: configState.config,
677
- ...toolContext
678
- },
679
- signal
680
- })
681
- } catch (error) {
682
- result = {
683
- name: call.name,
684
- status: "error",
685
- output: error.message,
686
- error: error.message
687
- }
688
- }
689
-
690
- const hookAfterResult = await HookBus.toolAfter({ tool: call.name, args: call.args, result, sessionId, step })
691
- if (hookAfterResult?.result) result = hookAfterResult.result
692
-
693
- // Plan approval interception: if the tool returned planApproval metadata,
694
- // pause and ask the user to approve/reject the plan
695
- if (result.metadata?.planApproval) {
696
- const approval = await askPlanApproval({
697
- plan: result.metadata.plan || "",
698
- files: result.metadata.files || []
699
- })
700
- result = {
701
- ...result,
702
- output: approval.approved
703
- ? "User APPROVED the plan. Proceed with implementation."
704
- : `User REJECTED the plan. Feedback: ${approval.feedback || "no feedback provided"}`,
705
- metadata: { ...result.metadata, planApprovalResult: approval }
706
- }
707
- }
708
-
709
- await appendPart(sessionId, {
710
- type: "tool-call",
711
- messageId: userMessage.id,
712
- step,
713
- turnId,
714
- runPartId: runningPart.id,
715
- tool: call.name,
716
- args: call.args,
717
- status: result.status,
718
- output: result.output
719
- })
720
-
721
- return { call, result }
722
- }
723
-
724
- // Split into read-only (parallelizable) and write (serial) groups
725
- const readOnlyCalls = []
726
- const writeCalls = []
727
- for (const call of response.toolCalls) {
728
- if (READ_ONLY_TOOLS.has(call.name)) {
729
- readOnlyCalls.push(call)
730
- } else {
731
- writeCalls.push(call)
732
- }
733
- }
734
-
735
- // Execute read-only tools in parallel
736
- const callResults = new Map() // call.id → { call, result }
737
- if (readOnlyCalls.length > 0) {
738
- const settled = await Promise.allSettled(readOnlyCalls.map(executeOneCall))
739
- for (let si = 0; si < settled.length; si++) {
740
- const outcome = settled[si]
741
- if (outcome.status === "fulfilled") {
742
- callResults.set(outcome.value.call.id, outcome.value)
743
- } else {
744
- const failedCall = readOnlyCalls[si]
745
- callResults.set(failedCall.id, {
746
- call: failedCall,
747
- result: {
748
- name: failedCall.name,
749
- status: "error",
750
- output: `Tool execution failed: ${outcome.reason?.message || "unknown error"}`,
751
- error: outcome.reason?.message || "unknown error"
752
- }
753
- })
754
- }
755
- }
756
- }
757
-
758
- // Execute write tools serially
759
- for (const call of writeCalls) {
760
- const outcome = await executeOneCall(call)
761
- callResults.set(outcome.call.id, outcome)
762
- }
763
-
764
- // Collect results in original order
765
- for (const call of response.toolCalls) {
766
- const entry = callResults.get(call.id)
767
- if (entry) {
768
- toolEvents.push({
769
- step,
770
- name: entry.call.name,
771
- args: entry.call.args,
772
- ...entry.result
773
- })
774
- }
775
- }
776
-
777
- // --- Build native tool_use / tool_result messages ---
778
- // Assistant message: text + tool_use blocks
779
- const assistantContent = []
780
- if (response.text) {
781
- assistantContent.push({ type: "text", text: response.text })
782
- }
783
- for (const call of response.toolCalls) {
784
- assistantContent.push({
785
- type: "tool_use",
786
- id: call.id,
787
- name: call.name,
788
- input: call.args || {}
789
- })
790
- }
791
- await appendMessage(sessionId, "assistant", assistantContent, {
792
- mode,
793
- model,
794
- providerType,
795
- step,
796
- turnId,
797
- toolCallPhase: true
798
- })
799
-
800
- // User message: tool_result blocks (one per tool call, in order)
801
- const resultContent = []
802
- for (const call of response.toolCalls) {
803
- const entry = callResults.get(call.id)
804
- const output = entry?.result?.output || ""
805
- const isError = entry?.result?.status === "error"
806
- resultContent.push({
807
- type: "tool_result",
808
- tool_use_id: call.id,
809
- content: output,
810
- is_error: isError
811
- })
812
- }
813
- await appendMessage(sessionId, "user", resultContent, {
814
- mode,
815
- model,
816
- providerType,
817
- step,
818
- turnId,
819
- synthetic: true
820
- })
821
-
822
- // --- Doom loop detection: 3x identical tool call → inject warning ---
823
- for (const call of response.toolCalls) {
824
- doomTracker.push(`${call.name}::${JSON.stringify(call.args || {})}`)
825
- }
826
- if (doomTracker.length > 6) doomTracker.splice(0, doomTracker.length - 6)
827
- if (doomTracker.length >= 3) {
828
- const last3 = doomTracker.slice(-3)
829
- if (last3[0] === last3[1] && last3[1] === last3[2]) {
830
- await appendMessage(sessionId, "user", "[DOOM LOOP DETECTED] You called the same tool with identical arguments 3 times consecutively. STOP repeating this approach — it will not work. Try a completely different strategy, re-read the relevant files, or ask the user for guidance.", {
831
- mode, model, providerType, step, turnId, synthetic: true
832
- })
833
- doomTracker.length = 0
834
- }
835
- }
836
-
837
- // --- Soft step warning: alert model when nearing the limit ---
838
- if (step === maxSteps - 2) {
839
- await appendMessage(sessionId, "user", `[STEP LIMIT WARNING] You have used ${step} of ${maxSteps} steps. You are running low — wrap up your current work, summarize progress, and list any remaining tasks.`, {
840
- mode, model, providerType, step, turnId, synthetic: true
841
- })
842
- }
843
-
844
- await EventBus.emit({
845
- type: EVENT_TYPES.TURN_STEP_FINISH,
846
- sessionId,
847
- turnId,
848
- payload: { step, toolCalls: response.toolCalls.length }
849
- })
850
- }
851
-
852
- finalReply = "Reached max steps. Review tool outputs and continue in a new turn."
853
- await appendMessage(sessionId, "assistant", finalReply, {
854
- mode,
855
- model,
856
- providerType,
857
- turnId,
858
- maxSteps: true
859
- })
860
- await markTurnFinished(sessionId)
861
- await EventBus.emit({
862
- type: EVENT_TYPES.TURN_FINISH,
863
- sessionId,
864
- turnId,
865
- payload: { maxSteps: true, reply: finalReply }
866
- })
867
- return {
868
- sessionId,
869
- turnId,
870
- reply: finalReply,
871
- emittedText: emittedAnyText,
872
- context: lastContextMeter,
873
- usage,
874
- toolEvents
875
- }
876
- } catch (error) {
877
- await markSessionStatus(sessionId, "error")
878
- await markTurnFinished(sessionId, recoveryEnabled)
879
- if (recoveryEnabled) {
880
- await updateSession(sessionId, {
881
- retryMeta: {
882
- inProgress: false,
883
- turnId,
884
- failedAt: Date.now(),
885
- error: error.message
886
- }
887
- })
888
- }
889
- await EventBus.emit({
890
- type: EVENT_TYPES.TURN_ERROR,
891
- sessionId,
892
- turnId,
893
- payload: { error: error.message }
894
- })
895
- return {
896
- sessionId,
897
- turnId,
898
- reply: `provider error: ${error.message}`,
899
- emittedText: emittedAnyText,
900
- context: lastContextMeter,
901
- usage,
902
- toolEvents
903
- }
904
- }
905
- }
1
+ import { newId } from "../core/types.mjs"
2
+ import { EventBus } from "../core/events.mjs"
3
+ import { EVENT_TYPES } from "../core/constants.mjs"
4
+ import { requestProviderStream, countTokensProvider } from "../provider/router.mjs"
5
+ import { ToolRegistry } from "../tool/registry.mjs"
6
+ import { executeTool } from "../tool/executor.mjs"
7
+ import { PermissionEngine } from "../permission/engine.mjs"
8
+ import { createTaskDelegate } from "../orchestration/task-scheduler.mjs"
9
+ import { loadInstructions } from "./instruction-loader.mjs"
10
+ import { buildSystemPromptBlocks } from "./system-prompt.mjs"
11
+ import { detectProjectContext } from "./project-context.mjs"
12
+ import { renderRulesPrompt } from "../rules/load-rules.mjs"
13
+ import { loadProfile } from "../onboarding.mjs"
14
+ import { SkillRegistry } from "../skill/registry.mjs"
15
+ import {
16
+ touchSession,
17
+ appendMessage,
18
+ appendPart,
19
+ getConversationHistory,
20
+ markSessionStatus,
21
+ updateSession
22
+ } from "./store.mjs"
23
+ import { pendingRejections, markRejectionsConsumed } from "../review/rejection-queue.mjs"
24
+ import { isRecoveryEnabled, markTurnFinished, markTurnInProgress } from "./recovery.mjs"
25
+ import { HookBus, initHookBus } from "../plugin/hook-bus.mjs"
26
+ import { shouldCompact, compactSession, estimateTokenCount, modelContextLimit, contextUtilization, supportsNativeCompaction } from "./compaction.mjs"
27
+ import { createStreamRenderer } from "../theme/markdown.mjs"
28
+ import { paint } from "../theme/color.mjs"
29
+ import { saveCheckpoint } from "./checkpoint.mjs"
30
+ import { askPlanApproval } from "../tool/question-prompt.mjs"
31
+ import { createValidator } from "./task-validator.mjs"
32
+
33
+ // Max chars kept in active context per tool_result process output beyond this is truncated
34
+ const TOOL_RESULT_ACTIVE_LIMIT = 3000
35
+
36
+ const READ_ONLY_TOOLS = new Set([
37
+ "read", "glob", "grep", "list", "webfetch", "websearch", "codesearch", "background_output", "todowrite", "enter_plan"
38
+ ])
39
+
40
+ function addUsage(target, delta) {
41
+ target.input += delta.input || 0
42
+ target.output += delta.output || 0
43
+ target.cacheRead += delta.cacheRead || 0
44
+ target.cacheWrite += delta.cacheWrite || 0
45
+ }
46
+
47
+
48
+ async function buildSystemPrompt({ mode, model, cwd, agent = null, tools = [], skills = [], language = "en" }) {
49
+ // Assemble user instructions + rules (Layer 6)
50
+ const instructions = await loadInstructions(cwd)
51
+ const rules = await renderRulesPrompt(cwd)
52
+
53
+ // Inject user profile as a context block
54
+ const profile = await loadProfile()
55
+ let profileBlock = ""
56
+ if (profile && !profile.beginner) {
57
+ const lines = ["# User Profile", "", "Apply these preferences consistently in all code you write and suggestions you make:"]
58
+ if (profile.languages?.length) {
59
+ lines.push(`- Languages: ${profile.languages.join(", ")} prefer these when suggesting solutions or writing code`)
60
+ }
61
+ if (profile.tech_stack?.length) {
62
+ lines.push(`- Tech stack: ${profile.tech_stack.join(", ")} — use these frameworks/tools when relevant`)
63
+ }
64
+ if (profile.design_style) {
65
+ lines.push(`- Code style: ${profile.design_style}`)
66
+ const s = profile.design_style.toLowerCase()
67
+ if (s.includes("minimal") || s.startsWith("clean")) {
68
+ lines.push(" → Write minimal code. Avoid over-engineering, unnecessary abstractions, and verbose implementations. Prefer simple, direct solutions.")
69
+ } else if (s.startsWith("functional") || s.includes("pure function")) {
70
+ lines.push(" → Prefer pure functions and immutability. Use map/filter/reduce over loops. Avoid side effects and mutable state where possible.")
71
+ } else if (s.startsWith("object-oriented") || s.includes("class")) {
72
+ lines.push(" → Use OOP patterns — encapsulation, design patterns, well-defined classes. Organize code around objects and their behaviors.")
73
+ } else if (s.startsWith("performance") || s.includes("optimize")) {
74
+ lines.push(" Optimize for performance. Consider time/space complexity. Avoid unnecessary allocations and redundant operations.")
75
+ }
76
+ }
77
+ if (profile.extra_notes) {
78
+ lines.push(`- User requirements: ${profile.extra_notes} — treat these as hard requirements`)
79
+ }
80
+ profileBlock = lines.join("\n")
81
+ }
82
+
83
+ const userInstructions = [...instructions, rules, profileBlock].filter(Boolean).join("\n\n")
84
+
85
+ // Detect project context (framework, language, build tool, etc.)
86
+ const projectContext = await detectProjectContext(cwd)
87
+
88
+ // Build structured blocks for provider-level cache optimization
89
+ const result = await buildSystemPromptBlocks({ mode, model, cwd, agent, tools, skills, userInstructions, projectContext, language })
90
+ return result
91
+ }
92
+
93
+ function toolPatternFromArgs(args) {
94
+ if (!args || typeof args !== "object") return "*"
95
+ if (Array.isArray(args.changes) && args.changes.length > 0) {
96
+ return args.changes
97
+ .map((change) => change?.path)
98
+ .filter(Boolean)
99
+ .join(",")
100
+ }
101
+ return String(args.path || args.command || args.pattern || args.task_id || "*")
102
+ }
103
+
104
+ function normalizeMessageForCache(msg) {
105
+ const content = msg?.content
106
+ // For array content (image blocks, tool_use, tool_result), serialize to a stable string
107
+ if (Array.isArray(content)) {
108
+ const textParts = content
109
+ .filter((b) => b.type === "text")
110
+ .map((b) => b.text || "")
111
+ .join("\n")
112
+ const imageParts = content
113
+ .filter((b) => b.type === "image")
114
+ .map((b) => `[image:${b.path || "inline"}]`)
115
+ .join(" ")
116
+ const toolUseParts = content
117
+ .filter((b) => b.type === "tool_use")
118
+ .map((b) => `[tool_use:${b.name}:${b.id}]`)
119
+ .join(" ")
120
+ const toolResultParts = content
121
+ .filter((b) => b.type === "tool_result")
122
+ .map((b) => `[tool_result:${b.tool_use_id}:${String(b.content || "").slice(0, 100)}]`)
123
+ .join(" ")
124
+ const extras = [imageParts, toolUseParts, toolResultParts].filter(Boolean).join("\n")
125
+ return {
126
+ role: String(msg?.role || ""),
127
+ content: `${textParts}${extras ? "\n" + extras : ""}`
128
+ }
129
+ }
130
+ return {
131
+ role: String(msg?.role || ""),
132
+ content: String(content || "")
133
+ }
134
+ }
135
+
136
+ function isPrefixMessages(prefix, full) {
137
+ if (!Array.isArray(prefix) || !Array.isArray(full)) return false
138
+ if (prefix.length > full.length) return false
139
+ for (let i = 0; i < prefix.length; i++) {
140
+ if (prefix[i].role !== full[i].role || prefix[i].content !== full[i].content) return false
141
+ }
142
+ return true
143
+ }
144
+
145
+ export async function processTurnLoop({
146
+ prompt,
147
+ contentBlocks = null,
148
+ mode,
149
+ model,
150
+ providerType,
151
+ sessionId,
152
+ configState,
153
+ baseUrl = null,
154
+ apiKeyEnv = null,
155
+ depth = 0,
156
+ signal = null,
157
+ output = null,
158
+ subagent = null,
159
+ agent = null,
160
+ allowQuestion = true,
161
+ toolContext = {}
162
+ }) {
163
+ const cwd = process.cwd()
164
+ await initHookBus(cwd)
165
+
166
+ if (depth > 8) {
167
+ return {
168
+ sessionId,
169
+ turnId: newId("turn"),
170
+ reply: "task delegation depth exceeded",
171
+ emittedText: false,
172
+ context: null,
173
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
174
+ toolEvents: []
175
+ }
176
+ }
177
+
178
+ const turnId = newId("turn")
179
+ const configMaxSteps = Math.max(1, Number(configState.config.agent.max_steps || 128))
180
+ const maxSteps = (subagent?.maxTurns > 0) ? Math.min(configMaxSteps, subagent.maxTurns) : configMaxSteps
181
+ const verifyCompletion = configState.config.agent?.verify_completion !== false
182
+ const recoveryEnabled = isRecoveryEnabled(configState.config)
183
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
184
+ const toolEvents = []
185
+ const doomTracker = [] // recent tool call signatures for doom loop detection
186
+ let emittedAnyText = false
187
+ let lastContextMeter = null
188
+ let contextCachePoint = null
189
+ const thresholdRatio = Number(configState.config.session?.compaction_threshold_ratio ?? 0.7)
190
+ const thresholdMessages = Number(configState.config.session?.compaction_threshold_messages ?? 50)
191
+ const cachePointsEnabled = configState.config.session?.context_cache_points !== false
192
+ const useNativeCompaction = supportsNativeCompaction(providerType, model)
193
+ const nativeCompactionTrigger = useNativeCompaction ? Math.floor(modelContextLimit(model, configState) * thresholdRatio) : 0
194
+
195
+ await touchSession({
196
+ sessionId,
197
+ mode,
198
+ model,
199
+ providerType,
200
+ cwd,
201
+ status: "active",
202
+ title: subagent ? `${subagent.name}: ${prompt.slice(0, 60)}` : null
203
+ })
204
+
205
+ await EventBus.emit({
206
+ type: EVENT_TYPES.TURN_START,
207
+ sessionId,
208
+ turnId,
209
+ payload: { mode, model, providerType, prompt }
210
+ })
211
+
212
+ const queue = await pendingRejections(cwd)
213
+ const rejectionText = queue.length
214
+ ? [
215
+ "<review-rejections>",
216
+ ...queue.map((entry, index) => `${index + 1}. file=${entry.file} reason=${entry.reason} risk=${entry.riskScore ?? "unknown"}`),
217
+ "</review-rejections>",
218
+ "Address these rejected changes before introducing new risky edits."
219
+ ].join("\n")
220
+ : ""
221
+ const effectivePrompt = rejectionText ? `${prompt}\n\n${rejectionText}` : prompt
222
+
223
+ // If contentBlocks provided (e.g. images), build array content for the message.
224
+ // Prepend rejection text as a text block if needed.
225
+ let messageContent
226
+ if (contentBlocks && Array.isArray(contentBlocks)) {
227
+ const blocks = [...contentBlocks]
228
+ if (rejectionText) {
229
+ // Find the first text block and prepend rejection text
230
+ const textIdx = blocks.findIndex((b) => b.type === "text")
231
+ if (textIdx >= 0) {
232
+ blocks[textIdx] = { type: "text", text: `${blocks[textIdx].text}\n\n${rejectionText}` }
233
+ } else {
234
+ blocks.unshift({ type: "text", text: rejectionText })
235
+ }
236
+ }
237
+ messageContent = blocks
238
+ } else {
239
+ messageContent = effectivePrompt
240
+ }
241
+
242
+ const userMessage = await appendMessage(sessionId, "user", messageContent, {
243
+ mode,
244
+ model,
245
+ providerType,
246
+ turnId
247
+ })
248
+
249
+ await appendPart(sessionId, {
250
+ type: "turn-start",
251
+ messageId: userMessage.id,
252
+ turnId,
253
+ mode,
254
+ model,
255
+ providerType
256
+ })
257
+
258
+ let systemTools = await ToolRegistry.list({ mode, config: configState.config, cwd })
259
+ if (agent?.tools) {
260
+ systemTools = systemTools.filter((t) => agent.tools.includes(t.name))
261
+ }
262
+ const skills = SkillRegistry.isReady() ? SkillRegistry.listForSystemPrompt() : []
263
+ const language = configState.config.language || "en"
264
+ const systemPrompt = await buildSystemPrompt({ mode, model, cwd, agent, tools: systemTools, skills, language })
265
+ // systemPrompt = { text, blocks } — providers use blocks for cache optimization
266
+ const delegateTask = createTaskDelegate({
267
+ config: configState.config,
268
+ parentSessionId: sessionId,
269
+ model,
270
+ providerType,
271
+ runSubtask: async ({
272
+ prompt: subPrompt,
273
+ sessionId: subSessionId,
274
+ model: subModel,
275
+ providerType: subProvider,
276
+ subagent: resolvedSubagent,
277
+ allowQuestion: subAllowQuestion = false
278
+ }) => {
279
+ return processTurnLoop({
280
+ prompt: subPrompt,
281
+ mode: "agent",
282
+ model: subModel,
283
+ providerType: subProvider,
284
+ sessionId: subSessionId,
285
+ configState,
286
+ baseUrl,
287
+ apiKeyEnv,
288
+ depth: depth + 1,
289
+ signal,
290
+ subagent: resolvedSubagent,
291
+ allowQuestion: subAllowQuestion,
292
+ toolContext
293
+ })
294
+ }
295
+ })
296
+
297
+ const MAX_CONTINUES = 8
298
+ const MAX_TOTAL_CONTINUES = 24 // hard cap on total auto-continues per turn
299
+ let continueCount = 0
300
+ let totalContinueCount = 0
301
+ let nudgeCount = 0
302
+ let finalReply = ""
303
+ const sinkWrite = typeof output?.write === "function"
304
+ ? output.write
305
+ : () => {}
306
+ try {
307
+ for (let step = 1; step <= maxSteps; step++) {
308
+ await markTurnInProgress(sessionId, turnId, step, recoveryEnabled)
309
+ await EventBus.emit({
310
+ type: EVENT_TYPES.TURN_STEP_START,
311
+ sessionId,
312
+ turnId,
313
+ payload: { step }
314
+ })
315
+
316
+ let tools = await ToolRegistry.list({ mode, config: configState.config, cwd })
317
+ if (agent?.tools) {
318
+ tools = tools.filter((t) => agent.tools.includes(t.name))
319
+ }
320
+ let history = await getConversationHistory(sessionId, Number(configState.config.session.max_history || 30))
321
+
322
+ const normalizedHistory = history.map(normalizeMessageForCache)
323
+ let contextTokens = estimateTokenCount(normalizedHistory)
324
+ let contextFromCache = false
325
+
326
+ // Use real token counting API when available (includes system + tools + messages)
327
+ const realCount = await countTokensProvider({
328
+ configState, providerType, model,
329
+ system: systemPrompt, messages: history, tools,
330
+ baseUrl, apiKeyEnv
331
+ })
332
+ if (realCount != null) {
333
+ contextTokens = realCount
334
+ } else if (contextCachePoint && isPrefixMessages(contextCachePoint.messages, normalizedHistory)) {
335
+ const delta = normalizedHistory.slice(contextCachePoint.messages.length)
336
+ contextTokens = contextCachePoint.tokens + estimateTokenCount(delta)
337
+ contextFromCache = true
338
+ } else if (contextCachePoint) {
339
+ contextCachePoint = null
340
+ }
341
+ const contextLimit = modelContextLimit(model, configState)
342
+ const contextRatio = contextLimit > 0 ? Math.min(1, contextTokens / contextLimit) : 0
343
+ lastContextMeter = {
344
+ tokens: contextTokens,
345
+ limit: contextLimit,
346
+ ratio: contextRatio,
347
+ percent: Math.round(contextRatio * 100),
348
+ fromCache: contextFromCache
349
+ }
350
+
351
+ if (cachePointsEnabled && (step === 1 || contextRatio >= thresholdRatio)) {
352
+ contextCachePoint = {
353
+ messages: normalizedHistory,
354
+ tokens: contextTokens
355
+ }
356
+ await appendPart(sessionId, {
357
+ type: "context-cache-point",
358
+ turnId,
359
+ step,
360
+ tokenEstimate: contextTokens,
361
+ contextLimit,
362
+ contextRatio
363
+ })
364
+ await saveCheckpoint(sessionId, {
365
+ kind: "context-cache-point",
366
+ iteration: step,
367
+ turnId,
368
+ step,
369
+ tokenEstimate: contextTokens,
370
+ contextLimit,
371
+ contextRatio,
372
+ messageCount: normalizedHistory.length,
373
+ fromCache: contextFromCache
374
+ })
375
+ }
376
+
377
+ if (!useNativeCompaction && shouldCompact({
378
+ messages: normalizedHistory,
379
+ model,
380
+ thresholdMessages,
381
+ thresholdRatio,
382
+ configState,
383
+ realTokenCount: realCount != null ? contextTokens : null
384
+ })) {
385
+ const compactResult = await compactSession({
386
+ sessionId, model, providerType, configState, baseUrl, apiKeyEnv
387
+ })
388
+ if (compactResult.compacted) {
389
+ await EventBus.emit({ type: EVENT_TYPES.SESSION_COMPACTED, sessionId, turnId, payload: compactResult })
390
+ history = await getConversationHistory(sessionId, Number(configState.config.session.max_history || 30))
391
+ const compactedMeter = contextUtilization(history.map(normalizeMessageForCache), model, configState)
392
+ lastContextMeter = { ...compactedMeter, fromCache: false }
393
+ contextCachePoint = {
394
+ messages: history.map(normalizeMessageForCache),
395
+ tokens: compactedMeter.tokens
396
+ }
397
+ }
398
+ }
399
+
400
+ const messages = await HookBus.messagesTransform([...history])
401
+
402
+ let response
403
+ try {
404
+ const chunks = requestProviderStream({
405
+ configState,
406
+ providerType,
407
+ model,
408
+ system: systemPrompt,
409
+ messages,
410
+ tools,
411
+ baseUrl,
412
+ apiKeyEnv,
413
+ signal,
414
+ compaction: useNativeCompaction ? { trigger: nativeCompactionTrigger } : null
415
+ })
416
+ const textParts = []
417
+ const streamToolCalls = []
418
+ let streamUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
419
+ let streamStopReason = "end_turn"
420
+ const mdEnabled = configState.config.ui?.markdown_render !== false
421
+ const streamRenderer = mdEnabled ? createStreamRenderer() : null
422
+ let inThinking = false
423
+ let thinkingLineStart = true
424
+
425
+ for await (const chunk of chunks) {
426
+ if (chunk.type === "thinking") {
427
+ const text = chunk.content || ""
428
+ if (!inThinking) {
429
+ inThinking = true
430
+ thinkingLineStart = true
431
+ await EventBus.emit({ type: EVENT_TYPES.STREAM_THINKING_START, sessionId, turnId, payload: { step } })
432
+ sinkWrite(paint("●", "#666666") + " " + paint("Thinking", null, { dim: true }) + " " + paint("", null, { dim: true }) + "\n")
433
+ }
434
+ // 只在行首加缩进,避免 chunk 中间出现多余空格
435
+ const indented = text.replace(/^|\n/g, (m) => {
436
+ if (m === "\n") { thinkingLineStart = true; return "\n" }
437
+ if (thinkingLineStart) { thinkingLineStart = false; return " " }
438
+ return ""
439
+ })
440
+ // 如果 chunk 末尾是换行,标记下一个 chunk 需要缩进
441
+ if (text.endsWith("\n")) thinkingLineStart = true
442
+ sinkWrite(paint(indented, null, { dim: true }))
443
+ } else if (chunk.type === "text") {
444
+ if (inThinking) {
445
+ sinkWrite("\n")
446
+ inThinking = false
447
+ }
448
+ if (textParts.length === 0) {
449
+ await EventBus.emit({ type: EVENT_TYPES.STREAM_TEXT_START, sessionId, turnId, payload: { step } })
450
+ }
451
+ if (streamRenderer) {
452
+ const rendered = streamRenderer.push(chunk.content)
453
+ if (rendered) sinkWrite(rendered)
454
+ } else {
455
+ sinkWrite(chunk.content)
456
+ }
457
+ textParts.push(chunk.content)
458
+ } else if (chunk.type === "tool_call") {
459
+ if (inThinking) {
460
+ sinkWrite("\n")
461
+ inThinking = false
462
+ }
463
+ streamToolCalls.push(chunk.call)
464
+ } else if (chunk.type === "usage") {
465
+ streamUsage = chunk.usage
466
+ } else if (chunk.type === "compaction") {
467
+ sinkWrite(paint("\n ↻ context compacted by provider\n", "cyan", { dim: true }))
468
+ } else if (chunk.type === "stop") {
469
+ streamStopReason = chunk.reason || "end_turn"
470
+ }
471
+ }
472
+ if (inThinking) {
473
+ sinkWrite("\n")
474
+ }
475
+ if (streamRenderer) {
476
+ const tail = streamRenderer.flush()
477
+ if (tail) sinkWrite(tail)
478
+ }
479
+ if (textParts.length) {
480
+ sinkWrite("\n")
481
+ emittedAnyText = true
482
+ }
483
+
484
+ response = {
485
+ text: textParts.join(""),
486
+ toolCalls: streamToolCalls,
487
+ usage: streamUsage,
488
+ stopReason: streamStopReason
489
+ }
490
+ } catch (error) {
491
+ if (error.needsCompaction) {
492
+ const compactResult = await compactSession({
493
+ sessionId, model, providerType, configState, baseUrl, apiKeyEnv
494
+ })
495
+ if (compactResult.compacted) {
496
+ await EventBus.emit({ type: EVENT_TYPES.SESSION_COMPACTED, sessionId, turnId, payload: compactResult })
497
+ continue
498
+ }
499
+ }
500
+ await appendPart(sessionId, {
501
+ type: "provider-error",
502
+ messageId: userMessage.id,
503
+ step,
504
+ turnId,
505
+ error: error.message,
506
+ errorClass: error.errorClass || "unknown",
507
+ needsCompaction: Boolean(error.needsCompaction)
508
+ })
509
+ throw error
510
+ }
511
+
512
+ addUsage(usage, response.usage || {})
513
+
514
+ // Update context meter with real API total input tokens
515
+ // Anthropic: input_tokens is only non-cached portion; total = input + cacheRead + cacheWrite
516
+ // OpenAI: prompt_tokens is already the total
517
+ const u = response.usage || {}
518
+ const totalInput = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0)
519
+ if (totalInput > 0) {
520
+ const contextLimit = modelContextLimit(model, configState)
521
+ const contextRatio = contextLimit > 0 ? Math.min(1, totalInput / contextLimit) : 0
522
+ lastContextMeter = {
523
+ tokens: totalInput,
524
+ limit: contextLimit,
525
+ ratio: contextRatio,
526
+ percent: Math.round(contextRatio * 100),
527
+ fromCache: false,
528
+ cacheRead: u.cacheRead || 0,
529
+ cacheWrite: u.cacheWrite || 0,
530
+ inputUncached: u.input || 0
531
+ }
532
+ }
533
+
534
+ // Emit cumulative usage so status bar can update in real-time
535
+ await EventBus.emit({
536
+ type: EVENT_TYPES.TURN_USAGE_UPDATE,
537
+ sessionId,
538
+ turnId,
539
+ payload: { usage: { ...usage }, step, model, context: lastContextMeter }
540
+ })
541
+
542
+ // --- Auto-continue on output truncation (max_tokens) ---
543
+ if (response.stopReason === "max_tokens" && continueCount < MAX_CONTINUES && totalContinueCount < MAX_TOTAL_CONTINUES) {
544
+ continueCount++
545
+ totalContinueCount++
546
+ sinkWrite(paint(`\n ↳ output truncated, auto-continuing (${continueCount}/${MAX_CONTINUES})...\n`, "yellow", { dim: true }))
547
+
548
+ // Drop any tool calls with parse errors (truncated JSON from cutoff)
549
+ const validToolCalls = (response.toolCalls || []).filter(tc => !tc.args?.__parse_error)
550
+
551
+ // Save partial output as assistant message
552
+ const partialContent = []
553
+ if (response.text) {
554
+ partialContent.push({ type: "text", text: response.text })
555
+ }
556
+ for (const call of validToolCalls) {
557
+ partialContent.push({ type: "tool_use", id: call.id, name: call.name, input: call.args || {} })
558
+ }
559
+ if (partialContent.length) {
560
+ await appendMessage(sessionId, "assistant", partialContent.length === 1 && partialContent[0].type === "text"
561
+ ? partialContent[0].text
562
+ : partialContent, {
563
+ mode, model, providerType, step, turnId, truncated: true
564
+ })
565
+ }
566
+
567
+ // If there were valid tool calls, execute them and add results before continuing
568
+ if (validToolCalls.length) {
569
+ const resultContent = []
570
+ for (const call of validToolCalls) {
571
+ resultContent.push({
572
+ type: "tool_result",
573
+ tool_use_id: call.id,
574
+ content: "[truncated response — tool call acknowledged but output was cut off]",
575
+ is_error: true
576
+ })
577
+ }
578
+ await appendMessage(sessionId, "user", resultContent, {
579
+ mode, model, providerType, step, turnId, synthetic: true
580
+ })
581
+ }
582
+
583
+ // Inject continue prompt (localized) — include info about what was truncated
584
+ const hadTruncatedToolCalls = (response.toolCalls || []).some(tc => tc.args?.__parse_error)
585
+ const truncatedToolNames = (response.toolCalls || []).filter(tc => tc.args?.__parse_error).map(tc => tc.name).join(", ")
586
+ const toolHint = hadTruncatedToolCalls
587
+ ? (language === "zh"
588
+ ? `\n被截断的工具调用: ${truncatedToolNames}。请完整重新发起这些工具调用。如果是创建大文件,使用 write(mode="append") 分段追加;如果是修改已有文件的局部内容,使用 patch 按行号范围替换。`
589
+ : `\nTruncated tool calls: ${truncatedToolNames}. Re-issue these tool calls completely. For large file creation, use write(mode="append") to append in chunks. For modifying sections of existing files, use patch to replace by line range.`)
590
+ : ""
591
+ // Anchor: last 200 chars of truncated text so model knows exactly where to resume
592
+ const textTail = response.text ? response.text.slice(-200) : ""
593
+ const anchorHint = textTail
594
+ ? (language === "zh"
595
+ ? `\n[锚点] 上次输出末尾:...${textTail}`
596
+ : `\n[Anchor] Last output ended with: ...${textTail}`)
597
+ : ""
598
+ const continuePrompt = language === "zh"
599
+ ? `[输出被截断 ${continueCount}/${MAX_CONTINUES}] 你的上一条回复在输出 token 上限处被截断。请从你停止的地方精确继续,不要重复已经写过的内容。如果你正在执行工具调用,请完整重新发起。${toolHint}${anchorHint}`
600
+ : `[OUTPUT TRUNCATED ${continueCount}/${MAX_CONTINUES}] Your previous response was cut off at the output token limit. Continue EXACTLY from where you stopped. Do not repeat any content you already wrote. If you were in the middle of a tool call, re-issue it completely.${toolHint}${anchorHint}`
601
+ await appendMessage(sessionId, "user", continuePrompt,
602
+ { mode, model, providerType, step, turnId, synthetic: true }
603
+ )
604
+
605
+ // Don't consume a step for auto-continue
606
+ step--
607
+ continue
608
+ }
609
+ // Reset continue count on successful non-truncated response
610
+ continueCount = 0
611
+
612
+ if (!response.toolCalls?.length) {
613
+ // Enhanced task completion verification
614
+ if (verifyCompletion && nudgeCount < 2) {
615
+ try {
616
+ const validator = await createValidator({ cwd, configState })
617
+ const validationResult = await validator.validate({
618
+ todoState: toolContext._todoState
619
+ })
620
+
621
+ if (!validationResult.passed) {
622
+ nudgeCount++
623
+ const validationPrompt = language === "zh"
624
+ ? `[任务验证失败] 您报告任务已完成,但以下验证失败:\n\n${validationResult.message}\n\n请修复问题后再报告完成。`
625
+ : `[TASK VERIFICATION FAILED] You indicated completion, but verification failed:\n\n${validationResult.message}\n\nPlease fix the issues before declaring completion.`
626
+
627
+ await appendMessage(sessionId, "user", validationPrompt,
628
+ { mode, model, providerType, step, turnId, synthetic: true }
629
+ )
630
+ continue
631
+ }
632
+ } catch (validationError) {
633
+ sinkWrite(paint(`\n ⚠ Task validation skipped: ${validationError.message}\n`, "yellow", { dim: true }))
634
+ }
635
+ }
636
+
637
+ finalReply = (response.text || "").trim() || "No content returned from provider."
638
+ const assistant = await appendMessage(sessionId, "assistant", finalReply, {
639
+ mode,
640
+ model,
641
+ providerType,
642
+ step,
643
+ turnId
644
+ })
645
+ await appendPart(sessionId, {
646
+ type: "assistant-response",
647
+ messageId: assistant.id,
648
+ step,
649
+ turnId,
650
+ hasText: Boolean(finalReply)
651
+ })
652
+ await markSessionStatus(sessionId, "active")
653
+ if (queue.length) {
654
+ await markRejectionsConsumed(
655
+ queue.map((entry) => entry.id),
656
+ sessionId,
657
+ cwd
658
+ )
659
+ }
660
+ await markTurnFinished(sessionId, recoveryEnabled)
661
+ await EventBus.emit({
662
+ type: EVENT_TYPES.TURN_FINISH,
663
+ sessionId,
664
+ turnId,
665
+ payload: { step, reply: finalReply }
666
+ })
667
+ return {
668
+ sessionId,
669
+ turnId,
670
+ reply: finalReply,
671
+ emittedText: emittedAnyText,
672
+ context: lastContextMeter,
673
+ usage,
674
+ toolEvents
675
+ }
676
+ }
677
+
678
+ // --- Execute tool calls (read-only in parallel, write tools serially) ---
679
+ async function executeOneCall(call) {
680
+ const runningPart = await appendPart(sessionId, {
681
+ type: "tool-call",
682
+ messageId: userMessage.id,
683
+ step,
684
+ turnId,
685
+ tool: call.name,
686
+ args: call.args,
687
+ status: "running",
688
+ output: ""
689
+ })
690
+
691
+ const pattern = toolPatternFromArgs(call.args)
692
+ const command = call.name === "bash" ? String(call.args?.command || "") : ""
693
+ const risk = ["bash", "write", "edit", "task"].includes(call.name) ? 9 : 1
694
+ let result
695
+ try {
696
+ const hookTransformed = await HookBus.toolBefore({
697
+ tool: call.name,
698
+ toolName: call.name,
699
+ args: call.args,
700
+ sessionId,
701
+ step,
702
+ cwd,
703
+ mode
704
+ })
705
+ if (hookTransformed?.args) call.args = hookTransformed.args
706
+
707
+ if (call.name === "question" && !allowQuestion) {
708
+ call.args = {
709
+ ...(call.args || {}),
710
+ _allowQuestion: false
711
+ }
712
+ }
713
+
714
+ // Plan mode enforcement: block write tools when _planMode is active
715
+ if (toolContext._planMode && !READ_ONLY_TOOLS.has(call.name) && call.name !== "exit_plan") {
716
+ result = {
717
+ name: call.name,
718
+ status: "error",
719
+ output: `[PLAN MODE] Cannot execute '${call.name}' in plan mode. Finish your plan outline and call exit_plan to present it for user approval.`
720
+ }
721
+ } else {
722
+ await PermissionEngine.check({
723
+ config: configState.config,
724
+ sessionId,
725
+ tool: call.name,
726
+ mode,
727
+ pattern,
728
+ command,
729
+ risk,
730
+ reason: `tool call from model at step ${step}`
731
+ })
732
+
733
+ const tool = await ToolRegistry.get(call.name)
734
+ result = !tool
735
+ ? {
736
+ name: call.name,
737
+ status: "error",
738
+ output: `unknown tool: ${call.name}`,
739
+ error: `unknown tool: ${call.name}`
740
+ }
741
+ : await executeTool({
742
+ tool,
743
+ args: call.args,
744
+ sessionId,
745
+ turnId,
746
+ context: {
747
+ cwd,
748
+ mode,
749
+ delegateTask,
750
+ signal,
751
+ sessionId,
752
+ turnId,
753
+ config: configState.config,
754
+ ...toolContext
755
+ },
756
+ signal
757
+ })
758
+ }
759
+ } catch (error) {
760
+ result = {
761
+ name: call.name,
762
+ status: "error",
763
+ output: error.message,
764
+ error: error.message
765
+ }
766
+ }
767
+
768
+ // Sync _planMode back to toolContext after enter_plan / exit_plan
769
+ if (call.name === "enter_plan" && result.status !== "error") {
770
+ toolContext._planMode = true
771
+ } else if (call.name === "exit_plan" && result.status !== "error") {
772
+ toolContext._planMode = false
773
+ }
774
+
775
+ const hookAfterResult = await HookBus.toolAfter({
776
+ tool: call.name,
777
+ toolName: call.name,
778
+ args: call.args,
779
+ result,
780
+ sessionId,
781
+ step,
782
+ cwd,
783
+ mode
784
+ })
785
+ if (hookAfterResult?.result) result = hookAfterResult.result
786
+
787
+ // Plan approval interception: if the tool returned planApproval metadata,
788
+ // pause and ask the user to approve/reject the plan
789
+ if (result.metadata?.planApproval) {
790
+ const approval = await askPlanApproval({
791
+ plan: result.metadata.plan || "",
792
+ files: result.metadata.files || []
793
+ })
794
+ result = {
795
+ ...result,
796
+ output: approval.approved
797
+ ? "User APPROVED the plan. Proceed with implementation immediately."
798
+ : approval.requestChanges
799
+ ? `User requested changes to the plan. Feedback: ${approval.feedback || "no specific feedback"}. Revise your plan and call exit_plan again with the updated plan.`
800
+ : `User REJECTED the plan. Feedback: ${approval.feedback || "no feedback provided"}. Do not proceed — the plan has been cancelled.`,
801
+ metadata: { ...result.metadata, planApprovalResult: approval }
802
+ }
803
+ }
804
+
805
+ await appendPart(sessionId, {
806
+ type: "tool-call",
807
+ messageId: userMessage.id,
808
+ step,
809
+ turnId,
810
+ runPartId: runningPart.id,
811
+ tool: call.name,
812
+ args: call.args,
813
+ status: result.status,
814
+ output: result.output
815
+ })
816
+
817
+ return { call, result }
818
+ }
819
+
820
+ // Split into read-only (parallelizable) and write (serial) groups
821
+ const readOnlyCalls = []
822
+ const writeCalls = []
823
+ for (const call of response.toolCalls) {
824
+ if (READ_ONLY_TOOLS.has(call.name)) {
825
+ readOnlyCalls.push(call)
826
+ } else {
827
+ writeCalls.push(call)
828
+ }
829
+ }
830
+
831
+ // Execute read-only tools in parallel
832
+ const callResults = new Map() // call.id → { call, result }
833
+ if (readOnlyCalls.length > 0) {
834
+ const settled = await Promise.allSettled(readOnlyCalls.map(executeOneCall))
835
+ for (let si = 0; si < settled.length; si++) {
836
+ const outcome = settled[si]
837
+ if (outcome.status === "fulfilled") {
838
+ callResults.set(outcome.value.call.id, outcome.value)
839
+ } else {
840
+ const failedCall = readOnlyCalls[si]
841
+ callResults.set(failedCall.id, {
842
+ call: failedCall,
843
+ result: {
844
+ name: failedCall.name,
845
+ status: "error",
846
+ output: `Tool execution failed: ${outcome.reason?.message || "unknown error"}`,
847
+ error: outcome.reason?.message || "unknown error"
848
+ }
849
+ })
850
+ }
851
+ }
852
+ }
853
+
854
+ // Execute write tools serially
855
+ for (const call of writeCalls) {
856
+ const outcome = await executeOneCall(call)
857
+ callResults.set(outcome.call.id, outcome)
858
+ }
859
+
860
+ // Collect results in original order
861
+ for (const call of response.toolCalls) {
862
+ const entry = callResults.get(call.id)
863
+ if (entry) {
864
+ toolEvents.push({
865
+ step,
866
+ name: entry.call.name,
867
+ args: entry.call.args,
868
+ ...entry.result
869
+ })
870
+ }
871
+ }
872
+
873
+ // --- Build native tool_use / tool_result messages ---
874
+ // Assistant message: text + tool_use blocks
875
+ const assistantContent = []
876
+ if (response.text) {
877
+ assistantContent.push({ type: "text", text: response.text })
878
+ }
879
+ for (const call of response.toolCalls) {
880
+ assistantContent.push({
881
+ type: "tool_use",
882
+ id: call.id,
883
+ name: call.name,
884
+ input: call.args || {}
885
+ })
886
+ }
887
+ await appendMessage(sessionId, "assistant", assistantContent, {
888
+ mode,
889
+ model,
890
+ providerType,
891
+ step,
892
+ turnId,
893
+ toolCallPhase: true
894
+ })
895
+
896
+ // User message: tool_result blocks (one per tool call, in order)
897
+ // Process output beyond TOOL_RESULT_ACTIVE_LIMIT is truncated to keep context lean
898
+ const resultContent = []
899
+ for (const call of response.toolCalls) {
900
+ const entry = callResults.get(call.id)
901
+ const rawOutput = entry?.result?.output || ""
902
+ const isError = entry?.result?.status === "error"
903
+ const content = rawOutput.length > TOOL_RESULT_ACTIVE_LIMIT
904
+ ? `${rawOutput.slice(0, TOOL_RESULT_ACTIVE_LIMIT)}\n[...过程输出已截断,共 ${rawOutput.length} 字符,仅保留前 ${TOOL_RESULT_ACTIVE_LIMIT} 字符]`
905
+ : rawOutput
906
+ resultContent.push({
907
+ type: "tool_result",
908
+ tool_use_id: call.id,
909
+ content,
910
+ is_error: isError
911
+ })
912
+ }
913
+ await appendMessage(sessionId, "user", resultContent, {
914
+ mode,
915
+ model,
916
+ providerType,
917
+ step,
918
+ turnId,
919
+ synthetic: true
920
+ })
921
+
922
+ // --- Doom loop detection: 3x identical tool call → inject warning ---
923
+ for (const call of response.toolCalls) {
924
+ doomTracker.push(`${call.name}::${JSON.stringify(call.args || {})}`)
925
+ }
926
+ if (doomTracker.length > 6) doomTracker.splice(0, doomTracker.length - 6)
927
+ if (doomTracker.length >= 3) {
928
+ const last3 = doomTracker.slice(-3)
929
+ if (last3[0] === last3[1] && last3[1] === last3[2]) {
930
+ await appendMessage(sessionId, "user", "[DOOM LOOP DETECTED] You called the same tool with identical arguments 3 times consecutively. STOP repeating this approach — it will not work. Try a completely different strategy, re-read the relevant files, or ask the user for guidance.", {
931
+ mode, model, providerType, step, turnId, synthetic: true
932
+ })
933
+ doomTracker.length = 0
934
+ }
935
+ }
936
+
937
+ // --- Soft step warning: alert model when nearing the limit ---
938
+ if (step === maxSteps - 2) {
939
+ await appendMessage(sessionId, "user", `[STEP LIMIT WARNING] You have used ${step} of ${maxSteps} steps. You are running low — wrap up your current work, summarize progress, and list any remaining tasks.`, {
940
+ mode, model, providerType, step, turnId, synthetic: true
941
+ })
942
+ }
943
+
944
+ await EventBus.emit({
945
+ type: EVENT_TYPES.TURN_STEP_FINISH,
946
+ sessionId,
947
+ turnId,
948
+ payload: { step, toolCalls: response.toolCalls.length }
949
+ })
950
+ }
951
+
952
+ finalReply = "Reached max steps. Review tool outputs and continue in a new turn."
953
+ await appendMessage(sessionId, "assistant", finalReply, {
954
+ mode,
955
+ model,
956
+ providerType,
957
+ turnId,
958
+ maxSteps: true
959
+ })
960
+ await markTurnFinished(sessionId, recoveryEnabled)
961
+ await EventBus.emit({
962
+ type: EVENT_TYPES.TURN_FINISH,
963
+ sessionId,
964
+ turnId,
965
+ payload: { maxSteps: true, reply: finalReply }
966
+ })
967
+ return {
968
+ sessionId,
969
+ turnId,
970
+ reply: finalReply,
971
+ emittedText: emittedAnyText,
972
+ context: lastContextMeter,
973
+ usage,
974
+ toolEvents
975
+ }
976
+ } catch (error) {
977
+ await markSessionStatus(sessionId, "error")
978
+ await markTurnFinished(sessionId, recoveryEnabled)
979
+ if (recoveryEnabled) {
980
+ await updateSession(sessionId, {
981
+ retryMeta: {
982
+ inProgress: false,
983
+ turnId,
984
+ failedAt: Date.now(),
985
+ error: error.message
986
+ }
987
+ })
988
+ }
989
+ await EventBus.emit({
990
+ type: EVENT_TYPES.TURN_ERROR,
991
+ sessionId,
992
+ turnId,
993
+ payload: { error: error.message }
994
+ })
995
+ return {
996
+ sessionId,
997
+ turnId,
998
+ reply: `provider error: ${error.message}`,
999
+ emittedText: emittedAnyText,
1000
+ context: lastContextMeter,
1001
+ usage,
1002
+ toolEvents
1003
+ }
1004
+ }
1005
+ }