feat(skill): scope agent runtime skill resolution to the conversation workspace

This commit is contained in:
matevip 2026-07-24 14:12:08 +08:00
parent f715e8ffc0
commit 728ed53062
15 changed files with 155 additions and 47 deletions

View File

@ -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<String> constraints = skill.getManifest().getConstraints();
if (constraints != null && !constraints.isEmpty()) {

View File

@ -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.
* <p>
* 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;
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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<String, String> 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");
}

View File

@ -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<ResolvedSkill> 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

View File

@ -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<Long> boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId);
if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) {

View File

@ -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");
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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")

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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")