agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call) and the ChatSessionHandler. Everything that shaped a Value — projection, recovery, compaction mechanics, the LLM loop, message building — lives in agent-loop or behind a loop_adapters trait. agent-loop: - projection/ (mod + media): stored history -> wire messages, the one place provider divergence lives; well-formedness contract, DTL injections (append-only), media parts. LinearAssembler is now a Projection + ProjectionHooks config, not its own implementation - recovery.rs: reap interrupted batches -> resolve the deepest frame's non-terminal calls (Running by policy + RestartHint, AwaitingHuman re-asked) -> un-wedge finished children -> cascade up, every frame on its own agent (B3) - compaction.rs: split point (never assistant+tool group), transcript, SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row - manager: resolve_pending (gate skipped, real ToolContext, then continue incl. sub-agent); start_loop used by recovery; LiveInput - delegate: AsyncExecutor + StoreSink for mode:async (durable cron row, result delivered back into the parent conversation) - kernel/context/store: support the above (TurnScope via Extensions, frame lookups, aligned result-text semantics) skald-core: - loop_adapters: UserLoopRuntime (D12 - one LoopManager per user), TurnScope (per-turn state in the Extensions type-map; no scope is denied), projection_cfg/media_source/tool_digest (Skald's projection knobs without owning projection code), async_task (CronExecutor + DurableSink) - session/handler: stripped to mod.rs + kernel_turn.rs + config.rs + interface_tools.rs + media.rs; deleted agent_dispatch, approval, dispatch, emitter, gate, llm_call, llm_loop, message_builder, messages, outcome, resume - compactor.rs: policy only (threshold, model pick, CompactionEvent); mechanics are the crate's CLAUDE.md updated (recovery, compaction, sub-agents, approval gate, projection sections now describe the crate-owned flow).
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
//! `AgentSystemContext` — Skald's agent prompt as a `SystemContextSource`
|
||||
//! (the static half of the old `MessageBuilder::build`, blueprint §10):
|
||||
//! AGENT.md + `inject_memory` files + skills index + `extra_system` +
|
||||
//! `__MCP_LIST__` / `__SHARED_FOLDERS__` / `__USER_PROFILE__` / custom
|
||||
//! substitutions. The dynamic tail (Honcho memory, per-turn overrides) rides
|
||||
//! as `dynamic_tail`; the datetime line and scratchpad stay assembler-side.
|
||||
//! `AgentSystemContext` — **every layer of Skald's system prompt**, as a
|
||||
//! `SystemContextSource` (blueprint §10). It owns the content; the crate's
|
||||
//! projection decides where each layer lands on the wire:
|
||||
//!
|
||||
//! | layer | wire position |
|
||||
//! |---|---|
|
||||
//! | AGENT.md + `inject_memory` + skills index + `extra_system` + substitutions | `base` — the cacheable prefix |
|
||||
//! | session scratchpad | `extra_static` — a system message before the conversation |
|
||||
//! | Honcho memory / per-turn overrides, then the date/time block | `dynamic_tail` — joined into the trailing system message |
|
||||
//! | trailing reminder | `tail_reminder` |
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -11,6 +15,7 @@ use std::sync::Arc;
|
||||
use agent_loop::context::{SystemContext, SystemContextSource, TurnInfo};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::mcp::McpProvider;
|
||||
|
||||
/// Registry of installed skills, relative to Skald's process cwd. Injected
|
||||
@@ -35,6 +40,10 @@ pub struct AgentSystemContext {
|
||||
pub mcp: Arc<dyn McpProvider>,
|
||||
/// Project root for `__PROJECT_ROOT__` expansion in `inject_memory`.
|
||||
pub project_root: Option<String>,
|
||||
/// Scratchpad scope: the session's own id, or the parent's for an async
|
||||
/// sub-task (the blackboard is shared by every agent of a session).
|
||||
pub scratchpad_sid: i64,
|
||||
pub datetime: DatetimeConfig,
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
@@ -84,21 +93,13 @@ impl SystemContextSource for AgentSystemContext {
|
||||
if static_content.contains("__SHARED_FOLDERS__") {
|
||||
static_content = static_content.replace(
|
||||
"__SHARED_FOLDERS__",
|
||||
&crate::session::handler::message_builder::render_shared_folders_section(
|
||||
&self.shared_pool,
|
||||
&self.user_id,
|
||||
)
|
||||
.await?,
|
||||
&render_shared_folders_section(&self.shared_pool, &self.user_id).await?,
|
||||
);
|
||||
}
|
||||
if static_content.contains("__USER_PROFILE__") {
|
||||
static_content = static_content.replace(
|
||||
"__USER_PROFILE__",
|
||||
&crate::session::handler::message_builder::render_user_profile_section(
|
||||
&self.shared_pool,
|
||||
&self.user_id,
|
||||
)
|
||||
.await?,
|
||||
&render_user_profile_section(&self.shared_pool, &self.user_id).await?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,16 +110,121 @@ impl SystemContextSource for AgentSystemContext {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: Vec::new(),
|
||||
dynamic_tail: self.extra_dynamic.clone().into_iter().collect(),
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
if notes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut s = String::from(
|
||||
"<scratchpad>\n \
|
||||
<!-- Temporary notes shared by all agents in this session. Not persisted across sessions. -->\n",
|
||||
);
|
||||
for (k, v) in ¬es {
|
||||
s.push_str(&format!(" <note key=\"{k}\">{v}</note>\n"));
|
||||
}
|
||||
s.push_str("</scratchpad>");
|
||||
Ok(Some(s))
|
||||
}
|
||||
|
||||
/// The current date/time + OS + cwd block (`None` when disabled).
|
||||
///
|
||||
/// Rounding exists for the prompt cache: a timestamp that changes every
|
||||
/// second would invalidate any cached suffix, so the instance can quantize
|
||||
/// it (this block is in the dynamic tail, after the cached prefix, but the
|
||||
/// rounding still helps providers that cache further).
|
||||
fn datetime_block(&self) -> Option<String> {
|
||||
if !self.datetime.enabled {
|
||||
return None;
|
||||
}
|
||||
let secs = chrono::Utc::now().timestamp();
|
||||
let secs = match self.datetime.round_minutes {
|
||||
Some(m) if m > 0 => {
|
||||
let bucket = (m as i64) * 60;
|
||||
(secs / bucket) * bucket
|
||||
}
|
||||
_ => secs,
|
||||
};
|
||||
|
||||
let tz = self
|
||||
.datetime
|
||||
.timezone
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<chrono_tz::Tz>().ok())
|
||||
.or_else(|| system_timezone().and_then(|s| s.parse::<chrono_tz::Tz>().ok()));
|
||||
|
||||
let (formatted, tz_name) = match tz {
|
||||
Some(tz) => {
|
||||
use chrono::TimeZone as _;
|
||||
let f = tz
|
||||
.timestamp_opt(secs, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
|
||||
.unwrap_or_else(|| {
|
||||
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
|
||||
});
|
||||
(f, Some(tz.name().to_string()))
|
||||
}
|
||||
None => {
|
||||
let f = chrono::DateTime::from_timestamp(secs, 0)
|
||||
.map(|utc| {
|
||||
utc.with_timezone(&chrono::Local)
|
||||
.format("%Y-%m-%dT%H:%M:%S%:z")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z").to_string()
|
||||
});
|
||||
(f, None)
|
||||
}
|
||||
};
|
||||
let date_line = match tz_name {
|
||||
Some(name) => format!("Current date and time: {formatted} ({name})"),
|
||||
None => format!("Current date and time: {formatted}"),
|
||||
};
|
||||
|
||||
// The agent's cwd is always its container home.
|
||||
let cwd = "~";
|
||||
|
||||
Some(format!(
|
||||
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
|
||||
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
|
||||
os_description()
|
||||
))
|
||||
}
|
||||
|
||||
/// Loads an `inject_memory` entry, returning `(content, display_path)`.
|
||||
/// Virtual memory paths read from SQLite; everything else is a disk read.
|
||||
async fn load_inject_memory(&self, mem_path: &str) -> (Option<String>, String) {
|
||||
@@ -184,3 +290,211 @@ impl AgentSystemContext {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt sections resolved from the registry ───────────────────────────────
|
||||
|
||||
|
||||
/// `__SHARED_FOLDERS__` section, resolved from the registry (shared with the
|
||||
/// `agent-loop` adapter's system-context source).
|
||||
pub(crate) async fn render_shared_folders_section(
|
||||
shared_pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let rows = crate::db::shared_folders::agent_view(shared_pool, user_id).await?;
|
||||
Ok(render_shared_folders_table(&rows))
|
||||
}
|
||||
|
||||
/// `__USER_PROFILE__` block, resolved from the registry (shared with the
|
||||
/// `agent-loop` adapter's system-context source).
|
||||
pub(crate) async fn render_user_profile_section(
|
||||
shared_pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let user = crate::db::users::get(shared_pool, user_id).await?;
|
||||
let locale = crate::i18n::resolve_locale(
|
||||
shared_pool,
|
||||
user.as_ref().and_then(|u| u.locale.as_deref()),
|
||||
).await;
|
||||
Ok(render_user_profile_block(
|
||||
user.as_ref(),
|
||||
&locale,
|
||||
chrono::Utc::now().date_naive(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Renders the shared-folders section body as a Markdown table — one row per
|
||||
/// folder the user belongs to, naming the folder's other members so the model
|
||||
/// knows exactly who sees what is written there. An empty membership yields an
|
||||
/// explicit "not a member" line so the model does not go probing `shared/` paths.
|
||||
fn render_shared_folders_table(rows: &[crate::db::shared_folders::SharedFolderAccess]) -> String { /// A free-text cell: single line, pipes escaped (they would split the table).
|
||||
fn cell(s: &str) -> String {
|
||||
s.trim().replace('|', "\\|").replace('\n', " ")
|
||||
}
|
||||
if rows.is_empty() {
|
||||
return "_You are not a member of any shared folder._\n".to_string();
|
||||
}
|
||||
let mut out = String::from("| Path | Access | Shared with | Description |\n|------|--------|-------------|-------------|\n");
|
||||
for r in rows {
|
||||
let access = if r.can_write { "read-write" } else { "read-only" };
|
||||
let shared_with = if r.shared_with.is_empty() { "—".to_string() } else { cell(&r.shared_with) };
|
||||
let desc = if r.description.trim().is_empty() { "—".to_string() } else { cell(&r.description) };
|
||||
out.push_str(&format!("| `shared/{}` | {access} | {shared_with} | {desc} |\n", r.folder_name));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders the profile block for `__USER_PROFILE__`. Every line is always
|
||||
/// present — an explicit `unknown` / `not specified` is a signal the agent can
|
||||
/// act on (e.g. gently ask) — except `Notes`, omitted entirely when empty.
|
||||
/// `today` is passed in so the age computation stays pure and testable.
|
||||
fn render_user_profile_block(
|
||||
user: Option<&crate::db::users::User>,
|
||||
locale: &str,
|
||||
today: chrono::NaiveDate,
|
||||
) -> String {
|
||||
let name = user
|
||||
.and_then(|u| non_empty(&u.display_name))
|
||||
.or_else(|| user.map(|u| u.username.as_str()))
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let birth = match user.and_then(|u| non_empty(&u.birthdate)) {
|
||||
Some(raw) => match chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
|
||||
Ok(dob) => match today.years_since(dob) {
|
||||
Some(age) => format!("{raw} (age {age})"),
|
||||
None => format!("{raw} (age unknown)"),
|
||||
},
|
||||
// Stored value bypassed validation — show it raw rather than drop it.
|
||||
Err(_) => raw.to_string(),
|
||||
},
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
|
||||
let sex = user.and_then(|u| non_empty(&u.sex)).unwrap_or("not specified");
|
||||
|
||||
let mut out = format!(
|
||||
"Name: {name}\nDate of birth: {birth}\nSex: {sex}\nPreferred language: {}\n",
|
||||
crate::i18n::language_name(locale),
|
||||
);
|
||||
if let Some(notes) = user.and_then(|u| non_empty(&u.notes)) {
|
||||
out.push_str(&format!("Notes: {notes}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An optional string field as a trimmed `&str`, `None` when empty/blank.
|
||||
fn non_empty(s: &Option<String>) -> Option<&str> {
|
||||
s.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_folders_table_renders_access_and_description() {
|
||||
use crate::db::shared_folders::SharedFolderAccess;
|
||||
let rows = vec![
|
||||
SharedFolderAccess { folder_name: "photos".into(), can_write: false, shared_with: "Bob, Carol".into(), description: "Shared photo archive".into() },
|
||||
SharedFolderAccess { folder_name: "recipes".into(), can_write: true, shared_with: "".into(), description: "a | b\nc".into() },
|
||||
];
|
||||
let out = render_shared_folders_table(&rows);
|
||||
assert!(out.starts_with("| Path | Access | Shared with | Description |\n|------|--------|-------------|-------------|\n"));
|
||||
assert!(out.contains("| `shared/photos` | read-only | Bob, Carol | Shared photo archive |\n"));
|
||||
// Empty shared_with → "—"; free-text cells stay on one line with escaped pipes.
|
||||
assert!(out.contains("| `shared/recipes` | read-write | — | a \\| b c |\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_folders_table_empty_membership_is_explicit() {
|
||||
assert_eq!(
|
||||
render_shared_folders_table(&[]),
|
||||
"_You are not a member of any shared folder._\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_user() -> crate::db::users::User {
|
||||
crate::db::users::User {
|
||||
id: "u-1".into(),
|
||||
username: "luca".into(),
|
||||
display_name: None,
|
||||
role_id: "members".into(),
|
||||
credentials: crate::db::users::Credentials::Cleartext(None),
|
||||
active: true,
|
||||
locale: None,
|
||||
birthdate: None,
|
||||
sex: None,
|
||||
notes: None,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_profile_renders_all_fields_with_runtime_age() {
|
||||
let mut u = test_user();
|
||||
u.display_name = Some("Luca Rossi".into());
|
||||
u.birthdate = Some("2019-02-10".into());
|
||||
u.sex = Some("male".into());
|
||||
u.notes = Some("loves dinosaurs".into());
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
|
||||
|
||||
let out = render_user_profile_block(Some(&u), "it", today);
|
||||
assert_eq!(
|
||||
out,
|
||||
"Name: Luca Rossi\n\
|
||||
Date of birth: 2019-02-10 (age 7)\n\
|
||||
Sex: male\n\
|
||||
Preferred language: Italian\n\
|
||||
Notes: loves dinosaurs\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_profile_age_counts_uncelebrated_birthdays() {
|
||||
let mut u = test_user();
|
||||
u.birthdate = Some("2019-12-25".into());
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
|
||||
let out = render_user_profile_block(Some(&u), "en", today);
|
||||
assert!(out.contains("Date of birth: 2019-12-25 (age 6)\n"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_profile_empty_fields_are_explicit_and_notes_omitted() {
|
||||
let u = test_user();
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
|
||||
let out = render_user_profile_block(Some(&u), "en", today);
|
||||
assert_eq!(
|
||||
out,
|
||||
"Name: luca\n\
|
||||
Date of birth: unknown\n\
|
||||
Sex: not specified\n\
|
||||
Preferred language: English\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_profile_tolerates_garbage_and_future_dates() {
|
||||
let mut u = test_user();
|
||||
u.birthdate = Some("not-a-date".into());
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
|
||||
let out = render_user_profile_block(Some(&u), "en", today);
|
||||
assert!(out.contains("Date of birth: not-a-date\n"), "{out}");
|
||||
|
||||
u.birthdate = Some("2099-01-01".into());
|
||||
let out = render_user_profile_block(Some(&u), "en", today);
|
||||
assert!(out.contains("Date of birth: 2099-01-01 (age unknown)\n"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_profile_missing_user_still_renders_language() {
|
||||
let today = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
|
||||
let out = render_user_profile_block(None, "fr", today);
|
||||
assert_eq!(
|
||||
out,
|
||||
"Name: unknown\n\
|
||||
Date of birth: unknown\n\
|
||||
Sex: not specified\n\
|
||||
Preferred language: French\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user