feat: let the fs-tools reach the whole container, and stop rebuilding the system prefix every round
Nightly Build / build (push) Successful in 7m33s
Nightly Build / build (push) Successful in 7m33s
Two changes to what a turn costs and what it can see.
## The system prefix is frozen per conversation
`AgentSystemContext::system_context` is called once per round and reassembled
`base` from disk and SQLite each time, so an agent writing `user-memory/index.md`
in round 3 turned round 4 — seconds later, with the provider cache certainly
warm — into a full miss. `base` is the head of every provider's cache key, so it
is the most expensive string in the request to touch.
`PrefixCache` builds it once per (conversation, agent) and holds it on
`UserLoopRuntime`. The refresh rule is the only free one: rebuild once the
conversation has been idle longer than a provider's cache could survive
(20 min). The clock is idle time of the conversation, not time since a file
changed, and reading restarts it — every get is a request about to go out.
Writes are deliberately not reacted to. The agent's own edits are already in the
context, two messages downstream. A write from elsewhere is invisible until the
TTL: that is precisely where an immediate rebuild costs the most, and the
cheaper freshness path already exists — a `read_file` result appends, and
appending invalidates nothing. The injection header now says so.
Also removes 4 DB queries and 2 file reads per round.
## The security boundary is the container, not the mounted subtree
`read_file /tmp/cv.txt` answered "path escapes your workspace" and the agent
re-read the file with `cat`. It was right to refuse — /tmp exists only inside
the container — but the refusal protected nothing: `execute_cmd` already runs
there with passwordless sudo. The mount is the fast path, not the perimeter.
`resolve_target` now routes an absolute path through `container_to_agent`
first. Landing on a mount takes the host path, which also fixes a real bug:
`/root/x` IS `~/x`, yet every tool rejected it, because `PathBuf::join` with an
absolute tail discards the base and the result then failed the prefix check
(`/root/shared/{X}/…` too). Landing nowhere means container-only, served by the
new `container::exec_fs` over `docker exec`, with paths passed positionally so
a path containing `$(…)` stays data. Membership still holds: `/root/shared/{X}`
for a non-member fails exactly as `shared/{X}` does.
Single-file tools get this without a second implementation: `fs::Shuttle` pulls
the file out, runs the unchanged tool on the copy, and pushes it back if the
bytes changed. `list_files` lists in place, `read_file` reads container paths as
text (a shuttled copy cannot back a MediaRef), and `grep_files` refuses them
with a pointer to `rg` rather than approximating its own semantics. The viewer
follows the same routing, so the user can open what the agent read.
Host containment is untouched and still guards every mounted path — it is the
defence against a symlink planted in the container pointing at the host's /etc,
and the container branch never touches the host filesystem at all.
Verified end-to-end against a live skald-runtime:v3 container: write/read/edit
on /tmp round-trip, /etc/os-release reads, binary and shell-metacharacter paths
survive, and /root/notes.md lands in the host home.
This commit is contained in:
@@ -28,6 +28,7 @@ use crate::llm::logging::RequestLogTarget;
|
||||
use crate::loop_adapters::activation::SkaldToolActivator;
|
||||
use crate::loop_adapters::builtins::{SkaldAskUserTool, SkaldHumanChannel};
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::prefix_cache::PrefixCache;
|
||||
use crate::loop_adapters::runtime::LoopConfig;
|
||||
use crate::loop_adapters::scope::TurnScope;
|
||||
use crate::loop_adapters::selector::SkaldSelector;
|
||||
@@ -51,6 +52,9 @@ pub struct SkaldAgentCatalog {
|
||||
/// The swappable fs cell, so a §6 remount reaches sub-agents too.
|
||||
fs: SharedFs,
|
||||
config: LoopConfig,
|
||||
/// Shared with the parent runtime: a child's prefix is keyed by its own
|
||||
/// agent id, so it never collides with the conversation's root frame.
|
||||
prefix_cache: Arc<PrefixCache>,
|
||||
/// The delegate tool, injected post-construction. **Weak** on purpose: the
|
||||
/// delegate holds the catalog, so an `Arc` here would be a cycle that never
|
||||
/// frees (and this graph lives as long as the user).
|
||||
@@ -70,6 +74,7 @@ impl SkaldAgentCatalog {
|
||||
registry: Arc<ToolRegistry>,
|
||||
fs: SharedFs,
|
||||
config: LoopConfig,
|
||||
prefix_cache: Arc<PrefixCache>,
|
||||
) -> Self {
|
||||
let core_tools = registry.all_tools();
|
||||
Self {
|
||||
@@ -84,6 +89,7 @@ impl SkaldAgentCatalog {
|
||||
core_tools,
|
||||
fs,
|
||||
config,
|
||||
prefix_cache,
|
||||
delegate: RwLock::new(Weak::new()),
|
||||
}
|
||||
}
|
||||
@@ -134,6 +140,7 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
// writes the SAME one as its parent.
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
prefix_cache: self.prefix_cache.clone(),
|
||||
});
|
||||
|
||||
// The child's def list: parent's base minus root-only minus the
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
//! tool result — the library does the shaping.
|
||||
//! - [`async_task`] — `execute_task mode=async` as a durable cron job, and the
|
||||
//! delivery of its result back into the parent conversation (§7.2).
|
||||
//! - [`prefix_cache`] — the cacheable half of the system prompt, frozen per
|
||||
//! conversation so a mid-turn memory write does not invalidate the provider's
|
||||
//! prompt cache.
|
||||
//! - [`runtime::UserLoopRuntime`] — the one `LoopManager` per user (D12) these
|
||||
//! are all assembled into, plus the per-turn parameters.
|
||||
|
||||
@@ -33,6 +36,7 @@ pub mod history;
|
||||
pub mod hooks;
|
||||
pub mod live_input;
|
||||
pub mod media_source;
|
||||
pub mod prefix_cache;
|
||||
pub mod preview;
|
||||
#[cfg(test)]
|
||||
mod projection_snapshots;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! `PrefixCache` — the system prefix, frozen for as long as a provider's prompt
|
||||
//! cache could still be holding it.
|
||||
//!
|
||||
//! Every provider that caches keys on the longest common *prefix*, and the
|
||||
//! system prompt is the first thing in it — so rebuilding it changes the whole
|
||||
//! request. That is what used to happen on every round:
|
||||
//! [`AgentSystemContext`](super::system::AgentSystemContext) reassembles `base`
|
||||
//! from disk and SQLite each time it is asked, so an agent writing to
|
||||
//! `user-memory/index.md` in round 3 turned round 4, seconds later and with the
|
||||
//! cache certainly warm, into a full miss.
|
||||
//!
|
||||
//! So the prefix is built once and kept. The refresh rule is the one that costs
|
||||
//! nothing: **rebuild only once the conversation has been idle long enough that
|
||||
//! the provider's cache is gone anyway.** Below that window a rebuild buys
|
||||
//! freshness at the price of a guaranteed miss; above it, it is free. Hence the
|
||||
//! clock is *idle time of this conversation*, not time since some file changed
|
||||
//! — and every call to [`PrefixCache::get`] is a request about to go out, which
|
||||
//! is why reading restarts the window.
|
||||
//!
|
||||
//! **Writes are deliberately not reacted to.** When the agent itself edits an
|
||||
//! injected file the new content is already in the context — the tool call and
|
||||
//! its result sit two messages downstream — so refreshing the prefix would only
|
||||
//! repeat what the model just said. A write from *elsewhere* (the same user's
|
||||
//! Telegram session, a cron job, another member editing `shared-memory/`) is
|
||||
//! genuinely invisible until the TTL, and that is the trade taken knowingly: it
|
||||
//! is precisely the case where an immediate rebuild costs the most, since a
|
||||
//! conversation that would notice is by definition a warm one. The freshness
|
||||
//! path already exists and is cheaper — the agent can `read_file`, and a tool
|
||||
//! result *appends*, which never invalidates anything. The injection header in
|
||||
//! `system.rs` tells it so.
|
||||
//!
|
||||
//! Reacting to another user's write would need a `SystemEventBus` variant and a
|
||||
//! subscriber per user, since the writer lives in a different `UserContext`.
|
||||
//! That is future work; the seam for it is this type's key.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use agent_loop::ids::ConversationId;
|
||||
|
||||
/// How long a prefix survives without its conversation calling a model.
|
||||
///
|
||||
/// The asymmetry that sets it: going *below* a provider's cache window pays
|
||||
/// misses that buy nothing, while going above only costs freshness we have
|
||||
/// already decided we do not need. Anthropic's `ephemeral` blocks live 5
|
||||
/// minutes; OpenAI's automatic prefix cache is fuzzier and can last longer.
|
||||
pub const PREFIX_TTL: Duration = Duration::from_secs(20 * 60);
|
||||
|
||||
/// A conversation plus the agent running in it. Both are needed: a sub-agent
|
||||
/// shares its parent's conversation but has its own prompt, and therefore its
|
||||
/// own cache prefix.
|
||||
type Key = (ConversationId, String);
|
||||
|
||||
struct Entry {
|
||||
base: String,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
/// One user's frozen prefixes. Lives on `UserLoopRuntime`, so it spans every
|
||||
/// turn of every conversation that user has open.
|
||||
pub struct PrefixCache {
|
||||
ttl: Duration,
|
||||
entries: Mutex<HashMap<Key, Entry>>,
|
||||
}
|
||||
|
||||
impl PrefixCache {
|
||||
pub fn new() -> Self {
|
||||
Self::with_ttl(PREFIX_TTL)
|
||||
}
|
||||
|
||||
/// A cache with a custom idle window — tests, and the knob a config key
|
||||
/// would turn if one is ever wanted.
|
||||
pub fn with_ttl(ttl: Duration) -> Self {
|
||||
Self { ttl, entries: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
/// The prefix for this turn, if one was built recently enough. Restarts the
|
||||
/// idle window on a hit.
|
||||
pub fn get(&self, key: &Key) -> Option<String> {
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
let entry = entries.get_mut(key)?;
|
||||
if entry.last_used.elapsed() >= self.ttl {
|
||||
entries.remove(key);
|
||||
return None;
|
||||
}
|
||||
entry.last_used = Instant::now();
|
||||
Some(entry.base.clone())
|
||||
}
|
||||
|
||||
/// Stores a freshly built prefix, dropping whatever has gone idle — which is
|
||||
/// what keeps the map bounded without an eviction policy to remember. It is
|
||||
/// also what collects the one-shot conversations (system-agent passes,
|
||||
/// ephemeral turns) that would otherwise each leave an entry behind.
|
||||
///
|
||||
/// Two rounds racing on the same key build twice and the last one wins. That
|
||||
/// is why the build happens *outside* this type: holding the lock across it
|
||||
/// would serialise every turn of every conversation behind one mutex, to
|
||||
/// save a duplicated string.
|
||||
pub fn put(&self, key: Key, base: String) {
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
entries.retain(|_, e| e.last_used.elapsed() < self.ttl);
|
||||
entries.insert(key, Entry { base, last_used: Instant::now() });
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PrefixCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(conv: &str, agent: &str) -> Key {
|
||||
(ConversationId::new(conv), agent.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_prefix_is_served_back() {
|
||||
let cache = PrefixCache::new();
|
||||
cache.put(key("session:1", "assistant"), "PROMPT".into());
|
||||
assert_eq!(cache.get(&key("session:1", "assistant")).as_deref(), Some("PROMPT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_prefix_is_a_miss() {
|
||||
let cache = PrefixCache::with_ttl(Duration::from_millis(20));
|
||||
cache.put(key("session:1", "assistant"), "PROMPT".into());
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
assert_eq!(cache.get(&key("session:1", "assistant")), None);
|
||||
}
|
||||
|
||||
/// The whole point of the idle clock: a conversation that keeps talking
|
||||
/// keeps its prefix, however long it runs.
|
||||
#[test]
|
||||
fn using_a_prefix_restarts_the_idle_window() {
|
||||
let cache = PrefixCache::with_ttl(Duration::from_millis(60));
|
||||
cache.put(key("session:1", "assistant"), "PROMPT".into());
|
||||
for _ in 0..4 {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(cache.get(&key("session:1", "assistant")).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// A sub-agent shares the conversation and must not be served its parent's
|
||||
/// prompt.
|
||||
#[test]
|
||||
fn the_agent_is_part_of_the_key() {
|
||||
let cache = PrefixCache::new();
|
||||
cache.put(key("session:1", "assistant"), "PARENT".into());
|
||||
cache.put(key("session:1", "researcher"), "CHILD".into());
|
||||
assert_eq!(cache.get(&key("session:1", "assistant")).as_deref(), Some("PARENT"));
|
||||
assert_eq!(cache.get(&key("session:1", "researcher")).as_deref(), Some("CHILD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storing_drops_the_entries_that_went_idle() {
|
||||
let cache = PrefixCache::with_ttl(Duration::from_millis(20));
|
||||
cache.put(key("session:1", "assistant"), "OLD".into());
|
||||
std::thread::sleep(Duration::from_millis(40));
|
||||
cache.put(key("session:2", "assistant"), "NEW".into());
|
||||
assert_eq!(cache.entries.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ use crate::loop_adapters::gate::ApprovalGate;
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::hooks::{DtlReanchorHook, SkaldWritePreviewHook};
|
||||
use crate::loop_adapters::live_input::PendingLiveInput;
|
||||
use crate::loop_adapters::prefix_cache::PrefixCache;
|
||||
use crate::loop_adapters::preview::PreviewContext;
|
||||
use crate::loop_adapters::projection_cfg::skald_assembler;
|
||||
use crate::loop_adapters::scope::TurnScope;
|
||||
@@ -92,6 +93,9 @@ pub struct UserLoopRuntime {
|
||||
clarification: Arc<ClarificationManager>,
|
||||
tool_discovery: Arc<ToolDiscovery>,
|
||||
config: LoopConfig,
|
||||
/// The user's frozen system prefixes, shared with the agent catalog so a
|
||||
/// sub-agent's own prefix is cached alongside its parent's.
|
||||
prefix_cache: Arc<PrefixCache>,
|
||||
}
|
||||
|
||||
/// What a turn contributes on top of the runtime.
|
||||
@@ -154,6 +158,10 @@ impl UserLoopRuntime {
|
||||
.build()?,
|
||||
);
|
||||
|
||||
// One per user, living as long as this runtime: a conversation's system
|
||||
// prefix must outlast its turns for the provider's cache to hold.
|
||||
let prefix_cache = Arc::new(PrefixCache::new());
|
||||
|
||||
let catalog = Arc::new(SkaldAgentCatalog::new(
|
||||
pool.clone(),
|
||||
shared_pool.clone(),
|
||||
@@ -165,6 +173,7 @@ impl UserLoopRuntime {
|
||||
tools.clone(),
|
||||
fs.clone(),
|
||||
config.clone(),
|
||||
prefix_cache.clone(),
|
||||
));
|
||||
// `mode: "async"` runs as a durable cron job; the manager behind it is
|
||||
// set at wiring time (see `CronExecutor`).
|
||||
@@ -197,6 +206,7 @@ impl UserLoopRuntime {
|
||||
clarification,
|
||||
tool_discovery,
|
||||
config,
|
||||
prefix_cache,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -244,6 +254,7 @@ impl UserLoopRuntime {
|
||||
project_root: scope.project_root.clone(),
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
prefix_cache: self.prefix_cache.clone(),
|
||||
});
|
||||
|
||||
// The agent's own declarations. Loaded once here and used twice below —
|
||||
|
||||
@@ -16,6 +16,7 @@ use agent_loop::context::{SystemContext, SystemContextSource, TurnInfo};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::loop_adapters::prefix_cache::PrefixCache;
|
||||
use crate::mcp::McpProvider;
|
||||
|
||||
/// Registry of installed skills, relative to Skald's process cwd. Injected
|
||||
@@ -44,18 +45,92 @@ pub struct AgentSystemContext {
|
||||
/// sub-task (the blackboard is shared by every agent of a session).
|
||||
pub scratchpad_sid: i64,
|
||||
pub datetime: DatetimeConfig,
|
||||
/// The user's frozen prefixes — `base` is assembled once per conversation
|
||||
/// and reused while its provider cache could still be warm.
|
||||
pub prefix_cache: Arc<PrefixCache>,
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl SystemContextSource for AgentSystemContext {
|
||||
async fn system_context(&self, _turn: &TurnInfo) -> agent_loop::Result<SystemContext> {
|
||||
async fn system_context(&self, turn: &TurnInfo) -> agent_loop::Result<SystemContext> {
|
||||
// `base` is the head of every provider's cache key, so reassembling it
|
||||
// between rounds — which is what an agent editing an injected memory
|
||||
// file used to cause — invalidates the entire request. It is therefore
|
||||
// built once per conversation and held; see [`super::prefix_cache`].
|
||||
let key = (turn.conversation.clone(), self.agent_id.clone());
|
||||
let static_content = match self.prefix_cache.get(&key) {
|
||||
Some(base) => base,
|
||||
None => {
|
||||
let base = self.build_base().await?;
|
||||
self.prefix_cache.put(key, base.clone());
|
||||
base
|
||||
}
|
||||
};
|
||||
|
||||
// The scratchpad sits before the conversation: shared by every agent of
|
||||
// the session, and re-read every turn (it changes, so it is its own
|
||||
// message rather than part of the cached prefix).
|
||||
let extra_static = self.scratchpad_block().await?.into_iter().collect();
|
||||
|
||||
// The fresh layers, in the order the model reads them.
|
||||
let mut dynamic_tail: Vec<String> = Vec::new();
|
||||
dynamic_tail.extend(self.extra_dynamic.clone());
|
||||
dynamic_tail.extend(self.datetime_block());
|
||||
|
||||
Ok(SystemContext {
|
||||
base: static_content,
|
||||
extra_static,
|
||||
dynamic_tail,
|
||||
tail_reminder: self.tail_reminder.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// OS description (type + version), computed once.
|
||||
fn os_description() -> &'static str {
|
||||
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
OS.get_or_init(|| os_info::get().to_string())
|
||||
}
|
||||
|
||||
/// Formats an instant to hour precision: `Sunday 2026-08-02 17:00 +02:00`.
|
||||
///
|
||||
/// Minutes and seconds are dropped by the format string itself, so the
|
||||
/// truncation always happens in the zone being displayed. The weekday is part
|
||||
/// of the format on purpose — see [`AgentSystemContext::datetime_block`].
|
||||
fn render_hour<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> String
|
||||
where
|
||||
Tz::Offset: std::fmt::Display,
|
||||
{
|
||||
dt.format("%A %Y-%m-%d %H:00 %:z").to_string()
|
||||
}
|
||||
|
||||
/// System IANA timezone name, computed once.
|
||||
fn system_timezone() -> Option<&'static str> {
|
||||
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
TZ.get_or_init(|| iana_time_zone::get_timezone().ok()).as_deref()
|
||||
}
|
||||
|
||||
impl AgentSystemContext {
|
||||
/// Assembles the cacheable prefix: the agent's prompt, its injected memory,
|
||||
/// the skills index, the interface extras and every substitution.
|
||||
///
|
||||
/// Every layer here is frozen together, because the unit a provider caches
|
||||
/// is the finished string — freezing the memory files while letting
|
||||
/// `__USER_PROFILE__` move would invalidate just as much. The cost is that
|
||||
/// an `AGENT.md` edit is picked up at the next rebuild rather than the next
|
||||
/// round, which matters only while writing prompts.
|
||||
async fn build_base(&self) -> agent_loop::Result<String> {
|
||||
let mut static_content = crate::agents::load_prompt(&self.agent_id)?;
|
||||
|
||||
let meta = crate::agents::load_meta(&self.agent_id)?;
|
||||
if !meta.inject_memory.is_empty() {
|
||||
static_content.push_str(
|
||||
"\n\n---\nThe following memory files have been loaded automatically. \
|
||||
You can edit them with `edit_file` or `write_file` using the path shown.\n"
|
||||
You can edit them with `edit_file` or `write_file` using the path shown.\n\
|
||||
Their contents are a snapshot taken earlier in this conversation. Your own \
|
||||
edits are already reflected in what you have seen since; but if it matters \
|
||||
that a file is current — a shared note another member may have changed in \
|
||||
the meantime — read it again before relying on it.\n"
|
||||
);
|
||||
for mem_path in &meta.inject_memory {
|
||||
let (content, display) = self.load_inject_memory(mem_path).await;
|
||||
@@ -116,52 +191,9 @@ impl SystemContextSource for AgentSystemContext {
|
||||
}
|
||||
}
|
||||
|
||||
static_content = resolve_harness_tag(static_content);
|
||||
|
||||
// The scratchpad sits before the conversation: shared by every agent of
|
||||
// the session, and re-read every turn (it changes, so it is its own
|
||||
// message rather than part of the cached prefix).
|
||||
let extra_static = self.scratchpad_block().await?.into_iter().collect();
|
||||
|
||||
// The fresh layers, in the order the model reads them.
|
||||
let mut dynamic_tail: Vec<String> = Vec::new();
|
||||
dynamic_tail.extend(self.extra_dynamic.clone());
|
||||
dynamic_tail.extend(self.datetime_block());
|
||||
|
||||
Ok(SystemContext {
|
||||
base: static_content,
|
||||
extra_static,
|
||||
dynamic_tail,
|
||||
tail_reminder: self.tail_reminder.clone(),
|
||||
})
|
||||
Ok(resolve_harness_tag(static_content))
|
||||
}
|
||||
}
|
||||
|
||||
/// OS description (type + version), computed once.
|
||||
fn os_description() -> &'static str {
|
||||
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
OS.get_or_init(|| os_info::get().to_string())
|
||||
}
|
||||
|
||||
/// Formats an instant to hour precision: `Sunday 2026-08-02 17:00 +02:00`.
|
||||
///
|
||||
/// Minutes and seconds are dropped by the format string itself, so the
|
||||
/// truncation always happens in the zone being displayed. The weekday is part
|
||||
/// of the format on purpose — see [`AgentSystemContext::datetime_block`].
|
||||
fn render_hour<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> String
|
||||
where
|
||||
Tz::Offset: std::fmt::Display,
|
||||
{
|
||||
dt.format("%A %Y-%m-%d %H:00 %:z").to_string()
|
||||
}
|
||||
|
||||
/// System IANA timezone name, computed once.
|
||||
fn system_timezone() -> Option<&'static str> {
|
||||
static TZ: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
TZ.get_or_init(|| iana_time_zone::get_timezone().ok()).as_deref()
|
||||
}
|
||||
|
||||
impl AgentSystemContext {
|
||||
/// The session scratchpad as an XML block, or `None` when empty.
|
||||
async fn scratchpad_block(&self) -> agent_loop::Result<Option<String>> {
|
||||
let notes = crate::db::scratchpad::for_session(&self.pool, self.scratchpad_sid).await?;
|
||||
|
||||
@@ -224,6 +224,9 @@ pub async fn project(db: &Db, agent: &AgentFixture, case: &Case) -> Vec<Value> {
|
||||
project_root: None,
|
||||
scratchpad_sid: 1,
|
||||
datetime: datetime(),
|
||||
// A cache of its own per projection: each case must see a freshly
|
||||
// assembled prefix, never one another case left behind.
|
||||
prefix_cache: Arc::new(crate::loop_adapters::prefix_cache::PrefixCache::new()),
|
||||
};
|
||||
let system = system_source
|
||||
.system_context(&TurnInfo {
|
||||
|
||||
Reference in New Issue
Block a user