システムプロンプトの実装
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user