mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): inject stale-ledger reminder when the model stops updating
This commit is contained in:
parent
8798668524
commit
c36abf38b8
@ -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 > 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
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user