システムプロンプトの実装

This commit is contained in:
2026-04-15 02:44:42 +09:00
parent 309dba7203
commit 66c6edec3e
12 changed files with 977 additions and 42 deletions
+7 -1
View File
@@ -72,12 +72,18 @@ impl ToolServerHandle {
/// Execute all pending factories and register the resulting tools.
///
/// Called implicitly by `Worker::lock()` before the first turn.
/// Exposed as `pub` so higher layers (e.g. Pod) can force-materialise
/// tools earlier — for example when building a system-prompt template
/// context that needs the list of registered tool names. Redundant
/// calls are no-ops.
///
/// # Panics
///
/// Panics if any factory produces a tool whose name collides with
/// an already-registered tool. Duplicate names are a programming
/// error and should be caught during development.
pub(crate) fn flush_pending(&self) {
pub fn flush_pending(&self) {
let pending: Vec<_> = {
let mut guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
std::mem::take(&mut *guard)
+108
View File
@@ -130,6 +130,59 @@ impl Scope {
pub fn is_writable(&self, path: &Path) -> bool {
matches!(self.permission_at(path), Some(Permission::Write))
}
/// Iterate over absolute paths granted at least `Read` by an allow
/// rule, preserving declaration order. Does not account for deny
/// rules, which only cap effective permission at query time.
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
self.allow.iter().map(|r| r.target.as_path())
}
/// Iterate over absolute paths granted `Write` by an allow rule.
/// Subset of [`readable_paths`](Self::readable_paths).
pub fn writable_paths(&self) -> impl Iterator<Item = &Path> {
self.allow
.iter()
.filter(|r| r.permission == Permission::Write)
.map(|r| r.target.as_path())
}
/// Human-readable grouping of allow rules, suitable for embedding in
/// LLM system prompts. Deny rules are intentionally omitted — they
/// only cap effective permission and surface them would mislead the
/// reader about what paths are accessible.
///
/// ```text
/// Readable:
/// - /abs/path1
/// Writable:
/// - /abs/path2
/// ```
pub fn summary(&self) -> String {
let mut out = String::new();
let readable: Vec<_> = self.readable_paths().collect();
if !readable.is_empty() {
out.push_str("Readable:\n");
for p in &readable {
out.push_str(" - ");
out.push_str(&p.display().to_string());
out.push('\n');
}
}
let writable: Vec<_> = self.writable_paths().collect();
if !writable.is_empty() {
out.push_str("Writable:\n");
for p in &writable {
out.push_str(" - ");
out.push_str(&p.display().to_string());
out.push('\n');
}
}
if out.ends_with('\n') {
out.pop();
}
out
}
}
impl ResolvedRule {
@@ -339,6 +392,61 @@ mod tests {
assert!(!scope.is_readable(&traversal));
}
#[test]
fn summary_lists_readable_and_writable() {
let dir = TempDir::new().unwrap();
let docs = dir.path().join("docs");
std::fs::create_dir(&docs).unwrap();
let cfg = ScopeConfig {
allow: vec![
allow_rule(dir.path(), Permission::Read),
allow_rule(&docs, Permission::Write),
],
deny: Vec::new(),
};
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
let summary = scope.summary();
assert!(summary.contains("Readable:"));
assert!(summary.contains("Writable:"));
assert!(summary.contains(&dir.path().canonicalize().unwrap().display().to_string()));
assert!(summary.contains(&docs.canonicalize().unwrap().display().to_string()));
assert!(!summary.ends_with('\n'));
}
#[test]
fn summary_excludes_deny_rules() {
let dir = TempDir::new().unwrap();
let secret = dir.path().join("secret");
std::fs::create_dir(&secret).unwrap();
let cfg = ScopeConfig {
allow: vec![allow_rule(dir.path(), Permission::Write)],
deny: vec![allow_rule(&secret, Permission::Read)],
};
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
let summary = scope.summary();
assert!(!summary.contains("secret"));
}
#[test]
fn readable_paths_includes_writable() {
let dir = TempDir::new().unwrap();
let docs = dir.path().join("docs");
std::fs::create_dir(&docs).unwrap();
let cfg = ScopeConfig {
allow: vec![
allow_rule(dir.path(), Permission::Read),
allow_rule(&docs, Permission::Write),
],
deny: Vec::new(),
};
let scope = Scope::from_config(&cfg, dir.path()).unwrap();
let readable: Vec<_> = scope.readable_paths().collect();
let writable: Vec<_> = scope.writable_paths().collect();
assert_eq!(readable.len(), 2);
assert_eq!(writable.len(), 1);
assert!(writable.iter().all(|w| readable.contains(w)));
}
#[test]
fn resolves_new_nested_file_inside_scope() {
let dir = TempDir::new().unwrap();
+2
View File
@@ -19,6 +19,8 @@ tokio = { version = "1.49", features = ["fs", "io-util", "macros", "net", "rt-mu
toml = "1.1.2"
tracing = "0.1.44"
tools = { version = "0.1.0", path = "../tools" }
minijinja = "2.19.0"
chrono = "0.4.44"
[dev-dependencies]
async-trait = "0.1.89"
+2
View File
@@ -9,6 +9,7 @@ mod compact_state;
mod hook_interceptor;
mod pod;
mod prune;
mod system_prompt;
mod token_counter;
mod usage_tracker;
@@ -23,3 +24,4 @@ pub use provider::{ProviderError, build_client};
pub use runtime_dir::RuntimeDir;
pub use shared_state::{PodSharedState, PodStatus};
pub use socket_server::SocketServer;
pub use system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
+109 -22
View File
@@ -20,6 +20,7 @@ use crate::hook::{
PreToolCall,
};
use crate::hook_interceptor::HookInterceptor;
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::usage_tracker::UsageTracker;
use async_trait::async_trait;
use llm_worker::interceptor::PreRequestAction;
@@ -91,6 +92,10 @@ pub struct Pod<C: LlmClient, St: Store> {
/// tools so that Pod-owned operations (e.g. compaction) can consult
/// the recency of touched files.
tracker: Option<tools::Tracker>,
/// Parsed system-prompt template awaiting first-turn materialisation.
/// `Some` until `ensure_system_prompt_materialized` renders it once,
/// then `None` forever — including after compaction.
system_prompt_template: Option<SystemPromptTemplate>,
}
impl<C: LlmClient, St: Store> Pod<C, St> {
@@ -106,18 +111,16 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> {
let state = SessionStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
};
let (session_id, head_hash) = session_store::create_session(&store, state).await?;
// Session creation is deferred to `ensure_session_head` at first
// run so a later-installed system-prompt template (see
// `set_system_prompt_template`) can be captured by `SessionStart`.
let session_id = session_store::new_session_id();
let mut pod = Self {
manifest,
worker: Some(worker),
store,
session_id,
head_hash: Some(head_hash),
head_hash: None,
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
@@ -127,11 +130,20 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::<UsageRecord>::new())),
tracker: None,
system_prompt_template: None,
};
pod.apply_prune_from_manifest();
Ok(pod)
}
/// Install a parsed system-prompt template that will be rendered
/// exactly once, immediately before the first LLM turn. Mirrors the
/// path used by `Pod::from_manifest` and is exposed for tests and
/// other callers that build a Pod without going through a manifest.
pub fn set_system_prompt_template(&mut self, template: SystemPromptTemplate) {
self.system_prompt_template = Some(template);
}
/// Restore a Pod from a persisted session.
pub async fn restore(
session_id: SessionId,
@@ -166,6 +178,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(state.usage_history)),
tracker: None,
system_prompt_template: None,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -364,6 +377,40 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
}
/// Render the manifest-supplied system-prompt template exactly once,
/// just before the first LLM turn, and hand the resulting string to
/// the Worker via `set_system_prompt`. Subsequent invocations are
/// no-ops: the template field is consumed with `Option::take()`, so
/// the rendered value persists across all later turns and compaction.
fn ensure_system_prompt_materialized(&mut self) -> Result<(), PodError> {
let Some(template) = self.system_prompt_template.take() else {
return Ok(());
};
let worker = self.worker.as_mut().expect("worker present");
// Materialise any pending tool factories so the template sees the
// full list of tool names. Redundant with the flush inside
// `Worker::lock()`; safe because `flush_pending` is idempotent.
worker.tool_server_handle().flush_pending();
let tool_names: Vec<String> = worker
.tool_server_handle()
.tool_definitions_sorted()
.into_iter()
.map(|d| d.name)
.collect();
let ctx = SystemPromptContext {
now: chrono::Utc::now(),
cwd: &self.pwd,
scope: &self.scope,
tool_names,
files: std::collections::BTreeMap::new(),
};
let rendered = template
.render(&ctx)
.map_err(|source| PodError::SystemPromptRender { source })?;
worker.set_system_prompt(rendered);
Ok(())
}
/// Send user input and run until the LLM turn completes.
///
/// If the between-turns compaction threshold is exceeded mid-run,
@@ -371,6 +418,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// automatically.
pub async fn run(&mut self, input: impl Into<String>) -> Result<PodRunResult, PodError> {
self.ensure_interceptor_installed();
self.ensure_system_prompt_materialized()?;
self.ensure_session_head().await?;
let history_before = self.worker.as_ref().unwrap().history().len();
@@ -387,6 +435,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// Resume from a paused state.
pub async fn resume(&mut self) -> Result<PodRunResult, PodError> {
self.ensure_interceptor_installed();
self.ensure_system_prompt_materialized()?;
self.ensure_session_head().await?;
let history_before = self.worker.as_ref().unwrap().history().len();
@@ -400,18 +449,32 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.handle_worker_result(result, history_before).await
}
/// Ensure session head exists (fork if needed).
/// Ensure the session exists and its head still matches ours.
///
/// On the first call for a Pod built via `from_manifest`, the session
/// has not been written to the store yet — this is when we append the
/// initial `SessionStart` entry, carrying the system prompt that
/// `ensure_system_prompt_materialized` has just rendered. Subsequent
/// calls fall through to `ensure_head_or_fork`, which auto-forks when
/// another writer has advanced the store head behind our back.
async fn ensure_session_head(&mut self) -> Result<(), PodError> {
let w = self.worker.as_ref().unwrap();
let state = SessionStartState {
system_prompt: w.get_system_prompt(),
config: w.request_config(),
history: w.history(),
};
if self.head_hash.is_none() {
let hash =
session_store::create_session_with_id(&self.store, self.session_id, state).await?;
self.head_hash = Some(hash);
return Ok(());
}
session_store::ensure_head_or_fork(
&self.store,
&mut self.session_id,
&mut self.head_hash,
SessionStartState {
system_prompt: w.get_system_prompt(),
config: w.request_config(),
history: w.history(),
},
state,
)
.await?;
Ok(())
@@ -725,18 +788,28 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
let mut worker = Worker::new(client);
apply_worker_manifest(&mut worker, &manifest.worker);
let state = SessionStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
// Parse the system-prompt template eagerly (syntax check only).
// Rendering is deferred to `ensure_system_prompt_materialized`
// at first turn so implementation runtime values (date, tools,
// scope summary, ...) can be injected.
let system_prompt_template = match manifest.worker.system_prompt.as_deref() {
Some(source) => Some(
SystemPromptTemplate::parse(source)
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
),
None => None,
};
let (session_id, head_hash) = session_store::create_session(&store, state).await?;
// Session creation is deferred to the first run (see
// `ensure_session_head`) so the SessionStart entry can capture
// the rendered system prompt, not the raw template source.
let session_id = session_store::new_session_id();
let mut pod = Self {
manifest,
worker: Some(worker),
store,
session_id,
head_hash: Some(head_hash),
head_hash: None,
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
@@ -746,6 +819,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
usage_tracker: Arc::new(UsageTracker::new()),
usage_history: Arc::new(Mutex::new(Vec::new())),
tracker: None,
system_prompt_template,
};
pod.apply_prune_from_manifest();
Ok(pod)
@@ -753,10 +827,11 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
}
/// Apply worker-level manifest settings to a Worker.
///
/// Note: `system_prompt` is intentionally not applied here. It is a
/// minijinja template that is parsed by `Pod::from_manifest` and
/// rendered once at first turn in `ensure_system_prompt_materialized`.
pub fn apply_worker_manifest<C: LlmClient>(worker: &mut Worker<C>, wm: &WorkerManifest) {
if let Some(ref prompt) = wm.system_prompt {
worker.set_system_prompt(prompt);
}
let mut config = RequestConfig::new();
if let Some(max_tokens) = wm.max_tokens {
config.max_tokens = Some(max_tokens);
@@ -856,6 +931,18 @@ pub enum PodError {
#[error("compaction thrash: context still exceeds threshold immediately after compact")]
CompactThrash,
#[error("invalid system prompt template: {source}")]
InvalidSystemPromptTemplate {
#[source]
source: SystemPromptError,
},
#[error("failed to render system prompt template: {source}")]
SystemPromptRender {
#[source]
source: SystemPromptError,
},
}
/// Resolve the pwd declared in a manifest against `manifest_dir` (or the
+244
View File
@@ -0,0 +1,244 @@
//! System prompt template machinery for the Pod layer.
//!
//! Manifests describe `system_prompt` as a minijinja template string.
//! The template is parsed eagerly at `Pod::from_manifest` (syntax check
//! only) and held on the Pod until `ensure_system_prompt_materialized`
//! renders it exactly once, just before the first LLM turn. The rendered
//! string is pushed to the worker via `set_system_prompt` and is reused
//! for every subsequent turn, including after compaction.
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope;
use minijinja::value::Value;
use minijinja::{Environment, UndefinedBehavior};
use thiserror::Error;
const TEMPLATE_NAME: &str = "system_prompt";
#[derive(Debug, Error)]
pub enum SystemPromptError {
#[error("system prompt template parse error: {0}")]
Parse(String),
#[error("system prompt template render error: {0}")]
Render(String),
}
/// Parsed system-prompt template. Holds a minijinja Environment with a
/// single named template; rendering only needs a fresh [`SystemPromptContext`].
#[derive(Clone)]
pub struct SystemPromptTemplate {
env: Arc<Environment<'static>>,
}
impl SystemPromptTemplate {
/// Parse a template source. Performs syntax validation only — no
/// variable resolution is attempted here.
pub fn parse(source: impl Into<String>) -> Result<Self, SystemPromptError> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.add_template_owned(TEMPLATE_NAME, source.into())
.map_err(|e| SystemPromptError::Parse(e.to_string()))?;
Ok(Self { env: Arc::new(env) })
}
/// Render the template with the supplied context. Missing variables
/// surface as [`SystemPromptError::Render`].
pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result<String, SystemPromptError> {
let tmpl = self
.env
.get_template(TEMPLATE_NAME)
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
tmpl.render(ctx.to_minijinja_value())
.map_err(|e| SystemPromptError::Render(e.to_string()))
}
}
impl std::fmt::Debug for SystemPromptTemplate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SystemPromptTemplate")
.finish_non_exhaustive()
}
}
/// Inputs available to a system-prompt template at materialisation time.
///
/// `files` is reserved for AGENTS.md and other external file ingestion
/// (supplied by a separate ticket). It is always present so template
/// authors can reference `{{ files.agents_md }}` without having to guard
/// for key existence.
pub struct SystemPromptContext<'a> {
pub now: DateTime<Utc>,
pub cwd: &'a Path,
pub scope: &'a Scope,
pub tool_names: Vec<String>,
pub files: BTreeMap<String, String>,
}
impl<'a> SystemPromptContext<'a> {
fn to_minijinja_value(&self) -> Value {
let mut root: BTreeMap<String, Value> = BTreeMap::new();
root.insert(
"date".into(),
Value::from(self.now.format("%Y-%m-%d").to_string()),
);
root.insert(
"time".into(),
Value::from(self.now.format("%H:%M:%S").to_string()),
);
root.insert(
"datetime".into(),
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
);
root.insert("cwd".into(), Value::from(self.cwd.display().to_string()));
root.insert("scope".into(), scope_value(self.scope));
root.insert(
"tools".into(),
Value::from(
self.tool_names
.iter()
.cloned()
.map(Value::from)
.collect::<Vec<_>>(),
),
);
root.insert(
"files".into(),
Value::from(
self.files
.iter()
.map(|(k, v)| (k.clone(), Value::from(v.clone())))
.collect::<BTreeMap<String, Value>>(),
),
);
Value::from(root)
}
}
fn scope_value(scope: &Scope) -> Value {
let readable: Vec<Value> = scope
.readable_paths()
.map(|p| Value::from(p.display().to_string()))
.collect();
let writable: Vec<Value> = scope
.writable_paths()
.map(|p| Value::from(p.display().to_string()))
.collect();
let mut obj: BTreeMap<String, Value> = BTreeMap::new();
obj.insert("readable".into(), Value::from(readable));
obj.insert("writable".into(), Value::from(writable));
obj.insert("summary".into(), Value::from(scope.summary()));
Value::from(obj)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use manifest::{Permission, ScopeConfig, ScopeRule};
use tempfile::TempDir;
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 15, 9, 30, 0).unwrap()
}
fn build_scope(dir: &Path) -> Scope {
let cfg = ScopeConfig {
allow: vec![ScopeRule {
target: dir.to_path_buf(),
permission: Permission::Write,
recursive: true,
}],
deny: Vec::new(),
};
Scope::from_config(&cfg, dir).unwrap()
}
fn ctx<'a>(cwd: &'a Path, scope: &'a Scope, tools: Vec<String>) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd,
scope,
tool_names: tools,
files: BTreeMap::new(),
}
}
#[test]
fn parse_succeeds_for_minimal_template() {
let t = SystemPromptTemplate::parse("hello").unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap();
assert_eq!(rendered, "hello");
}
#[test]
fn parse_fails_on_syntax_error() {
let err = SystemPromptTemplate::parse("{{ unclosed").unwrap_err();
assert!(matches!(err, SystemPromptError::Parse(_)));
}
#[test]
fn render_substitutes_date_cwd_tools() {
let t = SystemPromptTemplate::parse(
"date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}",
)
.unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = t
.render(&ctx(
dir.path(),
&scope,
vec!["alpha".into(), "beta".into()],
))
.unwrap();
assert!(rendered.contains("date=2026-04-15"));
assert!(rendered.contains(&format!("cwd={}", dir.path().display())));
assert!(rendered.contains("tools=alpha,beta"));
}
#[test]
fn render_fails_on_undefined_variable() {
let t = SystemPromptTemplate::parse("{{ ghost }}").unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let err = t.render(&ctx(dir.path(), &scope, vec![])).unwrap_err();
assert!(matches!(err, SystemPromptError::Render(_)));
}
#[test]
fn escape_double_braces() {
let t = SystemPromptTemplate::parse("literal {{ '{{' }} here").unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap();
assert_eq!(rendered, "literal {{ here");
}
#[test]
fn scope_summary_renders() {
let t = SystemPromptTemplate::parse("{{ scope.summary }}").unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap();
assert!(rendered.starts_with("Readable:"));
assert!(rendered.contains(&dir.path().canonicalize().unwrap().display().to_string()));
}
#[test]
fn files_reserved_namespace_is_empty() {
let t = SystemPromptTemplate::parse(
"{% if files.agents_md is defined %}yes{% else %}no{% endif %}",
)
.unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap();
assert_eq!(rendered, "no");
}
}
@@ -0,0 +1,240 @@
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use futures::Stream;
use llm_worker::Worker;
use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_worker::llm_client::{ClientError, LlmClient, Request};
use session_store::{FsStore, LogEntry, Store};
use pod::{Pod, PodError, SystemPromptTemplate};
// ---------------------------------------------------------------------------
// Mock LLM Client
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
call_count: Arc<AtomicUsize>,
}
impl MockClient {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)),
}
}
}
#[async_trait]
impl LlmClient for MockClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
let idx = count.min(self.responses.len() - 1);
let events = self.responses[idx].clone();
let stream = futures::stream::iter(events.into_iter().map(Ok));
Ok(Box::pin(stream))
}
}
fn single_text_events(text: &str) -> Vec<LlmEvent> {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, text),
LlmEvent::text_block_stop(0, None),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
fn manifest_toml(system_prompt: Option<&str>) -> String {
let prompt_line = match system_prompt {
Some(s) => format!("system_prompt = {:?}\n", s),
None => String::new(),
};
format!(
r#"
[pod]
name = "test-pod"
pwd = "./"
[provider]
kind = "anthropic"
model = "test-model"
[worker]
max_tokens = 100
{prompt_line}
[[scope.allow]]
target = "./"
permission = "write"
"#
)
}
async fn make_pod_with_template(
template_source: Option<&str>,
client: MockClient,
) -> Result<Pod<MockClient, FsStore>, PodError> {
let manifest = pod::PodManifest::from_toml(&manifest_toml(template_source)).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.unwrap();
std::mem::forget(store_tmp);
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = pod::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let worker = Worker::new(client);
let mut pod = Pod::new(manifest, worker, store, pwd, scope).await?;
if let Some(source) = template_source {
let template = SystemPromptTemplate::parse(source)
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?;
pod.set_system_prompt_template(template);
}
Ok(pod)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn template_parse_rejects_invalid_syntax() {
let err = SystemPromptTemplate::parse("{{ unclosed").unwrap_err();
// Surfaces via PodError::InvalidSystemPromptTemplate when used with
// Pod::from_manifest — tested at the SystemPromptTemplate level here
// because building a Pod via from_manifest requires a real provider.
let pod_err: PodError = PodError::InvalidSystemPromptTemplate { source: err };
assert!(matches!(
pod_err,
PodError::InvalidSystemPromptTemplate { .. }
));
}
#[tokio::test]
async fn template_is_not_materialised_before_first_run() {
let client = MockClient::new(vec![single_text_events("ok")]);
let pod = make_pod_with_template(Some("hello"), client).await.unwrap();
// Before first run, worker still has no system prompt.
assert!(pod.worker().get_system_prompt().is_none());
}
#[tokio::test]
async fn materialise_on_first_turn_populates_worker() {
let client = MockClient::new(vec![single_text_events("ok")]);
let mut pod = make_pod_with_template(
Some("date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}"),
client,
)
.await
.unwrap();
pod.run("hi").await.unwrap();
let rendered = pod
.worker()
.get_system_prompt()
.expect("system prompt materialised")
.to_string();
assert!(rendered.contains("date="));
assert!(rendered.contains("cwd="));
assert!(rendered.contains(&pod.pwd().display().to_string()));
assert!(rendered.starts_with("date="));
}
#[tokio::test]
async fn session_start_state_captures_rendered_prompt() {
let client = MockClient::new(vec![single_text_events("ok")]);
let mut pod = make_pod_with_template(Some("hello cwd={{ cwd }}"), client)
.await
.unwrap();
pod.run("hi").await.unwrap();
// Inspect the first log entry directly: it must be a SessionStart
// with the rendered system prompt, not `None`.
let entries = pod.store().read_all(pod.session_id()).await.unwrap();
let first = entries.first().expect("at least one entry");
match &first.entry {
LogEntry::SessionStart { system_prompt, .. } => {
let sp = system_prompt.as_deref().expect("system prompt set");
assert!(sp.starts_with("hello cwd="));
assert!(sp.contains(&pod.pwd().display().to_string()));
}
other => panic!("expected SessionStart as first entry, got {other:?}"),
}
}
#[tokio::test]
async fn render_failure_propagates_as_pod_error() {
let client = MockClient::new(vec![single_text_events("ok")]);
let mut pod = make_pod_with_template(Some("{{ ghost }}"), client)
.await
.unwrap();
let err = pod.run("hi").await.unwrap_err();
assert!(matches!(err, PodError::SystemPromptRender { .. }));
}
#[tokio::test]
async fn materialise_runs_only_once_across_turns() {
// Two turns; the second one must not re-render the template. We
// approximate this by checking that the rendered system prompt is
// identical across turns and that the Pod's template slot is
// exhausted after the first run.
let client = MockClient::new(vec![
single_text_events("first"),
single_text_events("second"),
]);
let mut pod = make_pod_with_template(Some("fixed prompt {{ cwd }}"), client)
.await
.unwrap();
pod.run("one").await.unwrap();
let first = pod.worker().get_system_prompt().unwrap().to_string();
pod.run("two").await.unwrap();
let second = pod.worker().get_system_prompt().unwrap().to_string();
assert_eq!(first, second);
}
#[tokio::test]
async fn compact_preserves_system_prompt() {
// Three user turns, then compact with retained_turns=1. The new
// compacted session must carry the same rendered system prompt and
// the template must not re-run.
let client = MockClient::new(vec![
single_text_events("a"),
single_text_events("b"),
single_text_events("summary"),
single_text_events("c"),
]);
let mut pod = make_pod_with_template(Some("SP cwd={{ cwd }}"), client)
.await
.unwrap();
pod.run("first").await.unwrap();
let before = pod.worker().get_system_prompt().unwrap().to_string();
pod.run("second").await.unwrap();
pod.compact(1).await.unwrap();
let after = pod.worker().get_system_prompt().unwrap().to_string();
assert_eq!(before, after);
// A further run must still see the same prompt (template is None, so
// ensure_system_prompt_materialized is a no-op).
pod.run("third").await.unwrap();
assert_eq!(pod.worker().get_system_prompt().unwrap(), after.as_str());
}
+3 -3
View File
@@ -35,9 +35,9 @@ pub mod store;
pub use event_trace::TraceEntry;
pub use fs_store::FsStore;
pub use session::{
SessionStartState, create_compacted_session, create_session, ensure_head_or_fork, fork,
fork_at, restore, save_cache_locked, save_cache_unlocked, save_config_changed, save_delta,
save_outcome, save_turn_end, save_usage,
SessionStartState, create_compacted_session, create_session, create_session_with_id,
ensure_head_or_fork, fork, fork_at, restore, save_cache_locked, save_cache_unlocked,
save_config_changed, save_delta, save_outcome, save_turn_end, save_usage,
};
pub use session_log::{
EntryHash, HashedEntry, LogEntry, Outcome, RestoredState, SessionOrigin, UsageRecord,
+15 -1
View File
@@ -25,6 +25,20 @@ pub async fn create_session(
state: SessionStartState<'_>,
) -> Result<(SessionId, EntryHash), StoreError> {
let session_id = crate::new_session_id();
let hash = create_session_with_id(store, session_id, state).await?;
Ok((session_id, hash))
}
/// Write a fresh `SessionStart` entry using a pre-generated session ID.
///
/// Used by callers that need to reserve a session ID synchronously but
/// defer the initial log append (e.g. Pod, which resolves a templated
/// system prompt only at first turn). Returns the resulting head hash.
pub async fn create_session_with_id(
store: &impl Store,
session_id: SessionId,
state: SessionStartState<'_>,
) -> Result<EntryHash, StoreError> {
let entry = LogEntry::SessionStart {
ts: session_log::now_millis(),
system_prompt: state.system_prompt.map(String::from),
@@ -40,7 +54,7 @@ pub async fn create_session(
entry,
};
store.append(session_id, &hashed_entry).await?;
Ok((session_id, hash))
Ok(hash)
}
/// Create a compacted session from an existing one.