From c36abf38b8a4236b427e181aeb245c61e2b68835 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 24 May 2026 23:00:35 +0800 Subject: [PATCH] feat(agent): inject stale-ledger reminder when the model stops updating --- .../mate/agent/progress/ProgressLedger.java | 88 +++++++++++++++++ .../ProgressLedgerStaleReminderTest.java | 96 +++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerStaleReminderTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java index b7d155c5..d5cbf882 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java @@ -1,10 +1,12 @@ 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 @@ -42,6 +44,92 @@ public final class ProgressLedger { return entries; } + /** + * @return the most recent {@code updatedAt} across all entries, or empty + * when the ledger is empty / all entries lack a timestamp. + */ + public Optional mostRecentUpdate() { + Instant max = null; + for (ProgressEntry e : entries.values()) { + 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 = 10; + + /** Iteration past which an empty ledger triggers a "you should register steps" reminder. */ + private static final int EMPTY_LEDGER_NUDGE_ITERATIONS = 15; + + /** Wall-clock gap that flips a non-empty ledger from "fresh" to "stale". */ + private static final long STALE_GAP_SECONDS = 90; + + /** + * Build a stale-reminder string for injection into the model's context + * when the ledger appears to be falling behind the actual reasoning + * progress. Returns {@code null} when the ledger is being maintained + * normally so the caller can skip the injection. + * + *

Trigger heuristics — derived from round-4 of the LLM-review smoke + * test, where the model stopped calling {@code progress_update} after + * the first 30s and silently fell out of the ledger discipline: + * + *

+ * + * @param currentIteration the agent's current ReAct iteration count + * @param now the reference instant for staleness ("now"); + * injected for testability + */ + public String renderStaleReminder(int currentIteration, Instant now) { + if (currentIteration < STALE_WARMUP_ITERATIONS) { + return null; + } + if (entries.isEmpty()) { + 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; + } + int done = (int) entries.values().stream() + .filter(e -> e.getStatus() == ProgressStatus.DONE).count(); + int inProgress = (int) entries.values().stream() + .filter(e -> e.getStatus() == ProgressStatus.IN_PROGRESS).count(); + return "## ⚠️ 进度账本已 " + gap + " 秒未更新\n\n" + + "你已运行 " + currentIteration + " 轮,但 progress_update 已经 " + + gap + " 秒(约 " + (gap / 60) + " 分钟)没被调用过。\n" + + "当前账本:" + done + " done / " + inProgress + " in_progress / " + + (entries.size() - done - inProgress) + " pending。\n\n" + + "**立即做以下一件事**(不要再 read_file 或 browser_use,先更新账本):\n" + + "- 把已经完成的子步骤切到 `done`(如果你能看到工作区文件已生成)\n" + + "- 把正在做的步骤切到 `in_progress`\n" + + "- 有阻塞切到 `blocked` + 写明原因\n" + + "不维护账本会导致重复工作 / 漏做项目 / 撞迭代上限。"; + } + /** * @return a compact, model-readable progress snapshot, or {@code null} * when the ledger is empty so the caller can skip injection diff --git a/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerStaleReminderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerStaleReminderTest.java new file mode 100644 index 00000000..b997979a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerStaleReminderTest.java @@ -0,0 +1,96 @@ +package vip.mate.agent.progress; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins {@link ProgressLedger#renderStaleReminder} — the heuristic that + * decides whether to inject a "the model is forgetting its ledger" warning + * into the next reasoning step. Triggers were calibrated against the + * round-4 failure mode where the model called progress_update 3 times in + * the first 30s and then never again across the remaining 27 minutes. + */ +class ProgressLedgerStaleReminderTest { + + private static final Instant NOW = Instant.parse("2026-05-24T19:30:00Z"); + + @Test + @DisplayName("Iteration < 10 → no reminder regardless of ledger state.") + void warmupPeriodNoReminder() { + assertNull(ProgressLedger.empty().renderStaleReminder(0, NOW)); + assertNull(ProgressLedger.empty().renderStaleReminder(5, NOW)); + assertNull(ProgressLedger.empty().renderStaleReminder(9, NOW)); + } + + @Test + @DisplayName("Empty ledger between iter 10 and 14 → still no reminder.") + void emptyLedgerBelowNudgeThreshold() { + assertNull(ProgressLedger.empty().renderStaleReminder(10, NOW)); + assertNull(ProgressLedger.empty().renderStaleReminder(14, NOW)); + } + + @Test + @DisplayName("Empty ledger at iter ≥ 15 → emit empty-ledger reminder.") + void emptyLedgerTriggersReminder() { + String out = ProgressLedger.empty().renderStaleReminder(15, NOW); + assertNotNull(out); + assertTrue(out.contains("进度账本是空的"), out); + assertTrue(out.contains("15 轮"), out); + assertTrue(out.contains("progress_update"), out); + } + + @Test + @DisplayName("Non-empty ledger with fresh update → no reminder.") + void freshUpdateNoReminder() { + Map entries = new LinkedHashMap<>(); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.IN_PROGRESS, null, + NOW.minusSeconds(30))); // 30s ago — well within threshold + assertNull(new ProgressLedger(entries).renderStaleReminder(20, NOW)); + } + + @Test + @DisplayName("Non-empty ledger with last update ≥ 90s ago → emit stale reminder.") + void staleUpdateTriggersReminder() { + Map entries = new LinkedHashMap<>(); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.DONE, null, + NOW.minusSeconds(180))); // 3 min ago + entries.put("b", new ProgressEntry("b", "B", ProgressStatus.PENDING, null, + NOW.minusSeconds(200))); + String out = new ProgressLedger(entries).renderStaleReminder(40, NOW); + assertNotNull(out); + assertTrue(out.contains("180 秒"), "expected gap in reminder: " + out); + assertTrue(out.contains("1 done"), "expected done count: " + out); + assertTrue(out.contains("1 pending"), "expected pending count: " + out); + assertTrue(out.contains("progress_update"), out); + } + + @Test + @DisplayName("Iteration < warm-up overrides stale-gap trigger.") + void warmupBeatsStaleGap() { + Map entries = new LinkedHashMap<>(); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.DONE, null, + NOW.minusSeconds(600))); + assertNull(new ProgressLedger(entries).renderStaleReminder(5, NOW)); + } + + @Test + @DisplayName("mostRecentUpdate returns the latest updatedAt across entries.") + void mostRecentUpdate() { + Map entries = new LinkedHashMap<>(); + Instant t1 = NOW.minusSeconds(300); + Instant t2 = NOW.minusSeconds(100); + Instant t3 = NOW.minusSeconds(200); + entries.put("a", new ProgressEntry("a", "A", ProgressStatus.DONE, null, t1)); + entries.put("b", new ProgressEntry("b", "B", ProgressStatus.DONE, null, t2)); + entries.put("c", new ProgressEntry("c", "C", ProgressStatus.DONE, null, t3)); + assertTrue(new ProgressLedger(entries).mostRecentUpdate().orElseThrow().equals(t2)); + } +}