mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): stop repeated plan skill loads (#606)
This commit is contained in:
parent
281ea53551
commit
d1a553ed77
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user