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