pi-web-ui 0.94.1 → 0.95.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 +49 -1
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +751 -206
- package/dist/server/agent-service.js +1869 -85
- package/dist/server/approval-rules.js +550 -0
- package/dist/server/attachment-store.js +112 -0
- package/dist/server/attachments.js +62 -171
- package/dist/server/client-state.js +4 -0
- package/dist/server/compact-context-tool.js +125 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/dangling-tools.js +162 -0
- package/dist/server/dsh/dsh-agent-service.js +50 -60
- package/dist/server/edit-soft-tool.js +70 -5
- package/dist/server/eval-tool.js +535 -0
- package/dist/server/files-service.js +35 -7
- package/dist/server/goal-service.js +240 -68
- package/dist/server/hashline-engine.js +665 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/index.js +157 -9
- package/dist/server/lsp-tool.js +679 -0
- package/dist/server/model-admin.js +20 -0
- package/dist/server/office-parse.js +309 -0
- package/dist/server/patch-tool.js +91 -0
- package/dist/server/plan-manager.js +104 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +47 -11
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugins.js +745 -161
- package/dist/server/process-utils.js +12 -6
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/serialize.js +13 -3
- package/dist/server/settings-service.js +108 -1
- package/dist/server/subagents.js +41 -0
- package/dist/server/terminals.js +69 -14
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +214 -6
- package/dist/server/workspace-snapshot.js +113 -0
- package/package.json +2 -2
- 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 +20 -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/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DlGBF2MD.js} +1 -1
- package/web/dist/assets/index-BXnCQL9p.js +364 -0
- package/web/dist/assets/index-RpIGFyi_.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -122,6 +122,12 @@ export class GoalService {
|
|
|
122
122
|
* Set (or clear) the active goal. `goal === ""` clears it. The goal is
|
|
123
123
|
* applied to the CURRENT active conversation of this project; reviews check
|
|
124
124
|
* whatever run finishes next (agent_end).
|
|
125
|
+
*
|
|
126
|
+
* `opts.targetConvId` retargets the write to a specific conversation — used by
|
|
127
|
+
* the goal wizard, which runs in the background while the user may have
|
|
128
|
+
* switched away: the refined goal must land in the conversation that LAUNCHED
|
|
129
|
+
* the survey (issue #292), not in whatever conversation happens to be active
|
|
130
|
+
* and not be thrown away.
|
|
125
131
|
*/
|
|
126
132
|
async setGoal(goalText, opts) {
|
|
127
133
|
const text = (goalText ?? "").trim();
|
|
@@ -138,11 +144,23 @@ export class GoalService {
|
|
|
138
144
|
});
|
|
139
145
|
return;
|
|
140
146
|
}
|
|
141
|
-
// A goal is scoped to the conversation
|
|
142
|
-
// This prevents an agent_end from a newly-created/switched conversation
|
|
147
|
+
// A goal is scoped to the conversation it is set on (default: the active
|
|
148
|
+
// one). This prevents an agent_end from a newly-created/switched conversation
|
|
143
149
|
// from consuming the previous conversation's goal.
|
|
144
|
-
const
|
|
145
|
-
|
|
150
|
+
const targetConv = opts?.targetConvId ? this.host.getConv(opts.targetConvId) : undefined;
|
|
151
|
+
if (opts?.targetConvId && !targetConv) {
|
|
152
|
+
// The targeted conversation is gone (closed / disposed) — refuse loudly
|
|
153
|
+
// instead of silently landing the goal somewhere else.
|
|
154
|
+
this.host.emit({
|
|
155
|
+
type: "notice",
|
|
156
|
+
level: "warning",
|
|
157
|
+
text: `目标未设置:发起目标调研的对话已关闭。`,
|
|
158
|
+
textEn: `Goal not set: the conversation that started the survey is gone.`,
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const conv = targetConv ?? this.host.activeConv();
|
|
163
|
+
const goalConversationId = conv.id;
|
|
146
164
|
conv.goalGeneration += 1;
|
|
147
165
|
const goal = conv.goal;
|
|
148
166
|
goal.reviewing = false;
|
|
@@ -171,6 +189,10 @@ export class GoalService {
|
|
|
171
189
|
locked: goal.locked,
|
|
172
190
|
});
|
|
173
191
|
// Reset the loop for a freshly-set goal (single-shot goals start at 0).
|
|
192
|
+
conv.stagnantRounds = 0;
|
|
193
|
+
conv.lastDiff = undefined;
|
|
194
|
+
conv.lastErrorSnippet = undefined;
|
|
195
|
+
conv.sameErrorRounds = 0;
|
|
174
196
|
goal.round = 0;
|
|
175
197
|
goal.reviewing = false;
|
|
176
198
|
goal.verdict = "pending";
|
|
@@ -232,6 +254,9 @@ export class GoalService {
|
|
|
232
254
|
// new active conversation while the wizard is still finishing.
|
|
233
255
|
const wizardConversationId = this.host.activeConvId();
|
|
234
256
|
const wizardConversation = this.host.activeConv();
|
|
257
|
+
// Human-readable name for notices that must say WHICH conversation the survey
|
|
258
|
+
// belongs to (issue #292: the user is expected to switch away mid-survey).
|
|
259
|
+
const wizardConversationTitle = wizardConversation.title;
|
|
235
260
|
if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
|
|
236
261
|
this.host.emit({
|
|
237
262
|
type: "notice",
|
|
@@ -329,6 +354,14 @@ export class GoalService {
|
|
|
329
354
|
});
|
|
330
355
|
// The main conversation to show wizard progress cards in.
|
|
331
356
|
const mainSession = wizardConversation.session;
|
|
357
|
+
// The raw draft gets its own read-only card BEFORE the first question, so the
|
|
358
|
+
// flow starts from a visible anchor. If the survey is interrupted (idle/total
|
|
359
|
+
// timeout, ✗, or the user switching away and never coming back), the original
|
|
360
|
+
// requirement is still readable and copyable in THIS conversation instead of
|
|
361
|
+
// having to be retyped (issue #292).
|
|
362
|
+
await this.pushWizardCard(mainSession, pick(this.lang(), `🎯 原始目标草案:${draft}`, `🎯 Initial goal draft: ${draft}`, "goal.wizard.draft.card", {
|
|
363
|
+
draft,
|
|
364
|
+
}), { draft });
|
|
332
365
|
let refinedGoal = "";
|
|
333
366
|
try {
|
|
334
367
|
const wmSpec = opts?.wizardModel ? this.resolveReviewModel(opts.wizardModel) : null; // reuse the honest "provider/id" parser
|
|
@@ -519,13 +552,17 @@ export class GoalService {
|
|
|
519
552
|
this.wizardSession = null;
|
|
520
553
|
this.emitGoalStatus();
|
|
521
554
|
}
|
|
522
|
-
// Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
|
|
555
|
+
// Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal. The raw
|
|
556
|
+
// draft card stays in the launching conversation's flow, so the user can read
|
|
557
|
+
// it back (and retry) instead of having to retype it (issue #292).
|
|
523
558
|
if (ac.signal.aborted || this.wizardCancelled) {
|
|
524
559
|
this.host.emit({
|
|
525
560
|
type: "notice",
|
|
526
561
|
level: "info",
|
|
527
|
-
text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}
|
|
528
|
-
|
|
562
|
+
text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
|
|
563
|
+
`。原始目标草案已保留在会话「${wizardConversationTitle}」的消息流中,可复制后重新发起。`,
|
|
564
|
+
textEn: `Goal survey cancelled${ac.signal.reason ? `: ${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}` +
|
|
565
|
+
`. The initial goal draft is preserved in the conversation "${wizardConversationTitle}" — copy it and start over.`,
|
|
529
566
|
});
|
|
530
567
|
this.wizardAbort = null;
|
|
531
568
|
return;
|
|
@@ -534,20 +571,26 @@ export class GoalService {
|
|
|
534
571
|
this.host.emit({
|
|
535
572
|
type: "notice",
|
|
536
573
|
level: "warning",
|
|
537
|
-
text:
|
|
538
|
-
textEn:
|
|
574
|
+
text: `调研未产出有效目标,请重试(原始目标草案在会话「${wizardConversationTitle}」的消息流中)`,
|
|
575
|
+
textEn: `The survey produced no usable goal — retry (the initial draft is in the conversation "${wizardConversationTitle}")`,
|
|
539
576
|
});
|
|
540
577
|
return;
|
|
541
578
|
}
|
|
542
|
-
|
|
579
|
+
// The survey belongs to the conversation that launched it. The user is EXPECTED
|
|
580
|
+
// to switch away while the questions are being answered (check code, read docs),
|
|
581
|
+
// so "active ≠ launcher" is the normal case, not an error: land the refined goal
|
|
582
|
+
// on the launcher instead of discarding the work (issue #292).
|
|
583
|
+
const targetConv = this.host.getConv(wizardConversationId);
|
|
584
|
+
if (!targetConv) {
|
|
543
585
|
this.host.emit({
|
|
544
586
|
type: "notice",
|
|
545
|
-
level: "
|
|
546
|
-
text:
|
|
547
|
-
textEn: "
|
|
587
|
+
level: "warning",
|
|
588
|
+
text: `目标调研完成,但发起会话「${wizardConversationTitle}」已关闭,结果未应用(可在新会话里重新发起)。`,
|
|
589
|
+
textEn: `The survey finished, but the conversation that started it ("${wizardConversationTitle}") is gone — the result was not applied. Start a new survey.`,
|
|
548
590
|
});
|
|
549
591
|
return;
|
|
550
592
|
}
|
|
593
|
+
const switchedAway = this.host.activeConvId() !== wizardConversationId;
|
|
551
594
|
// Auto-set the refined goal. The wizard workflow implies "set a goal and
|
|
552
595
|
// work until it passes", so default LOCKED=true unless the user explicitly
|
|
553
596
|
// turned the lock off (a lock lets the review loop keep revising to pass;
|
|
@@ -557,6 +600,9 @@ export class GoalService {
|
|
|
557
600
|
reviewModel: wgoal.reviewModel ?? undefined,
|
|
558
601
|
maxRounds: opts?.maxRounds,
|
|
559
602
|
locked: wantLocked,
|
|
603
|
+
// Land the goal on the conversation that launched the survey, even if the
|
|
604
|
+
// user is looking at another one right now (issue #292).
|
|
605
|
+
targetConvId: wizardConversationId,
|
|
560
606
|
// The wizard kicks off generation itself below — avoid a double kick.
|
|
561
607
|
autoStart: false,
|
|
562
608
|
});
|
|
@@ -565,8 +611,12 @@ export class GoalService {
|
|
|
565
611
|
this.host.emit({
|
|
566
612
|
type: "notice",
|
|
567
613
|
level: "info",
|
|
568
|
-
text:
|
|
569
|
-
|
|
614
|
+
text: switchedAway
|
|
615
|
+
? `🎯 会话「${wizardConversationTitle}」目标调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}(已切回该会话开始生成)`
|
|
616
|
+
: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
617
|
+
textEn: switchedAway
|
|
618
|
+
? `🎯 Survey done in "${wizardConversationTitle}", goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""} (switch back to that conversation to watch it generate)`
|
|
619
|
+
: `🎯 Survey done, goal set: ${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
570
620
|
});
|
|
571
621
|
// Kick the main agent into generating right away (no manual "开始吧").
|
|
572
622
|
// The kick-off is a user message so it appears in the flow and triggers a
|
|
@@ -613,6 +663,10 @@ export class GoalService {
|
|
|
613
663
|
async clearGoal() {
|
|
614
664
|
const conv = this.host.activeConv();
|
|
615
665
|
conv.goalGeneration += 1;
|
|
666
|
+
conv.stagnantRounds = 0;
|
|
667
|
+
conv.lastDiff = undefined;
|
|
668
|
+
conv.lastErrorSnippet = undefined;
|
|
669
|
+
conv.sameErrorRounds = 0;
|
|
616
670
|
const goal = conv.goal;
|
|
617
671
|
goal.reviewing = false;
|
|
618
672
|
goal.conversationId = null;
|
|
@@ -652,6 +706,10 @@ export class GoalService {
|
|
|
652
706
|
if (aborted) {
|
|
653
707
|
if (g.goal && g.conversationId === conv.id) {
|
|
654
708
|
conv.goalGeneration += 1;
|
|
709
|
+
conv.stagnantRounds = 0;
|
|
710
|
+
conv.lastDiff = undefined;
|
|
711
|
+
conv.lastErrorSnippet = undefined;
|
|
712
|
+
conv.sameErrorRounds = 0;
|
|
655
713
|
g.conversationId = null;
|
|
656
714
|
g.goal = null;
|
|
657
715
|
g.reviewing = false;
|
|
@@ -684,6 +742,38 @@ export class GoalService {
|
|
|
684
742
|
resolveReviewModel(spec) {
|
|
685
743
|
return parseModelSpec(spec);
|
|
686
744
|
}
|
|
745
|
+
/** 提取会话最近产生的错误特征,用于停滞与相同报错检测。 */
|
|
746
|
+
extractErrorSnippet(session, text) {
|
|
747
|
+
try {
|
|
748
|
+
const messages = session.agent?.state?.messages;
|
|
749
|
+
if (Array.isArray(messages)) {
|
|
750
|
+
for (let i = messages.length - 1; i >= 0 && i >= messages.length - 6; i--) {
|
|
751
|
+
const m = messages[i];
|
|
752
|
+
if (m.role === "toolResult" && m.isError) {
|
|
753
|
+
const errText = m.content
|
|
754
|
+
?.map((c) => (c.type === "text" ? c.text : ""))
|
|
755
|
+
.join(" ")
|
|
756
|
+
.trim();
|
|
757
|
+
if (errText)
|
|
758
|
+
return errText.slice(0, 300);
|
|
759
|
+
}
|
|
760
|
+
if (m.role === "bashExecution" && m.exitCode && m.exitCode !== 0) {
|
|
761
|
+
const snippet = m.output?.trim().slice(-300);
|
|
762
|
+
if (snippet)
|
|
763
|
+
return `bash exit ${m.exitCode}: ${snippet}`;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
// Ignore
|
|
770
|
+
}
|
|
771
|
+
const errMatch = text.match(/(?:(?:Error|Exception|Fail|Fatal):[^\n]+)/i);
|
|
772
|
+
if (errMatch) {
|
|
773
|
+
return errMatch[0].trim().slice(0, 300);
|
|
774
|
+
}
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
687
777
|
/**
|
|
688
778
|
* The whitelisted reviewer plan — tell the reviewer what to decide and how
|
|
689
779
|
* to report, regardless of which model it runs on.
|
|
@@ -798,65 +888,123 @@ export class GoalService {
|
|
|
798
888
|
}
|
|
799
889
|
let reviewerVerdict = "fail";
|
|
800
890
|
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
|
-
|
|
891
|
+
// 停滞与错误检测分析(DSH 风格防死循环与停滞检测):
|
|
892
|
+
const currentError = this.extractErrorSnippet(mainSession, finalText);
|
|
893
|
+
const prevError = conv.lastErrorSnippet;
|
|
894
|
+
if (currentError &&
|
|
895
|
+
prevError &&
|
|
896
|
+
(currentError === prevError || currentError.includes(prevError) || prevError.includes(currentError))) {
|
|
897
|
+
conv.sameErrorRounds = (conv.sameErrorRounds ?? 0) + 1;
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
conv.sameErrorRounds = currentError ? 1 : 0;
|
|
901
|
+
}
|
|
902
|
+
conv.lastErrorSnippet = currentError;
|
|
903
|
+
const trimmedDiff = diff.trim();
|
|
904
|
+
const prevDiff = conv.lastDiff;
|
|
905
|
+
const isNoDiffChange = trimmedDiff === "" || (prevDiff !== undefined && trimmedDiff === prevDiff);
|
|
906
|
+
if (isNoDiffChange) {
|
|
907
|
+
conv.stagnantRounds = (conv.stagnantRounds ?? 0) + 1;
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
conv.stagnantRounds = 0;
|
|
911
|
+
}
|
|
912
|
+
conv.lastDiff = trimmedDiff;
|
|
913
|
+
const isAutonomous = !g.reviewModel;
|
|
914
|
+
if (isAutonomous) {
|
|
915
|
+
// DSH 风格自主轮次驱动(免拉起独立审查会话,省 token + 零启动延迟):
|
|
916
|
+
// 检查模型自身是否在输出中表明目标已达成
|
|
917
|
+
const completionRegex = /【目标(?:已)?(?:达成|完成)】|GOAL(?:[::_]|\s+)*(?:IS\s+)?(?:COMPLETED|PASSED)|目标已达成|目标已完成/i;
|
|
918
|
+
const isCompleted = completionRegex.test(finalText);
|
|
919
|
+
if (isCompleted) {
|
|
920
|
+
reviewerVerdict = "pass";
|
|
921
|
+
reviewerFeedback = pick(this.lang(), "模型自主验证:目标已达成", "Model autonomous evaluation: Goal completed", "goal.autonomous.pass");
|
|
832
922
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
923
|
+
else if ((conv.sameErrorRounds ?? 0) >= 2 || (conv.stagnantRounds ?? 0) >= 2) {
|
|
924
|
+
// 触发防死循环与停滞熔断(Blocked)
|
|
925
|
+
reviewerVerdict = "blocked";
|
|
926
|
+
const blockedReason = (conv.sameErrorRounds ?? 0) >= 2
|
|
927
|
+
? `连续 ${conv.sameErrorRounds} 轮出现相同错误:${currentError}`
|
|
928
|
+
: `连续 ${conv.stagnantRounds} 轮未检测到有效文件修改或实质进展`;
|
|
929
|
+
const blockedReasonEn = (conv.sameErrorRounds ?? 0) >= 2
|
|
930
|
+
? `Identical error across ${conv.sameErrorRounds} consecutive rounds: ${currentError}`
|
|
931
|
+
: `No effective file modifications or progress across ${conv.stagnantRounds} consecutive rounds`;
|
|
932
|
+
reviewerFeedback = pick(this.lang(), `【目标防死循环保护:执行受阻(Blocked)】\n\n` +
|
|
933
|
+
`• 停滞原因:${blockedReason}\n` +
|
|
934
|
+
`• 当前轮次:第 ${g.round} 轮\n` +
|
|
935
|
+
`• 诊断分析:智能体在自主推进中连续轮次未产生有效进展或反复遭遇相同错误,已自动熔断以防止无谓消耗 token。\n` +
|
|
936
|
+
`• 建议措施:请检查相关代码、工具权限或手动调整提示词,排查阻碍后再继续。`, `[Goal Infinite-Loop Protection: Blocked]\n\n` +
|
|
937
|
+
`• Cause: ${blockedReasonEn}\n` +
|
|
938
|
+
`• Current round: Round ${g.round}\n` +
|
|
939
|
+
`• Diagnosis: Agent made no progress or encountered identical errors across consecutive rounds. Circuit breaker tripped to prevent token waste.\n` +
|
|
940
|
+
`• Recommendation: Please check code, tool permissions, or refine prompt before proceeding.`, "goal.review.blocked", { blockedReason, blockedReasonEn, round: g.round });
|
|
848
941
|
}
|
|
849
942
|
else {
|
|
850
|
-
// No JSON — assume fail with the raw output as feedback.
|
|
851
943
|
reviewerVerdict = "fail";
|
|
852
|
-
reviewerFeedback =
|
|
944
|
+
reviewerFeedback = pick(this.lang(), "目标尚未完成,自主推进下一轮迭代验证。", "Goal not yet completed; continuing to next iteration.", "goal.autonomous.continue");
|
|
853
945
|
}
|
|
854
|
-
await srv.session.dispose();
|
|
855
946
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
947
|
+
else {
|
|
948
|
+
try {
|
|
949
|
+
const rmSpec = this.resolveReviewModel(g.reviewModel);
|
|
950
|
+
const services = await createAgentSessionServices({
|
|
951
|
+
cwd: mainConv.cwd,
|
|
952
|
+
agentDir: this.host.agentDir,
|
|
953
|
+
// The reviewer has its own skill allow/deny list. It deliberately does
|
|
954
|
+
// not reuse the main session's disabledSkills setting.
|
|
955
|
+
resourceLoaderOptions: {
|
|
956
|
+
skillsOverride: (res) => ({
|
|
957
|
+
...res,
|
|
958
|
+
skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
|
|
959
|
+
}),
|
|
960
|
+
},
|
|
961
|
+
// A FRESH ModelRuntime for the reviewer — isolated from the shared
|
|
962
|
+
// one used by the main conversations, so its model choice is its own.
|
|
963
|
+
modelRuntime: await ModelRuntime.create({
|
|
964
|
+
authPath: join(this.host.agentDir, "auth.json"),
|
|
965
|
+
modelsPath: join(this.host.agentDir, "models.json"),
|
|
966
|
+
}),
|
|
967
|
+
});
|
|
968
|
+
// Model resolution: explicit reviewer model, else the main session's
|
|
969
|
+
// current model (so a goal works even when no reviewer model is given).
|
|
970
|
+
let model;
|
|
971
|
+
if (rmSpec) {
|
|
972
|
+
model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
|
|
973
|
+
}
|
|
974
|
+
if (!model) {
|
|
975
|
+
const mainModel = mainSession.model;
|
|
976
|
+
if (mainModel?.provider && mainModel.id) {
|
|
977
|
+
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const srv = await createAgentSessionFromServices({
|
|
981
|
+
services,
|
|
982
|
+
sessionManager: SessionManager.inMemory(mainConv.cwd),
|
|
983
|
+
...(model ? { model } : {}),
|
|
984
|
+
});
|
|
985
|
+
const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
|
|
986
|
+
const reviewer = srv.session;
|
|
987
|
+
await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
|
|
988
|
+
// Parse the reviewer's final output (expected to be a JSON object).
|
|
989
|
+
const raw = reviewer.getLastAssistantText() ?? "";
|
|
990
|
+
const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
|
|
991
|
+
if (m) {
|
|
992
|
+
reviewerVerdict = m[1];
|
|
993
|
+
const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
|
|
994
|
+
reviewerFeedback = fm?.[1] ?? "";
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
// No JSON — assume fail with the raw output as feedback.
|
|
998
|
+
reviewerVerdict = "fail";
|
|
999
|
+
reviewerFeedback = raw.slice(0, 2000);
|
|
1000
|
+
}
|
|
1001
|
+
await srv.session.dispose();
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
const reviewErrMsg = err.message;
|
|
1005
|
+
reviewerVerdict = "fail";
|
|
1006
|
+
reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
|
|
1007
|
+
}
|
|
860
1008
|
}
|
|
861
1009
|
// The user may have switched chats or replaced/cleared the goal while the
|
|
862
1010
|
// isolated reviewer was running. Never apply a stale verdict or inject it
|
|
@@ -897,6 +1045,30 @@ export class GoalService {
|
|
|
897
1045
|
this.host.flushSnapshot();
|
|
898
1046
|
return;
|
|
899
1047
|
}
|
|
1048
|
+
if (verdict === "blocked") {
|
|
1049
|
+
g.status = "⚠️ 目标受阻(停滞熔断)";
|
|
1050
|
+
g.statusEn = "⚠️ Goal blocked (stagnation circuit break)";
|
|
1051
|
+
this.host.emit({
|
|
1052
|
+
type: "notice",
|
|
1053
|
+
level: "warning",
|
|
1054
|
+
text: "⚠️ 目标执行受阻:检测到停滞或相同报错,已自动暂停",
|
|
1055
|
+
textEn: "⚠️ Goal execution blocked: stagnation or repeated error detected, auto-loop paused",
|
|
1056
|
+
});
|
|
1057
|
+
try {
|
|
1058
|
+
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 });
|
|
1059
|
+
await mainSession.sendUserMessage(blockedText, {
|
|
1060
|
+
deliverAs: mainSession.isStreaming ? "steer" : "followUp",
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
catch {
|
|
1064
|
+
// Best-effort.
|
|
1065
|
+
}
|
|
1066
|
+
g.conversationId = null;
|
|
1067
|
+
g.goal = null; // loop blocked — clear the active goal
|
|
1068
|
+
this.emitGoalStatus();
|
|
1069
|
+
this.host.flushSnapshot();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
900
1072
|
// Failure: if rounds remain, steer a revision; else report the loop done.
|
|
901
1073
|
// For unlimited (budget=0) isLastRound is always false → keeps revising.
|
|
902
1074
|
const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
|