@kkelly-offical/kkcode 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +445 -0
- package/package.json +46 -0
- package/src/agent/agent.mjs +170 -0
- package/src/agent/custom-agent-loader.mjs +158 -0
- package/src/agent/generator.mjs +115 -0
- package/src/agent/prompt/architect.txt +36 -0
- package/src/agent/prompt/build-fixer.txt +71 -0
- package/src/agent/prompt/build.txt +101 -0
- package/src/agent/prompt/compaction.txt +12 -0
- package/src/agent/prompt/explore.txt +29 -0
- package/src/agent/prompt/guide.txt +40 -0
- package/src/agent/prompt/longagent.txt +178 -0
- package/src/agent/prompt/plan.txt +50 -0
- package/src/agent/prompt/researcher.txt +23 -0
- package/src/agent/prompt/reviewer.txt +44 -0
- package/src/agent/prompt/security-reviewer.txt +62 -0
- package/src/agent/prompt/tdd-guide.txt +84 -0
- package/src/agent/prompt/title.txt +8 -0
- package/src/command/custom-commands.mjs +57 -0
- package/src/commands/agent.mjs +71 -0
- package/src/commands/audit.mjs +77 -0
- package/src/commands/background.mjs +86 -0
- package/src/commands/chat.mjs +114 -0
- package/src/commands/command.mjs +41 -0
- package/src/commands/config.mjs +44 -0
- package/src/commands/doctor.mjs +148 -0
- package/src/commands/hook.mjs +29 -0
- package/src/commands/init.mjs +141 -0
- package/src/commands/longagent.mjs +100 -0
- package/src/commands/mcp.mjs +89 -0
- package/src/commands/permission.mjs +36 -0
- package/src/commands/prompt.mjs +42 -0
- package/src/commands/review.mjs +266 -0
- package/src/commands/rule.mjs +34 -0
- package/src/commands/session.mjs +235 -0
- package/src/commands/theme.mjs +98 -0
- package/src/commands/usage.mjs +91 -0
- package/src/config/defaults.mjs +195 -0
- package/src/config/import-config.mjs +76 -0
- package/src/config/load-config.mjs +76 -0
- package/src/config/schema.mjs +509 -0
- package/src/context.mjs +40 -0
- package/src/core/constants.mjs +46 -0
- package/src/core/errors.mjs +57 -0
- package/src/core/events.mjs +29 -0
- package/src/core/types.mjs +57 -0
- package/src/github/api.mjs +78 -0
- package/src/github/auth.mjs +286 -0
- package/src/github/flow.mjs +298 -0
- package/src/github/workspace.mjs +212 -0
- package/src/index.mjs +82 -0
- package/src/knowledge/api-design.txt +9 -0
- package/src/knowledge/cpp.txt +10 -0
- package/src/knowledge/docker.txt +10 -0
- package/src/knowledge/dotnet.txt +9 -0
- package/src/knowledge/electron.txt +10 -0
- package/src/knowledge/flutter.txt +10 -0
- package/src/knowledge/go.txt +9 -0
- package/src/knowledge/graphql.txt +10 -0
- package/src/knowledge/java.txt +9 -0
- package/src/knowledge/kotlin.txt +10 -0
- package/src/knowledge/loader.mjs +125 -0
- package/src/knowledge/next.txt +8 -0
- package/src/knowledge/node.txt +8 -0
- package/src/knowledge/nuxt.txt +9 -0
- package/src/knowledge/php.txt +10 -0
- package/src/knowledge/python.txt +10 -0
- package/src/knowledge/react-native.txt +10 -0
- package/src/knowledge/react.txt +9 -0
- package/src/knowledge/ruby.txt +11 -0
- package/src/knowledge/rust.txt +9 -0
- package/src/knowledge/svelte.txt +9 -0
- package/src/knowledge/swift.txt +10 -0
- package/src/knowledge/tailwind.txt +10 -0
- package/src/knowledge/testing.txt +8 -0
- package/src/knowledge/typescript.txt +8 -0
- package/src/knowledge/vue.txt +9 -0
- package/src/mcp/client-http.mjs +157 -0
- package/src/mcp/client-sse.mjs +286 -0
- package/src/mcp/client-stdio.mjs +451 -0
- package/src/mcp/registry.mjs +394 -0
- package/src/mcp/stdio-framing.mjs +127 -0
- package/src/orchestration/background-manager.mjs +358 -0
- package/src/orchestration/background-worker.mjs +245 -0
- package/src/orchestration/longagent-manager.mjs +116 -0
- package/src/orchestration/stage-scheduler.mjs +489 -0
- package/src/orchestration/subagent-router.mjs +62 -0
- package/src/orchestration/task-scheduler.mjs +74 -0
- package/src/permission/engine.mjs +92 -0
- package/src/permission/exec-policy.mjs +372 -0
- package/src/permission/prompt.mjs +39 -0
- package/src/permission/rules.mjs +120 -0
- package/src/permission/workspace-trust.mjs +44 -0
- package/src/plugin/builtin-hooks/console-warn.mjs +41 -0
- package/src/plugin/builtin-hooks/extract-patterns.mjs +75 -0
- package/src/plugin/builtin-hooks/post-edit-format.mjs +57 -0
- package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +61 -0
- package/src/plugin/builtin-hooks/strategic-compaction.mjs +38 -0
- package/src/plugin/hook-bus.mjs +154 -0
- package/src/provider/anthropic.mjs +389 -0
- package/src/provider/ollama.mjs +236 -0
- package/src/provider/openai-compatible.mjs +1 -0
- package/src/provider/openai.mjs +339 -0
- package/src/provider/retry-policy.mjs +68 -0
- package/src/provider/router.mjs +228 -0
- package/src/provider/sse.mjs +91 -0
- package/src/repl.mjs +2929 -0
- package/src/review/diff-parser.mjs +36 -0
- package/src/review/rejection-queue.mjs +62 -0
- package/src/review/review-store.mjs +21 -0
- package/src/review/risk-score.mjs +61 -0
- package/src/rules/load-rules.mjs +64 -0
- package/src/runtime.mjs +1 -0
- package/src/session/checkpoint.mjs +239 -0
- package/src/session/compaction.mjs +276 -0
- package/src/session/engine.mjs +225 -0
- package/src/session/instinct-manager.mjs +172 -0
- package/src/session/instruction-loader.mjs +25 -0
- package/src/session/longagent-plan.mjs +329 -0
- package/src/session/longagent-scaffold.mjs +100 -0
- package/src/session/longagent.mjs +1462 -0
- package/src/session/loop.mjs +905 -0
- package/src/session/memory-loader.mjs +75 -0
- package/src/session/project-context.mjs +367 -0
- package/src/session/prompt/anthropic.txt +151 -0
- package/src/session/prompt/beast.txt +37 -0
- package/src/session/prompt/max-steps.txt +6 -0
- package/src/session/prompt/plan.txt +9 -0
- package/src/session/prompt/qwen.txt +46 -0
- package/src/session/prompt-loader.mjs +18 -0
- package/src/session/recovery.mjs +52 -0
- package/src/session/store.mjs +503 -0
- package/src/session/system-prompt.mjs +260 -0
- package/src/session/task-validator.mjs +266 -0
- package/src/session/usability-gates.mjs +379 -0
- package/src/skill/builtin/backend-patterns.mjs +123 -0
- package/src/skill/builtin/commit.mjs +64 -0
- package/src/skill/builtin/debug.mjs +45 -0
- package/src/skill/builtin/frontend-patterns.mjs +120 -0
- package/src/skill/builtin/frontend.mjs +188 -0
- package/src/skill/builtin/init.mjs +220 -0
- package/src/skill/builtin/review.mjs +49 -0
- package/src/skill/builtin/security-checklist.mjs +80 -0
- package/src/skill/builtin/tdd.mjs +54 -0
- package/src/skill/generator.mjs +113 -0
- package/src/skill/registry.mjs +336 -0
- package/src/storage/audit-store.mjs +83 -0
- package/src/storage/event-log.mjs +82 -0
- package/src/storage/ghost-commit-store.mjs +235 -0
- package/src/storage/json-store.mjs +53 -0
- package/src/storage/paths.mjs +148 -0
- package/src/theme/color.mjs +64 -0
- package/src/theme/default-theme.mjs +29 -0
- package/src/theme/load-theme.mjs +71 -0
- package/src/theme/markdown.mjs +135 -0
- package/src/theme/schema.mjs +45 -0
- package/src/theme/status-bar.mjs +158 -0
- package/src/tool/audit-wrapper.mjs +38 -0
- package/src/tool/edit-transaction.mjs +126 -0
- package/src/tool/executor.mjs +109 -0
- package/src/tool/file-lock-manager.mjs +85 -0
- package/src/tool/git-auto.mjs +545 -0
- package/src/tool/git-full-auto.mjs +478 -0
- package/src/tool/image-util.mjs +276 -0
- package/src/tool/prompt/background_cancel.txt +1 -0
- package/src/tool/prompt/background_output.txt +1 -0
- package/src/tool/prompt/bash.txt +71 -0
- package/src/tool/prompt/codesearch.txt +18 -0
- package/src/tool/prompt/edit.txt +27 -0
- package/src/tool/prompt/enter_plan.txt +74 -0
- package/src/tool/prompt/exit_plan.txt +62 -0
- package/src/tool/prompt/glob.txt +33 -0
- package/src/tool/prompt/grep.txt +43 -0
- package/src/tool/prompt/list.txt +8 -0
- package/src/tool/prompt/multiedit.txt +20 -0
- package/src/tool/prompt/notebookedit.txt +21 -0
- package/src/tool/prompt/patch.txt +24 -0
- package/src/tool/prompt/question.txt +44 -0
- package/src/tool/prompt/read.txt +40 -0
- package/src/tool/prompt/task.txt +83 -0
- package/src/tool/prompt/todowrite.txt +117 -0
- package/src/tool/prompt/webfetch.txt +38 -0
- package/src/tool/prompt/websearch.txt +43 -0
- package/src/tool/prompt/write.txt +38 -0
- package/src/tool/prompt-loader.mjs +18 -0
- package/src/tool/question-prompt.mjs +86 -0
- package/src/tool/registry.mjs +1309 -0
- package/src/tool/task-tool.mjs +28 -0
- package/src/ui/activity-renderer.mjs +410 -0
- package/src/ui/repl-dashboard.mjs +357 -0
- package/src/usage/pricing.mjs +121 -0
- package/src/usage/usage-meter.mjs +113 -0
- package/src/util/git.mjs +496 -0
- package/src/util/template.mjs +10 -0
- package/src/util/yaml.mjs +100 -0
|
@@ -0,0 +1,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 { 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
|
+
}
|