From 1e674d70c2b7e2c5febfab2b97e1c2ab8f9a23de Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 4 Sep 2026 22:55:37 +0900 Subject: [PATCH] refactor: unify Memory feature configuration authority --- crates/manifest/src/config.rs | 208 ++++--- crates/manifest/src/defaults.rs | 2 +- crates/manifest/src/lib.rs | 527 +++++++++++++----- crates/manifest/src/profile.rs | 70 +-- crates/memory/src/backend.rs | 18 +- crates/memory/src/consolidate/staging.rs | 3 +- crates/memory/src/workspace.rs | 41 +- crates/tui/src/setup_model.rs | 7 +- crates/worker-runtime/src/fs_store.rs | 24 +- crates/worker-runtime/src/worker_backend.rs | 26 +- crates/worker/src/controller.rs | 162 +++--- crates/worker/src/feature/builtin/memory.rs | 56 ++ .../src/feature/builtin/memory_lifecycle.rs | 127 ++--- crates/worker/src/spawn/tool.rs | 62 ++- crates/worker/src/worker.rs | 132 ++--- crates/workspace-server/src/server.rs | 33 +- docs/manifest.toml | 67 +-- resources/profiles/base.dcdl | 15 +- resources/profiles/default.dcdl | 2 +- resources/profiles/memory-consolidation.dcdl | 2 +- 20 files changed, 947 insertions(+), 637 deletions(-) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index f7d535be..6a6e6e96 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -18,8 +18,9 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::plugin::PluginConfig; use crate::{ CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, - McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, - MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig, + McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryExtractionProfileConfig, + MemoryFeatureProfileConfig, MemoryResidentProfileConfig, MergeRequestFeatureConfig, + ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta, }; @@ -67,9 +68,6 @@ pub struct WorkerManifestConfig { /// First-class web tool opt-in. See [`WebConfig`]. #[serde(default)] pub web: Option, - /// Memory subsystem opt-in. See [`MemoryConfig`]. - #[serde(default)] - pub memory: Option, /// External Agent Skills directories. See [`crate::SkillsConfig`]. #[serde(default)] pub skills: Option, @@ -193,18 +191,72 @@ impl From for WorkerFeatureConfig { } #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct MemoryFeatureConfigPartial { #[serde(default)] pub enabled: Option, #[serde(default)] - pub staging: Option, + pub staging_tools: Option, + #[serde(default)] + pub resident: Option, + #[serde(default)] + pub extraction: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryResidentProfileConfigPartial { + #[serde(default)] + pub inject_summary: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryExtractionProfileConfigPartial { + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub threshold: Option, + #[serde(default)] + pub worker_max_turns: Option, } impl MemoryFeatureConfigPartial { fn merge(self, other: Self) -> Self { Self { enabled: other.enabled.or(self.enabled), - staging: other.staging.or(self.staging), + staging_tools: other.staging_tools.or(self.staging_tools), + resident: merge_option( + self.resident, + other.resident, + MemoryResidentProfileConfigPartial::merge, + ), + extraction: merge_option( + self.extraction, + other.extraction, + MemoryExtractionProfileConfigPartial::merge, + ), + } + } +} + +impl MemoryResidentProfileConfigPartial { + fn merge(self, other: Self) -> Self { + Self { + inject_summary: other.inject_summary.or(self.inject_summary), + } + } +} + +impl MemoryExtractionProfileConfigPartial { + fn merge(self, other: Self) -> Self { + Self { + enabled: other.enabled.or(self.enabled), + model: other.model.or(self.model), + threshold: other.threshold.or(self.threshold), + worker_max_turns: other.worker_max_turns.or(self.worker_max_turns), } } } @@ -259,7 +311,7 @@ impl From for FeatureConfig { task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(), memory: value .memory - .map(MemoryFeatureConfig::from) + .map(ResolvedMemoryFeatureConfig::from) .unwrap_or_default(), web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(), image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(), @@ -329,20 +381,45 @@ impl From for WorkerFeatureConfigPartial { } } -impl From for MemoryFeatureConfig { +impl From for ResolvedMemoryFeatureConfig { fn from(value: MemoryFeatureConfigPartial) -> Self { + let resident = value.resident.unwrap_or_default(); + let extraction = value.extraction.unwrap_or_default(); Self { - enabled: value.enabled.unwrap_or_default(), - staging: value.staging.unwrap_or_default(), + profile: MemoryFeatureProfileConfig { + enabled: value.enabled.unwrap_or_default(), + staging_tools: value.staging_tools.unwrap_or_default(), + resident: MemoryResidentProfileConfig { + inject_summary: resident.inject_summary.unwrap_or(true), + }, + extraction: MemoryExtractionProfileConfig { + enabled: extraction.enabled.unwrap_or(true), + model: extraction.model, + threshold: extraction.threshold.or(Some(50_000)), + worker_max_turns: extraction + .worker_max_turns + .or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS), + }, + }, + workspace_settings: None, } } } -impl From for MemoryFeatureConfigPartial { - fn from(value: MemoryFeatureConfig) -> Self { +impl From for MemoryFeatureConfigPartial { + fn from(value: ResolvedMemoryFeatureConfig) -> Self { Self { - enabled: Some(value.enabled), - staging: Some(value.staging), + enabled: Some(value.profile.enabled), + staging_tools: Some(value.profile.staging_tools), + resident: Some(MemoryResidentProfileConfigPartial { + inject_summary: Some(value.profile.resident.inject_summary), + }), + extraction: Some(MemoryExtractionProfileConfigPartial { + enabled: Some(value.profile.extraction.enabled), + model: value.profile.extraction.model, + threshold: value.profile.extraction.threshold, + worker_max_turns: value.profile.extraction.worker_max_turns, + }), } } } @@ -543,13 +620,9 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er (removed; use compaction.prune_protected_tokens)", )); } - if value - .get("memory") - .and_then(toml::Value::as_table) - .is_some_and(|table| table.contains_key("extract_worker_max_input_tokens")) - { + if value.get("memory").is_some() { return Err(toml::de::Error::custom( - "unknown field in manifest: memory.extract_worker_max_input_tokens (removed)", + "unknown field in manifest: memory (removed; configure feature.memory)", )); } if value @@ -633,11 +706,6 @@ impl WorkerManifestConfig { for rule in &mut self.delegation_scope.deny { rule.target = join_if_relative(base, &rule.target); } - if let Some(ref mut memory) = self.memory - && let Some(ref mut root) = memory.workspace_root - { - *root = join_if_relative(base, root); - } if let Some(ref mut compaction) = self.compaction && let Some(ref mut cp) = compaction.model { @@ -682,7 +750,6 @@ impl WorkerManifestConfig { CompactionConfigPartial::merge, ), web: merge_option(self.web, upper.web, WebConfig::merge), - memory: merge_option(self.memory, upper.memory, MemoryConfig::merge), skills: merge_option(self.skills, upper.skills, SkillsConfig::merge), } } @@ -754,32 +821,6 @@ impl crate::WebFetchConfig { } } -impl MemoryConfig { - fn merge(self, upper: Self) -> Self { - Self { - workspace_root: upper.workspace_root.or(self.workspace_root), - query_result_limit: upper.query_result_limit.or(self.query_result_limit), - query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines), - inject_summary: upper.inject_summary.or(self.inject_summary), - workspace_id: upper.workspace_id.or(self.workspace_id), - settings_revision: upper.settings_revision.or(self.settings_revision), - language: upper.language.or(self.language), - extract_model: upper.extract_model.or(self.extract_model), - extract_threshold: upper.extract_threshold.or(self.extract_threshold), - extract_worker_max_turns: upper - .extract_worker_max_turns - .or(self.extract_worker_max_turns), - consolidation_model: upper.consolidation_model.or(self.consolidation_model), - consolidation_threshold_files: upper - .consolidation_threshold_files - .or(self.consolidation_threshold_files), - consolidation_threshold_bytes: upper - .consolidation_threshold_bytes - .or(self.consolidation_threshold_bytes), - } - } -} - impl WorkerMetaConfig { fn merge(self, upper: Self) -> Self { Self { @@ -1223,7 +1264,6 @@ impl TryFrom for WorkerManifest { mcp: cfg.mcp, compaction, web: cfg.web, - memory: cfg.memory, skills: cfg.skills, profile: None, }) @@ -1271,7 +1311,6 @@ mod tests { session: None, compaction: None, web: None, - memory: None, skills: None, } } @@ -1846,29 +1885,46 @@ prune_protected_turns = 3 } #[test] - fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() { - let bad = r#" -[memory] -extract_worker_max_input_tokens = 30000 -"#; - let err = WorkerManifestConfig::from_toml(bad).unwrap_err(); - assert!( - err.to_string() - .contains("memory.extract_worker_max_input_tokens"), - "unexpected error: {err}" - ); + fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() { + let cfg = WorkerManifestConfig::from_toml( + r#" +[feature.memory] +enabled = true +staging_tools = false + +[feature.memory.resident] +inject_summary = false + +[feature.memory.extraction] +enabled = true +threshold = 42000 +worker_max_turns = 2 +"#, + ) + .unwrap(); + let memory = cfg.feature.memory.unwrap(); + assert_eq!(memory.enabled, Some(true)); + assert_eq!(memory.staging_tools, Some(false)); + assert_eq!(memory.resident.unwrap().inject_summary, Some(false)); + let extraction = memory.extraction.unwrap(); + assert_eq!(extraction.enabled, Some(true)); + assert_eq!(extraction.threshold, Some(42_000)); + assert_eq!(extraction.worker_max_turns, Some(2)); } #[test] - fn from_toml_accepts_extract_worker_max_turns() { - let cfg = WorkerManifestConfig::from_toml( + fn from_toml_rejects_legacy_top_level_memory_authority() { + let err = WorkerManifestConfig::from_toml( r#" [memory] extract_worker_max_turns = 2 "#, ) - .unwrap(); - assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2)); + .unwrap_err(); + assert!( + err.to_string().contains("memory"), + "unexpected error: {err}" + ); } #[test] @@ -1948,7 +2004,7 @@ worker_max_turns = 7 fn feature_flags_default_disabled_in_resolved_manifest() { let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); assert!(!manifest.feature.task.enabled); - assert!(!manifest.feature.memory.enabled); + assert!(!manifest.feature.memory.profile.enabled); assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.sub_worker.enabled); assert!(!manifest.feature.objective.enabled); @@ -2025,8 +2081,8 @@ enabled = false } ); assert!(!manifest.feature.orchestration.enabled); - assert!(!manifest.feature.memory.enabled); - assert!(!manifest.feature.memory.staging); + assert!(!manifest.feature.memory.profile.enabled); + assert!(!manifest.feature.memory.profile.staging_tools); assert!(!manifest.feature.objective.enabled); } @@ -2074,7 +2130,7 @@ readiness_check = true enabled = true [feature.memory] -staging = true +staging_tools = true [feature.manage_workdir] enabled = true @@ -2111,8 +2167,8 @@ enabled = true }) .try_into() .unwrap(); - assert!(manifest.feature.memory.enabled); - assert!(manifest.feature.memory.staging); + assert!(manifest.feature.memory.profile.enabled); + assert!(manifest.feature.memory.profile.staging_tools); assert!(manifest.feature.manage_workdir.enabled); assert!(manifest.feature.ticket.enabled); assert!(!manifest.feature.ticket.authoring); diff --git a/crates/manifest/src/defaults.rs b/crates/manifest/src/defaults.rs index 70327972..88aeafab 100644 --- a/crates/manifest/src/defaults.rs +++ b/crates/manifest/src/defaults.rs @@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000; pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5; /// Optional maximum extract-worker tool-loop depth. `None` means unlimited. -/// See [`crate::MemoryConfig::extract_worker_max_turns`]. +/// See [`crate::MemoryExtractionProfileConfig::worker_max_turns`]. pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option = Some(8); diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 66535082..11c6c2f7 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize}; /// part of the manifest — it is the process's `std::env::current_dir()` /// at construction time. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct WorkerManifest { pub worker: WorkerMeta, pub model: ModelManifest, @@ -80,11 +81,6 @@ pub struct WorkerManifest { pub mcp: McpConfig, #[serde(default)] pub compaction: Option, - /// Memory subsystem configuration. Presence of `[memory]` configures memory - /// storage, extraction, consolidation, and resident injection, but memory - /// tools are surfaced only when `[feature.memory].enabled = true`. - #[serde(default)] - pub memory: Option, /// First-class web tools configuration. Network access remains fail-closed /// under this config; WebSearch/WebFetch schemas are surfaced only when /// `[feature.web].enabled = true`. @@ -109,12 +105,12 @@ pub struct WorkerManifest { /// profile/config data only: they do not carry runtime Worker names, sockets, /// sessions, secrets, or resolved host state. Tool registration still applies /// the normal scope, host-authority, backend, memory, and network checks. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FeatureConfig { #[serde(default)] pub task: FeatureFlagConfig, #[serde(default)] - pub memory: MemoryFeatureConfig, + pub memory: ResolvedMemoryFeatureConfig, #[serde(default)] pub web: FeatureFlagConfig, #[serde(default)] @@ -147,7 +143,7 @@ impl Default for FeatureConfig { fn default() -> Self { Self { task: FeatureFlagConfig::disabled(), - memory: MemoryFeatureConfig::disabled(), + memory: ResolvedMemoryFeatureConfig::default(), web: FeatureFlagConfig::disabled(), image: FeatureFlagConfig::disabled(), sub_worker: FeatureFlagConfig::disabled(), @@ -222,34 +218,117 @@ const fn default_true() -> bool { true } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryFeatureConfig { - #[serde(default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct MemoryFeatureProfileConfig { pub enabled: bool, /// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools. - #[serde(default)] - pub staging: bool, + pub staging_tools: bool, + pub resident: MemoryResidentProfileConfig, + pub extraction: MemoryExtractionProfileConfig, } -impl MemoryFeatureConfig { - pub const fn disabled() -> Self { - Self { - enabled: false, - staging: false, - } +impl MemoryFeatureProfileConfig { + pub fn disabled() -> Self { + Self::default() } - pub const fn enabled() -> Self { + pub fn enabled() -> Self { Self { enabled: true, - staging: false, + ..Self::default() } } } -impl Default for MemoryFeatureConfig { +impl Default for MemoryFeatureProfileConfig { fn default() -> Self { - Self::disabled() + Self { + enabled: false, + staging_tools: false, + resident: MemoryResidentProfileConfig::default(), + extraction: MemoryExtractionProfileConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct MemoryResidentProfileConfig { + pub inject_summary: bool, +} + +impl Default for MemoryResidentProfileConfig { + fn default() -> Self { + Self { + inject_summary: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct MemoryExtractionProfileConfig { + pub enabled: bool, + pub model: Option, + pub threshold: Option, + pub worker_max_turns: Option, +} + +impl Default for MemoryExtractionProfileConfig { + fn default() -> Self { + Self { + enabled: true, + model: None, + threshold: Some(50_000), + worker_max_turns: defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS, + } + } +} + +/// Immutable Memory execution configuration persisted in a resolved Worker Manifest. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default, deny_unknown_fields)] +pub struct ResolvedMemoryFeatureConfig { + pub profile: MemoryFeatureProfileConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_settings: Option, +} + +impl ResolvedMemoryFeatureConfig { + pub fn enabled(&self) -> bool { + self.profile.enabled + } + + pub fn bind_workspace_settings( + &mut self, + settings: WorkspaceMemorySettingsSnapshot, + ) -> Result<(), &'static str> { + if !self.profile.enabled { + if self.workspace_settings.is_some() { + return Err("disabled Memory feature must not carry Workspace settings"); + } + return Ok(()); + } + if self.workspace_settings.is_some() { + return Err("memory Workspace settings are already bound"); + } + self.workspace_settings = Some(settings); + Ok(()) + } + + pub fn workspace_settings(&self) -> Option { + self.workspace_settings.clone() + } + + pub fn validate_execution(&self) -> Result<(), &'static str> { + if self.profile.enabled && self.workspace_settings.is_none() { + return Err("enabled Memory feature requires trusted Workspace settings"); + } + if !self.profile.enabled && self.workspace_settings.is_some() { + return Err("disabled Memory feature must not carry Workspace settings"); + } + Ok(()) } } @@ -484,98 +563,6 @@ pub struct WorkspaceMemorySettingsSnapshot { pub language: String, } -/// Memory subsystem configuration. Presence in the manifest enables -/// memory; `workspace_root` pins the memory workspace explicitly. When it -/// is absent, memory resolution searches upward from the Worker's pwd for a -/// `.yoi/memory` marker rather than treating `.yoi` project records alone -/// as a memory root. -/// -/// All fields are `Option`; defaults are applied at the consumer -/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple -/// (`upper.x.or(self.x)`) without a separate partial/resolved split. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MemoryConfig { - /// Override for the memory workspace root. When `None`, consumers resolve - /// the root from their default path and ancestor `.yoi/memory` markers. - /// When set, must be an absolute path. - #[serde(default)] - pub workspace_root: Option, - /// Maximum number of records returned by `MemoryQuery` / - /// `MemoryQuery` per call. `None` ⇒ tool default (20). - #[serde(default)] - pub query_result_limit: Option, - /// Lines of context before and after each match in query excerpts. - /// Ignored when the request omits `query`. `None` ⇒ tool default (3). - #[serde(default)] - pub query_excerpt_lines: Option, - /// Whether the body of `memory/summary.md` is exposed in the resident - /// system-prompt section. `None` ⇒ enabled. - #[serde(default)] - pub inject_summary: Option, - /// Workspace that owns the bound Memory settings revision. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option, - /// Monotonic revision of the bound Workspace Memory settings. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub settings_revision: Option, - /// Language from the bound Workspace Memory settings revision. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub language: Option, - /// Optional model for the extract worker. When `None`, - /// the main engine model is cloned via `clone_boxed()`. Lightweight - /// reasoning-capable models (Haiku / 4o-mini / Flash class) are - /// recommended. - #[serde(default)] - pub extract_model: Option, - /// Cumulative input-token threshold (since the last extract pointer) - /// that triggers an extract run. `None` disables the extract trigger - /// entirely; memory tools and resident injection still work, only - /// the auto-extract trigger is dormant. - #[serde(default)] - pub extract_threshold: Option, - /// Optional maximum extract-worker tool-loop depth. `None` leaves - /// the worker unlimited; the default bounds runaway short-context - /// loops. Falls through to - /// [`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`] when unset. - #[serde(default)] - pub extract_worker_max_turns: Option, - /// Optional model for the consolidation worker. When - /// `None`, the main engine model is cloned via `clone_boxed()`. - /// Reasoning-class models are recommended. - #[serde(default)] - pub consolidation_model: Option, - /// Consolidation trigger: file-count threshold of `_staging/`. The - /// consolidation run fires when the staging directory has at least - /// this many entries. Either threshold reaching its limit fires - /// consolidation (logical OR). `None` for both thresholds ⇒ - /// consolidation disabled. - #[serde(default)] - pub consolidation_threshold_files: Option, - /// Consolidation trigger: byte-size threshold across all `_staging/` - /// entries. Either threshold reaching its limit fires consolidation. - /// `None` for both thresholds ⇒ consolidation disabled. - #[serde(default)] - pub consolidation_threshold_bytes: Option, -} - -impl MemoryConfig { - /// Replace any untrusted manifest values with a trusted Workspace snapshot. - pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) { - self.workspace_id = Some(snapshot.workspace_id.clone()); - self.settings_revision = Some(snapshot.settings_revision); - self.language = Some(snapshot.language.clone()); - } - - /// Return the complete bound Workspace settings snapshot, if every field is present. - pub fn workspace_settings(&self) -> Option { - Some(WorkspaceMemorySettingsSnapshot { - workspace_id: self.workspace_id.clone()?, - settings_revision: self.settings_revision?, - language: self.language.clone()?, - }) - } -} - /// Worker metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerMeta { @@ -941,6 +928,167 @@ impl WorkerManifest { } } +const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2; + +/// Serialize a resolved Worker Manifest for durable Worker-specific storage. +pub fn write_persisted_worker_manifest_snapshot( + manifest: &WorkerManifest, +) -> Result { + Ok(serde_json::json!({ + "schema_version": RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION, + "manifest": serde_json::to_value(manifest)?, + })) +} + +/// Read a durable resolved Worker Manifest through the versioned compatibility +/// boundary. Runtime code must not deserialize persisted snapshots directly. +pub fn read_persisted_worker_manifest_snapshot( + snapshot: serde_json::Value, +) -> Result { + let object = snapshot.as_object().ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "resolved Worker manifest snapshot must be an object", + )) + })?; + if let Some(version) = object.get("schema_version") { + let version = version.as_u64().ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "resolved Worker manifest snapshot schema_version must be an integer", + )) + })?; + if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unsupported resolved Worker manifest snapshot schema version {version}"), + ))); + } + if object.len() != 2 { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "resolved Worker manifest snapshot contains unknown fields", + ))); + } + let manifest = object.get("manifest").cloned().ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "resolved Worker manifest snapshot is missing manifest", + )) + })?; + return serde_json::from_value(manifest); + } + + migrate_legacy_resolved_manifest_snapshot(snapshot) +} + +fn migrate_legacy_resolved_manifest_snapshot( + mut snapshot: serde_json::Value, +) -> Result { + let root = snapshot.as_object_mut().ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy resolved Worker manifest snapshot must be an object", + )) + })?; + let legacy_memory = root.remove("memory"); + let feature = root + .entry("feature") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy resolved Worker manifest feature must be an object", + )) + })?; + let legacy_feature_memory = feature + .remove("memory") + .unwrap_or_else(|| serde_json::json!({})); + let legacy_feature_memory = legacy_feature_memory.as_object().ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy resolved Worker manifest feature.memory must be an object", + )) + })?; + if legacy_feature_memory + .keys() + .any(|key| !matches!(key.as_str(), "enabled" | "staging")) + { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy resolved Worker manifest mixes old and new Memory configuration", + ))); + } + let enabled = legacy_feature_memory + .get("enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let staging_tools = legacy_feature_memory + .get("staging") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + let legacy_memory = legacy_memory.unwrap_or_else(|| serde_json::json!({})); + let legacy_memory = legacy_memory.as_object().ok_or_else(|| { + serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy resolved Worker manifest memory must be an object", + )) + })?; + let workspace_id = legacy_memory.get("workspace_id").cloned(); + let settings_revision = legacy_memory.get("settings_revision").cloned(); + let language = legacy_memory.get("language").cloned(); + let workspace_settings = match (workspace_id, settings_revision, language) { + (Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({ + "workspace_id": workspace_id, + "settings_revision": settings_revision, + "language": language, + })), + (None, None, None) => None, + _ => { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy resolved Worker manifest contains a partial Memory settings snapshot", + ))); + } + }; + let extraction_threshold = legacy_memory + .get("extract_threshold") + .cloned() + .unwrap_or(serde_json::Value::Null); + let extraction_enabled = !extraction_threshold.is_null(); + let mut resolved = serde_json::json!({ + "profile": { + "enabled": enabled, + "staging_tools": staging_tools, + "resident": { + "inject_summary": legacy_memory + .get("inject_summary") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + }, + "extraction": { + "enabled": extraction_enabled, + "model": legacy_memory.get("extract_model").cloned().unwrap_or(serde_json::Value::Null), + "threshold": extraction_threshold, + "worker_max_turns": legacy_memory + .get("extract_worker_max_turns") + .cloned() + .unwrap_or(serde_json::Value::Null), + }, + }, + }); + if let Some(workspace_settings) = workspace_settings { + resolved + .as_object_mut() + .expect("resolved Memory config is an object") + .insert("workspace_settings".to_string(), workspace_settings); + } + feature.insert("memory".to_string(), resolved); + serde_json::from_value(snapshot) +} + #[cfg(test)] mod tests { use super::*; @@ -1246,36 +1394,129 @@ model_id = "claude-sonnet-4-20250514" } #[test] - fn omitted_memory_is_none() { + fn omitted_memory_feature_is_disabled() { let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap(); - assert!(manifest.memory.is_none()); + assert!(!manifest.feature.memory.profile.enabled); + assert!(manifest.feature.memory.workspace_settings.is_none()); } #[test] - fn empty_memory_section_enables_with_default_root() { - let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n"); + fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() { + let toml = format!( + "{MINIMAL_REQUIRED}\n\ + [feature.memory.profile]\n\ + enabled = true\n\ + staging_tools = false\n\n\ + [feature.memory.profile.resident]\n\ + inject_summary = false\n\n\ + [feature.memory.profile.extraction]\n\ + enabled = true\n\ + threshold = 42000\n\ + worker_max_turns = 2\n\n\ + [feature.memory.workspace_settings]\n\ + workspace_id = \"workspace-1\"\n\ + settings_revision = 7\n\ + language = \"日本語\"\n" + ); let manifest = WorkerManifest::from_toml(&toml).unwrap(); - let mem = manifest.memory.expect("memory section parsed"); - assert!(mem.workspace_root.is_none()); - assert_eq!(mem.inject_summary, None); - } - - #[test] - fn memory_section_with_inject_summary_false() { - let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n"); - let manifest = WorkerManifest::from_toml(&toml).unwrap(); - let mem = manifest.memory.unwrap(); - assert_eq!(mem.inject_summary, Some(false)); - } - - #[test] - fn memory_section_with_explicit_root() { - let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n"); - let manifest = WorkerManifest::from_toml(&toml).unwrap(); - let mem = manifest.memory.unwrap(); + assert!(manifest.feature.memory.profile.enabled); + assert!(!manifest.feature.memory.profile.resident.inject_summary); assert_eq!( - mem.workspace_root.unwrap(), - std::path::PathBuf::from("/some/where") + manifest.feature.memory.profile.extraction.threshold, + Some(42_000) + ); + assert_eq!( + manifest + .feature + .memory + .workspace_settings() + .unwrap() + .language, + "日本語" + ); + } + + #[test] + fn resolved_memory_execution_validation_fails_closed() { + let snapshot = WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 1, + language: "English".to_string(), + }; + let mut enabled = ResolvedMemoryFeatureConfig::default(); + enabled.profile.enabled = true; + assert!(enabled.validate_execution().is_err()); + enabled.bind_workspace_settings(snapshot.clone()).unwrap(); + assert!(enabled.validate_execution().is_ok()); + + let mut disabled = ResolvedMemoryFeatureConfig::default(); + disabled.workspace_settings = Some(snapshot.clone()); + assert!(disabled.validate_execution().is_err()); + assert!(disabled.bind_workspace_settings(snapshot).is_err()); + } + + #[test] + fn current_manifest_rejects_legacy_top_level_memory_authority() { + let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n"); + assert!(WorkerManifest::from_toml(&toml).is_err()); + } + + #[test] + fn persisted_manifest_adapter_migrates_legacy_memory_authority() { + let mut manifest = + serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap(); + manifest["feature"]["memory"] = serde_json::json!({ + "enabled": true, + "staging": true, + }); + manifest["memory"] = serde_json::json!({ + "workspace_root": "/discarded", + "query_result_limit": 999, + "inject_summary": false, + "workspace_id": "workspace-1", + "settings_revision": 9, + "language": "Français", + "extract_threshold": 1234, + "extract_worker_max_turns": 3, + "consolidation_threshold_files": 99, + }); + + let migrated = read_persisted_worker_manifest_snapshot(manifest).unwrap(); + assert!(migrated.feature.memory.profile.enabled); + assert!(migrated.feature.memory.profile.staging_tools); + assert!(!migrated.feature.memory.profile.resident.inject_summary); + assert_eq!( + migrated.feature.memory.profile.extraction.threshold, + Some(1234) + ); + assert_eq!( + migrated + .feature + .memory + .workspace_settings() + .unwrap() + .language, + "Français" + ); + let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap(); + assert_eq!(current["schema_version"], 2); + assert!(current["manifest"].get("memory").is_none()); + } + + #[test] + fn persisted_manifest_adapter_rejects_mixed_or_future_authority() { + let manifest = + serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap(); + let mut mixed = manifest.clone(); + mixed["feature"]["memory"] = serde_json::json!({ "enabled": true, "profile": {} }); + mixed["memory"] = serde_json::json!({}); + assert!(read_persisted_worker_manifest_snapshot(mixed).is_err()); + assert!( + read_persisted_worker_manifest_snapshot(serde_json::json!({ + "schema_version": 3, + "manifest": manifest, + })) + .is_err() ); } @@ -1291,14 +1532,6 @@ model_id = "claude-sonnet-4-20250514" )); } - #[test] - fn memory_section_with_language() { - let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n"); - let manifest = WorkerManifest::from_toml(&toml).unwrap(); - let mem = manifest.memory.unwrap(); - assert_eq!(mem.language.as_deref(), Some("Japanese")); - } - #[test] fn reject_unknown_scheme() { let toml = diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 2c8e639f..40bc0e1a 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -20,9 +20,9 @@ use crate::config::{ use crate::model::{AuthRef, ModelManifest}; use crate::plugin::PluginConfig; use crate::{ - EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError, - ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, - WorkerMetaConfig, paths, + EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig, + ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig, + paths, }; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; @@ -185,7 +185,7 @@ pub fn validate_profile_execution_target( if feature.manage_workdir.enabled { requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); } - if feature.memory.enabled || feature.memory.staging { + if feature.memory.profile.enabled || feature.memory.profile.staging_tools { requirements.insert(WorkspaceAuthorityRequirement::Memory); } if feature.merge_request.show @@ -642,7 +642,6 @@ fn resolve_profile_value( mcp: profile.mcp, compaction, web: profile.web, - memory: profile.memory.map(Into::into), skills: profile.skills, }; let config = @@ -663,51 +662,6 @@ fn resolve_profile_value( }) } -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -struct ProfileMemoryConfig { - #[serde(default)] - workspace_root: Option, - #[serde(default)] - query_result_limit: Option, - #[serde(default)] - query_excerpt_lines: Option, - #[serde(default)] - inject_summary: Option, - #[serde(default)] - extract_model: Option, - #[serde(default)] - extract_threshold: Option, - #[serde(default)] - extract_worker_max_turns: Option, - #[serde(default)] - consolidation_model: Option, - #[serde(default)] - consolidation_threshold_files: Option, - #[serde(default)] - consolidation_threshold_bytes: Option, -} - -impl From for MemoryConfig { - fn from(profile: ProfileMemoryConfig) -> Self { - Self { - workspace_root: profile.workspace_root, - query_result_limit: profile.query_result_limit, - query_excerpt_lines: profile.query_excerpt_lines, - inject_summary: profile.inject_summary, - workspace_id: None, - settings_revision: None, - language: None, - extract_model: profile.extract_model, - extract_threshold: profile.extract_threshold, - extract_worker_max_turns: profile.extract_worker_max_turns, - consolidation_model: profile.consolidation_model, - consolidation_threshold_files: profile.consolidation_threshold_files, - consolidation_threshold_bytes: profile.consolidation_threshold_bytes, - } - } -} - #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct ProfileConfig { @@ -738,8 +692,6 @@ struct ProfileConfig { #[serde(default)] web: Option, #[serde(default)] - memory: Option, - #[serde(default)] skills: Option, } @@ -940,12 +892,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> { .map_err(|source| ProfileError::ProfileDeserialize { source })?; reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?; } - if let Some(memory) = &profile.memory - && let Some(root) = &memory.workspace_root - && root.is_absolute() - { - return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into())); - } if let Some(skills) = &profile.skills { for dir in &skills.directories { if dir.is_absolute() { @@ -1299,7 +1245,9 @@ mod tests { ("settings_revision", serde_json::json!(2)), ("language", serde_json::json!("Japanese")), ] { - let artifact = serde_json::json!({ "memory": { (field): value } }); + let artifact = serde_json::json!({ + "feature": { "memory": { (field): value } } + }); let error = resolve_profile_artifact_value( artifact, ProfileSource::Registry { @@ -1351,7 +1299,7 @@ mod tests { assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| { rule.permission == protocol::Permission::Write && rule.target == tmp.path() })); - assert!(!resolved.manifest.feature.memory.enabled); + assert!(!resolved.manifest.feature.memory.profile.enabled); assert!(!resolved.manifest.feature.ticket.enabled); assert!(!resolved.manifest.feature.objective.enabled); assert!(!resolved.manifest.feature.flow.enabled); @@ -1630,7 +1578,7 @@ enabled = false .unwrap(); assert_eq!(resolved.manifest.worker.name, "runtime-worker"); assert!(resolved.manifest.feature.task.enabled); - assert!(!resolved.manifest.feature.memory.enabled); + assert!(!resolved.manifest.feature.memory.profile.enabled); assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.sub_worker.enabled); assert!(resolved.manifest.feature.ticket.enabled); diff --git a/crates/memory/src/backend.rs b/crates/memory/src/backend.rs index df8c548b..66a5b73a 100644 --- a/crates/memory/src/backend.rs +++ b/crates/memory/src/backend.rs @@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct MemoryConsolidateStagingOperation { #[serde(default)] pub force: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub threshold_files: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub threshold_bytes: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -450,10 +447,21 @@ mod tests { use super::*; use crate::extract::{CandidateKind, ExtractedCandidate}; + #[test] + fn consolidation_operation_rejects_caller_owned_thresholds() { + let error = + serde_json::from_value::(serde_json::json!({ + "force": false, + "threshold_files": 1, + })) + .unwrap_err(); + assert!(error.to_string().contains("threshold_files")); + } + #[test] fn staging_list_read_close_records_reason_and_deletes_candidate() { let temp = tempfile::tempdir().unwrap(); - let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path()); + let layout = WorkspaceLayout::resolve(temp.path()); let source = SourceRef { segment_id: "segment-1".into(), range: [0, 1], diff --git a/crates/memory/src/consolidate/staging.rs b/crates/memory/src/consolidate/staging.rs index 66ce4218..6449d63a 100644 --- a/crates/memory/src/consolidate/staging.rs +++ b/crates/memory/src/consolidate/staging.rs @@ -21,8 +21,7 @@ pub struct StagingEntry { pub id: Uuid, pub path: PathBuf, pub record: StagingRecord, - /// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`) - /// に使う。 + /// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。 pub bytes: u64, } diff --git a/crates/memory/src/workspace.rs b/crates/memory/src/workspace.rs index f4058994..3daa21b8 100644 --- a/crates/memory/src/workspace.rs +++ b/crates/memory/src/workspace.rs @@ -70,24 +70,12 @@ impl WorkspaceLayout { Self { root: root.into() } } - /// Resolve a layout from a `MemoryConfig`. + /// Resolve a layout from the nearest Memory marker. /// - /// An explicit `memory.workspace_root` is honored exactly. Without an - /// explicit root, resolution searches `default_root` and its ancestors for - /// the nearest `.yoi/memory` directory. This keeps child worktrees that - /// contain `.yoi` project records such as tickets from - /// becoming independent memory roots merely because they contain `.yoi`. - /// - /// If no memory marker exists, this falls back to `default_root` because - /// existing call sites require a concrete layout. That fallback is a - /// no-marker compatibility path, not a `.yoi` marker interpretation; it - /// must not be used as evidence that `.yoi` alone enables repo-local - /// memory. - pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self { - if let Some(root) = &cfg.workspace_root { - return Self::new(root.clone()); - } - + /// Resolution searches `default_root` and its ancestors for the nearest + /// `.yoi/memory` directory. This legacy local-storage helper owns its path + /// policy directly; resolved Worker Manifests do not carry storage paths. + pub fn resolve(default_root: &Path) -> Self { let root = find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf()); Self::new(root) @@ -335,16 +323,6 @@ mod tests { assert!(matches!(err, LintError::InvalidPath(_))); } - #[test] - fn resolve_uses_workspace_root_when_set() { - let cfg = manifest::MemoryConfig { - workspace_root: Some(PathBuf::from("/explicit")), - ..Default::default() - }; - let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback")); - assert_eq!(layout.root(), Path::new("/explicit")); - } - #[test] fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() { let tmp = TempDir::new().unwrap(); @@ -353,8 +331,7 @@ mod tests { std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap(); std::fs::create_dir_all(&child).unwrap(); - let cfg = manifest::MemoryConfig::default(); - let layout = WorkspaceLayout::resolve(&cfg, &child); + let layout = WorkspaceLayout::resolve(&child); assert_eq!(layout.root(), workspace.as_path()); } @@ -366,8 +343,7 @@ mod tests { std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap(); std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap(); - let cfg = manifest::MemoryConfig::default(); - let layout = WorkspaceLayout::resolve(&cfg, &child); + let layout = WorkspaceLayout::resolve(&child); assert_eq!(layout.root(), workspace.as_path()); } @@ -381,8 +357,7 @@ mod tests { assert_eq!(find_memory_marker_root(&child), None); - let cfg = manifest::MemoryConfig::default(); - let layout = WorkspaceLayout::resolve(&cfg, &child); + let layout = WorkspaceLayout::resolve(&child); assert_eq!(layout.root(), child.as_path()); } } diff --git a/crates/tui/src/setup_model.rs b/crates/tui/src/setup_model.rs index 951b434b..7bf733d2 100644 --- a/crates/tui/src/setup_model.rs +++ b/crates/tui/src/setup_model.rs @@ -228,7 +228,7 @@ worker_context_max_tokens = 100000 enabled = true [feature.memory] -enabled = true +enabled = false [feature.web] enabled = true @@ -241,11 +241,6 @@ enabled = true authoring = true thread = true -[memory] -extract_threshold = 50000 -consolidation_threshold_files = 5 -consolidation_threshold_bytes = 50000 - [web] enabled = true diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 120fb558..fdb33c1d 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -759,8 +759,8 @@ fn migrate_worker_aggregate_document( .get_mut("resolved_manifest_snapshot") .filter(|snapshot| !snapshot.is_null()) { - let manifest: manifest::WorkerManifest = - serde_json::from_value(snapshot.clone()).map_err(|error| { + let mut manifest = manifest::read_persisted_worker_manifest_snapshot(snapshot.clone()) + .map_err(|error| { runtime_store_corrupt( metadata_path, format!("decode Worker aggregate resolved manifest snapshot: {error}"), @@ -775,20 +775,14 @@ fn migrate_worker_aggregate_document( ), )); } - snapshot - .as_object_mut() - .and_then(|manifest| manifest.get_mut("worker")) - .and_then(serde_json::Value::as_object_mut) - .ok_or_else(|| { + manifest.worker.name = expected_name.clone(); + *snapshot = + manifest::write_persisted_worker_manifest_snapshot(&manifest).map_err(|error| { runtime_store_corrupt( metadata_path, - "Worker aggregate resolved manifest is missing worker metadata".to_string(), + format!("encode migrated Worker aggregate resolved manifest: {error}"), ) - })? - .insert( - "name".to_string(), - serde_json::Value::String(expected_name.clone()), - ); + })?; } metadata.insert( "worker_name".to_string(), @@ -809,8 +803,8 @@ fn migrate_worker_aggregate_document( )); } if let Some(snapshot) = metadata.resolved_manifest_snapshot { - let manifest: manifest::WorkerManifest = - serde_json::from_value(snapshot).map_err(|error| { + let manifest = + manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|error| { runtime_store_corrupt( metadata_path, format!("decode migrated Worker aggregate resolved manifest: {error}"), diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 15d34050..95d0c941 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -746,9 +746,15 @@ fn bind_workspace_memory_settings( )); } manifest + .feature .memory - .get_or_insert_with(manifest::MemoryConfig::default) - .bind_workspace_settings(snapshot); + .bind_workspace_settings(snapshot.clone()) + .map_err(str::to_string)?; + manifest + .feature + .memory + .validate_execution() + .map_err(str::to_string)?; Ok(()) } @@ -759,10 +765,18 @@ fn validate_worker_memory_settings( let Some(expected) = request.memory_settings.as_ref() else { return Ok(()); }; - let actual = manifest + manifest + .feature .memory - .as_ref() - .and_then(manifest::MemoryConfig::workspace_settings) + .validate_execution() + .map_err(str::to_string)?; + if !manifest.feature.memory.profile.enabled { + return Ok(()); + } + let actual = manifest + .feature + .memory + .workspace_settings() .ok_or_else(|| { "Workspace Worker restored without its bound Memory settings snapshot".to_string() })?; @@ -3165,7 +3179,7 @@ mod tests { Some(session_store::WorkerActiveSegmentRef::pending_segment( session_id, )), - Some(serde_json::to_value(&manifest).unwrap()), + Some(manifest::write_persisted_worker_manifest_snapshot(&manifest).unwrap()), ) .unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 8355f7ee..fb93f785 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -901,27 +901,39 @@ pub(crate) fn wire_event_bridges_on_engine( // per-item commit channel is wired at the top of this function. } -fn add_memory_lifecycle_if_configured( +fn add_memory_tools_if_configured( registry: &mut FeatureRegistryBuilder, - config: Option, - workspace_bound: bool, - build: impl FnOnce(manifest::MemoryConfig) -> std::io::Result, + config: &manifest::ResolvedMemoryFeatureConfig, + build: impl FnOnce() -> std::io::Result, ) -> std::io::Result where M: crate::feature::FeatureModule + 'static, { - let Some(config) = config else { - return Ok(false); - }; - if config.workspace_settings().is_none() { - if workspace_bound { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Workspace-bound Memory requires a Backend-authored settings snapshot", - )); - } + if !config.profile.enabled { return Ok(false); } + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + registry.add_module(build()?); + Ok(true) +} + +fn add_memory_lifecycle_if_configured( + registry: &mut FeatureRegistryBuilder, + config: manifest::ResolvedMemoryFeatureConfig, + lifecycle_enabled: bool, + build: impl FnOnce(manifest::ResolvedMemoryFeatureConfig) -> std::io::Result, +) -> std::io::Result +where + M: crate::feature::FeatureModule + 'static, +{ + if !lifecycle_enabled || !config.profile.enabled || !config.profile.extraction.enabled { + return Ok(false); + } + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; registry.add_module(build(config)?); Ok(true) } @@ -962,7 +974,7 @@ where let local_filesystem = worker.local_working_directory().cloned(); let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone()); let task_feature = worker.task_feature(); - let memory_config = worker.manifest().memory.clone(); + let memory_config = feature_config.memory.clone(); let web_config = worker.manifest().web.clone(); let mcp_config = worker.manifest().mcp.clone(); let spawner_name = worker.manifest().worker.name.clone(); @@ -1019,13 +1031,23 @@ where let worker_enabled = feature_config.worker.enabled; let sub_worker_enabled = feature_config.sub_worker.enabled; let mut feature_registry = FeatureRegistryBuilder::new(); + add_memory_tools_if_configured(&mut feature_registry, &memory_config, || { + let workspace_client = worker.workspace_client_handle(); + if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Memory tools require Backend Workspace API authority", + )); + } + Ok(crate::feature::builtin::memory::MemoryToolsFeature::new( + workspace_client, + memory_config.profile.staging_tools, + )) + })?; add_memory_lifecycle_if_configured( &mut feature_registry, - worker - .manifest_lifecycle_features_enabled() - .then(|| memory_config.clone()) - .flatten(), - spawner_workspace_context.workspace_id().is_some(), + memory_config.clone(), + worker.manifest_lifecycle_features_enabled(), |config| { let workspace_client = worker.workspace_client_handle(); if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { @@ -1202,38 +1224,6 @@ where } } - // Memory tools require explicit feature exposure. Workspace memory access - // is authority-bound to the Backend Workspace API; the Worker must not - // register local filesystem memory tools even when it has local cwd/root - // authority for shell/file tools. - if feature_config.memory.enabled { - let _mem = memory_config.as_ref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "[feature.memory].enabled = true requires a [memory] configuration section", - ) - })?; - if workspace_client.is_available() && workspace_client.workspace_id().is_some() { - let definitions = if feature_config.memory.staging { - crate::feature::builtin::memory::workspace_http_memory_consolidation_tools( - workspace_client.clone(), - ) - } else { - crate::feature::builtin::memory::workspace_http_memory_tools( - workspace_client.clone(), - ) - }; - for definition in definitions { - engine.register_tool(definition); - } - } else { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "memory tools require Backend Workspace API authority", - )); - } - } - let mut observation_providers: Vec< Arc, > = Vec::new(); @@ -2169,7 +2159,7 @@ mod tests { use tokio::net::UnixListener; #[test] - fn memory_lifecycle_registration_requires_bound_workspace_memory_config() { + fn memory_feature_registration_requires_bound_workspace_memory_config() { #[derive(Clone)] struct TestMemoryLifecycleModule; @@ -2189,16 +2179,43 @@ mod tests { } } + let mut registry = FeatureRegistryBuilder::new(); + let installed = add_memory_tools_if_configured::( + &mut registry, + &manifest::ResolvedMemoryFeatureConfig::default(), + || panic!("disabled Memory must not construct its tools Feature"), + ) + .unwrap(); + assert!(!installed); + + let mut missing_snapshot = manifest::ResolvedMemoryFeatureConfig::default(); + missing_snapshot.profile.enabled = true; + let error = add_memory_tools_if_configured::( + &mut registry, + &missing_snapshot, + || panic!("invalid Memory config must fail before tools Feature construction"), + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("requires trusted Workspace settings") + ); + let mut registry = FeatureRegistryBuilder::new(); let configured = std::cell::Cell::new(false); - let mut memory_config = manifest::MemoryConfig::default(); - memory_config.bind_workspace_settings(&manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: "workspace-1".to_string(), - settings_revision: 1, - language: "English".to_string(), - }); + let mut memory_config = manifest::ResolvedMemoryFeatureConfig::default(); + memory_config.profile.enabled = true; + memory_config.profile.extraction.enabled = true; + memory_config + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 1, + language: "English".to_string(), + }) + .unwrap(); let installed = - add_memory_lifecycle_if_configured(&mut registry, Some(memory_config), true, |_| { + add_memory_lifecycle_if_configured(&mut registry, memory_config, true, |_| { configured.set(true); Ok(TestMemoryLifecycleModule) }) @@ -2209,25 +2226,38 @@ mod tests { let mut registry = FeatureRegistryBuilder::new(); let installed = add_memory_lifecycle_if_configured::( &mut registry, - None, - false, + manifest::ResolvedMemoryFeatureConfig::default(), + true, |_| panic!("disabled Memory must not construct its lifecycle Feature"), ) .unwrap(); assert!(!installed); + let mut lifecycle_disabled = manifest::ResolvedMemoryFeatureConfig::default(); + lifecycle_disabled.profile.enabled = true; + lifecycle_disabled.profile.extraction.enabled = true; + lifecycle_disabled + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 1, + language: "English".to_string(), + }) + .unwrap(); let installed = add_memory_lifecycle_if_configured::( &mut registry, - Some(manifest::MemoryConfig::default()), + lifecycle_disabled, false, - |_| panic!("Memory without a Backend-authored settings snapshot must stay disabled"), + |_| panic!("disabled lifecycle must not construct its Feature"), ) .unwrap(); assert!(!installed); + let mut missing_snapshot = manifest::ResolvedMemoryFeatureConfig::default(); + missing_snapshot.profile.enabled = true; + missing_snapshot.profile.extraction.enabled = true; let error = add_memory_lifecycle_if_configured::( &mut registry, - Some(manifest::MemoryConfig::default()), + missing_snapshot, true, |_| panic!("invalid Workspace Memory config must fail before Feature construction"), ) @@ -2235,7 +2265,7 @@ mod tests { assert!( error .to_string() - .contains("Backend-authored settings snapshot") + .contains("requires trusted Workspace settings") ); } diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index 595eebc1..62403d1a 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -18,6 +18,10 @@ use schemars::JsonSchema; use serde::de::DeserializeOwned; use serde_json::json; +use crate::feature::{ + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, + ToolDeclaration, +}; use crate::worker::{ WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, }; @@ -338,6 +342,44 @@ fn query_schema() -> serde_json::Value { }) } +#[derive(Clone)] +pub struct MemoryToolsFeature { + tools: Vec, +} + +impl MemoryToolsFeature { + pub fn new(client: Arc, staging_tools: bool) -> Self { + let tools = if staging_tools { + workspace_http_memory_consolidation_tools(client) + } else { + workspace_http_memory_tools(client) + }; + Self { tools } + } +} + +impl FeatureModule for MemoryToolsFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = FeatureDescriptor::builtin("memory", "Memory") + .with_description("Workspace Memory document, query, and staging tools."); + for tool in &self.tools { + let (meta, _) = tool(); + descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description)); + } + descriptor + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + for tool in &self.tools { + let (meta, _) = tool(); + context + .tools() + .register(ToolContribution::new(meta.name, tool.clone()))?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -368,6 +410,20 @@ mod tests { .input_schema } + #[test] + fn memory_feature_owns_normal_and_staging_tool_surfaces() { + let normal = MemoryToolsFeature::new(test_client(), false); + let normal_names = tool_names(normal.tools); + assert!(normal_names.contains(&"MemoryQuery".to_string())); + assert!(!normal_names.contains(&"MemoryStagingList".to_string())); + + let staging = MemoryToolsFeature::new(test_client(), true); + assert_eq!(staging.descriptor().id.as_str(), "builtin:memory"); + let staging_names = tool_names(staging.tools); + assert!(staging_names.contains(&"MemoryQuery".to_string())); + assert!(staging_names.contains(&"MemoryStagingList".to_string())); + } + #[test] fn normal_workspace_memory_tools_do_not_include_staging_tools() { let names = tool_names(workspace_http_memory_tools(test_client())); diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index b7083428..9c601263 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -52,7 +52,7 @@ pub(crate) struct MemoryLifecycleFeature { #[derive(Clone)] struct MemoryLifecycleTask { - config: manifest::MemoryConfig, + config: manifest::ResolvedMemoryFeatureConfig, capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, workspace_client: Arc, @@ -66,7 +66,7 @@ struct MemoryLifecycleTask { impl MemoryLifecycleFeature { #[allow(clippy::too_many_arguments)] pub(crate) fn new( - config: manifest::MemoryConfig, + config: manifest::ResolvedMemoryFeatureConfig, capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, workspace_client: Arc, @@ -132,7 +132,9 @@ impl MemoryLifecycleTask { memory::audit::AuditWorker::MemoryExtract, memory::audit::AuditTrigger::TokenThreshold, self.config - .extract_model + .profile + .extraction + .model .as_ref() .or(Some(&self.manifest.model)) .map(model_audit_from_manifest), @@ -188,7 +190,9 @@ impl MemoryLifecycleTask { }; let Some(threshold) = self .config - .extract_threshold + .profile + .extraction + .threshold .filter(|threshold| *threshold > 0) else { audit @@ -283,7 +287,7 @@ impl MemoryLifecycleTask { source, audit.run_id.to_string(), ); - let client = if let Some(model) = self.config.extract_model.as_ref() { + let client = if let Some(model) = self.config.profile.extraction.model.as_ref() { match crate::model_client::build_client(model) { Ok(client) => client, Err(error) => { @@ -321,7 +325,7 @@ impl MemoryLifecycleTask { } }; let mut manifest = self.manifest.clone(); - if let Some(model) = self.config.extract_model.clone() { + if let Some(model) = self.config.profile.extraction.model.clone() { manifest.model = model; } @@ -349,7 +353,9 @@ impl MemoryLifecycleTask { cache_key: Some(capture.segment_id.clone()), max_turns: self .config - .extract_worker_max_turns + .profile + .extraction + .worker_max_turns .or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS), engine_configurator: None, features, @@ -493,36 +499,13 @@ impl MemoryLifecycleTask { let audit = WorkerAuditBase::new( memory::audit::AuditWorker::MemoryConsolidation, memory::audit::AuditTrigger::StagingBacklog, - self.config - .consolidation_model - .as_ref() - .or(Some(&self.manifest.model)) - .map(model_audit_from_manifest), + Some(model_audit_from_manifest(&self.manifest.model)), ) .with_memory_settings(&self.config); - let Some((threshold_files, threshold_bytes)) = consolidation_thresholds(&self.config) - else { - audit - .emit( - self.workspace_client.as_ref(), - self.event_tx.as_ref(), - memory::audit::WorkerLifecycleStatus::Skipped, - "consolidation_threshold_disabled", - None, - None, - None, - ) - .await; - return; - }; match self .workspace_client .request_memory_staging_consolidation( - memory::backend::MemoryConsolidateStagingOperation { - force: false, - threshold_files, - threshold_bytes, - }, + memory::backend::MemoryConsolidateStagingOperation { force: false }, ) .await { @@ -646,22 +629,6 @@ fn extract_pointer( Ok(pointer) } -fn consolidation_thresholds( - config: &manifest::MemoryConfig, -) -> Option<(Option, Option)> { - let threshold_files = config - .consolidation_threshold_files - .filter(|threshold| *threshold > 0); - let threshold_bytes = config - .consolidation_threshold_bytes - .filter(|threshold| *threshold > 0); - if threshold_files.is_none() && threshold_bytes.is_none() { - None - } else { - Some((threshold_files, threshold_bytes)) - } -} - fn extraction_run_eligible(exit: CommittedRunExit) -> bool { exit == CommittedRunExit::Finished } @@ -688,12 +655,17 @@ fn tokens_since_pointer( fn extraction_threshold_reached( capture: &CommittedSessionCapture, pointer: Option<&memory::ExtractPointerPayload>, - config: &manifest::MemoryConfig, + config: &manifest::ResolvedMemoryFeatureConfig, ) -> bool { if capture.history.is_empty() { return false; } - let Some(threshold) = config.extract_threshold.filter(|threshold| *threshold > 0) else { + let Some(threshold) = config + .profile + .extraction + .threshold + .filter(|threshold| *threshold > 0) + else { return false; }; tokens_since_pointer(capture, pointer) >= threshold @@ -723,7 +695,7 @@ impl WorkerAuditBase { } } - fn with_memory_settings(mut self, config: &manifest::MemoryConfig) -> Self { + fn with_memory_settings(mut self, config: &manifest::ResolvedMemoryFeatureConfig) -> Self { self.memory_settings = config .workspace_settings() @@ -1014,16 +986,18 @@ permission = "write" .unwrap() } - fn test_config() -> manifest::MemoryConfig { - let mut config = manifest::MemoryConfig { - extract_threshold: Some(1), - ..Default::default() - }; - config.bind_workspace_settings(&manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: "workspace-1".to_string(), - settings_revision: 1, - language: "English".to_string(), - }); + fn test_config() -> manifest::ResolvedMemoryFeatureConfig { + let mut config = manifest::ResolvedMemoryFeatureConfig::default(); + config.profile.enabled = true; + config.profile.extraction.enabled = true; + config.profile.extraction.threshold = Some(1); + config + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 1, + language: "English".to_string(), + }) + .unwrap(); config } @@ -1282,30 +1256,30 @@ permission = "write" } #[tokio::test] - async fn lifecycle_task_requests_backend_consolidation_from_configured_threshold() { + async fn lifecycle_task_requests_backend_owned_consolidation_eligibility() { let client = ScriptClient::new(Vec::new()); let extension_writes = Arc::new(Mutex::new(Vec::new())); let (event_tx, _) = broadcast::channel(16); let workspace_client = Arc::new(RecordingWorkspaceClient::default()); let mut interrupted = capture(2, 250); interrupted.run_exit = CommittedRunExit::Interrupted; - let mut task = test_task( + let task = test_task( interrupted, Box::new(client), extension_writes, event_tx, workspace_client.clone(), ); - task.config.consolidation_threshold_files = Some(3); run_background_task(task).await; let requests = workspace_client.requests.lock().unwrap(); assert!( requests.iter().any(|request| { request.path.contains("memory") - && request.body.as_deref().is_some_and(|body| { - body.contains("\"threshold_files\":3") && body.contains("\"force\":false") - }) + && request + .body + .as_deref() + .is_some_and(|body| body == "{\"force\":false}") }), "recorded requests: {requests:?}" ); @@ -1349,17 +1323,6 @@ permission = "write" } } - #[test] - fn consolidation_thresholds_enable_backend_request_on_either_limit() { - let mut config = manifest::MemoryConfig::default(); - assert_eq!(consolidation_thresholds(&config), None); - config.consolidation_threshold_files = Some(3); - assert_eq!(consolidation_thresholds(&config), Some((Some(3), None))); - config.consolidation_threshold_files = None; - config.consolidation_threshold_bytes = Some(4096); - assert_eq!(consolidation_thresholds(&config), Some((None, Some(4096)))); - } - #[test] fn interrupted_parent_run_is_not_extraction_eligible() { assert!(extraction_run_eligible(CommittedRunExit::Finished)); @@ -1412,8 +1375,8 @@ permission = "write" #[test] fn threshold_uses_committed_usage_after_pointer() { let capture = capture(2, 250); - let mut config = manifest::MemoryConfig::default(); - config.extract_threshold = Some(1); + let mut config = manifest::ResolvedMemoryFeatureConfig::default(); + config.profile.extraction.threshold = Some(1); assert!(extraction_threshold_reached( &capture, Some(&memory::ExtractPointerPayload { @@ -1500,8 +1463,8 @@ permission = "write" #[test] fn empty_capture_never_schedules_extraction() { let capture = capture(0, 500); - let mut config = manifest::MemoryConfig::default(); - config.extract_threshold = Some(1); + let mut config = manifest::ResolvedMemoryFeatureConfig::default(); + config.profile.extraction.threshold = Some(1); assert!(!extraction_threshold_reached(&capture, None, &config)); } } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 8c3ee231..c2498874 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -425,6 +425,8 @@ impl Tool for SubWorkerSpawnTool { WorkerManifestConfig::resolution_defaults().merge(child_config), ) .map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?; + bind_child_memory_settings(&self.spawner_manifest, &mut child_manifest) + .map_err(ToolError::ExecutionFailed)?; // Delegated children stay bound to their scoped session and cannot use // Workspace attachment tools to replace it with parent-level authority. child_manifest.feature.manage_workdir.enabled = false; @@ -827,6 +829,33 @@ fn profile_error_with_available(error: ProfileError, available: &AvailableProfil ) } +fn bind_child_memory_settings( + parent: &manifest::WorkerManifest, + child: &mut manifest::WorkerManifest, +) -> Result<(), String> { + if !child.feature.memory.profile.enabled { + return child + .feature + .memory + .validate_execution() + .map_err(str::to_string); + } + let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| { + "enabled child Memory feature requires the parent's trusted Workspace settings snapshot" + .to_string() + })?; + child + .feature + .memory + .bind_workspace_settings(workspace_settings) + .map_err(str::to_string)?; + child + .feature + .memory + .validate_execution() + .map_err(str::to_string) +} + fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfig { WorkerManifestConfig { worker: WorkerMetaConfig { @@ -894,7 +923,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi model: c.model.clone(), }), web: manifest.web.clone(), - memory: manifest.memory.clone(), skills: manifest.skills.clone(), } } @@ -1091,10 +1119,7 @@ enabled = true thread = true [feature.memory] -enabled = true - -[memory] -extract_threshold = 4000 +enabled = false "#; #[tokio::test] @@ -1526,6 +1551,33 @@ extract_threshold = 4000 .unwrap() } + #[test] + fn child_memory_inherits_only_the_parents_trusted_settings_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let mut parent = parent_manifest(temp.path(), None); + parent.feature.memory.profile.enabled = true; + parent + .feature + .memory + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-1".to_string(), + settings_revision: 4, + language: "日本語".to_string(), + }) + .unwrap(); + let mut child = parent.clone(); + child.feature.memory.workspace_settings = None; + + bind_child_memory_settings(&parent, &mut child).unwrap(); + assert_eq!( + child.feature.memory.workspace_settings(), + parent.feature.memory.workspace_settings() + ); + + child.feature.memory.profile.enabled = false; + assert!(bind_child_memory_settings(&parent, &mut child).is_err()); + } + fn write_project_profile_registry( project: &Path, default: Option<&str>, diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 483f508a..76888eff 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2478,11 +2478,10 @@ impl Worker { ) }); if is_memory_consolidation { - let memory_config = self.manifest.memory.as_ref().ok_or_else(|| { - WorkerError::InvalidState( - "Memory consolidation Worker has no Memory configuration".to_string(), - ) - })?; + let memory_config = &self.manifest.feature.memory; + memory_config + .validate_execution() + .map_err(|message| WorkerError::InvalidState(message.to_string()))?; let language = memory_language(memory_config)?; let rendered = self .prompts @@ -2517,11 +2516,8 @@ impl Worker { } } let inject_summary = self.inject_resident_summary - && self - .manifest - .memory - .as_ref() - .is_some_and(|m| m.inject_summary.unwrap_or(true)); + && self.manifest.feature.memory.profile.enabled + && self.manifest.feature.memory.profile.resident.inject_summary; let resident_summary: Option = if inject_summary { match self.resident_summary_from_workspace_authority().await { Ok(summary) => summary, @@ -4552,7 +4548,7 @@ impl Worker { } } -fn memory_language(config: &manifest::MemoryConfig) -> Result { +fn memory_language(config: &manifest::ResolvedMemoryFeatureConfig) -> Result { config .workspace_settings() .map(|snapshot| snapshot.language) @@ -5426,7 +5422,8 @@ fn worker_metadata_for_manifest( metadata = metadata.with_workspace_root(local_workspace_root.to_path_buf()); } if should_persist_resolved_manifest_snapshot(manifest) { - metadata.resolved_manifest_snapshot = serde_json::to_value(manifest).ok(); + metadata.resolved_manifest_snapshot = + manifest::write_persisted_worker_manifest_snapshot(manifest).ok(); } metadata } @@ -5439,10 +5436,20 @@ fn validate_workspace_memory_snapshot( let Some(workspace_id) = workspace_context.workspace_id() else { return Ok(()); }; - let snapshot = manifest + manifest + .feature .memory - .as_ref() - .and_then(manifest::MemoryConfig::workspace_settings) + .validate_execution() + .map_err(|message| { + WorkerError::InvalidState(format!("Workspace Worker {worker_name}: {message}")) + })?; + if !manifest.feature.memory.profile.enabled { + return Ok(()); + } + let snapshot = manifest + .feature + .memory + .workspace_settings() .ok_or_else(|| { WorkerError::InvalidState(format!( "Workspace Worker {worker_name} has no complete persisted Memory settings snapshot" @@ -5468,11 +5475,7 @@ fn validate_workspace_memory_snapshot( fn should_persist_resolved_manifest_snapshot(manifest: &WorkerManifest) -> bool { manifest.profile.is_some() || manifest.plugins.has_resolved_plan() - || manifest - .memory - .as_ref() - .and_then(manifest::MemoryConfig::workspace_settings) - .is_some() + || manifest.feature.memory.workspace_settings.is_some() } fn restore_manifest_from_worker_metadata_snapshot( @@ -5481,12 +5484,14 @@ fn restore_manifest_from_worker_metadata_snapshot( fallback: WorkerManifest, ) -> Result { match snapshot { - Some(snapshot) => serde_json::from_value(snapshot).map_err(|source| { - WorkerError::WorkerMetadataManifestSnapshot { - worker_name: worker_name.to_string(), - source, - } - }), + Some(snapshot) => { + manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|source| { + WorkerError::WorkerMetadataManifestSnapshot { + worker_name: worker_name.to_string(), + source, + } + }) + } None => Ok(fallback), } } @@ -6198,11 +6203,6 @@ fn prepare_worker_common_with_context_and_model_client( WorkerFilesystemAuthority::Local(LocalWorkingDirectory { root, cwd }) } }; - let mut scope_config = scope_config; - if let (Some(mem), Some(local)) = (manifest.memory.as_ref(), filesystem_authority.as_local()) { - let layout = memory::WorkspaceLayout::resolve(mem, &local.root); - scope_config.deny.extend(memory::deny_write_rules(&layout)); - } let scope = if scope_config.allow.is_empty() && filesystem_authority.as_local().is_none() { Scope::empty() } else { @@ -6292,8 +6292,7 @@ mod spawned_context_tests { std::fs::create_dir_all(&workspace_root).unwrap(); std::fs::create_dir_all(&cwd).unwrap(); - let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd); - manifest.memory = Some(manifest::MemoryConfig::default()); + let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd); let common = prepare_worker_common_with_context( &manifest, &PromptCatalogSource::builtins_only(), @@ -6327,8 +6326,7 @@ mod spawned_context_tests { let workspace_root = tmp.path().join("workspace-root"); let cwd = workspace_root.join("nested"); std::fs::create_dir_all(&cwd).unwrap(); - let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd); - manifest.memory = Some(manifest::MemoryConfig::default()); + let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd); let loader = PromptCatalogSource::builtins_only(); let workspace_id = WorkspaceId::new("ws-api-only").unwrap(); let common = prepare_worker_common_with_context( @@ -6535,7 +6533,7 @@ permission = "write" let restored = restore_manifest_from_worker_metadata_snapshot( "restore-scope", - Some(serde_json::to_value(&saved).unwrap()), + Some(manifest::write_persisted_worker_manifest_snapshot(&saved).unwrap()), current, ) .unwrap(); @@ -6590,24 +6588,26 @@ permission = "read" "#, ) .unwrap(); - manifest.memory = Some(manifest::MemoryConfig::default()); - manifest.memory.as_mut().unwrap().bind_workspace_settings( - &manifest::WorkspaceMemorySettingsSnapshot { + manifest.feature.memory.profile.enabled = true; + manifest + .feature + .memory + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-a".to_string(), settings_revision: 7, language: "Japanese".to_string(), - }, - ); + }) + .unwrap(); let metadata = worker_metadata_for_manifest(&manifest, None, None, None); - let restored: WorkerManifest = serde_json::from_value( + let restored = manifest::read_persisted_worker_manifest_snapshot( metadata .resolved_manifest_snapshot .expect("Memory settings require a resolved manifest snapshot"), ) .unwrap(); assert_eq!( - restored.memory.unwrap().workspace_settings(), + restored.feature.memory.workspace_settings(), Some(manifest::WorkspaceMemorySettingsSnapshot { workspace_id: "workspace-a".to_string(), settings_revision: 7, @@ -6638,7 +6638,7 @@ permission = "read" ); let mut missing = manifest.clone(); - missing.memory.as_mut().unwrap().settings_revision = None; + missing.feature.memory.workspace_settings = None; assert!( validate_workspace_memory_snapshot( "memory-snapshot", @@ -6715,7 +6715,7 @@ permission = "read" let snapshot = metadata .resolved_manifest_snapshot .expect("plugin-resolved manifest should be snapshotted"); - let restored: WorkerManifest = serde_json::from_value(snapshot).unwrap(); + let restored = manifest::read_persisted_worker_manifest_snapshot(snapshot).unwrap(); assert!(restored.profile.is_none()); assert_eq!(restored.plugins.resolved.len(), 1); @@ -8203,13 +8203,16 @@ mod build_summary_prompt_tests { }, profile: None, }); - let mut memory = manifest::MemoryConfig::default(); - memory.bind_workspace_settings(&manifest::WorkspaceMemorySettingsSnapshot { - workspace_id: "workspace-test".to_string(), - settings_revision: 3, - language: "Japanese".to_string(), - }); - manifest.memory = Some(memory); + let mut memory = manifest::ResolvedMemoryFeatureConfig::default(); + memory.profile.enabled = true; + memory + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: "workspace-test".to_string(), + settings_revision: 3, + language: "Japanese".to_string(), + }) + .unwrap(); + manifest.feature.memory = memory; let mut worker = Worker::new( manifest, Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), @@ -8235,7 +8238,7 @@ mod build_summary_prompt_tests { async fn render_system_prompt_with_summary( summary_doc: Option<&str>, - memory_config: Option, + memory_config: Option, resident_injection: bool, ) -> String { render_system_prompt_with_resident_sections( @@ -8249,7 +8252,7 @@ mod build_summary_prompt_tests { async fn render_system_prompt_with_resident_sections( summary_doc: Option<&str>, - memory_config: Option, + memory_config: Option, gates: ResidentInjectionGates, _unused: bool, ) -> String { @@ -8258,12 +8261,15 @@ mod build_summary_prompt_tests { let cwd = dir.path().join("workspace"); std::fs::create_dir_all(&cwd).unwrap(); let mut manifest = minimal_manifest(); - manifest.memory = memory_config.clone(); + manifest.feature.memory = memory_config.clone().unwrap_or_default(); + if memory_config.is_some() { + manifest.feature.memory.profile.enabled = true; + } let scope = Scope::writable(&cwd).unwrap(); let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); let workspace_context = if memory_config .as_ref() - .is_some_and(|cfg| cfg.inject_summary.unwrap_or(true)) + .is_some_and(|cfg| cfg.profile.resident.inject_summary) && gates.summary { stub_memory_backend_context(summary_doc.and_then(summary_content_for_backend)) @@ -8350,7 +8356,7 @@ mod build_summary_prompt_tests { async fn resident_summary_body_is_injected_without_frontmatter() { let rendered = render_system_prompt_with_summary( Some(&summary_doc("summary body for resident prompt\n")), - Some(manifest::MemoryConfig::default()), + Some(manifest::ResolvedMemoryFeatureConfig::default()), true, ) .await; @@ -8362,10 +8368,8 @@ mod build_summary_prompt_tests { #[tokio::test] async fn resident_summary_injection_can_be_disabled_by_manifest() { - let memory = manifest::MemoryConfig { - inject_summary: Some(false), - ..manifest::MemoryConfig::default() - }; + let mut memory = manifest::ResolvedMemoryFeatureConfig::default(); + memory.profile.resident.inject_summary = false; let rendered = render_system_prompt_with_summary( Some(&summary_doc("disabled summary body\n")), Some(memory), @@ -8377,7 +8381,7 @@ mod build_summary_prompt_tests { } #[tokio::test] - async fn resident_summary_is_absent_without_memory_config() { + async fn resident_summary_is_absent_when_memory_feature_is_disabled() { let rendered = render_system_prompt_with_summary( Some(&summary_doc("memory-disabled summary body\n")), None, @@ -8392,7 +8396,7 @@ mod build_summary_prompt_tests { async fn malformed_resident_summary_does_not_fail_render() { let rendered = render_system_prompt_with_summary( Some("---\nthis is not yaml: : :\n---\nbad summary body\n"), - Some(manifest::MemoryConfig::default()), + Some(manifest::ResolvedMemoryFeatureConfig::default()), true, ) .await; @@ -8405,7 +8409,7 @@ mod build_summary_prompt_tests { async fn resident_summary_gate_false_omits_only_summary() { let prompt = render_system_prompt_with_resident_sections( Some(&summary_doc("resident summary marker")), - Some(manifest::MemoryConfig::default()), + Some(manifest::ResolvedMemoryFeatureConfig::default()), ResidentInjectionGates { summary: false }, true, ) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 584cb96c..3599f26d 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -7993,17 +7993,15 @@ fn start_memory_staging_consolidation( total_bytes, }); } - let reached_files = operation - .threshold_files - .is_some_and(|threshold| candidate_count >= threshold); - let reached_bytes = operation - .threshold_bytes - .is_some_and(|threshold| total_bytes >= threshold); + const CONSOLIDATION_THRESHOLD_FILES: usize = 5; + const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000; + let reached_files = candidate_count >= CONSOLIDATION_THRESHOLD_FILES; + let reached_bytes = total_bytes >= CONSOLIDATION_THRESHOLD_BYTES; if !operation.force && !reached_files && !reached_bytes { return Ok(MemoryConsolidationOutput { status: "skipped_below_threshold".to_string(), summary: format!( - "Memory staging backlog has {candidate_count} candidate(s), {total_bytes} byte(s), below configured threshold." + "Memory staging backlog has {candidate_count} candidate(s), {total_bytes} byte(s), below Backend policy threshold." ), candidate_count, total_bytes, @@ -19921,11 +19919,7 @@ mod tests { let output = match start_memory_staging_consolidation( api, - MemoryConsolidateStagingOperation { - force: true, - threshold_files: None, - threshold_bytes: None, - }, + MemoryConsolidateStagingOperation { force: true }, ) { Ok(output) => output, Err(_) => panic!("unexpected ApiError from memory consolidation trigger"), @@ -19956,6 +19950,15 @@ mod tests { ) .unwrap(); + let below_threshold = start_memory_staging_consolidation( + api.clone(), + MemoryConsolidateStagingOperation { force: false }, + ) + .unwrap(); + assert_eq!(below_threshold.status, "skipped_below_threshold"); + assert_eq!(below_threshold.candidate_count, 1); + assert!(below_threshold.summary.contains("Backend policy threshold")); + let resolved_config_bundle = None; let existing = api .runtime @@ -20010,11 +20013,7 @@ mod tests { let second = match start_memory_staging_consolidation( api.clone(), - MemoryConsolidateStagingOperation { - force: true, - threshold_files: None, - threshold_bytes: None, - }, + MemoryConsolidateStagingOperation { force: true }, ) { Ok(output) => output, Err(_) => panic!("unexpected ApiError from second memory consolidation trigger"), diff --git a/docs/manifest.toml b/docs/manifest.toml index 28ce2e2f..2dc2386e 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -222,55 +222,38 @@ permission = "write" # # ref = "anthropic/claude-haiku-4-5" -# ===== [memory] ============================================================= -# Memory subsystem の opt-in。 -# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、 -# `/memory/` と `/` -# の通常 write を Worker 自体に対して deny する。 -# - セクションが *無い* … 何も起きない (legacy 動作)。 -# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化)。 -# [memory] +# ===== [feature.memory] ====================================================== +# Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では +# Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを +# `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが +# bindする信頼済み入力で、Profile・Browser・model入力から指定できない。 +# `profile.enabled = false` の場合、Memory tools、resident injection、extract、 +# consolidation requestをすべて無効にし、snapshotも保持しない。 # -# # 任意。デフォルト: Worker の pwd (構築時)。 -# # 必ず絶対パス (相対なら manifest base 起点で resolve)。 -# workspace_root = "/abs/path/to/workspace" +# [feature.memory.profile] +# enabled = true +# staging_tools = false # -# # 任意。デフォルト: tool 側既定 = 20。 -# # MemoryQuery / MemoryQuery が 1 回に返す最大件数。 -# query_result_limit = 20 +# [feature.memory.profile.resident] +# inject_summary = true # -# # 任意。デフォルト: tool 側既定 = 3。 -# # 各マッチ前後に表示するコンテキスト行数。`query` 省略時は無視。 -# query_excerpt_lines = 3 +# [feature.memory.profile.extraction] +# enabled = true +# threshold = 30000 +# worker_max_turns = 8 # -# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。 -# # extract ワーカーのモデル ([model] と同じ形式)。 -# # Haiku / 4o-mini / Flash クラスの軽量 reasoning モデル推奨。 -# # [memory.extract_model] +# # 任意。省略時はmain modelをcloneする。 +# # [feature.memory.profile.extraction.model] # # ref = "anthropic/claude-haiku-4-5" # -# # 任意。デフォルト: なし (extract 自動発火を完全停止)。 -# # 前回 extract pointer 以降の累積入力 token がこの値を超えると extract 起動。 -# # ※ memory tools と resident injection は extract_threshold が None でも動く。 -# extract_threshold = 30000 +# # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない。 +# # [feature.memory.workspace_settings] +# # workspace_id = "workspace-id" +# # settings_revision = 1 +# # language = "日本語" # -# # 任意。デフォルト: 8 (`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`)。 -# # extract worker 自身の tool loop 上限。Rust config で None の場合のみ無制限。 -# extract_worker_max_turns = 8 -# -# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。 -# # consolidation ワーカーのモデル。reasoning クラス推奨。 -# # [memory.consolidation_model] -# # ref = "anthropic/claude-sonnet-4-6" -# -# # 任意。デフォルト: なし。 -# # `_staging/` のエントリ数がこの値以上で consolidation 発火 (files / bytes は OR)。 -# consolidation_threshold_files = 50 -# -# # 任意。デフォルト: なし。 -# # `_staging/` の総バイト数がこの値以上で consolidation 発火 (files / bytes は OR)。 -# # files / bytes の両方が None だと consolidation 完全無効。 -# consolidation_threshold_bytes = 1048576 +# Query結果/抜粋の上限とconsolidation eligibility/thresholdはoperation/Backend +# policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する。 # ===== [skills] ============================================================= diff --git a/resources/profiles/base.dcdl b/resources/profiles/base.dcdl index c91afed8..88db46c7 100644 --- a/resources/profiles/base.dcdl +++ b/resources/profiles/base.dcdl @@ -23,7 +23,14 @@ compaction = { feature = { task = { enabled = true; }; - memory = { enabled = true; }; + memory = { + enabled = true; + resident = { inject_summary = true; }; + extraction = { + enabled = true; + threshold = 50000; + }; + }; web = { enabled = true; }; image = { enabled = true; }; sub_worker = { enabled = false; }; @@ -40,12 +47,6 @@ feature = { }; }; -memory = { - extract_threshold = 50000; - consolidation_threshold_files = 5; - consolidation_threshold_bytes = 50000; -}; - web = { enabled = true; search = { diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index 3d3721c2..84f0f3c5 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -6,7 +6,7 @@ import "./base.dcdl" // { feature = { task = { enabled = true; }; - memory = { enabled = false; staging = false; }; + memory = { enabled = false; staging_tools = false; }; web = { enabled = true; }; image = { enabled = true; }; sub_worker = { enabled = true; }; diff --git a/resources/profiles/memory-consolidation.dcdl b/resources/profiles/memory-consolidation.dcdl index 689fb934..d1a0474e 100644 --- a/resources/profiles/memory-consolidation.dcdl +++ b/resources/profiles/memory-consolidation.dcdl @@ -5,7 +5,7 @@ import "./base.dcdl" // { feature = { task = { enabled = false; }; - memory = { enabled = true; staging = true; }; + memory = { enabled = true; staging_tools = true; }; web = { enabled = false; }; sub_worker = { enabled = false; }; worker = { enabled = false; };