From 0614937b981ad6b9939629abaea974582a41af84 Mon Sep 17 00:00:00 2001 From: voicewitness Date: Mon, 16 Mar 2026 21:33:33 +0800 Subject: [PATCH] feat(agents): support multiple workspaces via composite symlink directory Allow agents to cover multiple physical workspaces by adding a multipleWorkspaces field (string[]). At runtime a composite directory is created with symlinks to each workspace, serving as the agent unified workspace root. Bootstrap files live in the composite root while each symlinked project retains its own .ai_context/. Design philosophy from AGENT_COMPOSITION_AND_GRANULARITY.md: separate the brain (agent identity) from the hands (execution directories) so one agent persona can span multiple codebases. Co-Authored-By: Claude Opus 4.6 --- src/agents/agent-scope.ts | 40 +++++++++++- src/agents/workspace-dirs.ts | 13 +++- src/agents/workspace.ts | 85 ++++++++++++++++++++++++++ src/auto-reply/reply/get-reply.ts | 14 ++++- src/config/types.agents.ts | 1 + src/config/zod-schema.agent-runtime.ts | 1 + 6 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 5425b033dca..e4a179ad80a 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -28,6 +28,7 @@ type AgentEntry = NonNullable["list"]>[num type ResolvedAgentConfig = { name?: string; workspace?: string; + multipleWorkspaces?: string[]; agentDir?: string; model?: AgentEntry["model"]; skills?: AgentEntry["skills"]; @@ -127,6 +128,9 @@ export function resolveAgentConfig( return { name: typeof entry.name === "string" ? entry.name : undefined, workspace: typeof entry.workspace === "string" ? entry.workspace : undefined, + multipleWorkspaces: Array.isArray(entry.multipleWorkspaces) + ? entry.multipleWorkspaces + : undefined, agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined, model: typeof entry.model === "string" || (entry.model && typeof entry.model === "object") @@ -253,8 +257,29 @@ export function resolveEffectiveModelFallbacks(params: { return agentFallbacksOverride ?? defaultFallbacks; } +export function resolveAgentMultipleWorkspaces( + cfg: OpenClawConfig, + agentId: string, +): string[] | undefined { + const id = normalizeAgentId(agentId); + const agentConfig = resolveAgentConfig(cfg, id); + const mw = agentConfig?.multipleWorkspaces; + if (!Array.isArray(mw) || mw.length === 0) { + return undefined; + } + return mw.map((p) => stripNullBytes(resolveUserPath(p))); +} + export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); + + // multipleWorkspaces takes precedence: return a composite workspace dir. + const multiWs = resolveAgentMultipleWorkspaces(cfg, id); + if (multiWs) { + const stateDir = resolveStateDir(process.env); + return stripNullBytes(path.join(stateDir, `workspace-composite-${id}`)); + } + const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); if (configured) { return stripNullBytes(resolveUserPath(configured)); @@ -303,10 +328,21 @@ export function resolveAgentIdsByWorkspacePath( for (let index = 0; index < ids.length; index += 1) { const id = ids[index]; const workspaceDir = normalizePathForComparison(resolveAgentWorkspaceDir(cfg, id)); - if (!isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) { + if (isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) { + matches.push({ id, workspaceDir, order: index }); continue; } - matches.push({ id, workspaceDir, order: index }); + // Also match individual multipleWorkspaces entries. + const multiWs = resolveAgentMultipleWorkspaces(cfg, id); + if (multiWs) { + for (const ws of multiWs) { + const normalizedWs = normalizePathForComparison(ws); + if (isPathWithinRoot(normalizedWorkspacePath, normalizedWs)) { + matches.push({ id, workspaceDir: normalizedWs, order: index }); + break; + } + } + } } matches.sort((left, right) => { diff --git a/src/agents/workspace-dirs.ts b/src/agents/workspace-dirs.ts index 62adbddd471..4ad03f8bdf1 100644 --- a/src/agents/workspace-dirs.ts +++ b/src/agents/workspace-dirs.ts @@ -1,5 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; +import { + resolveAgentMultipleWorkspaces, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "./agent-scope.js"; export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] { const dirs = new Set(); @@ -8,6 +12,13 @@ export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] { for (const entry of list) { if (entry && typeof entry === "object" && typeof entry.id === "string") { dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); + // Also include individual multipleWorkspaces entries. + const multiWs = resolveAgentMultipleWorkspaces(cfg, entry.id); + if (multiWs) { + for (const ws of multiWs) { + dirs.add(ws); + } + } } } } diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 09159b4b072..f922bb7cb7c 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { openBoundaryFile } from "../infra/boundary-file-read.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; @@ -645,3 +646,87 @@ export async function loadExtraBootstrapFilesWithDiagnostics( } return { files, diagnostics }; } + +const compositeLog = createSubsystemLogger("composite-workspace"); + +/** + * Ensure a composite workspace directory exists with symlinks to each workspace path. + * Stale symlinks (pointing to paths not in the current set) are removed. + * Basename collisions are resolved by prepending the parent directory name. + */ +export async function ensureCompositeWorkspace(params: { + compositeDir: string; + workspacePaths: string[]; +}): Promise { + const { compositeDir, workspacePaths } = params; + await fs.mkdir(compositeDir, { recursive: true }); + + // Build unique link names from basenames, deduping collisions. + const linkEntries: Array<{ linkName: string; target: string }> = []; + const nameCount = new Map(); + for (const ws of workspacePaths) { + const base = path.basename(ws); + nameCount.set(base, (nameCount.get(base) ?? 0) + 1); + } + const nameUsed = new Map(); + for (const ws of workspacePaths) { + const base = path.basename(ws); + let linkName: string; + if ((nameCount.get(base) ?? 0) > 1) { + const parentName = path.basename(path.dirname(ws)); + const candidate = `${parentName}-${base}`; + const usedCount = nameUsed.get(candidate) ?? 0; + linkName = usedCount > 0 ? `${candidate}-${usedCount}` : candidate; + nameUsed.set(candidate, usedCount + 1); + } else { + linkName = base; + } + linkEntries.push({ linkName, target: ws }); + } + + // Remove stale symlinks in composite dir that are not in the current set. + const desiredNames = new Set(linkEntries.map((e) => e.linkName)); + try { + const existing = await fs.readdir(compositeDir, { withFileTypes: true }); + for (const entry of existing) { + if (!entry.isSymbolicLink()) { + continue; + } + if (!desiredNames.has(entry.name)) { + const stalePath = path.join(compositeDir, entry.name); + compositeLog.info(`Removing stale symlink: ${stalePath}`); + await fs.unlink(stalePath).catch(() => {}); + } + } + } catch { + // Directory may not exist yet or be unreadable. + } + + // Create or update symlinks. + for (const { linkName, target } of linkEntries) { + const linkPath = path.join(compositeDir, linkName); + + // Warn on missing targets but still create the symlink. + try { + await fs.access(target); + } catch { + compositeLog.warn(`Workspace target does not exist: ${target}`); + } + + // Check if symlink already points to the correct target. + try { + const existingTarget = await fs.readlink(linkPath); + if (path.resolve(existingTarget) === path.resolve(target)) { + continue; + } + // Target changed — remove old symlink. + await fs.unlink(linkPath); + } catch { + // Symlink doesn't exist or isn't a symlink — remove whatever is there. + await fs.rm(linkPath, { force: true, recursive: false }).catch(() => {}); + } + + await fs.symlink(target, linkPath); + compositeLog.info(`Symlinked ${linkName} -> ${target}`); + } +} diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 9cee46cc2c9..aa46ff6b12a 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -1,12 +1,17 @@ import { resolveAgentDir, + resolveAgentMultipleWorkspaces, resolveAgentWorkspaceDir, resolveSessionAgentId, resolveAgentSkillsFilter, } from "../../agents/agent-scope.js"; import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; -import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, + ensureCompositeWorkspace, +} from "../../agents/workspace.js"; import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import { type OpenClawConfig, loadConfig } from "../../config/config.js"; import { applyLinkUnderstanding } from "../../link-understanding/apply.js"; @@ -103,6 +108,13 @@ export async function getReplyFromConfig( } const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; + + // multipleWorkspaces: create composite symlink directory before bootstrapping. + const multiWsPaths = resolveAgentMultipleWorkspaces(cfg, agentId); + if (multiWsPaths) { + await ensureCompositeWorkspace({ compositeDir: workspaceDirRaw, workspacePaths: multiWsPaths }); + } + const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..754ec24cb32 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -63,6 +63,7 @@ export type AgentConfig = { default?: boolean; name?: string; workspace?: string; + multipleWorkspaces?: string[]; agentDir?: string; model?: AgentModelConfig; /** Optional allowlist of skills for this agent (omit = all skills; empty = none). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 10cef396275..9dfb3caa783 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -770,6 +770,7 @@ export const AgentEntrySchema = z default: z.boolean().optional(), name: z.string().optional(), workspace: z.string().optional(), + multipleWorkspaces: z.array(z.string()).min(1).optional(), agentDir: z.string().optional(), model: AgentModelSchema.optional(), skills: z.array(z.string()).optional(),