podのマニフェストの分離実装

This commit is contained in:
2026-04-16 00:54:27 +09:00
parent 5848954ca8
commit 81e28a3c07
21 changed files with 2051 additions and 206 deletions
+2
View File
@@ -6,8 +6,10 @@ license.workspace = true
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde_ignored = "0.1.14"
thiserror = "2.0.18"
toml = "1.1.2"
tracing = "0.1.44"
[dev-dependencies]
tempfile = "3.27.0"
+670
View File
@@ -0,0 +1,670 @@
//! Partial-form of [`crate::PodManifest`] used as cascade layers.
//!
//! `PodManifestConfig` mirrors `PodManifest` but every field is optional
//! so individual layers (builtin defaults, user manifest, project
//! manifest, programmatic overlay) can be partial. Layers are combined
//! via [`PodManifestConfig::merge`] and the final config is converted to
//! a validated [`PodManifest`] via `TryFrom`.
use std::collections::HashMap;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::defaults;
use crate::{
CompactionConfig, PodManifest, PodMeta, ProviderConfig, ProviderKind, ScopeConfig,
ToolOutputLimits, WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
/// instances merge via [`PodManifestConfig::merge`] before being
/// converted to a validated [`PodManifest`] via `TryFrom`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PodManifestConfig {
#[serde(default)]
pub pod: PodMetaConfig,
#[serde(default)]
pub provider: ProviderConfigPartial,
#[serde(default)]
pub worker: WorkerManifestConfig,
#[serde(default)]
pub scope: ScopeConfig,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PodMetaConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub pwd: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProviderConfigPartial {
#[serde(default)]
pub kind: Option<ProviderKind>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key_file: Option<PathBuf>,
#[serde(default)]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerManifestConfig {
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub max_tokens: Option<u32>,
#[serde(default)]
pub max_turns: Option<NonZeroU32>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub tool_output: ToolOutputLimitsPartial,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolOutputLimitsPartial {
#[serde(default)]
pub default_max_bytes: Option<usize>,
#[serde(default)]
pub per_tool: HashMap<String, usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactionConfigPartial {
#[serde(default)]
pub prune_protected_turns: Option<usize>,
#[serde(default)]
pub prune_min_savings: Option<u64>,
#[serde(default)]
pub compact_threshold: Option<u64>,
#[serde(default)]
pub compact_retained_turns: Option<usize>,
#[serde(default)]
pub provider: Option<ProviderConfigPartial>,
}
/// Errors raised when converting a [`PodManifestConfig`] to a validated
/// [`PodManifest`] via `TryFrom`.
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("path must be absolute ({field}): {}", .path.display())]
RelativePath {
field: &'static str,
path: PathBuf,
},
}
impl PodManifestConfig {
/// Parse a partial manifest from a TOML string. Unknown top-level or
/// nested fields emit a `tracing::warn!` and are ignored; use
/// `tracing_subscriber` with `WARN` enabled to surface them to the
/// operator.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
let de = toml::Deserializer::parse(s)?;
serde_ignored::deserialize(de, |path| {
tracing::warn!("unknown field in manifest: {}", path);
})
}
/// Cascade layer populated with the in-code defaults listed in
/// [`crate::defaults`]. Used by [`PodFactory::resolve`] as the
/// bottom layer, so every per-field default lives at exactly one
/// call site (the `defaults` module).
///
/// `TryFrom<PodManifestConfig>` also reads the same constants as a
/// belt-and-suspenders fallback, so a manually-constructed config
/// that skips this layer still resolves to the same values.
pub fn builtin_defaults() -> Self {
Self {
worker: WorkerManifestConfig {
tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES),
per_tool: HashMap::new(),
},
..Default::default()
},
..Default::default()
}
}
/// Merge `upper` into `self`. Fields present in `upper` override
/// fields from `self`. Map entries merge key-wise with `upper`
/// winning on conflict. Scope rules from both layers accumulate
/// (see [`ScopeConfig`] semantics).
pub fn merge(self, upper: PodManifestConfig) -> Self {
Self {
pod: self.pod.merge(upper.pod),
provider: self.provider.merge(upper.provider),
worker: self.worker.merge(upper.worker),
scope: merge_scope(self.scope, upper.scope),
compaction: merge_option(
self.compaction,
upper.compaction,
CompactionConfigPartial::merge,
),
}
}
}
impl PodMetaConfig {
fn merge(self, upper: Self) -> Self {
Self {
name: upper.name.or(self.name),
pwd: upper.pwd.or(self.pwd),
}
}
}
impl ProviderConfigPartial {
fn merge(self, upper: Self) -> Self {
Self {
kind: upper.kind.or(self.kind),
model: upper.model.or(self.model),
api_key_file: upper.api_key_file.or(self.api_key_file),
base_url: upper.base_url.or(self.base_url),
}
}
}
impl WorkerManifestConfig {
fn merge(self, upper: Self) -> Self {
Self {
system_prompt: upper.system_prompt.or(self.system_prompt),
max_tokens: upper.max_tokens.or(self.max_tokens),
max_turns: upper.max_turns.or(self.max_turns),
temperature: upper.temperature.or(self.temperature),
tool_output: self.tool_output.merge(upper.tool_output),
}
}
}
impl ToolOutputLimitsPartial {
fn merge(self, upper: Self) -> Self {
let mut per_tool = self.per_tool;
per_tool.extend(upper.per_tool);
Self {
default_max_bytes: upper.default_max_bytes.or(self.default_max_bytes),
per_tool,
}
}
}
impl CompactionConfigPartial {
fn merge(self, upper: Self) -> Self {
Self {
prune_protected_turns: upper.prune_protected_turns.or(self.prune_protected_turns),
prune_min_savings: upper.prune_min_savings.or(self.prune_min_savings),
compact_threshold: upper.compact_threshold.or(self.compact_threshold),
compact_retained_turns: upper
.compact_retained_turns
.or(self.compact_retained_turns),
provider: merge_option(self.provider, upper.provider, ProviderConfigPartial::merge),
}
}
}
fn merge_scope(mut lower: ScopeConfig, upper: ScopeConfig) -> ScopeConfig {
lower.allow.extend(upper.allow);
lower.deny.extend(upper.deny);
lower
}
fn merge_option<T>(lower: Option<T>, upper: Option<T>, merge: fn(T, T) -> T) -> Option<T> {
match (lower, upper) {
(Some(l), Some(u)) => Some(merge(l, u)),
(l, u) => u.or(l),
}
}
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
if path.is_absolute() {
Ok(())
} else {
Err(ResolveError::RelativePath {
field,
path: path.to_path_buf(),
})
}
}
fn resolve_provider(
cfg: ProviderConfigPartial,
kind_field: &'static str,
model_field: &'static str,
api_key_field: &'static str,
) -> Result<ProviderConfig, ResolveError> {
let kind = cfg.kind.ok_or(ResolveError::MissingField(kind_field))?;
let model = cfg.model.ok_or(ResolveError::MissingField(model_field))?;
if let Some(ref p) = cfg.api_key_file {
ensure_absolute(api_key_field, p)?;
}
Ok(ProviderConfig {
kind,
model,
api_key_file: cfg.api_key_file,
base_url: cfg.base_url,
})
}
impl TryFrom<PodManifestConfig> for PodManifest {
type Error = ResolveError;
fn try_from(cfg: PodManifestConfig) -> Result<Self, Self::Error> {
let name = cfg
.pod
.name
.ok_or(ResolveError::MissingField("pod.name"))?;
let pwd = cfg.pod.pwd.ok_or(ResolveError::MissingField("pod.pwd"))?;
ensure_absolute("pod.pwd", &pwd)?;
let provider = resolve_provider(
cfg.provider,
"provider.kind",
"provider.model",
"provider.api_key_file",
)?;
let worker = WorkerManifest {
system_prompt: cfg.worker.system_prompt,
max_tokens: cfg.worker.max_tokens,
max_turns: cfg.worker.max_turns,
temperature: cfg.worker.temperature,
tool_output: ToolOutputLimits {
default_max_bytes: cfg
.worker
.tool_output
.default_max_bytes
.unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
per_tool: cfg.worker.tool_output.per_tool,
},
};
if cfg.scope.allow.is_empty() {
return Err(ResolveError::MissingField("scope.allow"));
}
for rule in &cfg.scope.allow {
ensure_absolute("scope.allow.target", &rule.target)?;
}
for rule in &cfg.scope.deny {
ensure_absolute("scope.deny.target", &rule.target)?;
}
let compaction = cfg
.compaction
.map(|c| -> Result<CompactionConfig, ResolveError> {
let comp_provider = c
.provider
.map(|p| {
resolve_provider(
p,
"compaction.provider.kind",
"compaction.provider.model",
"compaction.provider.api_key_file",
)
})
.transpose()?;
Ok(CompactionConfig {
prune_protected_turns: c
.prune_protected_turns
.unwrap_or(defaults::PRUNE_PROTECTED_TURNS),
prune_min_savings: c
.prune_min_savings
.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
compact_threshold: c.compact_threshold,
compact_retained_turns: c
.compact_retained_turns
.unwrap_or(defaults::COMPACT_RETAINED_TURNS),
provider: comp_provider,
})
})
.transpose()?;
Ok(PodManifest {
pod: PodMeta { name, pwd },
provider,
worker,
scope: cfg.scope,
compaction,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Permission, ScopeRule};
fn abs(path: &str) -> PathBuf {
PathBuf::from(format!("/tmp/insomnia-test{path}"))
}
fn minimal_valid() -> PodManifestConfig {
PodManifestConfig {
pod: PodMetaConfig {
name: Some("test".into()),
pwd: Some(abs("/pod")),
},
provider: ProviderConfigPartial {
kind: Some(ProviderKind::Anthropic),
model: Some("claude-sonnet-4-20250514".into()),
..Default::default()
},
worker: WorkerManifestConfig::default(),
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/pod"),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
},
compaction: None,
}
}
#[test]
fn resolve_minimal_succeeds() {
let manifest: PodManifest = minimal_valid().try_into().unwrap();
assert_eq!(manifest.pod.name, "test");
assert_eq!(manifest.pod.pwd, abs("/pod"));
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
}
#[test]
fn resolve_rejects_relative_pwd() {
let mut cfg = minimal_valid();
cfg.pod.pwd = Some(PathBuf::from("./rel"));
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath { field: "pod.pwd", .. }
));
}
#[test]
fn resolve_rejects_relative_api_key_file() {
let mut cfg = minimal_valid();
cfg.provider.api_key_file = Some(PathBuf::from("~/.config/key"));
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "provider.api_key_file",
..
}
));
}
#[test]
fn resolve_rejects_relative_scope_target() {
let mut cfg = minimal_valid();
cfg.scope.allow[0].target = PathBuf::from("./docs");
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::RelativePath {
field: "scope.allow.target",
..
}
));
}
#[test]
fn resolve_rejects_missing_pod_name() {
let mut cfg = minimal_valid();
cfg.pod.name = None;
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(err, ResolveError::MissingField("pod.name")));
}
#[test]
fn resolve_rejects_empty_scope() {
let mut cfg = minimal_valid();
cfg.scope.allow.clear();
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(err, ResolveError::MissingField("scope.allow")));
}
#[test]
fn merge_scalar_upper_wins() {
let lower = PodManifestConfig {
pod: PodMetaConfig {
name: Some("lower".into()),
pwd: Some(abs("/lower")),
},
..Default::default()
};
let upper = PodManifestConfig {
pod: PodMetaConfig {
name: Some("upper".into()),
pwd: None,
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.pod.name.as_deref(), Some("upper"));
// pwd not present in upper — retain lower
assert_eq!(merged.pod.pwd, Some(abs("/lower")));
}
#[test]
fn merge_scope_accumulates_allow_and_deny() {
let lower = PodManifestConfig {
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/a"),
permission: Permission::Read,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
};
let upper = PodManifestConfig {
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/b"),
permission: Permission::Write,
recursive: true,
}],
deny: vec![ScopeRule {
target: abs("/a/secret"),
permission: Permission::Read,
recursive: false,
}],
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(merged.scope.allow.len(), 2);
assert_eq!(merged.scope.deny.len(), 1);
}
#[test]
fn merge_tool_output_per_tool_keywise() {
let lower = PodManifestConfig {
worker: WorkerManifestConfig {
tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(8192),
per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
},
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
worker: WorkerManifestConfig {
tool_output: ToolOutputLimitsPartial {
default_max_bytes: None,
per_tool: [
("Read".to_string(), 2048),
("Grep".to_string(), 512),
]
.into_iter()
.collect(),
},
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
let to = &merged.worker.tool_output;
assert_eq!(to.default_max_bytes, Some(8192));
assert_eq!(to.per_tool.get("Read"), Some(&2048));
assert_eq!(to.per_tool.get("Grep"), Some(&512));
}
#[test]
fn merge_option_struct_field_wise() {
let lower = PodManifestConfig {
compaction: Some(CompactionConfigPartial {
compact_threshold: Some(50_000),
prune_protected_turns: Some(5),
..Default::default()
}),
..Default::default()
};
let upper = PodManifestConfig {
compaction: Some(CompactionConfigPartial {
compact_threshold: Some(80_000),
..Default::default()
}),
..Default::default()
};
let merged = lower.merge(upper);
let c = merged.compaction.unwrap();
assert_eq!(c.compact_threshold, Some(80_000));
// field from lower retained when upper has None
assert_eq!(c.prune_protected_turns, Some(5));
}
#[test]
fn from_toml_type_mismatch_is_hard_error() {
let bad = r#"
[pod]
name = "x"
pwd = "/abs"
[worker]
max_tokens = "not-a-number"
"#;
assert!(PodManifestConfig::from_toml(bad).is_err());
}
#[test]
fn from_toml_accepts_unknown_field() {
// Unknown keys are warn-and-ignored, not hard errors.
let ok = r#"
[pod]
name = "x"
pwd = "/abs"
[worker]
max_tokens = 1000
unknown_future_field = "tolerated"
"#;
let cfg = PodManifestConfig::from_toml(ok).unwrap();
assert_eq!(cfg.worker.max_tokens, Some(1000));
}
#[test]
fn from_toml_partial_layer_succeeds() {
// A project-layer manifest with only scope set must parse fine.
let toml = r#"
[[scope.allow]]
target = "/abs/project"
permission = "write"
"#;
let cfg = PodManifestConfig::from_toml(toml).unwrap();
assert!(cfg.pod.name.is_none());
assert_eq!(cfg.scope.allow.len(), 1);
}
#[test]
fn builtin_defaults_populates_tool_output_max_bytes() {
let cfg = PodManifestConfig::builtin_defaults();
assert_eq!(
cfg.worker.tool_output.default_max_bytes,
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
);
}
#[test]
fn builtin_defaults_merged_into_minimal_resolves_with_defaults() {
// Starting from builtin_defaults and overlaying only the
// required fields must resolve to a PodManifest carrying the
// centralised default values.
let overlay = PodManifestConfig {
pod: PodMetaConfig {
name: Some("x".into()),
pwd: Some(abs("/pod")),
},
provider: ProviderConfigPartial {
kind: Some(ProviderKind::Anthropic),
model: Some("m".into()),
..Default::default()
},
scope: ScopeConfig {
allow: vec![ScopeRule {
target: abs("/pod"),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
},
..Default::default()
};
let merged = PodManifestConfig::builtin_defaults().merge(overlay);
let manifest: PodManifest = merged.try_into().unwrap();
assert_eq!(
manifest.worker.tool_output.default_max_bytes,
defaults::TOOL_OUTPUT_MAX_BYTES
);
}
#[test]
fn end_to_end_cascade() {
let builtin = PodManifestConfig::default();
let user = PodManifestConfig::from_toml(
r#"
[provider]
kind = "anthropic"
model = "claude-sonnet-4-20250514"
"#,
)
.unwrap();
let project = PodManifestConfig::from_toml(
r#"
[[scope.allow]]
target = "/abs/project"
permission = "write"
"#,
)
.unwrap();
let overlay = PodManifestConfig::from_toml(
r#"
[pod]
name = "dbg"
pwd = "/abs/project"
"#,
)
.unwrap();
let merged = builtin.merge(user).merge(project).merge(overlay);
let manifest: PodManifest = merged.try_into().unwrap();
assert_eq!(manifest.pod.name, "dbg");
assert_eq!(manifest.pod.pwd, PathBuf::from("/abs/project"));
assert_eq!(manifest.provider.kind, ProviderKind::Anthropic);
assert_eq!(manifest.scope.allow.len(), 1);
}
}
+23
View File
@@ -0,0 +1,23 @@
//! Single source of truth for manifest default values.
//!
//! Every default that would otherwise be duplicated between serde
//! `#[serde(default = "...")]` attributes (on [`crate::PodManifest`])
//! and the cascade resolution in [`crate::config`] lives here as a
//! `pub const`. Both paths read from this module, so changing a
//! default requires editing exactly one line.
/// Byte-size cap applied to any tool's `content` output when no
/// per-tool override is set. See [`crate::ToolOutputLimits`].
pub const TOOL_OUTPUT_MAX_BYTES: usize = 16 * 1024;
/// Number of most-recent turns protected from pruning. See
/// [`crate::CompactionConfig::prune_protected_turns`].
pub const PRUNE_PROTECTED_TURNS: usize = 3;
/// Minimum estimated token savings required to trigger a prune. See
/// [`crate::CompactionConfig::prune_min_savings`].
pub const PRUNE_MIN_SAVINGS: u64 = 4096;
/// Number of most-recent turns retained after a compact. See
/// [`crate::CompactionConfig::compact_retained_turns`].
pub const COMPACT_RETAINED_TURNS: usize = 2;
+10 -4
View File
@@ -1,5 +1,11 @@
mod config;
pub mod defaults;
mod scope;
pub use config::{
CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ProviderConfigPartial, ResolveError,
ToolOutputLimitsPartial, WorkerManifestConfig,
};
pub use scope::{Scope, ScopeError};
use std::collections::HashMap;
@@ -106,7 +112,7 @@ pub struct ToolOutputLimits {
}
fn default_tool_output_max_bytes() -> usize {
16 * 1024
defaults::TOOL_OUTPUT_MAX_BYTES
}
impl Default for ToolOutputLimits {
@@ -206,13 +212,13 @@ pub struct CompactionConfig {
}
fn default_prune_protected_turns() -> usize {
3
defaults::PRUNE_PROTECTED_TURNS
}
fn default_prune_min_savings() -> u64 {
4096
defaults::PRUNE_MIN_SAVINGS
}
fn default_compact_retained_turns() -> usize {
2
defaults::COMPACT_RETAINED_TURNS
}
impl Default for CompactionConfig {