podのマニフェストの分離実装
This commit is contained in:
parent
5848954ca8
commit
81e28a3c07
32
Cargo.lock
generated
32
Cargo.lock
generated
|
|
@ -1255,6 +1255,25 @@ dependencies = [
|
|||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||
dependencies = [
|
||||
"include_dir_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir_macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.1"
|
||||
|
|
@ -1489,9 +1508,11 @@ name = "manifest"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1873,6 +1894,7 @@ dependencies = [
|
|||
"clap",
|
||||
"dotenv",
|
||||
"futures",
|
||||
"include_dir",
|
||||
"llm-worker",
|
||||
"manifest",
|
||||
"minijinja",
|
||||
|
|
@ -2417,6 +2439,16 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_ignored"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
|
|
|
|||
|
|
@ -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
crates/manifest/src/config.rs
Normal file
670
crates/manifest/src/config.rs
Normal 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
crates/manifest/src/defaults.rs
Normal file
23
crates/manifest/src/defaults.rs
Normal 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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ tracing = "0.1.44"
|
|||
tools = { version = "0.1.0", path = "../tools" }
|
||||
minijinja = "2.19.0"
|
||||
chrono = "0.4.44"
|
||||
include_dir = "0.7.4"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@
|
|||
use pod::{Pod, PodManifest, PodRunResult};
|
||||
use session_store::FsStore;
|
||||
|
||||
const MANIFEST_TOML: &str = r#"
|
||||
fn manifest_toml(pwd: &std::path::Path) -> String {
|
||||
let pwd = pwd.display();
|
||||
format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "hello-pod"
|
||||
pwd = "./"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
|
|
@ -28,24 +31,29 @@ system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
|||
max_tokens = 256
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
target = "{pwd}"
|
||||
permission = "write"
|
||||
"#;
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
// 1. Parse the manifest
|
||||
let manifest = PodManifest::from_toml(MANIFEST_TOML)?;
|
||||
println!("Pod: {}", manifest.pod.name);
|
||||
// 1. Build a manifest rooted at the current working directory.
|
||||
// All paths in a manifest must be absolute — see the pod-factory ticket.
|
||||
let pwd = std::env::current_dir()?;
|
||||
let toml = manifest_toml(&pwd);
|
||||
|
||||
// 2. Create a persistent store (temp dir for demo)
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let store = FsStore::new(tmp.path()).await?;
|
||||
|
||||
// 3. Build the Pod from manifest
|
||||
let mut pod = Pod::from_manifest(manifest, store, None).await?;
|
||||
// 3. Build the Pod from the single-layer manifest TOML
|
||||
let mut pod = Pod::from_manifest_toml(&toml, store).await?;
|
||||
let manifest: &PodManifest = pod.manifest();
|
||||
println!("Pod: {}", manifest.pod.name);
|
||||
println!("Session: {}", pod.session_id());
|
||||
|
||||
// 4. Run a prompt
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@
|
|||
//! cargo run -p pod --example pod_protocol
|
||||
//! ```
|
||||
|
||||
use pod::{Event, Method, PodController, PodManifest};
|
||||
use pod::{Event, Method, PodController};
|
||||
use session_store::FsStore;
|
||||
|
||||
const MANIFEST_TOML: &str = r#"
|
||||
fn manifest_toml(pwd: &std::path::Path) -> String {
|
||||
let pwd = pwd.display();
|
||||
format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "protocol-demo"
|
||||
pwd = "./"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
|
|
@ -22,18 +25,22 @@ system_prompt = "You are a concise assistant. Reply in one or two sentences."
|
|||
max_tokens = 256
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
target = "{pwd}"
|
||||
permission = "write"
|
||||
"#;
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
let manifest = PodManifest::from_toml(MANIFEST_TOML)?;
|
||||
// All manifest paths must be absolute — see the pod-factory ticket.
|
||||
let pwd = std::env::current_dir()?;
|
||||
let toml = manifest_toml(&pwd);
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let store = FsStore::new(tmp.path()).await?;
|
||||
let pod = pod::Pod::from_manifest(manifest, store, None).await?;
|
||||
let pod = pod::Pod::from_manifest_toml(&toml, store).await?;
|
||||
|
||||
let runtime_tmp = tempfile::tempdir()?;
|
||||
let handle = PodController::spawn(pod, runtime_tmp.path()).await?;
|
||||
|
|
|
|||
539
crates/pod/src/factory.rs
Normal file
539
crates/pod/src/factory.rs
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
//! Builder that assembles a [`PodManifest`] from cascade layers.
|
||||
//!
|
||||
//! Layers are merged in order of increasing priority:
|
||||
//! 1. **Builtin defaults** — in-code defaults, currently empty. Upper
|
||||
//! layers provide everything; `TryFrom<PodManifestConfig>` fills in
|
||||
//! per-field defaults (`ToolOutputLimits`, `CompactionConfig`, ...).
|
||||
//! 2. **User manifest** — `$XDG_CONFIG_HOME/insomnia/manifest.toml`
|
||||
//! (falling back to `~/.config/insomnia/manifest.toml`).
|
||||
//! 3. **Project manifest** — closest `.insomnia/manifest.toml` found by
|
||||
//! walking up from `cwd`.
|
||||
//! 4. **Programmatic overlay** — inline TOML string or typed
|
||||
//! [`PodManifestConfig`] supplied by the caller (CLI flags, GUI,
|
||||
//! spawning Pod, etc.). Highest priority.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use manifest::{PodManifest, PodManifestConfig, ResolveError};
|
||||
|
||||
use crate::prompt_loader::PromptLoader;
|
||||
|
||||
/// Errors raised while building a [`PodManifest`] from cascade layers.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FactoryError {
|
||||
#[error("failed to read manifest {}: {source}", .path.display())]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to parse manifest {}: {source}", .path.display())]
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
#[error("failed to parse overlay TOML: {0}")]
|
||||
OverlayParse(#[source] toml::de::Error),
|
||||
#[error("failed to resolve manifest config: {0}")]
|
||||
Resolve(#[source] ResolveError),
|
||||
#[error("cannot locate home directory for user manifest lookup")]
|
||||
HomeDirUnavailable,
|
||||
}
|
||||
|
||||
/// Builder that accumulates cascade layers and resolves them to a
|
||||
/// validated [`PodManifest`].
|
||||
///
|
||||
/// Call order does not matter — layers are always merged in the fixed
|
||||
/// priority order listed at the module level. Calling the same
|
||||
/// `with_*` method twice overwrites the previous value for that slot.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PodFactory {
|
||||
user: Option<PodManifestConfig>,
|
||||
project: Option<PodManifestConfig>,
|
||||
overlay: Option<PodManifestConfig>,
|
||||
/// Directory holding the user prompts library — co-located with
|
||||
/// the user manifest when loaded. `<user_manifest_dir>/prompts/`.
|
||||
user_prompts_dir: Option<PathBuf>,
|
||||
/// `<project_root>/.insomnia/prompts/` — co-located with the
|
||||
/// project manifest when loaded.
|
||||
project_prompts_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PodFactory {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Attempt to load the user manifest from the XDG config directory.
|
||||
///
|
||||
/// Looks at `$XDG_CONFIG_HOME/insomnia/manifest.toml` first, then
|
||||
/// falls back to `$HOME/.config/insomnia/manifest.toml`. If the
|
||||
/// resolved file does not exist the call is a no-op — user
|
||||
/// manifests are optional.
|
||||
pub fn with_user_manifest_auto(mut self) -> Result<Self, FactoryError> {
|
||||
let path = user_manifest_path()?;
|
||||
if path.exists() {
|
||||
self.user = Some(read_config_file(&path)?);
|
||||
self.user_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Load the user manifest from an explicit path. The file must
|
||||
/// exist; missing files are an error (unlike the `_auto` variant).
|
||||
pub fn with_user_manifest(mut self, path: impl AsRef<Path>) -> Result<Self, FactoryError> {
|
||||
let path = path.as_ref();
|
||||
self.user = Some(read_config_file(path)?);
|
||||
self.user_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Walk up from `cwd` looking for a `.insomnia/manifest.toml` and
|
||||
/// load it as the project layer. If no project root is found the
|
||||
/// call is a no-op.
|
||||
pub fn with_project_manifest_auto(mut self) -> Result<Self, FactoryError> {
|
||||
let cwd = std::env::current_dir().map_err(|source| FactoryError::Io {
|
||||
path: PathBuf::from("."),
|
||||
source,
|
||||
})?;
|
||||
if let Some(path) = find_project_manifest(&cwd) {
|
||||
self.project = Some(read_config_file(&path)?);
|
||||
self.project_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Walk up from `start` looking for a `.insomnia/manifest.toml`.
|
||||
/// Explicit variant of [`with_project_manifest_auto`] for tests.
|
||||
pub fn with_project_manifest_from(
|
||||
mut self,
|
||||
start: impl AsRef<Path>,
|
||||
) -> Result<Self, FactoryError> {
|
||||
if let Some(path) = find_project_manifest(start.as_ref()) {
|
||||
self.project = Some(read_config_file(&path)?);
|
||||
self.project_prompts_dir = path.parent().map(|p| p.join("prompts"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Install a programmatic overlay parsed from a TOML string. This
|
||||
/// is the highest-priority layer — use it to inject per-spawn
|
||||
/// values like `pod.name` or `pod.pwd` from CLI flags.
|
||||
pub fn with_overlay_toml(mut self, toml: &str) -> Result<Self, FactoryError> {
|
||||
let config = PodManifestConfig::from_toml(toml).map_err(FactoryError::OverlayParse)?;
|
||||
self.overlay = Some(match self.overlay {
|
||||
Some(existing) => existing.merge(config),
|
||||
None => config,
|
||||
});
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Install a programmatic overlay from an already-parsed config.
|
||||
pub fn with_overlay_config(mut self, config: PodManifestConfig) -> Self {
|
||||
self.overlay = Some(match self.overlay {
|
||||
Some(existing) => existing.merge(config),
|
||||
None => config,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Build a [`PromptLoader`] that reflects the user / project
|
||||
/// prompt directories registered with this factory (a sibling of
|
||||
/// each manifest file: `prompts/`). Missing directories are
|
||||
/// silently skipped.
|
||||
fn build_prompt_loader(&self) -> PromptLoader {
|
||||
let user = self
|
||||
.user_prompts_dir
|
||||
.as_ref()
|
||||
.filter(|p| p.is_dir())
|
||||
.cloned();
|
||||
let project = self
|
||||
.project_prompts_dir
|
||||
.as_ref()
|
||||
.filter(|p| p.is_dir())
|
||||
.cloned();
|
||||
PromptLoader::new(user, project)
|
||||
}
|
||||
|
||||
/// Merge all installed layers, convert the result to a validated
|
||||
/// [`PodManifest`], and return it together with a [`PromptLoader`]
|
||||
/// that reflects the user / project prompt directories. The loader
|
||||
/// feeds `{% include "name" %}` references in the Pod's system
|
||||
/// prompt template.
|
||||
///
|
||||
/// The base layer is [`PodManifestConfig::builtin_defaults`] so
|
||||
/// every per-field default flows through a single source of truth
|
||||
/// (see [`manifest::defaults`]).
|
||||
pub fn resolve(self) -> Result<(PodManifest, PromptLoader), FactoryError> {
|
||||
let loader = self.build_prompt_loader();
|
||||
let merged = PodManifestConfig::builtin_defaults();
|
||||
let merged = match self.user {
|
||||
Some(user) => merged.merge(user),
|
||||
None => merged,
|
||||
};
|
||||
let merged = match self.project {
|
||||
Some(project) => merged.merge(project),
|
||||
None => merged,
|
||||
};
|
||||
let merged = match self.overlay {
|
||||
Some(overlay) => merged.merge(overlay),
|
||||
None => merged,
|
||||
};
|
||||
let manifest = PodManifest::try_from(merged).map_err(FactoryError::Resolve)?;
|
||||
Ok((manifest, loader))
|
||||
}
|
||||
}
|
||||
|
||||
fn user_manifest_path() -> Result<PathBuf, FactoryError> {
|
||||
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
|
||||
if !dir.is_empty() {
|
||||
return Ok(PathBuf::from(dir).join("insomnia").join("manifest.toml"));
|
||||
}
|
||||
}
|
||||
let home = std::env::var("HOME").map_err(|_| FactoryError::HomeDirUnavailable)?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("insomnia")
|
||||
.join("manifest.toml"))
|
||||
}
|
||||
|
||||
fn find_project_manifest(start: &Path) -> Option<PathBuf> {
|
||||
let start = start.canonicalize().ok().unwrap_or_else(|| start.to_path_buf());
|
||||
let mut cur: Option<&Path> = Some(start.as_path());
|
||||
while let Some(dir) = cur {
|
||||
let candidate = dir.join(".insomnia").join("manifest.toml");
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
cur = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_config_file(path: &Path) -> Result<PodManifestConfig, FactoryError> {
|
||||
let toml = std::fs::read_to_string(path).map_err(|source| FactoryError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
PodManifestConfig::from_toml(&toml).map_err(|source| FactoryError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write(path: &Path, contents: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_overlay_only() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pwd = tmp.path().canonicalize().unwrap();
|
||||
let overlay = format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "solo"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{pwd}"
|
||||
permission = "write"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
);
|
||||
let manifest = PodFactory::new()
|
||||
.with_overlay_toml(&overlay)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
let manifest = manifest.0;
|
||||
assert_eq!(manifest.pod.name, "solo");
|
||||
assert_eq!(manifest.pod.pwd, pwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_stacking_merges_in_place() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pwd = tmp.path().canonicalize().unwrap();
|
||||
let user_cfg = PodManifestConfig::from_toml(&format!(
|
||||
r#"
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "user-model"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{pwd}"
|
||||
permission = "read"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
))
|
||||
.unwrap();
|
||||
let project_cfg = PodManifestConfig::from_toml(&format!(
|
||||
r#"
|
||||
[provider]
|
||||
model = "project-model"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{pwd}"
|
||||
permission = "write"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
))
|
||||
.unwrap();
|
||||
let overlay_cfg = PodManifestConfig::from_toml(&format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "overlay-name"
|
||||
pwd = "{pwd}"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let (manifest, _loader) = PodFactory::new()
|
||||
.with_overlay_config(user_cfg)
|
||||
.with_overlay_config(project_cfg)
|
||||
.with_overlay_config(overlay_cfg)
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
// Note: stacking via with_overlay_config merges into one
|
||||
// overlay layer so later calls win. This also exercises the
|
||||
// scope union across layers (two allow rules).
|
||||
assert_eq!(manifest.pod.name, "overlay-name");
|
||||
assert_eq!(manifest.provider.model, "project-model");
|
||||
assert_eq!(manifest.scope.allow.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_priority_layer_ordering() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pwd = tmp.path().canonicalize().unwrap();
|
||||
|
||||
// Simulate distinct user / project / overlay layers by using
|
||||
// the dedicated slots on the factory.
|
||||
let user = tmp.path().join("user.toml");
|
||||
write(
|
||||
&user,
|
||||
&format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "from-user"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "user-model"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{pwd}"
|
||||
permission = "write"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
),
|
||||
);
|
||||
|
||||
let project_root = tmp.path().join("proj");
|
||||
let project_manifest = project_root.join(".insomnia").join("manifest.toml");
|
||||
write(
|
||||
&project_manifest,
|
||||
r#"
|
||||
[provider]
|
||||
model = "project-model"
|
||||
"#,
|
||||
);
|
||||
|
||||
let (manifest, _loader) = PodFactory::new()
|
||||
.with_user_manifest(&user)
|
||||
.unwrap()
|
||||
.with_project_manifest_from(&project_root)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
// project layer overrides user layer on provider.model
|
||||
assert_eq!(manifest.provider.model, "project-model");
|
||||
// user layer provides the rest
|
||||
assert_eq!(manifest.pod.name, "from-user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_manifest_walks_up_from_nested_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let project_manifest = root.join(".insomnia").join("manifest.toml");
|
||||
write(
|
||||
&project_manifest,
|
||||
&format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "walked-up"
|
||||
pwd = "{root}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{root}"
|
||||
permission = "write"
|
||||
"#,
|
||||
root = root.display()
|
||||
),
|
||||
);
|
||||
|
||||
let nested = root.join("a").join("b").join("c");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
|
||||
let manifest = PodFactory::new()
|
||||
.with_project_manifest_from(&nested)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
let manifest = manifest.0;
|
||||
assert_eq!(manifest.pod.name, "walked-up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_project_root_is_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pwd = tmp.path().canonicalize().unwrap();
|
||||
let overlay = format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "standalone"
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "m"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{pwd}"
|
||||
permission = "write"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
);
|
||||
|
||||
// The temp dir has no .insomnia/ — walking up should skip the
|
||||
// project layer silently.
|
||||
let manifest = PodFactory::new()
|
||||
.with_project_manifest_from(&pwd)
|
||||
.unwrap()
|
||||
.with_overlay_toml(&overlay)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
let manifest = manifest.0;
|
||||
assert_eq!(manifest.pod.name, "standalone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_produces_loader_with_project_prompts_dir() {
|
||||
use crate::system_prompt::{SystemPromptContext, SystemPromptTemplate};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
// .insomnia/manifest.toml and .insomnia/prompts/coder.md
|
||||
let manifest_path = root.join(".insomnia").join("manifest.toml");
|
||||
write(
|
||||
&manifest_path,
|
||||
&format!(
|
||||
r#"
|
||||
[pod]
|
||||
name = "factory-pod"
|
||||
pwd = "{root}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "m"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{root}"
|
||||
permission = "write"
|
||||
"#,
|
||||
root = root.display()
|
||||
),
|
||||
);
|
||||
let project_prompts_dir = root.join(".insomnia").join("prompts");
|
||||
std::fs::create_dir_all(&project_prompts_dir).unwrap();
|
||||
std::fs::write(
|
||||
project_prompts_dir.join("coder.md"),
|
||||
"PROJECT-OVERRIDE from {{ cwd }}",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_manifest, loader) = PodFactory::new()
|
||||
.with_project_manifest_from(&root)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap();
|
||||
|
||||
// The loader must see the project override, not the builtin.
|
||||
let source = "{% include \"coder\" %}";
|
||||
let tmpl = SystemPromptTemplate::parse_with_loader(source, loader).unwrap();
|
||||
let scope_cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: root.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let scope = Scope::from_config(&scope_cfg, &root).unwrap();
|
||||
let ctx = SystemPromptContext {
|
||||
now: chrono::Utc::now(),
|
||||
cwd: &root,
|
||||
scope: &scope,
|
||||
tool_names: Vec::new(),
|
||||
files: std::collections::BTreeMap::new(),
|
||||
};
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
assert!(
|
||||
rendered.starts_with("PROJECT-OVERRIDE"),
|
||||
"expected project override, got: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_fails_on_missing_required_field() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pwd = tmp.path().canonicalize().unwrap();
|
||||
// pwd set but pod.name missing
|
||||
let overlay = format!(
|
||||
r#"
|
||||
[pod]
|
||||
pwd = "{pwd}"
|
||||
|
||||
[provider]
|
||||
kind = "anthropic"
|
||||
model = "m"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{pwd}"
|
||||
permission = "write"
|
||||
"#,
|
||||
pwd = pwd.display()
|
||||
);
|
||||
let err = PodFactory::new()
|
||||
.with_overlay_toml(&overlay)
|
||||
.unwrap()
|
||||
.resolve()
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FactoryError::Resolve(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,10 @@ pub mod socket_server;
|
|||
mod agents_md;
|
||||
mod compact_interceptor;
|
||||
mod compact_state;
|
||||
mod factory;
|
||||
mod hook_interceptor;
|
||||
mod pod;
|
||||
mod prompt_loader;
|
||||
mod prune;
|
||||
mod system_prompt;
|
||||
mod token_counter;
|
||||
|
|
@ -18,10 +20,12 @@ mod usage_tracker;
|
|||
pub use token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||
|
||||
pub use controller::{PodController, PodHandle};
|
||||
pub use factory::{FactoryError, PodFactory};
|
||||
pub use notifier::Notifier;
|
||||
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
||||
pub use manifest::{PodManifest, ProviderConfig, ProviderKind, Scope};
|
||||
pub use manifest::{PodManifest, PodManifestConfig, ProviderConfig, ProviderKind, Scope};
|
||||
pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
|
||||
pub use prompt_loader::PromptLoader;
|
||||
pub use protocol::{ErrorCode, Event, Method, TurnResult};
|
||||
pub use provider::{ProviderError, build_client};
|
||||
pub use runtime_dir::RuntimeDir;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,39 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use pod::{Pod, PodController};
|
||||
use pod::{Pod, PodController, PodFactory};
|
||||
use session_store::FsStore;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "pod", about = "Run a Pod process from a manifest file")]
|
||||
#[command(
|
||||
name = "pod",
|
||||
about = "Spawn a Pod process from cascaded manifest layers"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to the manifest TOML file
|
||||
#[arg(short, long)]
|
||||
manifest: PathBuf,
|
||||
/// User manifest TOML. Defaults to
|
||||
/// `$XDG_CONFIG_HOME/insomnia/manifest.toml`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
user_manifest: Option<PathBuf>,
|
||||
|
||||
/// Directory for session persistence (default: ~/.insomnia/sessions/)
|
||||
/// Start the project-manifest walk from this directory. When
|
||||
/// omitted, the factory walks up from the current working
|
||||
/// directory looking for `.insomnia/manifest.toml`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
project: Option<PathBuf>,
|
||||
|
||||
/// Inline TOML string applied as the highest-priority overlay
|
||||
/// layer. Example: `--overlay 'pod.name = "dbg"'`.
|
||||
#[arg(long, value_name = "TOML")]
|
||||
overlay: Option<String>,
|
||||
|
||||
/// Shorthand that injects `pod.pwd = <path>` into the overlay
|
||||
/// layer. `--pwd .` uses the current working directory.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pwd: Option<PathBuf>,
|
||||
|
||||
/// Directory for session persistence. Defaults to
|
||||
/// `~/.insomnia/sessions/`.
|
||||
#[arg(short, long)]
|
||||
store: Option<PathBuf>,
|
||||
}
|
||||
|
|
@ -36,30 +57,83 @@ fn default_runtime_dir() -> Result<PathBuf, std::io::Error> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Turn CLI inputs into a single programmatic overlay TOML string,
|
||||
/// combining `--pwd` and `--overlay`. Returns `None` if neither flag
|
||||
/// is set.
|
||||
fn build_overlay_toml(pwd: Option<&PathBuf>, overlay: Option<&str>) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(pwd) = pwd {
|
||||
// Canonicalize the pwd shorthand here so relative CLI arguments
|
||||
// (e.g. `--pwd .`) turn into the absolute path required by the
|
||||
// manifest cascade.
|
||||
let absolute = std::fs::canonicalize(pwd).unwrap_or_else(|_| pwd.clone());
|
||||
parts.push(format!(
|
||||
"[pod]\npwd = \"{}\"\n",
|
||||
absolute.display().to_string().replace('\\', "\\\\")
|
||||
));
|
||||
}
|
||||
if let Some(overlay) = overlay {
|
||||
parts.push(overlay.to_string());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_factory(cli: &Cli) -> Result<PodFactory, String> {
|
||||
let mut factory = PodFactory::new();
|
||||
|
||||
factory = match &cli.user_manifest {
|
||||
Some(path) => factory
|
||||
.with_user_manifest(path)
|
||||
.map_err(|e| format!("failed to load user manifest: {e}"))?,
|
||||
None => factory
|
||||
.with_user_manifest_auto()
|
||||
.map_err(|e| format!("failed to auto-load user manifest: {e}"))?,
|
||||
};
|
||||
|
||||
factory = match &cli.project {
|
||||
Some(path) => factory
|
||||
.with_project_manifest_from(path)
|
||||
.map_err(|e| format!("failed to load project manifest: {e}"))?,
|
||||
None => factory
|
||||
.with_project_manifest_auto()
|
||||
.map_err(|e| format!("failed to auto-load project manifest: {e}"))?,
|
||||
};
|
||||
|
||||
if let Some(overlay) = build_overlay_toml(cli.pwd.as_ref(), cli.overlay.as_deref()) {
|
||||
factory = factory
|
||||
.with_overlay_toml(&overlay)
|
||||
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(factory)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Read and parse the manifest
|
||||
let toml_str = match tokio::fs::read_to_string(&cli.manifest).await {
|
||||
Ok(s) => s,
|
||||
let factory = match build_factory(&cli).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to read manifest {:?}: {e}", cli.manifest);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let manifest = match manifest::PodManifest::from_toml(&toml_str) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("error: invalid manifest: {e}");
|
||||
eprintln!("error: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let pod_name = manifest.pod.name.clone();
|
||||
let (manifest, loader) = match factory.resolve() {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to resolve manifest cascade: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize persistent store
|
||||
let store_dir = cli.store.unwrap_or_else(|| {
|
||||
let store_dir = cli.store.clone().unwrap_or_else(|| {
|
||||
default_store_dir().unwrap_or_else(|_| PathBuf::from(".insomnia/sessions"))
|
||||
});
|
||||
let store = match FsStore::new(&store_dir).await {
|
||||
|
|
@ -70,17 +144,14 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
};
|
||||
|
||||
// Build the Pod (pwd/scope derived from manifest + manifest_dir).
|
||||
let manifest_dir = std::fs::canonicalize(&cli.manifest)
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(Path::to_path_buf));
|
||||
let pod = match Pod::from_manifest(manifest, store, manifest_dir).await {
|
||||
let pod = match Pod::from_manifest(manifest, store, loader).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to create pod: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let pod_name = pod.manifest().pod.name.clone();
|
||||
|
||||
// Spawn the controller (starts socket server)
|
||||
let runtime_base = match default_runtime_dir() {
|
||||
|
|
@ -113,8 +184,6 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: handle.shutdown().await — PodController に採用しないスフルシャットダウン機構を追加したら組み込む
|
||||
drop(handle);
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use session_store::{
|
|||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use manifest::{PodManifest, Scope, ScopeError, WorkerManifest};
|
||||
use manifest::{PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, WorkerManifest};
|
||||
|
||||
use crate::agents_md::read_agents_md;
|
||||
use crate::compact_interceptor::CompactInterceptor;
|
||||
|
|
@ -22,6 +22,7 @@ use crate::hook::{
|
|||
};
|
||||
use crate::hook_interceptor::HookInterceptor;
|
||||
use crate::notifier::Notifier;
|
||||
use crate::prompt_loader::PromptLoader;
|
||||
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
use crate::usage_tracker::UsageTracker;
|
||||
use protocol::{NotificationLevel, NotificationSource};
|
||||
|
|
@ -74,8 +75,6 @@ pub struct Pod<C: LlmClient, St: Store> {
|
|||
scope: Scope,
|
||||
hook_builder: HookRegistryBuilder,
|
||||
interceptor_installed: bool,
|
||||
/// Directory containing the manifest file (needed for api_key_file resolution).
|
||||
manifest_dir: Option<PathBuf>,
|
||||
/// Shared compaction state (present when compact_threshold is configured).
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
/// Per-LLM-request Usage tracker. Always present after construction.
|
||||
|
|
@ -136,7 +135,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
manifest_dir: None,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
|
||||
|
|
@ -185,7 +183,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
manifest_dir: None,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(state.usage_history)),
|
||||
|
|
@ -807,10 +804,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
fn build_compactor_client(&self) -> Result<Box<dyn LlmClient>, PodError> {
|
||||
if let Some(ref compaction) = self.manifest.compaction {
|
||||
if let Some(ref provider_config) = compaction.provider {
|
||||
let client = provider::build_client(
|
||||
provider_config,
|
||||
self.manifest_dir.as_deref().map(|p| p.as_ref()),
|
||||
)?;
|
||||
let client = provider::build_client(provider_config)?;
|
||||
return Ok(client);
|
||||
}
|
||||
}
|
||||
|
|
@ -820,24 +814,30 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
}
|
||||
|
||||
impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
/// Create a Pod entirely from a manifest.
|
||||
/// Create a Pod entirely from a validated manifest.
|
||||
///
|
||||
/// Resolves `manifest.pod.pwd` against `manifest_dir` (or the
|
||||
/// current working directory when absent), builds the [`Scope`]
|
||||
/// from `manifest.scope`, and validates that the resolved pwd is
|
||||
/// readable under that scope.
|
||||
/// `manifest.pod.pwd` must already be an absolute path (the cascade
|
||||
/// layer — `PodManifestConfig` → `PodManifest` — is the sole place
|
||||
/// where path normalisation happens). The Pod builds its [`Scope`]
|
||||
/// from `manifest.scope`, canonicalizes the pwd, and validates that
|
||||
/// the resolved pwd is readable under that scope.
|
||||
///
|
||||
/// `loader` is installed into the system-prompt template
|
||||
/// environment so that `{% include "name" %}` /
|
||||
/// `{% import "name" %}` references resolve against the three-layer
|
||||
/// prompt asset library.
|
||||
pub async fn from_manifest(
|
||||
manifest: PodManifest,
|
||||
store: St,
|
||||
manifest_dir: Option<PathBuf>,
|
||||
loader: PromptLoader,
|
||||
) -> Result<Self, PodError> {
|
||||
let pwd = resolve_pwd(&manifest.pod.pwd, manifest_dir.as_deref())?;
|
||||
let pwd = resolve_pwd(&manifest.pod.pwd)?;
|
||||
let scope = Scope::from_config(&manifest.scope, &pwd).map_err(PodError::Scope)?;
|
||||
if !scope.is_readable(&pwd) {
|
||||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
|
||||
let client = provider::build_client(&manifest.provider, manifest_dir.as_deref())?;
|
||||
let client = provider::build_client(&manifest.provider)?;
|
||||
let mut worker = Worker::new(client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
|
||||
|
|
@ -847,7 +847,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
// scope summary, ...) can be injected.
|
||||
let system_prompt_template = match manifest.worker.system_prompt.as_deref() {
|
||||
Some(source) => Some(
|
||||
SystemPromptTemplate::parse(source)
|
||||
SystemPromptTemplate::parse_with_loader(source, loader)
|
||||
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
|
||||
),
|
||||
None => None,
|
||||
|
|
@ -867,7 +867,6 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
manifest_dir,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
usage_history: Arc::new(Mutex::new(Vec::new())),
|
||||
|
|
@ -878,6 +877,18 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
pod.apply_prune_from_manifest();
|
||||
Ok(pod)
|
||||
}
|
||||
|
||||
/// Convenience: build a Pod from a single-layer TOML manifest string.
|
||||
///
|
||||
/// Parses the TOML into a [`PodManifestConfig`], converts to a
|
||||
/// validated [`PodManifest`] via `TryFrom`, then delegates to
|
||||
/// [`Pod::from_manifest`]. Useful for tests, debugging, and any
|
||||
/// caller that wants to skip the cascade entirely.
|
||||
pub async fn from_manifest_toml(toml: &str, store: St) -> Result<Self, PodError> {
|
||||
let config = PodManifestConfig::from_toml(toml).map_err(PodError::ManifestParse)?;
|
||||
let manifest = PodManifest::try_from(config).map_err(PodError::ManifestResolve)?;
|
||||
Self::from_manifest(manifest, store, PromptLoader::builtins_only()).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply worker-level manifest settings to a Worker.
|
||||
|
|
@ -984,6 +995,15 @@ pub enum PodError {
|
|||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("pwd must be absolute: {}", .0.display())]
|
||||
PwdNotAbsolute(PathBuf),
|
||||
|
||||
#[error("failed to parse manifest TOML: {0}")]
|
||||
ManifestParse(#[source] toml::de::Error),
|
||||
|
||||
#[error("failed to resolve manifest config: {0}")]
|
||||
ManifestResolve(#[source] ResolveError),
|
||||
|
||||
#[error(transparent)]
|
||||
Provider(#[from] provider::ProviderError),
|
||||
|
||||
|
|
@ -1003,22 +1023,16 @@ pub enum PodError {
|
|||
},
|
||||
}
|
||||
|
||||
/// Resolve the pwd declared in a manifest against `manifest_dir` (or the
|
||||
/// current working directory when absent), canonicalizing symlinks.
|
||||
fn resolve_pwd(pwd: &Path, manifest_dir: Option<&Path>) -> Result<PathBuf, PodError> {
|
||||
let joined = if pwd.is_absolute() {
|
||||
pwd.to_path_buf()
|
||||
} else {
|
||||
let base = manifest_dir
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join(pwd)
|
||||
};
|
||||
joined
|
||||
.canonicalize()
|
||||
.map_err(|source| PodError::InvalidPwd {
|
||||
pwd: joined,
|
||||
source,
|
||||
})
|
||||
/// Canonicalize an absolute pwd (resolves symlinks and any `.`/`..`
|
||||
/// components). Relative inputs are rejected — the cascade layer is
|
||||
/// the sole source of path normalisation and must hand off an absolute
|
||||
/// path.
|
||||
fn resolve_pwd(pwd: &Path) -> Result<PathBuf, PodError> {
|
||||
if !pwd.is_absolute() {
|
||||
return Err(PodError::PwdNotAbsolute(pwd.to_path_buf()));
|
||||
}
|
||||
pwd.canonicalize().map_err(|source| PodError::InvalidPwd {
|
||||
pwd: pwd.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
137
crates/pod/src/prompt_loader.rs
Normal file
137
crates/pod/src/prompt_loader.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! Three-layer prompt asset loader used by [`crate::SystemPromptTemplate`].
|
||||
//!
|
||||
//! Layers (highest priority first):
|
||||
//! 1. **Project prompts** — `<project>/.insomnia/prompts/`
|
||||
//! 2. **User prompts** — `$XDG_CONFIG_HOME/insomnia/prompts/`
|
||||
//! 3. **Builtin prompts** — baked into the binary from `resources/prompts/`
|
||||
//! via [`include_dir!`].
|
||||
//!
|
||||
//! A prompt name is its path stem without the `.md` extension.
|
||||
//! Subdirectories are supported: `common/tool-usage` maps to
|
||||
//! `common/tool-usage.md` under whichever layer provides it first.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
|
||||
static BUILTIN_PROMPTS: Dir<'static> =
|
||||
include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts");
|
||||
|
||||
/// Lookup table for prompt assets across the three cascade layers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptLoader {
|
||||
user_dir: Option<PathBuf>,
|
||||
project_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PromptLoader {
|
||||
/// Builtins-only loader. Used for direct `Pod::from_manifest`
|
||||
/// calls that skip the factory cascade (tests, examples, simple
|
||||
/// callers).
|
||||
pub fn builtins_only() -> Self {
|
||||
Self {
|
||||
user_dir: None,
|
||||
project_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loader with optional user and project prompts directories. Both
|
||||
/// are consulted before falling back to builtins; `None` on either
|
||||
/// skips that layer.
|
||||
pub fn new(user_dir: Option<PathBuf>, project_dir: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
user_dir,
|
||||
project_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the raw template source for `name`. Returns `None` if
|
||||
/// no layer provides it.
|
||||
pub fn lookup(&self, name: &str) -> Option<String> {
|
||||
if let Some(ref dir) = self.project_dir {
|
||||
if let Some(s) = read_from_dir(dir, name) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
if let Some(ref dir) = self.user_dir {
|
||||
if let Some(s) = read_from_dir(dir, name) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
read_from_include_dir(&BUILTIN_PROMPTS, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_from_dir(dir: &Path, name: &str) -> Option<String> {
|
||||
let path = dir.join(format!("{name}.md"));
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
fn read_from_include_dir(dir: &Dir<'static>, name: &str) -> Option<String> {
|
||||
let path = format!("{name}.md");
|
||||
dir.get_file(&path)
|
||||
.and_then(|f| f.contents_utf8())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn builtin_coder_prompt_present() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let coder = loader.lookup("coder").expect("coder builtin missing");
|
||||
assert!(coder.contains("software engineering agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_subdirectory_lookup() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tu = loader
|
||||
.lookup("common/tool-usage")
|
||||
.expect("common/tool-usage missing");
|
||||
assert!(tu.contains("tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_name_returns_none() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
assert!(loader.lookup("definitely-not-a-prompt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_layer_overrides_builtin() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(user_dir.join("coder.md"), "user-coder").unwrap();
|
||||
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
assert_eq!(loader.lookup("coder").as_deref(), Some("user-coder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_layer_overrides_user_and_builtin() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().join("user");
|
||||
let project_dir = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&user_dir).unwrap();
|
||||
std::fs::create_dir_all(&project_dir).unwrap();
|
||||
std::fs::write(user_dir.join("coder.md"), "user-coder").unwrap();
|
||||
std::fs::write(project_dir.join("coder.md"), "project-coder").unwrap();
|
||||
|
||||
let loader = PromptLoader::new(Some(user_dir), Some(project_dir));
|
||||
assert_eq!(loader.lookup("coder").as_deref(), Some("project-coder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_through_to_builtin_when_user_missing_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
// user layer only defines "only-user", not "coder"
|
||||
std::fs::write(user_dir.join("only-user.md"), "x").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
assert!(loader.lookup("coder").is_some()); // from builtin
|
||||
}
|
||||
}
|
||||
|
|
@ -14,9 +14,11 @@ use std::sync::Arc;
|
|||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use manifest::Scope;
|
||||
use minijinja::value::Value;
|
||||
use minijinja::{Environment, UndefinedBehavior};
|
||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::prompt_loader::PromptLoader;
|
||||
|
||||
const TEMPLATE_NAME: &str = "system_prompt";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
|
|
@ -35,11 +37,31 @@ pub struct SystemPromptTemplate {
|
|||
}
|
||||
|
||||
impl SystemPromptTemplate {
|
||||
/// Parse a template source. Performs syntax validation only — no
|
||||
/// variable resolution is attempted here.
|
||||
/// Parse a template source with a builtins-only prompt loader.
|
||||
/// Convenience wrapper for callers that do not need user/project
|
||||
/// prompt layers — see [`SystemPromptTemplate::parse_with_loader`]
|
||||
/// for the factory-driven path.
|
||||
pub fn parse(source: impl Into<String>) -> Result<Self, SystemPromptError> {
|
||||
Self::parse_with_loader(source, PromptLoader::builtins_only())
|
||||
}
|
||||
|
||||
/// Parse a template source with a custom prompt loader installed.
|
||||
/// The loader resolves `{% include "name" %}` / `{% import "name" %}`
|
||||
/// references by consulting the cascade layers (project → user →
|
||||
/// builtin) before reporting a missing template.
|
||||
pub fn parse_with_loader(
|
||||
source: impl Into<String>,
|
||||
loader: PromptLoader,
|
||||
) -> Result<Self, SystemPromptError> {
|
||||
let mut env = Environment::new();
|
||||
env.set_undefined_behavior(UndefinedBehavior::Strict);
|
||||
env.set_loader(move |name| match loader.lookup(name) {
|
||||
Some(source) => Ok(Some(source)),
|
||||
None => Err(minijinja::Error::new(
|
||||
ErrorKind::TemplateNotFound,
|
||||
format!("prompt asset '{name}' not found"),
|
||||
)),
|
||||
});
|
||||
env.add_template_owned(TEMPLATE_NAME, source.into())
|
||||
.map_err(|e| SystemPromptError::Parse(e.to_string()))?;
|
||||
Ok(Self { env: Arc::new(env) })
|
||||
|
|
@ -230,6 +252,45 @@ mod tests {
|
|||
assert!(rendered.contains(&dir.path().canonicalize().unwrap().display().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_resolves_builtin_prompt() {
|
||||
// User-supplied source pulls in a builtin via the loader.
|
||||
let source = "HEAD\n{% include \"common/tool-usage\" %}";
|
||||
let tmpl = SystemPromptTemplate::parse_with_loader(
|
||||
source,
|
||||
PromptLoader::builtins_only(),
|
||||
)
|
||||
.unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into()],
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.starts_with("HEAD"));
|
||||
// The common/tool-usage builtin references {{ tools | join(", ") }}
|
||||
// so including it must have resolved that expression with the
|
||||
// parent scope's variables.
|
||||
assert!(rendered.contains("Read"));
|
||||
assert!(rendered.contains("Edit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_unknown_prompt_fails_at_render() {
|
||||
let tmpl = SystemPromptTemplate::parse_with_loader(
|
||||
"{% include \"nonexistent-prompt\" %}",
|
||||
PromptLoader::builtins_only(),
|
||||
)
|
||||
.unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let err = tmpl.render(&ctx(dir.path(), &scope, vec![])).unwrap_err();
|
||||
assert!(matches!(err, SystemPromptError::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_reserved_namespace_is_empty() {
|
||||
let t = SystemPromptTemplate::parse(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use llm_worker::llm_client::client::LlmClient;
|
||||
use llm_worker::llm_client::providers::anthropic::AnthropicClient;
|
||||
use llm_worker::llm_client::providers::gemini::GeminiClient;
|
||||
|
|
@ -22,22 +20,23 @@ pub enum ProviderError {
|
|||
///
|
||||
/// Resolution order:
|
||||
/// 1. Environment variable `INSOMNIA_API_KEY_{KIND}`
|
||||
/// 2. File specified by `api_key_file` (trimmed)
|
||||
/// 2. File specified by `api_key_file` (must be an absolute path; the
|
||||
/// cascade layer is responsible for normalisation)
|
||||
/// 3. `None`
|
||||
fn resolve_api_key(
|
||||
config: &ProviderConfig,
|
||||
manifest_dir: Option<&Path>,
|
||||
) -> Result<Option<String>, ProviderError> {
|
||||
// 1. Convention-based environment variable
|
||||
fn resolve_api_key(config: &ProviderConfig) -> Result<Option<String>, ProviderError> {
|
||||
let env_name = config.kind.env_var_name();
|
||||
if let Ok(val) = std::env::var(&env_name) {
|
||||
return Ok(Some(val));
|
||||
}
|
||||
|
||||
// 2. File
|
||||
if let Some(ref raw_path) = config.api_key_file {
|
||||
let path = expand_key_path(raw_path, manifest_dir)?;
|
||||
let contents = std::fs::read_to_string(&path).map_err(|e| {
|
||||
if let Some(ref path) = config.api_key_file {
|
||||
if !path.is_absolute() {
|
||||
return Err(ProviderError::Config(format!(
|
||||
"api_key_file must be absolute: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let contents = std::fs::read_to_string(path).map_err(|e| {
|
||||
ProviderError::Config(format!(
|
||||
"failed to read api_key_file {}: {e}",
|
||||
path.display()
|
||||
|
|
@ -49,38 +48,13 @@ fn resolve_api_key(
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
/// Expand `~` and resolve relative paths against `manifest_dir`.
|
||||
fn expand_key_path(raw: &Path, manifest_dir: Option<&Path>) -> Result<PathBuf, ProviderError> {
|
||||
let path = if raw.starts_with("~") {
|
||||
let home = std::env::var("HOME")
|
||||
.map_err(|_| ProviderError::Config("HOME is not set for ~ expansion".into()))?;
|
||||
PathBuf::from(home).join(raw.strip_prefix("~").unwrap())
|
||||
} else {
|
||||
raw.to_path_buf()
|
||||
};
|
||||
|
||||
if path.is_relative() {
|
||||
match manifest_dir {
|
||||
Some(dir) => Ok(dir.join(&path)),
|
||||
None => Err(ProviderError::Config(format!(
|
||||
"relative api_key_file '{}' requires a manifest directory",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an [`LlmClient`] from a [`ProviderConfig`].
|
||||
///
|
||||
/// Resolves the API key from `INSOMNIA_API_KEY_{KIND}` env var or `api_key_file`.
|
||||
/// `manifest_dir` is used to resolve relative `api_key_file` paths.
|
||||
pub fn build_client(
|
||||
config: &ProviderConfig,
|
||||
manifest_dir: Option<&Path>,
|
||||
) -> Result<Box<dyn LlmClient>, ProviderError> {
|
||||
let api_key = resolve_api_key(config, manifest_dir)?;
|
||||
/// `api_key_file` (if set) must already be an absolute path — relative
|
||||
/// paths are rejected because cascade resolution is the sole source of
|
||||
/// path normalisation.
|
||||
pub fn build_client(config: &ProviderConfig) -> Result<Box<dyn LlmClient>, ProviderError> {
|
||||
let api_key = resolve_api_key(config)?;
|
||||
|
||||
match config.kind {
|
||||
ProviderKind::Anthropic => {
|
||||
|
|
@ -128,6 +102,7 @@ mod tests {
|
|||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn anthropic_config() -> ProviderConfig {
|
||||
ProviderConfig {
|
||||
|
|
@ -143,7 +118,7 @@ mod tests {
|
|||
fn resolve_from_env() {
|
||||
let env_name = ProviderKind::Anthropic.env_var_name();
|
||||
unsafe { std::env::set_var(&env_name, "sk-from-env") };
|
||||
let key = resolve_api_key(&anthropic_config(), None).unwrap();
|
||||
let key = resolve_api_key(&anthropic_config()).unwrap();
|
||||
unsafe { std::env::remove_var(&env_name) };
|
||||
assert_eq!(key.as_deref(), Some("sk-from-env"));
|
||||
}
|
||||
|
|
@ -160,7 +135,7 @@ mod tests {
|
|||
api_key_file: Some(key_path),
|
||||
..anthropic_config()
|
||||
};
|
||||
let key = resolve_api_key(&config, None).unwrap();
|
||||
let key = resolve_api_key(&config).unwrap();
|
||||
assert_eq!(key.as_deref(), Some("sk-from-file"));
|
||||
}
|
||||
|
||||
|
|
@ -178,40 +153,25 @@ mod tests {
|
|||
api_key_file: Some(key_path),
|
||||
..anthropic_config()
|
||||
};
|
||||
let key = resolve_api_key(&config, None).unwrap();
|
||||
let key = resolve_api_key(&config).unwrap();
|
||||
unsafe { std::env::remove_var(&env_name) };
|
||||
assert_eq!(key.as_deref(), Some("sk-from-env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_resolved_against_manifest_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key_path = dir.path().join("keys").join("anthropic");
|
||||
std::fs::create_dir_all(key_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&key_path, "sk-relative").unwrap();
|
||||
|
||||
fn relative_api_key_file_is_rejected() {
|
||||
let config = ProviderConfig {
|
||||
api_key_file: Some(PathBuf::from("keys/anthropic")),
|
||||
..anthropic_config()
|
||||
};
|
||||
let key = resolve_api_key(&config, Some(dir.path())).unwrap();
|
||||
assert_eq!(key.as_deref(), Some("sk-relative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_without_manifest_dir_errors() {
|
||||
let config = ProviderConfig {
|
||||
api_key_file: Some(PathBuf::from("keys/anthropic")),
|
||||
..anthropic_config()
|
||||
};
|
||||
let err = resolve_api_key(&config, None).unwrap_err();
|
||||
let err = resolve_api_key(&config).unwrap_err();
|
||||
assert!(matches!(err, ProviderError::Config(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_returns_api_key_missing() {
|
||||
let config = anthropic_config();
|
||||
let result = build_client(&config, None);
|
||||
let result = build_client(&config);
|
||||
assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. })));
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +183,6 @@ mod tests {
|
|||
api_key_file: None,
|
||||
base_url: None,
|
||||
};
|
||||
assert!(build_client(&config, None).is_ok());
|
||||
assert!(build_client(&config).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
resources/prompts/coder.md
Normal file
7
resources/prompts/coder.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
You are a focused software engineering agent operating in {{ cwd }}.
|
||||
|
||||
Today is {{ date }}. Stay precise, edit code directly when asked, and
|
||||
avoid speculative refactoring. Explain what you changed in one short
|
||||
paragraph at the end of each turn.
|
||||
|
||||
{% include "common/tool-usage" %}
|
||||
11
resources/prompts/common/tool-usage.md
Normal file
11
resources/prompts/common/tool-usage.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
## Tool usage
|
||||
|
||||
You have access to these tools: {{ tools | join(", ") }}.
|
||||
|
||||
Prefer the most specific tool for the job. When reading files you already
|
||||
know the path of, use the file-read tool directly instead of searching.
|
||||
When searching, use grep/glob primitives rather than shell pipelines.
|
||||
|
||||
Only touch paths inside your scope. Your scope is:
|
||||
|
||||
{{ scope.summary }}
|
||||
7
resources/prompts/planner.md
Normal file
7
resources/prompts/planner.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
You are a planning agent operating in {{ cwd }}.
|
||||
|
||||
Today is {{ date }}. Produce a concise, implementation-ready plan: list
|
||||
the files to touch, the order of changes, and the risks. Do not write
|
||||
code unless asked.
|
||||
|
||||
{% include "common/tool-usage" %}
|
||||
7
resources/prompts/reviewer.md
Normal file
7
resources/prompts/reviewer.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
You are a code reviewer operating in {{ cwd }}.
|
||||
|
||||
Today is {{ date }}. Read the diff or the requested files, then report
|
||||
problems grouped by severity (blocking / recommended / nit). Quote
|
||||
specific lines. Do not rewrite the code unless asked.
|
||||
|
||||
{% include "common/tool-usage" %}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
# Pod Factory: 設定カスケードとプロンプト資産による Pod 自動生成
|
||||
|
||||
## レビュー状態
|
||||
|
||||
初回レビュー実施済み。[pod-factory.review.md](pod-factory.review.md) を参照。
|
||||
要件全項目達成、アーキテクチャ・テスト被覆とも良好で**無条件で受け入れ可**。指摘は 6 件(すべて任意修正または nit レベル)。
|
||||
|
||||
## 背景
|
||||
|
||||
現状、Pod を起動するには `test_pod.local.toml` のような完全な `PodManifest` TOML を手書きする必要がある。1 人のユーザーが1 つのエージェントを試験運用するには十分だが、Insomnia が狙う「複数のエージェントが独立プロセスとして spawn されて自律的に動く」世界観では、**Pod のライフサイクル全体が自動化可能でなければならない**。そのためには、Pod の**作成自体**も自動化可能である必要がある。
|
||||
|
|
@ -21,39 +26,50 @@ Pod 作成を「**最終的に `PodManifest` を1 つ構築する問題**」と
|
|||
|
||||
### 同じ型で、層で上書きする
|
||||
|
||||
- **解決後の型は現行の `manifest::PodManifest` のまま**。Pod 側の契約(`Pod::from_manifest`)は変更しない。
|
||||
- カスケードは `PodManifest` より上の「部分的な `PodManifest` を層ごとに保持し、順番にマージして最終形を作る」層として設計する。
|
||||
- **解決後の型は現行の `manifest::PodManifest` のまま**(必須フィールド持ち)。
|
||||
- カスケードの各層は **`PodManifestConfig`**(`PodManifest` と同じ構造だが全フィールドを `Option` / 部分形で保持する新規型)で表現する。部分形同士を順番にマージし、最後に `TryFrom<PodManifestConfig> for PodManifest` で必須チェックを行いつつ確定させる。
|
||||
- 各層は**部分形**を持てる(全フィールドを埋める必要はない)。存在するフィールドだけが下層を上書きする。
|
||||
- 人間が書くときは `PodManifest` と同じ TOML スキーマで書く(サブセット可)。ファイル名は**すべて `manifest.toml`**。「設定」という曖昧な名前を避け、「Pod manifest」という語彙に揃える。
|
||||
|
||||
### カスケードの層
|
||||
|
||||
優先順位が低い方から高い方へ:
|
||||
|
||||
1. **ビルトインのデフォルト**: コードに焼き込んだ基本値(現在 `PodManifest` 各フィールドの `#[serde(default)]` や `Default` 実装に散っているものを集約)
|
||||
2. **ユーザー設定**: `~/.config/insomnia/config.toml` など。ユーザー個人のプロバイダ指定・デフォルトモデル・常用ツール設定等を書く
|
||||
3. **プロジェクト設定**: プロジェクト直下の `.insomnia/config.toml` など。プロジェクト固有の scope・compaction 設定・system_prompt のベース等を書く
|
||||
4. **プログラマティック上書き**: Pod 生成を呼ぶコード(GUI / CLI / 別 Pod からの spawn 等)が渡す `PodManifestOverlay` 的な部分形。ここで `pod.name` や `pod.pwd` のような**その Pod に固有の値**を与える
|
||||
2. **ユーザー manifest**: `$XDG_CONFIG_HOME/insomnia/manifest.toml`(`XDG_CONFIG_HOME` 未設定時は `~/.config/insomnia/manifest.toml`)。ユーザー個人のプロバイダ指定・デフォルトモデル・常用ツール設定等を書く
|
||||
3. **プロジェクト manifest**: プロジェクトルート下の `.insomnia/manifest.toml`。プロジェクト固有の scope・compaction 設定・system_prompt のベース等を書く
|
||||
4. **プログラマティック overlay**: Pod 生成を呼ぶコード(GUI / CLI / 別 Pod からの spawn 等)が渡す部分形。ここで `pod.name` や `pod.pwd` のような**その Pod に固有の値**を与える
|
||||
|
||||
各層とも人間が書くときは `PodManifest` と同じ TOML スキーマで書く(サブセット可)。
|
||||
### プロジェクトルートの判定
|
||||
|
||||
起動ディレクトリから上方向に `.insomnia/` ディレクトリを探索し、**最も近い**ものをプロジェクトルートとする。見つからなければ「プロジェクト無し」として扱い、プロジェクト層をスキップする。
|
||||
|
||||
### マージのセマンティクス
|
||||
|
||||
- **スカラー** (`String`, `u32`, `bool` 等): 上層が存在すれば丸ごと置換
|
||||
- **Option 型**: 上層が `Some` なら置換、`None` なら据え置き
|
||||
- **マップ** (例: `tool_output.per_tool`): キー単位でマージ、同一キーは上層優先
|
||||
- **リスト** (例: `scope.allow` / `scope.deny`): **原則置換**(append にすると下層の意図しないルールが漏れる危険)。ただし append したいケースはあるので、設計時に decoration の形式(例: `scope.allow_extra` など)を別途検討
|
||||
- 未知フィールドは manifest エラーにせずログ警告
|
||||
- **`scope.allow` / `scope.deny`**: **union(各層から全部足す)**。すべての層が追加のみ可能。最終的な effective scope は既存の `Scope::from_config` ロジックに委ね、`allow_union - deny_union` として解決される。**上位層は deny を追加することで下位層の allow を必ず削れる**ので、「上に行くほど強制力を持てる」構造が自然に出る
|
||||
- その他リスト: 当面は `scope.*` 以外にリスト型は存在しないが、新規にリスト型フィールドを追加するときは原則 union(scope と同じ)で扱う
|
||||
|
||||
### エラー戦略
|
||||
|
||||
- **未知フィールド**: TOML に書かれた未定義のキーは `tracing::warn!` のみ出して無視する。`#[serde(deny_unknown_fields)]` は**使わない**(将来バージョンアップで読めない旧設定が出るとユーザー体験が悪いため)
|
||||
- **型ミスマッチ**: `max_tokens = "100"` のような型エラーは hard error として resolve 失敗させる。ファイルパスと位置情報をエラーメッセージに含める
|
||||
- **パスフィールド**: `pod.pwd` / `provider.api_key_file` / `scope.*.target` 等のパスは**絶対パスのみ受け付ける**。相対パスが書かれていたら resolve 時に hard error とする。相対パス解決を層ごとの manifest_dir に依存させるとカスケードの意味論が濁るため、各ファイルを書く人間の責任で絶対パスを指定させる
|
||||
|
||||
### プロンプト資産ライブラリ
|
||||
|
||||
- プロンプトは TOML 文字列ではなく**ファイルとして管理**する。
|
||||
- 検索パスはカスケードと対応した3層:
|
||||
1. **ビルトイン**: バイナリに同梱されたデフォルトプロンプト(`coder` / `reviewer` / `planner` 等、設計時に選定)
|
||||
2. **ユーザー**: `~/.config/insomnia/prompts/*.md` 等
|
||||
3. **プロジェクト**: `.insomnia/prompts/*.md` 等
|
||||
- 同名があれば**上層が優先**。
|
||||
- 既存の `SystemPromptTemplate`(minijinja ベース)のローダを拡張し、テンプレート内から他のプロンプトを `{% include "coder" %}` / `{% import "planner" as p %}` のように参照できるようにする。
|
||||
- 層の異なる同名プロンプトを合成するための include 先解決は上記優先順位に従う。
|
||||
- 検索パスは以下の3層(上層優先で解決):
|
||||
1. **ビルトイン**: レポジトリ直下の `resources/prompts/` 以下を `include_dir!` マクロでバイナリに同梱
|
||||
2. **ユーザー**: `$XDG_CONFIG_HOME/insomnia/prompts/`
|
||||
3. **プロジェクト**: `<project>/.insomnia/prompts/`
|
||||
- **ファイル名の stem がそのままプロンプト名**。サブディレクトリも許容し、`resources/prompts/common/tool-usage.md` は `{% include "common/tool-usage" %}` で参照できる
|
||||
- ファイル形式は **`.md`**(frontmatter は無し。前方互換として `---` 区切りの TOML フロントマターを将来持てる余地だけ認識しておく)
|
||||
- 既存の `SystemPromptTemplate`(minijinja ベース)の `Environment` にカスタムローダを仕込み、テンプレート内から他のプロンプトを `{% include "name" %}` / `{% import "name" as p %}` で参照できるようにする
|
||||
- プロンプト資産自体もテンプレートとして評価され、現行の `SystemPromptContext`(`now` / `cwd` / `scope` / `tools` / `files` 等)と同じ変数が見える
|
||||
- include 時の**変数伝搬は minijinja のデフォルト挙動**(親スコープの変数が自動で見える)に任せる
|
||||
|
||||
### 設定値のテンプレート参照は扱わない
|
||||
|
||||
|
|
@ -61,80 +77,122 @@ Pod 作成を「**最終的に `PodManifest` を1 つ構築する問題**」と
|
|||
|
||||
### プログラマティック Pod 作成 API
|
||||
|
||||
- `Pod::from_manifest(path)` の隣に、カスケード解決を経由する生成経路を追加する。イメージ:
|
||||
factory は `PodManifest` を組み上げる builder として設計する。イメージ:
|
||||
|
||||
```rust
|
||||
// 層を明示的に指定して最終形を得る
|
||||
let manifest = PodFactory::new()
|
||||
.with_user_config(user_config_path)? // absent OK
|
||||
.with_project_config(project_root)? // absent OK
|
||||
.with_overlay(overlay_toml_or_struct) // programmatic
|
||||
.resolve()?; // -> PodManifest
|
||||
Pod::from_manifest(manifest, store).await?;
|
||||
```
|
||||
```rust
|
||||
let manifest: PodManifest = PodFactory::new()
|
||||
.with_user_manifest_auto()? // XDG から自動読み込み、不在 OK
|
||||
.with_project_manifest_auto()? // cwd から上方向に .insomnia/ を探索、不在 OK
|
||||
.with_overlay_toml(overlay)? // programmatic な最上層 overlay(TOML 文字列)
|
||||
.resolve()?; // -> PodManifest
|
||||
Pod::from_manifest(manifest, store).await?;
|
||||
```
|
||||
|
||||
- 細かい形状は設計時に詰める(builder 型 vs 関数型、`overlay` を TOML 文字列か型付きか等)。
|
||||
- CLI からは `insomnia spawn --overlay '...'` 相当で同じ経路を叩く想定。
|
||||
`Pod::from_manifest` は現在 `manifest_dir` を取るが、本チケットで以下の二段に書き換える:
|
||||
|
||||
- **`Pod::from_manifest(manifest: PodManifest, store)`** — 一次 API。factory の出力がそのまま入る
|
||||
- **`Pod::from_manifest_toml(toml: &str, store)`** — 便利関数。単層 manifest を TOML 文字列で直接投げる用途(テスト・デバッグ・動作確認)
|
||||
|
||||
`manifest_dir` 引数は廃止する。パスは絶対化済み前提のため、Pod 側で相対パス解決ロジックを持たない。ファイル読み込みは呼び出し側(factory か caller)の責務。
|
||||
|
||||
### CLI
|
||||
|
||||
`pod` バイナリの CLI surface を factory に合わせて更新する。新 umbrella コマンド(`insomnia spawn` 等)は**作らない**。ユーザーが直接 CLI を叩くのは debug/automation の文脈で、GUI/TUI は Pod を subprocess として spawn する運用のため、`pod` バイナリ1本の fla だけで十分。
|
||||
|
||||
```
|
||||
pod [--user-manifest <path>] [--project <path>] [--overlay <toml>] [--pwd <path>]
|
||||
```
|
||||
|
||||
- `--user-manifest`: 省略時は XDG から自動解決
|
||||
- `--project`: 省略時は cwd から上方向に `.insomnia/` を探索
|
||||
- `--overlay`: 最上層の overlay を inline TOML 文字列で渡す(例: `--overlay 'pod.name = "dbg"'`)
|
||||
- `--pwd`: 便利ショートカット(overlay に `pod.pwd = ...` を書く代わり)
|
||||
- 旧 `--manifest <path>` フラグは廃止。単層 manifest を直接読み込みたい場合は `--user-manifest <path>` で代替する
|
||||
|
||||
## 要件
|
||||
|
||||
### カスケード基盤
|
||||
|
||||
- ユーザー設定・プロジェクト設定・プログラマティック overlay を順に重ねた結果が `PodManifest` として取れる。
|
||||
- ユーザー manifest・プロジェクト manifest・プログラマティック overlay を順に重ねた結果が `PodManifest` として取れる。
|
||||
- 各層が部分形を許容する(`pod.pwd` だけ書いてあっても良い等)。
|
||||
- マージセマンティクスが**フィールドごとに定義**され、テストされる(スカラー / Option / マップ / リスト)。
|
||||
- 層が全て空でも**ビルトインデフォルト単体で有効な manifest にならない**(少なくとも `pod.pwd` と `provider` と `scope.allow` は上位層で与える必要がある)。その旨を resolve 時のエラーで明示する。
|
||||
- マージセマンティクスがフィールドごとに定義され、単体テストで担保される(スカラー / Option / マップ / scope union)。
|
||||
- 未知フィールドは warn のみ、型ミスマッチは hard error。
|
||||
- 層が全て空で resolve 失敗する場合は、必要なフィールド(`pod.pwd` / `provider` / `scope.allow` 等)を明示するエラーを返す。
|
||||
|
||||
### プロンプト資産ライブラリ
|
||||
|
||||
- 3層の検索パスでプロンプトファイルを解決できる。
|
||||
- 同名プロンプトは上層優先で解決される。
|
||||
- `SystemPromptTemplate` の minijinja `Environment` にカスタムローダを仕込み、`{% include "name" %}` / `{% import "name" as x %}` で資産を参照できる。
|
||||
- プロンプト資産自体もテンプレートとして評価され、現行の `SystemPromptContext`(`now` / `cwd` / `scope` / `tools` / `files` 等)と同じ変数が見える。
|
||||
- 親テンプレートの変数が include 先にも見える(minijinja デフォルト挙動)。
|
||||
- ビルトインプロンプトは `resources/prompts/` から `include_dir!` マクロで同梱され、追加・編集するときは該当ディレクトリにファイルを置く・編集するだけで済む。
|
||||
- プロンプト資産自体もテンプレートとして評価され、`SystemPromptContext` と同じ変数が見える。
|
||||
|
||||
### プログラマティック Pod 作成
|
||||
|
||||
- 既存の `Pod::from_manifest` を壊さず、追加経路として `PodFactory` 系の API を提供する。
|
||||
- TUI / GUI / daemon 等の上位クライアントが、TOML ファイルパスではなく**オーバーレイ + 設定ディレクトリパス**を渡すだけで Pod を起動できる。
|
||||
- `Pod::from_manifest(manifest: PodManifest, store)` と `Pod::from_manifest_toml(toml: &str, store)` の二段構成。path 受け API は廃止。
|
||||
- `PodFactory` builder が上記に繋がる。
|
||||
- TUI / GUI / daemon 等の上位クライアントが、TOML ファイルパスではなく overlay + 設定ディレクトリパスを渡すだけで Pod を起動できる。
|
||||
|
||||
### CLI
|
||||
|
||||
- `pod` バイナリが `--user-manifest` / `--project` / `--overlay` / `--pwd` を受け付ける。
|
||||
- 引数無しで cwd + XDG を自動解決して起動できる最小構成が動く。
|
||||
|
||||
### ドキュメント
|
||||
|
||||
- カスケード層の優先順位・マージ規則を `docs/` にまとめる。
|
||||
- ユーザー設定 / プロジェクト設定ファイルの**最小例**と**全オプション例**を残す。
|
||||
- ユーザー manifest / プロジェクト manifest の最小例と全オプション例を残す。
|
||||
- `resources/prompts/` のディレクトリ構造とビルトインプロンプト一覧を `docs/` に記載。
|
||||
|
||||
## 設計で決めること
|
||||
## リソース構成
|
||||
|
||||
- **ユーザー設定のパス**: `~/.config/insomnia/config.toml` か、XDG 非準拠のパスも許容するか。環境変数で上書きできるか。
|
||||
- **プロジェクト設定の場所**: プロジェクトルートの `.insomnia/config.toml` か、別の命名か。サブディレクトリから起動したときの discovery(上位ディレクトリ探索)の挙動。
|
||||
- **プロジェクトルートの判定**: 明示指定 vs `.git` や `.insomnia/` で自動検出
|
||||
- **preset の概念を入れるか**: 名前付きの overlay セット(例: `insomnia spawn coder`)を導入するか。導入する場合、preset はユーザー設定内に `[preset.coder]` として持つか、個別ファイル `~/.config/insomnia/presets/coder.toml` として持つか
|
||||
- **リストフィールドのマージ方針**: 置換 only にするか、append 用の別フィールド (`scope.allow_extra` 等) を用意するか
|
||||
- **ビルトインプロンプトの初期ラインナップ**: どの役割をデフォルトで同梱するか、どこに置くか(`crates/pod/assets/prompts/*.md` を `include_str!` で埋め込む等)
|
||||
- **プロンプト資産のファイル形式**: `.md` か `.txt` か、拡張子省略可能にするか、フロントマター(YAML/TOML)で引数デフォルトを持たせるか
|
||||
- **プロンプト include 時の context 伝搬**: 親テンプレートの変数を include 先でも見えるようにするか、明示的に `with` で渡させるか
|
||||
- **エラー戦略**: 上層で書かれた未知フィールドや型ミスマッチをどこまで寛容に扱うか
|
||||
- **既存の `Pod::from_manifest(path)` とのインターフェース整理**: 廃止するか、内部的に PodFactory に委譲するか
|
||||
- **CLI コマンド名**: `insomnia spawn` / `insomnia pod new` / その他
|
||||
```
|
||||
insomnia/
|
||||
├── crates/
|
||||
│ ├── pod/
|
||||
│ │ └── src/
|
||||
│ │ ├── factory.rs # 新規。PodManifestConfig カスケード → PodManifest
|
||||
│ │ ├── prompt_loader.rs # 新規。minijinja loader + 3層検索
|
||||
│ │ └── ...
|
||||
│ └── ...
|
||||
├── resources/
|
||||
│ └── prompts/
|
||||
│ ├── coder.md
|
||||
│ ├── reviewer.md
|
||||
│ ├── planner.md
|
||||
│ └── common/
|
||||
│ └── tool-usage.md
|
||||
```
|
||||
|
||||
- factory とプロンプト loader は新規 crate を作らず `pod` crate 内に配置する。既に `pod` が `manifest` に依存しているため追加の依存関係整理が不要
|
||||
- `include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts")` で全ビルトインプロンプトをバイナリに取り込む
|
||||
- ファイルの追加・編集は通常の file 操作だけで済む。既存ファイルの編集は cargo の変更追跡で自動再ビルドされる。新規追加・削除が反映されない場合は `cargo clean -p pod` を案内する(必要が増えたら `build.rs` で `rerun-if-changed` を足す)
|
||||
- ビルトインプロンプトの初期ラインナップ(`coder` / `reviewer` / `planner` / `common/tool-usage` 等)は実装時に選定し、ドキュメントに列挙する
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `PodManifest` の最終形を層のマージで構築する `PodFactory`(または同等の仕組み)が実装され、マージセマンティクスの単体テストが通る。
|
||||
- ユーザー設定・プロジェクト設定・プログラマティック overlay のすべての層を使う end-to-end テストで、Pod が TOML ファイルパスを一切渡さずに起動できる。
|
||||
- プロンプト資産ライブラリを経由して system_prompt が組み立てられ、`{% include "ビルトイン名" %}` で同梱プロンプトを参照できることをテストで確認できる。
|
||||
- ユーザー設定ファイル / プロジェクト設定ファイルのドキュメントが `docs/` に存在する。
|
||||
- 既存の `Pod::from_manifest(path)` 経路が動き続ける(回帰させない)。
|
||||
- `PodFactory` がカスケード層のマージで `PodManifest` を構築でき、マージセマンティクスの単体テストが通る(スカラー・Option・マップ・scope union・未知フィールド warn・型ミスマッチ hard error)。
|
||||
- ユーザー manifest・プロジェクト manifest・プログラマティック overlay のすべての層を使う end-to-end テストで、Pod が path を一切渡さずに起動できる。
|
||||
- プロンプト資産ライブラリを経由して system_prompt が組み立てられ、`{% include "ビルトイン名" %}` で `resources/prompts/` 以下の資産を参照できることをテストで確認できる。
|
||||
- `Pod::from_manifest(manifest)` と `Pod::from_manifest_toml(toml)` の二段 API が動き、path 受け API は削除されている。
|
||||
- `pod` バイナリが `--user-manifest` / `--project` / `--overlay` / `--pwd` を受け、引数無しでも cwd + XDG 自動解決で起動できる。
|
||||
- カスケード層の優先順位・マージ規則 / ユーザー manifest 例 / プロジェクト manifest 例 / ビルトインプロンプト一覧のドキュメントが `docs/` に存在する。
|
||||
|
||||
## 他チケットとの関係
|
||||
|
||||
- `tickets/native-gui-mvp.md`: 現状「manifest ファイルを選ぶ UI」を含むが、本チケット完了後はその UI が「preset 選択 + overlay 入力」に置き換わる想定。native-gui-mvp 実装時に本チケットの API を使うか、先に文字列パス渡しで済ませて後から差し替えるかは別途判断
|
||||
- `tickets/native-gui-mvp.md`: 現状「manifest ファイルを選ぶ UI」を含むが、本チケット完了後はその UI が「overlay 指定 + Pod 起動」に置き換わる想定。native-gui-mvp 実装時に本チケットの API を使うか、先に path 渡しで済ませて後から差し替えるかは別途判断
|
||||
- `tickets/tui-pod-spawn-ui.md`: 同上。Pod spawn UI は本チケットが提供する API の上に構築される
|
||||
- `tickets/protocol-design.md`: Pod ↔ Client protocol 自体は変わらない。spawn 要求を protocol に載せるかどうかは protocol-design 側で検討
|
||||
- `docs/system-prompt-template.md` / `crates/pod/src/system_prompt.rs`: プロンプト資産ライブラリはこの minijinja 基盤の拡張として実装される
|
||||
|
||||
## 範囲外
|
||||
|
||||
- **preset の概念**: 名前付き overlay セット(`insomnia spawn coder` 等)は本チケットでは導入しない。将来別チケットで検討
|
||||
- **設定値の中のテンプレート展開**(`max_tokens = "{{ env.X }}"` のような動的値)。プロンプト本文のテンプレート展開のみを扱う
|
||||
- **GUI 内での設定ファイル編集 UI**。編集は人間がエディタで TOML を書くだけ(あくまで「Pod 生成時に手書きしない」ことを目指す)
|
||||
- **チーム共有・同期**。ユーザー設定とプロジェクト設定は各自・各リポジトリ単位で管理される
|
||||
- **GUI 内での manifest 編集 UI**。編集は人間がエディタで TOML を書くだけ(あくまで「Pod 生成時に手書きしない」ことを目指す)
|
||||
- **チーム共有・同期**。ユーザー manifest とプロジェクト manifest は各自・各リポジトリ単位で管理される
|
||||
- **秘密情報管理**(API キー等)。既存の `api_key_file` 方式を維持する
|
||||
- **設定値の型バリデーション強化**(JSON Schema など)。現行の serde ベースで十分な範囲に留める
|
||||
- **プロンプトフロントマター**(description / required_vars 等のメタデータ)。将来一覧 UI が必要になった時点で検討。フォーマット上の前方互換性だけは意識する
|
||||
- **umbrella CLI コマンド**(`insomnia spawn` 等)。`pod` バイナリ単体で完結させる
|
||||
|
|
|
|||
222
tickets/pod-factory.review.md
Normal file
222
tickets/pod-factory.review.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# レビュー: Pod Factory
|
||||
|
||||
対象差分: `crates/manifest/src/{lib,config}.rs`, `crates/pod/src/{lib,pod,main,system_prompt,factory,prompt_loader}.rs`, `crates/pod/examples/{pod_cli,pod_protocol}.rs`, `crates/provider/src/lib.rs`, `resources/prompts/`, `docs/pod-factory.md`(いずれも未コミット)
|
||||
|
||||
## 要件達成状況
|
||||
|
||||
| 要件 | 状態 |
|
||||
|---|---|
|
||||
| カスケード基盤(ユーザー・プロジェクト・overlay の層マージ) | ✅ `manifest::PodManifestConfig` が部分形として実装。`PodFactory` が 4 層(default/user/project/overlay)を順にマージ |
|
||||
| 解決後の型は `PodManifest` のまま | ✅ `TryFrom<PodManifestConfig> for PodManifest` が検証付き変換 |
|
||||
| 各層の manifest.toml スキーマは同じ | ✅ partial 型群 (`PodMetaConfig` / `ProviderConfigPartial` / `WorkerManifestConfig` / ...) が本物と同構造 |
|
||||
| ユーザーパスは XDG | ✅ `user_manifest_path` が `XDG_CONFIG_HOME` → `$HOME/.config` の順 |
|
||||
| プロジェクトルートは `.insomnia/` 最近接 | ✅ `find_project_manifest` が cwd から上方向に walk |
|
||||
| scope は union マージ | ✅ `merge_scope` が allow/deny を `extend` |
|
||||
| マップは key-wise マージ | ✅ `ToolOutputLimitsPartial::merge` |
|
||||
| スカラー / Option は upper 優位 | ✅ `upper.x.or(self.x)` パターンで実装 |
|
||||
| 未知フィールドは warn のみ | ✅ `serde_ignored::deserialize` + `tracing::warn!` |
|
||||
| 型ミスマッチは hard error | ✅ toml パースで通常通り失敗 |
|
||||
| パスフィールドは絶対パスのみ | ✅ `ensure_absolute` が `pod.pwd` / `provider.api_key_file` / `scope.*.target` を検証 |
|
||||
| `Pod::from_manifest(manifest, store, loader)` 二段 API | ✅ + `from_manifest_toml` 便利関数。旧 path 受けは廃止 |
|
||||
| `manifest_dir` 引数の消滅 | ✅ `Pod` 構造体から削除、`provider::build_client` からも消滅 |
|
||||
| プロンプト3層ローダ | ✅ `PromptLoader` が project/user/builtin の順で解決 |
|
||||
| ビルトインは `resources/prompts/` + `include_dir!` | ✅ `pod/src/prompt_loader.rs:17` |
|
||||
| プロンプト include の親変数伝搬 | ✅ minijinja デフォルト挙動、`include_resolves_builtin_prompt` テストで確認 |
|
||||
| CLI の `--user-manifest` / `--project` / `--overlay` / `--pwd` | ✅ `pod/src/main.rs` で実装。引数無しでも XDG + cwd 自動解決で動く |
|
||||
| 引数無しでの最小構成動作 | ✅ CLI デフォルトが auto 系メソッドにつながる |
|
||||
| ドキュメント (`docs/pod-factory.md`) | ✅ カスケード層・マージ規則・CLI・プログラマティック API・ビルトイン一覧を網羅 |
|
||||
|
||||
**すべての要件を達成**。さらに当初のチケットに無かった点として CLI docs の整理、`examples/` の追随更新、provider の `~` 展開廃止(絶対パス徹底)までカバーしている。
|
||||
|
||||
## アーキテクチャ統合
|
||||
|
||||
### クレート境界
|
||||
|
||||
- **`manifest/src/config.rs`** — `PodManifestConfig` を manifest crate 側に置いたのは**正しい判断**。manifest crate は純データ型と検証ロジック (`Scope::from_config`, `TryFrom`) の置き場で I/O を持たない、という既存方針をそのまま継承している
|
||||
- **`crates/pod/src/factory.rs`** / **`prompt_loader.rs`** — I/O(ファイル読み、XDG 解決、`include_dir!`)は pod crate 側に集約。新規 crate を作らず、既存レイヤに素直に乗せている。前回私が推した統合方針どおり
|
||||
- **`PodFactory::resolve()` が `(PodManifest, PromptLoader)` を返す** — factory は「manifest + どこからプロンプトを引くか」を一体で管理し、Pod は渡された loader を素直に使うだけ。責務の分離が綺麗
|
||||
- **`Pod::manifest_dir` の完全削除 + `resolve_pwd` の絶対パス強制** — 「パスの正規化は cascade 層の専売事項」という単一ソースの原則が守られている。`provider::build_client` からも `manifest_dir` 引数が消え、`~` 展開も消え、relative rejection だけ残った。下流が大幅にシンプルになった
|
||||
|
||||
### 副作用的な改善
|
||||
|
||||
- `Pod::from_manifest_toml` が `PromptLoader::builtins_only()` を暗黙に使う → テスト・examples が「単層 TOML で Pod 起動」を一行で書けるようになった
|
||||
- `examples/pod_cli.rs` と `pod_protocol.rs` が絶対パス化と新 API に追随済み。壊れ残りなし
|
||||
|
||||
## 指摘事項
|
||||
|
||||
### 1. 🟢 `with_overlay_toml` / `with_overlay_config` の重ね合わせが「同じ層にマージ」
|
||||
|
||||
`factory.rs:123-139`:
|
||||
|
||||
```rust
|
||||
pub fn with_overlay_toml(mut self, toml: &str) -> Result<Self, FactoryError> {
|
||||
let config = PodManifestConfig::from_toml(toml).map_err(FactoryError::OverlayParse)?;
|
||||
self.overlay = Some(match self.overlay {
|
||||
Some(existing) => existing.merge(config),
|
||||
None => config,
|
||||
});
|
||||
Ok(self)
|
||||
}
|
||||
```
|
||||
|
||||
`with_overlay_*` を複数回呼ぶと、独立したレイヤにはならず**1 つの overlay スロットに逐次マージ**される(後勝ち)。これは CLI + 1 回のプログラマティック注入には十分だが、「複数の独立した overlay を priority order 付きで積みたい」ニーズには応えない。
|
||||
|
||||
**判断**: 現状の要件範囲内では問題なし。将来 preset や環境変数ベースの overlay を追加する場合に再検討。
|
||||
|
||||
**細かい付随点**: `factory.rs` のテスト `cascade_overlay_overrides_project_overrides_user` は名前が「overlay が project を override し、project が user を override する」と読めるが、実際には**全部 overlay スロットに積まれている**(user/project スロットは未使用)。テスト内コメントで断り書きはあるが、名前だけ見ると誤解されやすい。`cascade_priority_layer_ordering` のほうが本来のレイヤ順序を検証しているので、前者は `overlay_stacking_merges_in_place` などに改名するとより正確。
|
||||
|
||||
### 2. 🟢 "builtin defaults" 層の実体がゼロ
|
||||
|
||||
チケット方針:
|
||||
> ビルトインのデフォルト: コードに焼き込んだ基本値(現在 `PodManifest` 各フィールドの `#[serde(default)]` や `Default` 実装に散っているものを集約)
|
||||
|
||||
実装方針(`factory.rs` module doc):
|
||||
> 1. **Builtin defaults** — in-code defaults, currently empty. Upper layers provide everything; `TryFrom<PodManifestConfig>` fills in per-field defaults
|
||||
|
||||
実際の builtin layer は `PodManifestConfig::default()`(全部 None)で、デフォルト値の適用は `TryFrom` 内の `.unwrap_or(ToolOutputLimits::default())` のように散在している。つまり**「散らばってる defaults を builtin layer に集約する」という元の目的は達成されていない**。
|
||||
|
||||
**判断**: 実運用上の挙動は同じ(ユーザーから見れば `PodManifest` の各フィールドが既定値を持つことに変わりなし)。チケットの表現 vs 実装の厳密な乖離だけで、受け入れ可否には影響しない。将来 defaults の一覧を可視化したくなった時点で builtin layer を実体化する余地を残しておけば OK。
|
||||
|
||||
### 3. 🟢 CLI `--pwd` の overlay 注入が文字列フォーマット経由
|
||||
|
||||
`main.rs:64-76`:
|
||||
|
||||
```rust
|
||||
parts.push(format!(
|
||||
"[pod]\npwd = \"{}\"\n",
|
||||
absolute.display().to_string().replace('\\', "\\\\")
|
||||
));
|
||||
```
|
||||
|
||||
生成した TOML 断片を `with_overlay_toml` に渡している。`\` は escape しているが `"` は escape していないため、`"` を含むパス(Linux では理論上あり得る)で壊れる。また `--overlay` 側と `--pwd` 側を join するときに両者の構文が衝突しないかも微妙。
|
||||
|
||||
**推奨**: 文字列を作らず、`PodManifestConfig` を直接構築して `with_overlay_config` で渡す形に書き換える:
|
||||
|
||||
```rust
|
||||
if let Some(pwd) = cli.pwd.as_ref() {
|
||||
let absolute = std::fs::canonicalize(pwd).unwrap_or_else(|_| pwd.clone());
|
||||
factory = factory.with_overlay_config(PodManifestConfig {
|
||||
pod: PodMetaConfig { pwd: Some(absolute), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some(overlay) = cli.overlay.as_deref() {
|
||||
factory = factory.with_overlay_toml(overlay)?;
|
||||
}
|
||||
```
|
||||
|
||||
型を経由するので escape 問題が消え、`--pwd` と `--overlay` の干渉も無くなる。
|
||||
|
||||
**判断**: 現実には問題が起きる可能性は極めて低いが、型経由のほうが筋が良い。**任意**。
|
||||
|
||||
### 4. 🟢 `resolve_provider` に dead code
|
||||
|
||||
`manifest/src/config.rs:218-237`:
|
||||
|
||||
```rust
|
||||
fn resolve_provider(
|
||||
cfg: ProviderConfigPartial,
|
||||
field_prefix: &'static str, // ← 使われていない
|
||||
kind_field: &'static str,
|
||||
...
|
||||
) -> Result<ProviderConfig, ResolveError> {
|
||||
let _ = field_prefix; // ← 明示的に捨てられている
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`field_prefix` を取っているが関数内で使っていない(`let _ =` で捨てている)。将来エラーメッセージで `"missing field: {prefix}.kind"` のようにしたい意図だったと推察されるが、現在の `ResolveError::MissingField` は静的文字列を直接受けているので不要。
|
||||
|
||||
**判断**: 不要なので削除推奨。**任意**(実害なし、lint が効けば dead_code 警告が出るかも)。
|
||||
|
||||
### 5. 🟢 `~` 展開廃止は breaking change
|
||||
|
||||
`provider` から `~/.config/insomnia/keys/anthropic` のような `~` 始まりパスの展開処理が消えた。既存の手書き manifest にこの形式が書かれていると resolve で `RelativePath` エラーになる。
|
||||
|
||||
**判断**: チケットで「絶対パスのみ」と決めた結果であり、意図通り。`docs/pod-factory.md` でも「パスの絶対性」として明記されている。受け入れ可。
|
||||
|
||||
### 6. 🟢 `find_project_manifest` の canonicalize 失敗時フォールバック
|
||||
|
||||
`factory.rs:197-208` は `start.canonicalize()` が失敗したら raw path で walk を続ける。`.insomnia` 名前の検出は path 比較だけなので動作するが、shell が与えた相対パスが絶対化されないまま `dir.parent()` を続けると仕様上の最上位で止まる可能性。
|
||||
|
||||
**判断**: 実害小。canonicalize が失敗するケースは cwd が無効等で、そもそも Pod 起動前の別エラーに出るはず。**不問**。
|
||||
|
||||
## テスト
|
||||
|
||||
- `manifest/src/config.rs`: 14 ケース(resolve 成功 / 必須欠落 / 相対パス3種 / scalar merge / scope union / per_tool keywise / option struct / type mismatch / unknown field / partial layer / end-to-end cascade)
|
||||
- `pod/src/factory.rs`: 7 ケース(overlay only / 模擬レイヤ順 / 実レイヤ順 / walk-up / 無プロジェクト / loader 連携 / 必須欠落)
|
||||
- `pod/src/prompt_loader.rs`: 6 ケース(builtin 存在 / サブディレクトリ / 未知 / user override / project override / fallthrough)
|
||||
- `pod/src/system_prompt.rs`: 2 ケース(include 成功 / 未知 prompt)
|
||||
- `provider/src/lib.rs`: 既存テストを新シグネチャに追随 + 新規 relative rejection
|
||||
|
||||
**合計 30 ケース超**、各層の検証が丁寧。特に `resolve_produces_loader_with_project_prompts_dir` は factory + prompt_loader + system_prompt の**3 層を貫く end-to-end テスト**で、この変更でもっとも壊れやすい配線を lock-in している点が優秀。
|
||||
|
||||
## 結論
|
||||
|
||||
**無条件で受け入れ可**。要件は全項目達成、アーキテクチャ統合も筋が良く、テスト被覆も厚い。指摘はすべて任意修正または nit レベルで、受け入れ可否に影響しない。
|
||||
|
||||
任意修正として推せるのは:
|
||||
|
||||
- **指摘 3**(CLI `--pwd` の型経由化)が最も筋が良い改善。余力があれば
|
||||
- **指摘 4**(dead code)は数行で済む clean-up
|
||||
- **指摘 1 のテスト名**(`cascade_overlay_overrides_project_overrides_user` → `overlay_stacking_merges_in_place` 等)も小さな改善
|
||||
|
||||
上記はいずれも受け入れ後の別タスクとしても良い。
|
||||
|
||||
---
|
||||
|
||||
## フォローアップ差分 (2026-04-16)
|
||||
|
||||
レビュー後の追加作業として、指摘 #2(builtin defaults の実体ゼロ)を解消し、
|
||||
**デフォルト値のメンテナンス性向上**を目的とした集約リファクタを入れた。
|
||||
|
||||
### 変更内容
|
||||
|
||||
- **`crates/manifest/src/defaults.rs` 新設**: 全 manifest デフォルト値を
|
||||
`pub const` で宣言する単一の真実ソース
|
||||
- `TOOL_OUTPUT_MAX_BYTES`, `PRUNE_PROTECTED_TURNS`,
|
||||
`PRUNE_MIN_SAVINGS`, `COMPACT_RETAINED_TURNS`
|
||||
- **`crates/manifest/src/lib.rs`**: 既存の `default_*` fn 群を constants を
|
||||
返すだけの 1 行に縮小。`ToolOutputLimits::default()` /
|
||||
`CompactionConfig::default()` の serde `#[default = "..."]` 経路もすべて
|
||||
constants に収束
|
||||
- **`crates/manifest/src/config.rs`**:
|
||||
- `PodManifestConfig::builtin_defaults()` を追加(cascade の最下層として使う
|
||||
構築メソッド、constants 直接参照)
|
||||
- `TryFrom<PodManifestConfig> for PodManifest` が `ToolOutputLimits::default()`
|
||||
/ `CompactionConfig::default()` 経由をやめ、constants を直接 `unwrap_or`
|
||||
する belt-and-suspenders 形
|
||||
- 指摘 #4 の `resolve_provider` dead param (`field_prefix`) を削除
|
||||
- **`crates/pod/src/factory.rs`**:
|
||||
- `PodFactory::resolve` の base layer を `PodManifestConfig::default()` から
|
||||
`PodManifestConfig::builtin_defaults()` に切替。これで "builtin layer" が
|
||||
実体を持つ cascade 層として機能
|
||||
- 指摘 #1 のテスト名 `cascade_overlay_overrides_project_overrides_user` →
|
||||
`overlay_stacking_merges_in_place` にリネーム
|
||||
- **`crates/manifest/src/config.rs` テスト追加**:
|
||||
- `builtin_defaults_populates_tool_output_max_bytes`
|
||||
- `builtin_defaults_merged_into_minimal_resolves_with_defaults`
|
||||
|
||||
### 効果
|
||||
|
||||
- デフォルト値を変えるときの編集箇所が**1 ファイル 1 行**(`defaults.rs`)に。
|
||||
従来は `default_*` fn、`Default::default()` impl、`TryFrom` フォールバックの
|
||||
3 経路にそれぞれ値が書かれていたが、すべて `defaults::X` を参照する形に収束
|
||||
- チケット本文で当初意図されていた「builtin layer がデフォルト値を保持する」
|
||||
概念が `builtin_defaults()` + factory の base 層で実体化
|
||||
- `TryFrom` の fallback は残すので、`builtin_defaults()` を経由しない直接構築
|
||||
(テスト等)でも同じ既定値が保証される(belt-and-suspenders)
|
||||
|
||||
### テスト
|
||||
|
||||
全 ワークスペース テスト通過(manifest 45 / pod 71 を含む)。新規 2 ケース
|
||||
で `builtin_defaults` の挙動と constants の同一性を lock-in。
|
||||
|
||||
### 未処理
|
||||
|
||||
- **指摘 3**(CLI `--pwd` の型経由化)は未対応。現在の文字列 format 経由でも
|
||||
escape 問題が実際に起きる可能性は極めて低いため保留。必要になった時点で別
|
||||
フォローアップとする
|
||||
|
||||
この差分の後、指摘 #2 / #4 / #1(テスト名)は解消済み。受け入れ可否には
|
||||
変化なし(元々「無条件で受け入れ可」)。
|
||||
Loading…
Reference in New Issue
Block a user