@kkelly-offical/kkcode 0.1.3 → 0.1.6
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/README.md +110 -172
- package/package.json +46 -46
- package/src/agent/agent.mjs +41 -0
- package/src/agent/prompt/frontend-designer.txt +58 -0
- package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
- package/src/agent/prompt/longagent-coding-agent.txt +37 -0
- package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
- package/src/agent/prompt/longagent-preview-agent.txt +63 -0
- package/src/config/defaults.mjs +260 -195
- package/src/config/schema.mjs +71 -6
- package/src/core/constants.mjs +91 -46
- package/src/index.mjs +1 -1
- package/src/knowledge/frontend-aesthetics.txt +39 -0
- package/src/knowledge/loader.mjs +2 -1
- package/src/knowledge/tailwind.txt +12 -3
- package/src/mcp/client-http.mjs +141 -157
- package/src/mcp/client-sse.mjs +288 -286
- package/src/mcp/client-stdio.mjs +533 -451
- package/src/mcp/constants.mjs +2 -0
- package/src/mcp/registry.mjs +479 -394
- package/src/mcp/stdio-framing.mjs +133 -127
- package/src/mcp/tool-result.mjs +24 -0
- package/src/observability/index.mjs +42 -0
- package/src/observability/metrics.mjs +137 -0
- package/src/observability/tracer.mjs +137 -0
- package/src/orchestration/background-manager.mjs +372 -358
- package/src/orchestration/background-worker.mjs +305 -245
- package/src/orchestration/longagent-manager.mjs +171 -116
- package/src/orchestration/stage-scheduler.mjs +728 -489
- package/src/permission/exec-policy.mjs +9 -11
- package/src/provider/anthropic.mjs +1 -0
- package/src/provider/openai.mjs +340 -339
- package/src/provider/retry-policy.mjs +68 -68
- package/src/provider/router.mjs +241 -228
- package/src/provider/sse.mjs +104 -91
- package/src/repl.mjs +1 -1
- package/src/session/checkpoint.mjs +66 -3
- package/src/session/engine.mjs +227 -225
- package/src/session/longagent-4stage.mjs +460 -0
- package/src/session/longagent-hybrid.mjs +1081 -0
- package/src/session/longagent-plan.mjs +365 -329
- package/src/session/longagent-project-memory.mjs +53 -0
- package/src/session/longagent-scaffold.mjs +291 -100
- package/src/session/longagent-task-bus.mjs +54 -0
- package/src/session/longagent-utils.mjs +472 -0
- package/src/session/longagent.mjs +884 -1462
- package/src/session/project-context.mjs +30 -0
- package/src/session/store.mjs +510 -503
- package/src/session/task-validator.mjs +4 -3
- package/src/skill/builtin/design.mjs +76 -0
- package/src/skill/builtin/frontend.mjs +8 -0
- package/src/skill/registry.mjs +390 -336
- package/src/storage/ghost-commit-store.mjs +18 -8
- package/src/tool/executor.mjs +11 -0
- package/src/tool/git-auto.mjs +0 -19
- package/src/tool/registry.mjs +71 -37
- package/src/ui/activity-renderer.mjs +664 -410
- package/src/util/git.mjs +23 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LongAgent Hybrid 模式
|
|
3
|
+
* 融合 4-Stage 的只读探索/规划/调试回滚 + Parallel 的脚手架/并行执行/门控
|
|
4
|
+
*
|
|
5
|
+
* 流程: H0:Intake → H1:Preview → H2:Blueprint → H2.5:Git → H3:Scaffold → H4:Coding(并行) → H5:Debugging(回滚) → H5.5:Validation → H6:Gates → H7:GitMerge
|
|
6
|
+
*/
|
|
7
|
+
import { LongAgentManager } from "../orchestration/longagent-manager.mjs"
|
|
8
|
+
import { processTurnLoop } from "./loop.mjs"
|
|
9
|
+
import { markSessionStatus } from "./store.mjs"
|
|
10
|
+
import { EventBus } from "../core/events.mjs"
|
|
11
|
+
import { EVENT_TYPES, LONGAGENT_4STAGE_STAGES } from "../core/constants.mjs"
|
|
12
|
+
import { saveCheckpoint, loadCheckpoint, saveTaskCheckpoint, loadTaskCheckpoints, cleanupCheckpoints } from "./checkpoint.mjs"
|
|
13
|
+
import { getAgent } from "../agent/agent.mjs"
|
|
14
|
+
import { runStageBarrier } from "../orchestration/stage-scheduler.mjs"
|
|
15
|
+
import { runScaffoldPhase } from "./longagent-scaffold.mjs"
|
|
16
|
+
import {
|
|
17
|
+
runUsabilityGates,
|
|
18
|
+
hasGatePreferences,
|
|
19
|
+
getGatePreferences,
|
|
20
|
+
saveGatePreferences,
|
|
21
|
+
buildGatePromptText,
|
|
22
|
+
parseGateSelection
|
|
23
|
+
} from "./usability-gates.mjs"
|
|
24
|
+
import { runIntakeDialogue, validateAndNormalizeStagePlan, defaultStagePlan } from "./longagent-plan.mjs"
|
|
25
|
+
import { createValidator } from "./task-validator.mjs"
|
|
26
|
+
import { detectStageComplete, detectReturnToCoding, buildStageWrapper } from "./longagent-4stage.mjs"
|
|
27
|
+
import {
|
|
28
|
+
isComplete,
|
|
29
|
+
isLikelyActionableObjective,
|
|
30
|
+
mergeCappedFileChanges,
|
|
31
|
+
stageProgressStats,
|
|
32
|
+
summarizeGateFailures,
|
|
33
|
+
LONGAGENT_FILE_CHANGES_LIMIT,
|
|
34
|
+
createStuckTracker,
|
|
35
|
+
classifyError,
|
|
36
|
+
ERROR_CATEGORIES,
|
|
37
|
+
createSemanticErrorTracker,
|
|
38
|
+
createDegradationChain,
|
|
39
|
+
generateRecoverySuggestions,
|
|
40
|
+
stripFence,
|
|
41
|
+
parseJsonLoose
|
|
42
|
+
} from "./longagent-utils.mjs"
|
|
43
|
+
import { TaskBus } from "./longagent-task-bus.mjs"
|
|
44
|
+
import { loadProjectMemory, saveProjectMemory, memoryToContext, parseMemoryFromPreview } from "./longagent-project-memory.mjs"
|
|
45
|
+
import * as git from "../util/git.mjs"
|
|
46
|
+
|
|
47
|
+
// Checkpoint 结构校验
|
|
48
|
+
function validateCheckpoint(cp) {
|
|
49
|
+
if (!cp || !cp.stagePlan || !Array.isArray(cp.stagePlan.stages)) return false
|
|
50
|
+
if (typeof cp.stageIndex !== "number" || cp.stageIndex < 0) return false
|
|
51
|
+
if (cp.stageIndex > cp.stagePlan.stages.length) return false
|
|
52
|
+
// Verify the previous stage exists for task checkpoint loading
|
|
53
|
+
if (cp.stageIndex > 0 && !cp.stagePlan.stages[cp.stageIndex - 1]) return false
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Gate 修复策略路由 (Phase 8)
|
|
58
|
+
function getGateFixStrategy(failures) {
|
|
59
|
+
const gateTypes = (failures || []).map(f => f.gate).filter(Boolean)
|
|
60
|
+
if (gateTypes.includes("test")) return { agent: "debugging-agent", prefix: "Analyze test failures and fix:" }
|
|
61
|
+
if (gateTypes.every(g => g === "build")) return { agent: "coding-agent", prefix: "Fix build errors:" }
|
|
62
|
+
if (gateTypes.every(g => g === "lint")) return { autoFix: "npx eslint --fix .", agent: "coding-agent", prefix: "Fix lint errors:" }
|
|
63
|
+
return { agent: "coding-agent", prefix: "Fix gate failures:" }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// #13 上下文压缩
|
|
67
|
+
async function compressContext(text, limit, { model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, toolContext }) {
|
|
68
|
+
if (text.length <= limit) return text
|
|
69
|
+
const out = await processTurnLoop({
|
|
70
|
+
prompt: [
|
|
71
|
+
`Compress the following engineering context to max ${Math.round(limit * 0.6)} characters.`,
|
|
72
|
+
"Preserve ONLY:",
|
|
73
|
+
"- Concrete decisions made (technology choices, architecture patterns, API contracts)",
|
|
74
|
+
"- File paths and function signatures that were created or modified",
|
|
75
|
+
"- Error messages and their resolutions",
|
|
76
|
+
"- Cross-task dependencies and integration points",
|
|
77
|
+
"- Test results (pass/fail with specific failure reasons)",
|
|
78
|
+
"Discard: exploration logs, verbose tool output, repeated information, reasoning chains.",
|
|
79
|
+
"Output the compressed context directly — no preamble or explanation.",
|
|
80
|
+
"",
|
|
81
|
+
text.slice(0, limit * 2)
|
|
82
|
+
].join("\n"),
|
|
83
|
+
mode: "ask", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, allowQuestion: false, toolContext
|
|
84
|
+
})
|
|
85
|
+
return (out.reply || text.slice(0, limit)).slice(0, limit)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// #3 动态计划修订解析
|
|
89
|
+
function parseReplanMarker(text) {
|
|
90
|
+
const match = String(text || "").match(/\[REPLAN:\s*([\s\S]*?)\]/i)
|
|
91
|
+
if (!match) return null
|
|
92
|
+
try { return JSON.parse(match[1]) } catch { return null }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// #1 细粒度回滚:从 debugging 输出中提取失败的 taskId
|
|
96
|
+
function extractFailedTaskIds(text) {
|
|
97
|
+
const ids = []
|
|
98
|
+
const pattern = /\[FAILED_TASK:\s*(\S+)\]/gi
|
|
99
|
+
let m
|
|
100
|
+
while ((m = pattern.exec(text)) !== null) ids.push(m[1])
|
|
101
|
+
return ids
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
function parseBlueprintOutput(reply, objective, defaults) {
|
|
106
|
+
// 1. 尝试提取 ```stage_plan_json ... ``` 块
|
|
107
|
+
const jsonMatch = reply.match(/```stage_plan_json\s*([\s\S]*?)```/)
|
|
108
|
+
if (jsonMatch) {
|
|
109
|
+
const parsed = parseJsonLoose(jsonMatch[1])
|
|
110
|
+
if (parsed?.stages) {
|
|
111
|
+
const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
|
|
112
|
+
if (!errors.length) {
|
|
113
|
+
return { architectureText: reply.replace(/```stage_plan_json[\s\S]*?```/g, "").trim(), stagePlan: plan }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// 2. 回退:尝试任意 JSON 块
|
|
118
|
+
const anyJson = reply.match(/```(?:json)?\s*([\s\S]*?)```/g)
|
|
119
|
+
if (anyJson) {
|
|
120
|
+
for (const block of anyJson) {
|
|
121
|
+
const inner = block.replace(/```(?:json|stage_plan_json)?\s*/g, "").replace(/```/g, "").trim()
|
|
122
|
+
const parsed = parseJsonLoose(inner)
|
|
123
|
+
if (parsed?.stages) {
|
|
124
|
+
const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
|
|
125
|
+
if (!errors.length) return { architectureText: reply, stagePlan: plan }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// 3. 最终回退:单任务默认计划
|
|
130
|
+
return { architectureText: reply, stagePlan: defaultStagePlan(objective, defaults) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function runHybridLongAgent({
|
|
134
|
+
prompt, model, providerType, sessionId, configState,
|
|
135
|
+
baseUrl = null, apiKeyEnv = null, agent = null,
|
|
136
|
+
maxIterations = 0, signal = null, output = null,
|
|
137
|
+
allowQuestion = true, toolContext = {}
|
|
138
|
+
}) {
|
|
139
|
+
const longagentConfig = configState.config.agent.longagent || {}
|
|
140
|
+
const hybridConfig = longagentConfig.hybrid || {}
|
|
141
|
+
const parallelConfig = longagentConfig.parallel || {}
|
|
142
|
+
const gitConfig = longagentConfig.git || {}
|
|
143
|
+
const noProgressLimit = Number(longagentConfig.no_progress_limit || 5)
|
|
144
|
+
const maxGateAttempts = Number(longagentConfig.max_gate_attempts || 5)
|
|
145
|
+
const fileChangesLimit = Math.max(20, Number(longagentConfig.file_changes_limit || LONGAGENT_FILE_CHANGES_LIMIT))
|
|
146
|
+
|
|
147
|
+
// 每阶段模型选择
|
|
148
|
+
const separateModels = hybridConfig.separate_models || {}
|
|
149
|
+
const useSeparateModels = separateModels.enabled === true
|
|
150
|
+
const adaptiveModels = hybridConfig.adaptive_models || {}
|
|
151
|
+
const useAdaptiveModels = adaptiveModels.enabled === true
|
|
152
|
+
function getModelForStage(stage) {
|
|
153
|
+
if (!useSeparateModels) return { model, providerType }
|
|
154
|
+
const m = { preview: separateModels.preview_model, blueprint: separateModels.blueprint_model, debugging: separateModels.debugging_model }
|
|
155
|
+
return m[stage] ? { model: m[stage], providerType } : { model, providerType }
|
|
156
|
+
}
|
|
157
|
+
// #8 自适应模型路由:根据 task complexity 选择模型
|
|
158
|
+
function getModelForTask(task) {
|
|
159
|
+
if (!useAdaptiveModels) return model
|
|
160
|
+
const tier = task?.complexity || "medium"
|
|
161
|
+
return adaptiveModels[tier] || model
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let iteration = 0, recoveryCount = 0, stageIndex = 0
|
|
165
|
+
let currentPhase = "H0", currentGate = "init"
|
|
166
|
+
let gateStatus = {}, lastGateFailures = []
|
|
167
|
+
let lastProgress = { percentage: 0, currentStep: 0, totalSteps: 0 }
|
|
168
|
+
let finalReply = "", planFrozen = false, stagePlan = null
|
|
169
|
+
let taskProgress = {}, fileChanges = []
|
|
170
|
+
let completionMarkerSeen = false
|
|
171
|
+
let gitBranch = null, gitBaseBranch = null, gitActive = false
|
|
172
|
+
const aggregateUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
173
|
+
const toolEvents = []
|
|
174
|
+
const startTime = Date.now()
|
|
175
|
+
const stuckTracker = createStuckTracker()
|
|
176
|
+
// Phase 6: 降级链
|
|
177
|
+
const degradationChain = createDegradationChain(hybridConfig.degradation || {})
|
|
178
|
+
// Phase 2: 阶段超时配置
|
|
179
|
+
const codingPhaseTimeoutMs = Number(hybridConfig.coding_phase_timeout_ms || 1800000)
|
|
180
|
+
const debuggingPhaseTimeoutMs = Number(hybridConfig.debugging_phase_timeout_ms || 600000)
|
|
181
|
+
// #4 TaskBus
|
|
182
|
+
const taskBus = hybridConfig.task_bus !== false ? new TaskBus() : null
|
|
183
|
+
// #5 Project Memory
|
|
184
|
+
const cwd = process.cwd()
|
|
185
|
+
let projectMemory = null
|
|
186
|
+
if (hybridConfig.project_memory !== false) {
|
|
187
|
+
try { projectMemory = await loadProjectMemory(cwd) } catch { projectMemory = null }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function accumulateUsage(turn) {
|
|
191
|
+
aggregateUsage.input += turn.usage?.input || 0
|
|
192
|
+
aggregateUsage.output += turn.usage?.output || 0
|
|
193
|
+
aggregateUsage.cacheRead += turn.usage?.cacheRead || 0
|
|
194
|
+
aggregateUsage.cacheWrite += turn.usage?.cacheWrite || 0
|
|
195
|
+
if (turn.toolEvents?.length) toolEvents.push(...turn.toolEvents)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function setPhase(next, reason = "") {
|
|
199
|
+
if (currentPhase === next) return
|
|
200
|
+
const prev = currentPhase
|
|
201
|
+
currentPhase = next
|
|
202
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_CHANGED, sessionId, payload: { prevPhase: prev, nextPhase: next, reason, iteration } })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function syncState(patch = {}) {
|
|
206
|
+
const stats = stageProgressStats(taskProgress)
|
|
207
|
+
await LongAgentManager.update(sessionId, {
|
|
208
|
+
status: patch.status || "running", phase: currentPhase, gateStatus, currentGate,
|
|
209
|
+
recoveryCount, lastGateFailures, iterations: iteration, heartbeatAt: Date.now(),
|
|
210
|
+
progress: lastProgress, planFrozen, stageIndex,
|
|
211
|
+
stageCount: stagePlan?.stages?.length || 0,
|
|
212
|
+
taskProgress, stageProgress: { done: stats.done, total: stats.total },
|
|
213
|
+
remainingFilesCount: stats.remainingFilesCount,
|
|
214
|
+
...patch
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await markSessionStatus(sessionId, "running-longagent")
|
|
219
|
+
await syncState({ status: "running", lastMessage: "hybrid mode started" })
|
|
220
|
+
|
|
221
|
+
// 前置检查
|
|
222
|
+
if (!isLikelyActionableObjective(prompt)) {
|
|
223
|
+
const blocked = "LongAgent 需要明确的编码目标。请直接描述要实现/修复的内容。"
|
|
224
|
+
await LongAgentManager.update(sessionId, { status: "blocked", phase: "H0", lastMessage: blocked })
|
|
225
|
+
await markSessionStatus(sessionId, "active")
|
|
226
|
+
return { sessionId, turnId: `turn_long_${Date.now()}`, reply: blocked, usage: aggregateUsage, toolEvents, iterations: 0, status: "blocked", phase: "H0", gateStatus: {}, currentGate: "init", lastGateFailures: [], recoveryCount: 0, progress: lastProgress, elapsed: 0, stageIndex: 0, stageCount: 0, planFrozen: false, taskProgress: {}, fileChanges: [], stageProgress: { done: 0, total: 0 }, remainingFilesCount: 0 }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// #15 Checkpoint 恢复:如果有之前的检查点,跳过已完成阶段
|
|
230
|
+
// #22: 增强为 task 级粒度恢复
|
|
231
|
+
if (hybridConfig.checkpoint_resume !== false) {
|
|
232
|
+
try {
|
|
233
|
+
const cp = await loadCheckpoint(sessionId)
|
|
234
|
+
if (cp?.stageIndex > 0 && cp?.stagePlan) {
|
|
235
|
+
if (!validateCheckpoint(cp)) {
|
|
236
|
+
// Invalid checkpoint structure — discard and start fresh
|
|
237
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CHECKPOINT_INVALID, sessionId, payload: { reason: "structure_validation_failed" } })
|
|
238
|
+
} else {
|
|
239
|
+
stagePlan = cp.stagePlan; stageIndex = cp.stageIndex; planFrozen = true
|
|
240
|
+
taskProgress = cp.taskProgress || {}; lastProgress = cp.lastProgress || lastProgress
|
|
241
|
+
iteration = cp.iteration || 0
|
|
242
|
+
// #22: Load task-level checkpoints to recover intra-stage progress
|
|
243
|
+
if (stageIndex > 0) {
|
|
244
|
+
const prevStage = cp.stagePlan.stages[stageIndex - 1]
|
|
245
|
+
if (prevStage) {
|
|
246
|
+
const taskCps = await loadTaskCheckpoints(sessionId, prevStage.stageId)
|
|
247
|
+
for (const [tid, tData] of Object.entries(taskCps)) {
|
|
248
|
+
if (!taskProgress[tid] || taskProgress[tid].status !== "completed") {
|
|
249
|
+
taskProgress[tid] = { ...taskProgress[tid], ...tData }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CHECKPOINT_RESUMED, sessionId, payload: { stageIndex, iteration } })
|
|
255
|
+
await syncState({ lastMessage: `resumed from checkpoint at stage ${stageIndex}` })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch { /* no checkpoint, start fresh */ }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// #5 Memory 事件
|
|
262
|
+
if (projectMemory?.techStack?.length) {
|
|
263
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_MEMORY_LOADED, sessionId, payload: { techStack: projectMemory.techStack } })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ========== H0: INTAKE (需求澄清) ==========
|
|
267
|
+
let intakeSummary = prompt
|
|
268
|
+
if (hybridConfig.intake !== false && !planFrozen) {
|
|
269
|
+
await setPhase("H0", "intake")
|
|
270
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_INTAKE_STARTED, sessionId, payload: { objective: prompt } })
|
|
271
|
+
await syncState({ lastMessage: "H0: intake dialogue — clarifying requirements" })
|
|
272
|
+
|
|
273
|
+
const plannerConfig = longagentConfig.planner || {}
|
|
274
|
+
const intakeConfig = plannerConfig.intake_questions || {}
|
|
275
|
+
const intake = await runIntakeDialogue({
|
|
276
|
+
objective: prompt,
|
|
277
|
+
model, providerType, sessionId, configState,
|
|
278
|
+
baseUrl, apiKeyEnv, agent, signal,
|
|
279
|
+
maxRounds: Number(intakeConfig.max_rounds || 6)
|
|
280
|
+
})
|
|
281
|
+
intakeSummary = intake.summary || prompt
|
|
282
|
+
accumulateUsage(intake)
|
|
283
|
+
gateStatus.intake = { status: "pass", rounds: intake.transcript.length, summary: intakeSummary.slice(0, 500) }
|
|
284
|
+
await syncState({ lastMessage: `H0: intake complete (${intake.transcript.length} qa pairs)` })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ========== H1: PREVIEW (只读探索) ==========
|
|
288
|
+
await setPhase("H1", "preview")
|
|
289
|
+
currentGate = "preview"
|
|
290
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_PREVIEW_START, sessionId, payload: { objective: prompt } })
|
|
291
|
+
await syncState({ lastMessage: "H1: preview agent exploring codebase" })
|
|
292
|
+
|
|
293
|
+
const previewModel = getModelForStage("preview")
|
|
294
|
+
// #5 注入 project memory 到 preview prompt
|
|
295
|
+
const memCtx = projectMemory ? memoryToContext(projectMemory) : ""
|
|
296
|
+
const previewPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.PREVIEW, { preview: null, blueprint: null, coding: null }, memCtx ? `${memCtx}\n\n${intakeSummary}` : intakeSummary)
|
|
297
|
+
const previewOut = await processTurnLoop({
|
|
298
|
+
prompt: previewPrompt, mode: "agent", agent: getAgent("preview-agent"),
|
|
299
|
+
model: previewModel.model, providerType: previewModel.providerType,
|
|
300
|
+
sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
|
|
301
|
+
})
|
|
302
|
+
accumulateUsage(previewOut)
|
|
303
|
+
const previewFindings = previewOut.reply || ""
|
|
304
|
+
|
|
305
|
+
gateStatus.preview = { status: "pass", findingsLength: previewFindings.length }
|
|
306
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_PREVIEW_COMPLETE, sessionId, payload: { findingsLength: previewFindings.length } })
|
|
307
|
+
await syncState({ lastMessage: `H1: preview complete (${previewFindings.length} chars)` })
|
|
308
|
+
|
|
309
|
+
// ========== H2: BLUEPRINT (只读规划 + 结构化 stagePlan) ==========
|
|
310
|
+
await setPhase("H2", "blueprint")
|
|
311
|
+
currentGate = "blueprint"
|
|
312
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_START, sessionId, payload: {} })
|
|
313
|
+
await syncState({ lastMessage: "H2: blueprint agent designing architecture" })
|
|
314
|
+
|
|
315
|
+
const blueprintModel = getModelForStage("blueprint")
|
|
316
|
+
const blueprintPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.BLUEPRINT, { preview: previewFindings, blueprint: null, coding: null }, prompt)
|
|
317
|
+
+ [
|
|
318
|
+
"\n\n## HYBRID MODE: STRUCTURED EXECUTION PLAN (REQUIRED)",
|
|
319
|
+
"In addition to your architecture design, you MUST output a machine-parseable stage plan.",
|
|
320
|
+
"",
|
|
321
|
+
"Wrap it in a ```stage_plan_json ... ``` fenced block. Schema:",
|
|
322
|
+
'{"planId":"...","objective":"...","stages":[{"stageId":"...","name":"...","passRule":"all_success","tasks":[{"taskId":"...","prompt":"detailed task prompt for sub-agent","plannedFiles":["file1.mjs","file2.mjs"],"acceptance":["node --check file1.mjs","node --test test/file1.test.mjs"],"timeoutMs":600000,"maxRetries":2,"complexity":"low|medium|high"}]}]}',
|
|
323
|
+
"",
|
|
324
|
+
"Rules for the stage plan:",
|
|
325
|
+
"- Each task prompt must be SELF-CONTAINED: the sub-agent has NO access to your blueprint text",
|
|
326
|
+
"- plannedFiles must list EVERY file the task will create or modify (no file in multiple tasks)",
|
|
327
|
+
"- acceptance must be machine-verifiable commands (not subjective criteria)",
|
|
328
|
+
"- Files that import each other MUST be in the same task",
|
|
329
|
+
"- A module and its test file MUST be in the same task",
|
|
330
|
+
"- Order stages by dependency: shared types → core logic → integration → validation"
|
|
331
|
+
].join("\n")
|
|
332
|
+
const blueprintOut = await processTurnLoop({
|
|
333
|
+
prompt: blueprintPrompt, mode: "agent", agent: getAgent("blueprint-agent"),
|
|
334
|
+
model: blueprintModel.model, providerType: blueprintModel.providerType,
|
|
335
|
+
sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
|
|
336
|
+
})
|
|
337
|
+
accumulateUsage(blueprintOut)
|
|
338
|
+
|
|
339
|
+
const planDefaults = { timeoutMs: Number(parallelConfig.task_timeout_ms || 600000), maxRetries: Number(parallelConfig.task_max_retries ?? 2) }
|
|
340
|
+
const { architectureText, stagePlan: parsedPlan } = parseBlueprintOutput(blueprintOut.reply || "", prompt, planDefaults)
|
|
341
|
+
stagePlan = parsedPlan
|
|
342
|
+
planFrozen = true
|
|
343
|
+
|
|
344
|
+
gateStatus.blueprint = { status: "pass", hasArchitecture: architectureText.length > 100, stageCount: stagePlan.stages.length }
|
|
345
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_COMPLETE, sessionId, payload: { planId: stagePlan.planId, stageCount: stagePlan.stages.length } })
|
|
346
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PLAN_FROZEN, sessionId, payload: { planId: stagePlan.planId, stageCount: stagePlan.stages.length, errors: [] } })
|
|
347
|
+
await syncState({ planFrozen: true, lastMessage: `H2: blueprint complete, ${stagePlan.stages.length} stage(s)` })
|
|
348
|
+
|
|
349
|
+
// #9 Blueprint 语义验证
|
|
350
|
+
if (hybridConfig.blueprint_validation !== false && stagePlan.stages.length > 0) {
|
|
351
|
+
const totalTasks = stagePlan.stages.reduce((s, st) => s + (st.tasks?.length || 0), 0)
|
|
352
|
+
const totalFiles = new Set(stagePlan.stages.flatMap(st => (st.tasks || []).flatMap(t => t.plannedFiles || []))).size
|
|
353
|
+
const valid = totalTasks > 0 && totalFiles > 0
|
|
354
|
+
gateStatus.blueprintValidation = { status: valid ? "pass" : "warn", totalTasks, totalFiles }
|
|
355
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_VALIDATED, sessionId, payload: { totalTasks, totalFiles, valid } })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// #2 人工审查检查点
|
|
359
|
+
if (hybridConfig.blueprint_review === true && allowQuestion) {
|
|
360
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_REVIEW, sessionId, payload: { planId: stagePlan.planId } })
|
|
361
|
+
const reviewOut = await processTurnLoop({
|
|
362
|
+
prompt: `[SYSTEM] Blueprint 已生成,包含 ${stagePlan.stages.length} 个阶段。架构摘要:\n${architectureText.slice(0, 1500)}\n\n请确认是否继续执行?回复 yes/是 继续,no/否 中止。`,
|
|
363
|
+
mode: "ask", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
|
|
364
|
+
})
|
|
365
|
+
accumulateUsage(reviewOut)
|
|
366
|
+
const answer = String(reviewOut.reply || "").toLowerCase().trim()
|
|
367
|
+
if (["no", "否", "n", "取消", "abort"].some(k => answer.includes(k))) {
|
|
368
|
+
await LongAgentManager.update(sessionId, { status: "aborted", lastMessage: "user rejected blueprint" })
|
|
369
|
+
await markSessionStatus(sessionId, "active")
|
|
370
|
+
return { sessionId, turnId: `turn_long_${Date.now()}`, reply: "用户中止了 Blueprint 审查。", usage: aggregateUsage, toolEvents, iterations: iteration, status: "aborted", phase: "H2", gateStatus, currentGate, lastGateFailures: [], recoveryCount: 0, progress: lastProgress, elapsed: Math.round((Date.now() - startTime) / 1000), stageIndex: 0, stageCount: stagePlan.stages.length, planFrozen, taskProgress: {}, fileChanges: [], stageProgress: { done: 0, total: 0 }, remainingFilesCount: 0 }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ========== H2.5: GIT BRANCH (可选) ==========
|
|
375
|
+
const gitEnabled = gitConfig.enabled === true || gitConfig.enabled === "ask"
|
|
376
|
+
const gitAsk = gitConfig.enabled === "ask"
|
|
377
|
+
const inGitRepo = gitEnabled && await git.isGitRepo(cwd)
|
|
378
|
+
|
|
379
|
+
if (inGitRepo) {
|
|
380
|
+
await setPhase("H2.5", "git_branch")
|
|
381
|
+
let userWantsGit = !gitAsk
|
|
382
|
+
if (gitAsk && allowQuestion) {
|
|
383
|
+
const askResult = await processTurnLoop({
|
|
384
|
+
prompt: "[SYSTEM] 是否为本次 Hybrid LongAgent 创建独立 Git 分支?回复 yes/是 启用,no/否 跳过。",
|
|
385
|
+
mode: "ask", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
|
|
386
|
+
})
|
|
387
|
+
const answer = String(askResult.reply || "").toLowerCase().trim()
|
|
388
|
+
userWantsGit = ["yes", "是", "y", "ok", "好", "确认"].some(k => answer.includes(k))
|
|
389
|
+
accumulateUsage(askResult)
|
|
390
|
+
}
|
|
391
|
+
if (userWantsGit) {
|
|
392
|
+
gitBaseBranch = await git.currentBranch(cwd)
|
|
393
|
+
// Guard: skip git flow if branch is empty or HEAD detached
|
|
394
|
+
if (!gitBaseBranch || gitBaseBranch === "HEAD") {
|
|
395
|
+
gateStatus.git = { status: "warn", reason: "detached HEAD or no branch" }
|
|
396
|
+
} else {
|
|
397
|
+
const branchName = git.generateBranchName(sessionId, prompt)
|
|
398
|
+
const clean = await git.isClean(cwd)
|
|
399
|
+
let stashed = false
|
|
400
|
+
try {
|
|
401
|
+
if (!clean) {
|
|
402
|
+
const sr = await git.stash("kkcode-auto-stash", cwd)
|
|
403
|
+
stashed = sr.ok
|
|
404
|
+
if (!stashed) {
|
|
405
|
+
// Stash failed — skip branch creation
|
|
406
|
+
gateStatus.git = { status: "warn", reason: "git stash failed" }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!stashed && !clean) {
|
|
410
|
+
// stash failed, skip branch creation (already set gateStatus above)
|
|
411
|
+
} else {
|
|
412
|
+
const created = await git.createBranch(branchName, cwd)
|
|
413
|
+
if (created.ok) {
|
|
414
|
+
gitBranch = branchName; gitActive = true
|
|
415
|
+
gateStatus.git = { status: "pass", branch: branchName, baseBranch: gitBaseBranch }
|
|
416
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_BRANCH_CREATED, sessionId, payload: { branch: branchName, baseBranch: gitBaseBranch } })
|
|
417
|
+
} else {
|
|
418
|
+
gateStatus.git = { status: "warn", reason: created.message }
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} finally {
|
|
422
|
+
// Always restore stash on any exit path
|
|
423
|
+
if (stashed) await git.stashPop(cwd).catch(() => {})
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ========== H3: SCAFFOLD (脚手架) ==========
|
|
430
|
+
const scaffoldEnabled = longagentConfig.scaffold?.enabled !== false
|
|
431
|
+
if (scaffoldEnabled && stagePlan.stages.length > 0) {
|
|
432
|
+
await setPhase("H3", "scaffolding")
|
|
433
|
+
currentGate = "scaffold"
|
|
434
|
+
await syncState({ lastMessage: "H3: creating stub files" })
|
|
435
|
+
|
|
436
|
+
const scaffoldResult = await runScaffoldPhase({
|
|
437
|
+
objective: `${prompt}\n\n=== BLUEPRINT ARCHITECTURE ===\n${architectureText.slice(0, 4000)}`,
|
|
438
|
+
stagePlan, model, providerType, sessionId, configState,
|
|
439
|
+
baseUrl, apiKeyEnv, agent, signal, toolContext,
|
|
440
|
+
tddMode: hybridConfig.tdd_mode === true
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
gateStatus.scaffold = { status: scaffoldResult.scaffolded ? "pass" : "skip", fileCount: scaffoldResult.fileCount }
|
|
444
|
+
if (scaffoldResult.usage) accumulateUsage(scaffoldResult)
|
|
445
|
+
if (scaffoldResult.files?.length) {
|
|
446
|
+
fileChanges = mergeCappedFileChanges(fileChanges,
|
|
447
|
+
scaffoldResult.files.map(f => ({ path: f, addedLines: 0, removedLines: 0, stageId: "scaffold", taskId: "scaffold" })),
|
|
448
|
+
fileChangesLimit)
|
|
449
|
+
}
|
|
450
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_SCAFFOLD_COMPLETE, sessionId, payload: { fileCount: scaffoldResult.fileCount, files: scaffoldResult.files || [] } })
|
|
451
|
+
await syncState({ lastMessage: `H3: scaffolded ${scaffoldResult.fileCount} file(s)` })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ========== H4+H5: CODING(并行) + DEBUGGING(回滚) 循环 ==========
|
|
455
|
+
const gatesConfig = longagentConfig.usability_gates || {}
|
|
456
|
+
let priorContext = [
|
|
457
|
+
"### Preview Findings", previewFindings.slice(0, 2000), "",
|
|
458
|
+
"### Blueprint Architecture", architectureText.slice(0, 3000)
|
|
459
|
+
].join("\n")
|
|
460
|
+
|
|
461
|
+
let codingRollbackCount = 0
|
|
462
|
+
const maxCodingRollbacks = Number(hybridConfig.max_coding_rollbacks || 2)
|
|
463
|
+
const maxDebugIterations = Number(hybridConfig.debugging_max_iterations || 20)
|
|
464
|
+
let rerunCoding = true
|
|
465
|
+
|
|
466
|
+
while (rerunCoding && codingRollbackCount <= maxCodingRollbacks) {
|
|
467
|
+
rerunCoding = false
|
|
468
|
+
|
|
469
|
+
// --- H4: CODING (并行 stage 执行) ---
|
|
470
|
+
await setPhase("H4", "coding")
|
|
471
|
+
currentGate = "coding"
|
|
472
|
+
stageIndex = 0
|
|
473
|
+
const codingPhaseStart = Date.now()
|
|
474
|
+
|
|
475
|
+
while (stageIndex < stagePlan.stages.length) {
|
|
476
|
+
const state = await LongAgentManager.get(sessionId)
|
|
477
|
+
if (state?.stopRequested || signal?.aborted) break
|
|
478
|
+
|
|
479
|
+
// Phase 2: 阶段超时检测
|
|
480
|
+
if (Date.now() - codingPhaseStart > codingPhaseTimeoutMs) {
|
|
481
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H4", elapsed: Date.now() - codingPhaseStart } })
|
|
482
|
+
if (degradationChain.canDegrade()) {
|
|
483
|
+
const degCtx = { model, taskProgress, configState, shouldStop: false }
|
|
484
|
+
const deg = degradationChain.apply(degCtx)
|
|
485
|
+
if (degCtx.model !== model) model = degCtx.model
|
|
486
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4" } })
|
|
487
|
+
if (deg.applied && deg.strategy === "graceful_stop") break
|
|
488
|
+
} else {
|
|
489
|
+
break
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
iteration++
|
|
494
|
+
const stage = stagePlan.stages[stageIndex]
|
|
495
|
+
currentGate = `stage:${stage.stageId}`
|
|
496
|
+
await syncState({ stageStatus: "running", lastMessage: `H4: running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})` })
|
|
497
|
+
|
|
498
|
+
const seeded = Object.fromEntries(
|
|
499
|
+
stage.tasks.map(t => [t.taskId, taskProgress[t.taskId]]).filter(([, v]) => Boolean(v))
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
const stageResult = await runStageBarrier({
|
|
503
|
+
stage, sessionId, config: configState.config, model, providerType,
|
|
504
|
+
seedTaskProgress: seeded, objective: prompt,
|
|
505
|
+
stageIndex, stageCount: stagePlan.stages.length, priorContext,
|
|
506
|
+
stuckTracker,
|
|
507
|
+
onTaskComplete: async (taskData) => {
|
|
508
|
+
await saveTaskCheckpoint(sessionId, taskData.stageId, taskData.taskId, taskData)
|
|
509
|
+
},
|
|
510
|
+
taskBus
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
// 合并结果
|
|
514
|
+
for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
|
|
515
|
+
taskProgress[taskId] = { ...taskProgress[taskId], ...progress }
|
|
516
|
+
if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) completionMarkerSeen = true
|
|
517
|
+
// #4 TaskBus: 解析 task 输出中的广播消息
|
|
518
|
+
if (taskBus && progress.lastReply) taskBus.parseTaskOutput(taskId, progress.lastReply)
|
|
519
|
+
// #3 动态重规划: 检测 [REPLAN:...] 标记
|
|
520
|
+
const replan = parseReplanMarker(progress.lastReply)
|
|
521
|
+
if (replan?.stages) {
|
|
522
|
+
const { plan, errors } = validateAndNormalizeStagePlan(replan, { objective: prompt, defaults: planDefaults })
|
|
523
|
+
if (!errors.length) {
|
|
524
|
+
stagePlan = plan
|
|
525
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_REPLAN, sessionId, payload: { newStageCount: plan.stages.length } })
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (stageResult.completionMarkerSeen) completionMarkerSeen = true
|
|
530
|
+
if (stageResult.fileChanges?.length) {
|
|
531
|
+
fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
gateStatus[stage.stageId] = {
|
|
535
|
+
status: stageResult.allSuccess ? "pass" : "fail",
|
|
536
|
+
successCount: stageResult.successCount, failCount: stageResult.failCount
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 知识传递
|
|
540
|
+
const taskSummaries = Object.values(stageResult.taskProgress || {})
|
|
541
|
+
.filter(t => t.lastReply)
|
|
542
|
+
.map(t => `[${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 300)}`)
|
|
543
|
+
if (taskSummaries.length) {
|
|
544
|
+
priorContext += `\n### Stage ${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"})\n${taskSummaries.join("\n")}\n`
|
|
545
|
+
}
|
|
546
|
+
// #4 TaskBus 注入到 priorContext
|
|
547
|
+
if (taskBus) {
|
|
548
|
+
const busCtx = taskBus.toContextString()
|
|
549
|
+
if (busCtx) priorContext += `\n${busCtx}\n`
|
|
550
|
+
}
|
|
551
|
+
// #13 上下文压缩
|
|
552
|
+
const pressureLimit = Number(hybridConfig.context_pressure_limit || 8000)
|
|
553
|
+
if (priorContext.length > pressureLimit) {
|
|
554
|
+
priorContext = await compressContext(priorContext, pressureLimit, { model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, toolContext })
|
|
555
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CONTEXT_COMPRESSED, sessionId, payload: { newLength: priorContext.length } })
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
lastProgress = {
|
|
559
|
+
percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
|
|
560
|
+
currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
|
|
561
|
+
totalSteps: stagePlan.stages.length
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Git: 每 stage 自动 commit
|
|
565
|
+
if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
|
|
566
|
+
const msg = `[kkcode-hybrid] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
|
|
567
|
+
await git.commitAll(msg, cwd)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// #10 增量门控:每个 stage 完成后运行轻量检查
|
|
571
|
+
if (hybridConfig.incremental_gates !== false && stageResult.allSuccess && stageIndex < stagePlan.stages.length - 1) {
|
|
572
|
+
const stageFiles = (stageResult.fileChanges || []).map(f => f.path).filter(Boolean)
|
|
573
|
+
if (stageFiles.length > 0) {
|
|
574
|
+
const miniGate = await runUsabilityGates({
|
|
575
|
+
sessionId, configState, model, providerType, baseUrl, apiKeyEnv, signal, toolContext,
|
|
576
|
+
objective: `Verify stage ${stage.stageId}: ${stage.name || ""}`, fileChanges: stageResult.fileChanges || [],
|
|
577
|
+
gatesConfig: { ...gatesConfig, lint: true, typecheck: true, test: false, security: false, build: false }, allowQuestion: false
|
|
578
|
+
})
|
|
579
|
+
if (miniGate.usage) accumulateUsage(miniGate)
|
|
580
|
+
gateStatus[`gate_${stage.stageId}`] = { status: miniGate.allPassed ? "pass" : "warn" }
|
|
581
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_INCREMENTAL_GATE, sessionId, payload: { stageId: stage.stageId, passed: miniGate.allPassed } })
|
|
582
|
+
// #18: Feed gate results into priorContext so subsequent stages see lint/typecheck feedback
|
|
583
|
+
if (!miniGate.allPassed && miniGate.failures?.length) {
|
|
584
|
+
const gateFeedback = miniGate.failures.slice(0, 3).map(f => `${f.gate}: ${(f.reason || "").slice(0, 150)}`).join("; ")
|
|
585
|
+
priorContext += `\n### Incremental Gate Warning (${stage.stageId})\n${gateFeedback}\n`
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// #14 预算感知:检查 token 消耗是否超限
|
|
591
|
+
// #21: 增加基于历史平均值的预算预测
|
|
592
|
+
if (hybridConfig.budget_awareness !== false) {
|
|
593
|
+
const totalTokens = aggregateUsage.input + aggregateUsage.output
|
|
594
|
+
const budgetLimit = Number(longagentConfig.token_budget || 2000000)
|
|
595
|
+
|
|
596
|
+
// #21: Predict remaining budget based on average per-stage cost
|
|
597
|
+
const completedStages = stageIndex + (stageResult.allSuccess ? 1 : 0)
|
|
598
|
+
const remainingStages = stagePlan.stages.length - completedStages
|
|
599
|
+
if (completedStages > 0 && remainingStages > 0) {
|
|
600
|
+
const avgPerStage = totalTokens / completedStages
|
|
601
|
+
const predicted = totalTokens + avgPerStage * remainingStages
|
|
602
|
+
if (predicted > budgetLimit && totalTokens <= budgetLimit * 0.9) {
|
|
603
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BUDGET_WARNING, sessionId, payload: { totalTokens, budgetLimit, predicted: Math.round(predicted), percentage: Math.round(totalTokens / budgetLimit * 100), forecast: true } })
|
|
604
|
+
await syncState({ lastMessage: `H4: budget forecast — predicted ${Math.round(predicted / 1000)}k tokens (limit ${Math.round(budgetLimit / 1000)}k)` })
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (totalTokens > budgetLimit * 0.9) {
|
|
609
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BUDGET_WARNING, sessionId, payload: { totalTokens, budgetLimit, percentage: Math.round(totalTokens / budgetLimit * 100) } })
|
|
610
|
+
await syncState({ lastMessage: `H4: budget warning — ${Math.round(totalTokens / budgetLimit * 100)}% used` })
|
|
611
|
+
}
|
|
612
|
+
if (totalTokens > budgetLimit) {
|
|
613
|
+
// Phase 6: 尝试降级而非直接 break
|
|
614
|
+
if (degradationChain.canDegrade()) {
|
|
615
|
+
const degCtx2 = { model, taskProgress, configState, shouldStop: false }
|
|
616
|
+
const deg = degradationChain.apply(degCtx2)
|
|
617
|
+
if (degCtx2.model !== model) model = degCtx2.model
|
|
618
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "budget_exceeded" } })
|
|
619
|
+
if (deg.applied && deg.strategy === "graceful_stop") {
|
|
620
|
+
await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded, graceful stop` })
|
|
621
|
+
break
|
|
622
|
+
}
|
|
623
|
+
} else {
|
|
624
|
+
await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded (${totalTokens}/${budgetLimit})` })
|
|
625
|
+
break
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!stageResult.allSuccess) {
|
|
631
|
+
recoveryCount++
|
|
632
|
+
const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
|
|
633
|
+
await new Promise(r => setTimeout(r, backoffMs))
|
|
634
|
+
const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
|
|
635
|
+
if (recoveryCount >= maxStageRecoveries) {
|
|
636
|
+
// Phase 6: 尝试降级而非直接 abort
|
|
637
|
+
if (degradationChain.canDegrade()) {
|
|
638
|
+
const degCtx3 = { model, taskProgress, configState, shouldStop: false }
|
|
639
|
+
const deg = degradationChain.apply(degCtx3)
|
|
640
|
+
if (degCtx3.model !== model) model = degCtx3.model
|
|
641
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "max_recoveries" } })
|
|
642
|
+
if (deg.applied && deg.strategy === "graceful_stop") {
|
|
643
|
+
await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after degradation` })
|
|
644
|
+
break
|
|
645
|
+
}
|
|
646
|
+
// 降级成功但非 graceful_stop,重置 recoveryCount 继续
|
|
647
|
+
recoveryCount = 0
|
|
648
|
+
} else {
|
|
649
|
+
await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recoveries` })
|
|
650
|
+
break
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Phase 1: 根据错误类别决定是否重试
|
|
654
|
+
for (const [taskId, tp] of Object.entries(taskProgress)) {
|
|
655
|
+
if (tp.status === "error") {
|
|
656
|
+
const category = classifyError(tp.lastError)
|
|
657
|
+
if (category === ERROR_CATEGORIES.PERMANENT || category === ERROR_CATEGORIES.UNKNOWN) {
|
|
658
|
+
taskProgress[taskId] = { ...tp, status: "error", skipReason: `${category} error` }
|
|
659
|
+
} else {
|
|
660
|
+
taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
stageIndex++
|
|
668
|
+
await saveCheckpoint(sessionId, { name: `hybrid_stage_${stage.stageId}`, iteration, currentPhase, stageIndex, stagePlan, taskProgress, planFrozen, lastProgress })
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// #11 Cross-review:H4 完成后、H5 之前,让独立 agent 审查代码
|
|
672
|
+
if (hybridConfig.cross_review !== false && fileChanges.length > 0) {
|
|
673
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CROSS_REVIEW, sessionId, payload: { fileCount: fileChanges.length } })
|
|
674
|
+
const reviewFiles = fileChanges.slice(0, 20).map(f => f.path).join(", ")
|
|
675
|
+
const reviewOut = await processTurnLoop({
|
|
676
|
+
prompt: [
|
|
677
|
+
"You are the CROSS-REVIEW agent. Multiple parallel sub-agents just completed their coding tasks independently.",
|
|
678
|
+
"Your job: verify that their outputs are compatible, correct, and integrate properly.",
|
|
679
|
+
"",
|
|
680
|
+
"## Files to review:",
|
|
681
|
+
reviewFiles,
|
|
682
|
+
"",
|
|
683
|
+
"## Review Checklist",
|
|
684
|
+
"1. IMPORT RESOLUTION: Do all cross-file imports resolve? Are exported symbols correct?",
|
|
685
|
+
"2. INTERFACE COMPATIBILITY: Do function signatures match what callers expect?",
|
|
686
|
+
"3. ERROR HANDLING: Are errors properly caught, propagated, or thrown? No silent failures?",
|
|
687
|
+
"4. RESOURCE CLEANUP: Are timers cleared, listeners removed, handles closed in all code paths?",
|
|
688
|
+
"5. EDGE CASES: Null/undefined checks, empty arrays, concurrent access guards?",
|
|
689
|
+
"6. CONSISTENCY: Same naming conventions, error patterns, async style across files?",
|
|
690
|
+
"",
|
|
691
|
+
`## Original Objective: ${prompt}`,
|
|
692
|
+
"",
|
|
693
|
+
"## Output Format",
|
|
694
|
+
"For each issue found, output: [FAILED_TASK: taskId] with a description of the problem.",
|
|
695
|
+
"If no issues found, state that the cross-review passed.",
|
|
696
|
+
"Focus on REAL bugs that would cause runtime failures — not style preferences."
|
|
697
|
+
].join("\n"),
|
|
698
|
+
mode: "agent", agent: getAgent("debugging-agent"),
|
|
699
|
+
model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
|
|
700
|
+
})
|
|
701
|
+
accumulateUsage(reviewOut)
|
|
702
|
+
// 将审查发现注入 priorContext
|
|
703
|
+
if (reviewOut.reply) priorContext += `\n### Cross-Review Findings\n${reviewOut.reply.slice(0, 1500)}\n`
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// --- H5: DEBUGGING (回滚检测) ---
|
|
707
|
+
await setPhase("H5", "debugging")
|
|
708
|
+
currentGate = "debugging"
|
|
709
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_START, sessionId, payload: { codingRollbackCount } })
|
|
710
|
+
await syncState({ lastMessage: "H5: debugging agent verifying implementation" })
|
|
711
|
+
|
|
712
|
+
const debugModel = getModelForStage("debugging")
|
|
713
|
+
const debugPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.DEBUGGING, {
|
|
714
|
+
preview: previewFindings.slice(0, 2000),
|
|
715
|
+
blueprint: architectureText.slice(0, 3000),
|
|
716
|
+
coding: priorContext.slice(0, 4000)
|
|
717
|
+
}, prompt)
|
|
718
|
+
|
|
719
|
+
let debugIter = 0
|
|
720
|
+
let debugDone = false
|
|
721
|
+
const semanticTracker = createSemanticErrorTracker(3)
|
|
722
|
+
const debugPhaseStart = Date.now()
|
|
723
|
+
|
|
724
|
+
while (!debugDone && debugIter < maxDebugIterations) {
|
|
725
|
+
debugIter++
|
|
726
|
+
iteration++
|
|
727
|
+
const state = await LongAgentManager.get(sessionId)
|
|
728
|
+
if (state?.stopRequested || signal?.aborted) break
|
|
729
|
+
|
|
730
|
+
// Phase 2: debugging 阶段超时检测
|
|
731
|
+
if (Date.now() - debugPhaseStart > debuggingPhaseTimeoutMs) {
|
|
732
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H5", elapsed: Date.now() - debugPhaseStart } })
|
|
733
|
+
if (degradationChain.canDegrade()) {
|
|
734
|
+
const degCtx4 = { model, taskProgress, configState, shouldStop: false }
|
|
735
|
+
const deg = degradationChain.apply(degCtx4)
|
|
736
|
+
if (degCtx4.model !== model) model = degCtx4.model
|
|
737
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H5" } })
|
|
738
|
+
if (deg.applied && deg.strategy === "graceful_stop") break
|
|
739
|
+
} else {
|
|
740
|
+
break
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const debugOut = await processTurnLoop({
|
|
745
|
+
prompt: debugPrompt, mode: "agent", agent: getAgent("debugging-agent"),
|
|
746
|
+
model: debugModel.model, providerType: debugModel.providerType,
|
|
747
|
+
sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
|
|
748
|
+
})
|
|
749
|
+
accumulateUsage(debugOut)
|
|
750
|
+
finalReply = debugOut.reply || ""
|
|
751
|
+
|
|
752
|
+
// 防卡死检测
|
|
753
|
+
if (debugOut.toolEvents?.length) {
|
|
754
|
+
const stuckResult = stuckTracker.track(debugOut.toolEvents)
|
|
755
|
+
if (stuckResult.isStuck) {
|
|
756
|
+
stuckTracker.resetReadOnlyCount()
|
|
757
|
+
await EventBus.emit({
|
|
758
|
+
type: EVENT_TYPES.LONGAGENT_ALERT, sessionId,
|
|
759
|
+
payload: { kind: "stuck_warning", stage: "H5:debugging", reason: stuckResult.reason, debugIter }
|
|
760
|
+
})
|
|
761
|
+
await syncState({ lastMessage: `H5: stuck detected (${stuckResult.reason}), iter ${debugIter}` })
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Phase 5: 语义级错误检测
|
|
766
|
+
const semResult = semanticTracker.track(finalReply)
|
|
767
|
+
if (semResult.isRepeated) {
|
|
768
|
+
await EventBus.emit({
|
|
769
|
+
type: EVENT_TYPES.LONGAGENT_SEMANTIC_ERROR_REPEATED, sessionId,
|
|
770
|
+
payload: { error: semResult.error, count: semResult.count, debugIter }
|
|
771
|
+
})
|
|
772
|
+
// 注入更详细的错误分析提示,避免无限循环
|
|
773
|
+
await syncState({ lastMessage: `H5: repeated error detected (${semResult.count}x): ${(semResult.error || "").slice(0, 80)}` })
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (detectStageComplete(finalReply, LONGAGENT_4STAGE_STAGES.DEBUGGING)) {
|
|
777
|
+
debugDone = true
|
|
778
|
+
gateStatus.debugging = { status: "pass", iterations: debugIter }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (detectReturnToCoding(finalReply)) {
|
|
782
|
+
codingRollbackCount++
|
|
783
|
+
rerunCoding = true
|
|
784
|
+
// #1 细粒度回滚:优先只重置被标记的失败 task
|
|
785
|
+
const failedIds = extractFailedTaskIds(finalReply)
|
|
786
|
+
if (failedIds.length > 0) {
|
|
787
|
+
for (const fid of failedIds) {
|
|
788
|
+
if (taskProgress[fid]) taskProgress[fid] = { ...taskProgress[fid], status: "retrying", attempt: 0 }
|
|
789
|
+
}
|
|
790
|
+
} else {
|
|
791
|
+
// 回退:重置所有 error 状态的 task
|
|
792
|
+
for (const [taskId, tp] of Object.entries(taskProgress)) {
|
|
793
|
+
if (tp.status === "error") taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
gateStatus.debugging = { status: "rollback", iterations: debugIter, rollbackCount: codingRollbackCount, failedTaskIds: failedIds }
|
|
797
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_RETURN_TO_CODING, sessionId, payload: { rollbackCount: codingRollbackCount, failedTaskIds: failedIds } })
|
|
798
|
+
break
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (/\[TASK_COMPLETE\]/i.test(finalReply)) { completionMarkerSeen = true; debugDone = true }
|
|
802
|
+
await syncState({ lastMessage: `H5: debugging iteration ${debugIter}/${maxDebugIterations}` })
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (!debugDone && !rerunCoding) {
|
|
806
|
+
gateStatus.debugging = { status: "timeout", iterations: debugIter }
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_COMPLETE, sessionId, payload: { debugIter, rollback: rerunCoding } })
|
|
810
|
+
await syncState({ lastMessage: rerunCoding ? `H5: rollback to coding (attempt ${codingRollbackCount})` : `H5: debugging complete` })
|
|
811
|
+
} // end while(rerunCoding)
|
|
812
|
+
|
|
813
|
+
// ========== H5.5: COMPLETION VALIDATION ==========
|
|
814
|
+
if (hybridConfig.completion_validation !== false) {
|
|
815
|
+
await setPhase("H5.5", "completion_validation")
|
|
816
|
+
await syncState({ lastMessage: "H5.5: validating completion" })
|
|
817
|
+
|
|
818
|
+
const cwd = process.cwd()
|
|
819
|
+
try {
|
|
820
|
+
const validator = await createValidator({ cwd, configState })
|
|
821
|
+
const report = await validator.validate({ todoState: toolContext?._todoState, level: "standard" })
|
|
822
|
+
gateStatus.completionValidation = {
|
|
823
|
+
status: report.verdict === "BLOCK" ? "fail" : "pass",
|
|
824
|
+
verdict: report.verdict,
|
|
825
|
+
failedChecks: report.results?.filter(r => !r.passed).length || 0
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (report.verdict === "BLOCK" && !completionMarkerSeen) {
|
|
829
|
+
const fixPrompt = [
|
|
830
|
+
"## Completion Validation Failed — Fix Required",
|
|
831
|
+
"",
|
|
832
|
+
`Original objective: ${prompt}`,
|
|
833
|
+
"",
|
|
834
|
+
"## Validation Issues Found:",
|
|
835
|
+
report.message,
|
|
836
|
+
"",
|
|
837
|
+
"## Fix Instructions",
|
|
838
|
+
"1. Read each failing check and identify the root cause",
|
|
839
|
+
"2. Fix the issue in the source code (not by suppressing the check)",
|
|
840
|
+
"3. Re-run the relevant verification command to confirm the fix",
|
|
841
|
+
"4. If a fix requires changes to multiple files, ensure cross-file consistency",
|
|
842
|
+
"",
|
|
843
|
+
"When ALL issues are resolved and verified, include [TASK_COMPLETE] in your response."
|
|
844
|
+
].join("\n")
|
|
845
|
+
const fixOut = await processTurnLoop({
|
|
846
|
+
prompt: fixPrompt, mode: "agent", agent: getAgent("coding-agent"),
|
|
847
|
+
model, providerType, sessionId, configState,
|
|
848
|
+
baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
|
|
849
|
+
})
|
|
850
|
+
accumulateUsage(fixOut)
|
|
851
|
+
iteration++
|
|
852
|
+
if (/\[TASK_COMPLETE\]/i.test(fixOut.reply || "")) completionMarkerSeen = true
|
|
853
|
+
finalReply = fixOut.reply || finalReply
|
|
854
|
+
}
|
|
855
|
+
} catch (valErr) {
|
|
856
|
+
gateStatus.completionValidation = { status: "warn", reason: `skipped: ${valErr.message}` }
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ========== H6: USABILITY GATES ==========
|
|
861
|
+
await setPhase("H6", "gates")
|
|
862
|
+
currentGate = "gates"
|
|
863
|
+
await syncState({ lastMessage: "H6: running usability gates" })
|
|
864
|
+
|
|
865
|
+
// Gate 偏好提示(首次运行时询问用户)
|
|
866
|
+
const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
|
|
867
|
+
if (shouldPromptGates && allowQuestion) {
|
|
868
|
+
const hasPrefs = await hasGatePreferences()
|
|
869
|
+
if (!hasPrefs || gatesConfig.prompt_user === "always") {
|
|
870
|
+
const gateAskResult = await processTurnLoop({
|
|
871
|
+
prompt: buildGatePromptText(),
|
|
872
|
+
mode: "ask", model, providerType, sessionId, configState,
|
|
873
|
+
baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
|
|
874
|
+
})
|
|
875
|
+
accumulateUsage(gateAskResult)
|
|
876
|
+
const gatePrefs = parseGateSelection(gateAskResult.reply)
|
|
877
|
+
await saveGatePreferences(gatePrefs)
|
|
878
|
+
for (const [gate, enabled] of Object.entries(gatePrefs)) {
|
|
879
|
+
if (configState.config.agent.longagent.usability_gates[gate]) {
|
|
880
|
+
configState.config.agent.longagent.usability_gates[gate].enabled = enabled
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
} else {
|
|
884
|
+
const savedPrefs = await getGatePreferences()
|
|
885
|
+
if (savedPrefs) {
|
|
886
|
+
for (const [gate, enabled] of Object.entries(savedPrefs)) {
|
|
887
|
+
if (configState.config.agent.longagent.usability_gates[gate]) {
|
|
888
|
+
configState.config.agent.longagent.usability_gates[gate].enabled = enabled
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
let gateAttempt = 0
|
|
896
|
+
|
|
897
|
+
while (gateAttempt < maxGateAttempts) {
|
|
898
|
+
gateAttempt++
|
|
899
|
+
const state = await LongAgentManager.get(sessionId)
|
|
900
|
+
if (state?.stopRequested || signal?.aborted) break
|
|
901
|
+
|
|
902
|
+
const gateResult = await runUsabilityGates({
|
|
903
|
+
sessionId, configState, model, providerType,
|
|
904
|
+
baseUrl, apiKeyEnv, signal, toolContext,
|
|
905
|
+
objective: prompt, fileChanges,
|
|
906
|
+
gatesConfig, allowQuestion
|
|
907
|
+
})
|
|
908
|
+
if (gateResult.usage) accumulateUsage(gateResult)
|
|
909
|
+
|
|
910
|
+
if (gateResult.allPassed) {
|
|
911
|
+
gateStatus.usabilityGates = { status: "pass", attempt: gateAttempt }
|
|
912
|
+
break
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
lastGateFailures = gateResult.failures || []
|
|
916
|
+
gateStatus.usabilityGates = { status: "fixing", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
|
|
917
|
+
await syncState({ lastMessage: `H6: gate failures (attempt ${gateAttempt}/${maxGateAttempts}), fixing...` })
|
|
918
|
+
|
|
919
|
+
// 修复循环:根据 gate 类型选择修复策略 (Phase 8)
|
|
920
|
+
const strategy = getGateFixStrategy(lastGateFailures)
|
|
921
|
+
|
|
922
|
+
// lint 失败时先尝试自动修复
|
|
923
|
+
if (strategy.autoFix) {
|
|
924
|
+
try {
|
|
925
|
+
const { execSync } = await import("node:child_process")
|
|
926
|
+
execSync(strategy.autoFix, { cwd: process.cwd(), timeout: 30000, stdio: "ignore" })
|
|
927
|
+
} catch { /* autofix failed, fall through to agent */ }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const gateFailureSummary = summarizeGateFailures(lastGateFailures)
|
|
931
|
+
const fixPrompt = [
|
|
932
|
+
`## Quality Gate Failures — Attempt ${gateAttempt}/${maxGateAttempts}`,
|
|
933
|
+
"",
|
|
934
|
+
`${strategy.prefix || "Fix the following quality gate failures:"}`,
|
|
935
|
+
"",
|
|
936
|
+
gateFailureSummary,
|
|
937
|
+
"",
|
|
938
|
+
"## Fix Protocol",
|
|
939
|
+
"1. Read the error output carefully — identify the ROOT CAUSE, not just the symptom",
|
|
940
|
+
"2. Fix the source code (do NOT disable or skip the gate check)",
|
|
941
|
+
"3. Re-run the failing command to verify the fix works",
|
|
942
|
+
"4. If the fix touches shared code, verify no regressions in other modules",
|
|
943
|
+
"",
|
|
944
|
+
`Original objective: ${prompt}`
|
|
945
|
+
].join("\n")
|
|
946
|
+
const fixOut = await processTurnLoop({
|
|
947
|
+
prompt: fixPrompt, mode: "agent", agent: getAgent(strategy.agent || "coding-agent"),
|
|
948
|
+
model, providerType, sessionId, configState,
|
|
949
|
+
baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
|
|
950
|
+
})
|
|
951
|
+
accumulateUsage(fixOut)
|
|
952
|
+
iteration++
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (gateAttempt >= maxGateAttempts && lastGateFailures.length) {
|
|
956
|
+
gateStatus.usabilityGates = { status: "fail", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ========== H7: GIT MERGE ==========
|
|
960
|
+
if (gitActive && gitBaseBranch && gitBranch) {
|
|
961
|
+
await setPhase("H7", "git_merge")
|
|
962
|
+
try {
|
|
963
|
+
await git.commitAll(`[kkcode-hybrid] session ${sessionId} completed`, cwd)
|
|
964
|
+
if (gitConfig.auto_merge !== false) {
|
|
965
|
+
await git.checkoutBranch(gitBaseBranch, cwd)
|
|
966
|
+
await git.mergeBranch(gitBranch, cwd)
|
|
967
|
+
await git.deleteBranch(gitBranch, cwd)
|
|
968
|
+
gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch }
|
|
969
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
|
|
970
|
+
}
|
|
971
|
+
} catch (err) {
|
|
972
|
+
// Phase 9: 自愈式 Git 操作
|
|
973
|
+
if (git.isConflictError(err)) {
|
|
974
|
+
try {
|
|
975
|
+
const conflictFiles = await git.getConflictFiles(cwd)
|
|
976
|
+
if (conflictFiles.length > 0) {
|
|
977
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_CONFLICT_RESOLUTION, sessionId, payload: { files: conflictFiles } })
|
|
978
|
+
const conflictPrompt = [
|
|
979
|
+
"## Git Merge Conflict Resolution",
|
|
980
|
+
"",
|
|
981
|
+
"The following files have merge conflicts that must be resolved:",
|
|
982
|
+
...conflictFiles.map(f => `- ${f}`),
|
|
983
|
+
"",
|
|
984
|
+
"## Resolution Protocol",
|
|
985
|
+
"1. Read each conflicted file and locate ALL conflict markers (<<<<<<< ======= >>>>>>>)",
|
|
986
|
+
"2. For each conflict block:",
|
|
987
|
+
" - Understand what BOTH sides intended (ours = feature branch, theirs = base branch)",
|
|
988
|
+
" - Keep the feature branch changes (our work) unless they break base branch functionality",
|
|
989
|
+
" - If both sides modified the same logic, merge them intelligently (not just pick one)",
|
|
990
|
+
" - Remove ALL conflict markers — no <<<<<<< or ======= or >>>>>>> should remain",
|
|
991
|
+
"3. After resolving, run syntax check on each file (node --check / python -m py_compile)",
|
|
992
|
+
"4. Verify imports still resolve correctly across resolved files"
|
|
993
|
+
].join("\n")
|
|
994
|
+
const conflictOut = await processTurnLoop({
|
|
995
|
+
prompt: conflictPrompt, mode: "agent", agent: getAgent("coding-agent"),
|
|
996
|
+
model, providerType, sessionId, configState,
|
|
997
|
+
baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
|
|
998
|
+
})
|
|
999
|
+
accumulateUsage(conflictOut)
|
|
1000
|
+
const commitResult = await git.commitAll(`[kkcode-hybrid] resolved merge conflicts`, cwd)
|
|
1001
|
+
if (commitResult.ok) {
|
|
1002
|
+
gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch, conflictsResolved: true }
|
|
1003
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
|
|
1004
|
+
} else {
|
|
1005
|
+
await git.mergeAbort(cwd)
|
|
1006
|
+
gateStatus.gitMerge = { status: "warn", reason: "conflict resolution failed, staying on feature branch" }
|
|
1007
|
+
}
|
|
1008
|
+
} else {
|
|
1009
|
+
gateStatus.gitMerge = { status: "warn", reason: err.message }
|
|
1010
|
+
}
|
|
1011
|
+
} catch (resolveErr) {
|
|
1012
|
+
await git.mergeAbort(cwd).catch(() => {})
|
|
1013
|
+
gateStatus.gitMerge = { status: "warn", reason: `conflict resolution error: ${resolveErr.message}` }
|
|
1014
|
+
}
|
|
1015
|
+
} else {
|
|
1016
|
+
gateStatus.gitMerge = { status: "warn", reason: err.message }
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// #5 保存 project memory
|
|
1022
|
+
if (hybridConfig.project_memory !== false && previewFindings) {
|
|
1023
|
+
try {
|
|
1024
|
+
const newMemory = parseMemoryFromPreview(previewFindings)
|
|
1025
|
+
if (newMemory.techStack.length) {
|
|
1026
|
+
const merged = { ...projectMemory, techStack: [...new Set([...(projectMemory?.techStack || []), ...newMemory.techStack])].slice(0, 20), patterns: [...new Set([...(projectMemory?.patterns || []), ...newMemory.patterns])].slice(0, 20), conventions: projectMemory?.conventions || [] }
|
|
1027
|
+
await saveProjectMemory(cwd, merged)
|
|
1028
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_MEMORY_SAVED, sessionId, payload: { techStackCount: merged.techStack.length } })
|
|
1029
|
+
}
|
|
1030
|
+
} catch { /* ignore memory save errors */ }
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Phase 10: Checkpoint 清理
|
|
1034
|
+
if (hybridConfig.checkpoint_cleanup !== false) {
|
|
1035
|
+
try {
|
|
1036
|
+
const cleanResult = await cleanupCheckpoints(sessionId, {
|
|
1037
|
+
maxKeep: Number(hybridConfig.checkpoint_max_keep || 10),
|
|
1038
|
+
keepStageCheckpoints: true
|
|
1039
|
+
})
|
|
1040
|
+
if (cleanResult.removed > 0) {
|
|
1041
|
+
await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_CHECKPOINT_CLEANED, sessionId, payload: { removed: cleanResult.removed } })
|
|
1042
|
+
}
|
|
1043
|
+
} catch { /* ignore cleanup errors */ }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ========== 完成 ==========
|
|
1047
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
|
1048
|
+
const finalStatus = completionMarkerSeen ? "completed" : "done"
|
|
1049
|
+
await LongAgentManager.update(sessionId, { status: finalStatus, lastMessage: "hybrid longagent complete", elapsed })
|
|
1050
|
+
await markSessionStatus(sessionId, finalStatus === "completed" ? "completed" : "active")
|
|
1051
|
+
|
|
1052
|
+
const stats = stageProgressStats(taskProgress)
|
|
1053
|
+
|
|
1054
|
+
// Phase 11: 恢复建议生成
|
|
1055
|
+
let recoverySuggestions = null
|
|
1056
|
+
if (finalStatus !== "completed") {
|
|
1057
|
+
recoverySuggestions = generateRecoverySuggestions({
|
|
1058
|
+
status: finalStatus,
|
|
1059
|
+
taskProgress,
|
|
1060
|
+
gateStatus,
|
|
1061
|
+
phase: currentPhase,
|
|
1062
|
+
recoveryCount,
|
|
1063
|
+
fileChanges
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return {
|
|
1068
|
+
sessionId, turnId: `turn_long_${Date.now()}`,
|
|
1069
|
+
reply: finalReply || "hybrid longagent complete",
|
|
1070
|
+
usage: aggregateUsage, toolEvents, iterations: iteration,
|
|
1071
|
+
status: finalStatus, phase: currentPhase,
|
|
1072
|
+
gateStatus, currentGate, lastGateFailures, recoveryCount,
|
|
1073
|
+
progress: lastProgress, elapsed,
|
|
1074
|
+
stageIndex, stageCount: stagePlan?.stages?.length || 0,
|
|
1075
|
+
planFrozen, taskProgress, fileChanges,
|
|
1076
|
+
stageProgress: { done: stats.done, total: stats.total },
|
|
1077
|
+
remainingFilesCount: stats.remainingFilesCount,
|
|
1078
|
+
gitBranch, gitBaseBranch,
|
|
1079
|
+
recoverySuggestions
|
|
1080
|
+
}
|
|
1081
|
+
}
|