workspace split

This commit is contained in:
jaberjaber23
2026-05-12 16:33:49 +03:00
parent 4c496be02f
commit 6bed6c04ff
6 changed files with 321 additions and 95 deletions
+66 -23
View File
@@ -3703,7 +3703,14 @@ pub async fn install_skill(
let config = openfang_skills::marketplace::MarketplaceConfig::default();
let client = openfang_skills::marketplace::MarketplaceClient::new(config);
match client.install(&req.name, &skills_dir).await {
let opts = openfang_skills::installer::InstallOptions {
require_signed: req.require_signed,
allowed_signer_keys: req.allowed_signer_keys.clone(),
};
match client
.install_with_options(&req.name, &skills_dir, &opts)
.await
{
Ok(version) => {
// Hot-reload so agents see the new skill immediately
state.kernel.reload_skills();
@@ -9973,8 +9980,10 @@ pub async fn clone_agent(
// new_name always wins over any name field in overrides.
cloned_manifest.name = new_name.clone();
// Let the kernel assign a fresh workspace path — never share with the template.
// Let the kernel assign fresh workspace and state directory paths.
// Never share private state with the template (see issue #868, #1097).
cloned_manifest.workspace = None;
cloned_manifest.state_dir = None;
// Spawn the cloned agent.
let new_id = match state.kernel.spawn_agent(cloned_manifest) {
@@ -9987,25 +9996,42 @@ pub async fn clone_agent(
}
};
// Copy non-memory workspace files from source to destination.
// MEMORY.md and HEARTBEAT.md are intentionally skipped — the cloned agent
// must start with independent memory (issue #868).
// Copy non-memory identity files from source state_dir to destination
// state_dir. MEMORY.md and HEARTBEAT.md are intentionally skipped — the
// cloned agent must start with independent memory (issue #868). Identity
// files live in state_dir per #1097; fall back to legacy workspace for
// older agents.
let new_entry = state.kernel.registry.get(new_id);
let src_state = source
.manifest
.state_dir
.as_ref()
.or(source.manifest.workspace.as_ref());
let dst_state = new_entry.as_ref().and_then(|e| {
e.manifest
.state_dir
.as_ref()
.or(e.manifest.workspace.as_ref())
});
if let (Some(src_ws), Some(dst_ws)) = (src_state, dst_state) {
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
for &fname in KNOWN_IDENTITY_FILES {
if MEMORY_FILES.contains(&fname) {
continue;
}
let src_file = src_can.join(fname);
let dst_file = dst_can.join(fname);
if src_file.exists() {
let _ = std::fs::copy(&src_file, &dst_file);
}
}
}
}
// Copy the user-facing `skills/` subdirectory so curated skills travel
// with the template. These live in the workspace, not the state dir.
if let (Some(ref src_ws), Some(ref new_entry)) = (&source.manifest.workspace, &new_entry) {
if let Some(ref dst_ws) = new_entry.manifest.workspace {
if let (Ok(src_can), Ok(dst_can)) = (src_ws.canonicalize(), dst_ws.canonicalize()) {
for &fname in KNOWN_IDENTITY_FILES {
if MEMORY_FILES.contains(&fname) {
continue;
}
let src_file = src_can.join(fname);
let dst_file = dst_can.join(fname);
if src_file.exists() {
let _ = std::fs::copy(&src_file, &dst_file);
}
}
// Copy the `skills/` subdirectory if present so curated skills
// travel with the template.
let src_skills = src_can.join("skills");
let dst_skills = dst_can.join("skills");
if src_skills.is_dir() {
@@ -10108,8 +10134,11 @@ pub async fn list_agent_files(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to the legacy workspace location for agents created before the
// split so existing on-disk files remain reachable.
let workspace = match entry.manifest.state_dir.as_ref().or(entry.manifest.workspace.as_ref()) {
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
@@ -10170,8 +10199,15 @@ pub async fn get_agent_file(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to legacy workspace for agents created before the split.
let workspace = match entry
.manifest
.state_dir
.as_ref()
.or(entry.manifest.workspace.as_ref())
{
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
@@ -10277,8 +10313,15 @@ pub async fn set_agent_file(
}
};
let workspace = match entry.manifest.workspace {
Some(ref ws) => ws.clone(),
// Identity files live in the agent's private state directory (see #1097).
// Fall back to legacy workspace for agents created before the split.
let workspace = match entry
.manifest
.state_dir
.as_ref()
.or(entry.manifest.workspace.as_ref())
{
Some(ws) => ws.clone(),
None => {
return (
StatusCode::NOT_FOUND,
+1
View File
@@ -339,6 +339,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
+218 -69
View File
@@ -288,9 +288,38 @@ impl DeliveryTracker {
}
}
/// Create workspace directory structure for an agent.
/// Create the agent's private state directory layout. Holds identity files,
/// AGENT.json, sessions/, memory/, and logs/. Lives under
/// `~/.openfang/workspaces/{name}/` regardless of where the user pointed the
/// user-facing workspace. See issue #1097.
fn ensure_state_dir(state_dir: &Path, workspace: &Path) -> KernelResult<()> {
for subdir in &["sessions", "logs", "memory"] {
std::fs::create_dir_all(state_dir.join(subdir)).map_err(|e| {
KernelError::OpenFang(OpenFangError::Internal(format!(
"Failed to create state dir {}/{subdir}: {e}",
state_dir.display()
)))
})?;
}
// Write agent metadata file (best-effort).
let meta = serde_json::json!({
"created_at": chrono::Utc::now().to_rfc3339(),
"state_dir": state_dir.display().to_string(),
"workspace": workspace.display().to_string(),
});
let _ = std::fs::write(
state_dir.join("AGENT.json"),
serde_json::to_string_pretty(&meta).unwrap_or_default(),
);
Ok(())
}
/// Create the user-facing workspace layout. Only `data/`, `output/`, and
/// `skills/` are scaffolded here. When the user points the workspace at a
/// pre-existing path like `~/Documents`, these subdirs are created lazily
/// inside it without dumping identity files or sessions. See issue #1097.
fn ensure_workspace(workspace: &Path) -> KernelResult<()> {
for subdir in &["data", "output", "sessions", "skills", "logs", "memory"] {
for subdir in &["data", "output", "skills"] {
std::fs::create_dir_all(workspace.join(subdir)).map_err(|e| {
KernelError::OpenFang(OpenFangError::Internal(format!(
"Failed to create workspace dir {}/{subdir}: {e}",
@@ -298,15 +327,6 @@ fn ensure_workspace(workspace: &Path) -> KernelResult<()> {
)))
})?;
}
// Write agent metadata file (best-effort)
let meta = serde_json::json!({
"created_at": chrono::Utc::now().to_rfc3339(),
"workspace": workspace.display().to_string(),
});
let _ = std::fs::write(
workspace.join("AGENT.json"),
serde_json::to_string_pretty(&meta).unwrap_or_default(),
);
Ok(())
}
@@ -447,15 +467,18 @@ fn generate_identity_files(workspace: &Path, manifest: &AgentManifest) {
}
/// Append an assistant response summary to the daily memory log (best-effort, append-only).
/// Caps daily log at 1MB to prevent unbounded growth.
fn append_daily_memory_log(workspace: &Path, response: &str) {
/// Caps daily log at 1MB to prevent unbounded growth. Writes to the agent's
/// private state directory (`state_dir/memory/`), never to the user-facing
/// workspace, so pointing `workspace = "/home/me/Documents"` does not litter
/// the user's folder with per-day markdown files. See issue #1097.
fn append_daily_memory_log(state_dir: &Path, response: &str) {
use std::io::Write;
let trimmed = response.trim();
if trimmed.is_empty() {
return;
}
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let log_path = workspace.join("memory").join(format!("{today}.md"));
let log_path = state_dir.join("memory").join(format!("{today}.md"));
// Security: cap total daily log to 1MB
if let Ok(metadata) = std::fs::metadata(&log_path) {
if metadata.len() > 1_048_576 {
@@ -474,16 +497,18 @@ fn append_daily_memory_log(workspace: &Path, response: &str) {
}
}
/// Read a workspace identity file with a size cap to prevent prompt stuffing.
/// Returns None if the file doesn't exist or is empty.
fn read_identity_file(workspace: &Path, filename: &str) -> Option<String> {
/// Read an identity file from the agent's private state directory with a size
/// cap to prevent prompt stuffing. Returns None if the file doesn't exist or
/// is empty. Identity files live in `state_dir`, not in the user-facing
/// workspace (see issue #1097), so this is called with the state directory.
fn read_identity_file(state_dir: &Path, filename: &str) -> Option<String> {
const MAX_IDENTITY_FILE_BYTES: usize = 32_768; // 32KB cap
let path = workspace.join(filename);
// Security: ensure path stays inside workspace
let path = state_dir.join(filename);
// Security: ensure path stays inside the state directory
match path.canonicalize() {
Ok(canonical) => {
if let Ok(ws_canonical) = workspace.canonicalize() {
if !canonical.starts_with(&ws_canonical) {
if let Ok(sd_canonical) = state_dir.canonicalize() {
if !canonical.starts_with(&sd_canonical) {
return None; // path traversal attempt
}
}
@@ -800,6 +825,9 @@ impl OpenFangKernel {
// Initialize model catalog, detect provider auth, and apply URL overrides
let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new();
model_catalog.detect_auth();
// Env-var overrides for local providers (OLLAMA_HOST, LMSTUDIO_BASE_URL, etc.).
// Applied before `provider_urls` so explicit config.toml entries win. See #1154.
model_catalog.apply_local_env_overrides();
if !config.provider_urls.is_empty() {
model_catalog.apply_url_overrides(&config.provider_urls);
info!(
@@ -1631,15 +1659,24 @@ impl OpenFangKernel {
// Apply global budget defaults to agent resource quotas
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
// Create workspace directory for the agent (name-based, so SOUL.md survives recreation)
let workspace_dir = manifest
.workspace
// Agent private state always lives under ~/.openfang/workspaces/{name}/.
// This is name-based so SOUL.md and per-agent memory survive recreation
// and never get dumped into a user-supplied workspace path. See #1097.
let state_dir = manifest
.state_dir
.clone()
.unwrap_or_else(|| self.config.effective_workspaces_dir().join(&name));
// The user-facing workspace defaults to the state_dir when the manifest
// does not specify one. When the user sets `workspace = "/path"` in
// agent.toml we leave that path alone — only data/, output/, skills/
// get created lazily so private state never pollutes the target dir.
let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| state_dir.clone());
ensure_state_dir(&state_dir, &workspace_dir)?;
ensure_workspace(&workspace_dir)?;
if manifest.generate_identity_files {
generate_identity_files(&workspace_dir, &manifest);
generate_identity_files(&state_dir, &manifest);
}
manifest.state_dir = Some(state_dir);
manifest.workspace = Some(workspace_dir);
// Register capabilities
@@ -2049,16 +2086,27 @@ impl OpenFangKernel {
let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
let mut manifest = entry.manifest.clone();
// Lazy backfill: create workspace for existing agents spawned before workspaces
if manifest.workspace.is_none() {
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
// Lazy backfill: create state_dir and workspace for existing agents
// spawned before this field existed. Private state always lives under
// ~/.openfang/workspaces/{name}/; the user-facing workspace defaults
// to the same path unless the manifest already pinned one. See #1097.
if manifest.state_dir.is_none() {
let state_dir = self.config.effective_workspaces_dir().join(&manifest.name);
let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| state_dir.clone());
if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill state_dir (streaming): {e}");
}
if let Err(e) = ensure_workspace(&workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill workspace (streaming): {e}");
} else {
manifest.state_dir = Some(state_dir);
manifest.workspace = Some(workspace_dir);
let _ = self
.registry
.update_workspace(agent_id, manifest.workspace.clone());
let _ = self
.registry
.update_state_dir(agent_id, manifest.state_dir.clone());
}
}
@@ -2129,17 +2177,17 @@ impl OpenFangKernel {
},
workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()),
soul_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "SOUL.md")),
.and_then(|s| read_identity_file(s, "SOUL.md")),
user_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "USER.md")),
.and_then(|s| read_identity_file(s, "USER.md")),
memory_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "MEMORY.md")),
.and_then(|s| read_identity_file(s, "MEMORY.md")),
canonical_context: self
.memory
.canonical_context(agent_id, None)
@@ -2154,27 +2202,27 @@ impl OpenFangKernel {
.unwrap_or(false),
is_autonomous: manifest.autonomous.is_some(),
agents_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "AGENTS.md")),
.and_then(|s| read_identity_file(s, "AGENTS.md")),
bootstrap_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "BOOTSTRAP.md")),
.and_then(|s| read_identity_file(s, "BOOTSTRAP.md")),
workspace_context: manifest.workspace.as_ref().map(|w| {
let mut ws_ctx =
openfang_runtime::workspace_context::WorkspaceContext::detect(w);
ws_ctx.build_context_section()
}),
identity_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "IDENTITY.md")),
.and_then(|s| read_identity_file(s, "IDENTITY.md")),
heartbeat_md: if manifest.autonomous.is_some() {
manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "HEARTBEAT.md"))
.and_then(|s| read_identity_file(s, "HEARTBEAT.md"))
} else {
None
},
@@ -2315,15 +2363,17 @@ impl OpenFangKernel {
}
}
// Write JSONL session mirror to workspace
if let Some(ref workspace) = manifest.workspace {
// Write JSONL session mirror and daily memory log to the
// agent's private state directory, not the user-facing
// workspace. See issue #1097.
if let Some(ref state_dir) = manifest.state_dir {
if let Err(e) =
memory.write_jsonl_mirror(&session, &workspace.join("sessions"))
memory.write_jsonl_mirror(&session, &state_dir.join("sessions"))
{
warn!("Failed to write JSONL session mirror (streaming): {e}");
}
// Append daily memory log (best-effort)
append_daily_memory_log(workspace, &result.response);
append_daily_memory_log(state_dir, &result.response);
}
kernel_clone
@@ -2608,17 +2658,27 @@ impl OpenFangKernel {
// Apply model routing if configured (disabled in Stable mode)
let mut manifest = entry.manifest.clone();
// Lazy backfill: create workspace for existing agents spawned before workspaces
if manifest.workspace.is_none() {
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
// Lazy backfill: create state_dir and workspace for existing agents.
// Private state lives under ~/.openfang/workspaces/{name}/. User-facing
// workspace stays at whatever the manifest pinned (or defaults to the
// state_dir). See issue #1097.
if manifest.state_dir.is_none() {
let state_dir = self.config.effective_workspaces_dir().join(&manifest.name);
let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| state_dir.clone());
if let Err(e) = ensure_state_dir(&state_dir, &workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill state_dir: {e}");
}
if let Err(e) = ensure_workspace(&workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill workspace: {e}");
} else {
manifest.state_dir = Some(state_dir);
manifest.workspace = Some(workspace_dir);
// Persist updated workspace in registry
let _ = self
.registry
.update_workspace(agent_id, manifest.workspace.clone());
let _ = self
.registry
.update_state_dir(agent_id, manifest.state_dir.clone());
}
}
@@ -2697,17 +2757,17 @@ impl OpenFangKernel {
},
workspace_path: manifest.workspace.as_ref().map(|p| p.display().to_string()),
soul_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "SOUL.md")),
.and_then(|s| read_identity_file(s, "SOUL.md")),
user_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "USER.md")),
.and_then(|s| read_identity_file(s, "USER.md")),
memory_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "MEMORY.md")),
.and_then(|s| read_identity_file(s, "MEMORY.md")),
canonical_context: self
.memory
.canonical_context(agent_id, None)
@@ -2722,27 +2782,27 @@ impl OpenFangKernel {
.unwrap_or(false),
is_autonomous: manifest.autonomous.is_some(),
agents_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "AGENTS.md")),
.and_then(|s| read_identity_file(s, "AGENTS.md")),
bootstrap_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "BOOTSTRAP.md")),
.and_then(|s| read_identity_file(s, "BOOTSTRAP.md")),
workspace_context: manifest.workspace.as_ref().map(|w| {
let mut ws_ctx =
openfang_runtime::workspace_context::WorkspaceContext::detect(w);
ws_ctx.build_context_section()
}),
identity_md: manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "IDENTITY.md")),
.and_then(|s| read_identity_file(s, "IDENTITY.md")),
heartbeat_md: if manifest.autonomous.is_some() {
manifest
.workspace
.state_dir
.as_ref()
.and_then(|w| read_identity_file(w, "HEARTBEAT.md"))
.and_then(|s| read_identity_file(s, "HEARTBEAT.md"))
} else {
None
},
@@ -2880,16 +2940,17 @@ impl OpenFangKernel {
}
}
// Write JSONL session mirror to workspace
if let Some(ref workspace) = manifest.workspace {
// Write JSONL session mirror and daily memory log to the agent's
// private state directory, not the user-facing workspace. See #1097.
if let Some(ref state_dir) = manifest.state_dir {
if let Err(e) = self
.memory
.write_jsonl_mirror(&session, &workspace.join("sessions"))
.write_jsonl_mirror(&session, &state_dir.join("sessions"))
{
warn!("Failed to write JSONL session mirror: {e}");
}
// Append daily memory log (best-effort)
append_daily_memory_log(workspace, &result.response);
append_daily_memory_log(state_dir, &result.response);
}
// Record usage in the metering engine (uses catalog pricing as single source of truth)
@@ -6326,7 +6387,16 @@ impl OpenFangKernel {
summary.push_str(&format!("- {name}: {desc} [tools: {}]\n", tools.join(", ")));
}
}
summary.push_str("Use these skill tools when they match the user's request.");
// Issue #1038: skill directories (e.g. ~/.openfang/skills/) live OUTSIDE
// the workspace sandbox. Tell the agent to use the dedicated skill_*
// tools instead of falling back to file_read / shell_exec to inspect them.
summary.push_str(
"Use these skill tools when they match the user's request. \
To inspect a skill's full instructions, call skill_describe with the skill name \
do NOT use file_read or shell_exec on the skills directory, those paths are \
outside the agent workspace and will fail. \
Use skill_list to enumerate skills and skill_execute to run a skill's tool.",
);
summary
}
@@ -7856,6 +7926,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
@@ -7899,6 +7970,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: Some(std::path::PathBuf::from("/var/lib/openfang/agents/demo")),
state_dir: None,
generate_identity_files: true,
exec_policy: Some(ExecPolicy::default()),
tool_allowlist: vec![],
@@ -7950,6 +8022,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: Some(std::path::PathBuf::from("/old")),
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
@@ -8006,6 +8079,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: Some(cached_policy.clone()),
tool_allowlist: vec![],
@@ -8119,6 +8193,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
@@ -9230,4 +9305,78 @@ system_prompt = "You are a test agent."
kernel2.shutdown();
}
/// Regression for #1097: when a user points an agent's workspace at an
/// existing directory like `~/Documents`, the runtime must NOT scaffold
/// private state into that directory. Identity files (SOUL.md, AGENT.json,
/// etc.) and `sessions/` / `memory/` / `logs/` must land in the agent's
/// private state directory; only the lightweight user-facing layout
/// (`data/`, `output/`, `skills/`) may appear in the workspace.
#[test]
fn test_workspace_outside_openfang_stays_clean() {
use tempfile::TempDir;
let user_workspace = TempDir::new().expect("temp user workspace");
let state_dir = TempDir::new().expect("temp state dir");
// Pre-populate the user workspace with an unrelated file to make sure
// we don't trample existing contents either.
std::fs::write(user_workspace.path().join("pre-existing.txt"), b"hello")
.expect("write pre-existing file");
let manifest = AgentManifest {
name: "ws-test".to_string(),
description: "x".to_string(),
..AgentManifest::default()
};
// Simulate the spawn path: state dir gets the private layout, user
// workspace only gets the lightweight subdirs.
ensure_state_dir(state_dir.path(), user_workspace.path()).expect("ensure_state_dir");
ensure_workspace(user_workspace.path()).expect("ensure_workspace");
generate_identity_files(state_dir.path(), &manifest);
// Private state files must live in state_dir.
for fname in &[
"AGENT.json",
"SOUL.md",
"USER.md",
"MEMORY.md",
"AGENTS.md",
"BOOTSTRAP.md",
"IDENTITY.md",
] {
assert!(
state_dir.path().join(fname).exists(),
"{fname} should be created in state_dir"
);
assert!(
!user_workspace.path().join(fname).exists(),
"{fname} must NOT pollute the user-facing workspace (issue #1097)"
);
}
for subdir in &["sessions", "memory", "logs"] {
assert!(
state_dir.path().join(subdir).is_dir(),
"{subdir}/ should be created in state_dir"
);
assert!(
!user_workspace.path().join(subdir).exists(),
"{subdir}/ must NOT pollute the user-facing workspace (issue #1097)"
);
}
// The user-facing workspace gets only the lightweight layout.
for subdir in &["data", "output", "skills"] {
assert!(
user_workspace.path().join(subdir).is_dir(),
"{subdir}/ should be created in workspace"
);
}
// The pre-existing file must still be intact.
let contents = std::fs::read_to_string(user_workspace.path().join("pre-existing.txt"))
.expect("read pre-existing");
assert_eq!(contents, "hello", "must not overwrite user files");
}
}
+18
View File
@@ -134,6 +134,23 @@ impl AgentRegistry {
Ok(())
}
/// Update an agent's private state directory path. The state directory
/// holds identity files, sessions, and per-agent memory and is always
/// kept separate from the user-facing workspace. See issue #1097.
pub fn update_state_dir(
&self,
id: AgentId,
state_dir: Option<std::path::PathBuf>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
.get_mut(&id)
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
entry.manifest.state_dir = state_dir;
entry.last_active = chrono::Utc::now();
Ok(())
}
/// Update an agent's visual identity (emoji, avatar, color).
pub fn update_identity(
&self,
@@ -391,6 +408,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
+1
View File
@@ -176,6 +176,7 @@ impl SetupWizard {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
profile: None,
fallback_models: vec![],
+17 -3
View File
@@ -474,11 +474,23 @@ pub struct AgentManifest {
/// Pinned model override (used in Stable mode).
#[serde(default)]
pub pinned_model: Option<String>,
/// Agent workspace directory. Auto-created on spawn.
/// Default: `{workspaces_dir}/{agent_name}-{agent_id_prefix}/`
/// Agent workspace directory. User-facing working area for output, data,
/// skills, context.md, and uploads. When the user sets this in agent.toml
/// (e.g. `workspace = "/home/me/Documents"`) the runtime treats it as the
/// agent's working directory and will NOT scaffold private state files
/// there. Multiple agents can share the same workspace to collaborate on
/// files. See issue #1097.
/// Default: `{workspaces_dir}/{agent_name}/` (same as state_dir).
#[serde(default)]
pub workspace: Option<PathBuf>,
/// Whether to generate workspace identity files (SOUL.md, USER.md, etc.) on creation.
/// Agent private state directory. Stores identity files (SOUL.md, etc.),
/// AGENT.json, sessions/, and the daily memory log. Always lives under
/// `~/.openfang/workspaces/{name}/` regardless of where the user-facing
/// workspace points. Auto-derived on spawn. See issue #1097.
#[serde(default)]
pub state_dir: Option<PathBuf>,
/// Whether to generate identity files (SOUL.md, USER.md, etc.) in
/// `state_dir` on creation.
#[serde(default = "default_true")]
pub generate_identity_files: bool,
/// Per-agent exec policy override. If None, uses global exec_policy.
@@ -550,6 +562,7 @@ impl Default for AgentManifest {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),
@@ -809,6 +822,7 @@ mod tests {
autonomous: None,
pinned_model: None,
workspace: None,
state_dir: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),