refactor: unify Memory feature configuration authority

This commit is contained in:
2026-09-04 22:55:37 +09:00
parent 5ee77698db
commit 1e674d70c2
20 changed files with 947 additions and 637 deletions
+132 -76
View File
@@ -18,8 +18,9 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryExtractionProfileConfig,
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig, MergeRequestFeatureConfig,
ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
WorkerManifest, WorkerMeta, WorkerManifest, WorkerMeta,
}; };
@@ -67,9 +68,6 @@ pub struct WorkerManifestConfig {
/// First-class web tool opt-in. See [`WebConfig`]. /// First-class web tool opt-in. See [`WebConfig`].
#[serde(default)] #[serde(default)]
pub web: Option<WebConfig>, pub web: Option<WebConfig>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills directories. See [`crate::SkillsConfig`]. /// External Agent Skills directories. See [`crate::SkillsConfig`].
#[serde(default)] #[serde(default)]
pub skills: Option<SkillsConfig>, pub skills: Option<SkillsConfig>,
@@ -193,18 +191,72 @@ impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryFeatureConfigPartial { pub struct MemoryFeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub enabled: Option<bool>, pub enabled: Option<bool>,
#[serde(default)] #[serde(default)]
pub staging: Option<bool>, pub staging_tools: Option<bool>,
#[serde(default)]
pub resident: Option<MemoryResidentProfileConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryResidentProfileConfigPartial {
#[serde(default)]
pub inject_summary: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryExtractionProfileConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub model: Option<ModelManifest>,
#[serde(default)]
pub threshold: Option<u64>,
#[serde(default)]
pub worker_max_turns: Option<u32>,
} }
impl MemoryFeatureConfigPartial { impl MemoryFeatureConfigPartial {
fn merge(self, other: Self) -> Self { fn merge(self, other: Self) -> Self {
Self { Self {
enabled: other.enabled.or(self.enabled), 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<FeatureConfigPartial> for FeatureConfig {
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(), task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
memory: value memory: value
.memory .memory
.map(MemoryFeatureConfig::from) .map(ResolvedMemoryFeatureConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(), web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(), image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
@@ -329,20 +381,45 @@ impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
} }
} }
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig { impl From<MemoryFeatureConfigPartial> for ResolvedMemoryFeatureConfig {
fn from(value: MemoryFeatureConfigPartial) -> Self { fn from(value: MemoryFeatureConfigPartial) -> Self {
let resident = value.resident.unwrap_or_default();
let extraction = value.extraction.unwrap_or_default();
Self { Self {
enabled: value.enabled.unwrap_or_default(), profile: MemoryFeatureProfileConfig {
staging: value.staging.unwrap_or_default(), 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<MemoryFeatureConfig> for MemoryFeatureConfigPartial { impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: MemoryFeatureConfig) -> Self { fn from(value: ResolvedMemoryFeatureConfig) -> Self {
Self { Self {
enabled: Some(value.enabled), enabled: Some(value.profile.enabled),
staging: Some(value.staging), 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)", (removed; use compaction.prune_protected_tokens)",
)); ));
} }
if value if value.get("memory").is_some() {
.get("memory")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
{
return Err(toml::de::Error::custom( 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 if value
@@ -633,11 +706,6 @@ impl WorkerManifestConfig {
for rule in &mut self.delegation_scope.deny { for rule in &mut self.delegation_scope.deny {
rule.target = join_if_relative(base, &rule.target); 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 if let Some(ref mut compaction) = self.compaction
&& let Some(ref mut cp) = compaction.model && let Some(ref mut cp) = compaction.model
{ {
@@ -682,7 +750,6 @@ impl WorkerManifestConfig {
CompactionConfigPartial::merge, CompactionConfigPartial::merge,
), ),
web: merge_option(self.web, upper.web, WebConfig::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), 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 { impl WorkerMetaConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> Self {
Self { Self {
@@ -1223,7 +1264,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
mcp: cfg.mcp, mcp: cfg.mcp,
compaction, compaction,
web: cfg.web, web: cfg.web,
memory: cfg.memory,
skills: cfg.skills, skills: cfg.skills,
profile: None, profile: None,
}) })
@@ -1271,7 +1311,6 @@ mod tests {
session: None, session: None,
compaction: None, compaction: None,
web: None, web: None,
memory: None,
skills: None, skills: None,
} }
} }
@@ -1846,29 +1885,46 @@ prune_protected_turns = 3
} }
#[test] #[test]
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() { fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
let bad = r#" let cfg = WorkerManifestConfig::from_toml(
[memory] r#"
extract_worker_max_input_tokens = 30000 [feature.memory]
"#; enabled = true
let err = WorkerManifestConfig::from_toml(bad).unwrap_err(); staging_tools = false
assert!(
err.to_string() [feature.memory.resident]
.contains("memory.extract_worker_max_input_tokens"), inject_summary = false
"unexpected error: {err}"
); [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] #[test]
fn from_toml_accepts_extract_worker_max_turns() { fn from_toml_rejects_legacy_top_level_memory_authority() {
let cfg = WorkerManifestConfig::from_toml( let err = WorkerManifestConfig::from_toml(
r#" r#"
[memory] [memory]
extract_worker_max_turns = 2 extract_worker_max_turns = 2
"#, "#,
) )
.unwrap(); .unwrap_err();
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2)); assert!(
err.to_string().contains("memory"),
"unexpected error: {err}"
);
} }
#[test] #[test]
@@ -1948,7 +2004,7 @@ worker_max_turns = 7
fn feature_flags_default_disabled_in_resolved_manifest() { fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled); assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled); assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
@@ -2025,8 +2081,8 @@ enabled = false
} }
); );
assert!(!manifest.feature.orchestration.enabled); assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.memory.staging); assert!(!manifest.feature.memory.profile.staging_tools);
assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.objective.enabled);
} }
@@ -2074,7 +2130,7 @@ readiness_check = true
enabled = true enabled = true
[feature.memory] [feature.memory]
staging = true staging_tools = true
[feature.manage_workdir] [feature.manage_workdir]
enabled = true enabled = true
@@ -2111,8 +2167,8 @@ enabled = true
}) })
.try_into() .try_into()
.unwrap(); .unwrap();
assert!(manifest.feature.memory.enabled); assert!(manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.staging); assert!(manifest.feature.memory.profile.staging_tools);
assert!(manifest.feature.manage_workdir.enabled); assert!(manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.ticket.enabled); assert!(manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket.authoring); assert!(!manifest.feature.ticket.authoring);
+1 -1
View File
@@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5; pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited. /// 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<u32> = Some(8); pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
+380 -147
View File
@@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize};
/// part of the manifest — it is the process's `std::env::current_dir()` /// part of the manifest — it is the process's `std::env::current_dir()`
/// at construction time. /// at construction time.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerManifest { pub struct WorkerManifest {
pub worker: WorkerMeta, pub worker: WorkerMeta,
pub model: ModelManifest, pub model: ModelManifest,
@@ -80,11 +81,6 @@ pub struct WorkerManifest {
pub mcp: McpConfig, pub mcp: McpConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfig>, pub compaction: Option<CompactionConfig>,
/// 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<MemoryConfig>,
/// First-class web tools configuration. Network access remains fail-closed /// First-class web tools configuration. Network access remains fail-closed
/// under this config; WebSearch/WebFetch schemas are surfaced only when /// under this config; WebSearch/WebFetch schemas are surfaced only when
/// `[feature.web].enabled = true`. /// `[feature.web].enabled = true`.
@@ -109,12 +105,12 @@ pub struct WorkerManifest {
/// profile/config data only: they do not carry runtime Worker names, sockets, /// profile/config data only: they do not carry runtime Worker names, sockets,
/// sessions, secrets, or resolved host state. Tool registration still applies /// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks. /// 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 { pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub task: FeatureFlagConfig, pub task: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub memory: MemoryFeatureConfig, pub memory: ResolvedMemoryFeatureConfig,
#[serde(default)] #[serde(default)]
pub web: FeatureFlagConfig, pub web: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
@@ -147,7 +143,7 @@ impl Default for FeatureConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
task: FeatureFlagConfig::disabled(), task: FeatureFlagConfig::disabled(),
memory: MemoryFeatureConfig::disabled(), memory: ResolvedMemoryFeatureConfig::default(),
web: FeatureFlagConfig::disabled(), web: FeatureFlagConfig::disabled(),
image: FeatureFlagConfig::disabled(), image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(), sub_worker: FeatureFlagConfig::disabled(),
@@ -222,34 +218,117 @@ const fn default_true() -> bool {
true true
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryFeatureConfig { #[serde(default, deny_unknown_fields)]
#[serde(default)] pub struct MemoryFeatureProfileConfig {
pub enabled: bool, pub enabled: bool,
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools. /// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
#[serde(default)] pub staging_tools: bool,
pub staging: bool, pub resident: MemoryResidentProfileConfig,
pub extraction: MemoryExtractionProfileConfig,
} }
impl MemoryFeatureConfig { impl MemoryFeatureProfileConfig {
pub const fn disabled() -> Self { pub fn disabled() -> Self {
Self { Self::default()
enabled: false,
staging: false,
}
} }
pub const fn enabled() -> Self { pub fn enabled() -> Self {
Self { Self {
enabled: true, enabled: true,
staging: false, ..Self::default()
} }
} }
} }
impl Default for MemoryFeatureConfig { impl Default for MemoryFeatureProfileConfig {
fn default() -> Self { 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<ModelManifest>,
pub threshold: Option<u64>,
pub worker_max_turns: Option<u32>,
}
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<WorkspaceMemorySettingsSnapshot>,
}
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<WorkspaceMemorySettingsSnapshot> {
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, 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<PathBuf>,
/// Maximum number of records returned by `MemoryQuery` /
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
#[serde(default)]
pub query_result_limit: Option<usize>,
/// 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<usize>,
/// Whether the body of `memory/summary.md` is exposed in the resident
/// system-prompt section. `None` ⇒ enabled.
#[serde(default)]
pub inject_summary: Option<bool>,
/// Workspace that owns the bound Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
/// Monotonic revision of the bound Workspace Memory settings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_revision: Option<u64>,
/// Language from the bound Workspace Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// 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<ModelManifest>,
/// 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<u64>,
/// 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<u32>,
/// 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<ModelManifest>,
/// 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<usize>,
/// 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<u64>,
}
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<WorkspaceMemorySettingsSnapshot> {
Some(WorkspaceMemorySettingsSnapshot {
workspace_id: self.workspace_id.clone()?,
settings_revision: self.settings_revision?,
language: self.language.clone()?,
})
}
}
/// Worker metadata. /// Worker metadata.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerMeta { 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<serde_json::Value, serde_json::Error> {
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<WorkerManifest, serde_json::Error> {
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<WorkerManifest, serde_json::Error> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1246,36 +1394,129 @@ model_id = "claude-sonnet-4-20250514"
} }
#[test] #[test]
fn omitted_memory_is_none() { fn omitted_memory_feature_is_disabled() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap(); 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] #[test]
fn empty_memory_section_enables_with_default_root() { fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n"); 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 manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.expect("memory section parsed"); assert!(manifest.feature.memory.profile.enabled);
assert!(mem.workspace_root.is_none()); assert!(!manifest.feature.memory.profile.resident.inject_summary);
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_eq!( assert_eq!(
mem.workspace_root.unwrap(), manifest.feature.memory.profile.extraction.threshold,
std::path::PathBuf::from("/some/where") 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] #[test]
fn reject_unknown_scheme() { fn reject_unknown_scheme() {
let toml = let toml =
+9 -61
View File
@@ -20,9 +20,9 @@ use crate::config::{
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError, EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
WorkerMetaConfig, paths, paths,
}; };
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
@@ -185,7 +185,7 @@ pub fn validate_profile_execution_target(
if feature.manage_workdir.enabled { if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); 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); requirements.insert(WorkspaceAuthorityRequirement::Memory);
} }
if feature.merge_request.show if feature.merge_request.show
@@ -642,7 +642,6 @@ fn resolve_profile_value(
mcp: profile.mcp, mcp: profile.mcp,
compaction, compaction,
web: profile.web, web: profile.web,
memory: profile.memory.map(Into::into),
skills: profile.skills, skills: profile.skills,
}; };
let config = 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<PathBuf>,
#[serde(default)]
query_result_limit: Option<usize>,
#[serde(default)]
query_excerpt_lines: Option<usize>,
#[serde(default)]
inject_summary: Option<bool>,
#[serde(default)]
extract_model: Option<ModelManifest>,
#[serde(default)]
extract_threshold: Option<u64>,
#[serde(default)]
extract_worker_max_turns: Option<u32>,
#[serde(default)]
consolidation_model: Option<ModelManifest>,
#[serde(default)]
consolidation_threshold_files: Option<usize>,
#[serde(default)]
consolidation_threshold_bytes: Option<u64>,
}
impl From<ProfileMemoryConfig> 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)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct ProfileConfig { struct ProfileConfig {
@@ -738,8 +692,6 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
web: Option<WebConfig>, web: Option<WebConfig>,
#[serde(default)] #[serde(default)]
memory: Option<ProfileMemoryConfig>,
#[serde(default)]
skills: Option<SkillsConfig>, skills: Option<SkillsConfig>,
} }
@@ -940,12 +892,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
.map_err(|source| ProfileError::ProfileDeserialize { source })?; .map_err(|source| ProfileError::ProfileDeserialize { source })?;
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?; 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 { if let Some(skills) = &profile.skills {
for dir in &skills.directories { for dir in &skills.directories {
if dir.is_absolute() { if dir.is_absolute() {
@@ -1299,7 +1245,9 @@ mod tests {
("settings_revision", serde_json::json!(2)), ("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")), ("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( let error = resolve_profile_artifact_value(
artifact, artifact,
ProfileSource::Registry { ProfileSource::Registry {
@@ -1351,7 +1299,7 @@ mod tests {
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| { assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
rule.permission == protocol::Permission::Write && rule.target == tmp.path() 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.ticket.enabled);
assert!(!resolved.manifest.feature.objective.enabled); assert!(!resolved.manifest.feature.objective.enabled);
assert!(!resolved.manifest.feature.flow.enabled); assert!(!resolved.manifest.feature.flow.enabled);
@@ -1630,7 +1578,7 @@ enabled = false
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-worker"); assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert!(resolved.manifest.feature.task.enabled); 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.web.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled); assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.feature.ticket.enabled); assert!(resolved.manifest.feature.ticket.enabled);
+13 -5
View File
@@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation {
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidateStagingOperation { pub struct MemoryConsolidateStagingOperation {
#[serde(default)] #[serde(default)]
pub force: bool, pub force: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_files: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_bytes: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -450,10 +447,21 @@ mod tests {
use super::*; use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate}; use crate::extract::{CandidateKind, ExtractedCandidate};
#[test]
fn consolidation_operation_rejects_caller_owned_thresholds() {
let error =
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
"force": false,
"threshold_files": 1,
}))
.unwrap_err();
assert!(error.to_string().contains("threshold_files"));
}
#[test] #[test]
fn staging_list_read_close_records_reason_and_deletes_candidate() { fn staging_list_read_close_records_reason_and_deletes_candidate() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path()); let layout = WorkspaceLayout::resolve(temp.path());
let source = SourceRef { let source = SourceRef {
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
range: [0, 1], range: [0, 1],
+1 -2
View File
@@ -21,8 +21,7 @@ pub struct StagingEntry {
pub id: Uuid, pub id: Uuid,
pub path: PathBuf, pub path: PathBuf,
pub record: StagingRecord, pub record: StagingRecord,
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`) /// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
/// に使う。
pub bytes: u64, pub bytes: u64,
} }
+8 -33
View File
@@ -70,24 +70,12 @@ impl WorkspaceLayout {
Self { root: root.into() } 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 /// Resolution searches `default_root` and its ancestors for the nearest
/// explicit root, resolution searches `default_root` and its ancestors for /// `.yoi/memory` directory. This legacy local-storage helper owns its path
/// the nearest `.yoi/memory` directory. This keeps child worktrees that /// policy directly; resolved Worker Manifests do not carry storage paths.
/// contain `.yoi` project records such as tickets from pub fn resolve(default_root: &Path) -> Self {
/// 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());
}
let root = let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf()); find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root) Self::new(root)
@@ -335,16 +323,6 @@ mod tests {
assert!(matches!(err, LintError::InvalidPath(_))); 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] #[test]
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() { fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
let tmp = TempDir::new().unwrap(); 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(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(&child).unwrap(); std::fs::create_dir_all(&child).unwrap();
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), workspace.as_path()); 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(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap(); std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), workspace.as_path()); assert_eq!(layout.root(), workspace.as_path());
} }
@@ -381,8 +357,7 @@ mod tests {
assert_eq!(find_memory_marker_root(&child), None); assert_eq!(find_memory_marker_root(&child), None);
let cfg = manifest::MemoryConfig::default(); let layout = WorkspaceLayout::resolve(&child);
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), child.as_path()); assert_eq!(layout.root(), child.as_path());
} }
} }
+1 -6
View File
@@ -228,7 +228,7 @@ worker_context_max_tokens = 100000
enabled = true enabled = true
[feature.memory] [feature.memory]
enabled = true enabled = false
[feature.web] [feature.web]
enabled = true enabled = true
@@ -241,11 +241,6 @@ enabled = true
authoring = true authoring = true
thread = true thread = true
[memory]
extract_threshold = 50000
consolidation_threshold_files = 5
consolidation_threshold_bytes = 50000
[web] [web]
enabled = true enabled = true
+9 -15
View File
@@ -759,8 +759,8 @@ fn migrate_worker_aggregate_document(
.get_mut("resolved_manifest_snapshot") .get_mut("resolved_manifest_snapshot")
.filter(|snapshot| !snapshot.is_null()) .filter(|snapshot| !snapshot.is_null())
{ {
let manifest: manifest::WorkerManifest = let mut manifest = manifest::read_persisted_worker_manifest_snapshot(snapshot.clone())
serde_json::from_value(snapshot.clone()).map_err(|error| { .map_err(|error| {
runtime_store_corrupt( runtime_store_corrupt(
metadata_path, metadata_path,
format!("decode Worker aggregate resolved manifest snapshot: {error}"), format!("decode Worker aggregate resolved manifest snapshot: {error}"),
@@ -775,20 +775,14 @@ fn migrate_worker_aggregate_document(
), ),
)); ));
} }
snapshot manifest.worker.name = expected_name.clone();
.as_object_mut() *snapshot =
.and_then(|manifest| manifest.get_mut("worker")) manifest::write_persisted_worker_manifest_snapshot(&manifest).map_err(|error| {
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
runtime_store_corrupt( runtime_store_corrupt(
metadata_path, 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( metadata.insert(
"worker_name".to_string(), "worker_name".to_string(),
@@ -809,8 +803,8 @@ fn migrate_worker_aggregate_document(
)); ));
} }
if let Some(snapshot) = metadata.resolved_manifest_snapshot { if let Some(snapshot) = metadata.resolved_manifest_snapshot {
let manifest: manifest::WorkerManifest = let manifest =
serde_json::from_value(snapshot).map_err(|error| { manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|error| {
runtime_store_corrupt( runtime_store_corrupt(
metadata_path, metadata_path,
format!("decode migrated Worker aggregate resolved manifest: {error}"), format!("decode migrated Worker aggregate resolved manifest: {error}"),
+20 -6
View File
@@ -746,9 +746,15 @@ fn bind_workspace_memory_settings(
)); ));
} }
manifest manifest
.feature
.memory .memory
.get_or_insert_with(manifest::MemoryConfig::default) .bind_workspace_settings(snapshot.clone())
.bind_workspace_settings(snapshot); .map_err(str::to_string)?;
manifest
.feature
.memory
.validate_execution()
.map_err(str::to_string)?;
Ok(()) Ok(())
} }
@@ -759,10 +765,18 @@ fn validate_worker_memory_settings(
let Some(expected) = request.memory_settings.as_ref() else { let Some(expected) = request.memory_settings.as_ref() else {
return Ok(()); return Ok(());
}; };
let actual = manifest manifest
.feature
.memory .memory
.as_ref() .validate_execution()
.and_then(manifest::MemoryConfig::workspace_settings) .map_err(str::to_string)?;
if !manifest.feature.memory.profile.enabled {
return Ok(());
}
let actual = manifest
.feature
.memory
.workspace_settings()
.ok_or_else(|| { .ok_or_else(|| {
"Workspace Worker restored without its bound Memory settings snapshot".to_string() "Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?; })?;
@@ -3165,7 +3179,7 @@ mod tests {
Some(session_store::WorkerActiveSegmentRef::pending_segment( Some(session_store::WorkerActiveSegmentRef::pending_segment(
session_id, session_id,
)), )),
Some(serde_json::to_value(&manifest).unwrap()), Some(manifest::write_persisted_worker_manifest_snapshot(&manifest).unwrap()),
) )
.unwrap(); .unwrap();
+96 -66
View File
@@ -901,27 +901,39 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
// per-item commit channel is wired at the top of this function. // per-item commit channel is wired at the top of this function.
} }
fn add_memory_lifecycle_if_configured<M>( fn add_memory_tools_if_configured<M>(
registry: &mut FeatureRegistryBuilder, registry: &mut FeatureRegistryBuilder,
config: Option<manifest::MemoryConfig>, config: &manifest::ResolvedMemoryFeatureConfig,
workspace_bound: bool, build: impl FnOnce() -> std::io::Result<M>,
build: impl FnOnce(manifest::MemoryConfig) -> std::io::Result<M>,
) -> std::io::Result<bool> ) -> std::io::Result<bool>
where where
M: crate::feature::FeatureModule + 'static, M: crate::feature::FeatureModule + 'static,
{ {
let Some(config) = config else { if !config.profile.enabled {
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",
));
}
return Ok(false); 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<M>(
registry: &mut FeatureRegistryBuilder,
config: manifest::ResolvedMemoryFeatureConfig,
lifecycle_enabled: bool,
build: impl FnOnce(manifest::ResolvedMemoryFeatureConfig) -> std::io::Result<M>,
) -> std::io::Result<bool>
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)?); registry.add_module(build(config)?);
Ok(true) Ok(true)
} }
@@ -962,7 +974,7 @@ where
let local_filesystem = worker.local_working_directory().cloned(); let local_filesystem = worker.local_working_directory().cloned();
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone()); let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
let task_feature = worker.task_feature(); 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 web_config = worker.manifest().web.clone();
let mcp_config = worker.manifest().mcp.clone(); let mcp_config = worker.manifest().mcp.clone();
let spawner_name = worker.manifest().worker.name.clone(); let spawner_name = worker.manifest().worker.name.clone();
@@ -1019,13 +1031,23 @@ where
let worker_enabled = feature_config.worker.enabled; let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled; let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new(); 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( add_memory_lifecycle_if_configured(
&mut feature_registry, &mut feature_registry,
worker memory_config.clone(),
.manifest_lifecycle_features_enabled() worker.manifest_lifecycle_features_enabled(),
.then(|| memory_config.clone())
.flatten(),
spawner_workspace_context.workspace_id().is_some(),
|config| { |config| {
let workspace_client = worker.workspace_client_handle(); let workspace_client = worker.workspace_client_handle();
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { 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< let mut observation_providers: Vec<
Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>, Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>,
> = Vec::new(); > = Vec::new();
@@ -2169,7 +2159,7 @@ mod tests {
use tokio::net::UnixListener; use tokio::net::UnixListener;
#[test] #[test]
fn memory_lifecycle_registration_requires_bound_workspace_memory_config() { fn memory_feature_registration_requires_bound_workspace_memory_config() {
#[derive(Clone)] #[derive(Clone)]
struct TestMemoryLifecycleModule; struct TestMemoryLifecycleModule;
@@ -2189,16 +2179,43 @@ mod tests {
} }
} }
let mut registry = FeatureRegistryBuilder::new();
let installed = add_memory_tools_if_configured::<TestMemoryLifecycleModule>(
&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::<TestMemoryLifecycleModule>(
&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 mut registry = FeatureRegistryBuilder::new();
let configured = std::cell::Cell::new(false); let configured = std::cell::Cell::new(false);
let mut memory_config = manifest::MemoryConfig::default(); let mut memory_config = manifest::ResolvedMemoryFeatureConfig::default();
memory_config.bind_workspace_settings(&manifest::WorkspaceMemorySettingsSnapshot { memory_config.profile.enabled = true;
workspace_id: "workspace-1".to_string(), memory_config.profile.extraction.enabled = true;
settings_revision: 1, memory_config
language: "English".to_string(), .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
}); workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
let installed = 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); configured.set(true);
Ok(TestMemoryLifecycleModule) Ok(TestMemoryLifecycleModule)
}) })
@@ -2209,25 +2226,38 @@ mod tests {
let mut registry = FeatureRegistryBuilder::new(); let mut registry = FeatureRegistryBuilder::new();
let installed = add_memory_lifecycle_if_configured::<TestMemoryLifecycleModule>( let installed = add_memory_lifecycle_if_configured::<TestMemoryLifecycleModule>(
&mut registry, &mut registry,
None, manifest::ResolvedMemoryFeatureConfig::default(),
false, true,
|_| panic!("disabled Memory must not construct its lifecycle Feature"), |_| panic!("disabled Memory must not construct its lifecycle Feature"),
) )
.unwrap(); .unwrap();
assert!(!installed); 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::<TestMemoryLifecycleModule>( let installed = add_memory_lifecycle_if_configured::<TestMemoryLifecycleModule>(
&mut registry, &mut registry,
Some(manifest::MemoryConfig::default()), lifecycle_disabled,
false, false,
|_| panic!("Memory without a Backend-authored settings snapshot must stay disabled"), |_| panic!("disabled lifecycle must not construct its Feature"),
) )
.unwrap(); .unwrap();
assert!(!installed); 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::<TestMemoryLifecycleModule>( let error = add_memory_lifecycle_if_configured::<TestMemoryLifecycleModule>(
&mut registry, &mut registry,
Some(manifest::MemoryConfig::default()), missing_snapshot,
true, true,
|_| panic!("invalid Workspace Memory config must fail before Feature construction"), |_| panic!("invalid Workspace Memory config must fail before Feature construction"),
) )
@@ -2235,7 +2265,7 @@ mod tests {
assert!( assert!(
error error
.to_string() .to_string()
.contains("Backend-authored settings snapshot") .contains("requires trusted Workspace settings")
); );
} }
@@ -18,6 +18,10 @@ use schemars::JsonSchema;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::json; use serde_json::json;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::worker::{ use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
}; };
@@ -338,6 +342,44 @@ fn query_schema() -> serde_json::Value {
}) })
} }
#[derive(Clone)]
pub struct MemoryToolsFeature {
tools: Vec<ToolDefinition>,
}
impl MemoryToolsFeature {
pub fn new(client: Arc<dyn WorkspaceClient>, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -368,6 +410,20 @@ mod tests {
.input_schema .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] #[test]
fn normal_workspace_memory_tools_do_not_include_staging_tools() { fn normal_workspace_memory_tools_do_not_include_staging_tools() {
let names = tool_names(workspace_http_memory_tools(test_client())); let names = tool_names(workspace_http_memory_tools(test_client()));
@@ -52,7 +52,7 @@ pub(crate) struct MemoryLifecycleFeature {
#[derive(Clone)] #[derive(Clone)]
struct MemoryLifecycleTask { struct MemoryLifecycleTask {
config: manifest::MemoryConfig, config: manifest::ResolvedMemoryFeatureConfig,
capture: CommittedSessionCaptureHandle, capture: CommittedSessionCaptureHandle,
extensions: SessionExtensionHandle, extensions: SessionExtensionHandle,
workspace_client: Arc<dyn WorkspaceClient>, workspace_client: Arc<dyn WorkspaceClient>,
@@ -66,7 +66,7 @@ struct MemoryLifecycleTask {
impl MemoryLifecycleFeature { impl MemoryLifecycleFeature {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(crate) fn new( pub(crate) fn new(
config: manifest::MemoryConfig, config: manifest::ResolvedMemoryFeatureConfig,
capture: CommittedSessionCaptureHandle, capture: CommittedSessionCaptureHandle,
extensions: SessionExtensionHandle, extensions: SessionExtensionHandle,
workspace_client: Arc<dyn WorkspaceClient>, workspace_client: Arc<dyn WorkspaceClient>,
@@ -132,7 +132,9 @@ impl MemoryLifecycleTask {
memory::audit::AuditWorker::MemoryExtract, memory::audit::AuditWorker::MemoryExtract,
memory::audit::AuditTrigger::TokenThreshold, memory::audit::AuditTrigger::TokenThreshold,
self.config self.config
.extract_model .profile
.extraction
.model
.as_ref() .as_ref()
.or(Some(&self.manifest.model)) .or(Some(&self.manifest.model))
.map(model_audit_from_manifest), .map(model_audit_from_manifest),
@@ -188,7 +190,9 @@ impl MemoryLifecycleTask {
}; };
let Some(threshold) = self let Some(threshold) = self
.config .config
.extract_threshold .profile
.extraction
.threshold
.filter(|threshold| *threshold > 0) .filter(|threshold| *threshold > 0)
else { else {
audit audit
@@ -283,7 +287,7 @@ impl MemoryLifecycleTask {
source, source,
audit.run_id.to_string(), 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) { match crate::model_client::build_client(model) {
Ok(client) => client, Ok(client) => client,
Err(error) => { Err(error) => {
@@ -321,7 +325,7 @@ impl MemoryLifecycleTask {
} }
}; };
let mut manifest = self.manifest.clone(); 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; manifest.model = model;
} }
@@ -349,7 +353,9 @@ impl MemoryLifecycleTask {
cache_key: Some(capture.segment_id.clone()), cache_key: Some(capture.segment_id.clone()),
max_turns: self max_turns: self
.config .config
.extract_worker_max_turns .profile
.extraction
.worker_max_turns
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS), .or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
engine_configurator: None, engine_configurator: None,
features, features,
@@ -493,36 +499,13 @@ impl MemoryLifecycleTask {
let audit = WorkerAuditBase::new( let audit = WorkerAuditBase::new(
memory::audit::AuditWorker::MemoryConsolidation, memory::audit::AuditWorker::MemoryConsolidation,
memory::audit::AuditTrigger::StagingBacklog, memory::audit::AuditTrigger::StagingBacklog,
self.config Some(model_audit_from_manifest(&self.manifest.model)),
.consolidation_model
.as_ref()
.or(Some(&self.manifest.model))
.map(model_audit_from_manifest),
) )
.with_memory_settings(&self.config); .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 match self
.workspace_client .workspace_client
.request_memory_staging_consolidation( .request_memory_staging_consolidation(
memory::backend::MemoryConsolidateStagingOperation { memory::backend::MemoryConsolidateStagingOperation { force: false },
force: false,
threshold_files,
threshold_bytes,
},
) )
.await .await
{ {
@@ -646,22 +629,6 @@ fn extract_pointer(
Ok(pointer) Ok(pointer)
} }
fn consolidation_thresholds(
config: &manifest::MemoryConfig,
) -> Option<(Option<usize>, Option<u64>)> {
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 { fn extraction_run_eligible(exit: CommittedRunExit) -> bool {
exit == CommittedRunExit::Finished exit == CommittedRunExit::Finished
} }
@@ -688,12 +655,17 @@ fn tokens_since_pointer(
fn extraction_threshold_reached( fn extraction_threshold_reached(
capture: &CommittedSessionCapture, capture: &CommittedSessionCapture,
pointer: Option<&memory::ExtractPointerPayload>, pointer: Option<&memory::ExtractPointerPayload>,
config: &manifest::MemoryConfig, config: &manifest::ResolvedMemoryFeatureConfig,
) -> bool { ) -> bool {
if capture.history.is_empty() { if capture.history.is_empty() {
return false; 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; return false;
}; };
tokens_since_pointer(capture, pointer) >= threshold 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 = self.memory_settings =
config config
.workspace_settings() .workspace_settings()
@@ -1014,16 +986,18 @@ permission = "write"
.unwrap() .unwrap()
} }
fn test_config() -> manifest::MemoryConfig { fn test_config() -> manifest::ResolvedMemoryFeatureConfig {
let mut config = manifest::MemoryConfig { let mut config = manifest::ResolvedMemoryFeatureConfig::default();
extract_threshold: Some(1), config.profile.enabled = true;
..Default::default() config.profile.extraction.enabled = true;
}; config.profile.extraction.threshold = Some(1);
config.bind_workspace_settings(&manifest::WorkspaceMemorySettingsSnapshot { config
workspace_id: "workspace-1".to_string(), .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
settings_revision: 1, workspace_id: "workspace-1".to_string(),
language: "English".to_string(), settings_revision: 1,
}); language: "English".to_string(),
})
.unwrap();
config config
} }
@@ -1282,30 +1256,30 @@ permission = "write"
} }
#[tokio::test] #[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 client = ScriptClient::new(Vec::new());
let extension_writes = Arc::new(Mutex::new(Vec::new())); let extension_writes = Arc::new(Mutex::new(Vec::new()));
let (event_tx, _) = broadcast::channel(16); let (event_tx, _) = broadcast::channel(16);
let workspace_client = Arc::new(RecordingWorkspaceClient::default()); let workspace_client = Arc::new(RecordingWorkspaceClient::default());
let mut interrupted = capture(2, 250); let mut interrupted = capture(2, 250);
interrupted.run_exit = CommittedRunExit::Interrupted; interrupted.run_exit = CommittedRunExit::Interrupted;
let mut task = test_task( let task = test_task(
interrupted, interrupted,
Box::new(client), Box::new(client),
extension_writes, extension_writes,
event_tx, event_tx,
workspace_client.clone(), workspace_client.clone(),
); );
task.config.consolidation_threshold_files = Some(3);
run_background_task(task).await; run_background_task(task).await;
let requests = workspace_client.requests.lock().unwrap(); let requests = workspace_client.requests.lock().unwrap();
assert!( assert!(
requests.iter().any(|request| { requests.iter().any(|request| {
request.path.contains("memory") request.path.contains("memory")
&& request.body.as_deref().is_some_and(|body| { && request
body.contains("\"threshold_files\":3") && body.contains("\"force\":false") .body
}) .as_deref()
.is_some_and(|body| body == "{\"force\":false}")
}), }),
"recorded requests: {requests:?}" "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] #[test]
fn interrupted_parent_run_is_not_extraction_eligible() { fn interrupted_parent_run_is_not_extraction_eligible() {
assert!(extraction_run_eligible(CommittedRunExit::Finished)); assert!(extraction_run_eligible(CommittedRunExit::Finished));
@@ -1412,8 +1375,8 @@ permission = "write"
#[test] #[test]
fn threshold_uses_committed_usage_after_pointer() { fn threshold_uses_committed_usage_after_pointer() {
let capture = capture(2, 250); let capture = capture(2, 250);
let mut config = manifest::MemoryConfig::default(); let mut config = manifest::ResolvedMemoryFeatureConfig::default();
config.extract_threshold = Some(1); config.profile.extraction.threshold = Some(1);
assert!(extraction_threshold_reached( assert!(extraction_threshold_reached(
&capture, &capture,
Some(&memory::ExtractPointerPayload { Some(&memory::ExtractPointerPayload {
@@ -1500,8 +1463,8 @@ permission = "write"
#[test] #[test]
fn empty_capture_never_schedules_extraction() { fn empty_capture_never_schedules_extraction() {
let capture = capture(0, 500); let capture = capture(0, 500);
let mut config = manifest::MemoryConfig::default(); let mut config = manifest::ResolvedMemoryFeatureConfig::default();
config.extract_threshold = Some(1); config.profile.extraction.threshold = Some(1);
assert!(!extraction_threshold_reached(&capture, None, &config)); assert!(!extraction_threshold_reached(&capture, None, &config));
} }
} }
+57 -5
View File
@@ -425,6 +425,8 @@ impl Tool for SubWorkerSpawnTool {
WorkerManifestConfig::resolution_defaults().merge(child_config), WorkerManifestConfig::resolution_defaults().merge(child_config),
) )
.map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?; .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 // Delegated children stay bound to their scoped session and cannot use
// Workspace attachment tools to replace it with parent-level authority. // Workspace attachment tools to replace it with parent-level authority.
child_manifest.feature.manage_workdir.enabled = false; 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 { fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfig {
WorkerManifestConfig { WorkerManifestConfig {
worker: WorkerMetaConfig { worker: WorkerMetaConfig {
@@ -894,7 +923,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
model: c.model.clone(), model: c.model.clone(),
}), }),
web: manifest.web.clone(), web: manifest.web.clone(),
memory: manifest.memory.clone(),
skills: manifest.skills.clone(), skills: manifest.skills.clone(),
} }
} }
@@ -1091,10 +1119,7 @@ enabled = true
thread = true thread = true
[feature.memory] [feature.memory]
enabled = true enabled = false
[memory]
extract_threshold = 4000
"#; "#;
#[tokio::test] #[tokio::test]
@@ -1526,6 +1551,33 @@ extract_threshold = 4000
.unwrap() .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( fn write_project_profile_registry(
project: &Path, project: &Path,
default: Option<&str>, default: Option<&str>,
+68 -64
View File
@@ -2478,11 +2478,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
) )
}); });
if is_memory_consolidation { if is_memory_consolidation {
let memory_config = self.manifest.memory.as_ref().ok_or_else(|| { let memory_config = &self.manifest.feature.memory;
WorkerError::InvalidState( memory_config
"Memory consolidation Worker has no Memory configuration".to_string(), .validate_execution()
) .map_err(|message| WorkerError::InvalidState(message.to_string()))?;
})?;
let language = memory_language(memory_config)?; let language = memory_language(memory_config)?;
let rendered = self let rendered = self
.prompts .prompts
@@ -2517,11 +2516,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
} }
} }
let inject_summary = self.inject_resident_summary let inject_summary = self.inject_resident_summary
&& self && self.manifest.feature.memory.profile.enabled
.manifest && self.manifest.feature.memory.profile.resident.inject_summary;
.memory
.as_ref()
.is_some_and(|m| m.inject_summary.unwrap_or(true));
let resident_summary: Option<String> = if inject_summary { let resident_summary: Option<String> = if inject_summary {
match self.resident_summary_from_workspace_authority().await { match self.resident_summary_from_workspace_authority().await {
Ok(summary) => summary, Ok(summary) => summary,
@@ -4552,7 +4548,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
} }
} }
fn memory_language(config: &manifest::MemoryConfig) -> Result<String, WorkerError> { fn memory_language(config: &manifest::ResolvedMemoryFeatureConfig) -> Result<String, WorkerError> {
config config
.workspace_settings() .workspace_settings()
.map(|snapshot| snapshot.language) .map(|snapshot| snapshot.language)
@@ -5426,7 +5422,8 @@ fn worker_metadata_for_manifest(
metadata = metadata.with_workspace_root(local_workspace_root.to_path_buf()); metadata = metadata.with_workspace_root(local_workspace_root.to_path_buf());
} }
if should_persist_resolved_manifest_snapshot(manifest) { 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 metadata
} }
@@ -5439,10 +5436,20 @@ fn validate_workspace_memory_snapshot(
let Some(workspace_id) = workspace_context.workspace_id() else { let Some(workspace_id) = workspace_context.workspace_id() else {
return Ok(()); return Ok(());
}; };
let snapshot = manifest manifest
.feature
.memory .memory
.as_ref() .validate_execution()
.and_then(manifest::MemoryConfig::workspace_settings) .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(|| { .ok_or_else(|| {
WorkerError::InvalidState(format!( WorkerError::InvalidState(format!(
"Workspace Worker {worker_name} has no complete persisted Memory settings snapshot" "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 { fn should_persist_resolved_manifest_snapshot(manifest: &WorkerManifest) -> bool {
manifest.profile.is_some() manifest.profile.is_some()
|| manifest.plugins.has_resolved_plan() || manifest.plugins.has_resolved_plan()
|| manifest || manifest.feature.memory.workspace_settings.is_some()
.memory
.as_ref()
.and_then(manifest::MemoryConfig::workspace_settings)
.is_some()
} }
fn restore_manifest_from_worker_metadata_snapshot( fn restore_manifest_from_worker_metadata_snapshot(
@@ -5481,12 +5484,14 @@ fn restore_manifest_from_worker_metadata_snapshot(
fallback: WorkerManifest, fallback: WorkerManifest,
) -> Result<WorkerManifest, WorkerError> { ) -> Result<WorkerManifest, WorkerError> {
match snapshot { match snapshot {
Some(snapshot) => serde_json::from_value(snapshot).map_err(|source| { Some(snapshot) => {
WorkerError::WorkerMetadataManifestSnapshot { manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|source| {
worker_name: worker_name.to_string(), WorkerError::WorkerMetadataManifestSnapshot {
source, worker_name: worker_name.to_string(),
} source,
}), }
})
}
None => Ok(fallback), None => Ok(fallback),
} }
} }
@@ -6198,11 +6203,6 @@ fn prepare_worker_common_with_context_and_model_client(
WorkerFilesystemAuthority::Local(LocalWorkingDirectory { root, cwd }) 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() { let scope = if scope_config.allow.is_empty() && filesystem_authority.as_local().is_none() {
Scope::empty() Scope::empty()
} else { } else {
@@ -6292,8 +6292,7 @@ mod spawned_context_tests {
std::fs::create_dir_all(&workspace_root).unwrap(); std::fs::create_dir_all(&workspace_root).unwrap();
std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&cwd).unwrap();
let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd); let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
manifest.memory = Some(manifest::MemoryConfig::default());
let common = prepare_worker_common_with_context( let common = prepare_worker_common_with_context(
&manifest, &manifest,
&PromptCatalogSource::builtins_only(), &PromptCatalogSource::builtins_only(),
@@ -6327,8 +6326,7 @@ mod spawned_context_tests {
let workspace_root = tmp.path().join("workspace-root"); let workspace_root = tmp.path().join("workspace-root");
let cwd = workspace_root.join("nested"); let cwd = workspace_root.join("nested");
std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&cwd).unwrap();
let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd); let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
manifest.memory = Some(manifest::MemoryConfig::default());
let loader = PromptCatalogSource::builtins_only(); let loader = PromptCatalogSource::builtins_only();
let workspace_id = WorkspaceId::new("ws-api-only").unwrap(); let workspace_id = WorkspaceId::new("ws-api-only").unwrap();
let common = prepare_worker_common_with_context( let common = prepare_worker_common_with_context(
@@ -6535,7 +6533,7 @@ permission = "write"
let restored = restore_manifest_from_worker_metadata_snapshot( let restored = restore_manifest_from_worker_metadata_snapshot(
"restore-scope", "restore-scope",
Some(serde_json::to_value(&saved).unwrap()), Some(manifest::write_persisted_worker_manifest_snapshot(&saved).unwrap()),
current, current,
) )
.unwrap(); .unwrap();
@@ -6590,24 +6588,26 @@ permission = "read"
"#, "#,
) )
.unwrap(); .unwrap();
manifest.memory = Some(manifest::MemoryConfig::default()); manifest.feature.memory.profile.enabled = true;
manifest.memory.as_mut().unwrap().bind_workspace_settings( manifest
&manifest::WorkspaceMemorySettingsSnapshot { .feature
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
settings_revision: 7, settings_revision: 7,
language: "Japanese".to_string(), language: "Japanese".to_string(),
}, })
); .unwrap();
let metadata = worker_metadata_for_manifest(&manifest, None, None, None); 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 metadata
.resolved_manifest_snapshot .resolved_manifest_snapshot
.expect("Memory settings require a resolved manifest snapshot"), .expect("Memory settings require a resolved manifest snapshot"),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
restored.memory.unwrap().workspace_settings(), restored.feature.memory.workspace_settings(),
Some(manifest::WorkspaceMemorySettingsSnapshot { Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
settings_revision: 7, settings_revision: 7,
@@ -6638,7 +6638,7 @@ permission = "read"
); );
let mut missing = manifest.clone(); let mut missing = manifest.clone();
missing.memory.as_mut().unwrap().settings_revision = None; missing.feature.memory.workspace_settings = None;
assert!( assert!(
validate_workspace_memory_snapshot( validate_workspace_memory_snapshot(
"memory-snapshot", "memory-snapshot",
@@ -6715,7 +6715,7 @@ permission = "read"
let snapshot = metadata let snapshot = metadata
.resolved_manifest_snapshot .resolved_manifest_snapshot
.expect("plugin-resolved manifest should be snapshotted"); .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!(restored.profile.is_none());
assert_eq!(restored.plugins.resolved.len(), 1); assert_eq!(restored.plugins.resolved.len(), 1);
@@ -8203,13 +8203,16 @@ mod build_summary_prompt_tests {
}, },
profile: None, profile: None,
}); });
let mut memory = manifest::MemoryConfig::default(); let mut memory = manifest::ResolvedMemoryFeatureConfig::default();
memory.bind_workspace_settings(&manifest::WorkspaceMemorySettingsSnapshot { memory.profile.enabled = true;
workspace_id: "workspace-test".to_string(), memory
settings_revision: 3, .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
language: "Japanese".to_string(), workspace_id: "workspace-test".to_string(),
}); settings_revision: 3,
manifest.memory = Some(memory); language: "Japanese".to_string(),
})
.unwrap();
manifest.feature.memory = memory;
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
@@ -8235,7 +8238,7 @@ mod build_summary_prompt_tests {
async fn render_system_prompt_with_summary( async fn render_system_prompt_with_summary(
summary_doc: Option<&str>, summary_doc: Option<&str>,
memory_config: Option<manifest::MemoryConfig>, memory_config: Option<manifest::ResolvedMemoryFeatureConfig>,
resident_injection: bool, resident_injection: bool,
) -> String { ) -> String {
render_system_prompt_with_resident_sections( render_system_prompt_with_resident_sections(
@@ -8249,7 +8252,7 @@ mod build_summary_prompt_tests {
async fn render_system_prompt_with_resident_sections( async fn render_system_prompt_with_resident_sections(
summary_doc: Option<&str>, summary_doc: Option<&str>,
memory_config: Option<manifest::MemoryConfig>, memory_config: Option<manifest::ResolvedMemoryFeatureConfig>,
gates: ResidentInjectionGates, gates: ResidentInjectionGates,
_unused: bool, _unused: bool,
) -> String { ) -> String {
@@ -8258,12 +8261,15 @@ mod build_summary_prompt_tests {
let cwd = dir.path().join("workspace"); let cwd = dir.path().join("workspace");
std::fs::create_dir_all(&cwd).unwrap(); std::fs::create_dir_all(&cwd).unwrap();
let mut manifest = minimal_manifest(); 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 scope = Scope::writable(&cwd).unwrap();
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
let workspace_context = if memory_config let workspace_context = if memory_config
.as_ref() .as_ref()
.is_some_and(|cfg| cfg.inject_summary.unwrap_or(true)) .is_some_and(|cfg| cfg.profile.resident.inject_summary)
&& gates.summary && gates.summary
{ {
stub_memory_backend_context(summary_doc.and_then(summary_content_for_backend)) 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() { async fn resident_summary_body_is_injected_without_frontmatter() {
let rendered = render_system_prompt_with_summary( let rendered = render_system_prompt_with_summary(
Some(&summary_doc("summary body for resident prompt\n")), Some(&summary_doc("summary body for resident prompt\n")),
Some(manifest::MemoryConfig::default()), Some(manifest::ResolvedMemoryFeatureConfig::default()),
true, true,
) )
.await; .await;
@@ -8362,10 +8368,8 @@ mod build_summary_prompt_tests {
#[tokio::test] #[tokio::test]
async fn resident_summary_injection_can_be_disabled_by_manifest() { async fn resident_summary_injection_can_be_disabled_by_manifest() {
let memory = manifest::MemoryConfig { let mut memory = manifest::ResolvedMemoryFeatureConfig::default();
inject_summary: Some(false), memory.profile.resident.inject_summary = false;
..manifest::MemoryConfig::default()
};
let rendered = render_system_prompt_with_summary( let rendered = render_system_prompt_with_summary(
Some(&summary_doc("disabled summary body\n")), Some(&summary_doc("disabled summary body\n")),
Some(memory), Some(memory),
@@ -8377,7 +8381,7 @@ mod build_summary_prompt_tests {
} }
#[tokio::test] #[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( let rendered = render_system_prompt_with_summary(
Some(&summary_doc("memory-disabled summary body\n")), Some(&summary_doc("memory-disabled summary body\n")),
None, None,
@@ -8392,7 +8396,7 @@ mod build_summary_prompt_tests {
async fn malformed_resident_summary_does_not_fail_render() { async fn malformed_resident_summary_does_not_fail_render() {
let rendered = render_system_prompt_with_summary( let rendered = render_system_prompt_with_summary(
Some("---\nthis is not yaml: : :\n---\nbad summary body\n"), Some("---\nthis is not yaml: : :\n---\nbad summary body\n"),
Some(manifest::MemoryConfig::default()), Some(manifest::ResolvedMemoryFeatureConfig::default()),
true, true,
) )
.await; .await;
@@ -8405,7 +8409,7 @@ mod build_summary_prompt_tests {
async fn resident_summary_gate_false_omits_only_summary() { async fn resident_summary_gate_false_omits_only_summary() {
let prompt = render_system_prompt_with_resident_sections( let prompt = render_system_prompt_with_resident_sections(
Some(&summary_doc("resident summary marker")), Some(&summary_doc("resident summary marker")),
Some(manifest::MemoryConfig::default()), Some(manifest::ResolvedMemoryFeatureConfig::default()),
ResidentInjectionGates { summary: false }, ResidentInjectionGates { summary: false },
true, true,
) )
+16 -17
View File
@@ -7993,17 +7993,15 @@ fn start_memory_staging_consolidation(
total_bytes, total_bytes,
}); });
} }
let reached_files = operation const CONSOLIDATION_THRESHOLD_FILES: usize = 5;
.threshold_files const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000;
.is_some_and(|threshold| candidate_count >= threshold); let reached_files = candidate_count >= CONSOLIDATION_THRESHOLD_FILES;
let reached_bytes = operation let reached_bytes = total_bytes >= CONSOLIDATION_THRESHOLD_BYTES;
.threshold_bytes
.is_some_and(|threshold| total_bytes >= threshold);
if !operation.force && !reached_files && !reached_bytes { if !operation.force && !reached_files && !reached_bytes {
return Ok(MemoryConsolidationOutput { return Ok(MemoryConsolidationOutput {
status: "skipped_below_threshold".to_string(), status: "skipped_below_threshold".to_string(),
summary: format!( 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, candidate_count,
total_bytes, total_bytes,
@@ -19921,11 +19919,7 @@ mod tests {
let output = match start_memory_staging_consolidation( let output = match start_memory_staging_consolidation(
api, api,
MemoryConsolidateStagingOperation { MemoryConsolidateStagingOperation { force: true },
force: true,
threshold_files: None,
threshold_bytes: None,
},
) { ) {
Ok(output) => output, Ok(output) => output,
Err(_) => panic!("unexpected ApiError from memory consolidation trigger"), Err(_) => panic!("unexpected ApiError from memory consolidation trigger"),
@@ -19956,6 +19950,15 @@ mod tests {
) )
.unwrap(); .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 resolved_config_bundle = None;
let existing = api let existing = api
.runtime .runtime
@@ -20010,11 +20013,7 @@ mod tests {
let second = match start_memory_staging_consolidation( let second = match start_memory_staging_consolidation(
api.clone(), api.clone(),
MemoryConsolidateStagingOperation { MemoryConsolidateStagingOperation { force: true },
force: true,
threshold_files: None,
threshold_bytes: None,
},
) { ) {
Ok(output) => output, Ok(output) => output,
Err(_) => panic!("unexpected ApiError from second memory consolidation trigger"), Err(_) => panic!("unexpected ApiError from second memory consolidation trigger"),
+25 -42
View File
@@ -222,55 +222,38 @@ permission = "write"
# # ref = "anthropic/claude-haiku-4-5" # # ref = "anthropic/claude-haiku-4-5"
# ===== [memory] ============================================================= # ===== [feature.memory] ======================================================
# Memory subsystem の opt-in。 # Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では
# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、 # Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを
# `<workspace>/memory/` と `<workspace>/` # `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが
# の通常 write を Worker 自体に対して deny する # bindする信頼済み入力で、Profile・Browser・model入力から指定できない
# - セクションが *無い* … 何も起きない (legacy 動作)。 # `profile.enabled = false` の場合、Memory tools、resident injection、extract、
# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化) # consolidation requestをすべて無効にし、snapshotも保持しない
# [memory]
# #
# # 任意。デフォルト: Worker の pwd (構築時)。 # [feature.memory.profile]
# # 必ず絶対パス (相対なら manifest base 起点で resolve)。 # enabled = true
# workspace_root = "/abs/path/to/workspace" # staging_tools = false
# #
# # 任意。デフォルト: tool 側既定 = 20。 # [feature.memory.profile.resident]
# # MemoryQuery / MemoryQuery が 1 回に返す最大件数。 # inject_summary = true
# query_result_limit = 20
# #
# # 任意。デフォルト: tool 側既定 = 3。 # [feature.memory.profile.extraction]
# # 各マッチ前後に表示するコンテキスト行数。`query` 省略時は無視。 # enabled = true
# query_excerpt_lines = 3 # threshold = 30000
# worker_max_turns = 8
# #
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製 # # 任意。省略時はmain modelをcloneする
# # extract ワーカーのモデル ([model] と同じ形式)。 # # [feature.memory.profile.extraction.model]
# # Haiku / 4o-mini / Flash クラスの軽量 reasoning モデル推奨。
# # [memory.extract_model]
# # ref = "anthropic/claude-haiku-4-5" # # ref = "anthropic/claude-haiku-4-5"
# #
# # 任意。デフォルト: なし (extract 自動発火を完全停止) # # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない
# # 前回 extract pointer 以降の累積入力 token がこの値を超えると extract 起動。 # # [feature.memory.workspace_settings]
# # ※ memory tools と resident injection は extract_threshold が None でも動く。 # # workspace_id = "workspace-id"
# extract_threshold = 30000 # # settings_revision = 1
# # language = "日本語"
# #
# # 任意。デフォルト: 8 (`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`)。 # Query結果/抜粋の上限とconsolidation eligibility/thresholdはoperation/Backend
# # extract worker 自身の tool loop 上限。Rust config で None の場合のみ無制限 # policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する
# 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
# ===== [skills] ============================================================= # ===== [skills] =============================================================
+8 -7
View File
@@ -23,7 +23,14 @@ compaction = {
feature = { feature = {
task = { enabled = true; }; task = { enabled = true; };
memory = { enabled = true; }; memory = {
enabled = true;
resident = { inject_summary = true; };
extraction = {
enabled = true;
threshold = 50000;
};
};
web = { enabled = true; }; web = { enabled = true; };
image = { enabled = true; }; image = { enabled = true; };
sub_worker = { enabled = false; }; sub_worker = { enabled = false; };
@@ -40,12 +47,6 @@ feature = {
}; };
}; };
memory = {
extract_threshold = 50000;
consolidation_threshold_files = 5;
consolidation_threshold_bytes = 50000;
};
web = { web = {
enabled = true; enabled = true;
search = { search = {
+1 -1
View File
@@ -6,7 +6,7 @@ import "./base.dcdl" // {
feature = { feature = {
task = { enabled = true; }; task = { enabled = true; };
memory = { enabled = false; staging = false; }; memory = { enabled = false; staging_tools = false; };
web = { enabled = true; }; web = { enabled = true; };
image = { enabled = true; }; image = { enabled = true; };
sub_worker = { enabled = true; }; sub_worker = { enabled = true; };
+1 -1
View File
@@ -5,7 +5,7 @@ import "./base.dcdl" // {
feature = { feature = {
task = { enabled = false; }; task = { enabled = false; };
memory = { enabled = true; staging = true; }; memory = { enabled = true; staging_tools = true; };
web = { enabled = false; }; web = { enabled = false; };
sub_worker = { enabled = false; }; sub_worker = { enabled = false; };
worker = { enabled = false; }; worker = { enabled = false; };