diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index 66949b6a..424ac63f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -406,7 +406,7 @@ public class ActionNode implements NodeAction { return names; } - static Set extractLoadedSkillNames(List toolCalls) { + public static Set extractLoadedSkillNames(List toolCalls) { if (toolCalls == null || toolCalls.isEmpty()) { return Set.of(); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 85271f59..6cbfba30 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import vip.mate.agent.AgentToolSet; import vip.mate.agent.GraphEventPublisher; 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.PlanStateKeys; import vip.mate.agent.graph.state.DirectToolOutput; @@ -35,6 +36,7 @@ import vip.mate.tool.builtin.DelegationContext; import vip.mate.tool.builtin.ToolExecutionContext; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -195,6 +197,7 @@ public class StepExecutionNode implements NodeAction { .orElse(vip.mate.agent.context.ChatOrigin.EMPTY); String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""); String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""); + Set loadedSkills = new LinkedHashSet<>(accessor.loadedSkills()); if (stepIndex >= steps.size()) { log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size()); @@ -202,6 +205,7 @@ public class StepExecutionNode implements NodeAction { .currentStepResult("步骤索引越界") .completedResults(formatStepResult(stepIndex, "步骤索引越界")) .currentStepIndex(stepIndex + 1) + .loadedSkills(Set.copyOf(loadedSkills)) .build(); } @@ -379,20 +383,36 @@ public class StepExecutionNode implements NodeAction { } } else { // 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier) - ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute( - allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin); - toolResponses.addAll(execResult.responses()); - events.addAll(execResult.events()); - if (execResult.hasDirectOutputs()) { - stepDirectOutputs.addAll(execResult.directOutputs()); + List executableToolCalls = new ArrayList<>(); + for (AssistantMessage.ToolCall toolCall : allToolCalls) { + String alreadyLoadedSkill = alreadyLoadedSkillName(toolCall, loadedSkills); + if (alreadyLoadedSkill != null) { + toolResponses.add(alreadyLoadedSkillResponse(toolCall, alreadyLoadedSkill)); + } else { + executableToolCalls.add(toolCall); + } } - if (execResult.awaitingApproval()) { - approvalTriggered = true; - approvalToolName = execResult.barrierToolName() != null - ? execResult.barrierToolName() : "unknown"; + if (!executableToolCalls.isEmpty()) { + ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute( + executableToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin); + 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 requestedSkills = ActionNode.extractLoadedSkillNames(allToolCalls); + if (!requestedSkills.isEmpty() && loadedSkills.addAll(requestedSkills)) { + log.debug("[StepExecution] pinned loaded skills in plan state: {}", requestedSkills); + } + // 将工具响应追加到消息 ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder() .responses(toolResponses) @@ -450,6 +470,7 @@ public class StepExecutionNode implements NodeAction { .currentPhase("awaiting_approval") .contentStreamed(true) .thinkingStreamed(!stepThinking.isEmpty()) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -486,6 +507,7 @@ public class StepExecutionNode implements NodeAction { .contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送 .put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true) .put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs)) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -536,6 +558,7 @@ public class StepExecutionNode implements NodeAction { .currentStepTitle("") .currentStepResult("") .contentStreamed(false) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -594,6 +617,7 @@ public class StepExecutionNode implements NodeAction { .currentStepTitle("") .currentStepResult("") .contentStreamed(false) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -610,6 +634,7 @@ public class StepExecutionNode implements NodeAction { // FINAL_SUMMARY is the single persistence/broadcast channel. .finalSummary(shortError) .contentStreamed(false) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -654,6 +679,7 @@ public class StepExecutionNode implements NodeAction { .currentPhase("step_completed") .contentStreamed(true) .thinkingStreamed(!stepThinking.isEmpty()) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -803,10 +829,9 @@ public class StepExecutionNode implements NodeAction { """; messages.add(new SystemMessage(enhancedSystemPrompt)); // Runtime skill catalog (rendered here instead of baked into the system - // prompt). The Plan path never pins per-run loads, so render with an - // empty loaded set — this reproduces the pre-disclosure DB ordering. + // prompt), ranked with skills already loaded during this graph run. if (skillCatalogRenderer != null) { - String skillCatalog = skillCatalogRenderer.render(java.util.Set.of()); + String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills()); if (skillCatalog != null && !skillCatalog.isBlank()) { messages.add(new SystemMessage(skillCatalog)); } @@ -866,6 +891,26 @@ public class StepExecutionNode implements NodeAction { return String.format("步骤%d结果:%s", stepIndex + 1, result); } + private static String alreadyLoadedSkillName(AssistantMessage.ToolCall toolCall, Set loadedSkills) { + if (toolCall == null || loadedSkills == null || loadedSkills.isEmpty()) { + return null; + } + Set 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 格式: {"name":"toolName","arguments":"...","status":"running"} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java index 7522377e..5615148b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java @@ -140,6 +140,11 @@ public final class PlanStateAccessor { return state.value(WORKING_CONTEXT, ""); } + @SuppressWarnings("unchecked") + public Set loadedSkills() { + return state.>value(MateClawStateKeys.LOADED_SKILLS).orElse(Set.of()); + } + // ===== 输出构建器 ===== public static OutputBuilder output() { @@ -251,6 +256,10 @@ public final class PlanStateAccessor { return put(MateClawStateKeys.PENDING_EVENTS, events); } + public OutputBuilder loadedSkills(Set names) { + return put(MateClawStateKeys.LOADED_SKILLS, names); + } + // ---- 阶段标记(写入共享键 MateClawStateKeys.CURRENT_PHASE)---- public OutputBuilder currentPhase(String phase) { return put(MateClawStateKeys.CURRENT_PHASE, phase); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java index 36c4aa72..73086630 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java @@ -40,7 +40,7 @@ public class DocxRenderTool { private final MarkdownDocxRenderer renderer; private final GeneratedFileCache cache; - @Tool(description = """ + @Tool(returnDirect = true, description = """ 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 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 * 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 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 * 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 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, diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionSkillCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionSkillCatalogTest.java new file mode 100644 index 00000000..f2836717 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionSkillCatalogTest.java @@ -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> 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 messages = (List) 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 loadedSkills) { + Map 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()); + values.put(PlanStateKeys.WORKING_CONTEXT, ""); + values.put(MateClawStateKeys.LOADED_SKILLS, loadedSkills); + return new PlanStateAccessor(new OverAllState(values)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocxRenderToolReturnDirectTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocxRenderToolReturnDirectTest.java new file mode 100644 index 00000000..31d0aecf --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocxRenderToolReturnDirectTest.java @@ -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"); + } +}