mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): per-conversation progress ledger to survive context trims
This commit is contained in:
parent
fe9610dfa6
commit
05289e6bdb
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user