feat: connect subjektiv extraction lifecycle
This commit is contained in:
@@ -19,9 +19,10 @@ use crate::{
|
||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
|
||||
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
||||
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ResolvedSubjektivFeatureConfig,
|
||||
ScopeConfig, SessionConfig, SkillsConfig, SubjektivFeatureProfileConfig, TicketFeatureConfig,
|
||||
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
||||
WorkerManifest, WorkerMeta,
|
||||
};
|
||||
|
||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||
@@ -76,6 +77,8 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub memory: Option<MemoryFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub subjektiv: Option<SubjektivFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub web: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub image: Option<FeatureFlagConfigPartial>,
|
||||
@@ -104,6 +107,11 @@ impl FeatureConfigPartial {
|
||||
Self {
|
||||
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
|
||||
memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
|
||||
subjektiv: merge_option(
|
||||
self.subjektiv,
|
||||
other.subjektiv,
|
||||
SubjektivFeatureConfigPartial::merge,
|
||||
),
|
||||
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
|
||||
image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
|
||||
sub_worker: merge_option(
|
||||
@@ -227,6 +235,28 @@ pub struct MemoryConsolidationProfileConfigPartial {
|
||||
pub request_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||
}
|
||||
|
||||
impl SubjektivFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
extraction: merge_option(
|
||||
self.extraction,
|
||||
other.extraction,
|
||||
MemoryExtractionProfileConfigPartial::merge,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
@@ -331,6 +361,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.memory
|
||||
.map(ResolvedMemoryFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
subjektiv: value
|
||||
.subjektiv
|
||||
.map(ResolvedSubjektivFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
sub_worker: value
|
||||
@@ -447,6 +481,42 @@ impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
|
||||
fn from(value: SubjektivFeatureConfigPartial) -> Self {
|
||||
let extraction = value.extraction.unwrap_or_default();
|
||||
Self {
|
||||
profile: SubjektivFeatureProfileConfig {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
extraction: MemoryExtractionProfileConfig {
|
||||
enabled: extraction.enabled.unwrap_or(true),
|
||||
model: extraction.model,
|
||||
reasoning: extraction.reasoning,
|
||||
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<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
|
||||
fn from(value: ResolvedSubjektivFeatureConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.profile.enabled),
|
||||
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||
enabled: Some(value.profile.extraction.enabled),
|
||||
model: value.profile.extraction.model,
|
||||
reasoning: value.profile.extraction.reasoning,
|
||||
threshold: value.profile.extraction.threshold,
|
||||
worker_max_turns: value.profile.extraction.worker_max_turns,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
|
||||
fn from(value: TicketFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
@@ -500,6 +570,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
Self {
|
||||
task: Some(value.task.into()),
|
||||
memory: Some(value.memory.into()),
|
||||
subjektiv: Some(value.subjektiv.into()),
|
||||
web: Some(value.web.into()),
|
||||
image: Some(value.image.into()),
|
||||
sub_worker: Some(value.sub_worker.into()),
|
||||
@@ -2490,6 +2561,36 @@ directories = [".claude/skills", ".cursor/skills"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_config_is_explicit_and_roundtrips_without_subject_identity() {
|
||||
let config = WorkerManifestConfig::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "subject-worker"
|
||||
|
||||
[feature.subjektiv]
|
||||
enabled = true
|
||||
|
||||
[feature.subjektiv.extraction]
|
||||
enabled = true
|
||||
threshold = 321
|
||||
worker_max_turns = 7
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let partial = config.feature.subjektiv.unwrap();
|
||||
assert_eq!(partial.enabled, Some(true));
|
||||
assert_eq!(partial.extraction.as_ref().unwrap().threshold, Some(321));
|
||||
|
||||
let resolved = ResolvedSubjektivFeatureConfig::from(partial);
|
||||
assert!(resolved.profile.enabled);
|
||||
assert_eq!(resolved.profile.extraction.threshold, Some(321));
|
||||
assert_eq!(resolved.profile.extraction.worker_max_turns, Some(7));
|
||||
let encoded = serde_json::to_value(SubjektivFeatureConfigPartial::from(resolved)).unwrap();
|
||||
assert!(encoded.get("subject_id").is_none());
|
||||
assert_eq!(encoded["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_ref() {
|
||||
let lower = WorkerManifestConfig {
|
||||
|
||||
@@ -108,6 +108,11 @@ pub struct FeatureConfig {
|
||||
pub task: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub memory: ResolvedMemoryFeatureConfig,
|
||||
/// Subject-scoped Memory extraction. Subject identity is resolved by the
|
||||
/// Workspace host from the Worker's keyed singleton lease, never from this
|
||||
/// model-visible manifest.
|
||||
#[serde(default)]
|
||||
pub subjektiv: ResolvedSubjektivFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub web: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
@@ -139,6 +144,7 @@ impl Default for FeatureConfig {
|
||||
Self {
|
||||
task: FeatureFlagConfig::disabled(),
|
||||
memory: ResolvedMemoryFeatureConfig::default(),
|
||||
subjektiv: ResolvedSubjektivFeatureConfig::default(),
|
||||
web: FeatureFlagConfig::disabled(),
|
||||
image: FeatureFlagConfig::disabled(),
|
||||
sub_worker: FeatureFlagConfig::disabled(),
|
||||
@@ -350,6 +356,79 @@ impl ResolvedMemoryFeatureConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile-owned subjektiv activation and extraction policy. The subject id is
|
||||
/// deliberately absent: the Workspace host binds it from `subjektiv:<id>`
|
||||
/// singleton ownership for each operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct SubjektivFeatureProfileConfig {
|
||||
pub enabled: bool,
|
||||
pub extraction: MemoryExtractionProfileConfig,
|
||||
}
|
||||
|
||||
impl Default for SubjektivFeatureProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
extraction: MemoryExtractionProfileConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable subjektiv execution configuration persisted in a resolved Worker
|
||||
/// Manifest. Workspace settings provide only trusted extraction language and
|
||||
/// are not subject identity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ResolvedSubjektivFeatureConfig {
|
||||
pub profile: SubjektivFeatureProfileConfig,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
|
||||
}
|
||||
|
||||
impl ResolvedSubjektivFeatureConfig {
|
||||
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 subjektiv feature must not carry Workspace settings");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.workspace_settings.is_some() {
|
||||
return Err("subjektiv 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 subjektiv feature requires trusted Workspace settings");
|
||||
}
|
||||
if !self.profile.enabled && self.workspace_settings.is_some() {
|
||||
return Err("disabled subjektiv feature must not carry Workspace settings");
|
||||
}
|
||||
if let Some(settings) = &self.workspace_settings
|
||||
&& (settings.settings_revision == 0
|
||||
|| !is_normalized_workspace_memory_language(&settings.language))
|
||||
{
|
||||
return Err("subjektiv Workspace settings snapshot metadata is invalid");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct TicketFeatureConfig {
|
||||
#[serde(default)]
|
||||
@@ -963,7 +1042,9 @@ impl Default for CompactionConfig {
|
||||
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|
||||
self.profile.is_some()
|
||||
|| self.feature.memory.workspace_settings.is_some()
|
||||
|| self.feature.subjektiv.workspace_settings.is_some()
|
||||
}
|
||||
|
||||
/// Parse a manifest from a TOML string.
|
||||
|
||||
@@ -144,6 +144,7 @@ pub enum WorkspaceAuthorityRequirement {
|
||||
Flow,
|
||||
ManageWorkdir,
|
||||
Memory,
|
||||
Subjektiv,
|
||||
MergeRequest,
|
||||
Objective,
|
||||
Orchestration,
|
||||
@@ -157,6 +158,7 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
|
||||
Self::Flow => formatter.write_str("feature.flow"),
|
||||
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
||||
Self::Memory => formatter.write_str("feature.memory"),
|
||||
Self::Subjektiv => formatter.write_str("feature.subjektiv"),
|
||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
||||
Self::Objective => formatter.write_str("feature.objective"),
|
||||
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
||||
@@ -185,6 +187,9 @@ pub fn validate_profile_execution_target(
|
||||
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
||||
}
|
||||
if feature.subjektiv.profile.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Subjektiv);
|
||||
}
|
||||
if feature.merge_request.show
|
||||
|| feature.merge_request.open
|
||||
|| feature.merge_request.review
|
||||
|
||||
Reference in New Issue
Block a user