Merge pull request #144 from multica-ai/feat/agent-workspace
feat(agent): add default workspace directory per profile
This commit is contained in:
commit
0f4d579cb7
8 changed files with 190 additions and 3 deletions
|
|
@ -31,6 +31,8 @@ export interface ProfileConfig {
|
|||
reasoningMode?: "off" | "on" | "stream" | undefined;
|
||||
/** Exec approval configuration (security level, ask mode, allowlist) */
|
||||
execApproval?: ExecApprovalConfig | undefined;
|
||||
/** Custom workspace directory path (overrides default) */
|
||||
workspaceDir?: string | undefined;
|
||||
/** Heartbeat configuration */
|
||||
heartbeat?: {
|
||||
/** Global heartbeat enable switch */
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
sanitizeToolUseResultPairing,
|
||||
} from "./session/session-transcript-repair.js";
|
||||
import { isContextOverflowError } from "./errors.js";
|
||||
import { resolveWorkspaceDir, ensureWorkspaceDir } from "./workspace.js";
|
||||
|
||||
// ============================================================
|
||||
// Error classification for auth profile rotation
|
||||
|
|
@ -121,6 +122,9 @@ export class Agent {
|
|||
private readonly pinnedProfile: boolean;
|
||||
private readonly explicitApiKey: boolean;
|
||||
|
||||
/** Resolved workspace directory */
|
||||
readonly workspaceDir: string;
|
||||
|
||||
/** Current session ID */
|
||||
readonly sessionId: string;
|
||||
|
||||
|
|
@ -318,13 +322,22 @@ export class Agent {
|
|||
this.originalToolsConfig = options.tools;
|
||||
}
|
||||
|
||||
// Resolve workspace directory
|
||||
const profileConfig = this.profile?.getProfile()?.config;
|
||||
this.workspaceDir = resolveWorkspaceDir({
|
||||
profileId: options.profileId,
|
||||
configWorkspaceDir: profileConfig?.workspaceDir,
|
||||
});
|
||||
ensureWorkspaceDir(this.workspaceDir);
|
||||
const effectiveCwd = options.cwd ?? this.workspaceDir;
|
||||
|
||||
// Merge Profile tools config with options.tools (options takes precedence)
|
||||
const profileToolsConfig = this.profile?.getToolsConfig();
|
||||
const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools);
|
||||
const profileDir = this.profile?.getProfileDir();
|
||||
this.toolsOptions = mergedToolsConfig
|
||||
? { ...options, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider }
|
||||
: { ...options, profileDir, provider: this.resolvedProvider };
|
||||
? { ...options, cwd: effectiveCwd, tools: mergedToolsConfig, profileDir, provider: this.resolvedProvider }
|
||||
: { ...options, cwd: effectiveCwd, profileDir, provider: this.resolvedProvider };
|
||||
|
||||
const tools = resolveTools(this.toolsOptions);
|
||||
if (this.debug) {
|
||||
|
|
@ -1155,6 +1168,7 @@ export class Agent {
|
|||
agentName: this.profile?.getName(),
|
||||
provider: this.resolvedProvider,
|
||||
model: this.agent.state.model?.id,
|
||||
cwd: this.toolsOptions.cwd,
|
||||
});
|
||||
|
||||
return buildStructuredSystemPrompt({
|
||||
|
|
@ -1168,6 +1182,7 @@ export class Agent {
|
|||
config: profile.config,
|
||||
},
|
||||
profileDir: this.profile!.getProfileDir(),
|
||||
workspaceDir: this.workspaceDir,
|
||||
tools: toolNames,
|
||||
skillsPrompt,
|
||||
runtime,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
|
|||
mode,
|
||||
profile,
|
||||
profileDir,
|
||||
workspaceDir,
|
||||
tools,
|
||||
skillsPrompt,
|
||||
runtime,
|
||||
|
|
@ -58,7 +59,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
|
|||
const candidates: Array<{ name: string; lines: string[] }> = [
|
||||
{ name: "identity", lines: buildIdentitySection(profile, mode) },
|
||||
{ name: "user", lines: buildUserSection(profile, mode) },
|
||||
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) },
|
||||
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir, workspaceDir) },
|
||||
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
|
||||
{ name: "heartbeat", lines: buildHeartbeatSection(profile, mode) },
|
||||
{ name: "safety", lines: buildSafetySection(includeSafety) },
|
||||
|
|
|
|||
|
|
@ -89,11 +89,23 @@ export function buildWorkspaceSection(
|
|||
profile: ProfileContent | undefined,
|
||||
mode: SystemPromptMode,
|
||||
profileDir?: string,
|
||||
workspaceDir?: string,
|
||||
): string[] {
|
||||
if (mode !== "full") return [];
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Working directory info
|
||||
if (workspaceDir) {
|
||||
lines.push(
|
||||
"## Working Directory",
|
||||
"",
|
||||
`Your working directory is: \`${workspaceDir}\``,
|
||||
"Use this as the default location for file operations unless the user specifies a different path.",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// Add profile directory context first
|
||||
if (profileDir) {
|
||||
lines.push(
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ export interface SystemPromptOptions {
|
|||
runtime?: RuntimeInfo | undefined;
|
||||
/** Subagent context (for minimal/none modes) */
|
||||
subagent?: SubagentContext | undefined;
|
||||
/** Workspace directory path (for agent working directory info) */
|
||||
workspaceDir?: string | undefined;
|
||||
/** Extra system prompt to append */
|
||||
extraSystemPrompt?: string | undefined;
|
||||
/** Whether to include the safety constitution (default: true) */
|
||||
|
|
|
|||
107
packages/core/src/agent/workspace.test.ts
Normal file
107
packages/core/src/agent/workspace.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveWorkspaceDir, ensureWorkspaceDir } from "./workspace.js";
|
||||
import { DATA_DIR, DEFAULT_WORKSPACE_DIR } from "@multica/utils";
|
||||
|
||||
describe("resolveWorkspaceDir", () => {
|
||||
const originalEnv = process.env.MULTICA_WORKSPACE_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.MULTICA_WORKSPACE_DIR;
|
||||
} else {
|
||||
process.env.MULTICA_WORKSPACE_DIR = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns ~/.super-multica/workspace/default for default profile", () => {
|
||||
delete process.env.MULTICA_WORKSPACE_DIR;
|
||||
expect(resolveWorkspaceDir()).toBe(path.join(DEFAULT_WORKSPACE_DIR, "default"));
|
||||
expect(resolveWorkspaceDir({ profileId: "default" })).toBe(path.join(DEFAULT_WORKSPACE_DIR, "default"));
|
||||
});
|
||||
|
||||
it("returns ~/.super-multica/workspace/{id} for named profile", () => {
|
||||
delete process.env.MULTICA_WORKSPACE_DIR;
|
||||
const result = resolveWorkspaceDir({ profileId: "research" });
|
||||
expect(result).toBe(path.join(DEFAULT_WORKSPACE_DIR, "research"));
|
||||
});
|
||||
|
||||
it("prioritizes MULTICA_WORKSPACE_DIR env var", () => {
|
||||
process.env.MULTICA_WORKSPACE_DIR = "/tmp/custom-ws";
|
||||
expect(resolveWorkspaceDir({ profileId: "default" })).toBe("/tmp/custom-ws");
|
||||
});
|
||||
|
||||
it("prioritizes config workspaceDir over profile default", () => {
|
||||
delete process.env.MULTICA_WORKSPACE_DIR;
|
||||
const result = resolveWorkspaceDir({
|
||||
profileId: "default",
|
||||
configWorkspaceDir: "/tmp/config-ws",
|
||||
});
|
||||
expect(result).toBe("/tmp/config-ws");
|
||||
});
|
||||
|
||||
it("env var takes precedence over config", () => {
|
||||
process.env.MULTICA_WORKSPACE_DIR = "/tmp/env-ws";
|
||||
const result = resolveWorkspaceDir({
|
||||
profileId: "default",
|
||||
configWorkspaceDir: "/tmp/config-ws",
|
||||
});
|
||||
expect(result).toBe("/tmp/env-ws");
|
||||
});
|
||||
|
||||
it("expands ~ in env var", () => {
|
||||
process.env.MULTICA_WORKSPACE_DIR = "~/my-workspace";
|
||||
const result = resolveWorkspaceDir();
|
||||
expect(result).toBe(path.resolve(path.join(os.homedir(), "my-workspace")));
|
||||
});
|
||||
|
||||
it("expands ~ in config workspaceDir", () => {
|
||||
delete process.env.MULTICA_WORKSPACE_DIR;
|
||||
const result = resolveWorkspaceDir({ configWorkspaceDir: "~/my-ws" });
|
||||
expect(result).toBe(path.resolve(path.join(os.homedir(), "my-ws")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureWorkspaceDir", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "multica-ws-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates directory and README.md", () => {
|
||||
const wsDir = path.join(tmpDir, "workspace");
|
||||
ensureWorkspaceDir(wsDir);
|
||||
|
||||
expect(fs.existsSync(wsDir)).toBe(true);
|
||||
const readmePath = path.join(wsDir, "README.md");
|
||||
expect(fs.existsSync(readmePath)).toBe(true);
|
||||
const content = fs.readFileSync(readmePath, "utf-8");
|
||||
expect(content).toContain("Multica Workspace");
|
||||
});
|
||||
|
||||
it("does not overwrite existing README.md", () => {
|
||||
const wsDir = path.join(tmpDir, "workspace");
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
const readmePath = path.join(wsDir, "README.md");
|
||||
fs.writeFileSync(readmePath, "custom content");
|
||||
|
||||
ensureWorkspaceDir(wsDir);
|
||||
|
||||
const content = fs.readFileSync(readmePath, "utf-8");
|
||||
expect(content).toBe("custom content");
|
||||
});
|
||||
|
||||
it("succeeds when directory already exists", () => {
|
||||
const wsDir = path.join(tmpDir, "workspace");
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
|
||||
expect(() => ensureWorkspaceDir(wsDir)).not.toThrow();
|
||||
});
|
||||
});
|
||||
45
packages/core/src/agent/workspace.ts
Normal file
45
packages/core/src/agent/workspace.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DATA_DIR, DEFAULT_WORKSPACE_DIR } from "@multica/utils";
|
||||
|
||||
/**
|
||||
* Resolve the workspace directory for a given profile.
|
||||
* Priority: env var > config > profile-based default
|
||||
*/
|
||||
export function resolveWorkspaceDir(options?: {
|
||||
profileId?: string;
|
||||
configWorkspaceDir?: string;
|
||||
}): string {
|
||||
// 1. Env var override
|
||||
const envDir = process.env.MULTICA_WORKSPACE_DIR?.trim();
|
||||
if (envDir) return path.resolve(envDir.replace(/^~/, os.homedir()));
|
||||
|
||||
// 2. Config override (from profile config.json)
|
||||
if (options?.configWorkspaceDir?.trim()) {
|
||||
return path.resolve(options.configWorkspaceDir.replace(/^~/, os.homedir()));
|
||||
}
|
||||
|
||||
// 3. Profile-based default: ~/.super-multica/workspace/{profileId}
|
||||
const profileId = options?.profileId ?? "default";
|
||||
return path.join(DEFAULT_WORKSPACE_DIR, profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure workspace directory exists. Creates README.md on first creation.
|
||||
*/
|
||||
export function ensureWorkspaceDir(dir: string): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const readmePath = path.join(dir, "README.md");
|
||||
try {
|
||||
writeFileSync(readmePath, README_CONTENT, { encoding: "utf-8", flag: "wx" });
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== "EEXIST") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const README_CONTENT = `# Multica Workspace
|
||||
|
||||
This directory is the default workspace for your Multica agent.
|
||||
Files created by the agent will be saved here unless you specify a different directory.
|
||||
`;
|
||||
|
|
@ -6,3 +6,6 @@ export const DATA_DIR = join(homedir(), ".super-multica");
|
|||
|
||||
/** Cache directory for downloaded media files */
|
||||
export const MEDIA_CACHE_DIR = join(DATA_DIR, "cache", "media");
|
||||
|
||||
/** Default workspace directory for the default profile */
|
||||
export const DEFAULT_WORKSPACE_DIR = join(DATA_DIR, "workspace");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue