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

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