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 <noreply@anthropic.com>
This commit is contained in:
voicewitness 2026-03-16 21:33:33 +08:00
parent 4337b1eba5
commit 0614937b98
6 changed files with 150 additions and 4 deletions

View File

@ -28,6 +28,7 @@ type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["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) => {

View File

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

View File

@ -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<void> {
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<string, number>();
for (const ws of workspacePaths) {
const base = path.basename(ws);
nameCount.set(base, (nameCount.get(base) ?? 0) + 1);
}
const nameUsed = new Map<string, number>();
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}`);
}
}

View File

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

View File

@ -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). */

View File

@ -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(),