package vip.mate.agent.progress; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; /** * Read-only view over a conversation's progress entries with a renderer that * turns the map into a compact markdown snapshot for system-prompt injection. * *

The snapshot is grouped by status (done → in-progress → pending → * blocked) and stays short on purpose: the agent reads it on every turn, so * spending more than ~200 tokens on it would defeat the very context * pressure this ledger exists to relieve. * *

Three entry classes coexist: *

*/ public final class ProgressLedger { /** Hard cap on the snapshot's note suffix so a rambling note can't bloat every turn. */ private static final int NOTE_PREVIEW_CHARS = 120; /** * Key prefix for entries auto-recorded by ActionNode after successful * tool calls (B5). Lets the renderer group them into a separate * "auto-recorded" section and exclude them from staleness calculation. */ public static final String AUTO_RECORDED_PREFIX = "auto_"; private final Map entries; private final Map pinned; public ProgressLedger(Map entries) { this(entries, null); } public ProgressLedger(Map entries, Map pinned) { this.entries = entries != null ? entries : new LinkedHashMap<>(); this.pinned = pinned != null ? pinned : new LinkedHashMap<>(); } public static ProgressLedger empty() { return new ProgressLedger(new LinkedHashMap<>(), new LinkedHashMap<>()); } public boolean isEmpty() { return entries.isEmpty() && pinned.isEmpty(); } public int size() { return entries.size() + pinned.size(); } public Map asMap() { return entries; } /** @return the pinned (Java-controlled) entries, never null. */ public Map pinnedEntries() { return pinned; } /** * @return the most recent {@code updatedAt} across regular (non-auto, * non-pinned) entries, or empty when none have a timestamp. * Only regular entries count because pinned entries are static * constraints and auto-recorded entries are Java-side — neither * indicates the LLM is maintaining the ledger. */ public Optional mostRecentUpdate() { Instant max = null; for (ProgressEntry e : entries.values()) { if (e.getKey() != null && e.getKey().startsWith(AUTO_RECORDED_PREFIX)) { continue; } Instant t = e.getUpdatedAt(); if (t != null && (max == null || t.isAfter(max))) { max = t; } } return Optional.ofNullable(max); } /** * Iteration before which no stale reminder is ever issued — too early to judge. */ private static final int STALE_WARMUP_ITERATIONS = 3; private static final int EMPTY_LEDGER_NUDGE_ITERATIONS = 5; private static final long STALE_GAP_SECONDS = 45; public String renderStaleReminder(int currentIteration, Instant now) { if (currentIteration < STALE_WARMUP_ITERATIONS) { return null; } // Only regular (non-auto) entries indicate LLM engagement with the ledger. boolean hasRegularEntries = entries.values().stream() .anyMatch(e -> e.getKey() == null || !e.getKey().startsWith(AUTO_RECORDED_PREFIX)); if (!hasRegularEntries) { if (currentIteration < EMPTY_LEDGER_NUDGE_ITERATIONS) { return null; } return "## ⚠️ 进度账本是空的(已运行 " + currentIteration + " 轮)\n\n" + "你正在进行一个看起来需要拆解的多步任务,但还没有调用 `progress_update`。\n" + "**立即用一条并行 tool_calls 回复批量注册所有 pending 步骤**,否则上下文\n" + "窗口被裁剪后,你会忘记自己做过的工作并重复执行。"; } Optional lastUpdate = mostRecentUpdate(); if (lastUpdate.isEmpty()) { return null; } long gap = java.time.Duration.between(lastUpdate.get(), now).getSeconds(); if (gap < STALE_GAP_SECONDS) { return null; } long done = entries.values().stream() .filter(e -> isRegular(e) && e.getStatus() == ProgressStatus.DONE).count(); long inProgress = entries.values().stream() .filter(e -> isRegular(e) && e.getStatus() == ProgressStatus.IN_PROGRESS).count(); long pending = entries.values().stream() .filter(e -> isRegular(e) && e.getStatus() == ProgressStatus.PENDING).count(); return "## ⚠️ 进度账本已 " + gap + " 秒未更新\n\n" + "你已运行 " + currentIteration + " 轮,但 progress_update 已经 " + gap + " 秒(约 " + (gap / 60) + " 分钟)没被调用过。\n" + "当前账本:" + done + " done / " + inProgress + " in_progress / " + pending + " pending。\n\n" + "**立即做以下一件事**(不要再 read_file 或 browser_use,先更新账本):\n" + "- 把已经完成的子步骤切到 `done`(如果你能看到工作区文件已生成)\n" + "- 把正在做的步骤切到 `in_progress`\n" + "- 有阻塞切到 `blocked` + 写明原因\n" + "不维护账本会导致重复工作 / 漏做项目 / 撞迭代上限。"; } /** True when the entry is regular (not auto-recorded, not pinned). */ private boolean isRegular(ProgressEntry e) { return e.getKey() == null || !e.getKey().startsWith(AUTO_RECORDED_PREFIX); } /** * @return a compact, model-readable progress snapshot, or {@code null} * when the ledger is empty so the caller can skip injection * entirely (no "(empty)" placeholder noise). */ public String renderSnapshot() { if (isEmpty()) { return null; } StringBuilder sb = new StringBuilder(256); sb.append("## 当前任务进度(执行参考,权威记录)\n\n"); // Pinned constraints (highest priority — always visible) if (!pinned.isEmpty()) { sb.append("🔒 固定约束(来自 skill,全程不可忽略):\n"); for (ProgressEntry e : pinned.values()) { String label = e.getLabel() != null && !e.getLabel().isBlank() ? e.getLabel() : e.getKey(); sb.append("- ").append(label); String note = e.getNote(); if (note != null && !note.isBlank()) { String trimmed = note.length() > NOTE_PREVIEW_CHARS ? note.substring(0, NOTE_PREVIEW_CHARS) + "…" : note; sb.append(" — ").append(trimmed); } sb.append('\n'); } sb.append('\n'); } // Auto-recorded tool completions (B5) List autoRecorded = new ArrayList<>(); for (ProgressEntry e : entries.values()) { if (e.getKey() != null && e.getKey().startsWith(AUTO_RECORDED_PREFIX)) { autoRecorded.add(e); } } if (!autoRecorded.isEmpty()) { sb.append("🔧 自动记录(工具调用完成):\n"); for (ProgressEntry e : autoRecorded) { String label = e.getLabel() != null && !e.getLabel().isBlank() ? e.getLabel() : e.getKey(); sb.append("- ").append(label).append(" → ").append(e.getStatus()); sb.append('\n'); } sb.append('\n'); } // Regular entries grouped by status List done = bucketRegular(ProgressStatus.DONE); List inProgress = bucketRegular(ProgressStatus.IN_PROGRESS); List pending = bucketRegular(ProgressStatus.PENDING); List blocked = bucketRegular(ProgressStatus.BLOCKED); appendBucket(sb, "✅ 已完成", done); appendBucket(sb, "🔄 进行中", inProgress); appendBucket(sb, "⏳ 待办", pending); appendBucket(sb, "⛔ 受阻", blocked); sb.append("\n请基于此进度继续推进;已完成的步骤不要重复执行。") .append("完成新步骤后调用 `progress_update` 工具更新本账本。"); return sb.toString(); } private List bucketRegular(ProgressStatus status) { List out = new ArrayList<>(); for (ProgressEntry e : entries.values()) { if (isRegular(e) && e.getStatus() == status) { out.add(e); } } return out; } private void appendBucket(StringBuilder sb, String header, Collection items) { if (items.isEmpty()) { return; } sb.append(header).append(" (").append(items.size()).append("):\n"); for (ProgressEntry e : items) { String label = e.getLabel() != null && !e.getLabel().isBlank() ? e.getLabel() : e.getKey(); sb.append("- ").append(label).append(" [`").append(e.getKey()).append("`]"); String note = e.getNote(); if (note != null && !note.isBlank()) { String trimmed = note.length() > NOTE_PREVIEW_CHARS ? note.substring(0, NOTE_PREVIEW_CHARS) + "…" : note; sb.append(" — ").append(trimmed); } sb.append('\n'); } sb.append('\n'); } }