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:
parent
4337b1eba5
commit
0614937b98
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user