From 9e9a96f674f89df69fcabbe5ba80ad41e42e5045 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 25 May 2026 15:58:05 +0800 Subject: [PATCH] fix(agent): resolve relative workspaceBasePath under workspace root --- .../vip/mate/agent/AgentGraphBuilder.java | 59 +++++++++++-- .../vip/mate/agent/model/AgentEntity.java | 6 +- .../h2/V125__agent_workspace_base_path.sql | 4 +- ...entGraphBuilderBasePathResolutionTest.java | 82 +++++++++++++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 2 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 +- 6 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.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 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: + *

    + *
  1. When the agent-level override is set, it wins.
  2. + *
  3. An absolute override is used verbatim.
  4. + *
  5. A relative override is resolved under the workspace basePath + * when the workspace has one, matching the UI hint that agent paths + * are relative to the workspace root.
  6. + *
  7. A relative override with no workspace basePath is used as-is + * (resolves against the JVM working directory at file-tool time).
  8. + *
  9. With no override, the workspace basePath is inherited verbatim; + * returns {@code null} when neither side has a value.
  10. + *
+ */ + static String resolveAgentBasePath(String agentOverride, String workspaceBase) { + boolean hasOverride = agentOverride != null && !agentOverride.isBlank(); + boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank(); + if (!hasOverride) { + return hasWorkspace ? workspaceBase : null; + } + Path overridePath = Paths.get(agentOverride); + if (overridePath.isAbsolute()) { + return agentOverride; + } + if (hasWorkspace) { + return Paths.get(workspaceBase).resolve(agentOverride).toString(); + } + return agentOverride; + } + /** * Finds the first enabled chat model whose provider is fully configured. * Used as a fallback when the default model's provider is not available. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java index 254a71da..df95ab63 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java @@ -78,7 +78,11 @@ public class AgentEntity { /** 默认思考深度:off / low / medium / high / max,null 表示跟随模型默认 */ private String defaultThinkingLevel; - /** Agent 级别的工作目录覆盖,为 null 时继承工作区 basePath */ + /** + * Agent-level working directory override. When non-blank, takes priority + * over the workspace's basePath; relative values are resolved under the + * workspace basePath. Null/blank means inherit the workspace value. + */ @TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS) private String workspaceBasePath; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V125__agent_workspace_base_path.sql b/mateclaw-server/src/main/resources/db/migration/h2/V125__agent_workspace_base_path.sql index 9199b958..ff29de3d 100644 --- a/mateclaw-server/src/main/resources/db/migration/h2/V125__agent_workspace_base_path.sql +++ b/mateclaw-server/src/main/resources/db/migration/h2/V125__agent_workspace_base_path.sql @@ -1,3 +1,3 @@ --- V100: Add workspace_base_path column to mate_agent for Agent-level directory override. --- When set, this overrides the workspace-level basePath for this Agent only. +-- V125: Add workspace_base_path column to mate_agent for agent-level directory override. +-- When set, this overrides the workspace-level basePath for this agent only. ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS workspace_base_path VARCHAR(512) DEFAULT NULL; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java new file mode 100644 index 00000000..01321c23 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java @@ -0,0 +1,82 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies the agent-vs-workspace basePath precedence rules used by + * {@link AgentGraphBuilder#resolveAgentBasePath(String, String)}. + * + *

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: '为该员工单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',