feat(agent): per-conversation progress ledger to survive context trims

This commit is contained in:
matevip 2026-05-24 23:00:03 +08:00
parent fe9610dfa6
commit 05289e6bdb
14 changed files with 780 additions and 5 deletions

View File

@ -80,6 +80,7 @@ public class AgentGraphBuilder {
private final SkillService skillService;
private final vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService;
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
/** Escape hatch: when false, the load_skill meta tool is not advertised. */
@org.springframework.beans.factory.annotation.Value(
@ -704,7 +705,7 @@ public class AgentGraphBuilder {
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
supportsReasoningEffort,
streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService,
skillCatalogRenderer, toolDisclosureService);
skillCatalogRenderer, toolDisclosureService, progressLedgerService);
ActionNode actionNode = new ActionNode(executor, streamTracker);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);

View File

@ -581,6 +581,12 @@ public class AgentBindingService implements AgentBindingResolver {
"addGoalCriterion",
"completeGoal",
"getGoalStatus",
// Conversation-scoped progress ledger same rationale as the
// goal primitives above. Long multi-step research / drafting
// tasks need it on every business agent, not just the planner,
// since context-window trims can otherwise let an agent forget
// what it has already produced and re-do work or stall.
"progress_update",
// Document / media generation agent-wide capabilities, never
// declared inside any skill manifest. Pre-Phase-2b these were
// universally visible; the new gate silently strips them whenever

View File

@ -128,7 +128,16 @@ public class ReasoningNode implements NodeAction {
+ "- 如果上一次工具调用因 args JSON 截断max_tokens 超限)失败,\n"
+ " 请重新调用同一工具但**缩小内容**,或拆成多次顺序调用,**不要改成纯文字回答**。\n"
+ "- 只在确实没有合适工具,或所有工具步骤都已完成、可以最终回答用户时,\n"
+ " 才输出无 tool_call 的纯文字回答。\n";
+ " 才输出无 tool_call 的纯文字回答。\n\n"
+ "## 进度跟踪(多步任务必读)\n\n"
+ "- 对于包含 ≥3 个独立子步骤的任务(逐项调研、分节起草、批量生成等),\n"
+ " 你**应当**使用 `progress_update` 工具维护进度账本:\n"
+ " 1. 任务起手时为每个子步骤注册一条 `pending` 条目;\n"
+ " 2. 开始执行某条前切到 `in_progress`\n"
+ " 3. 完成后立即切到 `done`;遇到阻塞切到 `blocked` 并写明原因。\n"
+ "- 系统会在你的每一次推理前注入一份**当前进度快照**(标题 \"当前任务进度\"\n"
+ " 请把它视为权威的\"已完成清单\"**不要重复执行已经 done 的步骤**。\n"
+ "- 单一问题(无须拆解的短任务)不需要使用本工具;用错不会报错,但会浪费一次调用。\n";
private final ChatModel chatModel;
private final List<ToolCallback> toolCallbacks;
@ -165,6 +174,16 @@ public class ReasoningNode implements NodeAction {
*/
private final vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer;
/**
* Loads the per-conversation progress ledger each reasoning step so a
* compact snapshot can be injected into {@code nonHistoryPrefix}
* surviving message-window trims so the agent never loses track of
* "what is already done" on long multi-step tasks. Null in legacy /
* test constructors; when null the snapshot block is suppressed and
* the prompt is identical to pre-feature behavior.
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
@ -230,9 +249,10 @@ public class ReasoningNode implements NodeAction {
}
/**
* Primary constructor with the {@link vip.mate.tool.disclosure.ToolDisclosureService}.
* When non-null, {@code buildChatOptions} advertises only core tools plus
* the extensions enabled this run; when null, the full tool set is advertised.
* Backward-compatible delegate for callers built before the
* {@link vip.mate.agent.progress.ProgressLedgerService} was wired in
* passes {@code null} so the progress snapshot block is suppressed.
* New call sites should use the 13-arg primary constructor below.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
@ -242,6 +262,26 @@ public class ReasoningNode implements NodeAction {
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer,
vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService) {
this(chatModel, toolSet, reasoningEffort, supportsReasoningEffort, streamingHelper,
conversationWindowManager, streamTracker, maxOutputTokens, wikiContextService,
skillCatalogRenderer, toolDisclosureService, null);
}
/**
* Primary constructor with the {@link vip.mate.agent.progress.ProgressLedgerService}.
* When non-null, a compact snapshot of the conversation's progress ledger
* is appended to {@code nonHistoryPrefix} each turn so the agent retains
* its "what is already done" view across message-window trims.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService,
vip.mate.skill.runtime.SkillCatalogRenderer skillCatalogRenderer,
vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService,
vip.mate.agent.progress.ProgressLedgerService progressLedgerService) {
this.chatModel = chatModel;
this.toolSet = toolSet;
this.toolCallbacks = toolSet.callbacks();
@ -254,6 +294,7 @@ public class ReasoningNode implements NodeAction {
this.maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS;
this.wikiContextService = wikiContextService;
this.skillCatalogRenderer = skillCatalogRenderer;
this.progressLedgerService = progressLedgerService;
}
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
@ -289,6 +330,7 @@ public class ReasoningNode implements NodeAction {
this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
this.wikiContextService = null;
this.skillCatalogRenderer = null;
this.progressLedgerService = null;
}
@Override
@ -451,6 +493,26 @@ public class ReasoningNode implements NodeAction {
}
}
// Inject the conversation's progress-ledger snapshot as a separate
// SystemMessage. Sits in nonHistoryPrefix (never trimmed) so the
// agent always sees its own "what's done / what's pending" record
// even after the message-window trim above drops the tool-call
// history that produced those done entries. Suppressed when the
// ledger column is empty so short single-turn questions stay
// prompt-cache-friendly.
if (progressLedgerService != null && conversationId != null && !conversationId.isBlank()) {
try {
String snapshot = progressLedgerService.load(conversationId).renderSnapshot();
if (snapshot != null) {
nonHistoryPrefix.add(new SystemMessage(snapshot));
}
} catch (Exception e) {
// Never let a ledger-side failure break the reasoning step.
log.warn("[ReasoningNode] Failed to load progress ledger for {}: {}",
conversationId, e.getMessage());
}
}
if (conversationWindowManager != null) {
// Pass conversationId + workspaceBasePath so oversized older
// tool results can be spilled to the workspace spill directory

View File

@ -0,0 +1,35 @@
package vip.mate.agent.progress;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* A single step inside a conversation's {@link ProgressLedger}.
*
* <p>{@code key} is the stable identifier the agent picks (e.g. {@code
* "model_gpt55"} for "research GPT-5.5" or {@code "step_pptx"} for "generate
* the slide deck"). The same key on subsequent updates overwrites the entry
* in place so the model can advance one step from {@code PENDING}
* {@code IN_PROGRESS} {@code DONE} without producing duplicates.
*
* <p>{@code note} is optional and capped at a few hundred characters when
* rendered into the snapshot; the field itself isn't length-limited because
* the underlying column is LONGTEXT and a model that wants to dump rich
* context shouldn't be silently truncated at the schema layer.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProgressEntry {
private String key;
private String label;
private ProgressStatus status;
private String note;
private Instant updatedAt;
}

View File

@ -0,0 +1,99 @@
package vip.mate.agent.progress;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 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.
*
* <p>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.
*/
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;
private final Map<String, ProgressEntry> entries;
public ProgressLedger(Map<String, ProgressEntry> entries) {
this.entries = entries != null ? entries : new LinkedHashMap<>();
}
public static ProgressLedger empty() {
return new ProgressLedger(new LinkedHashMap<>());
}
public boolean isEmpty() {
return entries.isEmpty();
}
public int size() {
return entries.size();
}
public Map<String, ProgressEntry> asMap() {
return entries;
}
/**
* @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 (entries.isEmpty()) {
return null;
}
List<ProgressEntry> done = bucket(ProgressStatus.DONE);
List<ProgressEntry> inProgress = bucket(ProgressStatus.IN_PROGRESS);
List<ProgressEntry> pending = bucket(ProgressStatus.PENDING);
List<ProgressEntry> blocked = bucket(ProgressStatus.BLOCKED);
StringBuilder sb = new StringBuilder(256);
sb.append("## 当前任务进度(执行参考,权威记录)\n\n");
appendBucket(sb, "✅ 已完成", done);
appendBucket(sb, "🔄 进行中", inProgress);
appendBucket(sb, "⏳ 待办", pending);
appendBucket(sb, "⛔ 受阻", blocked);
sb.append("\n请基于此进度继续推进已完成的步骤不要重复执行。")
.append("完成新步骤后调用 `progress_update` 工具更新本账本。");
return sb.toString();
}
private List<ProgressEntry> bucket(ProgressStatus status) {
List<ProgressEntry> out = new ArrayList<>();
for (ProgressEntry e : entries.values()) {
if (e.getStatus() == status) {
out.add(e);
}
}
return out;
}
private void appendBucket(StringBuilder sb, String header, Collection<ProgressEntry> 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');
}
}

View File

@ -0,0 +1,115 @@
package vip.mate.agent.progress;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Loader / writer for the per-conversation progress ledger persisted as a
* JSON blob on {@code mate_conversation.progress_ledger} (see V100 migration).
*
* <p>The service is the only component that touches the JSON column directly.
* Callers above it work with {@link ProgressLedger} (immutable view) or plain
* {@code Map<String, ProgressEntry>}.
*
* <p>Failure mode: a malformed JSON value never throws back at the caller
* the runtime would rather render no snapshot than crash the reasoning loop
* over a corrupted ledger column. Parse failures are logged at warn level so
* the operator notices on a long-running deployment.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ProgressLedgerService {
/** Map<stepKey, ProgressEntry> — LinkedHashMap preserves insertion order in the rendered snapshot. */
private static final TypeReference<LinkedHashMap<String, ProgressEntry>> LEDGER_TYPE =
new TypeReference<>() {};
private final ConversationMapper conversationMapper;
private final ObjectMapper objectMapper;
/**
* @return the conversation's ledger, never null an empty map when the
* column is NULL or unparseable.
*/
public ProgressLedger load(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return ProgressLedger.empty();
}
ConversationEntity row = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)
.select(ConversationEntity::getProgressLedger));
if (row == null) {
return ProgressLedger.empty();
}
return parse(row.getProgressLedger());
}
/**
* Upsert one entry on the ledger atomically (load mutate save).
*
* @return the updated ledger so callers can render a fresh snapshot
* without a second DB roundtrip.
*/
public ProgressLedger upsert(String conversationId, String key, String label,
ProgressStatus status, String note) {
if (conversationId == null || conversationId.isBlank()) {
throw new IllegalArgumentException("conversationId is required");
}
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("step key is required");
}
if (status == null) {
throw new IllegalArgumentException("status is required");
}
ProgressLedger ledger = load(conversationId);
Map<String, ProgressEntry> map = ledger.asMap();
ProgressEntry existing = map.get(key);
String effectiveLabel = (label != null && !label.isBlank())
? label
: (existing != null ? existing.getLabel() : key);
map.put(key, new ProgressEntry(key, effectiveLabel, status, note, Instant.now()));
persist(conversationId, map);
return new ProgressLedger(map);
}
private ProgressLedger parse(String json) {
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
return ProgressLedger.empty();
}
try {
LinkedHashMap<String, ProgressEntry> map = objectMapper.readValue(json, LEDGER_TYPE);
return new ProgressLedger(map);
} catch (Exception e) {
log.warn("Failed to parse progress ledger JSON, treating as empty: {}", e.getMessage());
return ProgressLedger.empty();
}
}
private void persist(String conversationId, Map<String, ProgressEntry> map) {
try {
String json = objectMapper.writeValueAsString(map);
conversationMapper.update(null,
new LambdaUpdateWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)
.set(ConversationEntity::getProgressLedger, json));
} catch (Exception e) {
// Surface to caller so the tool can return an error message to
// the LLM rather than silently dropping the update.
throw new IllegalStateException(
"Failed to persist progress ledger for " + conversationId + ": " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,52 @@
package vip.mate.agent.progress;
import java.util.Locale;
/**
* Status of a single step in the conversation-scoped progress ledger.
*
* <p>Kept deliberately small four states cover the workflow patterns we
* see in long multi-step agent tasks (research one item at a time, draft a
* document section by section, etc.) without inviting bikeshedding on
* intermediate states. The wire form is the lowercase enum name; the tool's
* {@code status} parameter accepts case-insensitive input.
*/
public enum ProgressStatus {
/** Step is known to be needed but not yet started. */
PENDING,
/** Currently being worked on. */
IN_PROGRESS,
/** Finished and verified by the agent. */
DONE,
/** Cannot continue — note must explain why so the user / next pass can intervene. */
BLOCKED;
public String wireValue() {
return name().toLowerCase(Locale.ROOT);
}
/**
* Parse a model-supplied status string. Tolerates case differences,
* hyphens, and spaces (the model often writes "in progress" or
* "in-progress" both map to {@link #IN_PROGRESS}).
*
* @return the matching status, or {@code null} when no match is found so
* the caller can surface a structured error back to the LLM.
*/
public static ProgressStatus parse(String raw) {
if (raw == null) {
return null;
}
String normalised = raw.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_');
for (ProgressStatus s : values()) {
if (s.name().equals(normalised)) {
return s;
}
}
return null;
}
}

View File

@ -0,0 +1,80 @@
package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.progress.ProgressLedger;
import vip.mate.agent.progress.ProgressLedgerService;
import vip.mate.agent.progress.ProgressStatus;
/**
* Tool exposed to the LLM for maintaining the conversation-scoped progress
* ledger. The runtime renders the ledger into the system prompt before every
* reasoning step, so the model can rely on this tool as the durable record
* of "what I have done and what remains" across context-window trims.
*
* <p>Why a single mutating tool rather than separate
* {@code progress_mark_done} / {@code progress_block} / etc. methods: the
* model already volunteers the desired status as a string. Splitting into
* per-status methods would multiply the tool schema for no gain and forces
* a re-classification when statuses evolve.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ProgressLedgerTool {
private final ProgressLedgerService service;
@Tool(description = "Record or update a single step in the current conversation's progress "
+ "ledger. Use this to track multi-step tasks (research workflows, document drafting "
+ "split by section, etc.) — the runtime injects a rendered snapshot of the ledger "
+ "into your context before every reasoning step so you never lose track of what is "
+ "already done after a context trim. Call once per step transition: "
+ "register pending entries up front when you decompose a task, mark in_progress "
+ "before starting each one, then done as soon as it lands. Re-using the same stepKey "
+ "overwrites the entry in place (no duplicates).")
public String progress_update(
@ToolParam(description = "Stable identifier for this step (e.g. 'model_gpt55', "
+ "'section_intro', 'step_pptx'). Reuse exactly to update an existing entry.")
String stepKey,
@ToolParam(description = "Human-readable label shown in the snapshot (e.g. "
+ "'GPT-5.5 调研'). Pass empty to keep the existing label when updating.",
required = false)
String label,
@ToolParam(description = "One of: pending, in_progress, done, blocked.")
String status,
@ToolParam(description = "Optional 1-line note (why it's blocked, what was produced, "
+ "next sub-step). Capped at ~120 chars when rendered into the snapshot.",
required = false)
String note,
@Nullable ToolContext ctx) {
String conversationId = ToolExecutionContext.conversationId(ctx);
if (conversationId == null || conversationId.isBlank()) {
// Happens only on test paths that bypass the executor wiring;
// give a structured error so the LLM doesn't loop on it.
return "Error: no conversation context bound to this call. progress_update is only "
+ "usable from inside an active agent run.";
}
if (stepKey == null || stepKey.isBlank()) {
return "Error: stepKey is required.";
}
ProgressStatus parsed = ProgressStatus.parse(status);
if (parsed == null) {
return "Error: status must be one of pending, in_progress, done, blocked. Got: " + status;
}
try {
ProgressLedger updated = service.upsert(conversationId, stepKey, label, parsed, note);
return "Recorded " + stepKey + "" + parsed.wireValue()
+ ". Ledger now has " + updated.size() + " entries.";
} catch (Exception e) {
log.warn("progress_update failed for conv={} key={}: {}", conversationId, stepKey, e.getMessage());
return "Error: failed to persist progress entry — " + e.getMessage();
}
}
}

View File

@ -61,6 +61,17 @@ public class ConversationEntity {
/** Model id this conversation is pinned to. See {@link #modelProvider}. */
private String modelName;
/**
* Per-conversation progress notebook JSON (see V100 migration).
* <p>
* Map of {@code stepKey -> {label, status, note, updatedAt}}, written by
* the {@code progress_update} tool and rendered into the system prompt
* before each LLM call to survive message-window trimming. NULL means
* "no ledger yet" the runtime suppresses the snapshot.
*/
@TableField(value = "progress_ledger", updateStrategy = FieldStrategy.ALWAYS)
private String progressLedger;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -0,0 +1,20 @@
-- V100: per-conversation progress ledger
--
-- Adds a single JSON column on mate_conversation that holds the agent's
-- structured progress notebook for the conversation: a map of stepKey to
-- {label, status, note, updatedAt}. The runtime renders a compact snapshot of
-- the ledger into the system prompt before every LLM call so the agent never
-- forgets what it has already done after a context-window trim — the symptom
-- that caused a long research task to either duplicate work or stall in
-- meta-reasoning under aggressive trimming.
--
-- Why a JSON column on mate_conversation rather than a per-step table:
-- * Ledgers are read together with the conversation row in the hot path;
-- a per-step table would require a join on every reasoning step.
-- * Cardinality is small — a typical multi-step task carries 5-15 entries.
-- * No external query needs to enumerate steps across conversations today.
--
-- NULL means "no ledger yet" — the rendered snapshot is suppressed and the
-- agent runs with the legacy system prompt unchanged.
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS progress_ledger CLOB;

View File

@ -0,0 +1,19 @@
-- V100: per-conversation progress ledger (see the H2 copy for full background).
--
-- MySQL idempotency: ALTER TABLE ADD COLUMN IF NOT EXISTS is not portable
-- across server versions, so guard with INFORMATION_SCHEMA + a prepared
-- statement so re-running the migration on an already-patched schema is a
-- no-op.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_conversation'
AND COLUMN_NAME = 'progress_ledger'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_conversation ADD COLUMN progress_ledger LONGTEXT NULL COMMENT ''Per-conversation progress ledger JSON (stepKey -> {label, status, note, updatedAt})''',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,103 @@
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.assertFalse;
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#renderSnapshot} the exact string the runtime
* splices into the system prompt before each LLM call. Order of the buckets
* and the per-entry shape are part of the contract; the agent is going to
* parse this text every turn.
*/
class ProgressLedgerSnapshotTest {
@Test
@DisplayName("Empty ledger renders null so the runtime can skip injection.")
void emptyRendersNull() {
assertNull(ProgressLedger.empty().renderSnapshot());
assertNull(new ProgressLedger(new LinkedHashMap<>()).renderSnapshot());
}
@Test
@DisplayName("Buckets ordered done → in-progress → pending → blocked, with stable status icons.")
void bucketOrdering() {
Map<String, ProgressEntry> entries = new LinkedHashMap<>();
entries.put("a", entry("a", "Step A", ProgressStatus.PENDING, null));
entries.put("b", entry("b", "Step B", ProgressStatus.DONE, null));
entries.put("c", entry("c", "Step C", ProgressStatus.IN_PROGRESS, null));
entries.put("d", entry("d", "Step D", ProgressStatus.BLOCKED, "missing dep"));
String out = new ProgressLedger(entries).renderSnapshot();
assertNotNull(out);
int done = out.indexOf("");
int inProg = out.indexOf("🔄");
int pending = out.indexOf("");
int blocked = out.indexOf("");
assertTrue(done >= 0 && inProg > done && pending > inProg && blocked > pending,
"Bucket order should be done → in-progress → pending → blocked: " + out);
}
@Test
@DisplayName("Empty buckets are suppressed — no \"0 entries\" placeholder noise.")
void emptyBucketsAreOmitted() {
Map<String, ProgressEntry> entries = new LinkedHashMap<>();
entries.put("only", entry("only", "Only step", ProgressStatus.DONE, null));
String out = new ProgressLedger(entries).renderSnapshot();
assertNotNull(out);
assertTrue(out.contains(""));
assertFalse(out.contains("🔄"));
assertFalse(out.contains(""));
assertFalse(out.contains(""));
}
@Test
@DisplayName("Each entry shows label + bracketed key + optional note suffix.")
void entryShape() {
Map<String, ProgressEntry> entries = new LinkedHashMap<>();
entries.put("step_pptx", entry("step_pptx", "Generate PPTX", ProgressStatus.IN_PROGRESS,
"currently on slide 4"));
String out = new ProgressLedger(entries).renderSnapshot();
assertNotNull(out);
assertTrue(out.contains("Generate PPTX"), out);
assertTrue(out.contains("[`step_pptx`]"), out);
assertTrue(out.contains("— currently on slide 4"), out);
}
@Test
@DisplayName("A very long note is truncated to the preview cap with an ellipsis.")
void longNoteTruncated() {
String huge = "x".repeat(500);
Map<String, ProgressEntry> entries = new LinkedHashMap<>();
entries.put("k", entry("k", "K", ProgressStatus.DONE, huge));
String out = new ProgressLedger(entries).renderSnapshot();
assertNotNull(out);
assertTrue(out.endsWith("\n请基于此进度继续推进已完成的步骤不要重复执行。完成新步骤后调用 `progress_update` 工具更新本账本。")
|| out.contains(""), "expected ellipsis when note exceeds preview cap");
// Snapshot must be far smaller than the raw 500-char note.
assertTrue(out.length() < 500, "snapshot length=" + out.length());
}
@Test
@DisplayName("Missing label falls back to the key so the bullet is never blank.")
void missingLabelFallsBackToKey() {
Map<String, ProgressEntry> entries = new LinkedHashMap<>();
entries.put("only_key", entry("only_key", null, ProgressStatus.PENDING, null));
String out = new ProgressLedger(entries).renderSnapshot();
assertNotNull(out);
assertTrue(out.contains("only_key"), out);
}
private static ProgressEntry entry(String key, String label, ProgressStatus status, String note) {
return new ProgressEntry(key, label, status, note, Instant.now());
}
}

View File

@ -0,0 +1,49 @@
package vip.mate.agent.progress;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Pins {@link ProgressStatus#parse} the only entry the LLM controls.
* The parser must tolerate the variants a model naturally produces (case,
* hyphens, spaces) so a status like "In Progress" doesn't kick the tool
* into a structured-error path purely over formatting.
*/
class ProgressStatusTest {
@Test
@DisplayName("Wire values round-trip through parse/wireValue.")
void wireValuesRoundtrip() {
for (ProgressStatus s : ProgressStatus.values()) {
assertEquals(s, ProgressStatus.parse(s.wireValue()));
}
}
@Test
@DisplayName("Mixed case input parses to the same enum.")
void caseInsensitive() {
assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse("In_Progress"));
assertEquals(ProgressStatus.DONE, ProgressStatus.parse("DONE"));
assertEquals(ProgressStatus.PENDING, ProgressStatus.parse("pending"));
}
@Test
@DisplayName("Hyphen or space variants — \"in-progress\" / \"in progress\" — map to IN_PROGRESS.")
void hyphensAndSpaces() {
assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse("in-progress"));
assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse("in progress"));
assertEquals(ProgressStatus.IN_PROGRESS, ProgressStatus.parse(" In Progress "));
}
@Test
@DisplayName("Unknown or null inputs return null so the tool can return a structured error.")
void unknownReturnsNull() {
assertNull(ProgressStatus.parse(null));
assertNull(ProgressStatus.parse(""));
assertNull(ProgressStatus.parse("ready"));
assertNull(ProgressStatus.parse("finished"));
}
}

View File

@ -0,0 +1,123 @@
package vip.mate.tool.builtin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.agent.progress.ProgressEntry;
import vip.mate.agent.progress.ProgressLedger;
import vip.mate.agent.progress.ProgressLedgerService;
import vip.mate.agent.progress.ProgressStatus;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Pins {@link ProgressLedgerTool#progress_update} the only mutation entry
* the LLM has into the conversation-scoped progress ledger. Bad inputs must
* surface as structured "Error:" strings rather than throwing, so the model
* can recover by correcting the call instead of breaking the agent run.
*/
class ProgressLedgerToolTest {
@AfterEach
void clearContext() {
ToolExecutionContext.clear();
}
@Test
@DisplayName("Happy path: valid args persist + return entry count.")
void happyPath() {
ToolExecutionContext.set("conv-1", "admin");
ProgressLedgerService service = mock(ProgressLedgerService.class);
Map<String, ProgressEntry> after = new LinkedHashMap<>();
after.put("step_a", new ProgressEntry("step_a", "Step A",
ProgressStatus.IN_PROGRESS, "note", Instant.now()));
when(service.upsert(eq("conv-1"), eq("step_a"), eq("Step A"),
eq(ProgressStatus.IN_PROGRESS), eq("starting now")))
.thenReturn(new ProgressLedger(after));
ProgressLedgerTool tool = new ProgressLedgerTool(service);
String out = tool.progress_update("step_a", "Step A", "in_progress", "starting now", null);
assertEquals("Recorded step_a → in_progress. Ledger now has 1 entries.", out);
verify(service, times(1)).upsert(any(), any(), any(), any(), any());
}
@Test
@DisplayName("No conversation context → structured error, no DB call.")
void missingContext() {
ProgressLedgerService service = mock(ProgressLedgerService.class);
ProgressLedgerTool tool = new ProgressLedgerTool(service);
String out = tool.progress_update("step_a", "Step A", "done", null, null);
assertTrue(out.startsWith("Error: no conversation context"), out);
verifyNoInteractions(service);
}
@Test
@DisplayName("Blank stepKey rejected without touching the service.")
void blankKey() {
ToolExecutionContext.set("conv-1", "admin");
ProgressLedgerService service = mock(ProgressLedgerService.class);
ProgressLedgerTool tool = new ProgressLedgerTool(service);
String out = tool.progress_update(" ", "L", "done", null, null);
assertTrue(out.startsWith("Error: stepKey is required"), out);
verifyNoInteractions(service);
}
@Test
@DisplayName("Unknown status string → structured error listing valid values.")
void unknownStatus() {
ToolExecutionContext.set("conv-1", "admin");
ProgressLedgerService service = mock(ProgressLedgerService.class);
ProgressLedgerTool tool = new ProgressLedgerTool(service);
String out = tool.progress_update("step_a", "Step A", "finished", null, null);
assertTrue(out.contains("pending"), out);
assertTrue(out.contains("in_progress"), out);
assertTrue(out.contains("done"), out);
assertTrue(out.contains("blocked"), out);
verifyNoInteractions(service);
}
@Test
@DisplayName("Service failure surfaces as a structured error instead of throwing.")
void serviceFailureNotPropagated() {
ToolExecutionContext.set("conv-1", "admin");
ProgressLedgerService service = mock(ProgressLedgerService.class);
when(service.upsert(any(), any(), any(), any(), any()))
.thenThrow(new IllegalStateException("disk full"));
ProgressLedgerTool tool = new ProgressLedgerTool(service);
String out = tool.progress_update("step_a", "Step A", "done", null, null);
assertTrue(out.startsWith("Error:"), out);
assertTrue(out.contains("disk full"), out);
}
@Test
@DisplayName("Empty 'note' is forwarded verbatim — service decides how to store null vs blank.")
void emptyNoteForwarded() {
ToolExecutionContext.set("conv-1", "admin");
ProgressLedgerService service = mock(ProgressLedgerService.class);
when(service.upsert(any(), any(), any(), any(), any()))
.thenReturn(ProgressLedger.empty());
ProgressLedgerTool tool = new ProgressLedgerTool(service);
tool.progress_update("step_a", "Step A", "done", null, null);
verify(service, times(1)).upsert(eq("conv-1"), eq("step_a"), eq("Step A"),
eq(ProgressStatus.DONE), isNull());
}
}