pi-web-ui 0.94.1 → 0.96.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 +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/workspace-snapshot.js +113 -0
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -12,11 +12,111 @@
|
|
|
12
12
|
* 结构化子集 GoalConversation 传入(真实 Conversation 满足该结构),会话创建/对话框
|
|
13
13
|
* 取消/git diff 等宿主能力走回调,便于独立测试。UI 文案直接中文(服务端 notice 约定)。
|
|
14
14
|
*/
|
|
15
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { Type } from "typebox";
|
|
17
18
|
import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import {
|
|
19
|
+
import { pick } from "./i18n.js";
|
|
19
20
|
import { parseModelSpec } from "./attachments.js";
|
|
21
|
+
/**
|
|
22
|
+
* 自主模式完成标记(与注入对话的【目标…】约定严格一致):
|
|
23
|
+
* - 【目标已达成】/【目标完成】/【目标达成】——必须带全角括号;
|
|
24
|
+
* - GOAL 后必须跟至少一个分隔符(冒号/下划线/空白)且 COMPLETED/PASSED 为整词。
|
|
25
|
+
* 刻意不收裸子串(如「目标已达成」不带括号):模型在计划、复述目标或假设句里
|
|
26
|
+
* 也会写出这些字样("如果测试全绿则目标已达成"),裸匹配会把中间轮误判成 pass。
|
|
27
|
+
*/
|
|
28
|
+
const GOAL_COMPLETION_RE = /【目标(?:已)?(?:达成|完成)】|(?<![A-Za-z])GOAL(?:[::_]|\s+)+(?:IS\s+)?(?:COMPLETED|PASSED)(?![A-Za-z])/i;
|
|
29
|
+
/** 自主轮次完成信号判定(纯函数,供 runGoalReview 与单测共用)。 */
|
|
30
|
+
export function isGoalCompletionSignal(finalText) {
|
|
31
|
+
return GOAL_COMPLETION_RE.test(finalText);
|
|
32
|
+
}
|
|
33
|
+
/** 提取 raw 中第一个括号平衡的 {...} 子串(字符串字面量内的引号/转义/花括号不参与配对)。 */
|
|
34
|
+
function firstBalancedJsonObject(raw) {
|
|
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;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (ch === '"')
|
|
53
|
+
inString = true;
|
|
54
|
+
else if (ch === "{")
|
|
55
|
+
depth++;
|
|
56
|
+
else if (ch === "}") {
|
|
57
|
+
depth--;
|
|
58
|
+
if (depth === 0)
|
|
59
|
+
return raw.slice(start, i + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 解析审查模型的 verdict 输出(纯函数)。优先取第一个平衡 {...} 做 JSON.parse:
|
|
66
|
+
* 模型常包 markdown 围栏或前后闲话,feedback 里也可能有 \" 转义与嵌套引号,
|
|
67
|
+
* 这些由 JSON 语义天然处理;整体解析失败(单引号/尾逗号等)再退回旧的宽松
|
|
68
|
+
* 正则逐字段抠。两者都失败返回 undefined,调用方按「无 JSON」处理。
|
|
69
|
+
*/
|
|
70
|
+
export function parseReviewerVerdict(raw) {
|
|
71
|
+
const json = firstBalancedJsonObject(raw);
|
|
72
|
+
if (json !== undefined) {
|
|
73
|
+
try {
|
|
74
|
+
const value = JSON.parse(json);
|
|
75
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
76
|
+
if (value.verdict === "pass" || value.verdict === "fail") {
|
|
77
|
+
return { verdict: value.verdict, feedback: typeof value.feedback === "string" ? value.feedback : "" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// 不是合法 JSON(围栏残留/单引号/尾逗号)→ 落到正则兜底
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
|
|
86
|
+
if (m) {
|
|
87
|
+
const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
|
|
88
|
+
return { verdict: m[1], feedback: fm?.[1] ?? "" };
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/** diff 正文进审查 prompt 的截断上限(完整规模信息走 [diff-meta] 尾段)。 */
|
|
93
|
+
export const GIT_DIFF_CAP = 60_000;
|
|
94
|
+
/**
|
|
95
|
+
* 由 git 原始输出构造「变更指纹」(纯函数,供 AgentService.gitDiff 与单测共用)。
|
|
96
|
+
* - diff 正文非空 → 截断正文 + [diff-meta] 尾段(完整字符数 + 排序后的 status
|
|
97
|
+
* 指纹)。尾段永不参与截断:大 diff 两轮的前 60_000 字符可能完全相同(改动
|
|
98
|
+
* 落在截断线之后),只比截断正文会把持续推进误判成停滞;对内容变化敏感的
|
|
99
|
+
* 完整字符数让 prevDiff 等值比较能区分「真没变」与「变了但被截断」。
|
|
100
|
+
* - diff 正文为空 → 排序后的 `git status --porcelain` 指纹(未跟踪文件不进
|
|
101
|
+
* diff,却是新工作区最常见的实际进展);两段都空(返回 "")才算真停滞。
|
|
102
|
+
* - status 输出为空/拍不到 → 对应段省略,退化为旧版纯 diff 行为。
|
|
103
|
+
*/
|
|
104
|
+
export function buildDiffFingerprint(diffOut, statusOut) {
|
|
105
|
+
let status = "";
|
|
106
|
+
if (statusOut.trim() !== "") {
|
|
107
|
+
// porcelain 不承诺输出有序,显式排序保证指纹逐轮稳定可比。
|
|
108
|
+
status = statusOut
|
|
109
|
+
.split("\n")
|
|
110
|
+
.filter((line) => line.trim() !== "")
|
|
111
|
+
.sort()
|
|
112
|
+
.join("\n")
|
|
113
|
+
.slice(0, 20_000);
|
|
114
|
+
}
|
|
115
|
+
if (diffOut.trim() === "")
|
|
116
|
+
return status;
|
|
117
|
+
const meta = `\n[diff-meta] chars=${diffOut.length}${status ? `\n[git-status]\n${status}` : ""}`;
|
|
118
|
+
return diffOut.slice(0, GIT_DIFF_CAP) + meta;
|
|
119
|
+
}
|
|
20
120
|
/** System prompt for the goal-wizard session. The wizard asks the user a few
|
|
21
121
|
* questions (via its goal_ask tool) to scope a raw requirement into a precise,
|
|
22
122
|
* reviewable goal, then emits ONLY the final goal text as its last message. */
|
|
@@ -27,8 +127,11 @@ function wizardPrompt(draft) {
|
|
|
27
127
|
`# User's raw requirement`, // eslint-disable-line no-regex-spaces
|
|
28
128
|
draft,
|
|
29
129
|
``,
|
|
30
|
-
`Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details
|
|
31
|
-
`
|
|
130
|
+
`Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details.`,
|
|
131
|
+
`Convergence guidelines:`,
|
|
132
|
+
`- 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
|
+
`- Prefer multiple-choice with 2-4 mutually exclusive options and place your recommended choice FIRST.`,
|
|
134
|
+
`- 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
|
|
32
135
|
`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
|
|
33
136
|
`GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
|
|
34
137
|
`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
|
|
@@ -122,6 +225,12 @@ export class GoalService {
|
|
|
122
225
|
* Set (or clear) the active goal. `goal === ""` clears it. The goal is
|
|
123
226
|
* applied to the CURRENT active conversation of this project; reviews check
|
|
124
227
|
* whatever run finishes next (agent_end).
|
|
228
|
+
*
|
|
229
|
+
* `opts.targetConvId` retargets the write to a specific conversation — used by
|
|
230
|
+
* the goal wizard, which runs in the background while the user may have
|
|
231
|
+
* switched away: the refined goal must land in the conversation that LAUNCHED
|
|
232
|
+
* the survey (issue #292), not in whatever conversation happens to be active
|
|
233
|
+
* and not be thrown away.
|
|
125
234
|
*/
|
|
126
235
|
async setGoal(goalText, opts) {
|
|
127
236
|
const text = (goalText ?? "").trim();
|
|
@@ -138,11 +247,23 @@ export class GoalService {
|
|
|
138
247
|
});
|
|
139
248
|
return;
|
|
140
249
|
}
|
|
141
|
-
// A goal is scoped to the conversation
|
|
142
|
-
// This prevents an agent_end from a newly-created/switched conversation
|
|
250
|
+
// A goal is scoped to the conversation it is set on (default: the active
|
|
251
|
+
// one). This prevents an agent_end from a newly-created/switched conversation
|
|
143
252
|
// from consuming the previous conversation's goal.
|
|
144
|
-
const
|
|
145
|
-
|
|
253
|
+
const targetConv = opts?.targetConvId ? this.host.getConv(opts.targetConvId) : undefined;
|
|
254
|
+
if (opts?.targetConvId && !targetConv) {
|
|
255
|
+
// The targeted conversation is gone (closed / disposed) — refuse loudly
|
|
256
|
+
// instead of silently landing the goal somewhere else.
|
|
257
|
+
this.host.emit({
|
|
258
|
+
type: "notice",
|
|
259
|
+
level: "warning",
|
|
260
|
+
text: `目标未设置:发起目标调研的对话已关闭。`,
|
|
261
|
+
textEn: `Goal not set: the conversation that started the survey is gone.`,
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const conv = targetConv ?? this.host.activeConv();
|
|
266
|
+
const goalConversationId = conv.id;
|
|
146
267
|
conv.goalGeneration += 1;
|
|
147
268
|
const goal = conv.goal;
|
|
148
269
|
goal.reviewing = false;
|
|
@@ -171,6 +292,10 @@ export class GoalService {
|
|
|
171
292
|
locked: goal.locked,
|
|
172
293
|
});
|
|
173
294
|
// Reset the loop for a freshly-set goal (single-shot goals start at 0).
|
|
295
|
+
conv.stagnantRounds = 0;
|
|
296
|
+
conv.lastDiff = undefined;
|
|
297
|
+
conv.lastErrorSnippet = undefined;
|
|
298
|
+
conv.sameErrorRounds = 0;
|
|
174
299
|
goal.round = 0;
|
|
175
300
|
goal.reviewing = false;
|
|
176
301
|
goal.verdict = "pending";
|
|
@@ -232,6 +357,9 @@ export class GoalService {
|
|
|
232
357
|
// new active conversation while the wizard is still finishing.
|
|
233
358
|
const wizardConversationId = this.host.activeConvId();
|
|
234
359
|
const wizardConversation = this.host.activeConv();
|
|
360
|
+
// Human-readable name for notices that must say WHICH conversation the survey
|
|
361
|
+
// belongs to (issue #292: the user is expected to switch away mid-survey).
|
|
362
|
+
const wizardConversationTitle = wizardConversation.title;
|
|
235
363
|
if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
|
|
236
364
|
this.host.emit({
|
|
237
365
|
type: "notice",
|
|
@@ -329,7 +457,16 @@ export class GoalService {
|
|
|
329
457
|
});
|
|
330
458
|
// The main conversation to show wizard progress cards in.
|
|
331
459
|
const mainSession = wizardConversation.session;
|
|
460
|
+
// The raw draft gets its own read-only card BEFORE the first question, so the
|
|
461
|
+
// flow starts from a visible anchor. If the survey is interrupted (idle/total
|
|
462
|
+
// timeout, ✗, or the user switching away and never coming back), the original
|
|
463
|
+
// requirement is still readable and copyable in THIS conversation instead of
|
|
464
|
+
// having to be retyped (issue #292).
|
|
465
|
+
await this.pushWizardCard(mainSession, pick(this.lang(), `🎯 原始目标草案:${draft}`, `🎯 Initial goal draft: ${draft}`, "goal.wizard.draft.card", {
|
|
466
|
+
draft,
|
|
467
|
+
}), { draft });
|
|
332
468
|
let refinedGoal = "";
|
|
469
|
+
let goalEphemeralDir;
|
|
333
470
|
try {
|
|
334
471
|
const wmSpec = opts?.wizardModel ? this.resolveReviewModel(opts.wizardModel) : null; // reuse the honest "provider/id" parser
|
|
335
472
|
const services = await createAgentSessionServices({
|
|
@@ -354,10 +491,14 @@ export class GoalService {
|
|
|
354
491
|
const goalAsk = defineTool({
|
|
355
492
|
name: "goal_ask",
|
|
356
493
|
label: "Ask the user",
|
|
357
|
-
description:
|
|
494
|
+
description: "Ask the user ONE focused question at a time to scope down the goal. " +
|
|
495
|
+
"Provide 2-4 mutually exclusive options with the recommended option first, " +
|
|
496
|
+
"briefly noting its impact or tradeoff; or ask an open question. Returns the user's chosen answer.",
|
|
358
497
|
parameters: Type.Object({
|
|
359
|
-
question: Type.String({ description:
|
|
360
|
-
options: Type.Optional(Type.Array(Type.String()
|
|
498
|
+
question: Type.String({ description: "The question to ask" }),
|
|
499
|
+
options: Type.Optional(Type.Array(Type.String(), {
|
|
500
|
+
description: "2-4 mutually exclusive options (recommended option first)",
|
|
501
|
+
})),
|
|
361
502
|
}),
|
|
362
503
|
// ONE question at a time. Sequential execution prevents the agent from
|
|
363
504
|
// firing parallel goal_ask calls whose dialogs would overwrite each other
|
|
@@ -463,9 +604,16 @@ export class GoalService {
|
|
|
463
604
|
}
|
|
464
605
|
},
|
|
465
606
|
});
|
|
607
|
+
const sm = SessionManager.inMemory(this.host.cwd());
|
|
608
|
+
goalEphemeralDir = join(services.agentDir, "goal-sessions", `wizard-${Date.now()}`);
|
|
609
|
+
try {
|
|
610
|
+
mkdirSync(goalEphemeralDir, { recursive: true });
|
|
611
|
+
sm.sessionDir = goalEphemeralDir;
|
|
612
|
+
}
|
|
613
|
+
catch { }
|
|
466
614
|
const srv = await createAgentSessionFromServices({
|
|
467
615
|
services,
|
|
468
|
-
sessionManager:
|
|
616
|
+
sessionManager: sm,
|
|
469
617
|
customTools: [goalAsk],
|
|
470
618
|
...(model ? { model } : {}),
|
|
471
619
|
});
|
|
@@ -517,15 +665,25 @@ export class GoalService {
|
|
|
517
665
|
wgoal.wizard.status = "";
|
|
518
666
|
wgoal.wizard.statusEn = "";
|
|
519
667
|
this.wizardSession = null;
|
|
668
|
+
if (goalEphemeralDir) {
|
|
669
|
+
try {
|
|
670
|
+
rmSync(goalEphemeralDir, { recursive: true, force: true });
|
|
671
|
+
}
|
|
672
|
+
catch { }
|
|
673
|
+
}
|
|
520
674
|
this.emitGoalStatus();
|
|
521
675
|
}
|
|
522
|
-
// Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
|
|
676
|
+
// Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal. The raw
|
|
677
|
+
// draft card stays in the launching conversation's flow, so the user can read
|
|
678
|
+
// it back (and retry) instead of having to retype it (issue #292).
|
|
523
679
|
if (ac.signal.aborted || this.wizardCancelled) {
|
|
524
680
|
this.host.emit({
|
|
525
681
|
type: "notice",
|
|
526
682
|
level: "info",
|
|
527
|
-
text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}
|
|
528
|
-
|
|
683
|
+
text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
|
|
684
|
+
`。原始目标草案已保留在会话「${wizardConversationTitle}」的消息流中,可复制后重新发起。`,
|
|
685
|
+
textEn: `Goal survey cancelled${ac.signal.reason ? `: ${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
|
|
686
|
+
`. The initial goal draft is preserved in the conversation "${wizardConversationTitle}" — copy it and start over.`,
|
|
529
687
|
});
|
|
530
688
|
this.wizardAbort = null;
|
|
531
689
|
return;
|
|
@@ -534,20 +692,26 @@ export class GoalService {
|
|
|
534
692
|
this.host.emit({
|
|
535
693
|
type: "notice",
|
|
536
694
|
level: "warning",
|
|
537
|
-
text:
|
|
538
|
-
textEn:
|
|
695
|
+
text: `调研未产出有效目标,请重试(原始目标草案在会话「${wizardConversationTitle}」的消息流中)`,
|
|
696
|
+
textEn: `The survey produced no usable goal — retry (the initial draft is in the conversation "${wizardConversationTitle}")`,
|
|
539
697
|
});
|
|
540
698
|
return;
|
|
541
699
|
}
|
|
542
|
-
|
|
700
|
+
// The survey belongs to the conversation that launched it. The user is EXPECTED
|
|
701
|
+
// to switch away while the questions are being answered (check code, read docs),
|
|
702
|
+
// so "active ≠ launcher" is the normal case, not an error: land the refined goal
|
|
703
|
+
// on the launcher instead of discarding the work (issue #292).
|
|
704
|
+
const targetConv = this.host.getConv(wizardConversationId);
|
|
705
|
+
if (!targetConv) {
|
|
543
706
|
this.host.emit({
|
|
544
707
|
type: "notice",
|
|
545
|
-
level: "
|
|
546
|
-
text:
|
|
547
|
-
textEn: "
|
|
708
|
+
level: "warning",
|
|
709
|
+
text: `目标调研完成,但发起会话「${wizardConversationTitle}」已关闭,结果未应用(可在新会话里重新发起)。`,
|
|
710
|
+
textEn: `The survey finished, but the conversation that started it ("${wizardConversationTitle}") is gone — the result was not applied. Start a new survey.`,
|
|
548
711
|
});
|
|
549
712
|
return;
|
|
550
713
|
}
|
|
714
|
+
const switchedAway = this.host.activeConvId() !== wizardConversationId;
|
|
551
715
|
// Auto-set the refined goal. The wizard workflow implies "set a goal and
|
|
552
716
|
// work until it passes", so default LOCKED=true unless the user explicitly
|
|
553
717
|
// turned the lock off (a lock lets the review loop keep revising to pass;
|
|
@@ -557,6 +721,9 @@ export class GoalService {
|
|
|
557
721
|
reviewModel: wgoal.reviewModel ?? undefined,
|
|
558
722
|
maxRounds: opts?.maxRounds,
|
|
559
723
|
locked: wantLocked,
|
|
724
|
+
// Land the goal on the conversation that launched the survey, even if the
|
|
725
|
+
// user is looking at another one right now (issue #292).
|
|
726
|
+
targetConvId: wizardConversationId,
|
|
560
727
|
// The wizard kicks off generation itself below — avoid a double kick.
|
|
561
728
|
autoStart: false,
|
|
562
729
|
});
|
|
@@ -565,8 +732,12 @@ export class GoalService {
|
|
|
565
732
|
this.host.emit({
|
|
566
733
|
type: "notice",
|
|
567
734
|
level: "info",
|
|
568
|
-
text:
|
|
569
|
-
|
|
735
|
+
text: switchedAway
|
|
736
|
+
? `🎯 会话「${wizardConversationTitle}」目标调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}(已切回该会话开始生成)`
|
|
737
|
+
: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
738
|
+
textEn: switchedAway
|
|
739
|
+
? `🎯 Survey done in "${wizardConversationTitle}", goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""} (switch back to that conversation to watch it generate)`
|
|
740
|
+
: `🎯 Survey done, goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
570
741
|
});
|
|
571
742
|
// Kick the main agent into generating right away (no manual "开始吧").
|
|
572
743
|
// The kick-off is a user message so it appears in the flow and triggers a
|
|
@@ -613,6 +784,10 @@ export class GoalService {
|
|
|
613
784
|
async clearGoal() {
|
|
614
785
|
const conv = this.host.activeConv();
|
|
615
786
|
conv.goalGeneration += 1;
|
|
787
|
+
conv.stagnantRounds = 0;
|
|
788
|
+
conv.lastDiff = undefined;
|
|
789
|
+
conv.lastErrorSnippet = undefined;
|
|
790
|
+
conv.sameErrorRounds = 0;
|
|
616
791
|
const goal = conv.goal;
|
|
617
792
|
goal.reviewing = false;
|
|
618
793
|
goal.conversationId = null;
|
|
@@ -652,6 +827,10 @@ export class GoalService {
|
|
|
652
827
|
if (aborted) {
|
|
653
828
|
if (g.goal && g.conversationId === conv.id) {
|
|
654
829
|
conv.goalGeneration += 1;
|
|
830
|
+
conv.stagnantRounds = 0;
|
|
831
|
+
conv.lastDiff = undefined;
|
|
832
|
+
conv.lastErrorSnippet = undefined;
|
|
833
|
+
conv.sameErrorRounds = 0;
|
|
655
834
|
g.conversationId = null;
|
|
656
835
|
g.goal = null;
|
|
657
836
|
g.reviewing = false;
|
|
@@ -670,9 +849,12 @@ export class GoalService {
|
|
|
670
849
|
// Goal review hook: after the run finished normally, if a goal is
|
|
671
850
|
// active (and it belonged to the ACTIVE conversation) and we're not
|
|
672
851
|
// already mid-review, spawn the isolated reviewer.
|
|
852
|
+
// Only run when verdict is pending — failed/blocked terminal goals must not
|
|
853
|
+
// re-trigger reviews on subsequent unrelated conversational turns.
|
|
673
854
|
if (g.goal &&
|
|
674
855
|
g.conversationId === conv.id &&
|
|
675
856
|
!g.reviewing &&
|
|
857
|
+
g.verdict === "pending" &&
|
|
676
858
|
!conv.wizardRunning &&
|
|
677
859
|
!this.host.isDisposed() &&
|
|
678
860
|
this.goalEnabled()) {
|
|
@@ -684,6 +866,38 @@ export class GoalService {
|
|
|
684
866
|
resolveReviewModel(spec) {
|
|
685
867
|
return parseModelSpec(spec);
|
|
686
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
|
+
}
|
|
687
901
|
/**
|
|
688
902
|
* The whitelisted reviewer plan — tell the reviewer what to decide and how
|
|
689
903
|
* to report, regardless of which model it runs on.
|
|
@@ -798,65 +1012,121 @@ export class GoalService {
|
|
|
798
1012
|
}
|
|
799
1013
|
let reviewerVerdict = "fail";
|
|
800
1014
|
let reviewerFeedback = pick(this.lang(), "(审查无法完成)", "(The review could not be completed)", "goal.review.incomplete");
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
1015
|
+
// 停滞与错误检测分析(DSH 风格防死循环与停滞检测):
|
|
1016
|
+
const currentError = this.extractErrorSnippet(mainSession, finalText);
|
|
1017
|
+
const prevError = conv.lastErrorSnippet;
|
|
1018
|
+
if (currentError &&
|
|
1019
|
+
prevError &&
|
|
1020
|
+
(currentError === prevError || currentError.includes(prevError) || prevError.includes(currentError))) {
|
|
1021
|
+
conv.sameErrorRounds = (conv.sameErrorRounds ?? 0) + 1;
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
conv.sameErrorRounds = currentError ? 1 : 0;
|
|
1025
|
+
}
|
|
1026
|
+
conv.lastErrorSnippet = currentError;
|
|
1027
|
+
const trimmedDiff = diff.trim();
|
|
1028
|
+
const prevDiff = conv.lastDiff;
|
|
1029
|
+
const isNoDiffChange = trimmedDiff === "" || (prevDiff !== undefined && trimmedDiff === prevDiff);
|
|
1030
|
+
if (isNoDiffChange) {
|
|
1031
|
+
conv.stagnantRounds = (conv.stagnantRounds ?? 0) + 1;
|
|
1032
|
+
}
|
|
1033
|
+
else {
|
|
1034
|
+
conv.stagnantRounds = 0;
|
|
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");
|
|
832
1045
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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 });
|
|
848
1064
|
}
|
|
849
1065
|
else {
|
|
850
|
-
// No JSON — assume fail with the raw output as feedback.
|
|
851
1066
|
reviewerVerdict = "fail";
|
|
852
|
-
reviewerFeedback =
|
|
1067
|
+
reviewerFeedback = pick(this.lang(), "目标尚未完成,自主推进下一轮迭代验证。", "Goal not yet completed; continuing to next iteration.", "goal.autonomous.continue");
|
|
853
1068
|
}
|
|
854
|
-
await srv.session.dispose();
|
|
855
1069
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1070
|
+
else {
|
|
1071
|
+
try {
|
|
1072
|
+
const rmSpec = this.resolveReviewModel(g.reviewModel);
|
|
1073
|
+
const services = await createAgentSessionServices({
|
|
1074
|
+
cwd: mainConv.cwd,
|
|
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();
|
|
1124
|
+
}
|
|
1125
|
+
catch (err) {
|
|
1126
|
+
const reviewErrMsg = err.message;
|
|
1127
|
+
reviewerVerdict = "fail";
|
|
1128
|
+
reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
|
|
1129
|
+
}
|
|
860
1130
|
}
|
|
861
1131
|
// The user may have switched chats or replaced/cleared the goal while the
|
|
862
1132
|
// isolated reviewer was running. Never apply a stale verdict or inject it
|
|
@@ -897,12 +1167,37 @@ export class GoalService {
|
|
|
897
1167
|
this.host.flushSnapshot();
|
|
898
1168
|
return;
|
|
899
1169
|
}
|
|
1170
|
+
if (verdict === "blocked") {
|
|
1171
|
+
g.status = "⚠️ 目标受阻(停滞熔断)";
|
|
1172
|
+
g.statusEn = "⚠️ Goal blocked (stagnation circuit break)";
|
|
1173
|
+
this.host.emit({
|
|
1174
|
+
type: "notice",
|
|
1175
|
+
level: "warning",
|
|
1176
|
+
text: "⚠️ 目标执行受阻:检测到停滞或相同报错,已自动暂停",
|
|
1177
|
+
textEn: "⚠️ Goal execution blocked: stagnation or repeated error detected, auto-loop paused",
|
|
1178
|
+
});
|
|
1179
|
+
try {
|
|
1180
|
+
const blockedText = pick(this.lang(), `⚠️ 目标执行受阻(第 ${round} 轮已触发停滞熔断)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已暂停,请在排查问题后重新设定目标或手动继续。)`, `⚠️ Goal execution blocked (stagnation circuit breaker at round ${round}).\n\nGoal: ${goalText}\n\n${feedback}\n\n(Goal mode paused — please investigate and reset goal or continue manually.)`, "goal.review.blocked_msg", { round, goalText, feedback });
|
|
1181
|
+
await mainSession.sendUserMessage(blockedText, {
|
|
1182
|
+
deliverAs: mainSession.isStreaming ? "steer" : "followUp",
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
// Best-effort.
|
|
1187
|
+
}
|
|
1188
|
+
g.reviewing = false;
|
|
1189
|
+
this.emitGoalStatus();
|
|
1190
|
+
this.host.flushSnapshot();
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
900
1193
|
// Failure: if rounds remain, steer a revision; else report the loop done.
|
|
901
1194
|
// For unlimited (budget=0) isLastRound is always false → keeps revising.
|
|
902
1195
|
const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
|
|
903
1196
|
if (!isLastRound) {
|
|
904
1197
|
g.status = `本轮不通过,正在把意见交给 agent 修改(${roundsZh})…`;
|
|
905
1198
|
g.statusEn = `Round failed, sending feedback to the agent (${roundsEn})…`;
|
|
1199
|
+
// 注入修改意见后保持 verdict 为 pending,让 agent 下一次答完后继续下一轮审查
|
|
1200
|
+
g.verdict = "pending";
|
|
906
1201
|
this.host.emit({
|
|
907
1202
|
type: "notice",
|
|
908
1203
|
level: "warning",
|
|
@@ -954,8 +1249,7 @@ export class GoalService {
|
|
|
954
1249
|
text: "目标未通过审查(已达最大轮数)",
|
|
955
1250
|
textEn: "Goal failed review (max rounds reached)",
|
|
956
1251
|
});
|
|
957
|
-
g.
|
|
958
|
-
g.goal = null; // loop exhausted — clear the active goal
|
|
1252
|
+
g.reviewing = false; // 审查结束:保留 g.goal 与 g.conversationId,让用户看到未通过的目标,不丢弃目标文本
|
|
959
1253
|
this.emitGoalStatus();
|
|
960
1254
|
this.host.flushSnapshot();
|
|
961
1255
|
}
|