From 728ed53062f42ee72a27ad31f155f63f358ce86b Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 24 Jul 2026 14:12:08 +0800 Subject: [PATCH] feat(skill): scope agent runtime skill resolution to the conversation workspace --- .../vip/mate/agent/AgentGraphBuilder.java | 2 +- .../agent/context/AgentWorkspaceResolver.java | 38 +++++++++++++ .../graph/executor/ToolExecutionExecutor.java | 36 ++++++++++-- .../vip/mate/agent/graph/node/ActionNode.java | 3 +- .../mate/tool/builtin/CodeExecuteTool.java | 7 ++- .../vip/mate/tool/builtin/SkillFileTool.java | 8 ++- .../vip/mate/tool/builtin/SkillLoadTool.java | 9 ++- .../mate/tool/builtin/SkillScriptTool.java | 6 +- ...xecutionExecutorSkillAutoRedirectTest.java | 3 +- .../ToolExecutionExecutorSkillHintTest.java | 3 +- .../tool/builtin/CodeExecuteToolArgsTest.java | 2 +- .../builtin/CodeExecuteToolArtifactTest.java | 4 +- .../mate/tool/builtin/SkillFileToolTest.java | 57 +++++++++++++------ .../mate/tool/builtin/SkillLoadToolTest.java | 22 ++++--- .../tool/builtin/SkillScriptToolArgsTest.java | 2 +- 15 files changed, 155 insertions(+), 47 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 45a47bd8..a68a6ea9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1784,7 +1784,7 @@ public class AgentGraphBuilder { boolean anyHasConstraints = false; for (String skillName : loaded) { try { - vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName); + vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName, workspaceId); if (skill != null && skill.getManifest() != null) { List constraints = skill.getManifest().getConstraints(); if (constraints != null && !constraints.isEmpty()) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java b/mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java new file mode 100644 index 00000000..e66fcd9a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/AgentWorkspaceResolver.java @@ -0,0 +1,38 @@ +package vip.mate.agent.context; + +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.approval.grant.WorkspaceLookupCache; + +/** + * Resolves the workspace of the currently-executing conversation for the agent + * runtime skill-resolution path. + *

+ * The {@link ChatOrigin} carried in a tool's {@code ToolContext} usually already + * holds the workspaceId (populated at the web / channel entry point). Some paths + * — notably approval replay — carry a conversationId but a {@code null} + * workspaceId; there we fall back to {@link WorkspaceLookupCache}, which maps a + * conversationId to its owning workspace. When neither yields a workspace, the + * result is {@code null}: callers must treat that conservatively (resolve only + * builtin / global skills, never another workspace's skill). + */ +@Component +@RequiredArgsConstructor +public class AgentWorkspaceResolver { + + private final WorkspaceLookupCache workspaceLookupCache; + + /** Best-effort workspace id for the given origin; {@code null} if unresolved. */ + @Nullable + public Long resolve(@Nullable ChatOrigin origin) { + if (origin == null) { + return null; + } + if (origin.workspaceId() != null) { + return origin.workspaceId(); + } + String conversationId = origin.conversationId(); + return conversationId != null ? workspaceLookupCache.resolveByConversation(conversationId) : null; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 9aae7949..8f2554a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -594,7 +594,7 @@ public class ToolExecutionExecutor { toolCall.id(), toolName, redirect.response())); continue; } - String msg = skillAwareNotFoundMessage(toolName); + String msg = skillAwareNotFoundMessage(toolName, safeOrigin); log.warn("[ToolExecutor] {}", msg); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); allResponses.add(new ToolResponseMessage.ToolResponse( @@ -684,7 +684,7 @@ public class ToolExecutionExecutor { return new ToolResponseMessage.ToolResponse( toolCall.id(), toolName, redirect.response()); } - String msg = skillAwareNotFoundMessage(toolName); + String msg = skillAwareNotFoundMessage(toolName, replayOriginForRedirect); log.warn("[ToolExecutor] Pre-approved {}", msg); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg); @@ -1276,10 +1276,34 @@ public class ToolExecutionExecutor { return Map.copyOf(result); } - private String skillAwareNotFoundMessage(String toolName) { + /** + * Best-effort conversation workspace from a {@link ChatOrigin}, with a + * {@code WorkspaceLookupCache} fallback for paths (e.g. approval replay) + * that carry a conversationId but no workspaceId. A {@code null} result + * makes the skill lookup scope to builtin/global only — never another + * workspace's skill. + */ + private Long resolveWorkspaceId(ChatOrigin origin) { + if (origin == null) return null; + if (origin.workspaceId() != null) return origin.workspaceId(); + return workspaceIdForConversation(origin.conversationId()); + } + + /** + * Resolve a conversation's owning workspace via the lookup cache, or + * {@code null} when unavailable. Exposed so sibling graph nodes (e.g. + * {@code ActionNode}) that only hold a conversationId can scope skill + * resolution to the right workspace without their own cache dependency. + */ + public Long workspaceIdForConversation(String conversationId) { + return (workspaceLookupCache != null && conversationId != null) + ? workspaceLookupCache.resolveByConversation(conversationId) : null; + } + + private String skillAwareNotFoundMessage(String toolName, ChatOrigin origin) { if (skillRuntimeService != null && toolName != null && !toolName.isBlank()) { try { - boolean isSkill = skillRuntimeService.getActiveSkills().stream() + boolean isSkill = skillRuntimeService.getActiveSkills(resolveWorkspaceId(origin)).stream() .anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName)); if (isSkill) { return String.format( @@ -1398,7 +1422,9 @@ public class ToolExecutionExecutor { private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) { if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null; try { - boolean isSkill = skillRuntimeService.getActiveSkills().stream() + // Scope to the conversation's workspace so an agent is never redirected + // into (and handed the SKILL.md content of) another workspace's skill. + boolean isSkill = skillRuntimeService.getActiveSkills(resolveWorkspaceId(origin)).stream() .anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName)); if (!isSkill) return null; } catch (Exception e) { 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 c952328c..886aca3a 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 @@ -213,7 +213,8 @@ public class ActionNode implements NodeAction { } for (String skillName : skillNames) { try { - vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName); + vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill( + skillName, executor.workspaceIdForConversation(conversationId)); if (skill == null || skill.getManifest() == null) { continue; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java index 3aa174f4..1ecec6c8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillRuntimeService; @@ -57,6 +58,7 @@ import java.util.Set; public class CodeExecuteTool { private final SkillRuntimeService runtimeService; + private final AgentWorkspaceResolver workspaceResolver; private final SkillScriptExecutionService executionService; private final SkillSecretService skillSecretService; private final ObjectMapper objectMapper; @@ -119,8 +121,9 @@ public class CodeExecuteTool { Map envVars = Collections.emptyMap(); if (skillName != null && !skillName.isBlank()) { - // Skill-scoped run: validate binding + resolve the skill directory. - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + // Skill-scoped run: validate binding + resolve the skill directory, + // scoped to the conversation's workspace (+ builtin/global). + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return formatError("Skill '" + skillName + "' not found or not enabled"); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index 8b4e2a47..2f40ab6e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.TokenEstimator; import vip.mate.llm.routing.AgentBindingResolver; @@ -42,6 +43,7 @@ public class SkillFileTool { private final SkillRuntimeService runtimeService; private final SkillFileAccessPolicy accessPolicy; private final SkillUsageService usageService; + private final AgentWorkspaceResolver workspaceResolver; @Lazy @Autowired @@ -84,7 +86,7 @@ public class SkillFileTool { log.info("Reading skill file: skill={}, path={}", skillName, filePath); // 查找 active skill - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return "Error: Skill '" + skillName + "' not found or not enabled"; } @@ -245,7 +247,7 @@ public class SkillFileTool { ) { log.info("Listing skill files: skill={}", skillName); - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return "Error: Skill '" + skillName + "' not found or not enabled"; } @@ -341,7 +343,7 @@ public class SkillFileTool { // entries — no need to thread the (package-private) recommended // comparator back through here. List activeSkills = SkillCatalogSorter.sortResolved( - runtimeService.getActiveSkills().stream() + runtimeService.getActiveSkills(workspaceResolver.resolve(ChatOrigin.from(ctx))).stream() .filter(s -> SkillCatalogSorter.sourceMatches(s, source)) .filter(s -> SkillCatalogSorter.runtimeMatches(s, status)) .filter(s -> boundSkillIds == null diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java index 5409977c..1a6575d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillRuntimeService; @@ -37,6 +38,7 @@ public class SkillLoadTool { private final SkillRuntimeService runtimeService; private final SkillFileTool skillFileTool; + private final AgentWorkspaceResolver workspaceResolver; @Lazy @Autowired @@ -69,13 +71,16 @@ public class SkillLoadTool { if (skillName == null || skillName.isBlank()) { return "Error: skillName is required. Call listAvailableSkills() to see loadable skills."; } - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + ChatOrigin origin = ChatOrigin.from(ctx); + // Resolve only within the conversation's workspace (+ builtin/global), so + // an agent can never load another workspace's same-named skill. + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(origin)); if (skill == null) { log.info("load_skill: skill '{}' not found or not enabled", skillName); return "Error: Skill '" + skillName + "' not found or not enabled. " + "Call listAvailableSkills(keyword=\"" + skillName + "\") to find the correct name."; } - Long agentId = ChatOrigin.from(ctx).agentId(); + Long agentId = origin.agentId(); if (agentId != null) { Set boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId); if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java index ae3a5a49..7e54c1df 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillScriptTool.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillFileAccessPolicy; @@ -38,6 +39,7 @@ import java.util.Set; public class SkillScriptTool { private final SkillRuntimeService runtimeService; + private final AgentWorkspaceResolver workspaceResolver; private final SkillFileAccessPolicy accessPolicy; private final SkillScriptExecutionService executionService; private final SkillSecretService skillSecretService; @@ -84,8 +86,8 @@ public class SkillScriptTool { ) { log.info("Executing skill script: skill={}, script={}, args={}", skillName, scriptPath, args); - // Look up active skill. - ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + // Look up active skill within the conversation's workspace (+ builtin/global). + ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(ChatOrigin.from(ctx))); if (skill == null) { return formatError("Skill '" + skillName + "' not found or not enabled"); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java index 51d07bab..63d01219 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; /** @@ -52,7 +53,7 @@ class ToolExecutionExecutorSkillAutoRedirectTest { when(s.getName()).thenReturn(name); return s; }).toList(); - when(svc.getActiveSkills()).thenReturn(skills); + when(svc.getActiveSkills(any())).thenReturn(skills); return svc; } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java index 0facac70..93452262 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java @@ -13,6 +13,7 @@ import vip.mate.tool.guard.ToolGuardResult; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; /** @@ -36,7 +37,7 @@ class ToolExecutionExecutorSkillHintTest { when(s.getName()).thenReturn(name); return s; }).toList(); - when(svc.getActiveSkills()).thenReturn(skills); + when(svc.getActiveSkills(any())).thenReturn(skills); return svc; } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java index 38e62ff0..5c58a0c6 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java @@ -24,7 +24,7 @@ class CodeExecuteToolArgsTest { /** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */ private final CodeExecuteTool tool = - new CodeExecuteTool(null, null, null, objectMapper, null); + new CodeExecuteTool(null, null, null, null, objectMapper, null); @Test @DisplayName("null / blank / empty-array args yield no argument list") diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java index 7ea04621..876b4025 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArtifactTest.java @@ -25,7 +25,7 @@ class CodeExecuteToolArtifactTest { @Test @DisplayName("formatResult embeds links so extraction yields the clean filename, not '\"[name'") void formatResultExtractsCleanFilename() { - CodeExecuteTool tool = new CodeExecuteTool(null, null, null, null, null); + CodeExecuteTool tool = new CodeExecuteTool(null, null, null, null, null, null); var result = vip.mate.skill.runtime.SkillScriptExecutionService.ScriptResult.error(0, ""); String out = tool.formatResult(result, List.of( "[report.csv](http://localhost:18088/api/v1/files/generated/abc-123)", @@ -43,7 +43,7 @@ class CodeExecuteToolArtifactTest { @Test @DisplayName("No artifacts → no generatedFiles field") void noArtifactsNoField() { - CodeExecuteTool tool = new CodeExecuteTool(null, null, null, null, null); + CodeExecuteTool tool = new CodeExecuteTool(null, null, null, null, null, null); var result = vip.mate.skill.runtime.SkillScriptExecutionService.ScriptResult.error(0, "ok"); String out = tool.formatResult(result, List.of()); assertEquals(false, out.contains("generatedFiles"), out); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java index bfe21503..5dc01775 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.model.ToolContext; import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.agent.context.ChatOrigin; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillFileAccessPolicy; @@ -17,6 +18,8 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,8 +32,10 @@ class SkillFileToolTest { SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); - when(runtimeService.getActiveSkills()).thenReturn(List.of( + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); + when(runtimeService.getActiveSkills(any())).thenReturn(List.of( skill("apple-notes", "database", true), skill("ckjia-shopping", "mcp", false), skill("claude-code", "acp", false))); @@ -48,10 +53,12 @@ class SkillFileToolTest { SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ResolvedSkill skill = skill("browser-cdp", "database", true); skill.setContent("# Browser CDP\nUse devtools."); - when(runtimeService.findActiveSkill("browser-cdp")).thenReturn(skill); + when(runtimeService.findActiveSkill(eq("browser-cdp"), any())).thenReturn(skill); String content = tool.readSkillFile("browser-cdp", "SKILL.md", null, null, null); @@ -70,10 +77,12 @@ class SkillFileToolTest { SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ResolvedSkill skill = skill("large-skill", "database", true); skill.setContent("line\n".repeat(500)); - when(runtimeService.findActiveSkill("large-skill")).thenReturn(skill); + when(runtimeService.findActiveSkill(eq("large-skill"), any())).thenReturn(skill); String content = tool.readSkillFile("large-skill", "SKILL.md", 10, 20, null); @@ -94,12 +103,14 @@ class SkillFileToolTest { SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ResolvedSkill skill = skill("huge-line-skill", "database", true); // 12 KB single line — well past MAX_OUTPUT_CHARS (8KB). String hugeLine = "x".repeat(12_000); skill.setContent(hugeLine + "\nsecond line\nthird line\n"); - when(runtimeService.findActiveSkill("huge-line-skill")).thenReturn(skill); + when(runtimeService.findActiveSkill(eq("huge-line-skill"), any())).thenReturn(skill); String content = tool.readSkillFile("huge-line-skill", "SKILL.md", 1, 5, null); @@ -126,12 +137,14 @@ class SkillFileToolTest { SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ResolvedSkill skill = skill("large-skill", "database", true); // 500 lines * 5 chars = 2500 chars; 250 lines is also above DEFAULT_MAX_LINES (200). String body = "line\n".repeat(500); skill.setContent(body); - when(runtimeService.findActiveSkill("large-skill")).thenReturn(skill); + when(runtimeService.findActiveSkill(eq("large-skill"), any())).thenReturn(skill); String content = tool.readSkillFile("large-skill", "SKILL.md", null, null, null); @@ -148,12 +161,14 @@ class SkillFileToolTest { SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); ResolvedSkill bound = skill("alpha-skill", "database", true); ResolvedSkill unbound = skill("beta-skill", "database", true); - when(runtimeService.getActiveSkills()).thenReturn(List.of(bound, unbound)); + when(runtimeService.getActiveSkills(any())).thenReturn(List.of(bound, unbound)); // Agent 42 is bound only to alpha-skill; beta-skill must not surface. when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(bound.getId())); @@ -171,12 +186,14 @@ class SkillFileToolTest { SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); ResolvedSkill beta = skill("beta-skill", "database", true); beta.setContent("# Beta\nsecret body"); - when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + when(runtimeService.findActiveSkill(eq("beta-skill"), any())).thenReturn(beta); // Bound to some other skill id, never beta's. when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(999L)); @@ -194,11 +211,13 @@ class SkillFileToolTest { SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); ResolvedSkill beta = skill("beta-skill", "database", true); - when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + when(runtimeService.findActiveSkill(eq("beta-skill"), any())).thenReturn(beta); when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(999L)); ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); @@ -214,12 +233,14 @@ class SkillFileToolTest { SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); SkillUsageService usageService = mock(SkillUsageService.class); AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); - SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService, workspaceResolver); ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); ResolvedSkill beta = skill("beta-skill", "database", true); beta.setContent("# Beta\nvisible body"); - when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + when(runtimeService.findActiveSkill(eq("beta-skill"), any())).thenReturn(beta); // null == no explicit binding restriction → inherit every enabled skill. when(bindingResolver.getBoundSkillIds(42L)).thenReturn(null); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java index e4f03ae1..ebb7c456 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillLoadToolTest.java @@ -2,6 +2,7 @@ package vip.mate.tool.builtin; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import vip.mate.agent.context.AgentWorkspaceResolver; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; @@ -26,7 +27,8 @@ class SkillLoadToolTest { void blankSkillNameRejected() { SkillRuntimeService runtime = mock(SkillRuntimeService.class); SkillFileTool fileTool = mock(SkillFileTool.class); - SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool, workspaceResolver); String out = tool.loadSkill(" ", null, null); @@ -40,8 +42,10 @@ class SkillLoadToolTest { void unknownSkillReturnsError() { SkillRuntimeService runtime = mock(SkillRuntimeService.class); SkillFileTool fileTool = mock(SkillFileTool.class); - when(runtime.findActiveSkill("nope")).thenReturn(null); - SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + when(runtime.findActiveSkill(eq("nope"), any())).thenReturn(null); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool, workspaceResolver); String out = tool.loadSkill("nope", null, null); @@ -55,10 +59,12 @@ class SkillLoadToolTest { void loadsSkillMdByDefault() { SkillRuntimeService runtime = mock(SkillRuntimeService.class); SkillFileTool fileTool = mock(SkillFileTool.class); - when(runtime.findActiveSkill("foo")).thenReturn(skill("foo")); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + when(runtime.findActiveSkill(eq("foo"), any())).thenReturn(skill("foo")); when(fileTool.readSkillFile(eq("foo"), eq("SKILL.md"), isNull(), isNull(), any())) .thenReturn("SKILL CONTENT"); - SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool, workspaceResolver); String out = tool.loadSkill("foo", null, null); @@ -71,10 +77,12 @@ class SkillLoadToolTest { void loadsExplicitSubFile() { SkillRuntimeService runtime = mock(SkillRuntimeService.class); SkillFileTool fileTool = mock(SkillFileTool.class); - when(runtime.findActiveSkill("foo")).thenReturn(skill("foo")); + AgentWorkspaceResolver workspaceResolver = mock(AgentWorkspaceResolver.class); + when(workspaceResolver.resolve(any())).thenReturn(1L); + when(runtime.findActiveSkill(eq("foo"), any())).thenReturn(skill("foo")); when(fileTool.readSkillFile(eq("foo"), eq("references/api.md"), isNull(), isNull(), any())) .thenReturn("REF CONTENT"); - SkillLoadTool tool = new SkillLoadTool(runtime, fileTool); + SkillLoadTool tool = new SkillLoadTool(runtime, fileTool, workspaceResolver); String out = tool.loadSkill("foo", "references/api.md", null); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillScriptToolArgsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillScriptToolArgsTest.java index 642d38d3..75384297 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillScriptToolArgsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillScriptToolArgsTest.java @@ -26,7 +26,7 @@ class SkillScriptToolArgsTest { /** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */ private final SkillScriptTool tool = - new SkillScriptTool(null, null, null, null, objectMapper); + new SkillScriptTool(null, null, null, null, null, objectMapper); @Test @DisplayName("null / blank args yield no argument list")