chore: add extensions/ directory (real location for goal-driven-loop extension)
Skill lives at .agents/skills/goal-driven-loop (symlinked from .pi/skills/) Extension lives at extensions/goal-driven-loop (symlinked from .pi/extensions/)
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* goal-driven-loop extension for Pi.
|
||||
*
|
||||
* Wires together trigger detection, state injection, and timed
|
||||
* auto-defaults to eliminate unnecessary user interaction.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { detectTrigger, isCoarseGrained } from "./trigger-detector.js";
|
||||
import {
|
||||
readGoalState,
|
||||
buildInjectionText,
|
||||
buildContinuationPrompt,
|
||||
type GoalState,
|
||||
} from "./state-injector.js";
|
||||
import {
|
||||
promptStaleState,
|
||||
notifyCoarseGrainedDelegation,
|
||||
notifyMidLoopUpgrade,
|
||||
notifyStagnation,
|
||||
shouldNotifyStagnation,
|
||||
type UIContext,
|
||||
} from "./ui-prompts.js";
|
||||
|
||||
const CONTEXT_TENSION_THRESHOLD = 0.8;
|
||||
const STAGNATION_KEYWORDS = [
|
||||
"found",
|
||||
"fixed",
|
||||
"implemented",
|
||||
"added",
|
||||
"refactored",
|
||||
"verified",
|
||||
];
|
||||
const ABANDON_KEYWORDS = ["abandon", "cancel", "不要了", "放弃", "停"];
|
||||
|
||||
function buildUIContext(ctx: ExtensionContext): UIContext {
|
||||
return {
|
||||
hasUI: ctx.hasUI,
|
||||
select: ctx.ui.select.bind(ctx.ui),
|
||||
notify: ctx.ui.notify.bind(ctx.ui),
|
||||
confirm: ctx.ui.confirm.bind(ctx.ui),
|
||||
};
|
||||
}
|
||||
|
||||
function handleAbandon(ctx: ExtensionContext): void {
|
||||
const goalPath = join(ctx.cwd, ".agents", "goal", "GOAL.md");
|
||||
try {
|
||||
if (existsSync(goalPath)) {
|
||||
unlinkSync(goalPath);
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify("目标已放弃", "info");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the progress log shows stagnation (dual signal).
|
||||
* Stagnation = criteria passing count unchanged for 3 iterations
|
||||
* AND no progress keywords in the last 3 iterations.
|
||||
*/
|
||||
function detectStagnation(state: GoalState): boolean {
|
||||
// Signal 1: We can't compare passing counts across iterations
|
||||
// from the file alone without history. Instead, check if the
|
||||
// last 3 iteration headers exist but have no progress keywords.
|
||||
// For a more robust check, the skill should track passing count
|
||||
// history in GOAL.md. For now, we rely on the skill's own
|
||||
// stagnation detection and the extension's stagnation counter.
|
||||
return false; // Placeholder — skill-side detection is authoritative
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let consecutiveStagnations = 0;
|
||||
|
||||
// ---- Trigger detection on user input ----
|
||||
pi.on("input", async (event, ctx) => {
|
||||
if (event.source === "extension") {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
// Check for explicit abandon first
|
||||
const lowerText = event.text.toLowerCase();
|
||||
const isAbandon = ABANDON_KEYWORDS.some((kw) =>
|
||||
lowerText.includes(kw.toLowerCase())
|
||||
);
|
||||
if (isAbandon) {
|
||||
const state = readGoalState(ctx.cwd);
|
||||
if (state) {
|
||||
handleAbandon(ctx);
|
||||
return { action: "continue" };
|
||||
}
|
||||
}
|
||||
|
||||
// Try to detect a goal-setting trigger
|
||||
const match = detectTrigger(event.text);
|
||||
if (!match) {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
// Trigger detected — check stale state
|
||||
const existingState = readGoalState(ctx.cwd);
|
||||
if (existingState && existingState.status === "in-progress") {
|
||||
const ui = buildUIContext(ctx);
|
||||
const choice = await promptStaleState(ui);
|
||||
if (choice === "abandon") {
|
||||
handleAbandon(ctx);
|
||||
return { action: "continue" };
|
||||
}
|
||||
if (choice === "replace") {
|
||||
// Let the skill handle replacement on the next turn
|
||||
return { action: "continue" };
|
||||
}
|
||||
// "continue" or undefined → let the skill resume
|
||||
}
|
||||
|
||||
// Granularity check
|
||||
if (isCoarseGrained(match.goalText)) {
|
||||
const ui = buildUIContext(ctx);
|
||||
notifyCoarseGrainedDelegation(ui, match.goalText);
|
||||
// Check if comet is available (has openspec directory)
|
||||
const hasOpenspec = existsSync(join(ctx.cwd, "openspec"));
|
||||
if (hasOpenspec) {
|
||||
pi.sendUserMessage(`/skill:comet-open ${match.goalText}`, {
|
||||
deliverAs: "followUp",
|
||||
});
|
||||
} else {
|
||||
// Fallback: inject multi-stage mode instruction
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "goal-driven-loop-coarse-fallback",
|
||||
content: `Coarse-grained goal detected but comet unavailable. Running in multi-stage mode. Split the goal into phases and verify each one:\n\n${match.goalText}`,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: true, deliverAs: "followUp" }
|
||||
);
|
||||
}
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
// Fine-grained — let the skill handle it
|
||||
return { action: "continue" };
|
||||
});
|
||||
|
||||
// ---- State injection on every agent start ----
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
const state = readGoalState(ctx.cwd);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Context tension check
|
||||
const usage = ctx.getContextUsage();
|
||||
if (
|
||||
usage &&
|
||||
usage.contextWindow > 0 &&
|
||||
usage.tokens / usage.contextWindow > CONTEXT_TENSION_THRESHOLD
|
||||
) {
|
||||
const continuationMsg = buildContinuationPrompt(state);
|
||||
return {
|
||||
systemPrompt:
|
||||
event.systemPrompt +
|
||||
"\n\n" +
|
||||
continuationMsg +
|
||||
"\n\nContext is tight. Finalize .agents/goal/GOAL.md updates before completing this turn.",
|
||||
};
|
||||
}
|
||||
|
||||
// Stagnation auto-recovery injection
|
||||
if (state.status === "blocked") {
|
||||
consecutiveStagnations++;
|
||||
if (shouldNotifyStagnation(consecutiveStagnations)) {
|
||||
const ui = buildUIContext(ctx);
|
||||
notifyStagnation(
|
||||
ui,
|
||||
state.goal,
|
||||
state.passedCriteria,
|
||||
state.totalCriteria,
|
||||
state.iteration
|
||||
);
|
||||
} else {
|
||||
// Auto-inject strategy-change prompt
|
||||
return {
|
||||
systemPrompt:
|
||||
event.systemPrompt +
|
||||
"\n\n" +
|
||||
buildInjectionText(state) +
|
||||
"\n\n[goal-driven-loop STAGNATION RECOVERY] Previous approach not making progress. Try: a different tool/library, refactor before retry, or split the failing criterion into sub-steps.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Normal goal state injection
|
||||
if (state.status !== "blocked") {
|
||||
consecutiveStagnations = 0;
|
||||
}
|
||||
|
||||
const injection = buildInjectionText(state);
|
||||
return {
|
||||
systemPrompt: event.systemPrompt + "\n\n" + injection,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "goal-driven-loop",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"pi": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* State injection for goal-driven-loop.
|
||||
*
|
||||
* Reads .agents/goal/GOAL.md and injects goal state into
|
||||
* the agent's context via before_agent_start.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface GoalState {
|
||||
goal: string;
|
||||
status: "in-progress" | "blocked" | "complete";
|
||||
iteration: number;
|
||||
totalCriteria: number;
|
||||
passedCriteria: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
blocker?: string;
|
||||
progressLog: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GOAL.md frontmatter and body to extract goal state.
|
||||
*/
|
||||
export function readGoalState(cwd: string): GoalState | null {
|
||||
const goalPath = join(cwd, ".agents", "goal", "GOAL.md");
|
||||
if (!existsSync(goalPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(goalPath, "utf-8");
|
||||
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const goal = extractField(frontmatter, "goal") || "";
|
||||
const status =
|
||||
(extractField(frontmatter, "status") as GoalState["status"]) ||
|
||||
"in-progress";
|
||||
const iteration = parseInt(
|
||||
extractField(frontmatter, "iteration") || "0",
|
||||
10
|
||||
);
|
||||
const created = extractField(frontmatter, "created") || "";
|
||||
const updated = extractField(frontmatter, "updated") || "";
|
||||
|
||||
const criteriaSection = extractSection(content, "Acceptance Criteria");
|
||||
const totalCriteria = countCheckboxes(criteriaSection);
|
||||
const passedCriteria = countCheckedBoxes(criteriaSection);
|
||||
|
||||
const blockerSection = extractSection(content, "Blocked");
|
||||
const blockerRaw = blockerSection.trim() || undefined;
|
||||
const blocker = blockerRaw
|
||||
? blockerRaw.replace(/^[-*]\s*/, "").trim() || undefined
|
||||
: undefined;
|
||||
|
||||
const progressSection = extractSection(content, "Progress Log");
|
||||
const progressLog = extractIterations(progressSection);
|
||||
|
||||
return {
|
||||
goal,
|
||||
status,
|
||||
iteration,
|
||||
totalCriteria,
|
||||
passedCriteria,
|
||||
created,
|
||||
updated,
|
||||
blocker,
|
||||
progressLog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the injection text for the system prompt or message.
|
||||
*/
|
||||
export function buildInjectionText(state: GoalState): string {
|
||||
if (state.status === "complete") {
|
||||
return `[goal-driven-loop] GOAL COMPLETE
|
||||
${state.goal}
|
||||
Final: ${state.passedCriteria}/${state.totalCriteria} criteria passed across ${state.iteration} iterations.
|
||||
GOAL.md is being cleaned up.`;
|
||||
}
|
||||
|
||||
if (state.status === "blocked" && state.blocker) {
|
||||
return `[goal-driven-loop] GOAL BLOCKED
|
||||
${state.goal}
|
||||
Blocker: ${state.blocker}
|
||||
Progress: ${state.passedCriteria}/${state.totalCriteria} criteria passed, iteration ${state.iteration}.
|
||||
Continue by trying a different strategy. If still blocked after 2 attempts, surface to the user.`;
|
||||
}
|
||||
|
||||
return `[goal-driven-loop] ACTIVE GOAL
|
||||
${state.goal}
|
||||
Progress: ${state.passedCriteria}/${state.totalCriteria} criteria passed, iteration ${state.iteration}
|
||||
Next step: read .agents/goal/GOAL.md and continue the loop.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a continuation prompt for context tension situations.
|
||||
*/
|
||||
export function buildContinuationPrompt(state: GoalState): string {
|
||||
return `🔄 目标状态已保存至 .agents/goal/GOAL.md
|
||||
当前进度:${state.passedCriteria}/${state.totalCriteria} criteria 通过,第 ${state.iteration} 轮迭代。`;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
function extractField(frontmatter: string, field: string): string | null {
|
||||
const match = frontmatter.match(new RegExp(`^${field}:\\s*(.*)$`, "m"));
|
||||
if (!match) return null;
|
||||
let value = match[1].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractSection(content: string, sectionName: string): string {
|
||||
const regex = new RegExp(`## ${sectionName}\\n([\\s\\S]*?)(?=\\n## |$)`);
|
||||
const match = content.match(regex);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function countCheckboxes(section: string): number {
|
||||
return (section.match(/^- \[[ x]\]/gm) || []).length;
|
||||
}
|
||||
|
||||
function countCheckedBoxes(section: string): number {
|
||||
return (section.match(/^- \[x\]/gim) || []).length;
|
||||
}
|
||||
|
||||
function extractIterations(section: string): string[] {
|
||||
return section.match(/### Iteration \d+/g) || [];
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Trigger detection for goal-driven-loop.
|
||||
*
|
||||
* Detects goal-setting phrases in user input and validates them
|
||||
* against anti-trigger rules.
|
||||
*/
|
||||
|
||||
export interface TriggerMatch {
|
||||
goalText: string;
|
||||
rawCriteria?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger phrases — order matters (longer patterns first to avoid
|
||||
* "当前目标是" matching "是" alone).
|
||||
*/
|
||||
const TRIGGER_PATTERNS: RegExp[] = [
|
||||
// Chinese
|
||||
/设定当前目标为\s*(.+)/,
|
||||
/把当前目标设为\s*(.+)/,
|
||||
/当前目标是\s*(.+)/,
|
||||
/我的目标是\s*(.+)/,
|
||||
// English
|
||||
/set\s+(?:the\s+)?current\s+goal\s+to\s+(.+)/i,
|
||||
/current\s+goal\s+is\s+(.+)/i,
|
||||
/my\s+goal\s+is\s+(.+)/i,
|
||||
/the\s+goal\s+is\s+(.+)/i,
|
||||
// Uncertainty fallback: commitment language + deliverable
|
||||
/(?:goal|目标)\s*(?:is|为|是)\s+(?:(?:to|去)\s+)?(.+)/i,
|
||||
];
|
||||
|
||||
const TRIVIAL_KEYWORDS = [
|
||||
"hotfix",
|
||||
"tweak",
|
||||
"fix a bug",
|
||||
"fix bug",
|
||||
"small change",
|
||||
"quick fix",
|
||||
"修个bug",
|
||||
"小改",
|
||||
];
|
||||
|
||||
const ARCHITECTURAL_KEYWORDS = [
|
||||
"architecture",
|
||||
"architectural",
|
||||
"module",
|
||||
"system",
|
||||
"refactor",
|
||||
"integration",
|
||||
"重构",
|
||||
"模块",
|
||||
"系统",
|
||||
"集成",
|
||||
];
|
||||
|
||||
const REPORTED_SPEECH_MARKERS = [
|
||||
"he said",
|
||||
"she said",
|
||||
"they said",
|
||||
"he says",
|
||||
"she says",
|
||||
"they say",
|
||||
"他说",
|
||||
"她说",
|
||||
"他们说",
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect if the input is a goal-setting trigger and extract the goal text.
|
||||
* Returns null if no trigger or if anti-trigger rules match.
|
||||
*/
|
||||
export function detectTrigger(input: string): TriggerMatch | null {
|
||||
// Anti-trigger: code block — remove code blocks from input before checking
|
||||
// for trigger phrases, so triggers outside code blocks still fire.
|
||||
const inputWithoutCode = input.replace(/```[\s\S]*?```/g, "");
|
||||
|
||||
// Anti-trigger: markdown quote block — only block if trigger phrase appears
|
||||
// exclusively inside quote lines (no trigger in non-quote content)
|
||||
const nonQuoteLines = inputWithoutCode
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*>/.test(line));
|
||||
const nonQuoteContent = nonQuoteLines.join("\n");
|
||||
const hasTriggerOutsideQuote = TRIGGER_PATTERNS.some((p) =>
|
||||
p.test(nonQuoteContent)
|
||||
);
|
||||
if (!hasTriggerOutsideQuote) {
|
||||
// All trigger phrases are inside quotes or absent — block
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the code-stripped, non-quote content for further checks
|
||||
const effectiveInput = nonQuoteContent;
|
||||
|
||||
// Anti-trigger: reported speech — check per-sentence
|
||||
const lowerInput = effectiveInput.toLowerCase();
|
||||
const hasReportedSpeech = REPORTED_SPEECH_MARKERS.some((marker) =>
|
||||
lowerInput.includes(marker)
|
||||
);
|
||||
if (hasReportedSpeech) {
|
||||
const sentences = effectiveInput.split(/[.!?。!?]/);
|
||||
for (const sentence of sentences) {
|
||||
const lowerSentence = sentence.toLowerCase();
|
||||
const hasMarker = REPORTED_SPEECH_MARKERS.some((m) =>
|
||||
lowerSentence.includes(m)
|
||||
);
|
||||
const hasTrigger = TRIGGER_PATTERNS.some((p) => p.test(sentence));
|
||||
if (hasMarker && hasTrigger) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anti-trigger: trivial task keywords
|
||||
const hasTrivialKeyword = TRIVIAL_KEYWORDS.some((kw) =>
|
||||
lowerInput.includes(kw.toLowerCase())
|
||||
);
|
||||
if (hasTrivialKeyword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to match trigger pattern
|
||||
for (const pattern of TRIGGER_PATTERNS) {
|
||||
const match = effectiveInput.match(pattern);
|
||||
if (match) {
|
||||
const fullText = match[1].trim();
|
||||
|
||||
// Validate: must be non-empty and not a short fragment
|
||||
if (fullText.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Anti-trigger: short description without architectural keywords
|
||||
if (fullText.length <= 10) {
|
||||
const hasArchitectural = ARCHITECTURAL_KEYWORDS.some((kw) =>
|
||||
fullText.toLowerCase().includes(kw.toLowerCase())
|
||||
);
|
||||
if (!hasArchitectural) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract acceptance criteria
|
||||
const criteriaMatch = fullText.match(
|
||||
/(?:验收条件[是为::]\s*|acceptance\s*(?:criteria)?\s*[::]\s*)(.+)$/i
|
||||
);
|
||||
|
||||
if (criteriaMatch) {
|
||||
const goalText = fullText.substring(0, criteriaMatch.index).trim();
|
||||
return {
|
||||
goalText: cleanGoalText(goalText),
|
||||
rawCriteria: criteriaMatch[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
goalText: cleanGoalText(fullText),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanGoalText(text: string): string {
|
||||
return text.replace(/[.,;:。;,]+$/, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a goal is coarse-grained (should delegate to comet).
|
||||
*/
|
||||
export function isCoarseGrained(goalText: string): boolean {
|
||||
const lower = goalText.toLowerCase();
|
||||
|
||||
const multiModuleSignals = [
|
||||
"across",
|
||||
"throughout",
|
||||
"整个",
|
||||
"全",
|
||||
"all modules",
|
||||
"multiple",
|
||||
"multiple files",
|
||||
"多模块",
|
||||
];
|
||||
for (const signal of multiModuleSignals) {
|
||||
if (lower.includes(signal)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const fileCountMatch = goalText.match(/(\d+)\s*(?:个)?\s*(?:file|files|文件)/i);
|
||||
if (fileCountMatch) {
|
||||
const count = parseInt(fileCountMatch[1], 10);
|
||||
if (count > 5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const architecturalSignals = [
|
||||
"redesign",
|
||||
"rewrite",
|
||||
"migrate",
|
||||
"重构",
|
||||
"迁移",
|
||||
"重写",
|
||||
"重新设计",
|
||||
];
|
||||
for (const signal of architecturalSignals) {
|
||||
if (lower.includes(signal.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* UI prompts for goal-driven-loop.
|
||||
*
|
||||
* Wraps Pi's UI methods with timed auto-defaults to avoid
|
||||
* blocking the user forever.
|
||||
*/
|
||||
|
||||
export interface UIContext {
|
||||
hasUI: boolean;
|
||||
select: (
|
||||
title: string,
|
||||
options: string[],
|
||||
opts?: { timeout?: number }
|
||||
) => Promise<string | undefined>;
|
||||
notify: (message: string, level?: "info" | "warn" | "error") => void;
|
||||
confirm: (
|
||||
title: string,
|
||||
message: string,
|
||||
opts?: { timeout?: number }
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const STAGNATION_NOTIFY_THRESHOLD = 2;
|
||||
|
||||
/**
|
||||
* Prompt the user to resolve a stale goal state.
|
||||
* Returns the choice or undefined on timeout.
|
||||
* Auto-default: "continue"
|
||||
*/
|
||||
export async function promptStaleState(
|
||||
ctx: UIContext
|
||||
): Promise<"continue" | "replace" | "abandon" | undefined> {
|
||||
if (!ctx.hasUI) {
|
||||
return "continue";
|
||||
}
|
||||
|
||||
const choice = await ctx.select(
|
||||
"已有目标在进行中",
|
||||
["继续当前目标", "替换为新目标", "放弃当前目标"],
|
||||
{ timeout: DEFAULT_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
switch (choice) {
|
||||
case "继续当前目标":
|
||||
return "continue";
|
||||
case "替换为新目标":
|
||||
return "replace";
|
||||
case "放弃当前目标":
|
||||
return "abandon";
|
||||
default:
|
||||
return "continue";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user that the goal is being delegated to comet.
|
||||
*/
|
||||
export function notifyCoarseGrainedDelegation(
|
||||
ctx: UIContext,
|
||||
goal: string
|
||||
): void {
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.notify(
|
||||
`目标粒度较大,已委托给 comet 全流程处理:${goal}`,
|
||||
"info"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user about mid-loop granularity upgrade.
|
||||
*/
|
||||
export function notifyMidLoopUpgrade(ctx: UIContext, goal: string): void {
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.notify(
|
||||
`执行中发现目标比预期复杂,已升级到 comet:${goal}`,
|
||||
"info"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the user about stagnation that auto-recovery couldn't fix.
|
||||
* Called after STAGNATION_NOTIFY_THRESHOLD auto-recovery attempts.
|
||||
*/
|
||||
export function notifyStagnation(
|
||||
ctx: UIContext,
|
||||
goal: string,
|
||||
passedCount: number,
|
||||
totalCount: number,
|
||||
iteration: number
|
||||
): void {
|
||||
if (!ctx.hasUI) return;
|
||||
ctx.notify(
|
||||
`目标停滞:${goal} (${passedCount}/${totalCount} 通过, 第 ${iteration} 轮)。自动策略调整未奏效,请人工介入。`,
|
||||
"warn"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should notify the user about stagnation yet.
|
||||
*/
|
||||
export function shouldNotifyStagnation(
|
||||
consecutiveStagnations: number
|
||||
): boolean {
|
||||
return consecutiveStagnations > STAGNATION_NOTIFY_THRESHOLD;
|
||||
}
|
||||
Reference in New Issue
Block a user