fix(agent): resolve relative workspaceBasePath under workspace root

This commit is contained in:
matevip 2026-05-25 15:58:05 +08:00
parent cbdd70379b
commit 9e9a96f674
6 changed files with 141 additions and 14 deletions

View File

@ -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.
* <p>Precedence:
* <ol>
* <li>When the agent-level override is set, it wins.</li>
* <li>An absolute override is used verbatim.</li>
* <li>A relative override is resolved <em>under</em> the workspace basePath
* when the workspace has one, matching the UI hint that agent paths
* are relative to the workspace root.</li>
* <li>A relative override with no workspace basePath is used as-is
* (resolves against the JVM working directory at file-tool time).</li>
* <li>With no override, the workspace basePath is inherited verbatim;
* returns {@code null} when neither side has a value.</li>
* </ol>
*/
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.

View File

@ -78,7 +78,11 @@ public class AgentEntity {
/** 默认思考深度off / low / medium / high / maxnull 表示跟随模型默认 */
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;

View File

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

View File

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

View File

@ -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.',

View File

@ -1056,7 +1056,7 @@ export default {
maxIterations: '最大迭代次数',
defaultThinkingLevel: '默认思考深度',
workspaceBasePath: '工作目录',
workspaceBasePathHint: '可选。为该员工指定独立的工作目录(相对于工作区根目录)。留空则继承工作区默认目录。',
workspaceBasePathHint: '可选。为该员工指定独立的工作目录:相对路径会基于工作区根目录解析,也可填写绝对路径。留空则继承工作区默认目录。',
modelName: '模型',
modelGlobalDefault: '使用全局默认模型',
modelHint: '为该员工单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',