fix(agent): stop repeated plan skill loads (#606)

This commit is contained in:
matevip 2026-08-20 01:51:56 -04:00
parent 281ea53551
commit d1a553ed77
6 changed files with 160 additions and 17 deletions

View File

@ -406,7 +406,7 @@ public class ActionNode implements NodeAction {
return names; return names;
} }
static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) { public static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) { if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of(); return Set.of();
} }

View File

@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.NodeStreamingChatHelper; import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.node.ActionNode;
import vip.mate.agent.graph.plan.state.PlanStateAccessor; import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys; import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.DirectToolOutput; import vip.mate.agent.graph.state.DirectToolOutput;
@ -35,6 +36,7 @@ import vip.mate.tool.builtin.DelegationContext;
import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.tool.builtin.ToolExecutionContext;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -195,6 +197,7 @@ public class StepExecutionNode implements NodeAction {
.orElse(vip.mate.agent.context.ChatOrigin.EMPTY); .orElse(vip.mate.agent.context.ChatOrigin.EMPTY);
String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""); String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, "");
String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""); String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "");
Set<String> loadedSkills = new LinkedHashSet<>(accessor.loadedSkills());
if (stepIndex >= steps.size()) { if (stepIndex >= steps.size()) {
log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size()); log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size());
@ -202,6 +205,7 @@ public class StepExecutionNode implements NodeAction {
.currentStepResult("步骤索引越界") .currentStepResult("步骤索引越界")
.completedResults(formatStepResult(stepIndex, "步骤索引越界")) .completedResults(formatStepResult(stepIndex, "步骤索引越界"))
.currentStepIndex(stepIndex + 1) .currentStepIndex(stepIndex + 1)
.loadedSkills(Set.copyOf(loadedSkills))
.build(); .build();
} }
@ -379,20 +383,36 @@ public class StepExecutionNode implements NodeAction {
} }
} else { } else {
// 正常路径委托 ToolExecutionExecutor支持并发执行 + 审批 barrier // 正常路径委托 ToolExecutionExecutor支持并发执行 + 审批 barrier
ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute( List<AssistantMessage.ToolCall> executableToolCalls = new ArrayList<>();
allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin); for (AssistantMessage.ToolCall toolCall : allToolCalls) {
toolResponses.addAll(execResult.responses()); String alreadyLoadedSkill = alreadyLoadedSkillName(toolCall, loadedSkills);
events.addAll(execResult.events()); if (alreadyLoadedSkill != null) {
if (execResult.hasDirectOutputs()) { toolResponses.add(alreadyLoadedSkillResponse(toolCall, alreadyLoadedSkill));
stepDirectOutputs.addAll(execResult.directOutputs()); } else {
executableToolCalls.add(toolCall);
}
} }
if (execResult.awaitingApproval()) { if (!executableToolCalls.isEmpty()) {
approvalTriggered = true; ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute(
approvalToolName = execResult.barrierToolName() != null executableToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin);
? execResult.barrierToolName() : "unknown"; toolResponses.addAll(execResult.responses());
events.addAll(execResult.events());
if (execResult.hasDirectOutputs()) {
stepDirectOutputs.addAll(execResult.directOutputs());
}
if (execResult.awaitingApproval()) {
approvalTriggered = true;
approvalToolName = execResult.barrierToolName() != null
? execResult.barrierToolName() : "unknown";
}
} }
} }
Set<String> requestedSkills = ActionNode.extractLoadedSkillNames(allToolCalls);
if (!requestedSkills.isEmpty() && loadedSkills.addAll(requestedSkills)) {
log.debug("[StepExecution] pinned loaded skills in plan state: {}", requestedSkills);
}
// 将工具响应追加到消息 // 将工具响应追加到消息
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder() ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(toolResponses) .responses(toolResponses)
@ -450,6 +470,7 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("awaiting_approval") .currentPhase("awaiting_approval")
.contentStreamed(true) .contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty()) .thinkingStreamed(!stepThinking.isEmpty())
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens, .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
@ -486,6 +507,7 @@ public class StepExecutionNode implements NodeAction {
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送 .contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true) .put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs)) .put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens, .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
@ -536,6 +558,7 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("") .currentStepTitle("")
.currentStepResult("") .currentStepResult("")
.contentStreamed(false) .contentStreamed(false)
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens, .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
@ -594,6 +617,7 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("") .currentStepTitle("")
.currentStepResult("") .currentStepResult("")
.contentStreamed(false) .contentStreamed(false)
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens, .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
@ -610,6 +634,7 @@ public class StepExecutionNode implements NodeAction {
// FINAL_SUMMARY is the single persistence/broadcast channel. // FINAL_SUMMARY is the single persistence/broadcast channel.
.finalSummary(shortError) .finalSummary(shortError)
.contentStreamed(false) .contentStreamed(false)
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens, .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
@ -654,6 +679,7 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("step_completed") .currentPhase("step_completed")
.contentStreamed(true) .contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty()) .thinkingStreamed(!stepThinking.isEmpty())
.loadedSkills(Set.copyOf(loadedSkills))
.addStepUsage(state, stepPromptTokens, stepCompletionTokens, .addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events) .events(events)
@ -803,10 +829,9 @@ public class StepExecutionNode implements NodeAction {
"""; """;
messages.add(new SystemMessage(enhancedSystemPrompt)); messages.add(new SystemMessage(enhancedSystemPrompt));
// Runtime skill catalog (rendered here instead of baked into the system // Runtime skill catalog (rendered here instead of baked into the system
// prompt). The Plan path never pins per-run loads, so render with an // prompt), ranked with skills already loaded during this graph run.
// empty loaded set this reproduces the pre-disclosure DB ordering.
if (skillCatalogRenderer != null) { if (skillCatalogRenderer != null) {
String skillCatalog = skillCatalogRenderer.render(java.util.Set.of()); String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills());
if (skillCatalog != null && !skillCatalog.isBlank()) { if (skillCatalog != null && !skillCatalog.isBlank()) {
messages.add(new SystemMessage(skillCatalog)); messages.add(new SystemMessage(skillCatalog));
} }
@ -866,6 +891,26 @@ public class StepExecutionNode implements NodeAction {
return String.format("步骤%d结果%s", stepIndex + 1, result); return String.format("步骤%d结果%s", stepIndex + 1, result);
} }
private static String alreadyLoadedSkillName(AssistantMessage.ToolCall toolCall, Set<String> loadedSkills) {
if (toolCall == null || loadedSkills == null || loadedSkills.isEmpty()) {
return null;
}
Set<String> requested = ActionNode.extractLoadedSkillNames(List.of(toolCall));
if (requested.isEmpty()) {
return null;
}
String skillName = requested.iterator().next();
return loadedSkills.contains(skillName) ? skillName : null;
}
private static ToolResponseMessage.ToolResponse alreadyLoadedSkillResponse(
AssistantMessage.ToolCall toolCall, String skillName) {
String message = "Skill '" + skillName + "' was already loaded earlier in this run. "
+ "Reuse the SKILL.md content already present in the conversation; "
+ "do not call load_skill for this skill again.";
return new ToolResponseMessage.ToolResponse(toolCall.id(), toolCall.name(), message);
}
/** /**
* 判断当前工具调用是否与预批准 payload 中的工具名匹配 * 判断当前工具调用是否与预批准 payload 中的工具名匹配
* payload 格式: {"name":"toolName","arguments":"...","status":"running"} * payload 格式: {"name":"toolName","arguments":"...","status":"running"}

View File

@ -140,6 +140,11 @@ public final class PlanStateAccessor {
return state.value(WORKING_CONTEXT, ""); return state.value(WORKING_CONTEXT, "");
} }
@SuppressWarnings("unchecked")
public Set<String> loadedSkills() {
return state.<Set<String>>value(MateClawStateKeys.LOADED_SKILLS).orElse(Set.of());
}
// ===== 输出构建器 ===== // ===== 输出构建器 =====
public static OutputBuilder output() { public static OutputBuilder output() {
@ -251,6 +256,10 @@ public final class PlanStateAccessor {
return put(MateClawStateKeys.PENDING_EVENTS, events); return put(MateClawStateKeys.PENDING_EVENTS, events);
} }
public OutputBuilder loadedSkills(Set<String> names) {
return put(MateClawStateKeys.LOADED_SKILLS, names);
}
// ---- 阶段标记写入共享键 MateClawStateKeys.CURRENT_PHASE---- // ---- 阶段标记写入共享键 MateClawStateKeys.CURRENT_PHASE----
public OutputBuilder currentPhase(String phase) { public OutputBuilder currentPhase(String phase) {
return put(MateClawStateKeys.CURRENT_PHASE, phase); return put(MateClawStateKeys.CURRENT_PHASE, phase);

View File

@ -40,7 +40,7 @@ public class DocxRenderTool {
private final MarkdownDocxRenderer renderer; private final MarkdownDocxRenderer renderer;
private final GeneratedFileCache cache; private final GeneratedFileCache cache;
@Tool(description = """ @Tool(returnDirect = true, description = """
Render a new .docx (Microsoft Word) file from Markdown text and return a Render a new .docx (Microsoft Word) file from Markdown text and return a
one-time download URL. Use for creating EDITABLE Word documents the user one-time download URL. Use for creating EDITABLE Word documents the user
will continue to revise reports, memos, contracts, letters, resumes. will continue to revise reports, memos, contracts, letters, resumes.
@ -105,7 +105,7 @@ public class DocxRenderTool {
* the markdown locally calls this tool with the file path docx is * the markdown locally calls this tool with the file path docx is
* rendered from disk in one IO call. Token cost 50 (just the path). * rendered from disk in one IO call. Token cost 50 (just the path).
*/ */
@Tool(description = """ @Tool(returnDirect = true, description = """
Render a .docx (Microsoft Word) file from a markdown FILE on disk and return Render a .docx (Microsoft Word) file from a markdown FILE on disk and return
a one-time download URL. Use this for EDITABLE Word documents only. a one-time download URL. Use this for EDITABLE Word documents only.
@ -172,7 +172,7 @@ public class DocxRenderTool {
* Empty / missing files abort the render with a clear error so the agent * Empty / missing files abort the render with a clear error so the agent
* can fix its file list before retrying. * can fix its file list before retrying.
*/ */
@Tool(description = """ @Tool(returnDirect = true, description = """
Render a .docx by concatenating MULTIPLE markdown files in order and return a Render a .docx by concatenating MULTIPLE markdown files in order and return a
download URL. Use when a report is split into chapters / sections, or when the download URL. Use when a report is split into chapters / sections, or when the
agent assembled the document piece by piece (cover, table of contents, body, agent assembled the document piece by piece (cover, table of contents, body,

View File

@ -0,0 +1,64 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import vip.mate.agent.graph.plan.state.PlanStateAccessor;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StepExecutionSkillCatalogTest {
@Test
@SuppressWarnings("unchecked")
void stepMessagesRenderSkillCatalogWithSkillsLoadedThisRun() throws Exception {
AtomicReference<Set<String>> seenLoaded = new AtomicReference<>();
StepExecutionNode node = new StepExecutionNode(
null, null, null, null, null, null, null, null,
loaded -> {
seenLoaded.set(loaded);
return "## Skills\n- docx";
},
1_000L);
Method method = StepExecutionNode.class.getDeclaredMethod(
"buildStepMessages",
PlanStateAccessor.class, String.class, String.class,
String.class, String.class, String.class);
method.setAccessible(true);
List<Message> messages = (List<Message>) method.invoke(
node,
accessor(Set.of("docx")),
"生成 Word 文档",
"system",
"/tmp/workspace",
"qwen",
"dashscope");
assertEquals(Set.of("docx"), seenLoaded.get());
assertTrue(messages.stream().anyMatch(m -> m.getText().contains("## Skills")));
}
private static PlanStateAccessor accessor(Set<String> loadedSkills) {
Map<String, Object> values = new HashMap<>();
values.put(PlanStateKeys.GOAL, "生成文档");
values.put(PlanStateKeys.PLAN_STEPS, new ArrayList<>(List.of("生成 Word 文档")));
values.put(PlanStateKeys.CURRENT_STEP_INDEX, 0);
values.put(PlanStateKeys.COMPLETED_RESULTS, new ArrayList<String>());
values.put(PlanStateKeys.WORKING_CONTEXT, "");
values.put(MateClawStateKeys.LOADED_SKILLS, loadedSkills);
return new PlanStateAccessor(new OverAllState(values));
}
}

View File

@ -0,0 +1,25 @@
package vip.mate.tool.builtin;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.chat.model.ToolContext;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DocxRenderToolReturnDirectTest {
@Test
void docxRenderToolsReturnGeneratedFileDirectly() throws Exception {
assertReturnDirect("renderDocx", String.class, String.class, String.class, ToolContext.class);
assertReturnDirect("renderDocxFromFile", String.class, String.class, String.class, ToolContext.class);
assertReturnDirect("renderDocxFromFiles", java.util.List.class, String.class, String.class, ToolContext.class);
}
private static void assertReturnDirect(String methodName, Class<?>... parameterTypes) throws Exception {
Tool tool = DocxRenderTool.class
.getMethod(methodName, parameterTypes)
.getAnnotation(Tool.class);
assertTrue(tool.returnDirect(), methodName + " must stop the tool loop after producing a download link");
}
}