From 05289e6bdb2fe417ed317d923cf3f0e6b0027374 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 24 May 2026 23:00:03 +0800 Subject: [PATCH] feat(agent): per-conversation progress ledger to survive context trims --- .../vip/mate/agent/AgentGraphBuilder.java | 3 +- .../binding/service/AgentBindingService.java | 6 + .../mate/agent/graph/node/ReasoningNode.java | 70 +++++++++- .../mate/agent/progress/ProgressEntry.java | 35 +++++ .../mate/agent/progress/ProgressLedger.java | 99 ++++++++++++++ .../agent/progress/ProgressLedgerService.java | 115 ++++++++++++++++ .../mate/agent/progress/ProgressStatus.java | 52 ++++++++ .../mate/tool/builtin/ProgressLedgerTool.java | 80 ++++++++++++ .../model/ConversationEntity.java | 11 ++ .../h2/V123__conversation_progress_ledger.sql | 20 +++ .../V123__conversation_progress_ledger.sql | 19 +++ .../progress/ProgressLedgerSnapshotTest.java | 103 +++++++++++++++ .../agent/progress/ProgressStatusTest.java | 49 +++++++ .../tool/builtin/ProgressLedgerToolTest.java | 123 ++++++++++++++++++ 14 files changed, 780 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressEntry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressStatus.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V123__conversation_progress_ledger.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V123__conversation_progress_ledger.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerSnapshotTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressStatusTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressLedgerToolTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 5079e21f..168c25bd 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 3e9bdb1a..da7fe756 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -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 diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 1ac3c305..b18a9c4a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -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 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 diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressEntry.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressEntry.java new file mode 100644 index 00000000..d261d776 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressEntry.java @@ -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}. + * + *

{@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. + * + *

{@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; +} 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 new file mode 100644 index 00000000..b7d155c5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedger.java @@ -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. + * + *

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 entries; + + public ProgressLedger(Map 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 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 done = bucket(ProgressStatus.DONE); + List inProgress = bucket(ProgressStatus.IN_PROGRESS); + List pending = bucket(ProgressStatus.PENDING); + List 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 bucket(ProgressStatus status) { + List 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 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'); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java new file mode 100644 index 00000000..69426c97 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressLedgerService.java @@ -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). + * + *

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}. + * + *

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 — LinkedHashMap preserves insertion order in the rendered snapshot. */ + private static final TypeReference> 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() + .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 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 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 map) { + try { + String json = objectMapper.writeValueAsString(map); + conversationMapper.update(null, + new LambdaUpdateWrapper() + .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); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressStatus.java b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressStatus.java new file mode 100644 index 00000000..f1f6575e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/progress/ProgressStatus.java @@ -0,0 +1,52 @@ +package vip.mate.agent.progress; + +import java.util.Locale; + +/** + * Status of a single step in the conversation-scoped progress ledger. + * + *

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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java new file mode 100644 index 00000000..50a018a2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressLedgerTool.java @@ -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. + * + *

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(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java index 4b042fcb..2242e49e 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java @@ -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). + *

+ * 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; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V123__conversation_progress_ledger.sql b/mateclaw-server/src/main/resources/db/migration/h2/V123__conversation_progress_ledger.sql new file mode 100644 index 00000000..3fc09028 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V123__conversation_progress_ledger.sql @@ -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; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V123__conversation_progress_ledger.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V123__conversation_progress_ledger.sql new file mode 100644 index 00000000..99957ee3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V123__conversation_progress_ledger.sql @@ -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; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerSnapshotTest.java b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerSnapshotTest.java new file mode 100644 index 00000000..b53d766f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressLedgerSnapshotTest.java @@ -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 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 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 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 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 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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressStatusTest.java b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressStatusTest.java new file mode 100644 index 00000000..7d969f05 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/progress/ProgressStatusTest.java @@ -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")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressLedgerToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressLedgerToolTest.java new file mode 100644 index 00000000..98b7446a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressLedgerToolTest.java @@ -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 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()); + } +}