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 8bcd4c9d..88ec7228 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -56,6 +56,8 @@ import vip.mate.channel.web.ChatStreamTracker; import vip.mate.wiki.service.WikiContextService; import java.lang.reflect.Field; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -358,21 +360,29 @@ public class AgentGraphBuilder { agent.topP = runtimeModel.getTopP(); agent.toolCallingEnabled = toolCallingEnabled; - // Agent 级别覆盖优先,否则继承工作区 - if (entity.getWorkspaceBasePath() != null && !entity.getWorkspaceBasePath().isBlank()) { - agent.workspaceBasePath = entity.getWorkspaceBasePath(); - log.info("Agent {} using agent-level basePath: {}", entity.getName(), agent.workspaceBasePath); - } else if (entity.getWorkspaceId() != null) { + // Agent-level override takes priority; a relative override is resolved + // under the workspace basePath so admins can express agent directories + // relative to the workspace root (matching the UI hint). + String workspaceBase = null; + if (entity.getWorkspaceId() != null) { try { var workspace = workspaceService.getById(entity.getWorkspaceId()); - if (workspace != null && workspace.getBasePath() != null && !workspace.getBasePath().isBlank()) { - agent.workspaceBasePath = workspace.getBasePath(); - log.info("Agent {} inherited workspace basePath: {}", entity.getName(), agent.workspaceBasePath); + if (workspace != null) { + workspaceBase = workspace.getBasePath(); } } catch (Exception e) { - log.warn("Failed to lookup workspace basePath for agent {}: {}", entity.getName(), e.getMessage()); + log.warn("Failed to lookup workspace basePath for agent {}: {}", + entity.getName(), e.getMessage()); } } + String resolvedBase = resolveAgentBasePath(entity.getWorkspaceBasePath(), workspaceBase); + if (resolvedBase != null && !resolvedBase.isBlank()) { + agent.workspaceBasePath = resolvedBase; + boolean fromOverride = entity.getWorkspaceBasePath() != null + && !entity.getWorkspaceBasePath().isBlank(); + log.info("Agent {} basePath = {} (source: {})", + entity.getName(), resolvedBase, fromOverride ? "agent-override" : "workspace"); + } log.info("Built agent instance: {} (type={}, protocol={}, tools={}, toolCallingEnabled={})", entity.getName(), entity.getAgentType(), protocol.getId(), @@ -1150,6 +1160,37 @@ public class AgentGraphBuilder { return reordered; } + /** + * Resolve the effective working directory for an agent. + *
Precedence: + *
The UI advertises agent-level paths as "relative to the workspace root", + * so a relative agent override must compose with the workspace basePath rather + * than fall through to the JVM working directory. + */ +class AgentGraphBuilderBasePathResolutionTest { + + @Test + @DisplayName("Both null/blank → null (no working directory configured)") + void noOverrideNoWorkspace_returnsNull() { + assertNull(AgentGraphBuilder.resolveAgentBasePath(null, null)); + assertNull(AgentGraphBuilder.resolveAgentBasePath("", "")); + assertNull(AgentGraphBuilder.resolveAgentBasePath(" ", null)); + } + + @Test + @DisplayName("No agent override → workspace basePath inherited verbatim") + void noOverride_inheritsWorkspace() { + assertEquals("/srv/ws-root", + AgentGraphBuilder.resolveAgentBasePath(null, "/srv/ws-root")); + assertEquals("/srv/ws-root", + AgentGraphBuilder.resolveAgentBasePath("", "/srv/ws-root")); + } + + @Test + @DisplayName("Agent override is absolute → used verbatim, workspace ignored") + @DisabledOnOs(OS.WINDOWS) + void absoluteOverride_usedAsIs_unix() { + assertEquals("/opt/agents/code-review", + AgentGraphBuilder.resolveAgentBasePath("/opt/agents/code-review", "/srv/ws-root")); + assertEquals("/opt/agents/code-review", + AgentGraphBuilder.resolveAgentBasePath("/opt/agents/code-review", null)); + } + + @Test + @DisplayName("Agent override is absolute (Windows) → used verbatim") + @EnabledOnOs(OS.WINDOWS) + void absoluteOverride_usedAsIs_windows() { + assertEquals("C:\\agents\\code-review", + AgentGraphBuilder.resolveAgentBasePath("C:\\agents\\code-review", "C:\\ws-root")); + } + + @Test + @DisplayName("Relative agent override + workspace basePath → resolved under workspace") + void relativeOverride_resolvedUnderWorkspace() { + String expected = Paths.get("/srv/ws-root").resolve("projects/code-review").toString(); + assertEquals(expected, + AgentGraphBuilder.resolveAgentBasePath("projects/code-review", "/srv/ws-root")); + } + + @Test + @DisplayName("Relative agent override with no workspace → used verbatim (legacy fallback)") + void relativeOverride_noWorkspace_usedAsIs() { + assertEquals("projects/code-review", + AgentGraphBuilder.resolveAgentBasePath("projects/code-review", null)); + assertEquals("projects/code-review", + AgentGraphBuilder.resolveAgentBasePath("projects/code-review", "")); + } + + @Test + @DisplayName("Blank workspace basePath treated like null when override is relative") + void relativeOverride_blankWorkspace_usedAsIs() { + assertEquals("agent-dir", + AgentGraphBuilder.resolveAgentBasePath("agent-dir", " ")); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 84fc3355..776f6ec7 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1164,7 +1164,7 @@ export default { maxIterations: 'Max Iterations', defaultThinkingLevel: 'Default Thinking Level', workspaceBasePath: 'Working Directory', - workspaceBasePathHint: 'Optional. Set a dedicated working directory for this employee (relative to workspace root). Leave blank to inherit the workspace default.', + workspaceBasePathHint: 'Optional. Set a dedicated working directory for this employee. Relative paths resolve under the workspace root; absolute paths are used as-is. Leave blank to inherit the workspace default.', modelName: 'Model', modelGlobalDefault: 'Use global default', modelHint: 'Override the global default model for this employee. Leave blank to follow Settings → Models.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 700a0ffd..1b2c633f 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1056,7 +1056,7 @@ export default { maxIterations: '最大迭代次数', defaultThinkingLevel: '默认思考深度', workspaceBasePath: '工作目录', - workspaceBasePathHint: '可选。为该员工指定独立的工作目录(相对于工作区根目录)。留空则继承工作区默认目录。', + workspaceBasePathHint: '可选。为该员工指定独立的工作目录:相对路径会基于工作区根目录解析,也可填写绝对路径。留空则继承工作区默认目录。', modelName: '模型', modelGlobalDefault: '使用全局默认模型', modelHint: '为该员工单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',