feat(agent): inject stale-ledger reminder when the model stops updating

This commit is contained in:
matevip 2026-05-24 23:00:35 +08:00
parent 8798668524
commit c36abf38b8
2 changed files with 184 additions and 0 deletions

View File

@ -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<Instant> 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.
*
* <p>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:
*
* <ul>
* <li><strong>Warm-up</strong>: {@code currentIteration < 10} never
* remind, the model is still setting up the task.</li>
* <li><strong>Empty ledger</strong>: {@code currentIteration 15} and
* no entries at all likely a multi-step task being executed
* without any ledger discipline.</li>
* <li><strong>Stale updates</strong>: ledger has entries, but the
* most recent {@code updatedAt} is &gt; 90 s ago ledger is no
* longer tracking the real work.</li>
* </ul>
*
* @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<Instant> 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

View File

@ -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<String, ProgressEntry> 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<String, ProgressEntry> 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<String, ProgressEntry> 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<String, ProgressEntry> 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));
}
}