pi-web-ui 0.96.1 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -2
- package/README.md +26 -0
- package/README.zh-CN.md +25 -0
- package/bin/pi-web-ui.mjs +11 -2
- package/dist/server/agent-service.js +1195 -120
- package/dist/server/client-state.js +89 -1
- package/dist/server/compacted-history.js +146 -0
- package/dist/server/delegate-mode.js +149 -0
- package/dist/server/delegate-task.js +10 -10
- package/dist/server/dsh/dsh-agent-service.js +35 -2
- package/dist/server/dsh/dsh-client.js +3 -1
- package/dist/server/eval-tool.js +3 -1
- package/dist/server/goal-review-gate.js +263 -0
- package/dist/server/goal-service.js +1225 -445
- package/dist/server/index.js +35 -1
- package/dist/server/lsp-tool.js +13 -10
- package/dist/server/markers/builtins/todo.js +0 -2
- package/dist/server/mcp-bridge.js +13 -0
- package/dist/server/patch-tool.js +1 -0
- package/dist/server/patch-turn-end-boundary.js +77 -0
- package/dist/server/permission-preset.js +44 -0
- package/dist/server/plan-mode.js +376 -0
- package/dist/server/plugin-facilities.js +36 -3
- package/dist/server/plugins.js +6 -3
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/serialize.js +11 -0
- package/dist/server/settings-service.js +18 -6
- package/dist/server/subagents.js +360 -408
- package/dist/server/text-sniff.js +15 -9
- package/dist/server/tool-manager.js +48 -4
- package/dist/server/update-check.js +11 -3
- package/dist/server/wait-subscription-scan.js +5 -1
- package/package.json +3 -3
- package/themes/zhupi-dark.css +5 -30
- package/themes/zhupi.css +5 -30
- package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-CITBqQrb.js} +1 -1
- package/web/dist/assets/index-DwToli0J.css +1 -0
- package/web/dist/assets/index-oI7UKOqJ.js +368 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B7Zp23Vc.css +0 -1
- package/web/dist/assets/index-ByfoaDwp.js +0 -366
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* goal-service —
|
|
2
|
+
* goal-service — 目标模式 2.0:目标状态机 + 服务端驱动的「执行 ↔ 审查」循环 + 调研向导,
|
|
3
|
+
* 从 agent-service.ts 抽出。
|
|
3
4
|
*
|
|
4
5
|
* 职责:
|
|
5
6
|
* - setGoal/clearGoal/setGoalPrefs:目标状态机 + 偏好「全局记忆」(client-state.json)
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
7
|
+
* - 委托循环(**唯一**的审查/执行路径,目标模式 2.0):主对话 = 审查者,服务端另起一个
|
|
8
|
+
* 常驻执行对话干活。一轮 = 派活 → 等它结束 → 取样(工作区 diff 指纹 + 执行者会话的
|
|
9
|
+
* 错误特征)→ 把审查指令交给主对话 → 解析 verdict → 判定(pass / 再来一轮 /
|
|
10
|
+
* 熔断 / 预算用尽)。轮次、熔断、代次全在服务端,模型不得自循环。
|
|
8
11
|
* - startGoalWizard:AI 提炼——独立调研会话经 goal_ask 工具逐题提问(对话框桥接浏览器),
|
|
9
|
-
* 收敛出 GOAL:
|
|
12
|
+
* 收敛出 GOAL: 后自动设为目标并启动循环
|
|
13
|
+
*
|
|
14
|
+
* 历史说明:v1 的「隐藏隔离审查会话 + 自治标记」路径(runGoalReview / isAutonomous /
|
|
15
|
+
* GOAL_COMPLETION_RE)已按产品决策删除,只留委托一条。`GoalStatus.reviewModel` 字段名
|
|
16
|
+
* 保留(DSH 引擎仍在构造该状态),pi 侧语义已变为「调研(向导)模型」记忆位 ——
|
|
17
|
+
* 审查者在委托模式下就是主对话,不再有独立的审查模型。
|
|
10
18
|
*
|
|
11
19
|
* 经 GoalHost 窄接口与 ClientSession 解耦(同 settings-service 模式):对话记录按
|
|
12
20
|
* 结构化子集 GoalConversation 传入(真实 Conversation 满足该结构),会话创建/对话框
|
|
@@ -18,76 +26,138 @@ import { Type } from "typebox";
|
|
|
18
26
|
import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
19
27
|
import { pick } from "./i18n.js";
|
|
20
28
|
import { parseModelSpec } from "./attachments.js";
|
|
29
|
+
function allBalancedJsonObjects(raw) {
|
|
30
|
+
const src = raw.length > 32768 ? raw.slice(-32768) : raw;
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let start = src.indexOf("{", 0); start >= 0; start = src.indexOf("{", start + 1)) {
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let inString = false;
|
|
35
|
+
let escaped = false;
|
|
36
|
+
for (let i = start; i < src.length; i++) {
|
|
37
|
+
const ch = src[i];
|
|
38
|
+
if (inString) {
|
|
39
|
+
if (escaped)
|
|
40
|
+
escaped = false;
|
|
41
|
+
else if (ch === "\\")
|
|
42
|
+
escaped = true;
|
|
43
|
+
else if (ch === '"')
|
|
44
|
+
inString = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '"')
|
|
48
|
+
inString = true;
|
|
49
|
+
else if (ch === "{")
|
|
50
|
+
depth++;
|
|
51
|
+
else if (ch === "}") {
|
|
52
|
+
depth--;
|
|
53
|
+
if (depth === 0) {
|
|
54
|
+
out.push(src.slice(start, i + 1));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** 剥离复制调研草案卡片时带入的前缀(支持中英文与多层重复,纯函数供单测共用)。 */
|
|
63
|
+
export function stripGoalDraftPrefix(raw) {
|
|
64
|
+
return (raw ?? "")
|
|
65
|
+
.trim()
|
|
66
|
+
.replace(/^(?:(?:🎯\s*)?(?:Initial goal draft|原始目标草案)\s*[::]\s*)+/i, "")
|
|
67
|
+
.trim();
|
|
68
|
+
}
|
|
21
69
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* - GOAL 后必须跟至少一个分隔符(冒号/下划线/空白)且 COMPLETED/PASSED 为整词。
|
|
25
|
-
* 刻意不收裸子串(如「目标已达成」不带括号):模型在计划、复述目标或假设句里
|
|
26
|
-
* 也会写出这些字样("如果测试全绿则目标已达成"),裸匹配会把中间轮误判成 pass。
|
|
70
|
+
* 从主会话消息列表中提取结构化上下文(压缩摘要 + 近期轮次),注入向导 prompt,
|
|
71
|
+
* 避免向导会话处于无上下文的盲搜状态(借鉴 pi-goal-x / pi-plan 的 warm context 设计)。
|
|
27
72
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const start = raw.indexOf("{");
|
|
36
|
-
if (start < 0)
|
|
37
|
-
return undefined;
|
|
38
|
-
let depth = 0;
|
|
39
|
-
let inString = false;
|
|
40
|
-
let escaped = false;
|
|
41
|
-
for (let i = start; i < raw.length; i++) {
|
|
42
|
-
const ch = raw[i];
|
|
43
|
-
if (inString) {
|
|
44
|
-
if (escaped)
|
|
45
|
-
escaped = false;
|
|
46
|
-
else if (ch === "\\")
|
|
47
|
-
escaped = true;
|
|
48
|
-
else if (ch === '"')
|
|
49
|
-
inString = false;
|
|
73
|
+
export function buildWizardConversationContext(messages, maxChars = 16_000) {
|
|
74
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
75
|
+
return "";
|
|
76
|
+
let summaryPart = "";
|
|
77
|
+
const recentLines = [];
|
|
78
|
+
for (const m of messages) {
|
|
79
|
+
if (!m || typeof m !== "object")
|
|
50
80
|
continue;
|
|
81
|
+
const msg = m;
|
|
82
|
+
if (msg.role === "compactionSummary" && typeof msg.summary === "string" && msg.summary.trim()) {
|
|
83
|
+
const s = msg.summary.trim();
|
|
84
|
+
summaryPart = s.length > 8000 ? s.slice(0, 4000) + "\n...\n" + s.slice(-4000) : s;
|
|
51
85
|
}
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
86
|
+
else if (msg.role === "user" || msg.role === "assistant") {
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (Array.isArray(msg.content)) {
|
|
89
|
+
for (const c of msg.content) {
|
|
90
|
+
if (!c || typeof c !== "object")
|
|
91
|
+
continue;
|
|
92
|
+
const block = c;
|
|
93
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
94
|
+
parts.push(block.text.trim());
|
|
95
|
+
}
|
|
96
|
+
else if (block.type === "toolCall" && block.name) {
|
|
97
|
+
const args = block.arguments;
|
|
98
|
+
const target = args?.path || args?.file_path || args?.pattern || args?.command || args?.query || "";
|
|
99
|
+
parts.push(`[tool:${block.name}${target ? " " + String(target).slice(0, 80) : ""}]`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (typeof msg.content === "string" && msg.content.trim()) {
|
|
104
|
+
parts.push(msg.content.trim());
|
|
105
|
+
}
|
|
106
|
+
if (parts.length > 0) {
|
|
107
|
+
const joined = parts.join(" ").slice(0, 2000);
|
|
108
|
+
recentLines.push(`[${msg.role}]: ${joined}`);
|
|
109
|
+
}
|
|
60
110
|
}
|
|
61
111
|
}
|
|
62
|
-
|
|
112
|
+
const recentBudget = summaryPart ? Math.max(4000, maxChars - summaryPart.length) : maxChars;
|
|
113
|
+
let recentAcc = "";
|
|
114
|
+
for (let i = recentLines.length - 1; i >= 0; i--) {
|
|
115
|
+
const line = recentLines[i];
|
|
116
|
+
if (recentAcc.length + line.length + 1 > recentBudget)
|
|
117
|
+
break;
|
|
118
|
+
recentAcc = line + (recentAcc ? "\n" + recentAcc : "");
|
|
119
|
+
}
|
|
120
|
+
const sections = [];
|
|
121
|
+
if (summaryPart)
|
|
122
|
+
sections.push(`## Previous Context Summary\n${summaryPart}`);
|
|
123
|
+
if (recentAcc)
|
|
124
|
+
sections.push(`## Recent Conversation Turns\n${recentAcc}`);
|
|
125
|
+
return sections.join("\n\n").slice(0, maxChars);
|
|
63
126
|
}
|
|
64
127
|
/**
|
|
65
|
-
* 解析审查模型的 verdict
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
128
|
+
* 解析审查模型的 verdict 输出(纯函数)。扫描全文所有平衡 {...} 做 JSON.parse,
|
|
129
|
+
* **最后一个合法 verdict 胜出**:模型常先复述契约里的示例 JSON 再给真正结论,
|
|
130
|
+
* 取第一个会被示例带偏(复述 pass 示例 + 真结论 fail → 假通过)。feedback 里的
|
|
131
|
+
* \" 转义与嵌套引号由 JSON 语义天然处理;都不是合法 JSON(单引号/尾逗号等)
|
|
132
|
+
* 再退回宽松正则逐字段抠(同样以后出现者为准)。两者都失败返回 undefined,
|
|
133
|
+
* 调用方按「无 JSON」处理。
|
|
69
134
|
*/
|
|
70
135
|
export function parseReviewerVerdict(raw) {
|
|
71
|
-
|
|
72
|
-
|
|
136
|
+
let found;
|
|
137
|
+
for (const json of allBalancedJsonObjects(raw)) {
|
|
73
138
|
try {
|
|
74
139
|
const value = JSON.parse(json);
|
|
75
140
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
76
141
|
if (value.verdict === "pass" || value.verdict === "fail") {
|
|
77
|
-
|
|
142
|
+
found = { verdict: value.verdict, feedback: typeof value.feedback === "string" ? value.feedback : "" };
|
|
78
143
|
}
|
|
79
144
|
}
|
|
80
145
|
}
|
|
81
146
|
catch {
|
|
82
|
-
// 不是合法 JSON(围栏残留/单引号/尾逗号)→
|
|
147
|
+
// 不是合法 JSON(围栏残留/单引号/尾逗号)→ 继续扫下一个,实在没有落正则兜底
|
|
83
148
|
}
|
|
84
149
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
150
|
+
if (found)
|
|
151
|
+
return found;
|
|
152
|
+
const re = /\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/g;
|
|
153
|
+
let m;
|
|
154
|
+
let last;
|
|
155
|
+
while ((m = re.exec(raw)) !== null) {
|
|
156
|
+
const block = m[0];
|
|
157
|
+
const fm = block.match(/"feedback"\s*:\s*"([^"]*)"/);
|
|
158
|
+
last = { verdict: m[1], feedback: fm?.[1] ?? "" };
|
|
89
159
|
}
|
|
90
|
-
return
|
|
160
|
+
return last;
|
|
91
161
|
}
|
|
92
162
|
/** diff 正文进审查 prompt 的截断上限(完整规模信息走 [diff-meta] 尾段)。 */
|
|
93
163
|
export const GIT_DIFF_CAP = 60_000;
|
|
@@ -117,23 +187,174 @@ export function buildDiffFingerprint(diffOut, statusOut) {
|
|
|
117
187
|
const meta = `\n[diff-meta] chars=${diffOut.length}${status ? `\n[git-status]\n${status}` : ""}`;
|
|
118
188
|
return diffOut.slice(0, GIT_DIFF_CAP) + meta;
|
|
119
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* 提取会话最近产生的错误特征(纯函数,供停滞与相同报错检测)。
|
|
192
|
+
* 从后往前扫最近 6 条消息:toolResult.isError 优先,其次非 0 退出的 bashExecution;
|
|
193
|
+
* 都没有再退回从最终文本里找 Error/Exception/Fail/Fatal 行。
|
|
194
|
+
* 抽成纯函数是因为委托执行(Plan A)要拿**执行者子代理**的错误特征(而不是主对话的)。
|
|
195
|
+
*/
|
|
196
|
+
export function extractErrorSnippetFromSession(session, text) {
|
|
197
|
+
try {
|
|
198
|
+
const messages = session?.agent?.state?.messages;
|
|
199
|
+
if (Array.isArray(messages)) {
|
|
200
|
+
for (let i = messages.length - 1; i >= 0 && i >= messages.length - 6; i--) {
|
|
201
|
+
const m = messages[i];
|
|
202
|
+
if (m.role === "toolResult" && m.isError) {
|
|
203
|
+
const errText = m.content
|
|
204
|
+
?.map((c) => (c.type === "text" ? c.text : ""))
|
|
205
|
+
.join(" ")
|
|
206
|
+
.trim();
|
|
207
|
+
if (errText)
|
|
208
|
+
return errText.slice(0, 300);
|
|
209
|
+
}
|
|
210
|
+
if (m.role === "bashExecution" && m.exitCode && m.exitCode !== 0) {
|
|
211
|
+
const snippet = m.output?.trim().slice(-300);
|
|
212
|
+
if (snippet)
|
|
213
|
+
return `bash exit ${m.exitCode}: ${snippet}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Ignore
|
|
220
|
+
}
|
|
221
|
+
const errMatch = text.match(/(?:(?:Error|Exception|Fail|Fatal):[^\n]+)/i);
|
|
222
|
+
if (errMatch) {
|
|
223
|
+
return errMatch[0].trim().slice(0, 300);
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 该会话最近用过的工具名(纯函数,供目标条「执行者在干什么」用)。
|
|
229
|
+
* 从后往前扫最近 6 条消息,最新的一次 toolCall 即结果;6 条里没有则返回
|
|
230
|
+
* undefined(不翻旧账:几轮前的工具不是「正在干」)。
|
|
231
|
+
*/
|
|
232
|
+
export function lastToolNameOfSession(session) {
|
|
233
|
+
try {
|
|
234
|
+
const messages = session?.agent?.state?.messages;
|
|
235
|
+
if (!Array.isArray(messages))
|
|
236
|
+
return undefined;
|
|
237
|
+
for (let i = messages.length - 1; i >= 0 && i >= messages.length - 6; i--) {
|
|
238
|
+
const m = messages[i];
|
|
239
|
+
if (m.role !== "assistant" || !Array.isArray(m.content))
|
|
240
|
+
continue;
|
|
241
|
+
for (let j = m.content.length - 1; j >= 0; j--) {
|
|
242
|
+
const b = m.content[j];
|
|
243
|
+
if (b?.type === "toolCall" && typeof b.name === "string" && b.name.trim() !== "") {
|
|
244
|
+
return b.name.trim();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Ignore
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* 解析向导输出中的目标与结构化执行计划(纯函数)。
|
|
256
|
+
* - 目标从 `GOAL: ...` 提取;
|
|
257
|
+
* - 步骤从 `STEPS:` 或后续数字/无序列表行提取(若有),作为 PlanStep 供看板使用。
|
|
258
|
+
*/
|
|
259
|
+
export function parseWizardOutput(raw) {
|
|
260
|
+
const trimmed = (raw ?? "").trim();
|
|
261
|
+
if (!trimmed)
|
|
262
|
+
return { goal: "", steps: [] };
|
|
263
|
+
let goalText = "";
|
|
264
|
+
let stepsPart = "";
|
|
265
|
+
// 先看是否有明确的 STEPS: / PLAN: / TASKS: 标头
|
|
266
|
+
const stepsHeaderMatch = trimmed.match(/\n\s*(?:STEPS|PLAN|TASKS)\s*[::]\s*([\s\S]*)/i);
|
|
267
|
+
let preSteps = trimmed;
|
|
268
|
+
if (stepsHeaderMatch) {
|
|
269
|
+
preSteps = trimmed.slice(0, stepsHeaderMatch.index).trim();
|
|
270
|
+
stepsPart = stepsHeaderMatch[1].trim();
|
|
271
|
+
}
|
|
272
|
+
// 从前半部分提取 GOAL:
|
|
273
|
+
const goalMatch = preSteps.match(/GOAL\s*[::]\s*([\s\S]*)/i);
|
|
274
|
+
if (goalMatch) {
|
|
275
|
+
goalText = goalMatch[1].trim();
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
// 没有显式 GOAL: 标头时,按原来逻辑去掉前导前言行
|
|
279
|
+
const lines = preSteps.split("\n").filter((l) => l.trim());
|
|
280
|
+
if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
|
|
281
|
+
goalText = lines.slice(1).join(" ").trim();
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
goalText = preSteps;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// 解析 stepsPart
|
|
288
|
+
const steps = [];
|
|
289
|
+
if (stepsPart) {
|
|
290
|
+
const stepLines = stepsPart
|
|
291
|
+
.split("\n")
|
|
292
|
+
.map((l) => l.trim())
|
|
293
|
+
.filter(Boolean);
|
|
294
|
+
let stepIdx = 1;
|
|
295
|
+
for (const line of stepLines) {
|
|
296
|
+
if (line.startsWith("(") && line.endsWith(")"))
|
|
297
|
+
continue;
|
|
298
|
+
const itemMatch = line.match(/^(?:(?:\d+[.、)]|-|\*|\[\s*\])\s*)*(?:(?:Step\s*\d+|步骤\s*\d+)[::\s]*)?(.+)$/i);
|
|
299
|
+
if (!itemMatch)
|
|
300
|
+
continue;
|
|
301
|
+
const content = itemMatch[1].trim();
|
|
302
|
+
if (!content)
|
|
303
|
+
continue;
|
|
304
|
+
let title = content;
|
|
305
|
+
let description;
|
|
306
|
+
const splitIdx = content.indexOf("|");
|
|
307
|
+
if (splitIdx > 0) {
|
|
308
|
+
title = content.slice(0, splitIdx).trim();
|
|
309
|
+
description = content.slice(splitIdx + 1).trim();
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const colonIdx = content.indexOf(":") !== -1 ? content.indexOf(":") : content.indexOf(":");
|
|
313
|
+
if (colonIdx > 0 && colonIdx < 40) {
|
|
314
|
+
title = content.slice(0, colonIdx).trim();
|
|
315
|
+
description = content.slice(colonIdx + 1).trim();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
steps.push({
|
|
319
|
+
id: `step-${stepIdx}`,
|
|
320
|
+
title: title.slice(0, 200),
|
|
321
|
+
status: "pending",
|
|
322
|
+
...(description ? { description: description.slice(0, 1000) } : {}),
|
|
323
|
+
});
|
|
324
|
+
stepIdx++;
|
|
325
|
+
if (steps.length >= 10)
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
goal: goalText,
|
|
331
|
+
steps,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
120
334
|
/** System prompt for the goal-wizard session. The wizard asks the user a few
|
|
121
335
|
* questions (via its goal_ask tool) to scope a raw requirement into a precise,
|
|
122
336
|
* reviewable goal, then emits ONLY the final goal text as its last message. */
|
|
123
|
-
function wizardPrompt(draft) {
|
|
337
|
+
function wizardPrompt(draft, contextSummary = "") {
|
|
124
338
|
return [
|
|
125
339
|
`You are a goal-clarification wizard. The user has stated a raw requirement. Your job is to turn it into ONE precise, actionable goal that a coding agent can fully satisfy and that can be strictly reviewed.`, // eslint-disable-line max-len
|
|
340
|
+
...(contextSummary ? [``, `# Current conversation context (background & recent history)`, contextSummary] : []),
|
|
126
341
|
``,
|
|
127
342
|
`# User's raw requirement`, // eslint-disable-line no-regex-spaces
|
|
128
343
|
draft,
|
|
129
344
|
``,
|
|
130
345
|
`Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details.`,
|
|
131
346
|
`Convergence guidelines:`,
|
|
347
|
+
`- Ground your understanding in the conversation context above so you already know what files, models, and prior work the user is referring to. Do NOT re-ask things already clear from context.`, // eslint-disable-line max-len
|
|
348
|
+
`- If you need to verify a specific file or directory in the workspace, do at most 1 to 3 quick read-only checks (read/ls/find/grep), then IMMEDIATELY call goal_ask. Never do exhaustive exploration or attempt the actual task during scoping.`, // eslint-disable-line max-len
|
|
132
349
|
`- Ask ONE question at a time, strictly 1 to 3 questions total: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment).`, // eslint-disable-line max-len
|
|
133
350
|
`- Prefer multiple-choice with 2-4 mutually exclusive options and place your recommended choice FIRST.`,
|
|
134
351
|
`- In each option, concisely explain the impact or tradeoff. Use open questions only for things that genuinely need free text.`, // eslint-disable-line max-len
|
|
135
352
|
`Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`, // eslint-disable-line max-len
|
|
136
353
|
`GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
|
|
354
|
+
`STEPS:`,
|
|
355
|
+
`1. <first step title> | <brief description / acceptance check>`,
|
|
356
|
+
`2. <second step title> | <brief description / acceptance check>`,
|
|
357
|
+
`(include 2 to 6 concrete, sequential steps for executing the goal)`,
|
|
137
358
|
`If the user cancels or stops answering (the tool reports a cancellation), still produce a sensible best-effort goal from what you already know.`, // eslint-disable-line max-len
|
|
138
359
|
].join("\n");
|
|
139
360
|
}
|
|
@@ -145,6 +366,8 @@ export class GoalService {
|
|
|
145
366
|
reviewModel: null,
|
|
146
367
|
maxRounds: 0,
|
|
147
368
|
locked: true,
|
|
369
|
+
/** 目标模式 2.0:委托执行者模型。 */
|
|
370
|
+
execModel: null,
|
|
148
371
|
};
|
|
149
372
|
/** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
|
|
150
373
|
* the in-flight goal_ask dialog to resolve as cancelled and (via the run
|
|
@@ -158,11 +381,26 @@ export class GoalService {
|
|
|
158
381
|
/** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
|
|
159
382
|
* startGoalWizard reads this after the run to avoid setting a goal. */
|
|
160
383
|
wizardCancelled = false;
|
|
384
|
+
// ---------------------------------------------------------------------
|
|
385
|
+
// 目标模式 2.0(只有委托执行)的循环状态。
|
|
386
|
+
// ---------------------------------------------------------------------
|
|
387
|
+
/** 正在等主对话(审查者)verdict 的循环:convId → resolve。 */
|
|
388
|
+
verdictWaiters = new Map();
|
|
389
|
+
/** 「已把审查指令交给主对话」的观测点(waitForVerdict 登记后触发;测试/宿主观测用)。 */
|
|
390
|
+
verdictSignals = new Map();
|
|
391
|
+
/** 每个对话至多一个在飞循环(convid → promise)。 */
|
|
392
|
+
delegatedLoops = new Map();
|
|
393
|
+
/** 旧循环还在退场时到来的新启动请求(convId → 最新一次):旧循环收束后接力
|
|
394
|
+
* 启动。没有它的话,setGoal 紧跟 clearGoal 会因「表里还有旧循环」而静默早退,
|
|
395
|
+
* 新目标永远不派活(状态却写着「等待生成」)。 */
|
|
396
|
+
delegatedPending = new Map();
|
|
161
397
|
/** Idle-timeout for the wizard: if no answer arrives within this window (a
|
|
162
398
|
* dialog is up but the user doesn't respond), the wizard is auto-cancelled. */
|
|
163
399
|
static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
164
400
|
/** Absolute deadline for the whole wizard session (model latency guard). */
|
|
165
401
|
static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
|
|
402
|
+
/** 执行者连续 error/timeout 超过该轮数 → 直接受阻(不限轮下也不会无限空转)。 */
|
|
403
|
+
static EXEC_FAILED_ROUNDS_LIMIT = 2;
|
|
166
404
|
constructor(host) {
|
|
167
405
|
this.host = host;
|
|
168
406
|
// Restore last-used goal/review preferences so model & rounds survive reload.
|
|
@@ -172,13 +410,11 @@ export class GoalService {
|
|
|
172
410
|
reviewModel: gPrefs.reviewModel,
|
|
173
411
|
maxRounds: gPrefs.maxRounds,
|
|
174
412
|
locked: gPrefs.locked,
|
|
413
|
+
execModel: gPrefs.execModel ?? null,
|
|
175
414
|
};
|
|
176
415
|
}
|
|
177
416
|
}
|
|
178
417
|
/** Remembered defaults (model choice / rounds cap / lock). */
|
|
179
|
-
get reviewPrefs() {
|
|
180
|
-
return this.prefs;
|
|
181
|
-
}
|
|
182
418
|
/** 当前服务端语言(英文默认,未接线前保持原有英文行为)。 */
|
|
183
419
|
lang() {
|
|
184
420
|
return this.host.lang?.() ?? "en";
|
|
@@ -196,6 +432,8 @@ export class GoalService {
|
|
|
196
432
|
reviewModel: this.prefs.reviewModel,
|
|
197
433
|
maxRounds: this.prefs.maxRounds,
|
|
198
434
|
locked: this.prefs.locked,
|
|
435
|
+
execModel: this.prefs.execModel,
|
|
436
|
+
phase: "idle",
|
|
199
437
|
reviewing: false,
|
|
200
438
|
round: 0,
|
|
201
439
|
status: "",
|
|
@@ -213,11 +451,22 @@ export class GoalService {
|
|
|
213
451
|
/** Push the active conversation's goal status to the client (the goal bar
|
|
214
452
|
* restores remembered prefs when nothing is active). */
|
|
215
453
|
emitGoalStatus() {
|
|
216
|
-
const
|
|
454
|
+
const conv = this.host.activeConv();
|
|
455
|
+
const goal = conv.goal;
|
|
456
|
+
// 本目标累计用量随状态下发(目标条展示用;无累计即不发字段)。
|
|
457
|
+
if (conv.goalUsage) {
|
|
458
|
+
goal.usage = { inputTokens: conv.goalUsage.input, outputTokens: conv.goalUsage.output };
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
goal.usage = undefined;
|
|
462
|
+
}
|
|
463
|
+
// 目标历史随状态下发(清目标不清空,内存态)。
|
|
464
|
+
goal.history = conv.goalHistory && conv.goalHistory.length > 0 ? [...conv.goalHistory] : undefined;
|
|
217
465
|
if (!goal.goal && !goal.reviewing && !goal.wizard.active) {
|
|
218
466
|
goal.reviewModel = this.prefs.reviewModel;
|
|
219
467
|
goal.maxRounds = this.prefs.maxRounds;
|
|
220
468
|
goal.locked = this.prefs.locked;
|
|
469
|
+
goal.execModel = this.prefs.execModel;
|
|
221
470
|
}
|
|
222
471
|
this.host.emit({ type: "goal_status", status: { ...goal } });
|
|
223
472
|
}
|
|
@@ -247,6 +496,22 @@ export class GoalService {
|
|
|
247
496
|
});
|
|
248
497
|
return;
|
|
249
498
|
}
|
|
499
|
+
// quiesce(服务排空):新目标是新工作,直接拒绝(宿主的 quiesceBlocked
|
|
500
|
+
// 自带「正在排空」notice,与 prompt/编辑入口同口径)。清目标不受影响。
|
|
501
|
+
if (this.host.quiesceBlocked())
|
|
502
|
+
return;
|
|
503
|
+
// 目标模式 2.0 只有一条路径(执行对话干活 + 当前对话验收),需要宿主提供角色
|
|
504
|
+
// 对话桥(spawn/wait/send/read/stop/dismiss);缺任何一个(未接线 / 非 pi
|
|
505
|
+
// 引擎)则在**动目标状态之前**就拒绝并说明原因。
|
|
506
|
+
if (!this.roleBridgeReady()) {
|
|
507
|
+
this.host.emit({
|
|
508
|
+
type: "notice",
|
|
509
|
+
level: "warning",
|
|
510
|
+
text: "目标模式需要支持「执行对话」的引擎(当前引擎不支持,目标未设置)。",
|
|
511
|
+
textEn: "Goal mode requires an engine that supports executor conversations (unsupported here; the goal was not set).",
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
250
515
|
// A goal is scoped to the conversation it is set on (default: the active
|
|
251
516
|
// one). This prevents an agent_end from a newly-created/switched conversation
|
|
252
517
|
// from consuming the previous conversation's goal.
|
|
@@ -265,6 +530,9 @@ export class GoalService {
|
|
|
265
530
|
const conv = targetConv ?? this.host.activeConv();
|
|
266
531
|
const goalConversationId = conv.id;
|
|
267
532
|
conv.goalGeneration += 1;
|
|
533
|
+
// 上一轮目标可能还留着常驻执行者子代理(委托执行):先停掉再开新的,
|
|
534
|
+
// 否则旧循环会在新目标上继续派活(代次已作废,但角色对话得收)。
|
|
535
|
+
this.stopDelegated(conv);
|
|
268
536
|
const goal = conv.goal;
|
|
269
537
|
goal.reviewing = false;
|
|
270
538
|
goal.conversationId = goalConversationId;
|
|
@@ -280,26 +548,30 @@ export class GoalService {
|
|
|
280
548
|
}
|
|
281
549
|
if (opts?.locked !== undefined)
|
|
282
550
|
goal.locked = opts.locked;
|
|
551
|
+
if (opts?.execModel !== undefined)
|
|
552
|
+
goal.execModel = opts.execModel || null;
|
|
283
553
|
this.prefs = {
|
|
284
554
|
reviewModel: goal.reviewModel,
|
|
285
555
|
maxRounds: goal.maxRounds,
|
|
286
556
|
locked: goal.locked,
|
|
557
|
+
execModel: goal.execModel ?? null,
|
|
287
558
|
};
|
|
288
559
|
// Persist the chosen preferences so they survive reload.
|
|
289
560
|
this.host.stateStore.saveGoalPrefs(this.host.clientId, {
|
|
290
561
|
reviewModel: goal.reviewModel,
|
|
291
562
|
maxRounds: goal.maxRounds,
|
|
292
563
|
locked: goal.locked,
|
|
564
|
+
execModel: this.prefs.execModel,
|
|
293
565
|
});
|
|
294
566
|
// Reset the loop for a freshly-set goal (single-shot goals start at 0).
|
|
295
|
-
conv
|
|
296
|
-
conv.
|
|
297
|
-
conv.lastErrorSnippet = undefined;
|
|
298
|
-
conv.sameErrorRounds = 0;
|
|
567
|
+
this.resetLoopCounters(conv);
|
|
568
|
+
conv.awaitingVerdict = undefined;
|
|
299
569
|
goal.round = 0;
|
|
300
570
|
goal.reviewing = false;
|
|
301
571
|
goal.verdict = "pending";
|
|
302
572
|
goal.feedback = undefined;
|
|
573
|
+
goal.phase = "idle";
|
|
574
|
+
goal.roles = {};
|
|
303
575
|
goal.wizard.active = false;
|
|
304
576
|
goal.wizard.status = "";
|
|
305
577
|
goal.wizard.statusEn = "";
|
|
@@ -316,16 +588,10 @@ export class GoalService {
|
|
|
316
588
|
// the wizard's internal one, which kicks off itself). This makes the direct
|
|
317
589
|
// goal-bar path behave like the AI-提炼 path: set a target → agent begins.
|
|
318
590
|
if (opts?.autoStart !== false) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
deliverAs: s.isStreaming ? "steer" : "followUp",
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
catch {
|
|
327
|
-
// Best-effort; the user can still prompt manually.
|
|
328
|
-
}
|
|
591
|
+
// 目标模式 2.0(唯一路径):主对话当审查者,干活的是常驻执行对话 ——
|
|
592
|
+
// 不向主对话注入「请开始实现」,改由服务端驱动循环(等执行者跑完再把
|
|
593
|
+
// 审查指令交给主对话)。
|
|
594
|
+
this.startDelegatedLoop(conv, conv.goalGeneration, text);
|
|
329
595
|
this.host.flushSnapshot();
|
|
330
596
|
}
|
|
331
597
|
}
|
|
@@ -349,7 +615,7 @@ export class GoalService {
|
|
|
349
615
|
});
|
|
350
616
|
return;
|
|
351
617
|
}
|
|
352
|
-
const draft = (text
|
|
618
|
+
const draft = stripGoalDraftPrefix(text);
|
|
353
619
|
if (!draft)
|
|
354
620
|
return;
|
|
355
621
|
// The wizard and its progress cards belong to the conversation that
|
|
@@ -404,11 +670,13 @@ export class GoalService {
|
|
|
404
670
|
reviewModel: wgoal.reviewModel,
|
|
405
671
|
maxRounds: wgoal.maxRounds,
|
|
406
672
|
locked: wgoal.locked,
|
|
673
|
+
execModel: wgoal.execModel ?? null,
|
|
407
674
|
};
|
|
408
675
|
this.host.stateStore.saveGoalPrefs(this.host.clientId, {
|
|
409
676
|
reviewModel: wgoal.reviewModel,
|
|
410
677
|
maxRounds: wgoal.maxRounds,
|
|
411
678
|
locked: wgoal.locked,
|
|
679
|
+
execModel: wgoal.execModel ?? null,
|
|
412
680
|
});
|
|
413
681
|
wgoal.wizard.step = 0;
|
|
414
682
|
wgoal.wizard.maxSteps = maxSteps;
|
|
@@ -417,9 +685,11 @@ export class GoalService {
|
|
|
417
685
|
wgoal.status = "目标调研中…";
|
|
418
686
|
wgoal.statusEn = "Scoping the goal…";
|
|
419
687
|
this.emitGoalStatus();
|
|
420
|
-
// Idle-timeout: cancel the wizard if
|
|
421
|
-
// window (a stale dialog with no user response must not
|
|
422
|
-
//
|
|
688
|
+
// Idle-timeout: cancel the wizard if the user does NOT answer a pending dialog
|
|
689
|
+
// within the window (a stale dialog with no user response must not hang forever).
|
|
690
|
+
// Note: armed strictly while waiting for the user's answer in goal_ask, and
|
|
691
|
+
// cleared once the user answers — model thinking / read-only checks are governed
|
|
692
|
+
// by totalTimer, avoiding false "waited too long for an answer" timeouts.
|
|
423
693
|
const ac = this.wizardAbort;
|
|
424
694
|
let idleTimer = null;
|
|
425
695
|
const armIdle = () => {
|
|
@@ -439,7 +709,6 @@ export class GoalService {
|
|
|
439
709
|
idleTimer = null;
|
|
440
710
|
}
|
|
441
711
|
};
|
|
442
|
-
armIdle();
|
|
443
712
|
// Total-duration guard: hard cap on the whole wizard session (model
|
|
444
713
|
// latency / unexpected loops must not run forever).
|
|
445
714
|
const totalTimer = setTimeout(() => {
|
|
@@ -466,12 +735,16 @@ export class GoalService {
|
|
|
466
735
|
draft,
|
|
467
736
|
}), { draft });
|
|
468
737
|
let refinedGoal = "";
|
|
738
|
+
let parsedSteps = [];
|
|
469
739
|
let goalEphemeralDir;
|
|
470
740
|
try {
|
|
471
|
-
const wmSpec = opts?.wizardModel ?
|
|
741
|
+
const wmSpec = opts?.wizardModel ? parseModelSpec(opts.wizardModel) : null; // "provider/id" 解析(唯一事实源)
|
|
472
742
|
const services = await createAgentSessionServices({
|
|
473
743
|
cwd: wizardConversation.cwd,
|
|
474
744
|
agentDir: this.host.agentDir,
|
|
745
|
+
resourceLoaderOptions: {
|
|
746
|
+
skillsOverride: (res) => ({ ...res, skills: [] }),
|
|
747
|
+
},
|
|
475
748
|
modelRuntime: await ModelRuntime.create({
|
|
476
749
|
authPath: join(this.host.agentDir, "auth.json"),
|
|
477
750
|
modelsPath: join(this.host.agentDir, "models.json"),
|
|
@@ -529,7 +802,7 @@ export class GoalService {
|
|
|
529
802
|
armIdle();
|
|
530
803
|
const isChoice = !!(params.options && params.options.length > 0);
|
|
531
804
|
const qTitle = pick(lang, `🔍 第 ${qStep} 题:${params.question}`, `🔍 Question ${qStep}: ${params.question}`, "goal.wizard.question.title", { qStep: qStep, "params.question": params.question });
|
|
532
|
-
const optionsJoined = params.options.join(" / ");
|
|
805
|
+
const optionsJoined = isChoice ? params.options.join(" / ") : "";
|
|
533
806
|
const choiceSuffixZh = isChoice ? `【${optionsJoined}】` : "";
|
|
534
807
|
const choiceSuffixEn = isChoice ? ` [${optionsJoined}]` : "";
|
|
535
808
|
await this.pushWizardCard(mainSession, pick(lang, `🔍 第 ${qStep} 题:${params.question}${choiceSuffixZh}`, `🔍 Question ${qStep}: ${params.question}${choiceSuffixEn}`, "goal.wizard.question.card", {
|
|
@@ -547,6 +820,7 @@ export class GoalService {
|
|
|
547
820
|
const choose = isChoice ? ctx.ui.select(qTitle, params.options) : ctx.ui.input(qTitle);
|
|
548
821
|
const ans = (await choose);
|
|
549
822
|
ac.signal.removeEventListener("abort", onAbort);
|
|
823
|
+
clearIdle();
|
|
550
824
|
if (aborted || ac.signal.aborted) {
|
|
551
825
|
return {
|
|
552
826
|
content: [
|
|
@@ -615,11 +889,57 @@ export class GoalService {
|
|
|
615
889
|
services,
|
|
616
890
|
sessionManager: sm,
|
|
617
891
|
customTools: [goalAsk],
|
|
892
|
+
// 仅暴露提问工具与只读检索工具:允许向导在提问前查阅工作区文件细节,
|
|
893
|
+
// 但严禁调用 bash/edit/write 等具破坏性或耗时不可控的写/执行工具。
|
|
894
|
+
tools: ["goal_ask", "read", "ls", "find", "grep"],
|
|
618
895
|
...(model ? { model } : {}),
|
|
619
896
|
});
|
|
620
897
|
const wizard = srv.session;
|
|
621
898
|
this.wizardSession = wizard;
|
|
622
899
|
await wizard.bindExtensions({ mode: "rpc", uiContext: this.host.webUi });
|
|
900
|
+
// 实时反馈:向导查阅上下文文件时,在目标栏展示当前动作,告别黑盒等待
|
|
901
|
+
const unsubscribe = wizard.subscribe((event) => {
|
|
902
|
+
if (event.type === "tool_execution_start" && event.toolName !== "goal_ask") {
|
|
903
|
+
const argHint = typeof event.args === "object" && event.args !== null
|
|
904
|
+
? String(event.args.path ||
|
|
905
|
+
event.args.file_path ||
|
|
906
|
+
event.args.pattern ||
|
|
907
|
+
event.args.query ||
|
|
908
|
+
"").slice(0, 30)
|
|
909
|
+
: "";
|
|
910
|
+
const toolLabelZh = event.toolName === "read"
|
|
911
|
+
? "正在查阅文件"
|
|
912
|
+
: event.toolName === "grep"
|
|
913
|
+
? "正在检索内容"
|
|
914
|
+
: event.toolName === "find"
|
|
915
|
+
? "正在查找文件"
|
|
916
|
+
: event.toolName === "ls"
|
|
917
|
+
? "正在浏览目录"
|
|
918
|
+
: "正在查阅上下文";
|
|
919
|
+
wgoal.wizard.status = `调研中:${toolLabelZh}${argHint ? ` ${argHint}` : "…"}`;
|
|
920
|
+
wgoal.wizard.statusEn = `Scoping: checking ${event.toolName}${argHint ? ` ${argHint}` : "…"}`;
|
|
921
|
+
this.emitGoalStatus();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
// 扩展动态注册的模型(如 cliproxyapi/grok-4.7)在 bindExtensions 后才进入 ModelRuntime,此处兜底补绑
|
|
925
|
+
if (!model) {
|
|
926
|
+
if (wmSpec)
|
|
927
|
+
model = services.modelRuntime.getModel(wmSpec.provider, wmSpec.id);
|
|
928
|
+
if (!model) {
|
|
929
|
+
const mainModel = mainSession.model;
|
|
930
|
+
if (mainModel?.provider && mainModel.id) {
|
|
931
|
+
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (model) {
|
|
935
|
+
try {
|
|
936
|
+
await wizard.setModel(model);
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
/* best-effort */
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
623
943
|
// Cancel watcher: when the user ✗s / idle-timeout fires, truly stop the
|
|
624
944
|
// wizard's agent run (not just mark it).
|
|
625
945
|
if (!ac.signal.aborted) {
|
|
@@ -629,21 +949,21 @@ export class GoalService {
|
|
|
629
949
|
this.host.webUi.cancelPendingDialogs();
|
|
630
950
|
}, { once: true });
|
|
631
951
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
952
|
+
// 从发起调研的主会话中提取上下文历史(摘要与近期轮次),避免孤立向导完全失忆
|
|
953
|
+
const mainMessages = mainSession.sessionManager?.buildSessionContext?.()?.messages ??
|
|
954
|
+
mainSession.messages ??
|
|
955
|
+
[];
|
|
956
|
+
const contextSummary = buildWizardConversationContext(mainMessages);
|
|
957
|
+
try {
|
|
958
|
+
await wizard.prompt(wizardPrompt(draft, contextSummary));
|
|
639
959
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
|
|
643
|
-
// First line looks like preamble (no sentence-ending punctuation).
|
|
644
|
-
refinedGoal = lines.slice(1).join(" ").trim();
|
|
645
|
-
}
|
|
960
|
+
finally {
|
|
961
|
+
unsubscribe();
|
|
646
962
|
}
|
|
963
|
+
const rawAssistantText = wizard.getLastAssistantText()?.trim() ?? "";
|
|
964
|
+
const parsed = parseWizardOutput(rawAssistantText);
|
|
965
|
+
refinedGoal = parsed.goal;
|
|
966
|
+
parsedSteps = parsed.steps;
|
|
647
967
|
await srv.session.dispose();
|
|
648
968
|
}
|
|
649
969
|
catch (err) {
|
|
@@ -712,6 +1032,9 @@ export class GoalService {
|
|
|
712
1032
|
return;
|
|
713
1033
|
}
|
|
714
1034
|
const switchedAway = this.host.activeConvId() !== wizardConversationId;
|
|
1035
|
+
if (parsedSteps.length > 0) {
|
|
1036
|
+
this.host.setPlan?.(wizardConversationId, parsedSteps);
|
|
1037
|
+
}
|
|
715
1038
|
// Auto-set the refined goal. The wizard workflow implies "set a goal and
|
|
716
1039
|
// work until it passes", so default LOCKED=true unless the user explicitly
|
|
717
1040
|
// turned the lock off (a lock lets the review loop keep revising to pass;
|
|
@@ -721,6 +1044,8 @@ export class GoalService {
|
|
|
721
1044
|
reviewModel: wgoal.reviewModel ?? undefined,
|
|
722
1045
|
maxRounds: opts?.maxRounds,
|
|
723
1046
|
locked: wantLocked,
|
|
1047
|
+
// 目标模式 2.0:执行者模型随调研一起带过去(wizard 与目标条共用记忆偏好)。
|
|
1048
|
+
execModel: wgoal.execModel ?? undefined,
|
|
724
1049
|
// Land the goal on the conversation that launched the survey, even if the
|
|
725
1050
|
// user is looking at another one right now (issue #292).
|
|
726
1051
|
targetConvId: wizardConversationId,
|
|
@@ -739,17 +1064,11 @@ export class GoalService {
|
|
|
739
1064
|
? `🎯 Survey done in "${wizardConversationTitle}", goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""} (switch back to that conversation to watch it generate)`
|
|
740
1065
|
: `🎯 Survey done, goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
741
1066
|
});
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
await mainSession.sendUserMessage(wizardKick, {
|
|
748
|
-
deliverAs: mainSession.isStreaming ? "steer" : "followUp",
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
catch {
|
|
752
|
-
// Generation kick-off is best-effort; the user can still prompt manually.
|
|
1067
|
+
// 目标模式 2.0(唯一路径):不向主对话注入「开始实现」——它在目标模式下是审查者,
|
|
1068
|
+
// 干活的另有执行对话,改由服务端循环驱动(setGoal 已把 goal 落在发起会话上)。
|
|
1069
|
+
if (targetConv.goal.goal) {
|
|
1070
|
+
this.startDelegatedLoop(targetConv, targetConv.goalGeneration, targetConv.goal.goal);
|
|
1071
|
+
this.host.flushSnapshot();
|
|
753
1072
|
}
|
|
754
1073
|
}
|
|
755
1074
|
/** Persist goal/review preference defaults (model, rounds cap, locked) without
|
|
@@ -760,22 +1079,38 @@ export class GoalService {
|
|
|
760
1079
|
return;
|
|
761
1080
|
const goal = this.host.activeConv().goal;
|
|
762
1081
|
if (opts?.reviewModel !== undefined)
|
|
763
|
-
goal.reviewModel = opts
|
|
1082
|
+
goal.reviewModel = opts?.reviewModel || null;
|
|
764
1083
|
if (typeof opts?.maxRounds === "number") {
|
|
765
1084
|
const mr = Math.round(opts.maxRounds);
|
|
766
1085
|
goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
767
1086
|
}
|
|
768
1087
|
if (opts?.locked !== undefined)
|
|
769
1088
|
goal.locked = opts.locked;
|
|
1089
|
+
// 执行者模型:目标进行中不换轨(换模型对已存在的执行对话无效)——只记偏好,
|
|
1090
|
+
// 下一个目标生效并明确告知。
|
|
1091
|
+
const hasExecOpt = opts?.execModel !== undefined;
|
|
1092
|
+
if (goal.goal && hasExecOpt) {
|
|
1093
|
+
this.host.emit({
|
|
1094
|
+
type: "notice",
|
|
1095
|
+
level: "info",
|
|
1096
|
+
text: "执行模型将在下一个目标生效(当前目标继续用启动时的模型跑完)。",
|
|
1097
|
+
textEn: "The executor model applies to the next goal (the current one keeps the model it started with).",
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
else if (hasExecOpt) {
|
|
1101
|
+
goal.execModel = opts?.execModel || null;
|
|
1102
|
+
}
|
|
770
1103
|
this.prefs = {
|
|
771
1104
|
reviewModel: goal.reviewModel,
|
|
772
1105
|
maxRounds: goal.maxRounds,
|
|
773
1106
|
locked: goal.locked,
|
|
1107
|
+
execModel: hasExecOpt ? opts?.execModel || null : this.prefs.execModel,
|
|
774
1108
|
};
|
|
775
1109
|
this.host.stateStore.saveGoalPrefs(this.host.clientId, {
|
|
776
1110
|
reviewModel: goal.reviewModel,
|
|
777
1111
|
maxRounds: goal.maxRounds,
|
|
778
1112
|
locked: goal.locked,
|
|
1113
|
+
execModel: this.prefs.execModel,
|
|
779
1114
|
});
|
|
780
1115
|
this.emitGoalStatus();
|
|
781
1116
|
}
|
|
@@ -784,35 +1119,63 @@ export class GoalService {
|
|
|
784
1119
|
async clearGoal() {
|
|
785
1120
|
const conv = this.host.activeConv();
|
|
786
1121
|
conv.goalGeneration += 1;
|
|
787
|
-
|
|
788
|
-
conv
|
|
789
|
-
conv
|
|
790
|
-
conv
|
|
791
|
-
const goal = conv.goal;
|
|
792
|
-
goal.reviewing = false;
|
|
793
|
-
goal.conversationId = null;
|
|
794
|
-
goal.goal = null;
|
|
795
|
-
goal.reviewing = false;
|
|
796
|
-
goal.verdict = "pending";
|
|
797
|
-
goal.feedback = undefined;
|
|
798
|
-
goal.wizard.active = false;
|
|
799
|
-
goal.wizard.status = "";
|
|
800
|
-
goal.wizard.statusEn = "";
|
|
801
|
-
goal.status = "";
|
|
802
|
-
goal.statusEn = "";
|
|
1122
|
+
// 委托执行:停掉在飞的执行者(循环靠代次作废 + 代次守卫退出)。
|
|
1123
|
+
this.stopDelegated(conv);
|
|
1124
|
+
this.resetLoopCounters(conv);
|
|
1125
|
+
this.clearGoalFields(conv);
|
|
803
1126
|
this.emitGoalStatus();
|
|
804
1127
|
// Abort a running wizard for real (✗ in the goal bar while scoping).
|
|
805
1128
|
if (this.wizardOwnerId === this.host.activeConvId()) {
|
|
806
|
-
this.
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1129
|
+
await this.abortWizard();
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/** 真正中止在飞的调研向导(对话框按取消返回 + agent run 停掉),返回是否停掉了一个。
|
|
1133
|
+
* 注意:不碰 wizardOwnerId —— 它由向导 run 自己的 finally 清理;提前清掉会让
|
|
1134
|
+
* 旧向导还没退完时就能开新向导(两个 run 并发)。 */
|
|
1135
|
+
async abortWizard() {
|
|
1136
|
+
if (this.wizardOwnerId === null)
|
|
1137
|
+
return false;
|
|
1138
|
+
this.wizardCancelled = true;
|
|
1139
|
+
this.host.webUi.cancelPendingDialogs();
|
|
1140
|
+
this.wizardAbort?.abort();
|
|
1141
|
+
const ws2 = this.wizardSession;
|
|
1142
|
+
this.wizardSession = null;
|
|
1143
|
+
if (ws2) {
|
|
1144
|
+
await ws2.abort().catch(() => { });
|
|
1145
|
+
ws2.dispose();
|
|
1146
|
+
}
|
|
1147
|
+
this.wizardAbort = null;
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* 目标模式总开关关闭:停掉所有在飞的委托循环并收掉各自的执行者(幂等),
|
|
1152
|
+
* 在跑的调研向导一并中止。已受阻/未通过的目标只留文本(不运行,无需处理),
|
|
1153
|
+
* 开关重开后仍可继续处置。无在飞目标/调研时无声无 notice。
|
|
1154
|
+
*/
|
|
1155
|
+
async stopAllGoals() {
|
|
1156
|
+
let stopped = 0;
|
|
1157
|
+
// 直接迭代:stopDelegated 只发 fire-and-forget 的停/收请求,不会同步改这张表
|
|
1158
|
+
// (循环退出是异步的,靠代次守卫),无需快照。
|
|
1159
|
+
for (const convId of this.delegatedLoops.keys()) {
|
|
1160
|
+
const conv = this.host.getConv(convId);
|
|
1161
|
+
if (!conv)
|
|
1162
|
+
continue;
|
|
1163
|
+
conv.goalGeneration += 1; // 作废在飞回调(循环靠代次守卫退出)
|
|
1164
|
+
this.stopDelegated(conv); // 停执行者 + 唤醒 verdict 等待者 + 清 roles/phase
|
|
1165
|
+
this.resetLoopCounters(conv);
|
|
1166
|
+
this.clearGoalFields(conv);
|
|
1167
|
+
stopped++;
|
|
1168
|
+
}
|
|
1169
|
+
const wizardAborted = await this.abortWizard();
|
|
1170
|
+
if (stopped > 0 || wizardAborted) {
|
|
1171
|
+
this.emitGoalStatus();
|
|
1172
|
+
this.host.emit({
|
|
1173
|
+
type: "notice",
|
|
1174
|
+
level: "warning",
|
|
1175
|
+
text: "目标模式已关闭,在飞的目标/调研已停止(执行对话已移出左栏)。",
|
|
1176
|
+
textEn: "Goal mode is off; running goals/surveys were stopped (executor conversations removed).",
|
|
1177
|
+
});
|
|
1178
|
+
this.host.flushSnapshot();
|
|
816
1179
|
}
|
|
817
1180
|
}
|
|
818
1181
|
/**
|
|
@@ -826,16 +1189,31 @@ export class GoalService {
|
|
|
826
1189
|
const g = conv.goal;
|
|
827
1190
|
if (aborted) {
|
|
828
1191
|
if (g.goal && g.conversationId === conv.id) {
|
|
1192
|
+
// 中止的若是审查回合(服务端正在等 verdict):只作废这一次审查(走无 JSON
|
|
1193
|
+
// 重试/受阻),目标与执行对话都保留 —— 用户按 Stop 往往只是想掐掉那段机器
|
|
1194
|
+
// JSON 回合,不是要把整个目标连执行者一起清掉。
|
|
1195
|
+
if (conv.awaitingVerdict) {
|
|
1196
|
+
const settle = this.verdictWaiters.get(conv.id);
|
|
1197
|
+
this.verdictWaiters.delete(conv.id);
|
|
1198
|
+
conv.awaitingVerdict = undefined;
|
|
1199
|
+
settle?.("invalid");
|
|
1200
|
+
this.emitGoalStatus();
|
|
1201
|
+
return {
|
|
1202
|
+
text: "⏹ 审查回合已中止(目标保留,将重新审查这一轮)",
|
|
1203
|
+
textEn: "⏹ Review round aborted (the goal is kept and this round will be reviewed again)",
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
829
1206
|
conv.goalGeneration += 1;
|
|
830
|
-
|
|
831
|
-
conv
|
|
832
|
-
conv
|
|
833
|
-
conv.sameErrorRounds = 0;
|
|
1207
|
+
// 委托执行:手动停止也要把在飞的执行者停掉(否则它还在后台改工作区)。
|
|
1208
|
+
this.stopDelegated(conv);
|
|
1209
|
+
this.resetLoopCounters(conv);
|
|
834
1210
|
g.conversationId = null;
|
|
835
1211
|
g.goal = null;
|
|
836
1212
|
g.reviewing = false;
|
|
837
1213
|
g.verdict = "pending";
|
|
838
1214
|
g.feedback = undefined;
|
|
1215
|
+
g.phase = "idle";
|
|
1216
|
+
// roles 已由上面的 stopDelegated 经 detachRoleExec 清掉。
|
|
839
1217
|
g.status = "已手动停止,目标审查已中止";
|
|
840
1218
|
g.statusEn = "Stopped manually, goal review aborted";
|
|
841
1219
|
this.emitGoalStatus();
|
|
@@ -846,84 +1224,15 @@ export class GoalService {
|
|
|
846
1224
|
}
|
|
847
1225
|
return null;
|
|
848
1226
|
}
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if (g.goal &&
|
|
855
|
-
g.conversationId === conv.id &&
|
|
856
|
-
!g.reviewing &&
|
|
857
|
-
g.verdict === "pending" &&
|
|
858
|
-
!conv.wizardRunning &&
|
|
859
|
-
!this.host.isDisposed() &&
|
|
860
|
-
this.goalEnabled()) {
|
|
861
|
-
void this.runGoalReview(conv);
|
|
1227
|
+
// 本对话就是审查者:只有服务端把审查指令交给它之后结束的那个回合才是审查回合
|
|
1228
|
+
// (awaitingVerdict 非空);其余 agent_end(用户在执行期聊天/插话)一律不当
|
|
1229
|
+
// 审查结果,也不会重新触发任何循环(目标模式 2.0 只有委托一条路径)。
|
|
1230
|
+
if (g.goal && g.conversationId === conv.id && conv.awaitingVerdict) {
|
|
1231
|
+
this.deliverDelegatedVerdict(conv);
|
|
862
1232
|
}
|
|
863
1233
|
return null;
|
|
864
1234
|
}
|
|
865
|
-
|
|
866
|
-
resolveReviewModel(spec) {
|
|
867
|
-
return parseModelSpec(spec);
|
|
868
|
-
}
|
|
869
|
-
/** 提取会话最近产生的错误特征,用于停滞与相同报错检测。 */
|
|
870
|
-
extractErrorSnippet(session, text) {
|
|
871
|
-
try {
|
|
872
|
-
const messages = session.agent?.state?.messages;
|
|
873
|
-
if (Array.isArray(messages)) {
|
|
874
|
-
for (let i = messages.length - 1; i >= 0 && i >= messages.length - 6; i--) {
|
|
875
|
-
const m = messages[i];
|
|
876
|
-
if (m.role === "toolResult" && m.isError) {
|
|
877
|
-
const errText = m.content
|
|
878
|
-
?.map((c) => (c.type === "text" ? c.text : ""))
|
|
879
|
-
.join(" ")
|
|
880
|
-
.trim();
|
|
881
|
-
if (errText)
|
|
882
|
-
return errText.slice(0, 300);
|
|
883
|
-
}
|
|
884
|
-
if (m.role === "bashExecution" && m.exitCode && m.exitCode !== 0) {
|
|
885
|
-
const snippet = m.output?.trim().slice(-300);
|
|
886
|
-
if (snippet)
|
|
887
|
-
return `bash exit ${m.exitCode}: ${snippet}`;
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
catch {
|
|
893
|
-
// Ignore
|
|
894
|
-
}
|
|
895
|
-
const errMatch = text.match(/(?:(?:Error|Exception|Fail|Fatal):[^\n]+)/i);
|
|
896
|
-
if (errMatch) {
|
|
897
|
-
return errMatch[0].trim().slice(0, 300);
|
|
898
|
-
}
|
|
899
|
-
return undefined;
|
|
900
|
-
}
|
|
901
|
-
/**
|
|
902
|
-
* The whitelisted reviewer plan — tell the reviewer what to decide and how
|
|
903
|
-
* to report, regardless of which model it runs on.
|
|
904
|
-
*/
|
|
905
|
-
reviewerPrompt(goal, round, maxRounds, output, gitDiff, customPrompt = "") {
|
|
906
|
-
return [
|
|
907
|
-
`You are a strict, independent goal-reviewer. Your ONLY job is to judge whether the agent's work fully satisfies the stated goal, by checking the agent's final output and, when present, its git diff.`, // eslint-disable-line max-len
|
|
908
|
-
``,
|
|
909
|
-
`# Goal`, // eslint-disable-line no-regex-spaces
|
|
910
|
-
goal,
|
|
911
|
-
``,
|
|
912
|
-
`# Agent's final output`, // eslint-disable-line no-regex-spaces
|
|
913
|
-
output.length > 0 ? output : "(the agent produced no text — inspect the diff)", // eslint-disable-line max-len
|
|
914
|
-
``,
|
|
915
|
-
`# Git diff (if any)`, // eslint-disable-line no-regex-spaces
|
|
916
|
-
gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
|
|
917
|
-
``,
|
|
918
|
-
`This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
|
|
919
|
-
...(customPrompt.trim() ? [``, `# Additional reviewer instructions`, customPrompt.trim()] : []),
|
|
920
|
-
``,
|
|
921
|
-
`Decide: does the work satisfy the goal? If yes, respond with ONLY a JSON object with this exact shape (no markdown fences, no extra text):`, // eslint-disable-line max-len
|
|
922
|
-
`{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
|
|
923
|
-
`If NO, respond with ONLY: {"verdict":"fail","feedback":"<concise, actionable list of what the agent must fix to satisfy the goal>"}`, // eslint-disable-line max-len
|
|
924
|
-
`The feedback for a fail must be specific enough that the agent can act on it directly.`, // eslint-disable-line max-len
|
|
925
|
-
].join("\n");
|
|
926
|
-
}
|
|
1235
|
+
// =====================================================================
|
|
927
1236
|
/** Insert a wizard progress card into the MAIN conversation flow and render it
|
|
928
1237
|
* IMMEDIATELY (the main session is idle while the wizard runs in its own
|
|
929
1238
|
* session, so — unlike nextTurn, which queues until the next user prompt —
|
|
@@ -941,79 +1250,395 @@ export class GoalService {
|
|
|
941
1250
|
// Card insertion is cosmetic — never block the question flow on it.
|
|
942
1251
|
}
|
|
943
1252
|
}
|
|
944
|
-
|
|
1253
|
+
/** 目标模式 2.0(唯一路径:主对话 = 审查者 + 常驻执行对话)
|
|
1254
|
+
//
|
|
1255
|
+
// 干活的是服务端拉起的常驻执行对话(落盘、左栏可见可点开)。服务端持有整台
|
|
1256
|
+
// 状态机,一轮 = 派活 → 等它结束 → 取样(工作区 diff 指纹 + **执行者会话**
|
|
1257
|
+
// 错误特征)→ 把审查指令交给主对话 → 解析 verdict → 判定(pass / 再来一轮 /
|
|
1258
|
+
// 熔断 / 预算用尽)。模型不得自循环,轮次与熔断全在服务端
|
|
1259
|
+
// (详见 docs/goal-conversation-design.md §3/§4)。
|
|
1260
|
+
// =====================================================================
|
|
1261
|
+
|
|
1262
|
+
/** 角色对话桥是否齐备(缺一即无法运行目标模式:setGoal 直接拒绝)。 */
|
|
1263
|
+
roleBridgeReady() {
|
|
1264
|
+
const h = this.host;
|
|
1265
|
+
return !!(h.spawnRoleAgent && h.waitRoleAgent && h.sendRoleAgent && h.readRoleAgent && h.hasConv);
|
|
1266
|
+
}
|
|
1267
|
+
/** 角色轮等待上限:与工具看门狗同口径(默认 20 分钟)。 */
|
|
1268
|
+
roleDeadline() {
|
|
1269
|
+
const v = this.host.roleDeadlineMs?.();
|
|
1270
|
+
return typeof v === "number" && v > 0 ? v : 20 * 60_000;
|
|
1271
|
+
}
|
|
1272
|
+
/** 代次守卫:目标没被改/清、会话还在才算本轮有效。 */
|
|
1273
|
+
isCurrentDelegated(conv, goalGeneration) {
|
|
945
1274
|
return (!this.host.isDisposed() &&
|
|
946
1275
|
this.host.getConv(conv.id) === conv &&
|
|
947
1276
|
conv.goal.conversationId === conv.id &&
|
|
948
1277
|
conv.goalGeneration === goalGeneration &&
|
|
949
|
-
conv.goalReviewGeneration === reviewGeneration &&
|
|
950
1278
|
!!conv.goal.goal);
|
|
951
1279
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1280
|
+
isConvStreaming(conv) {
|
|
1281
|
+
try {
|
|
1282
|
+
return conv.session?.isStreaming === true;
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/** 启动委托循环(同一对话同时只允许一个在飞;旧循环退场中到来的请求排队接力)。 */
|
|
1289
|
+
startDelegatedLoop(conv, goalGeneration, goalText) {
|
|
1290
|
+
if (this.delegatedLoops.has(conv.id)) {
|
|
1291
|
+
this.delegatedPending.set(conv.id, { goalGeneration, goalText });
|
|
956
1292
|
return;
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1293
|
+
}
|
|
1294
|
+
const convId = conv.id;
|
|
1295
|
+
const loop = this.runDelegatedLoop(conv, goalGeneration, goalText)
|
|
1296
|
+
.catch((err) => {
|
|
1297
|
+
void this.finishDelegated(conv, goalGeneration, "blocked", conv.goal.round, `循环内部错误:${err instanceof Error ? err.message : String(err)}`, goalText);
|
|
1298
|
+
})
|
|
1299
|
+
.finally(() => {
|
|
1300
|
+
if (this.delegatedLoops.get(convId) === loop)
|
|
1301
|
+
this.delegatedLoops.delete(convId);
|
|
1302
|
+
// 接力:退场期间排队的新请求现在启动(代次已过期则首个守卫即退出,无副作用)。
|
|
1303
|
+
const pending = this.delegatedPending.get(convId);
|
|
1304
|
+
if (pending) {
|
|
1305
|
+
this.delegatedPending.delete(convId);
|
|
1306
|
+
const target = this.host.getConv(convId);
|
|
1307
|
+
if (target)
|
|
1308
|
+
this.startDelegatedLoop(target, pending.goalGeneration, pending.goalText);
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
this.delegatedLoops.set(conv.id, loop);
|
|
1312
|
+
}
|
|
1313
|
+
/** 观测/测试用:等某对话的委托循环走到「已把审查指令交给主对话」这一步。 */
|
|
1314
|
+
whenAwaitingVerdict(convId, timeoutMs = 5000) {
|
|
1315
|
+
if (this.verdictWaiters.has(convId))
|
|
1316
|
+
return Promise.resolve(true);
|
|
1317
|
+
return new Promise((resolve) => {
|
|
1318
|
+
const set = this.verdictSignals.get(convId) ?? new Set();
|
|
1319
|
+
this.verdictSignals.set(convId, set);
|
|
1320
|
+
let done = false;
|
|
1321
|
+
const fire = () => {
|
|
1322
|
+
if (done)
|
|
1323
|
+
return;
|
|
1324
|
+
done = true;
|
|
1325
|
+
clearTimeout(timer);
|
|
1326
|
+
set.delete(fire);
|
|
1327
|
+
if (set.size === 0)
|
|
1328
|
+
this.verdictSignals.delete(convId);
|
|
1329
|
+
resolve(true);
|
|
1330
|
+
};
|
|
1331
|
+
const timer = setTimeout(() => {
|
|
1332
|
+
if (done)
|
|
1333
|
+
return;
|
|
1334
|
+
done = true;
|
|
1335
|
+
set.delete(fire);
|
|
1336
|
+
if (set.size === 0)
|
|
1337
|
+
this.verdictSignals.delete(convId);
|
|
1338
|
+
resolve(false);
|
|
1339
|
+
}, timeoutMs);
|
|
1340
|
+
timer.unref?.();
|
|
1341
|
+
set.add(fire);
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* 观测/测试用:等某对话的委托循环收束(循环已结束后立刻返回)。
|
|
1346
|
+
* 注意:循环在「等执行者」或「等 verdict」时不会收束 —— 那两个时点用
|
|
1347
|
+
* whenAwaitingVerdict / 脚本化的 waitRoleAgent 推进。
|
|
1348
|
+
*/
|
|
1349
|
+
async whenDelegatedSettled(convId) {
|
|
1350
|
+
const loop = this.delegatedLoops.get(convId);
|
|
1351
|
+
if (loop)
|
|
1352
|
+
await loop.catch(() => { });
|
|
1353
|
+
}
|
|
1354
|
+
signalAwaitingVerdict(convId) {
|
|
1355
|
+
const set = this.verdictSignals.get(convId);
|
|
1356
|
+
if (!set)
|
|
1357
|
+
return;
|
|
1358
|
+
for (const fn of set)
|
|
1359
|
+
fn();
|
|
1360
|
+
}
|
|
1361
|
+
/** 停掉该对话的委托循环并收掉常驻执行者(幂等;清目标/重设目标/中止时调)。 */
|
|
1362
|
+
/** 一轮循环的计数器归零(新目标/清目标/中止/总开关停摆时调;目标文本与历史不动)。 */
|
|
1363
|
+
resetLoopCounters(conv) {
|
|
1364
|
+
conv.stagnantRounds = 0;
|
|
1365
|
+
conv.failedRounds = 0;
|
|
1366
|
+
conv.execUsageBefore = undefined;
|
|
1367
|
+
conv.reviewUsageBefore = undefined;
|
|
1368
|
+
conv.goalUsage = undefined;
|
|
1369
|
+
conv.lastDiff = undefined;
|
|
1370
|
+
conv.lastErrorSnippet = undefined;
|
|
1371
|
+
conv.sameErrorRounds = 0;
|
|
1372
|
+
}
|
|
1373
|
+
/** 目标字段清空(清目标/总开关停摆时调;历史与偏好保留)。 */
|
|
1374
|
+
clearGoalFields(conv) {
|
|
1375
|
+
const goal = conv.goal;
|
|
1376
|
+
goal.reviewing = false;
|
|
1377
|
+
goal.conversationId = null;
|
|
1378
|
+
goal.goal = null;
|
|
1379
|
+
goal.verdict = "pending";
|
|
1380
|
+
goal.feedback = undefined;
|
|
1381
|
+
goal.status = "";
|
|
1382
|
+
goal.statusEn = "";
|
|
1383
|
+
goal.phase = "idle";
|
|
1384
|
+
goal.roles = {};
|
|
1385
|
+
goal.wizard.active = false;
|
|
1386
|
+
goal.wizard.status = "";
|
|
1387
|
+
goal.wizard.statusEn = "";
|
|
1388
|
+
}
|
|
1389
|
+
/** 轮次预算(locked=false = 单次;locked + maxRounds>0 = 有限轮并夹到 50;否则不限)。 */
|
|
1390
|
+
roundBudget(goal) {
|
|
1391
|
+
return goal.locked ? (goal.maxRounds > 0 ? Math.min(goal.maxRounds, 50) : Number.POSITIVE_INFINITY) : 1;
|
|
1392
|
+
}
|
|
1393
|
+
/** 摘掉常驻执行者引用并清 roles(停/收由调用方按 sync/async 上下文自行处理,
|
|
1394
|
+
* 停与收本身都是 best-effort)。返回被摘掉的执行者对话 id(没有则 undefined)。 */
|
|
1395
|
+
detachRoleExec(conv) {
|
|
1396
|
+
const exec = conv.roleExec;
|
|
1397
|
+
conv.roleExec = undefined;
|
|
1398
|
+
conv.goal.roles = {};
|
|
1399
|
+
return exec?.convId;
|
|
1400
|
+
}
|
|
1401
|
+
stopDelegated(conv) {
|
|
1402
|
+
// 排队的启动请求一并作废:setGoal 会在后面按新代次重新排,clearGoal 则不需要。
|
|
1403
|
+
this.delegatedPending.delete(conv.id);
|
|
1404
|
+
conv.awaitingVerdict = undefined;
|
|
1405
|
+
// 审查回合里排队的用户插话在这里也顺带发出(take 语义,与 deliverAndWait 的
|
|
1406
|
+
// flush 互斥 —— 先到先得,清目标/停循环不断用户的话)。
|
|
1407
|
+
void this.flushDeferredPrompts(conv);
|
|
1408
|
+
const settle = this.verdictWaiters.get(conv.id);
|
|
1409
|
+
if (settle) {
|
|
1410
|
+
this.verdictWaiters.delete(conv.id);
|
|
1411
|
+
settle("gone");
|
|
1412
|
+
}
|
|
1413
|
+
const execId = this.detachRoleExec(conv);
|
|
1414
|
+
if (execId) {
|
|
1415
|
+
void this.host.stopRoleAgent?.(execId).catch(() => { });
|
|
1416
|
+
void this.host.dismissRoleAgent?.(execId).catch(() => { });
|
|
1417
|
+
}
|
|
1418
|
+
conv.goal.phase = "idle";
|
|
1419
|
+
}
|
|
1420
|
+
/** 读某会话累计用量(取不到返回 undefined;getSessionStats 会遍历转写,只在轮次边界调)。 */
|
|
1421
|
+
sessionUsage(session) {
|
|
1422
|
+
try {
|
|
1423
|
+
const t = session.getSessionStats()?.tokens;
|
|
1424
|
+
if (t && typeof t.input === "number" && typeof t.output === "number") {
|
|
1425
|
+
return { input: t.input, output: t.output };
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
catch {
|
|
1429
|
+
// Ignore
|
|
1430
|
+
}
|
|
1431
|
+
return undefined;
|
|
1432
|
+
}
|
|
1433
|
+
/** 把一轮增量并入本目标累计(任一端缺失即跳过,不污染总数)。 */
|
|
1434
|
+
addGoalUsage(conv, before) {
|
|
1435
|
+
const after = this.sessionUsage(conv.session);
|
|
1436
|
+
if (!after || !before)
|
|
1437
|
+
return;
|
|
1438
|
+
conv.goalUsage = {
|
|
1439
|
+
input: (conv.goalUsage?.input ?? 0) + Math.max(0, after.input - before.input),
|
|
1440
|
+
output: (conv.goalUsage?.output ?? 0) + Math.max(0, after.output - before.output),
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/** 轮次标签("/M";不限轮时为空串)。 */
|
|
1444
|
+
roundsLabel(budget) {
|
|
1445
|
+
return budget > 0 && Number.isFinite(budget) ? `/${budget}` : "";
|
|
1446
|
+
}
|
|
1447
|
+
async runDelegatedLoop(conv, goalGeneration, goalText) {
|
|
1448
|
+
let feedback = "";
|
|
1449
|
+
for (;;) {
|
|
1450
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1451
|
+
return;
|
|
1452
|
+
const g = conv.goal;
|
|
1453
|
+
// quiesce(服务排空):中途不再派单 —— 存量回合跑完,下一轮不再派
|
|
1454
|
+
// (设计文档 §4.6;与 prompt/编辑入口「新的拒绝、存量跑完」同口径)。
|
|
1455
|
+
if (this.host.quiesceBlocked()) {
|
|
1456
|
+
await this.finishDelegated(conv, goalGeneration, "blocked", g.round, this.roleFailureText("quiesce"), goalText);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
// 轮次预算:locked=false = 单次(一轮就收);locked=true 且 maxRounds>0 = 有限轮。
|
|
1460
|
+
const budget = this.roundBudget(g);
|
|
1461
|
+
if (g.round >= budget) {
|
|
1462
|
+
await this.finishDelegated(conv, goalGeneration, "exhausted", g.round, feedback, goalText);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const round = g.round + 1;
|
|
1466
|
+
g.round = round;
|
|
1467
|
+
g.reviewing = true;
|
|
1468
|
+
g.verdict = "pending";
|
|
1469
|
+
g.feedback = undefined;
|
|
1470
|
+
g.phase = "executing";
|
|
1471
|
+
g.status = `执行中(第 ${round}${this.roundsLabel(budget)} 轮)…`;
|
|
1472
|
+
g.statusEn = `Executing (round ${round}${this.roundsLabel(budget)})…`;
|
|
1473
|
+
this.emitGoalStatus();
|
|
1474
|
+
// ① 派发:首轮 spawn 常驻执行者,之后向同一个它追加回合(记忆连续)。
|
|
1475
|
+
const execId = await this.dispatchExecutor(conv, goalGeneration, goalText, round, budget, feedback);
|
|
1476
|
+
if (!execId)
|
|
1477
|
+
return; // 已降级 / 已收尾
|
|
1478
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1479
|
+
return;
|
|
1480
|
+
// ② 等执行者本轮结束(事件驱动,上限 = 看门狗口径)。
|
|
1481
|
+
const outcome = await this.host.waitRoleAgent(execId, this.roleDeadline());
|
|
1482
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1483
|
+
return;
|
|
1484
|
+
// ③ 取样:工作区 diff 指纹 + 执行者会话的错误特征(主对话是审查者,它的
|
|
1485
|
+
// 工具报错不代表执行受挫 —— 取数口径见设计文档 §4.5)。
|
|
1486
|
+
const sample = await this.sampleRound(conv, execId);
|
|
1487
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1488
|
+
return;
|
|
1489
|
+
// 熔断先于一切分支:停滞 / 同错 / 执行连败连续两轮 → 直接终止(不问审查者,
|
|
1490
|
+
// 省 token;判定权始终在服务端)。注意它必须在 outcome 分支之前 —— 否则
|
|
1491
|
+
// error/timeout 轮会经由下面的 continue 跳过熔断,在「不限轮」下无限空转。
|
|
1492
|
+
if ((conv.sameErrorRounds ?? 0) >= 2 ||
|
|
1493
|
+
(conv.stagnantRounds ?? 0) >= 2 ||
|
|
1494
|
+
(conv.failedRounds ?? 0) >= GoalService.EXEC_FAILED_ROUNDS_LIMIT) {
|
|
1495
|
+
await this.finishDelegated(conv, goalGeneration, "blocked", round, "", goalText);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (outcome !== "done") {
|
|
1499
|
+
// 「人为中止」与「对话失联」是终点事件:用户按了子代理的 ⏹ / 把执行对话关了,
|
|
1500
|
+
// 就是要停下 —— 这里绝不能再派一轮(否则看起来像「关了自己又启动」)。
|
|
1501
|
+
if (outcome === "gone" || outcome === "canceled") {
|
|
1502
|
+
const reason = outcome === "gone"
|
|
1503
|
+
? this.roleFailureText("exec-gone")
|
|
1504
|
+
: this.roleFailureText("canceled", sample.errorSnippet ?? "");
|
|
1505
|
+
await this.finishDelegated(conv, goalGeneration, "blocked", round, reason, goalText);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
// 超时 / 报错:不花审查 token,直接进下一轮(预算内)。连败计数在这里累加,
|
|
1509
|
+
// 阈值见本轮顶部的熔断(连续 2 轮都起不来就停,不烧无限 token)。
|
|
1510
|
+
conv.failedRounds = (conv.failedRounds ?? 0) + 1;
|
|
1511
|
+
if (conv.failedRounds >= GoalService.EXEC_FAILED_ROUNDS_LIMIT) {
|
|
1512
|
+
await this.finishDelegated(conv, goalGeneration, "blocked", round, this.roleFailureText(outcome === "timeout" ? "timeout-repeat" : "error-repeat", sample.errorSnippet ?? ""), goalText);
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
feedback = this.roleFailureText(outcome === "timeout" ? "timeout" : "error", sample.errorSnippet ?? "");
|
|
1516
|
+
if (outcome === "timeout")
|
|
1517
|
+
await this.host.stopRoleAgent?.(execId).catch(() => { });
|
|
1518
|
+
continue; // 顶部自增轮次后重新派活
|
|
1519
|
+
}
|
|
1520
|
+
// 执行者本轮正常结束:连败清零。
|
|
1521
|
+
conv.failedRounds = 0;
|
|
1522
|
+
// ④⑤ 把审查指令交给主对话(= 审查者),等它的 verdict。
|
|
1523
|
+
g.phase = "reviewing";
|
|
1524
|
+
g.reviewing = true;
|
|
1525
|
+
g.status = `审查中(第 ${round}${this.roundsLabel(budget)} 轮)…`;
|
|
1526
|
+
g.statusEn = `Reviewing (round ${round}${this.roundsLabel(budget)})…`;
|
|
961
1527
|
this.emitGoalStatus();
|
|
1528
|
+
const verdict = await this.askDelegatedReview(conv, goalGeneration, round, budget, sample.output);
|
|
1529
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1530
|
+
return;
|
|
1531
|
+
if (verdict === "invalid" || verdict === "timeout" || verdict === "gone") {
|
|
1532
|
+
await this.finishDelegated(conv, goalGeneration, "blocked", round, this.roleFailureText(`review-${verdict}`), goalText);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
// 审查结论卡:把 verdict JSON 翻译成人话框住(裸 JSON 留在流里,但不再是唯一载体)。
|
|
1536
|
+
if (this.isCurrentDelegated(conv, goalGeneration)) {
|
|
1537
|
+
await this.pushReviewCard(conv, this.reviewCardText(verdict.verdict, round, budget, verdict.feedback), {
|
|
1538
|
+
phase: "result",
|
|
1539
|
+
round,
|
|
1540
|
+
verdict: verdict.verdict,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1544
|
+
return;
|
|
1545
|
+
if (verdict.verdict === "pass") {
|
|
1546
|
+
await this.finishDelegated(conv, goalGeneration, "pass", round, verdict.feedback, goalText);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
feedback = verdict.feedback;
|
|
1550
|
+
// fail:回到循环顶部(预算检查在那里兜底)。
|
|
962
1551
|
}
|
|
963
1552
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
// asynchronous reviewer mutate the new conversation's goal state.
|
|
968
|
-
const mainConv = this.host.getConv(conv.id) ?? conv;
|
|
969
|
-
const mainSession = mainConv.session;
|
|
1553
|
+
/** 派发一轮执行:首轮 spawn 常驻执行者,之后 steer 同一个(记忆连续)。 */
|
|
1554
|
+
async dispatchExecutor(conv, goalGeneration, goalText, round, budget, feedback) {
|
|
1555
|
+
const host = this.host;
|
|
970
1556
|
const g = conv.goal;
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1557
|
+
const existing = conv.roleExec;
|
|
1558
|
+
if (existing) {
|
|
1559
|
+
// 执行对话被移出 / 服务重启 → 执行者记忆已丢:重建会静默换个「新人」,
|
|
1560
|
+
// 按受阻收尾比悄悄换人可靠(设计文档 §4.6 失效矩阵)。
|
|
1561
|
+
const alive = host.hasConv ? host.hasConv(existing.convId) : true;
|
|
1562
|
+
if (!alive) {
|
|
1563
|
+
await this.finishDelegated(conv, goalGeneration, "blocked", round, this.roleFailureText("exec-gone"), goalText);
|
|
1564
|
+
return undefined;
|
|
1565
|
+
}
|
|
1566
|
+
const sent = await host.sendRoleAgent(existing.convId, this.executorRoundPrompt(goalText, round, budget, feedback));
|
|
1567
|
+
if (!sent) {
|
|
1568
|
+
await this.finishDelegated(conv, goalGeneration, "blocked", round, this.roleFailureText("exec-gone"), goalText);
|
|
1569
|
+
return undefined;
|
|
1570
|
+
}
|
|
1571
|
+
this.beginRoundVitals(conv, existing.convId);
|
|
1572
|
+
return existing.convId;
|
|
1573
|
+
}
|
|
1574
|
+
try {
|
|
1575
|
+
const convId = await host.spawnRoleAgent({
|
|
1576
|
+
role: "executor",
|
|
1577
|
+
prompt: this.executorRoundPrompt(goalText, round, budget, ""),
|
|
1578
|
+
cwd: conv.cwd,
|
|
1579
|
+
model: g.execModel ?? null,
|
|
1580
|
+
parentId: conv.id,
|
|
1581
|
+
// 落盘对话在左栏/历史里没有「子代理」微标,用带前缀的标题保持可辨识。
|
|
1582
|
+
title: this.roleConvTitle(goalText),
|
|
1583
|
+
});
|
|
1584
|
+
conv.roleExec = { convId, generation: (conv.roleExec?.generation ?? 0) + 1 };
|
|
1585
|
+
g.roles = { ...g.roles, executor: { convId, spawned: true } };
|
|
1586
|
+
this.beginRoundVitals(conv, convId);
|
|
990
1587
|
this.emitGoalStatus();
|
|
991
|
-
return;
|
|
1588
|
+
return convId;
|
|
992
1589
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1590
|
+
catch (err) {
|
|
1591
|
+
// 配额满 / runtime 创建失败:目标模式只有委托一条路径,没有可降级对象。
|
|
1592
|
+
await this.abortOnSpawnFailure(conv, goalGeneration, goalText, err instanceof Error ? err.message : String(err));
|
|
1593
|
+
return undefined;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/** 派活后记一笔本轮 vitals 基线:执行者用量起点 + 快照(目标条实时进度用)。
|
|
1597
|
+
* 取数失败不阻断派活(read 缺字段即跳过,老 host 照常工作)。 */
|
|
1598
|
+
beginRoundVitals(conv, execId) {
|
|
1599
|
+
let read;
|
|
1002
1600
|
try {
|
|
1003
|
-
|
|
1601
|
+
read = this.host.readRoleAgent?.(execId);
|
|
1004
1602
|
}
|
|
1005
1603
|
catch {
|
|
1006
|
-
|
|
1604
|
+
read = undefined;
|
|
1007
1605
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1606
|
+
conv.execUsageBefore = read?.usage;
|
|
1607
|
+
this.snapshotExecutor(conv, execId, read);
|
|
1608
|
+
}
|
|
1609
|
+
/** 刷新执行者快照进 `goal.roles.executor`(目标条实时进度用;轮次边界调用)。
|
|
1610
|
+
* 在跑且有最近工具 → 「xx 运行中」;否则取自述头 60 字;都没有则清空(不留脏数据)。 */
|
|
1611
|
+
snapshotExecutor(conv, execId, read) {
|
|
1612
|
+
const g = conv.goal;
|
|
1613
|
+
const role = g.roles?.executor;
|
|
1614
|
+
if (!role || role.convId !== execId)
|
|
1011
1615
|
return;
|
|
1616
|
+
const streaming = read?.streaming;
|
|
1617
|
+
let activity;
|
|
1618
|
+
let activityEn;
|
|
1619
|
+
if (streaming && read?.lastTool) {
|
|
1620
|
+
activity = `${read.lastTool} 运行中`;
|
|
1621
|
+
activityEn = `${read.lastTool} running`;
|
|
1622
|
+
}
|
|
1623
|
+
else if (read?.text?.trim()) {
|
|
1624
|
+
const head = read.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
|
1625
|
+
activity = head;
|
|
1626
|
+
activityEn = head;
|
|
1627
|
+
}
|
|
1628
|
+
g.roles = { ...g.roles, executor: { ...role, streaming, activity, activityEn } };
|
|
1629
|
+
}
|
|
1630
|
+
/** 取样一轮:工作区 diff 指纹 + 执行者会话错误特征(熔断信号独立于 verdict)。 */
|
|
1631
|
+
async sampleRound(conv, execId) {
|
|
1632
|
+
const read = this.host.readRoleAgent?.(execId);
|
|
1633
|
+
const output = read?.text ?? "";
|
|
1634
|
+
let diffOut = "";
|
|
1635
|
+
try {
|
|
1636
|
+
diffOut = await this.host.gitDiff(conv.cwd);
|
|
1012
1637
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const currentError =
|
|
1638
|
+
catch {
|
|
1639
|
+
diffOut = "";
|
|
1640
|
+
}
|
|
1641
|
+
const currentError = read?.errorSnippet ?? extractErrorSnippetFromSession(undefined, output);
|
|
1017
1642
|
const prevError = conv.lastErrorSnippet;
|
|
1018
1643
|
if (currentError &&
|
|
1019
1644
|
prevError &&
|
|
@@ -1024,142 +1649,228 @@ export class GoalService {
|
|
|
1024
1649
|
conv.sameErrorRounds = currentError ? 1 : 0;
|
|
1025
1650
|
}
|
|
1026
1651
|
conv.lastErrorSnippet = currentError;
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1652
|
+
// 非 git 目录(git diff 恒为空)下没有可信的工作区进展信号:跳过停滞计数
|
|
1653
|
+
// (不清零也不累加),否则纯问答/回答类目标会在第 2 轮被误判「无进展」。
|
|
1654
|
+
// 错误特征与连败熔断不受影响,仍正常计数。
|
|
1655
|
+
let repoAvailable = true;
|
|
1656
|
+
try {
|
|
1657
|
+
if (this.host.isGitRepo)
|
|
1658
|
+
repoAvailable = await this.host.isGitRepo(conv.cwd);
|
|
1032
1659
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
}
|
|
1036
|
-
conv.lastDiff = trimmedDiff;
|
|
1037
|
-
const isAutonomous = !g.reviewModel;
|
|
1038
|
-
if (isAutonomous) {
|
|
1039
|
-
// DSH 风格自主轮次驱动(免拉起独立审查会话,省 token + 零启动延迟):
|
|
1040
|
-
// 检查模型自身是否在输出中表明目标已达成(只认约定标记,见 GOAL_COMPLETION_RE)
|
|
1041
|
-
const isCompleted = isGoalCompletionSignal(finalText);
|
|
1042
|
-
if (isCompleted) {
|
|
1043
|
-
reviewerVerdict = "pass";
|
|
1044
|
-
reviewerFeedback = pick(this.lang(), "模型自主验证:目标已达成", "Model autonomous evaluation: Goal completed", "goal.autonomous.pass");
|
|
1045
|
-
}
|
|
1046
|
-
else if ((conv.sameErrorRounds ?? 0) >= 2 || (conv.stagnantRounds ?? 0) >= 2) {
|
|
1047
|
-
// 触发防死循环与停滞熔断(Blocked)
|
|
1048
|
-
reviewerVerdict = "blocked";
|
|
1049
|
-
const blockedReason = (conv.sameErrorRounds ?? 0) >= 2
|
|
1050
|
-
? `连续 ${conv.sameErrorRounds} 轮出现相同错误:${currentError}`
|
|
1051
|
-
: `连续 ${conv.stagnantRounds} 轮未检测到有效文件修改或实质进展`;
|
|
1052
|
-
const blockedReasonEn = (conv.sameErrorRounds ?? 0) >= 2
|
|
1053
|
-
? `Identical error across ${conv.sameErrorRounds} consecutive rounds: ${currentError}`
|
|
1054
|
-
: `No effective file modifications or progress across ${conv.stagnantRounds} consecutive rounds`;
|
|
1055
|
-
reviewerFeedback = pick(this.lang(), `【目标防死循环保护:执行受阻(Blocked)】\n\n` +
|
|
1056
|
-
`• 停滞原因:${blockedReason}\n` +
|
|
1057
|
-
`• 当前轮次:第 ${g.round} 轮\n` +
|
|
1058
|
-
`• 诊断分析:智能体在自主推进中连续轮次未产生有效进展或反复遭遇相同错误,已自动熔断以防止无谓消耗 token。\n` +
|
|
1059
|
-
`• 建议措施:请检查相关代码、工具权限或手动调整提示词,排查阻碍后再继续。`, `[Goal Infinite-Loop Protection: Blocked]\n\n` +
|
|
1060
|
-
`• Cause: ${blockedReasonEn}\n` +
|
|
1061
|
-
`• Current round: Round ${g.round}\n` +
|
|
1062
|
-
`• Diagnosis: Agent made no progress or encountered identical errors across consecutive rounds. Circuit breaker tripped to prevent token waste.\n` +
|
|
1063
|
-
`• Recommendation: Please check code, tool permissions, or refine prompt before proceeding.`, "goal.review.blocked", { blockedReason, blockedReasonEn, round: g.round });
|
|
1064
|
-
}
|
|
1065
|
-
else {
|
|
1066
|
-
reviewerVerdict = "fail";
|
|
1067
|
-
reviewerFeedback = pick(this.lang(), "目标尚未完成,自主推进下一轮迭代验证。", "Goal not yet completed; continuing to next iteration.", "goal.autonomous.continue");
|
|
1068
|
-
}
|
|
1660
|
+
catch {
|
|
1661
|
+
repoAvailable = true;
|
|
1069
1662
|
}
|
|
1070
|
-
|
|
1663
|
+
if (repoAvailable) {
|
|
1664
|
+
const trimmed = diffOut.trim();
|
|
1665
|
+
const prevDiff = conv.lastDiff;
|
|
1666
|
+
const noChange = trimmed === "" || (prevDiff !== undefined && trimmed === prevDiff);
|
|
1667
|
+
conv.stagnantRounds = noChange ? (conv.stagnantRounds ?? 0) + 1 : 0;
|
|
1668
|
+
conv.lastDiff = trimmed;
|
|
1669
|
+
}
|
|
1670
|
+
// 本轮执行者用量 = 取样点累计 - 派活点累计(取不到任一端即跳过,不污染总数)。
|
|
1671
|
+
const after = read?.usage;
|
|
1672
|
+
const before = conv.execUsageBefore;
|
|
1673
|
+
if (after && before) {
|
|
1674
|
+
const dIn = Math.max(0, after.input - before.input);
|
|
1675
|
+
const dOut = Math.max(0, after.output - before.output);
|
|
1676
|
+
conv.goalUsage = {
|
|
1677
|
+
input: (conv.goalUsage?.input ?? 0) + dIn,
|
|
1678
|
+
output: (conv.goalUsage?.output ?? 0) + dOut,
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
conv.execUsageBefore = undefined;
|
|
1682
|
+
// 取样即轮次边界:执行者快照同步刷新(目标条实时进度用)。
|
|
1683
|
+
this.snapshotExecutor(conv, execId, read);
|
|
1684
|
+
return { output, errorSnippet: currentError };
|
|
1685
|
+
}
|
|
1686
|
+
/** 审查:把审查指令交给主对话,等 verdict;无 JSON 允许重试一次。 */
|
|
1687
|
+
async askDelegatedReview(conv, goalGeneration, round, budget, execOutput) {
|
|
1688
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1689
|
+
// 委托执行下用户仍可自由聊天:先等主对话空闲,否则审查指令会被当成
|
|
1690
|
+
// steer 插进用户自己的回合里(语义冲突)。
|
|
1691
|
+
const idle = await this.waitMainIdle(conv);
|
|
1692
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1693
|
+
return "gone";
|
|
1694
|
+
if (!idle)
|
|
1695
|
+
return "timeout";
|
|
1696
|
+
const planDesc = this.host.describePlan?.(conv.id) ?? "";
|
|
1697
|
+
const text = attempt === 0
|
|
1698
|
+
? this.reviewerRoundPrompt(conv.goal.goal ?? "", round, budget, execOutput, planDesc)
|
|
1699
|
+
: this.verdictRetryPrompt();
|
|
1700
|
+
const verdict = await this.deliverAndWait(conv, round, text);
|
|
1701
|
+
if (verdict !== "invalid")
|
|
1702
|
+
return verdict;
|
|
1703
|
+
}
|
|
1704
|
+
return "invalid";
|
|
1705
|
+
}
|
|
1706
|
+
/** 等主对话空闲(事件驱动,不轮询);超上限返回 false。 */
|
|
1707
|
+
async waitMainIdle(conv) {
|
|
1708
|
+
const end = Date.now() + this.roleDeadline();
|
|
1709
|
+
for (;;) {
|
|
1710
|
+
if (!this.isConvStreaming(conv))
|
|
1711
|
+
return true;
|
|
1712
|
+
if (Date.now() >= end)
|
|
1713
|
+
return false;
|
|
1714
|
+
const r = await this.host.waitRoleAgent?.(conv.id, Math.min(30_000, end - Date.now()));
|
|
1715
|
+
if (r === "gone")
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/** 投递审查指令并等 onAgentEnd 送来 verdict(登记 waiter 后再投递,防抢跑)。 */
|
|
1720
|
+
/** 往主对话消息流里插一张目标审查卡(customType "goal-review",前端有专属卡片样式)。
|
|
1721
|
+
* 纯妆点:审查指令(user 消息)与 verdict JSON(assistant 消息)之间本来没有任何
|
|
1722
|
+
* 视觉分隔,用户看到的是裸 JSON;起止两张卡把一轮审查框起来。失败不阻断循环。 */
|
|
1723
|
+
async pushReviewCard(conv, text, details) {
|
|
1724
|
+
try {
|
|
1725
|
+
await conv.session.sendCustomMessage({
|
|
1726
|
+
customType: "goal-review",
|
|
1727
|
+
content: [{ type: "text", text }],
|
|
1728
|
+
display: true,
|
|
1729
|
+
details: { type: "goal-review", ...details },
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
catch {
|
|
1733
|
+
// Card insertion is cosmetic — never block the review loop on it.
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
/** 取出该对话在审查回合里排队的用户插话并按序发出(take 语义,防重复投递)。
|
|
1737
|
+
* followUp 投递:在跑回合结束后送达,不污染已结算的 verdict;空闲则直接开新回合。 */
|
|
1738
|
+
async flushDeferredPrompts(conv) {
|
|
1739
|
+
const queued = conv.deferredPrompts;
|
|
1740
|
+
conv.deferredPrompts = undefined;
|
|
1741
|
+
if (!queued || queued.length === 0)
|
|
1742
|
+
return;
|
|
1743
|
+
for (const text of queued) {
|
|
1071
1744
|
try {
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
agentDir: this.host.agentDir,
|
|
1076
|
-
// The reviewer has its own skill allow/deny list. It deliberately does
|
|
1077
|
-
// not reuse the main session's disabledSkills setting.
|
|
1078
|
-
resourceLoaderOptions: {
|
|
1079
|
-
skillsOverride: (res) => ({
|
|
1080
|
-
...res,
|
|
1081
|
-
skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
|
|
1082
|
-
}),
|
|
1083
|
-
},
|
|
1084
|
-
// A FRESH ModelRuntime for the reviewer — isolated from the shared
|
|
1085
|
-
// one used by the main conversations, so its model choice is its own.
|
|
1086
|
-
modelRuntime: await ModelRuntime.create({
|
|
1087
|
-
authPath: join(this.host.agentDir, "auth.json"),
|
|
1088
|
-
modelsPath: join(this.host.agentDir, "models.json"),
|
|
1089
|
-
}),
|
|
1090
|
-
});
|
|
1091
|
-
// Model resolution: explicit reviewer model, else the main session's
|
|
1092
|
-
// current model (so a goal works even when no reviewer model is given).
|
|
1093
|
-
let model;
|
|
1094
|
-
if (rmSpec) {
|
|
1095
|
-
model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
|
|
1096
|
-
}
|
|
1097
|
-
if (!model) {
|
|
1098
|
-
const mainModel = mainSession.model;
|
|
1099
|
-
if (mainModel?.provider && mainModel.id) {
|
|
1100
|
-
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
const srv = await createAgentSessionFromServices({
|
|
1104
|
-
services,
|
|
1105
|
-
sessionManager: SessionManager.inMemory(mainConv.cwd),
|
|
1106
|
-
...(model ? { model } : {}),
|
|
1107
|
-
});
|
|
1108
|
-
const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
|
|
1109
|
-
const reviewer = srv.session;
|
|
1110
|
-
await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
|
|
1111
|
-
// Parse the reviewer's final output (expected to be a JSON object).
|
|
1112
|
-
const raw = reviewer.getLastAssistantText() ?? "";
|
|
1113
|
-
const parsed = parseReviewerVerdict(raw);
|
|
1114
|
-
if (parsed) {
|
|
1115
|
-
reviewerVerdict = parsed.verdict;
|
|
1116
|
-
reviewerFeedback = parsed.feedback;
|
|
1117
|
-
}
|
|
1118
|
-
else {
|
|
1119
|
-
// No JSON — assume fail with the raw output as feedback.
|
|
1120
|
-
reviewerVerdict = "fail";
|
|
1121
|
-
reviewerFeedback = raw.slice(0, 2000);
|
|
1122
|
-
}
|
|
1123
|
-
await srv.session.dispose();
|
|
1745
|
+
const ok = await this.host.sendRoleAgent(conv.id, text, "followUp");
|
|
1746
|
+
if (!ok)
|
|
1747
|
+
break; // 对话已不在,剩下发不出去,直接丢(排队时已告知用户)
|
|
1124
1748
|
}
|
|
1125
|
-
catch
|
|
1126
|
-
|
|
1127
|
-
reviewerVerdict = "fail";
|
|
1128
|
-
reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
|
|
1749
|
+
catch {
|
|
1750
|
+
break;
|
|
1129
1751
|
}
|
|
1130
1752
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1753
|
+
this.host.flushSnapshot();
|
|
1754
|
+
}
|
|
1755
|
+
async deliverAndWait(conv, round, text) {
|
|
1756
|
+
const host = this.host;
|
|
1757
|
+
conv.awaitingVerdict = { round };
|
|
1758
|
+
// 审查者(主对话)本轮用量基线:verdict 落定点相减即审查增量。
|
|
1759
|
+
conv.reviewUsageBefore = this.sessionUsage(conv.session);
|
|
1760
|
+
// 审查开始卡:先框住本轮,再投递审查指令(主对话空闲,顺序即流序)。
|
|
1761
|
+
await this.pushReviewCard(conv, this.reviewCardText("start", round, this.roundBudget(conv.goal), ""), {
|
|
1762
|
+
phase: "start",
|
|
1763
|
+
round,
|
|
1764
|
+
});
|
|
1765
|
+
const verdict = await new Promise((resolve) => {
|
|
1766
|
+
let settled = false;
|
|
1767
|
+
let timer;
|
|
1768
|
+
const settle = (v) => {
|
|
1769
|
+
if (settled)
|
|
1770
|
+
return;
|
|
1771
|
+
settled = true;
|
|
1772
|
+
if (timer)
|
|
1773
|
+
clearTimeout(timer);
|
|
1774
|
+
if (this.verdictWaiters.get(conv.id) === settle)
|
|
1775
|
+
this.verdictWaiters.delete(conv.id);
|
|
1776
|
+
resolve(v);
|
|
1777
|
+
};
|
|
1778
|
+
this.verdictWaiters.set(conv.id, settle);
|
|
1779
|
+
timer = setTimeout(() => settle("timeout"), this.roleDeadline());
|
|
1780
|
+
timer.unref?.();
|
|
1781
|
+
this.signalAwaitingVerdict(conv.id);
|
|
1782
|
+
void host.sendRoleAgent(conv.id, text, "followUp")
|
|
1783
|
+
.then((ok) => {
|
|
1784
|
+
if (!ok)
|
|
1785
|
+
settle("gone");
|
|
1786
|
+
})
|
|
1787
|
+
.catch(() => settle("gone"));
|
|
1788
|
+
});
|
|
1789
|
+
if (conv.awaitingVerdict?.round === round)
|
|
1790
|
+
conv.awaitingVerdict = undefined;
|
|
1791
|
+
// verdict 已结算(任何结局):把审查回合里排队的用户插话按序发出去。
|
|
1792
|
+
await this.flushDeferredPrompts(conv);
|
|
1793
|
+
return verdict;
|
|
1794
|
+
}
|
|
1795
|
+
/** onAgentEnd 钩子:审查回合结束 → 取主对话最后一条 assistant 文本解析 verdict。 */
|
|
1796
|
+
deliverDelegatedVerdict(conv) {
|
|
1797
|
+
const settle = this.verdictWaiters.get(conv.id);
|
|
1798
|
+
if (!settle)
|
|
1799
|
+
return;
|
|
1800
|
+
this.verdictWaiters.delete(conv.id);
|
|
1801
|
+
conv.awaitingVerdict = undefined;
|
|
1802
|
+
// 审查回合结束:审查者本轮用量落袋(下一轮投递前会重记基线,重试轮不丢数)。
|
|
1803
|
+
this.addGoalUsage(conv, conv.reviewUsageBefore);
|
|
1804
|
+
conv.reviewUsageBefore = undefined;
|
|
1805
|
+
let text = "";
|
|
1806
|
+
try {
|
|
1807
|
+
text = conv.session.getLastAssistantText() ?? "";
|
|
1808
|
+
}
|
|
1809
|
+
catch {
|
|
1810
|
+
text = "";
|
|
1811
|
+
}
|
|
1812
|
+
settle(parseReviewerVerdict(text) ?? "invalid");
|
|
1813
|
+
}
|
|
1814
|
+
/** 终态落历史(本对话最近 20 个;调用点在状态清空之前,保证目标文本还在)。 */
|
|
1815
|
+
recordHistory(conv, verdict, rounds, feedback) {
|
|
1816
|
+
const text = (conv.goal.goal ?? "").trim();
|
|
1817
|
+
if (!text)
|
|
1818
|
+
return;
|
|
1819
|
+
conv.goalHistory = [
|
|
1820
|
+
{
|
|
1821
|
+
goal: text.slice(0, 200),
|
|
1822
|
+
verdict,
|
|
1823
|
+
rounds,
|
|
1824
|
+
feedback: feedback.trim().replace(/\s+/g, " ").slice(0, 200),
|
|
1825
|
+
finishedAt: Date.now(),
|
|
1826
|
+
},
|
|
1827
|
+
...(conv.goalHistory ?? []),
|
|
1828
|
+
].slice(0, 20);
|
|
1829
|
+
}
|
|
1830
|
+
/** 收尾:pass / blocked / exhausted / 异常 → 状态、通知、角色对话清理。 */
|
|
1831
|
+
async finishDelegated(conv, goalGeneration, kind, round, feedback, goalText) {
|
|
1832
|
+
if (!this.isCurrentDelegated(conv, goalGeneration))
|
|
1136
1833
|
return;
|
|
1834
|
+
const g = conv.goal;
|
|
1835
|
+
// 先落历史(此时目标文本还在;pass 分支后面会清掉它)。
|
|
1836
|
+
this.recordHistory(conv, kind === "pass" ? "pass" : kind === "exhausted" ? "fail" : "blocked", round, feedback);
|
|
1837
|
+
if (conv.awaitingVerdict) {
|
|
1838
|
+
const settle = this.verdictWaiters.get(conv.id);
|
|
1839
|
+
this.verdictWaiters.delete(conv.id);
|
|
1840
|
+
conv.awaitingVerdict = undefined;
|
|
1841
|
+
settle?.("gone");
|
|
1137
1842
|
}
|
|
1843
|
+
const budget = g.locked ? (g.maxRounds > 0 ? Math.min(g.maxRounds, 50) : 0) : 1;
|
|
1844
|
+
const roundsZh = budget > 0 ? `第 ${round}/${budget} 轮` : `第 ${round} 轮(不限)`;
|
|
1845
|
+
const roundsEn = budget > 0 ? `Round ${round}/${budget}` : `Round ${round} (unlimited)`;
|
|
1138
1846
|
g.reviewing = false;
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
//
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1847
|
+
// 角色对话收尾:停掉在飞的回合并一律移出左栏(转录已落盘,历史里仍可回看)。
|
|
1848
|
+
// 受阻/未通过也不再常驻:落盘执行对话占「每项目 8 个普通对话」名额之一,
|
|
1849
|
+
// 连续几个失败目标就会把名额吃满、新目标连执行者都拉不起来。
|
|
1850
|
+
// (用户正看着执行对话时 dismiss 是 no-op,它会暂留,用户可自行关闭。)
|
|
1851
|
+
// 与 stopDelegated 共用 detachRoleExec;这里 await 是为了收尾顺序确定,
|
|
1852
|
+
// 那边 fire-and-forget 是因为调它的都是同步上下文。
|
|
1853
|
+
const execId = this.detachRoleExec(conv);
|
|
1854
|
+
if (execId) {
|
|
1855
|
+
await this.host.stopRoleAgent?.(execId).catch(() => { });
|
|
1856
|
+
await this.host.dismissRoleAgent?.(execId).catch(() => { });
|
|
1857
|
+
}
|
|
1858
|
+
if (kind === "pass") {
|
|
1859
|
+
g.verdict = "pass";
|
|
1860
|
+
g.feedback = feedback;
|
|
1861
|
+
g.phase = "idle";
|
|
1862
|
+
// roles 已由函数顶部的 detachRoleExec 清掉。
|
|
1150
1863
|
g.status = "✅ 已通过目标审查";
|
|
1151
1864
|
g.statusEn = "✅ Goal review passed";
|
|
1152
|
-
this.host.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查", textEn: "✅ Goal passed review" });
|
|
1153
1865
|
g.conversationId = null;
|
|
1154
|
-
g.goal = null;
|
|
1866
|
+
g.goal = null;
|
|
1867
|
+
this.host.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查", textEn: "✅ Goal passed review" });
|
|
1155
1868
|
this.emitGoalStatus();
|
|
1156
|
-
// Pass = the review result goes straight into the conversation as an
|
|
1157
|
-
// ordinary user message (NO separate goal-review card). It both tells the
|
|
1158
|
-
// USER the outcome and hands the main agent back out of "goal mode", so a
|
|
1159
|
-
// follow-up instruction like "发布" is a normal request — not a confirm echo.
|
|
1160
1869
|
try {
|
|
1161
1870
|
const passText = pick(this.lang(), `✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, `✅ Goal achieved and passed review (round ${round}).\n\nGoal: ${goalText}\n\n${feedback}\n\n(Goal mode is off — respond to further instructions normally.)`, "goal.review.pass", { round: round, goalText: goalText, feedback: feedback });
|
|
1162
|
-
await
|
|
1871
|
+
await conv.session.sendUserMessage(passText, {
|
|
1872
|
+
deliverAs: conv.session.isStreaming ? "steer" : "followUp",
|
|
1873
|
+
});
|
|
1163
1874
|
}
|
|
1164
1875
|
catch {
|
|
1165
1876
|
// Best-effort.
|
|
@@ -1167,20 +1878,28 @@ export class GoalService {
|
|
|
1167
1878
|
this.host.flushSnapshot();
|
|
1168
1879
|
return;
|
|
1169
1880
|
}
|
|
1170
|
-
if (
|
|
1171
|
-
g.
|
|
1172
|
-
g.
|
|
1881
|
+
if (kind === "exhausted") {
|
|
1882
|
+
g.verdict = "fail";
|
|
1883
|
+
g.feedback = feedback;
|
|
1884
|
+
g.phase = "idle";
|
|
1885
|
+
if (g.locked && g.maxRounds > 0) {
|
|
1886
|
+
g.status = `已达最大轮数(${g.maxRounds}),目标仍未通过`;
|
|
1887
|
+
g.statusEn = `Max rounds reached (${g.maxRounds}), goal still failing`;
|
|
1888
|
+
}
|
|
1889
|
+
else {
|
|
1890
|
+
g.status = `目标未通过(${roundsZh})`;
|
|
1891
|
+
g.statusEn = `Goal failed (${roundsEn})`;
|
|
1892
|
+
}
|
|
1173
1893
|
this.host.emit({
|
|
1174
1894
|
type: "notice",
|
|
1175
1895
|
level: "warning",
|
|
1176
|
-
text: "
|
|
1177
|
-
textEn: "
|
|
1896
|
+
text: "目标未通过审查(已达最大轮数)",
|
|
1897
|
+
textEn: "Goal failed review (max rounds reached)",
|
|
1178
1898
|
});
|
|
1179
1899
|
try {
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
});
|
|
1900
|
+
const capped = budget > 0 ? budget : "不限";
|
|
1901
|
+
const cappedEn = budget > 0 ? budget : "unlimited";
|
|
1902
|
+
await conv.session.sendUserMessage(pick(this.lang(), `❌ 目标未通过审查(第 ${round}/${capped} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, `❌ Goal failed review (round ${round}/${cappedEn}).\n\nGoal: ${goalText}\n\nFeedback: ${feedback}`, "goal.review.fail", { round: round, capped: capped, goalText: goalText, feedback: feedback, cappedEn: cappedEn }), { deliverAs: conv.session.isStreaming ? "steer" : "followUp" });
|
|
1184
1903
|
}
|
|
1185
1904
|
catch {
|
|
1186
1905
|
// Best-effort.
|
|
@@ -1190,67 +1909,128 @@ export class GoalService {
|
|
|
1190
1909
|
this.host.flushSnapshot();
|
|
1191
1910
|
return;
|
|
1192
1911
|
}
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
const cappedEn = budgetForCard > 0 ? budgetForCard : "unlimited";
|
|
1212
|
-
const steerText = pick(this.lang(), `【目标审查:第 ${g.round}/${capped} 轮未通过】\n\n目标:${goalText}\n\n` +
|
|
1213
|
-
`审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`, `[Goal review: round ${g.round}/${cappedEn} failed]\n\nGoal: ${goalText}\n\n` +
|
|
1214
|
-
`Feedback: ${feedback}\n\nRevise your work based on the feedback above so it fully satisfies the goal.`, "goal.review.revise", { "g.round": g.round, capped: capped, goalText: goalText, feedback: feedback, cappedEn: cappedEn });
|
|
1215
|
-
await mainSession.sendUserMessage(steerText, {
|
|
1216
|
-
deliverAs: mainSession.isStreaming ? "steer" : "followUp",
|
|
1217
|
-
});
|
|
1218
|
-
}
|
|
1219
|
-
catch (err) {
|
|
1220
|
-
g.status = `意见注入失败:${err.message}`;
|
|
1221
|
-
g.statusEn = `Feedback injection failed: ${err.message}`;
|
|
1222
|
-
}
|
|
1223
|
-
this.emitGoalStatus();
|
|
1224
|
-
this.host.flushSnapshot();
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
// Rounds exhausted (finite cap reached / single-shot failed). Deliver the
|
|
1228
|
-
// fail result as an ordinary user message (no separate card), like the pass
|
|
1229
|
-
// and revise paths — the review result always lands in the conversation.
|
|
1230
|
-
if (g.locked && g.maxRounds > 0) {
|
|
1231
|
-
g.status = `已达最大轮数(${g.maxRounds}),目标仍未通过`;
|
|
1232
|
-
g.statusEn = `Max rounds reached (${g.maxRounds}), goal still failing`;
|
|
1233
|
-
}
|
|
1234
|
-
else {
|
|
1235
|
-
g.status = `目标未通过(${roundsZh})`;
|
|
1236
|
-
g.statusEn = `Goal failed (${roundsEn})`;
|
|
1237
|
-
}
|
|
1912
|
+
// blocked:保留目标文本让用户看见并处置(与既有熔断口径一致)。
|
|
1913
|
+
// 执行对话在函数顶部已统一收掉(roles 已清,不留指向死对话的「执行对话」按钮)。
|
|
1914
|
+
const reason = feedback.trim() !== ""
|
|
1915
|
+
? feedback.trim()
|
|
1916
|
+
: (conv.sameErrorRounds ?? 0) >= 2
|
|
1917
|
+
? `连续 ${conv.sameErrorRounds} 轮出现相同错误:${conv.lastErrorSnippet ?? ""}`
|
|
1918
|
+
: `连续 ${conv.stagnantRounds} 轮未检测到有效文件修改或实质进展`;
|
|
1919
|
+
g.verdict = "blocked";
|
|
1920
|
+
g.feedback = reason;
|
|
1921
|
+
g.phase = "blocked";
|
|
1922
|
+
g.status = "⚠️ 目标受阻(委托执行已暂停)";
|
|
1923
|
+
g.statusEn = "⚠️ Goal blocked (delegated execution paused)";
|
|
1924
|
+
this.host.emit({
|
|
1925
|
+
type: "notice",
|
|
1926
|
+
level: "warning",
|
|
1927
|
+
text: "⚠️ 目标执行受阻:委托执行已暂停(可在目标条重新设定目标继续)",
|
|
1928
|
+
textEn: "⚠️ Goal blocked: delegated execution paused (set the goal again to continue)",
|
|
1929
|
+
});
|
|
1238
1930
|
try {
|
|
1239
|
-
|
|
1240
|
-
const cappedEn = budgetForCard > 0 ? budgetForCard : "unlimited";
|
|
1241
|
-
await mainSession.sendUserMessage(pick(this.lang(), `❌ 目标未通过审查(第 ${round}/${capped} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, `❌ Goal failed review (round ${round}/${cappedEn}).\n\nGoal: ${goalText}\n\nFeedback: ${feedback}`, "goal.review.fail", { round: round, capped: capped, goalText: goalText, feedback: feedback, cappedEn: cappedEn }), { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
1931
|
+
await conv.session.sendUserMessage(pick(this.lang(), `⚠️ 目标执行受阻(${roundsZh})。\n\n目标:${goalText}\n\n原因:${reason}\n\n(委托执行已暂停,不会自己重新派活。要彻底退出目标模式:点目标条右侧的 ■ 停止目标(取消);想继续就重新设定目标。)`, `⚠️ Goal execution blocked (${roundsEn}).\n\nGoal: ${goalText}\n\nReason: ${reason}\n\n(Delegated execution is paused and will not dispatch again by itself. To leave goal mode entirely, press ■ Stop goal (cancel) in the goal bar; to resume, set the goal again.)`, "goal.role.blocked", { round: round, goalText: goalText, reason: reason }), { deliverAs: conv.session.isStreaming ? "steer" : "followUp" });
|
|
1242
1932
|
}
|
|
1243
1933
|
catch {
|
|
1244
1934
|
// Best-effort.
|
|
1245
1935
|
}
|
|
1936
|
+
this.emitGoalStatus();
|
|
1937
|
+
this.host.flushSnapshot();
|
|
1938
|
+
}
|
|
1939
|
+
/** 角色对话在左栏/历史里的标题(落盘对话没有「子代理」微标,靠前缀可辨识)。 */
|
|
1940
|
+
roleConvTitle(goalText) {
|
|
1941
|
+
const brief = goalText.replace(/\s+/g, " ").trim().slice(0, 40);
|
|
1942
|
+
const withTail = goalText.trim().length > 40 ? "…" : "";
|
|
1943
|
+
return pick(this.lang(), `[目标执行] ${brief}${withTail}`, `[Goal executor] ${brief}${withTail}`, "goal.role.conv_title");
|
|
1944
|
+
}
|
|
1945
|
+
/** 拉起执行对话失败(配额满 / runtime 创建失败):目标模式只有这一条路径,
|
|
1946
|
+
* 没有可降级的对象 —— 当场中止循环并把原因摆到目标条上(用户可在左栏关掉几个
|
|
1947
|
+
* 对话后重新设定目标)。 */
|
|
1948
|
+
async abortOnSpawnFailure(conv, goalGeneration, goalText, reason) {
|
|
1949
|
+
const g = conv.goal;
|
|
1950
|
+
this.recordHistory(conv, "blocked", conv.goal.round, reason);
|
|
1951
|
+
g.verdict = "blocked";
|
|
1952
|
+
g.phase = "blocked";
|
|
1953
|
+
// 执行者都没建出来,不会有角色引用;roles 不动(setGoal 前的 stopDelegated 已清过)。
|
|
1954
|
+
g.feedback = reason;
|
|
1955
|
+
g.status = "执行对话创建失败,目标未开始";
|
|
1956
|
+
g.statusEn = "Failed to create the executor conversation; the goal did not start";
|
|
1957
|
+
this.emitGoalStatus();
|
|
1246
1958
|
this.host.emit({
|
|
1247
1959
|
type: "notice",
|
|
1248
1960
|
level: "warning",
|
|
1249
|
-
text:
|
|
1250
|
-
textEn:
|
|
1961
|
+
text: `无法创建执行对话,目标未开始:${reason}`,
|
|
1962
|
+
textEn: `Could not create the executor conversation; the goal did not start: ${reason}`,
|
|
1251
1963
|
});
|
|
1252
|
-
|
|
1253
|
-
|
|
1964
|
+
if (/上限|limit/i.test(reason)) {
|
|
1965
|
+
this.host.emit({
|
|
1966
|
+
type: "notice",
|
|
1967
|
+
level: "warning",
|
|
1968
|
+
text: "提示:执行对话会占掉本项目的一个普通对话名额(上限 8 个),可在左栏关掉几个对话后重新设定目标。",
|
|
1969
|
+
textEn: "Tip: the executor conversation takes one of the project's regular conversation slots (max 8) — close a few chats in the left panel, then set the goal again.",
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1254
1972
|
this.host.flushSnapshot();
|
|
1255
1973
|
}
|
|
1974
|
+
/** 委托执行的角色轮提示词:执行者(干活)与审查者(判定)。双语走 pick。 */ executorRoundPrompt(goalText, round, budget, feedback) {
|
|
1975
|
+
const rounds = this.roundsLabel(budget);
|
|
1976
|
+
const prev = feedback.trim();
|
|
1977
|
+
return pick(this.lang(), `【目标 · 第 ${round}${rounds} 轮】\n\n${goalText}\n\n${prev ? `上一轮审查意见:\n${prev}\n\n` : ""}要求:\n- 直接修改工作区(不要只在回复里描述改动),做完用一段话说明「改了什么、怎么验证的」。\n- 禁止向用户提问(本对话由服务端自动驱动,弹窗会被按取消返回)。\n- 禁止派生或等待其他子代理(轮次由服务端控制,你只负责这一轮)。`, `[Goal · round ${round}${rounds}]\n\n${goalText}\n\n${prev ? `Review feedback from the previous round:\n${prev}\n\n` : ""}Requirements:\n- Change the workspace directly (don't just describe it), then summarize in one paragraph what you changed and how you verified it.\n- Do NOT ask the user anything (this conversation is server-driven; dialogs are auto-cancelled).\n- Do NOT spawn or wait for other subagents (the server owns the rounds; you own this one only).`, "goal.role.exec");
|
|
1978
|
+
}
|
|
1979
|
+
/** 审查起止卡的文案(进消息流给人看的;结论 feedback 截断,details 不进大文本)。 */
|
|
1980
|
+
reviewCardText(kind, round, budget, feedback) {
|
|
1981
|
+
const rounds = this.roundsLabel(budget);
|
|
1982
|
+
const fb = feedback.trim().replace(/\s+/g, " ").slice(0, 300);
|
|
1983
|
+
if (kind === "start") {
|
|
1984
|
+
return pick(this.lang(), `🔍 第 ${round}${rounds} 轮审查开始(本回合只回 verdict JSON;审查进行中发消息会自动排队,不用等)`, `🔍 Review round ${round}${rounds} started (reply with only the verdict JSON this round; messages sent mid-review are queued automatically)`, "goal.role.card.start");
|
|
1985
|
+
}
|
|
1986
|
+
return pick(this.lang(), kind === "pass"
|
|
1987
|
+
? `✅ 第 ${round}${rounds} 轮审查通过${fb ? `:${fb}` : ""}`
|
|
1988
|
+
: `❌ 第 ${round}${rounds} 轮未通过${fb ? `:${fb}` : ""}`, kind === "pass"
|
|
1989
|
+
? `✅ Review round ${round}${rounds} passed${fb ? `: ${fb}` : ""}`
|
|
1990
|
+
: `❌ Review round ${round}${rounds} failed${fb ? `: ${fb}` : ""}`, "goal.role.card.result");
|
|
1991
|
+
}
|
|
1992
|
+
reviewerRoundPrompt(goalText, round, budget, execOutput, planDesc = "") {
|
|
1993
|
+
const rounds = this.roundsLabel(budget);
|
|
1994
|
+
const out = execOutput.trim().slice(0, 4000);
|
|
1995
|
+
const planBlockZh = planDesc && planDesc !== "No active plan."
|
|
1996
|
+
? `\n\n【任务计划看板当前状态】\n${planDesc}\n核验时请同时核实上述计划步骤的推进与完成状态是否真实。`
|
|
1997
|
+
: "";
|
|
1998
|
+
const planBlockEn = planDesc && planDesc !== "No active plan."
|
|
1999
|
+
? `\n\n# Task Plan Board\n${planDesc}\nWhen verifying, also check whether the above plan steps have been legitimately advanced or completed.`
|
|
2000
|
+
: "";
|
|
2001
|
+
return pick(this.lang(), `你是严格、独立的验收者。只判断目标是否被完全满足:不要相信描述,去看工作区的实际状态。\n\n【目标】\n${goalText}\n\n【这是第 ${round}${rounds} 轮】${planBlockZh}\n\n【执行者本轮自述】\n${out || "(执行者本轮没有给出自述)"}\n\n你可以用只读手段核实:read / grep / scm(只读 git)/ 只读 bash(跑测试)。\n\n只输出一个 JSON 对象,不要有任何其他文本、不要代码围栏、不要复述下面的形状示例。本回合写类与派发类工具会被服务端直接拒绝(不要试)。字段:verdict 只能填 pass(目标已完全满足,一句话说明满足了什么)或 fail(未满足,给出可以直接动手改的具体待改项);feedback 是一句话说明。形状示例(不要照抄尖括号里的占位符):\n{"verdict":"<pass|fail>","feedback":"<一句话说明>"}\n[goal-review]`, `You are a strict, independent acceptor. Judge only whether the goal is fully satisfied: do not trust the summary — inspect the actual workspace state.\n\n# Goal\n${goalText}\n\n# This is round ${round}${rounds}${planBlockEn}\n\n# Executor's summary this round\n${out || "(the executor produced no summary)"}\n\nYou may verify with read-only means: read / grep / scm (read-only git) / read-only bash (run tests).\n\nReply with ONLY one JSON object — no other text, no code fences, do not echo the shape example below. Write and dispatch tools are blocked by the server during this round — do not try them. Fields: verdict must be pass (goal fully satisfied, say what in one sentence) or fail (not satisfied, give concrete items the executor must fix); feedback is one short sentence. Shape example (do not copy the placeholders in angle brackets):\n{"verdict":"<pass|fail>","feedback":"<one sentence>"}\n[goal-review]`, "goal.role.review");
|
|
2002
|
+
}
|
|
2003
|
+
verdictRetryPrompt() {
|
|
2004
|
+
return pick(this.lang(), `你上一条回复没有给出约定的 JSON。现在只回一个 JSON 对象,不要有任何其他文本或代码围栏:\n{"verdict":"pass|fail","feedback":"…"}\n[goal-review]`, `Your last reply did not contain the required JSON. Reply with ONLY one JSON object now — no other text, no code fences:\n{"verdict":"pass|fail","feedback":"..."}\n[goal-review]`, "goal.role.review.retry");
|
|
2005
|
+
}
|
|
2006
|
+
/** 委托执行受阻时的原因文案(双语走 pick,key 全局唯一)。 */
|
|
2007
|
+
roleFailureText(kind, detail = "") {
|
|
2008
|
+
const detailZh = detail ? `(${detail.slice(0, 160)})` : "";
|
|
2009
|
+
const detailEn = detail ? ` (${detail.slice(0, 160)})` : "";
|
|
2010
|
+
const zh = {
|
|
2011
|
+
timeout: `上一轮执行超时,未完成既定改动${detailZh}。请拆成更小的步骤完成。`,
|
|
2012
|
+
"timeout-repeat": `执行连续 ${GoalService.EXEC_FAILED_ROUNDS_LIMIT} 轮超时未完成${detailZh},目标循环已暂停(不会自己重新派活)。请检查执行环境后重新设定目标。`,
|
|
2013
|
+
canceled: `执行被手动中止${detailZh},目标循环已暂停(不会自己重新派活)。`,
|
|
2014
|
+
error: `上一轮执行报错${detailZh}。请先排查错误再继续。`,
|
|
2015
|
+
"error-repeat": `执行连续 ${GoalService.EXEC_FAILED_ROUNDS_LIMIT} 轮报错${detailZh},目标循环已暂停(不会自己重新派活)。请先排查错误再重新设定目标。`,
|
|
2016
|
+
"exec-gone": "执行对话已被移出或服务重启,执行者记忆已丢失,目标循环已暂停(不会自己重新派活)。",
|
|
2017
|
+
quiesce: "服务器正在排空存量工作(quiesce),不再派发新一轮,目标循环已暂停。用 pi-web-ui server unquiesce 恢复后可重新设定目标继续。",
|
|
2018
|
+
"review-timeout": "审查回合超时,未收到结论。",
|
|
2019
|
+
"review-gone": "审查对话已不可用,未收到结论。",
|
|
2020
|
+
"review-invalid": "审查回合没有给出约定的 JSON 结论(重试一次仍未通过)。",
|
|
2021
|
+
};
|
|
2022
|
+
const en = {
|
|
2023
|
+
timeout: `The previous execution round timed out before finishing the work${detailEn}. Please split it into smaller steps.`,
|
|
2024
|
+
"timeout-repeat": `The executor has timed out for ${GoalService.EXEC_FAILED_ROUNDS_LIMIT} consecutive rounds${detailEn}; the goal loop is paused (it will not dispatch again by itself). Check the environment, then set the goal again.`,
|
|
2025
|
+
canceled: `The execution was aborted manually${detailEn}; the goal loop is paused (it will not dispatch again by itself).`,
|
|
2026
|
+
error: `The previous execution round hit an error${detailEn}. Please investigate before continuing.`,
|
|
2027
|
+
"error-repeat": `The executor has errored for ${GoalService.EXEC_FAILED_ROUNDS_LIMIT} consecutive rounds${detailEn}; the goal loop is paused (it will not dispatch again by itself). Please investigate, then set the goal again.`,
|
|
2028
|
+
"exec-gone": "The executor conversation is gone (dismissed or the server restarted); its memory is lost and the loop is paused (it will not dispatch again by itself).",
|
|
2029
|
+
quiesce: "The server is draining (quiesce); no new rounds will be dispatched and the goal loop is paused. Resume with pi-web-ui server unquiesce, then set the goal again to continue.",
|
|
2030
|
+
"review-timeout": "The review round timed out without a verdict.",
|
|
2031
|
+
"review-gone": "The reviewer conversation is unavailable; no verdict was received.",
|
|
2032
|
+
"review-invalid": "The review round did not produce the required JSON verdict (still missing after one retry).",
|
|
2033
|
+
};
|
|
2034
|
+
return pick(this.lang(), zh[kind] ?? kind, en[kind] ?? kind, `goal.role.failure.${kind}`);
|
|
2035
|
+
}
|
|
1256
2036
|
}
|