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:
@@ -19,7 +19,7 @@ use crate::tools::tool_names::CONFIG_GROUP;
|
||||
|
||||
/// Reads the durable activations of one scope (root session or sub-agent
|
||||
/// frame) and resolves them to OpenAI tool defs for the assembler's DTL
|
||||
/// injection. Port of `MessageBuilder::resolve_activation_defs`.
|
||||
/// injection: which tool definitions an activation resolves to.
|
||||
pub struct SkaldActivationSource {
|
||||
pool: Arc<SqlitePool>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
//! `SkaldAssembler` — Skald's history projection behind the crate's
|
||||
//! `ContextAssembler` (port of `MessageBuilder::build`'s message-array half,
|
||||
//! blueprint §10). Byte-parity with the current builder is the contract:
|
||||
//! same layers, same tool-result texts, same DTL injections, same media rules.
|
||||
//!
|
||||
//! During phase 2 the old `MessageBuilder` still serves the legacy paths
|
||||
//! (resume/recovery); the two are deleted together in phase 5.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::activation::{ActivationSource, ToolRendering};
|
||||
use agent_loop::context::{AssembleInput, ContextAssembler};
|
||||
use agent_loop::store::{CallState, HistoryStore, Role};
|
||||
use core_api::message_meta::{MessageMetadata, attachments_block};
|
||||
use core_api::tool::MediaRef;
|
||||
use core_api::user_fs::UserFs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::compactor::SUMMARY_PREFIX;
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::loop_adapters::activation::SkaldActivationSource;
|
||||
use crate::session::handler::media;
|
||||
use crate::tools::tool_names as tn;
|
||||
|
||||
/// Stand-in for a tool-call turn's `reasoning_content` when none was recorded
|
||||
/// (DeepSeek's thinking mode 400s on replay without it).
|
||||
const REASONING_ROUNDTRIP_PLACEHOLDER: &str = "(no reasoning recorded for this step)";
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Skald's `ContextAssembler`: static system → scratchpad → summary → history
|
||||
/// (with DTL + media) → dynamic tail (+datetime) → tail reminder.
|
||||
pub struct SkaldAssembler {
|
||||
/// Owner pool — scratchpad reads (keyed on `scratchpad_sid`).
|
||||
pub pool: Arc<sqlx::SqlitePool>,
|
||||
/// Scratchpad scope (session_id, or the parent's for async sub-tasks).
|
||||
pub scratchpad_sid: i64,
|
||||
pub datetime_config: DatetimeConfig,
|
||||
pub max_history_messages: usize,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
/// The history window applies only when compaction is disabled.
|
||||
pub compactor_enabled: bool,
|
||||
/// The caller's fs view — media containment for inlining. `None` skips
|
||||
/// media inlining entirely.
|
||||
pub fs: Option<Arc<UserFs>>,
|
||||
/// DTL activations (consulted only in non-Inline modes).
|
||||
pub activation: Option<SkaldActivationSource>,
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl ContextAssembler for SkaldAssembler {
|
||||
async fn build(
|
||||
&self,
|
||||
store: &Arc<dyn HistoryStore>,
|
||||
input: &AssembleInput,
|
||||
) -> agent_loop::Result<Vec<Value>> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
|
||||
// ── 1. Static system message ──────────────────────────────────────────
|
||||
let static_msg = if input.model.prompt_cache {
|
||||
json!({
|
||||
"role": "system",
|
||||
"content": [{ "type": "text", "text": input.system.base, "cache_control": { "type": "ephemeral" } }]
|
||||
})
|
||||
} else {
|
||||
json!({ "role": "system", "content": input.system.base })
|
||||
};
|
||||
out.push(static_msg);
|
||||
|
||||
// ── 2. Scratchpad system message (before conversation) ────────────────
|
||||
let scratch = crate::db::scratchpad::for_session(&self.pool, self.scratchpad_sid).await?;
|
||||
if !scratch.is_empty() {
|
||||
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 &scratch {
|
||||
s.push_str(&format!(" <note key=\"{k}\">{v}</note>\n"));
|
||||
}
|
||||
s.push_str("</scratchpad>");
|
||||
out.push(json!({ "role": "system", "content": s }));
|
||||
}
|
||||
|
||||
// ── 3. Compaction summary + surviving history ─────────────────────────
|
||||
let summary = store.latest_summary(input.frame).await?;
|
||||
if let Some(s) = &summary {
|
||||
out.push(json!({
|
||||
"role": "system",
|
||||
"content": format!(
|
||||
"{SUMMARY_PREFIX}\n\n{}\n\n\
|
||||
[End of context summary — the following messages are the most recent exchanges in full.]",
|
||||
s.text
|
||||
)
|
||||
}));
|
||||
}
|
||||
let mut history = match &summary {
|
||||
Some(s) => store.load_since(input.frame, s.covered_up_to).await?,
|
||||
None => store.load(input.frame).await?,
|
||||
};
|
||||
|
||||
if !self.compactor_enabled && history.len() > self.max_history_messages {
|
||||
history.drain(..history.len() - self.max_history_messages);
|
||||
if matches!(history.first().map(|m| m.role), Some(Role::Assistant)) {
|
||||
history.drain(..1);
|
||||
}
|
||||
}
|
||||
|
||||
let current_turn_boundary = history
|
||||
.iter()
|
||||
.rposition(|e| matches!(e.role, Role::User | Role::Agent));
|
||||
|
||||
// Inline-media turn group: trailing assistant rows are the in-flight
|
||||
// turn's own rounds; the current turn's user messages sit just before
|
||||
// them. Older-turn media degrades to the textual path block.
|
||||
let mut media_turn_start = history.len();
|
||||
while media_turn_start > 0 && matches!(history[media_turn_start - 1].role, Role::Assistant) {
|
||||
media_turn_start -= 1;
|
||||
}
|
||||
while media_turn_start > 0
|
||||
&& matches!(history[media_turn_start - 1].role, Role::User | Role::Agent)
|
||||
{
|
||||
media_turn_start -= 1;
|
||||
}
|
||||
|
||||
// DTL: tools activated at each assistant message (empty in Inline mode).
|
||||
let activation_defs: std::collections::HashMap<i64, Vec<Value>> =
|
||||
match (&self.activation, input.model.tool_rendering) {
|
||||
(Some(src), ToolRendering::Inline) => {
|
||||
let _ = src;
|
||||
Default::default()
|
||||
}
|
||||
(Some(src), _) => src
|
||||
.activations(input.frame)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| (a.anchor.get(), a.defs))
|
||||
.collect(),
|
||||
(None, _) => Default::default(),
|
||||
};
|
||||
|
||||
// ── 4. Conversation history ───────────────────────────────────────────
|
||||
for (idx, entry) in history.iter().enumerate() {
|
||||
let is_previous_turn = current_turn_boundary.is_some_and(|b| idx < b);
|
||||
|
||||
match entry.role {
|
||||
Role::System => {}
|
||||
Role::User | Role::Agent => {
|
||||
let metadata: Option<MessageMetadata> = entry
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
let (text, media_parts) = match &metadata {
|
||||
Some(meta)
|
||||
if !meta.attachments.is_empty()
|
||||
&& idx >= media_turn_start
|
||||
&& self.fs.is_some() =>
|
||||
{
|
||||
let fs = self.fs.as_deref().expect("guarded by is_some()");
|
||||
let partition = media::partition(&meta.attachments, &input.model.capabilities, fs).await;
|
||||
(
|
||||
format!("{}{}", entry.content, attachments_block(&partition.rest)),
|
||||
partition.parts,
|
||||
)
|
||||
}
|
||||
Some(meta) if !meta.attachments.is_empty() => (
|
||||
format!("{}{}", entry.content, attachments_block(&meta.attachments)),
|
||||
Vec::new(),
|
||||
),
|
||||
_ => (entry.content.clone(), Vec::new()),
|
||||
};
|
||||
push_user_chunk(&mut out, text, media_parts);
|
||||
}
|
||||
Role::Assistant => {
|
||||
if entry.calls.is_empty() {
|
||||
let mut msg = json!({ "role": "assistant", "content": entry.content });
|
||||
if let Some(rc) = entry.reasoning.as_deref().filter(|s| !s.is_empty()) {
|
||||
msg["reasoning_content"] = rc.into();
|
||||
msg["reasoning"] = rc.into();
|
||||
}
|
||||
out.push(msg);
|
||||
} else {
|
||||
let tc_array: Vec<Value> = entry.calls
|
||||
.iter()
|
||||
.map(|tc| json!({
|
||||
"id": tc.provider_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.name,
|
||||
"arguments": serde_json::to_string(&tc.arguments)
|
||||
.unwrap_or_else(|_| "{}".into()),
|
||||
}
|
||||
}))
|
||||
.collect();
|
||||
|
||||
let mut msg = json!({
|
||||
"role": "assistant",
|
||||
"content": entry.content,
|
||||
"tool_calls": tc_array,
|
||||
});
|
||||
// DeepSeek thinking mode: a tool-calling assistant turn must
|
||||
// carry a NON-EMPTY reasoning_content on replay.
|
||||
let rc = entry.reasoning.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(REASONING_ROUNDTRIP_PLACEHOLDER);
|
||||
msg["reasoning_content"] = rc.into();
|
||||
msg["reasoning"] = rc.into();
|
||||
out.push(msg);
|
||||
|
||||
for tc in &entry.calls {
|
||||
let result_content = match tc.state {
|
||||
CallState::Done => tc.result.clone().unwrap_or_default(),
|
||||
CallState::Failed => format!(
|
||||
"Error: {}",
|
||||
tc.result.as_deref().unwrap_or("unknown error")
|
||||
),
|
||||
CallState::Rejected => tc.result.clone()
|
||||
.unwrap_or_else(|| "User rejected this tool call.".to_string()),
|
||||
CallState::Cancelled => tc.result.clone()
|
||||
.unwrap_or_else(|| "Tool call was cancelled by the user.".to_string()),
|
||||
// 'pending'/'running' left behind by a crash or a lost
|
||||
// connection: the call really was interrupted mid-flight.
|
||||
_ => "Error: tool call was interrupted (connection lost before user approval). Please retry the operation.".to_string(),
|
||||
};
|
||||
|
||||
let result_content = self.maybe_hide_tool_result(
|
||||
result_content,
|
||||
is_previous_turn,
|
||||
&tc.name,
|
||||
&tc.arguments,
|
||||
);
|
||||
|
||||
let mut tool_msg = json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.provider_id,
|
||||
"content": result_content,
|
||||
});
|
||||
// Anthropic DTL: an `activate_tools` result becomes a set of
|
||||
// `tool_reference`s.
|
||||
if matches!(input.model.tool_rendering, ToolRendering::DeferredToolReference)
|
||||
&& tc.name == tn::ACTIVATE_TOOLS
|
||||
&& let Some(adefs) = activation_defs.get(&entry.id.get())
|
||||
{
|
||||
let names: Vec<Value> = adefs.iter()
|
||||
.filter_map(|d| d["function"]["name"].as_str())
|
||||
.map(|n| Value::String(n.to_string()))
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
tool_msg["_tool_references"] = Value::Array(names);
|
||||
}
|
||||
}
|
||||
out.push(tool_msg);
|
||||
}
|
||||
|
||||
// Tool-produced media of the current turn: inline as a
|
||||
// synthetic `user` message right after the tool-result group.
|
||||
if idx >= media_turn_start
|
||||
&& let Some(fs) = self.fs.as_deref()
|
||||
{
|
||||
let mut refs: Vec<MediaRef> = Vec::new();
|
||||
for tc in &entry.calls {
|
||||
if let Some(mj) = tc.extras["media"].as_str()
|
||||
&& let Ok(mut v) = serde_json::from_str::<Vec<MediaRef>>(mj)
|
||||
{
|
||||
refs.append(&mut v);
|
||||
}
|
||||
}
|
||||
if !refs.is_empty() {
|
||||
let parts = media::inline_paths(&refs, &input.model.capabilities, fs).await;
|
||||
if !parts.is_empty() {
|
||||
out.push(json!({ "role": "user", "content": parts }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kimi K3 DTL: the tools activated at this assistant message,
|
||||
// as a `system` message carrying a `tools` field, right after
|
||||
// its tool-result group (append-only → cache-safe).
|
||||
if matches!(input.model.tool_rendering, ToolRendering::SystemToolBlock)
|
||||
&& let Some(adefs) = activation_defs.get(&entry.id.get())
|
||||
&& !adefs.is_empty()
|
||||
{
|
||||
out.push(json!({ "role": "system", "tools": adefs }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Dynamic tail (extra dynamic + datetime) ────────────────────────
|
||||
{
|
||||
let datetime_line = self.datetime_line();
|
||||
let extra_dynamic = input.system.dynamic_tail.first().map(String::as_str);
|
||||
let tail = match (extra_dynamic, datetime_line.as_deref()) {
|
||||
(Some(dyn_ctx), Some(dt)) => Some(format!("{dyn_ctx}\n\n---\n{dt}")),
|
||||
(Some(dyn_ctx), None) => Some(dyn_ctx.to_string()),
|
||||
(None, Some(dt)) => Some(dt.to_string()),
|
||||
(None, None) => None,
|
||||
};
|
||||
if let Some(content) = tail {
|
||||
out.push(json!({ "role": "system", "content": content }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Tail reminder ──────────────────────────────────────────────────
|
||||
if let Some(reminder) = &input.system.tail_reminder {
|
||||
out.push(json!({ "role": "system", "content": reminder }));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl SkaldAssembler {
|
||||
/// The current date/time + OS + cwd block (empty when disabled).
|
||||
fn datetime_line(&self) -> Option<String> {
|
||||
if !self.datetime_config.enabled {
|
||||
return None;
|
||||
}
|
||||
let now_utc = chrono::Utc::now();
|
||||
let secs = now_utc.timestamp();
|
||||
|
||||
let secs = match self.datetime_config.round_minutes {
|
||||
Some(m) if m > 0 => {
|
||||
let bucket = (m as i64) * 60;
|
||||
(secs / bucket) * bucket
|
||||
}
|
||||
_ => secs,
|
||||
};
|
||||
|
||||
let tz = self.datetime_config.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}"),
|
||||
};
|
||||
|
||||
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()
|
||||
))
|
||||
}
|
||||
|
||||
/// Replaces an over-limit previous-turn result with an informative 1-liner.
|
||||
fn maybe_hide_tool_result(
|
||||
&self,
|
||||
result: String,
|
||||
is_previous_turn: bool,
|
||||
tool_name: &str,
|
||||
arguments: &Value,
|
||||
) -> String {
|
||||
if !is_previous_turn {
|
||||
return result;
|
||||
}
|
||||
let Some(limit) = self.max_tool_result_chars else {
|
||||
return result;
|
||||
};
|
||||
if result.len() <= limit {
|
||||
return result;
|
||||
}
|
||||
summarize_tool_result(tool_name, arguments, &result)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Free helpers (ported verbatim from message_builder.rs) ─────────────────────
|
||||
|
||||
/// Appends one user/agent chunk, coalescing with a preceding `user` message.
|
||||
fn push_user_chunk(out: &mut Vec<Value>, text: String, media: Vec<Value>) {
|
||||
fn text_part(t: &str) -> Value {
|
||||
json!({ "type": "text", "text": t })
|
||||
}
|
||||
|
||||
if let Some(last) = out.last_mut()
|
||||
&& last["role"] == "user"
|
||||
{
|
||||
if !last["content"].is_array() && media.is_empty() {
|
||||
let prev = last["content"].as_str().unwrap_or("").to_string();
|
||||
last["content"] = Value::String(format!("{prev}\n\n{text}"));
|
||||
return;
|
||||
}
|
||||
let mut parts = match last["content"].take() {
|
||||
Value::Array(a) => a,
|
||||
Value::String(s) => vec![text_part(&s)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if let Some(tp) = parts.iter_mut().rev().find(|p| p["type"] == "text") {
|
||||
let prev = tp["text"].as_str().unwrap_or("").to_string();
|
||||
tp["text"] = Value::String(format!("{prev}\n\n{text}"));
|
||||
} else {
|
||||
parts.insert(0, text_part(&text));
|
||||
}
|
||||
parts.extend(media);
|
||||
last["content"] = Value::Array(parts);
|
||||
return;
|
||||
}
|
||||
if media.is_empty() {
|
||||
out.push(json!({ "role": "user", "content": text }));
|
||||
} else {
|
||||
let mut parts = vec![text_part(&text)];
|
||||
parts.extend(media);
|
||||
out.push(json!({ "role": "user", "content": parts }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an informative 1-line summary of a tool call result.
|
||||
fn summarize_tool_result(tool_name: &str, arguments: &Value, result: &str) -> String {
|
||||
let args = arguments;
|
||||
|
||||
let char_count = result.len();
|
||||
let line_count = if result.trim().is_empty() { 0 } else { result.lines().count() };
|
||||
|
||||
fn arg_str<'a>(args: &'a serde_json::Value, key: &str) -> &'a str {
|
||||
args[key].as_str().unwrap_or("?")
|
||||
}
|
||||
|
||||
match tool_name {
|
||||
tn::EXECUTE_CMD => {
|
||||
let cmd = args["command"].as_str().unwrap_or("");
|
||||
let cmd_display = crate::session::handler::preview_truncate(cmd, 77);
|
||||
let exit_code = result
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|l| l.strip_prefix("exit: "))
|
||||
.unwrap_or("?");
|
||||
format!("[execute_cmd] ran `{cmd_display}` → exit {exit_code}, {line_count} lines output")
|
||||
}
|
||||
|
||||
"read_file" | "read_file_chunk" => {
|
||||
let path = arg_str(args, "path");
|
||||
format!("[{tool_name}] read {path} ({char_count} chars)")
|
||||
}
|
||||
|
||||
"write_file" => {
|
||||
let path = arg_str(args, "path");
|
||||
format!("[write_file] wrote to {path}")
|
||||
}
|
||||
|
||||
"edit_file" | "patch_file" => {
|
||||
let path = arg_str(args, "path");
|
||||
format!("[{tool_name}] edited {path}")
|
||||
}
|
||||
|
||||
"list_dir" | "glob" => {
|
||||
let path = args["path"].as_str()
|
||||
.or_else(|| args["pattern"].as_str())
|
||||
.unwrap_or("?");
|
||||
format!("[{tool_name}] {path} ({char_count} chars)")
|
||||
}
|
||||
|
||||
"list_items" => {
|
||||
let kind = arg_str(args, "type");
|
||||
format!("[list_items] {kind} ({char_count} chars)")
|
||||
}
|
||||
|
||||
"toggle_item" => {
|
||||
let kind = arg_str(args, "kind");
|
||||
let id = arg_str(args, "id");
|
||||
let enabled = args["enabled"].as_bool().unwrap_or(false);
|
||||
format!("[toggle_item] {kind} '{id}' → {}", if enabled { "enabled" } else { "disabled" })
|
||||
}
|
||||
|
||||
tn::READ_NOTIFICATION => {
|
||||
let count = serde_json::from_str::<Vec<serde_json::Value>>(result)
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
format!("[read_notification] {count} notification(s)")
|
||||
}
|
||||
|
||||
tn::EXECUTE_TASK | tn::EXECUTE_SUBTASK => {
|
||||
let agent = arg_str(args, "agent_id");
|
||||
format!("[{tool_name}] → {agent} ({char_count} chars result)")
|
||||
}
|
||||
|
||||
tn::ACTIVATE_TOOLS => {
|
||||
let groups = args["groups"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!("[activate_tools] loaded: {groups}")
|
||||
}
|
||||
|
||||
_ if tool_name.starts_with("mcp__") => {
|
||||
format!("[{tool_name}] ({char_count} chars result)")
|
||||
}
|
||||
|
||||
_ => {
|
||||
let first_arg = args.as_object()
|
||||
.and_then(|m| m.iter().next())
|
||||
.map(|(k, v)| {
|
||||
let sv = crate::session::handler::preview_truncate(v.as_str().unwrap_or_default(), 40);
|
||||
format!(" {k}={sv}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("[{tool_name}]{first_arg} ({char_count} chars result)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Skald's async delegation seam (blueprint §7.2) — `execute_task mode=async`.
|
||||
//!
|
||||
//! The library defines *what* an out-of-band task is ([`AsyncExecutor`] submits
|
||||
//! it, [`AsyncResultSink`] delivers its result); this says *how* Skald runs one:
|
||||
//!
|
||||
//! - [`CronExecutor`] — a row in `scheduled_jobs`, run by the cron machinery.
|
||||
//! Durable by construction: the row survives a restart and `recover_interrupted`
|
||||
//! re-runs a job that was in flight when the process died. That is the whole
|
||||
//! reason Skald does not use the crate's `InProcessExecutor`, which is lossy.
|
||||
//! - [`DurableSink`] — the crate's store write plus Skald's wake-up: the result
|
||||
//! is history the instant it lands, and the parent session is resumed so the
|
||||
//! model actually reads it.
|
||||
//!
|
||||
//! The `TaskManager` arrives late (it needs a `ChatSessionManager`, which builds
|
||||
//! the loop runtime — the same cycle `ChatHub` resolves with its own
|
||||
//! `OnceLock`), so the executor is constructed empty and filled in at wiring
|
||||
//! time. Submitting before that is a wiring bug and says so.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use agent_loop::delegate::{
|
||||
AsyncExecutor, AsyncResultSink, AsyncSpec, CompletedTask, StoreSink, TaskHandle,
|
||||
};
|
||||
use agent_loop::ids::{ConversationId, TaskId};
|
||||
use agent_loop::store::HistoryStore;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::chat_hub::ChatHub;
|
||||
use crate::cron::TaskManager;
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::scope::TurnScope;
|
||||
|
||||
// ── CronExecutor ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs a delegated task as a `scheduled_jobs` row of kind `async`.
|
||||
pub struct CronExecutor {
|
||||
tasks: OnceLock<Arc<TaskManager>>,
|
||||
}
|
||||
|
||||
impl CronExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self { tasks: OnceLock::new() }
|
||||
}
|
||||
|
||||
/// Called once at wiring time (see the module docs). A second call is
|
||||
/// ignored — the first manager is the one the user's jobs belong to.
|
||||
pub fn set_task_manager(&self, tasks: Arc<TaskManager>) {
|
||||
let _ = self.tasks.set(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CronExecutor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl AsyncExecutor for CronExecutor {
|
||||
async fn submit(&self, spec: AsyncSpec) -> agent_loop::Result<TaskHandle> {
|
||||
let tasks = self
|
||||
.tasks
|
||||
.get()
|
||||
.ok_or_else(|| anyhow::anyhow!("async tasks are not available in this session"))?;
|
||||
let session_id = SqliteHistory::session_id(&spec.conversation)?;
|
||||
|
||||
// The child inherits the parent's run context (security group, project
|
||||
// root): a background task must not run with more reach than the turn
|
||||
// that asked for it.
|
||||
let run_context = match TurnScope::from(&spec.extensions) {
|
||||
Some(scope) => scope.run_context.read().await.as_ref().map(|rc| rc.to_db()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let title = spec
|
||||
.title
|
||||
.clone()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("{} task", spec.agent));
|
||||
let description = spec.description.clone().unwrap_or_default();
|
||||
|
||||
let job = tasks.add_job_async(
|
||||
&title,
|
||||
&description,
|
||||
&spec.prompt,
|
||||
&spec.agent,
|
||||
session_id,
|
||||
run_context.as_deref(),
|
||||
)?;
|
||||
Ok(TaskHandle { id: TaskId(job.id), title: job.title })
|
||||
}
|
||||
}
|
||||
|
||||
// ── DurableSink ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Delivers a finished task into its parent conversation: the crate writes the
|
||||
/// synthetic assistant message + completed call, then the parent session is
|
||||
/// resumed so the model reads the result now rather than on its next message.
|
||||
///
|
||||
/// `ChatHub::resume` skips a session with a turn already in flight, which is the
|
||||
/// right rule here too: a live loop reads the store each round and picks the
|
||||
/// result up on its own.
|
||||
pub struct DurableSink {
|
||||
inner: StoreSink,
|
||||
pool: Arc<SqlitePool>,
|
||||
hub: Arc<ChatHub>,
|
||||
}
|
||||
|
||||
impl DurableSink {
|
||||
pub fn new(pool: Arc<SqlitePool>, hub: Arc<ChatHub>) -> Self {
|
||||
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
|
||||
Self { inner: StoreSink::new(store), pool, hub }
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl AsyncResultSink for DurableSink {
|
||||
async fn deliver(&self, parent: ConversationId, task: CompletedTask) -> agent_loop::Result<()> {
|
||||
self.inner.deliver(parent.clone(), task).await?;
|
||||
|
||||
let session_id = SqliteHistory::session_id(&parent)?;
|
||||
let source = crate::db::chat_sessions::find_by_id(&self.pool, session_id)
|
||||
.await?
|
||||
.map(|s| s.source)
|
||||
.ok_or_else(|| anyhow::anyhow!("deliver: session {session_id} not found"))?;
|
||||
self.hub.resume(&source).await
|
||||
}
|
||||
}
|
||||
@@ -195,22 +195,27 @@ impl Tool for SkaldAskUserTool {
|
||||
|
||||
// ── ExecuteTaskAliasTool ─────────────────────────────────────────────────────
|
||||
|
||||
/// The legacy `execute_task`: `mode=sync` (or unspecified) delegates to the
|
||||
/// crate's `DelegateTool`; `mode=async` rides the legacy interface-tool
|
||||
/// handler (ChatHub's task injection) until phase 3 wires `CronExecutor`.
|
||||
/// The legacy `execute_task`, split by what the mode actually is.
|
||||
///
|
||||
/// `sync` and `async` are **delegation** — one agent handing work to another —
|
||||
/// so both go to the crate's `DelegateTool` (which runs the child in place, or
|
||||
/// submits it to the async executor). `cron` is **scheduling**: it creates a
|
||||
/// recurring job and delegates nothing, so it stays on the interface-tool
|
||||
/// handler that owns the schedule. Without that handler (a non-interactive
|
||||
/// session, where cron was never offered) the mode is refused.
|
||||
pub struct ExecuteTaskAliasTool {
|
||||
delegate: DelegateTool,
|
||||
definition: Value,
|
||||
async_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
|
||||
delegate: DelegateTool,
|
||||
definition: Value,
|
||||
cron_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ExecuteTaskAliasTool {
|
||||
pub fn new(
|
||||
delegate: DelegateTool,
|
||||
definition: Value,
|
||||
async_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
|
||||
delegate: DelegateTool,
|
||||
definition: Value,
|
||||
cron_handler: Option<Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>>,
|
||||
) -> Self {
|
||||
Self { delegate, definition, async_handler }
|
||||
Self { delegate, definition, cron_handler }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,14 +226,15 @@ impl Tool for ExecuteTaskAliasTool {
|
||||
fn definition(&self) -> Value { self.definition.clone() }
|
||||
|
||||
fn concurrency_safe(&self, args: &Value) -> bool {
|
||||
args["mode"].as_str() != Some("async")
|
||||
// Only a sync delegate is a plain "slow tool" the fan-out may batch.
|
||||
!matches!(args["mode"].as_str(), Some("async") | Some("cron"))
|
||||
}
|
||||
|
||||
async fn call(&self, args: Value, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
|
||||
if args["mode"].as_str() == Some("async") {
|
||||
let Some(handler) = &self.async_handler else {
|
||||
if args["mode"].as_str() == Some("cron") {
|
||||
let Some(handler) = &self.cron_handler else {
|
||||
return Err(ToolFailure::Failed(
|
||||
"execute_task: async mode is not available in this session".into(),
|
||||
"execute_task: cron mode is not available in this session".into(),
|
||||
));
|
||||
};
|
||||
return handler(args)
|
||||
|
||||
@@ -3,26 +3,32 @@
|
||||
//! profile — its own prompt (never the parent's, B3), derived tool set
|
||||
//! (root-only strip + sub-agent augmentation + approval visibility), own
|
||||
//! strength selector (D14), own DTL-scoped assembler and activator.
|
||||
//!
|
||||
//! Built **once per user**: everything about the delegating turn comes from the
|
||||
//! call's [`TurnScope`], never captured here.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::{Arc, RwLock, Weak};
|
||||
|
||||
use agent_loop::context::ContextAssembler;
|
||||
use agent_loop::delegate::{AgentCatalog, AgentKind, AgentProfile, AgentSummary, DelegateTool, ToolSelection};
|
||||
use agent_loop::delegate::{
|
||||
AgentCatalog, AgentKind, AgentProfile, AgentSummary, DelegateTool, ToolSelection,
|
||||
};
|
||||
use agent_loop::ids::FrameId;
|
||||
use agent_loop::model::ModelHint;
|
||||
use agent_loop::tool::Tool as LoopTool;
|
||||
use agent_loop::tool::{Tool as LoopTool, ToolCtx};
|
||||
use agent_loop::activation::ActivateToolsTool;
|
||||
use core_api::user_fs::SharedFs;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::approval::ApprovalManager;
|
||||
use crate::clarification::ClarificationManager;
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::loop_adapters::activation::SkaldToolActivator;
|
||||
use crate::loop_adapters::assembler::SkaldAssembler;
|
||||
use crate::loop_adapters::builtins::{SkaldAskUserTool, SkaldHumanChannel};
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::runtime::LoopConfig;
|
||||
use crate::loop_adapters::scope::TurnScope;
|
||||
use crate::loop_adapters::selector::SkaldSelector;
|
||||
use crate::loop_adapters::system::AgentSystemContext;
|
||||
use crate::loop_adapters::toolset::SkaldToolSet;
|
||||
@@ -30,36 +36,24 @@ use crate::mcp::McpProvider;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::tool_names as tn;
|
||||
|
||||
/// Everything the catalog needs from the parent turn, captured at wiring time.
|
||||
/// The catalog's own dependencies — all of them user-scoped.
|
||||
pub struct SkaldAgentCatalog {
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
session_id: i64,
|
||||
source: String,
|
||||
is_interactive: bool,
|
||||
context_label: Arc<RwLock<Option<String>>>,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
clarification: Arc<ClarificationManager>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
registry: Arc<ToolRegistry>,
|
||||
/// Parent turn's derived def lists (the child's base derives from these).
|
||||
base_defs: Vec<serde_json::Value>,
|
||||
config_defs: Arc<Vec<serde_json::Value>>,
|
||||
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
core_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
root_only: Vec<String>,
|
||||
/// The delegate tool, injected post-construction (catalog ↔ delegate cycle).
|
||||
delegate: RwLock<Option<Arc<DelegateTool>>>,
|
||||
/// Per-turn assembler knobs shared with children.
|
||||
datetime_config: DatetimeConfig,
|
||||
max_history_messages: usize,
|
||||
max_tool_result_chars: Option<usize>,
|
||||
compactor_enabled: bool,
|
||||
fs: Option<Arc<core_api::user_fs::UserFs>>,
|
||||
project_root: Option<String>,
|
||||
/// The swappable fs cell, so a §6 remount reaches sub-agents too.
|
||||
fs: SharedFs,
|
||||
config: LoopConfig,
|
||||
/// 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).
|
||||
delegate: RwLock<Weak<DelegateTool>>,
|
||||
}
|
||||
|
||||
impl SkaldAgentCatalog {
|
||||
@@ -68,69 +62,51 @@ impl SkaldAgentCatalog {
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
session_id: i64,
|
||||
source: String,
|
||||
is_interactive: bool,
|
||||
context_label: Arc<RwLock<Option<String>>>,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
clarification: Arc<ClarificationManager>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
registry: Arc<ToolRegistry>,
|
||||
base_defs: Vec<serde_json::Value>,
|
||||
config_defs: Arc<Vec<serde_json::Value>>,
|
||||
memory_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
image_tools: Vec<Arc<dyn crate::tools::Tool>>,
|
||||
root_only: Vec<String>,
|
||||
datetime_config: DatetimeConfig,
|
||||
max_history_messages: usize,
|
||||
max_tool_result_chars: Option<usize>,
|
||||
compactor_enabled: bool,
|
||||
fs: Option<Arc<core_api::user_fs::UserFs>>,
|
||||
project_root: Option<String>,
|
||||
fs: SharedFs,
|
||||
config: LoopConfig,
|
||||
) -> Self {
|
||||
let core_tools = registry.all_tools();
|
||||
Self {
|
||||
pool,
|
||||
shared_pool,
|
||||
user_id,
|
||||
session_id,
|
||||
source,
|
||||
is_interactive,
|
||||
context_label,
|
||||
llm_manager,
|
||||
approval,
|
||||
clarification,
|
||||
mcp,
|
||||
registry,
|
||||
base_defs,
|
||||
config_defs,
|
||||
memory_tools,
|
||||
image_tools,
|
||||
core_tools,
|
||||
root_only,
|
||||
delegate: RwLock::new(None),
|
||||
datetime_config,
|
||||
max_history_messages,
|
||||
max_tool_result_chars,
|
||||
compactor_enabled,
|
||||
fs,
|
||||
project_root,
|
||||
config,
|
||||
delegate: RwLock::new(Weak::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-construction wiring of the delegate (the catalog ↔ delegate cycle).
|
||||
pub fn set_delegate(&self, delegate: DelegateTool) {
|
||||
*self.delegate.write().unwrap() = Some(Arc::new(delegate));
|
||||
/// Post-construction wiring of the delegate (catalog ↔ delegate cycle,
|
||||
/// broken by the `Weak` above).
|
||||
pub fn set_delegate(&self, delegate: &Arc<DelegateTool>) {
|
||||
*self.delegate.write().unwrap() = Arc::downgrade(delegate);
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl AgentCatalog for SkaldAgentCatalog {
|
||||
async fn get(&self, id: &str, child_frame: FrameId) -> agent_loop::Result<AgentProfile> {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &str,
|
||||
child_frame: FrameId,
|
||||
ctx: &ToolCtx,
|
||||
) -> agent_loop::Result<AgentProfile> {
|
||||
let scope = TurnScope::from(&ctx.extensions)
|
||||
.ok_or_else(|| anyhow::anyhow!("delegate: the turn published no scope"))?;
|
||||
|
||||
// Only `task` agents are dispatchable (rejects chat/system/unknown).
|
||||
let meta = crate::agents::load_task_meta(id)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let meta = crate::agents::load_task_meta(id).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
// The child's own strength drives its selector (D14) — never the
|
||||
// parent's resolved client.
|
||||
@@ -139,27 +115,31 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
|
||||
// The child's system context: its own prompt, no per-turn extras.
|
||||
let context = Arc::new(AgentSystemContext {
|
||||
agent_id: id.to_string(),
|
||||
extra_static: None,
|
||||
extra_dynamic: None,
|
||||
tail_reminder: None,
|
||||
substitutions: Default::default(),
|
||||
pool: self.pool.clone(),
|
||||
shared_pool: self.shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
project_root: self.project_root.clone(),
|
||||
agent_id: id.to_string(),
|
||||
extra_static: None,
|
||||
extra_dynamic: None,
|
||||
tail_reminder: None,
|
||||
substitutions: Default::default(),
|
||||
pool: self.pool.clone(),
|
||||
shared_pool: self.shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
project_root: scope.project_root.clone(),
|
||||
// The scratchpad is the session's blackboard: a sub-agent reads and
|
||||
// writes the SAME one as its parent.
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
});
|
||||
|
||||
// The child's def list: parent's base minus root-only minus the
|
||||
// re-derived augmentations (added back natively below), plus
|
||||
// sub-agents-only tools, through the approval visibility filter.
|
||||
let mut child_defs: Vec<serde_json::Value> = self
|
||||
let mut child_defs: Vec<serde_json::Value> = scope
|
||||
.base_defs
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
let name = d["function"]["name"].as_str().unwrap_or("");
|
||||
!self.root_only.iter().any(|n| n == name)
|
||||
!scope.root_only.iter().any(|n| n == name)
|
||||
&& name != tn::ASK_USER_CLARIFICATION
|
||||
&& name != tn::EXECUTE_SUBTASK
|
||||
&& name != tn::EXECUTE_TASK
|
||||
@@ -178,7 +158,8 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
}
|
||||
|
||||
// Native child tools: clarification, sub-delegation (depth permitting),
|
||||
// and the frame-scoped activate_tools with a FRESH grant set.
|
||||
// and the frame-scoped activate_tools with a FRESH grant set — a child
|
||||
// never inherits the parent's activations.
|
||||
let child_grants: Arc<RwLock<HashSet<String>>> = Arc::new(RwLock::new(
|
||||
crate::db::activated_tools::list_refs_stack(&self.pool, child_frame.get())
|
||||
.await
|
||||
@@ -191,60 +172,66 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
{
|
||||
let channel = Arc::new(SkaldHumanChannel::new(
|
||||
self.clarification.clone(),
|
||||
self.session_id,
|
||||
scope.session_id,
|
||||
id,
|
||||
&self.source,
|
||||
self.is_interactive,
|
||||
self.context_label.clone(),
|
||||
&scope.source,
|
||||
scope.is_interactive,
|
||||
scope.context_label.clone(),
|
||||
));
|
||||
native.push(Arc::new(SkaldAskUserTool::new(
|
||||
channel,
|
||||
Arc::new(SqliteHistory::new(self.pool.clone())),
|
||||
)));
|
||||
}
|
||||
// `execute_subtask` only while the child can still recurse.
|
||||
let delegate = self.delegate.read().unwrap().clone();
|
||||
if let Some(d) = delegate {
|
||||
native.push(Arc::new(d.as_ref().clone().with_name(tn::EXECUTE_SUBTASK)));
|
||||
// `execute_subtask` only while the child can still recurse. A dead Weak
|
||||
// means the runtime is shutting down: the child simply cannot delegate.
|
||||
if let Some(d) = self.delegate.read().unwrap().upgrade() {
|
||||
// Legacy name AND legacy schema (D11): a sub-agent sees the same
|
||||
// definition it has always seen, not the crate's generic one.
|
||||
native.push(Arc::new(
|
||||
d.as_ref()
|
||||
.clone()
|
||||
.with_name(tn::EXECUTE_SUBTASK)
|
||||
.with_definition(crate::session::handler::execute_subtask_tool_def()),
|
||||
));
|
||||
}
|
||||
native.push(Arc::new(ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
|
||||
self.pool.clone(),
|
||||
self.mcp.clone(),
|
||||
child_grants.clone(),
|
||||
self.session_id,
|
||||
scope.session_id,
|
||||
Some(child_frame.get()),
|
||||
)))));
|
||||
|
||||
let toolset: Arc<dyn agent_loop::tool::ToolSet> = Arc::new(
|
||||
SkaldToolSet::new(
|
||||
child_defs,
|
||||
self.config_defs.clone(),
|
||||
scope.config_defs.clone(),
|
||||
self.mcp.clone(),
|
||||
child_grants,
|
||||
self.memory_tools.clone(),
|
||||
self.image_tools.clone(),
|
||||
scope.memory_tools.as_ref().clone(),
|
||||
scope.image_tools.as_ref().clone(),
|
||||
Vec::new(),
|
||||
self.core_tools.clone(),
|
||||
)
|
||||
.with_native_all(native),
|
||||
);
|
||||
|
||||
let assembler: Arc<dyn ContextAssembler> = Arc::new(SkaldAssembler {
|
||||
pool: self.pool.clone(),
|
||||
scratchpad_sid: self.session_id,
|
||||
datetime_config: self.datetime_config.clone(),
|
||||
max_history_messages: self.max_history_messages,
|
||||
max_tool_result_chars: self.max_tool_result_chars,
|
||||
compactor_enabled: self.compactor_enabled,
|
||||
fs: self.fs.clone(),
|
||||
activation: Some(crate::loop_adapters::activation::SkaldActivationSource::new(
|
||||
self.pool.clone(),
|
||||
self.mcp.clone(),
|
||||
self.config_defs.clone(),
|
||||
self.session_id,
|
||||
Some(child_frame.get()),
|
||||
)),
|
||||
});
|
||||
let assembler: Arc<dyn ContextAssembler> = Arc::new(
|
||||
crate::loop_adapters::projection_cfg::skald_assembler(
|
||||
Arc::new(crate::loop_adapters::activation::SkaldActivationSource::new(
|
||||
self.pool.clone(),
|
||||
self.mcp.clone(),
|
||||
scope.config_defs.clone(),
|
||||
scope.session_id,
|
||||
Some(child_frame.get()),
|
||||
)),
|
||||
Some(self.fs.load()),
|
||||
self.config.max_history_messages,
|
||||
self.config.compaction_enabled,
|
||||
self.config.max_tool_result_chars,
|
||||
),
|
||||
);
|
||||
|
||||
Ok(AgentProfile {
|
||||
id: id.to_string(),
|
||||
|
||||
@@ -10,74 +10,45 @@
|
||||
//! to `GateDecision::Suspend` (the call stays `AwaitingHuman`, the turn
|
||||
//! ends) — the old `GateOutcome::ChannelClosed`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use agent_loop::events::{EventSink, LoopEvent};
|
||||
use agent_loop::gate::{Gate, GateDecision, PendingCall};
|
||||
use agent_loop::store::{CallState, HistoryStore};
|
||||
use core_api::user_fs::SharedFs;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::approval::{ApprovalManager, GateResult};
|
||||
use crate::loop_adapters::scope::TurnScope;
|
||||
use crate::run_context::RunContext;
|
||||
use crate::session::handler::ApprovalDecision;
|
||||
use crate::tools::{ToolRegistry, is_file_read_tool, is_file_write_tool, tool_names as tn};
|
||||
|
||||
/// Everything the gate needs that the current loop keeps on the handler.
|
||||
/// Shared by reference so phase-2 wiring shares the same cells.
|
||||
/// The gate's **long-lived** dependencies: it is built once per user, and reads
|
||||
/// the turn's own state (session, source, group, run context) from the call's
|
||||
/// [`TurnScope`] instead of capturing it.
|
||||
pub struct ApprovalGate {
|
||||
approval: Arc<ApprovalManager>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
session_id: i64,
|
||||
source: String,
|
||||
group_id: Option<String>,
|
||||
run_context: Arc<RwLock<Option<RunContext>>>,
|
||||
pre_approved: Arc<Mutex<HashSet<i64>>>,
|
||||
auto_deny: Arc<AtomicBool>,
|
||||
context_label: Arc<std::sync::RwLock<Option<String>>>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
/// For the `PendingWrite` diff: owner pool (user-memory), shared pool
|
||||
/// (shared-memory), and the caller's fs view (host paths).
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
fs: Option<SharedFs>,
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
fs: Option<SharedFs>,
|
||||
}
|
||||
|
||||
impl ApprovalGate {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
approval: Arc<ApprovalManager>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
session_id: i64,
|
||||
source: impl Into<String>,
|
||||
group_id: Option<String>,
|
||||
run_context: Arc<RwLock<Option<RunContext>>>,
|
||||
pre_approved: Arc<Mutex<HashSet<i64>>>,
|
||||
auto_deny: Arc<AtomicBool>,
|
||||
context_label: Arc<std::sync::RwLock<Option<String>>>,
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
fs: Option<SharedFs>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
fs: Option<SharedFs>,
|
||||
) -> Self {
|
||||
Self {
|
||||
approval,
|
||||
store,
|
||||
tools,
|
||||
session_id,
|
||||
source: source.into(),
|
||||
group_id,
|
||||
run_context,
|
||||
pre_approved,
|
||||
auto_deny,
|
||||
context_label,
|
||||
pool,
|
||||
shared_pool,
|
||||
fs,
|
||||
}
|
||||
Self { approval, store, tools, pool, shared_pool, fs }
|
||||
}
|
||||
|
||||
/// Reads the current content of a file for the `PendingWrite` diff, routed
|
||||
@@ -203,8 +174,17 @@ impl ApprovalGate {
|
||||
#[agent_loop::async_trait]
|
||||
impl Gate for ApprovalGate {
|
||||
async fn check(&self, call: &PendingCall, events: &EventSink) -> GateDecision {
|
||||
// No scope = a wiring bug. Denying is the only safe reading: an
|
||||
// unscoped call cannot be evaluated against any policy.
|
||||
let Some(scope) = TurnScope::from(&call.extensions) else {
|
||||
return GateDecision::Reject {
|
||||
reason: "approval: the turn published no scope; refusing to run the tool"
|
||||
.to_string(),
|
||||
};
|
||||
};
|
||||
|
||||
// Post-restart manual resolve: already approved via a resolve endpoint.
|
||||
if self.pre_approved.lock().unwrap().remove(&call.id.get()) {
|
||||
if scope.pre_approved.lock().unwrap().remove(&call.id.get()) {
|
||||
return GateDecision::Allow;
|
||||
}
|
||||
|
||||
@@ -214,13 +194,13 @@ impl Gate for ApprovalGate {
|
||||
let mut gate = self
|
||||
.approval
|
||||
.check(
|
||||
self.session_id,
|
||||
scope.session_id,
|
||||
category,
|
||||
&call.agent,
|
||||
&self.source,
|
||||
&scope.source,
|
||||
&call.name,
|
||||
&call.args,
|
||||
self.group_id.as_deref(),
|
||||
scope.group_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -228,7 +208,7 @@ impl Gate for ApprovalGate {
|
||||
// (never overrides a Deny).
|
||||
if matches!(gate, GateResult::Require) {
|
||||
let path = call.args["path"].as_str().unwrap_or("");
|
||||
let guard = self.run_context.read().await.clone();
|
||||
let guard = scope.run_context.read().await.clone();
|
||||
let dflt = RunContext::default();
|
||||
let rc = guard.as_ref().unwrap_or(&dflt);
|
||||
let pre_allowed = if is_file_read_tool(&call.name) {
|
||||
@@ -249,7 +229,7 @@ impl Gate for ApprovalGate {
|
||||
reason: "Tool call denied by approval policy.".to_string(),
|
||||
},
|
||||
GateResult::Require => {
|
||||
if self.auto_deny.load(Ordering::Relaxed) {
|
||||
if scope.auto_deny.load(Ordering::Relaxed) {
|
||||
return GateDecision::Reject {
|
||||
reason: "Tool call auto-denied: this session does not support approval requests."
|
||||
.to_string(),
|
||||
@@ -263,16 +243,16 @@ impl Gate for ApprovalGate {
|
||||
};
|
||||
}
|
||||
|
||||
let label = self.context_label.read().ok().and_then(|g| g.clone());
|
||||
let label = scope.context_label.read().ok().and_then(|g| g.clone());
|
||||
let (request_id, approve_rx) = self
|
||||
.approval
|
||||
.register(
|
||||
self.session_id,
|
||||
scope.session_id,
|
||||
call.id.get(),
|
||||
&call.name,
|
||||
call.args.clone(),
|
||||
&call.agent,
|
||||
&self.source,
|
||||
&scope.source,
|
||||
label.as_deref(),
|
||||
category,
|
||||
)
|
||||
@@ -293,6 +273,11 @@ impl Gate for ApprovalGate {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::*;
|
||||
use agent_loop::events::EventSink;
|
||||
use agent_loop::ids::{ConversationId, FrameId, ToolCallId};
|
||||
@@ -318,6 +303,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The scope a turn publishes, with the knobs a test wants to vary.
|
||||
fn scope(source: &str, auto_deny: bool) -> Arc<TurnScope> {
|
||||
Arc::new(TurnScope {
|
||||
session_id: 1,
|
||||
source: source.to_string(),
|
||||
is_interactive: source == "web",
|
||||
agent_id: "assistant".into(),
|
||||
scratchpad_sid: 1,
|
||||
project_root: None,
|
||||
context_label: Arc::new(std::sync::RwLock::new(None)),
|
||||
run_context: Arc::new(RwLock::new(None)),
|
||||
group_id: None,
|
||||
pre_approved: Arc::new(Mutex::new(HashSet::new())),
|
||||
auto_deny: Arc::new(AtomicBool::new(auto_deny)),
|
||||
grants: Arc::new(std::sync::RwLock::new(HashSet::new())),
|
||||
base_defs: Arc::new(Vec::new()),
|
||||
config_defs: Arc::new(Vec::new()),
|
||||
memory_tools: Arc::new(Vec::new()),
|
||||
image_tools: Arc::new(Vec::new()),
|
||||
root_only: Arc::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// A `PendingCall` carrying its turn's scope, as the kernel builds it.
|
||||
fn pending(call_id: i64, frame: i64, scope: Arc<TurnScope>) -> PendingCall {
|
||||
let mut extensions = Extensions::new();
|
||||
extensions.insert(scope);
|
||||
PendingCall {
|
||||
id: ToolCallId(call_id),
|
||||
name: "some_tool".into(),
|
||||
args: json!({}),
|
||||
frame: FrameId(frame),
|
||||
parent_frame: None,
|
||||
agent: "assistant".into(),
|
||||
extensions,
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
gate: ApprovalGate,
|
||||
events: EventSink,
|
||||
@@ -350,28 +373,13 @@ mod tests {
|
||||
approval.clone(),
|
||||
store,
|
||||
tools,
|
||||
1,
|
||||
"web",
|
||||
None,
|
||||
Arc::new(tokio::sync::RwLock::new(None)),
|
||||
Arc::new(Mutex::new(HashSet::new())),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
Arc::new(std::sync::RwLock::new(None)),
|
||||
pool.clone(),
|
||||
pool.clone(),
|
||||
None,
|
||||
);
|
||||
let (bus, _) = tokio::sync::broadcast::channel(16);
|
||||
let events = EventSink::new(ConversationId::new("session:1"), bus);
|
||||
let call = PendingCall {
|
||||
id: ToolCallId(call_id),
|
||||
name: "some_tool".into(),
|
||||
args: json!({}),
|
||||
frame: FrameId(frame.id),
|
||||
parent_frame: None,
|
||||
agent: "assistant".into(),
|
||||
extensions: Extensions::new(),
|
||||
};
|
||||
let call = pending(call_id, frame.id, scope("web", false));
|
||||
Fixture { gate, events, pool, call, path, approval }
|
||||
}
|
||||
|
||||
@@ -416,28 +424,14 @@ mod tests {
|
||||
approval,
|
||||
Arc::new(SqliteHistory::new(pool.clone())),
|
||||
Arc::new(ToolRegistry::new()),
|
||||
1,
|
||||
"cron", // background source: auto-deny
|
||||
None,
|
||||
Arc::new(tokio::sync::RwLock::new(None)),
|
||||
Arc::new(Mutex::new(HashSet::new())),
|
||||
Arc::new(AtomicBool::new(true)),
|
||||
Arc::new(std::sync::RwLock::new(None)),
|
||||
pool.clone(),
|
||||
pool.clone(),
|
||||
None,
|
||||
);
|
||||
let (bus, _) = tokio::sync::broadcast::channel(16);
|
||||
let events = EventSink::new(ConversationId::new("session:1"), bus);
|
||||
let call = PendingCall {
|
||||
id: ToolCallId(call_id),
|
||||
name: "some_tool".into(),
|
||||
args: json!({}),
|
||||
frame: FrameId(frame.id),
|
||||
parent_frame: None,
|
||||
agent: "assistant".into(),
|
||||
extensions: Extensions::new(),
|
||||
};
|
||||
// A background source that cannot ask a human.
|
||||
let call = pending(call_id, frame.id, scope("cron", true));
|
||||
|
||||
// No rules at all → the seeded-less default is Require; auto-deny rejects.
|
||||
let d = gate.check(&call, &events).await;
|
||||
@@ -447,6 +441,24 @@ mod tests {
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
/// A call with no scope means the turn was wired wrong. Denying is the only
|
||||
/// safe reading — there is no policy to evaluate it against.
|
||||
#[tokio::test]
|
||||
async fn an_unscoped_call_is_denied() {
|
||||
let f = fixture("gate-unscoped").await;
|
||||
let mut call = f.call.clone();
|
||||
call.extensions = Extensions::new();
|
||||
|
||||
let d = f.gate.check(&call, &f.events).await;
|
||||
match d {
|
||||
GateDecision::Reject { reason } => assert!(reason.contains("no scope"), "{reason}"),
|
||||
other => panic!("expected Reject, got {other:?}"),
|
||||
}
|
||||
|
||||
f.pool.close().await;
|
||||
cleanup(&f.path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn human_approval_allows_and_marks_pending_first() {
|
||||
let f = fixture("gate-human").await;
|
||||
|
||||
@@ -35,8 +35,13 @@ pub struct SqliteHistory {
|
||||
impl SqliteHistory {
|
||||
pub fn new(pool: Arc<SqlitePool>) -> Self { Self { pool } }
|
||||
|
||||
/// The conversation id of a session — the encoding, in one place.
|
||||
pub fn conversation(session_id: i64) -> ConversationId {
|
||||
ConversationId::new(format!("session:{session_id}"))
|
||||
}
|
||||
|
||||
/// Parse `"session:{id}"` (the adapter's conversation encoding).
|
||||
fn session_id(conv: &ConversationId) -> anyhow::Result<i64> {
|
||||
pub fn session_id(conv: &ConversationId) -> anyhow::Result<i64> {
|
||||
conv.as_str()
|
||||
.strip_prefix("session:")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
@@ -105,6 +110,10 @@ impl SqliteHistory {
|
||||
provider_id: format!("tc_{}", c.id),
|
||||
name: c.name,
|
||||
arguments,
|
||||
// The column holds the model's own string: the projection replays it
|
||||
// verbatim, so the prompt-cache prefix stays byte-identical (a
|
||||
// re-serialized Value would reorder the object keys).
|
||||
arguments_raw: c.arguments,
|
||||
state: Self::unmap_state(&c.status),
|
||||
result: c.result,
|
||||
result_kind: c.result_type,
|
||||
@@ -239,6 +248,22 @@ impl HistoryStore for SqliteHistory {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn frame_of_call(&self, id: ToolCallId) -> agent_loop::Result<Option<FrameRecord>> {
|
||||
let frame = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT h.stack_id
|
||||
FROM chat_llm_tools t
|
||||
JOIN chat_history h ON h.id = t.message_id
|
||||
WHERE t.id = ?",
|
||||
)
|
||||
.bind(id.get())
|
||||
.fetch_optional(&*self.pool)
|
||||
.await?;
|
||||
match frame {
|
||||
Some(f) => self.get_frame(FrameId(f)).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn deepest_active(&self, conv: &ConversationId) -> agent_loop::Result<Option<FrameRecord>> {
|
||||
Ok(self
|
||||
.active_frames(conv)
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
//! Skald's `LoopHooks`: the file-write diff preview bracket (pre: capture the
|
||||
//! old content; post: capture the new one and persist via `set_call_extras`).
|
||||
//! Port of the `execute_tool_call` preview bracketing (blueprint §10).
|
||||
//! Skald's `LoopHooks` — the two app-specific things that happen around the
|
||||
//! loop, neither of which the kernel should know about:
|
||||
//!
|
||||
//! - [`SkaldWritePreviewHook`]: the file-write diff bracket (pre: capture the
|
||||
//! old content; post: the new one, persisted via `set_call_extras`).
|
||||
//! - [`DtlReanchorHook`]: after a compaction, move dynamic-tool activations off
|
||||
//! the messages that just went away.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agent_loop::events::PendingToolCall;
|
||||
use agent_loop::hooks::{HookCtx, LoopHooks};
|
||||
use agent_loop::ids::{FrameId, MessageId};
|
||||
use agent_loop::store::CallOutcome;
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::loop_adapters::preview::{PreviewContext, cap_preview, read_current_content};
|
||||
use crate::tools::is_file_write_tool;
|
||||
@@ -59,3 +66,42 @@ impl LoopHooks for SkaldWritePreviewHook {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DtlReanchorHook ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Keeps dynamic tool loading working across a compaction.
|
||||
///
|
||||
/// An activation is pinned to the message whose round activated it — that is
|
||||
/// where its `tool_reference` marker or its `system`+`tools` block renders. When
|
||||
/// compaction summarises that message away, the activation would render nowhere
|
||||
/// and the model would silently lose tools it had already loaded. Re-anchoring
|
||||
/// them onto the first surviving message keeps them exactly where the
|
||||
/// projection can still find them.
|
||||
///
|
||||
/// Best-effort: a failure costs the model one re-activation, never a wrong
|
||||
/// answer, so it is logged rather than propagated.
|
||||
pub struct DtlReanchorHook {
|
||||
pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl DtlReanchorHook {
|
||||
pub fn new(pool: Arc<SqlitePool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl LoopHooks for DtlReanchorHook {
|
||||
async fn on_compacted(&self, frame: FrameId, covered: MessageId, first_surviving: MessageId) {
|
||||
if let Err(e) = crate::db::activated_tools::reanchor_compacted(
|
||||
&self.pool,
|
||||
frame.get(),
|
||||
covered.get(),
|
||||
first_surviving.get(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(frame = %frame, error = %e, "failed to re-anchor DTL activations after compaction");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! `SkaldMediaSource` — **which** files may reach a model
|
||||
//! (`agent_loop::projection::MediaSource`).
|
||||
//!
|
||||
//! The split with the crate is the §6 containment boundary: the library decides
|
||||
//! shape, capability and budget; this decides *authorization*, and only files
|
||||
//! that pass are ever handed over as blobs.
|
||||
//!
|
||||
//! Two paths, two rules:
|
||||
//!
|
||||
//! - **uploaded attachments** must resolve, through the caller's [`UserFs`],
|
||||
//! under their `~/uploads/` — where the upload seam writes them. An image
|
||||
//! sitting anywhere else in the workspace is never inlined just because a
|
||||
//! message mentions it.
|
||||
//! - **tool-produced media** must land under one of the caller's workspace
|
||||
//! roots (home, shared folders, projects, docs). The tool already resolved
|
||||
//! and contained the path, so this is a fail-closed re-check against a
|
||||
//! symlink swapped since the read.
|
||||
//!
|
||||
//! Both are re-checked here even though the paths came from trusted code: the
|
||||
//! container is writable by the agent, so any host-side read must re-verify.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::projection::{MediaBlob, MediaSource};
|
||||
use agent_loop::store::{StoredCall, StoredMessage};
|
||||
use core_api::message_meta::{Attachment, MessageMetadata, attachments_block};
|
||||
use core_api::tool::MediaRef;
|
||||
use core_api::user_fs::{UPLOADS_SUBDIR, UserFs};
|
||||
use tracing::debug;
|
||||
|
||||
/// A contained file, read lazily.
|
||||
struct FileBlob {
|
||||
name: String,
|
||||
/// `None` = failed authorization; every read then returns `None`, so the
|
||||
/// projection skips it (fail-closed, no panic, no partial inline).
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl MediaBlob for FileBlob {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn size(&self) -> Option<u64> {
|
||||
let path = self.path.as_ref()?;
|
||||
tokio::fs::metadata(path).await.ok().map(|m| m.len())
|
||||
}
|
||||
|
||||
async fn head(&self) -> Option<Vec<u8>> {
|
||||
let path = self.path.as_ref()?;
|
||||
let mut file = tokio::fs::File::open(path).await.ok()?;
|
||||
let mut head = [0u8; 16];
|
||||
let n = tokio::io::AsyncReadExt::read(&mut file, &mut head).await.ok()?;
|
||||
Some(head[..n].to_vec())
|
||||
}
|
||||
|
||||
async fn read_all(&self) -> Option<Vec<u8>> {
|
||||
let path = self.path.as_ref()?;
|
||||
tokio::fs::read(path).await.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// The uploads directory, canonicalized for prefix-checking.
|
||||
fn uploads_root(fs: &UserFs) -> Option<PathBuf> {
|
||||
std::fs::canonicalize(fs.home_host.join(UPLOADS_SUBDIR)).ok()
|
||||
}
|
||||
|
||||
/// The caller's workspace roots: private home, each shared folder, each project,
|
||||
/// and the read-only docs mount.
|
||||
fn workspace_roots(fs: &UserFs) -> Vec<PathBuf> {
|
||||
let canon =
|
||||
|p: &Path| crate::tools::fs::canonicalize_for_policy(&p.to_string_lossy(), Path::new("/"));
|
||||
let mut roots = vec![canon(&fs.home_host)];
|
||||
for m in &fs.shared {
|
||||
roots.push(canon(&m.host));
|
||||
}
|
||||
for m in &fs.projects {
|
||||
roots.push(canon(&m.host));
|
||||
}
|
||||
if let Some(d) = &fs.docs_host {
|
||||
roots.push(canon(d));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// One blob per attachment, **in attachment order** — an unauthorized one
|
||||
/// yields a blob that reads as nothing, so positions stay aligned with the
|
||||
/// caller's list and the projection simply skips it.
|
||||
pub fn attachment_blobs(fs: &UserFs, attachments: &[Attachment]) -> Vec<Arc<dyn MediaBlob>> {
|
||||
let root = uploads_root(fs);
|
||||
attachments
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let path = root.as_ref().and_then(|root| {
|
||||
let abs = crate::tools::fs::resolve_host_path(fs, &a.path).ok()?;
|
||||
if abs.starts_with(root) {
|
||||
Some(abs)
|
||||
} else {
|
||||
debug!(path = %a.path, "media not inlined: outside the uploads root");
|
||||
None
|
||||
}
|
||||
});
|
||||
Arc::new(FileBlob { name: a.name.clone(), path }) as Arc<dyn MediaBlob>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Blobs for tool-produced media, dropping anything outside the workspace.
|
||||
pub fn ref_blobs(fs: &UserFs, refs: &[MediaRef]) -> Vec<Arc<dyn MediaBlob>> {
|
||||
let roots = workspace_roots(fs);
|
||||
refs.iter()
|
||||
.filter_map(|r| {
|
||||
let canon = crate::tools::fs::canonicalize_for_policy(&r.host_path, Path::new("/"));
|
||||
if !roots.iter().any(|root| crate::tools::fs::path_under(&canon, root)) {
|
||||
debug!(path = %r.host_path, "tool media not inlined: outside the workspace");
|
||||
return None;
|
||||
}
|
||||
let name = canon
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "file".to_string());
|
||||
Some(Arc::new(FileBlob { name, path: Some(canon) }) as Arc<dyn MediaBlob>)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The caller's media authorization.
|
||||
pub struct SkaldMediaSource {
|
||||
fs: Arc<UserFs>,
|
||||
}
|
||||
|
||||
impl SkaldMediaSource {
|
||||
pub fn new(fs: Arc<UserFs>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
/// The attachments a stored message carries, in wire order.
|
||||
fn attachments(msg: &StoredMessage) -> Vec<Attachment> {
|
||||
msg.metadata
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<MessageMetadata>(v.clone()).ok())
|
||||
.map(|m| m.attachments)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl MediaSource for SkaldMediaSource {
|
||||
async fn message_media(&self, msg: &StoredMessage) -> Vec<Arc<dyn MediaBlob>> {
|
||||
// Positions matter: `skipped_text` indexes this same list.
|
||||
attachment_blobs(&self.fs, &Self::attachments(msg))
|
||||
}
|
||||
|
||||
async fn call_media(&self, calls: &[StoredCall]) -> Vec<Arc<dyn MediaBlob>> {
|
||||
// Tool media rides `extras.media` as a JSON string of `MediaRef`s.
|
||||
let refs: Vec<MediaRef> = calls
|
||||
.iter()
|
||||
.filter_map(|c| c.extras["media"].as_str())
|
||||
.filter_map(|s| serde_json::from_str::<Vec<MediaRef>>(s).ok())
|
||||
.flatten()
|
||||
.collect();
|
||||
ref_blobs(&self.fs, &refs)
|
||||
}
|
||||
|
||||
fn skipped_text(&self, msg: &StoredMessage, skipped: &[usize]) -> Option<String> {
|
||||
if skipped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let attachments = Self::attachments(msg);
|
||||
let left: Vec<Attachment> = skipped
|
||||
.iter()
|
||||
.filter_map(|&i| attachments.get(i).cloned())
|
||||
.collect();
|
||||
if left.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The textual path block: the agent can still read these with a tool.
|
||||
Some(attachments_block(&left))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! What may be inlined — the §6 half. The library's budgets and part shapes
|
||||
//! are tested in `agent_loop::projection::media`; these assert the
|
||||
//! authorization: uploads only, workspace only, fail-closed on traversal.
|
||||
|
||||
use super::*;
|
||||
use agent_loop::projection::{MediaBudget, media::partition};
|
||||
|
||||
fn att(path: &str) -> Attachment {
|
||||
Attachment {
|
||||
path: path.to_string(),
|
||||
name: path.rsplit('/').next().unwrap().to_string(),
|
||||
mimetype: None,
|
||||
filesize: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
v.extend_from_slice(&[0xAA; 64]);
|
||||
v
|
||||
}
|
||||
|
||||
fn pdf_bytes() -> Vec<u8> {
|
||||
let mut v = b"%PDF-1.7\n".to_vec();
|
||||
v.extend_from_slice(&[0x00; 64]);
|
||||
v
|
||||
}
|
||||
|
||||
fn caps(xs: &[&str]) -> Vec<String> {
|
||||
xs.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// A throwaway [`UserFs`] whose private home is `root/homes/u1`.
|
||||
fn fs_home(home: &Path) -> UserFs {
|
||||
UserFs::new(
|
||||
"u1",
|
||||
home.to_path_buf(),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// `(inlined parts, skipped positions)` for a message's attachments.
|
||||
async fn inline(
|
||||
attachments: &[Attachment],
|
||||
capabilities: &[String],
|
||||
fs: &UserFs,
|
||||
) -> (Vec<serde_json::Value>, Vec<usize>) {
|
||||
let blobs = attachment_blobs(fs, attachments);
|
||||
partition(&blobs, capabilities, &MediaBudget::default()).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_uploaded_png_reaches_a_vision_model() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
|
||||
let home = tmp.join("homes/u1");
|
||||
let dir = home.join("uploads/1");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("a.png"), png_bytes()).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
let (parts, skipped) = inline(&[att("uploads/1/a.png")], &caps(&["vision"]), &fs).await;
|
||||
assert!(skipped.is_empty());
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert!(
|
||||
parts[0]["image_url"]["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_the_uploads_directory_is_authorized() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
|
||||
let home = tmp.join("homes/u1");
|
||||
let dir = home.join("uploads/1");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("a.png"), png_bytes()).await.unwrap();
|
||||
// A real image inside the home but OUTSIDE the uploads dir.
|
||||
tokio::fs::write(home.join("secret.png"), png_bytes()).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
// No capability → everything stays textual.
|
||||
let (parts, skipped) = inline(&[att("uploads/1/a.png")], &caps(&[]), &fs).await;
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(parts.is_empty());
|
||||
|
||||
// An image elsewhere in the home is never inlined…
|
||||
let (parts, skipped) = inline(&[att("secret.png")], &caps(&["vision"]), &fs).await;
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(parts.is_empty());
|
||||
|
||||
// …and traversal out of the workspace is rejected fail-closed.
|
||||
let (parts, skipped) =
|
||||
inline(&[att("uploads/../../secret.png")], &caps(&["vision"]), &fs).await;
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(parts.is_empty());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pdf_needs_the_document_capability() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-media-{}", uuid::Uuid::new_v4()));
|
||||
let home = tmp.join("homes/u1");
|
||||
let dir = home.join("uploads/1");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("a.pdf"), pdf_bytes()).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
let (parts, _) = inline(&[att("uploads/1/a.pdf")], &caps(&["document"]), &fs).await;
|
||||
assert_eq!(parts[0]["type"], "file");
|
||||
assert_eq!(parts[0]["file"]["filename"], "a.pdf");
|
||||
|
||||
// vision alone does not unlock PDFs.
|
||||
let (_, skipped) = inline(&[att("uploads/1/a.pdf")], &caps(&["vision"]), &fs).await;
|
||||
assert_eq!(skipped.len(), 1);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_media_is_contained_to_the_workspace() {
|
||||
let tmp = std::env::temp_dir().join(format!("skald-toolmedia-{}", uuid::Uuid::new_v4()));
|
||||
let home = tmp.join("homes/u1");
|
||||
tokio::fs::create_dir_all(&home).await.unwrap();
|
||||
tokio::fs::write(home.join("pic.png"), png_bytes()).await.unwrap();
|
||||
tokio::fs::write(tmp.join("outside.png"), png_bytes()).await.unwrap();
|
||||
let fs = fs_home(&home);
|
||||
|
||||
let inside = MediaRef {
|
||||
host_path: home.join("pic.png").to_string_lossy().into_owned(),
|
||||
mime: "image/png".into(),
|
||||
};
|
||||
let outside = MediaRef {
|
||||
host_path: tmp.join("outside.png").to_string_lossy().into_owned(),
|
||||
mime: "image/png".into(),
|
||||
};
|
||||
let refs = |r: &MediaRef| ref_blobs(&fs, std::slice::from_ref(r));
|
||||
|
||||
let (parts, _) =
|
||||
partition(&refs(&inside), &caps(&["vision"]), &MediaBudget::default()).await;
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!(parts[0]["type"], "image_url");
|
||||
|
||||
// No capability → nothing inlined.
|
||||
let (parts, _) = partition(&refs(&inside), &caps(&[]), &MediaBudget::default()).await;
|
||||
assert!(parts.is_empty());
|
||||
// A real image outside the workspace never becomes a blob at all.
|
||||
assert!(ref_blobs(&fs, std::slice::from_ref(&outside)).is_empty());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&tmp).await;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Skald-side adapters implementing the `agent-loop` trait surface over the
|
||||
//! existing infrastructure (blueprint §14 phase 1). **Unused by the current
|
||||
//! loop** — they compile and are unit-tested here, and get wired in phase 2.
|
||||
//! Skald-side adapters implementing the `agent-loop` trait surface: everything
|
||||
//! the library asks a host for, answered the way Skald does it. The loop itself
|
||||
//! — rounds, projection, delegation, recovery, compaction — is the crate's.
|
||||
//!
|
||||
//! - [`history::SqliteHistory`] — `HistoryStore` over the existing
|
||||
//! `chat_sessions_stack` / `chat_history` / `chat_llm_tools` / `chat_summaries`
|
||||
@@ -14,17 +14,35 @@
|
||||
//! `AgentRunConfig::all_tool_defs`), plus the core-api→agent-loop tool bridge.
|
||||
//! - [`activation`] — `ActivationSource` + `ToolActivator` over the
|
||||
//! `activated_tools` table and the MCP provider (D15).
|
||||
//! - [`projection_cfg`] — the wire knobs Skald's models need, handed to the
|
||||
//! library's projection engine, plus the assembler every turn runs on.
|
||||
//! Skald owns no projection code: [`media_source`] authorizes which files may
|
||||
//! be inlined (§6 containment) and [`tool_digest`] condenses an over-long
|
||||
//! 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).
|
||||
//! - [`runtime::UserLoopRuntime`] — the one `LoopManager` per user (D12) these
|
||||
//! are all assembled into, plus the per-turn parameters.
|
||||
|
||||
pub mod activation;
|
||||
pub mod assembler;
|
||||
pub mod async_task;
|
||||
pub mod builtins;
|
||||
pub mod catalog;
|
||||
pub mod gate;
|
||||
pub mod history;
|
||||
pub mod hooks;
|
||||
pub mod live_input;
|
||||
pub mod media_source;
|
||||
pub mod preview;
|
||||
#[cfg(test)]
|
||||
mod projection_snapshots;
|
||||
pub mod scope;
|
||||
pub mod projection_cfg;
|
||||
pub mod runtime;
|
||||
pub mod selector;
|
||||
pub mod system;
|
||||
#[cfg(test)]
|
||||
mod testkit;
|
||||
pub mod tool_digest;
|
||||
pub mod toolset;
|
||||
pub mod translate;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Skald's projection configuration — the only place the app states what its
|
||||
//! models need on the wire. The projection engine itself is the library's
|
||||
//! (`agent_loop::projection`); this is the set of knobs, in one place, so a
|
||||
//! provider quirk is a value change and not a code change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::activation::ActivationSource;
|
||||
use agent_loop::context::LinearAssembler;
|
||||
use agent_loop::projection::{MediaBudget, Projection, ReasoningEcho, ResultLimit};
|
||||
use core_api::user_fs::UserFs;
|
||||
|
||||
use crate::compactor::SUMMARY_PREFIX;
|
||||
use crate::loop_adapters::media_source::SkaldMediaSource;
|
||||
use crate::loop_adapters::tool_digest::SkaldDigest;
|
||||
use crate::tools::tool_names as tn;
|
||||
|
||||
/// Where the summary block ends and full history resumes.
|
||||
const SUMMARY_SUFFIX: &str =
|
||||
"[End of context summary — the following messages are the most recent exchanges in full.]";
|
||||
|
||||
/// A call still `running`/`pending` at projection time died mid-flight: the
|
||||
/// wording tells the model it may retry, which a bare "interrupted" would not.
|
||||
const INTERRUPTED: &str = "Error: tool call was interrupted (connection lost before user approval). \
|
||||
Please retry the operation.";
|
||||
|
||||
/// The knobs Skald's model fleet needs.
|
||||
///
|
||||
/// - `max_history_messages` applies **only without compaction**: with the
|
||||
/// compactor on, the summary is what bounds the context, and a window on top
|
||||
/// of it would silently drop messages the summary does not cover.
|
||||
/// - tool results are shrunk for previous turns only, so the in-flight turn
|
||||
/// always sees its own output in full.
|
||||
pub fn skald_projection(
|
||||
max_history_messages: usize,
|
||||
compaction_enabled: bool,
|
||||
max_tool_result_chars: Option<usize>,
|
||||
) -> Projection {
|
||||
Projection {
|
||||
summary_prefix: SUMMARY_PREFIX.to_string(),
|
||||
summary_suffix: Some(SUMMARY_SUFFIX.to_string()),
|
||||
max_messages: (!compaction_enabled).then_some(max_history_messages),
|
||||
max_tool_result: max_tool_result_chars.map(|max_chars| ResultLimit {
|
||||
max_chars,
|
||||
previous_turns_only: true,
|
||||
}),
|
||||
interrupted_text: INTERRUPTED.to_string(),
|
||||
rejected_default: "User rejected this tool call.".to_string(),
|
||||
cancelled_default: "Tool call was cancelled by the user.".to_string(),
|
||||
// DeepSeek's thinking mode rejects a replayed tool-calling turn whose
|
||||
// reasoning_content is empty.
|
||||
reasoning_placeholder: Some("(no reasoning recorded for this step)".to_string()),
|
||||
// Some endpoints read `reasoning_content`, others `reasoning`; neither
|
||||
// rejects the extra key, so Skald sends both.
|
||||
reasoning_echo: ReasoningEcho::Both,
|
||||
tail_separator: "\n\n---\n".to_string(),
|
||||
media: MediaBudget::default(),
|
||||
// The DTL marker belongs on the activation's own result, not on
|
||||
// whichever tool result happens to come first in the round.
|
||||
activation_anchor_tool: Some(tn::ACTIVATE_TOOLS.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The assembler every Skald turn runs on: the configuration above plus the two
|
||||
/// content hooks. `fs` is the caller's filesystem view — without it media is
|
||||
/// never inlined (nothing can be authorized), which is the right default for a
|
||||
/// context with no user workspace.
|
||||
pub fn skald_assembler(
|
||||
activation: Arc<dyn ActivationSource>,
|
||||
fs: Option<Arc<UserFs>>,
|
||||
max_history_messages: usize,
|
||||
compaction_enabled: bool,
|
||||
max_tool_result_chars: Option<usize>,
|
||||
) -> LinearAssembler {
|
||||
let mut assembler = LinearAssembler::new()
|
||||
.with_projection(skald_projection(
|
||||
max_history_messages,
|
||||
compaction_enabled,
|
||||
max_tool_result_chars,
|
||||
))
|
||||
.with_activation(activation)
|
||||
.with_digest(Arc::new(SkaldDigest));
|
||||
if let Some(fs) = fs {
|
||||
assembler = assembler.with_media(Arc::new(SkaldMediaSource::new(fs)));
|
||||
}
|
||||
assembler
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! The projection's regression net **in the context of Skald**: a real owner
|
||||
//! database, a real `UserFs`, real DTL rendering — asserted against the wire
|
||||
//! arrays stored under `snapshots/`.
|
||||
//!
|
||||
//! The stored arrays were **frozen while the old `MessageBuilder` still ran
|
||||
//! beside the new projection and a parity harness asserted they matched**, so
|
||||
//! each one is a byte-for-byte record of what Skald sent before the projection
|
||||
//! moved into the library. The harness died with the builder; the record is
|
||||
//! what survives it.
|
||||
//!
|
||||
//! A failure here means the bytes a model receives changed. That is either a
|
||||
//! bug or a deliberate change; if deliberate, rerun with
|
||||
//! `UPDATE_PROJECTION_SNAPSHOTS=1` and **review the diff**.
|
||||
//!
|
||||
//! The state seeded per scenario lives in [`super::testkit`].
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::llm::DtlMode;
|
||||
use crate::loop_adapters::testkit::{
|
||||
self, AgentFixture, Case, Db, MediaHome, TOOL_RESULT_LIMIT, assert_snapshot, project,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_plain_conversation() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-plain").await;
|
||||
testkit::seed_plain(&db).await;
|
||||
|
||||
let wire = project(&db, &agent, &Case::default()).await;
|
||||
assert_snapshot("plain_conversation", &wire);
|
||||
// Sanity: the fixture really produced the layers the snapshot means to pin.
|
||||
assert!(wire.len() >= 5, "{wire:#?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_scratchpad_and_cache_hints() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-scratch").await;
|
||||
testkit::seed_scratchpad(&db).await;
|
||||
|
||||
let wire = project(&db, &agent, &Case { cache_hints: true, ..Case::default() }).await;
|
||||
assert_snapshot("scratchpad_and_cache_hints", &wire);
|
||||
assert!(
|
||||
wire[0]["content"][0]["cache_control"].is_object(),
|
||||
"the cache breakpoint must be on the static prefix: {:#?}",
|
||||
wire[0]
|
||||
);
|
||||
assert!(wire[1]["content"].as_str().unwrap().contains("<scratchpad>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_tool_round_every_state() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-tools").await;
|
||||
testkit::seed_tool_round(&db).await;
|
||||
|
||||
let wire = project(&db, &agent, &Case::default()).await;
|
||||
assert_snapshot("tool_round_every_state", &wire);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_interrupted_call_survives_a_restart() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-interrupted").await;
|
||||
testkit::seed_interrupted(&db).await;
|
||||
|
||||
let wire = project(&db, &agent, &Case::default()).await;
|
||||
assert_snapshot("interrupted_call", &wire);
|
||||
let tool_msg = wire.iter().find(|m| m["role"] == "tool").unwrap();
|
||||
assert!(tool_msg["content"].as_str().unwrap().contains("interrupted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_condensed_previous_turn_results() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-condense").await;
|
||||
testkit::seed_condensed(&db).await;
|
||||
|
||||
let wire = project(&db, &agent, &Case::default()).await;
|
||||
assert_snapshot("condensed_previous_turn", &wire);
|
||||
let results: Vec<&str> = wire
|
||||
.iter()
|
||||
.filter(|m| m["role"] == "tool")
|
||||
.map(|m| m["content"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(results[0], "[read_file] read big.txt (120 chars)");
|
||||
assert_eq!(results[1].len(), TOOL_RESULT_LIMIT * 3, "the current turn keeps its output");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_with_a_compaction_summary() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-summary").await;
|
||||
testkit::seed_summary(&db).await;
|
||||
|
||||
let wire = project(&db, &agent, &Case::default()).await;
|
||||
assert_snapshot("compaction_summary", &wire);
|
||||
assert!(
|
||||
wire.iter().any(|m| {
|
||||
m["content"]
|
||||
.as_str()
|
||||
.is_some_and(|c| c.contains(crate::compactor::SUMMARY_PREFIX))
|
||||
}),
|
||||
"the summary block must carry Skald's own prefix: {wire:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_dtl_all_three_modes() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-dtl").await;
|
||||
testkit::seed_activation(&db).await;
|
||||
|
||||
for (dtl, name) in [
|
||||
(DtlMode::None, "dtl_none"),
|
||||
(DtlMode::AnthropicToolReference, "dtl_anthropic_tool_reference"),
|
||||
(DtlMode::KimiSystemTools, "dtl_kimi_system_tools"),
|
||||
] {
|
||||
let wire = project(&db, &agent, &Case { dtl, ..Case::default() }).await;
|
||||
assert_snapshot(name, &wire);
|
||||
|
||||
// The marker rides the activation's own result, not whichever tool
|
||||
// result happens to come first in the round.
|
||||
if dtl == DtlMode::AnthropicToolReference {
|
||||
let tools: Vec<&Value> = wire.iter().filter(|m| m["role"] == "tool").collect();
|
||||
assert!(tools[0].get("_tool_references").is_none());
|
||||
assert_eq!(tools[1]["_tool_references"], json!(["mcp__gmail__send"]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_inlined_attachment() {
|
||||
let agent = AgentFixture::new();
|
||||
let db = Db::new("snap-media").await;
|
||||
let home = MediaHome::new();
|
||||
testkit::seed_media(&db).await;
|
||||
|
||||
let wire = project(&db, &agent, &Case {
|
||||
capabilities: vec!["vision".into()],
|
||||
fs: Some(home.fs.clone()),
|
||||
..Case::default()
|
||||
})
|
||||
.await;
|
||||
assert_snapshot("inlined_attachment", &wire);
|
||||
|
||||
let current = wire.iter().rev().find(|m| m["role"] == "user").unwrap();
|
||||
assert_eq!(current["content"][1]["type"], "image_url");
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! `UserLoopRuntime` — the loop stack of one user, built once.
|
||||
//!
|
||||
//! Everything that lives as long as the owner's pool lives here: the
|
||||
//! `LoopManager` (event bus + live-loop registry), the history store, the
|
||||
//! approval gate, the hooks, the agent catalog and the delegate tool. A turn
|
||||
//! then contributes only what is genuinely its own — the agent's prompt, its
|
||||
//! tool set, its model pin — through [`UserLoopRuntime::turn_params`].
|
||||
//!
|
||||
//! Why one per user and not one per turn (blueprint D12): the manager's job is
|
||||
//! the *global* view — which conversations are running, `/stop`, recovery,
|
||||
//! shutdown. A manager rebuilt for every message can answer none of those, and
|
||||
//! rebuilding the graph per message also leaks it (the catalog ↔ delegate cycle
|
||||
//! is broken by a `Weak`, but a per-turn graph would still pile up).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::activation::ActivateToolsTool;
|
||||
use agent_loop::delegate::DelegateTool;
|
||||
use agent_loop::ids::ConversationId;
|
||||
use agent_loop::manager::{LiveInput, LoopManager, TurnMeta, TurnParams};
|
||||
use agent_loop::model::{ModelHint, ModelSelector};
|
||||
use agent_loop::store::HistoryStore;
|
||||
use agent_loop::tool::{Extensions, Tool as LoopTool, ToolSet};
|
||||
use core_api::interface_tool::InterfaceTool;
|
||||
use core_api::user_fs::SharedFs;
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::approval::ApprovalManager;
|
||||
use crate::clarification::ClarificationManager;
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
|
||||
use crate::loop_adapters::async_task::CronExecutor;
|
||||
use crate::loop_adapters::builtins::{
|
||||
ExecuteTaskAliasTool, LegacyInterfaceTool, SkaldAskUserTool, SkaldHumanChannel,
|
||||
UpdateScratchpadTool, WriteTodosTool,
|
||||
};
|
||||
use crate::loop_adapters::catalog::SkaldAgentCatalog;
|
||||
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::preview::PreviewContext;
|
||||
use crate::loop_adapters::projection_cfg::skald_assembler;
|
||||
use crate::loop_adapters::scope::TurnScope;
|
||||
use crate::loop_adapters::selector::SkaldSelector;
|
||||
use crate::loop_adapters::system::AgentSystemContext;
|
||||
use crate::loop_adapters::toolset::{CallerUserId, SkaldToolSet};
|
||||
use crate::mcp::McpProvider;
|
||||
use crate::session::handler::PendingUserInput;
|
||||
use crate::session::handler::interface_tools::AgentRunConfig;
|
||||
use crate::tool_discovery::ToolDiscovery;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::tool_names as tn;
|
||||
|
||||
/// Instance-wide loop limits (from `config.yml`).
|
||||
#[derive(Clone)]
|
||||
pub struct LoopConfig {
|
||||
pub max_rounds: usize,
|
||||
pub max_parallel_calls: usize,
|
||||
pub max_history_messages: usize,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
/// Compaction bounds the context instead of a message window.
|
||||
pub compaction_enabled: bool,
|
||||
pub datetime: DatetimeConfig,
|
||||
pub max_agent_depth: u32,
|
||||
}
|
||||
|
||||
/// Names handled natively; a legacy interface tool of the same name is dropped.
|
||||
const NATIVE_NAMES: &[&str] = &[tn::ACTIVATE_TOOLS, tn::EXECUTE_TASK];
|
||||
|
||||
/// One user's loop stack.
|
||||
pub struct UserLoopRuntime {
|
||||
manager: Arc<LoopManager>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
catalog: Arc<SkaldAgentCatalog>,
|
||||
delegate: Arc<DelegateTool>,
|
||||
/// Backs `execute_task mode=async`; its `TaskManager` lands at wiring time.
|
||||
async_exec: Arc<CronExecutor>,
|
||||
// per-turn assembly material
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
fs: SharedFs,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
clarification: Arc<ClarificationManager>,
|
||||
tool_discovery: Arc<ToolDiscovery>,
|
||||
config: LoopConfig,
|
||||
}
|
||||
|
||||
/// What a turn contributes on top of the runtime.
|
||||
pub struct TurnInputs<'a> {
|
||||
pub scope: Arc<TurnScope>,
|
||||
pub config: &'a AgentRunConfig,
|
||||
/// Messages queued while the turn runs, drained at round boundaries.
|
||||
pub live_input: Option<Arc<dyn PendingUserInput>>,
|
||||
}
|
||||
|
||||
impl UserLoopRuntime {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
pool: Arc<SqlitePool>,
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
user_id: String,
|
||||
fs: SharedFs,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
llm_manager: Arc<LlmManager>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
clarification: Arc<ClarificationManager>,
|
||||
tool_discovery: Arc<ToolDiscovery>,
|
||||
config: LoopConfig,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
|
||||
|
||||
let gate = ApprovalGate::new(
|
||||
approval.clone(),
|
||||
store.clone(),
|
||||
tools.clone(),
|
||||
pool.clone(),
|
||||
shared_pool.clone(),
|
||||
Some(fs.clone()),
|
||||
);
|
||||
|
||||
let preview_hook = Arc::new(SkaldWritePreviewHook::new(PreviewContext {
|
||||
pool: pool.clone(),
|
||||
shared_pool: shared_pool.clone(),
|
||||
fs: Some(fs.clone()),
|
||||
}));
|
||||
|
||||
// The default selector has no strength requirement; every turn overrides
|
||||
// it with the agent's own (D14).
|
||||
let default_selector: Arc<dyn ModelSelector> =
|
||||
Arc::new(SkaldSelector::new(llm_manager.clone(), None));
|
||||
|
||||
let manager = Arc::new(
|
||||
LoopManager::builder()
|
||||
.models(default_selector)
|
||||
.store(store.clone())
|
||||
.gate_arc(Arc::new(gate))
|
||||
.hook(preview_hook)
|
||||
.hook(Arc::new(DtlReanchorHook::new(pool.clone())))
|
||||
.max_rounds(config.max_rounds)
|
||||
.max_parallel_calls(config.max_parallel_calls)
|
||||
.build()?,
|
||||
);
|
||||
|
||||
let catalog = Arc::new(SkaldAgentCatalog::new(
|
||||
pool.clone(),
|
||||
shared_pool.clone(),
|
||||
user_id.clone(),
|
||||
llm_manager.clone(),
|
||||
approval,
|
||||
clarification.clone(),
|
||||
mcp.clone(),
|
||||
tools.clone(),
|
||||
fs.clone(),
|
||||
config.clone(),
|
||||
));
|
||||
// `mode: "async"` runs as a durable cron job; the manager behind it is
|
||||
// set at wiring time (see `CronExecutor`).
|
||||
let async_exec = Arc::new(CronExecutor::new());
|
||||
let delegate = Arc::new(
|
||||
DelegateTool::new(
|
||||
manager.clone(),
|
||||
catalog.clone(),
|
||||
store.clone(),
|
||||
config.max_agent_depth,
|
||||
)
|
||||
.with_async(async_exec.clone()),
|
||||
);
|
||||
// The catalog hands `execute_subtask` to children; it holds this Weak.
|
||||
catalog.set_delegate(&delegate);
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
manager,
|
||||
store,
|
||||
catalog,
|
||||
delegate,
|
||||
async_exec,
|
||||
pool,
|
||||
shared_pool,
|
||||
user_id,
|
||||
fs,
|
||||
tools,
|
||||
mcp,
|
||||
llm_manager,
|
||||
clarification,
|
||||
tool_discovery,
|
||||
config,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn manager(&self) -> &Arc<LoopManager> {
|
||||
&self.manager
|
||||
}
|
||||
|
||||
/// Hands the user's `TaskManager` to the async executor. Called once the
|
||||
/// cron side exists (it needs the session manager that owns this runtime).
|
||||
pub fn set_task_manager(&self, tasks: Arc<crate::cron::TaskManager>) {
|
||||
self.async_exec.set_task_manager(tasks);
|
||||
}
|
||||
|
||||
pub fn store(&self) -> &Arc<dyn HistoryStore> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// The conversation id of a session — the store's encoding.
|
||||
pub fn conversation(session_id: i64) -> ConversationId {
|
||||
SqliteHistory::conversation(session_id)
|
||||
}
|
||||
|
||||
/// Everything a turn needs, assembled from the run config and the scope.
|
||||
pub async fn turn_params(&self, inputs: TurnInputs<'_>) -> anyhow::Result<TurnParams> {
|
||||
let TurnInputs { scope, config, live_input } = inputs;
|
||||
let frame_agent = config.agent_id.clone();
|
||||
|
||||
// ── System context ──
|
||||
let system = Arc::new(AgentSystemContext {
|
||||
agent_id: frame_agent.clone(),
|
||||
extra_static: config.extra_system.clone(),
|
||||
extra_dynamic: config.extra_system_dynamic.clone(),
|
||||
tail_reminder: config.tail_reminder.clone(),
|
||||
substitutions: config.system_substitutions.clone(),
|
||||
pool: self.pool.clone(),
|
||||
shared_pool: self.shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
project_root: scope.project_root.clone(),
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
});
|
||||
|
||||
// ── Tool set: the native tools, then the surface's legacy ones ──
|
||||
let tools = self.build_toolset(&scope, config);
|
||||
|
||||
// ── Assembler: the shared projection, scoped to this session's DTL ──
|
||||
let assembler = Arc::new(skald_assembler(
|
||||
Arc::new(SkaldActivationSource::new(
|
||||
self.pool.clone(),
|
||||
self.mcp.clone(),
|
||||
scope.config_defs.clone(),
|
||||
scope.session_id,
|
||||
None,
|
||||
)),
|
||||
Some(self.fs.load()),
|
||||
self.config.max_history_messages,
|
||||
self.config.compaction_enabled,
|
||||
self.config.max_tool_result_chars,
|
||||
));
|
||||
|
||||
// ── Extensions: the tool bridge's context + the turn's own scope ──
|
||||
let mut extensions = Extensions::new();
|
||||
extensions.insert(self.pool.clone());
|
||||
extensions.insert(self.fs.load());
|
||||
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
|
||||
extensions.insert(scope.clone());
|
||||
|
||||
// ── Selector: this agent's strength (D14) ──
|
||||
let strength = crate::agents::load_meta(&frame_agent).ok().and_then(|m| m.strength);
|
||||
let selector: Arc<dyn ModelSelector> =
|
||||
Arc::new(SkaldSelector::new(self.llm_manager.clone(), strength));
|
||||
|
||||
// The session's root frame; the store reuses the provisioned row.
|
||||
let frame = self
|
||||
.store
|
||||
.open_frame(
|
||||
&Self::conversation(scope.session_id),
|
||||
None,
|
||||
agent_loop::store::FrameSpec::root(&frame_agent),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(TurnParams {
|
||||
frame,
|
||||
agent: frame_agent,
|
||||
system,
|
||||
tools,
|
||||
model_hint: ModelHint::name(config.client_name.clone()),
|
||||
selector: Some(selector),
|
||||
live_input: live_input
|
||||
.map(|p| Arc::new(PendingLiveInput::new(p)) as Arc<dyn LiveInput>),
|
||||
extensions,
|
||||
meta: TurnMeta {
|
||||
synthetic: false,
|
||||
interactive: scope.is_interactive,
|
||||
context_label: scope.context_label.read().ok().and_then(|g| g.clone()),
|
||||
user_message: None,
|
||||
},
|
||||
assembler: Some(assembler),
|
||||
})
|
||||
}
|
||||
|
||||
/// The root agent's tool set: natives (activation, delegation, clarification,
|
||||
/// scratchpad, todos) plus the surface's own interface tools.
|
||||
fn build_toolset(&self, scope: &Arc<TurnScope>, config: &AgentRunConfig) -> Arc<dyn ToolSet> {
|
||||
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
|
||||
|
||||
// activate_tools, sharing the turn's grant set so the next round sees
|
||||
// whatever this round activated.
|
||||
native.push(Arc::new(
|
||||
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
|
||||
self.pool.clone(),
|
||||
self.mcp.clone(),
|
||||
scope.grants.clone(),
|
||||
scope.session_id,
|
||||
None,
|
||||
)))
|
||||
.with_definition(crate::session::handler::config::activate_tools_tool_def()),
|
||||
));
|
||||
|
||||
// execute_task: sync/async → the delegate; cron → the scheduling handler.
|
||||
{
|
||||
let injected = config
|
||||
.interface_tools
|
||||
.iter()
|
||||
.find(|it| it.definition["function"]["name"].as_str() == Some(tn::EXECUTE_TASK))
|
||||
.cloned();
|
||||
let (def, handler) = match injected {
|
||||
Some(it) => (it.definition.clone(), Some(it.handler.clone())),
|
||||
None => (legacy_execute_task_def(), None),
|
||||
};
|
||||
native.push(Arc::new(ExecuteTaskAliasTool::new(
|
||||
self.delegate.as_ref().clone().with_name(tn::EXECUTE_TASK),
|
||||
def,
|
||||
handler,
|
||||
)));
|
||||
}
|
||||
|
||||
native.push(Arc::new(SkaldAskUserTool::new(
|
||||
Arc::new(SkaldHumanChannel::new(
|
||||
self.clarification.clone(),
|
||||
scope.session_id,
|
||||
&scope.agent_id,
|
||||
&scope.source,
|
||||
scope.is_interactive,
|
||||
scope.context_label.clone(),
|
||||
)),
|
||||
self.store.clone(),
|
||||
)));
|
||||
native.push(Arc::new(UpdateScratchpadTool::new(
|
||||
self.pool.clone(),
|
||||
scope.scratchpad_sid,
|
||||
)));
|
||||
native.push(Arc::new(WriteTodosTool));
|
||||
|
||||
let legacy: Vec<InterfaceTool> = config
|
||||
.interface_tools
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
let name = it.definition["function"]["name"].as_str().unwrap_or("");
|
||||
!NATIVE_NAMES.contains(&name)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for it in &legacy {
|
||||
native.push(Arc::new(LegacyInterfaceTool::new(it.clone())));
|
||||
}
|
||||
|
||||
Arc::new(
|
||||
SkaldToolSet::new(
|
||||
scope.base_defs.as_ref().clone(),
|
||||
scope.config_defs.clone(),
|
||||
self.mcp.clone(),
|
||||
scope.grants.clone(),
|
||||
scope.memory_tools.as_ref().clone(),
|
||||
scope.image_tools.as_ref().clone(),
|
||||
legacy,
|
||||
self.tools.all_tools(),
|
||||
)
|
||||
.with_discovery(self.tool_discovery.clone())
|
||||
.with_native_all(native),
|
||||
)
|
||||
}
|
||||
|
||||
/// The catalog, for callers that list dispatchable agents.
|
||||
pub fn catalog(&self) -> &Arc<SkaldAgentCatalog> {
|
||||
&self.catalog
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback definition for `execute_task` when no interface handler was injected
|
||||
/// (non-interactive sessions): mirrors the injected one.
|
||||
fn legacy_execute_task_def() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tn::EXECUTE_TASK,
|
||||
"description": "Execute a task with a sub-agent. mode=sync waits for the result; \
|
||||
mode=async schedules it in the background.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "string" },
|
||||
"prompt": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"mode": { "type": "string", "enum": ["sync", "async"] },
|
||||
"client": { "type": "string" }
|
||||
},
|
||||
"required": ["agent_id", "prompt"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! `TurnScope` — everything about the turn in flight, published once in the
|
||||
//! kernel's `Extensions`.
|
||||
//!
|
||||
//! The adapters that need it (the approval gate, the agent catalog) live as
|
||||
//! long as the **user**, not the turn: one `LoopManager` per `UserContext`
|
||||
//! (blueprint D12) means they cannot capture a session id, a source or a
|
||||
//! permission group at construction. So they read them from here — the seam the
|
||||
//! library designed for exactly this (`PendingCall.extensions`,
|
||||
//! `ToolCtx.extensions`, blueprint §4.6).
|
||||
//!
|
||||
//! Everything mutable rides a shared cell, so a change during the turn (a
|
||||
//! `/stop`-time auto-deny flip, a security-group switch, an `activate_tools`
|
||||
//! grant) is seen by the adapters without rebuilding anything.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
use crate::run_context::RunContext;
|
||||
use crate::tools::Tool;
|
||||
|
||||
/// The turn's own state. Cheap to build (everything is an `Arc` or a small
|
||||
/// value) because it is built once per turn.
|
||||
pub struct TurnScope {
|
||||
// ── identity ──
|
||||
pub session_id: i64,
|
||||
pub source: String,
|
||||
pub is_interactive: bool,
|
||||
pub agent_id: String,
|
||||
/// Scratchpad scope: the session's own id, or the parent's for an async
|
||||
/// sub-task.
|
||||
pub scratchpad_sid: i64,
|
||||
/// Project root (agent path) when this is a project session.
|
||||
pub project_root: Option<String>,
|
||||
|
||||
// ── live cells (shared with the session handler) ──
|
||||
pub context_label: Arc<RwLock<Option<String>>>,
|
||||
pub run_context: Arc<AsyncRwLock<Option<RunContext>>>,
|
||||
/// Security group driving the approval rules.
|
||||
pub group_id: Option<String>,
|
||||
/// Calls a human approved through a REST resolve after a restart: the gate
|
||||
/// lets them through once.
|
||||
pub pre_approved: Arc<Mutex<HashSet<i64>>>,
|
||||
/// Surfaces that cannot ask a human deny instead of hanging.
|
||||
pub auto_deny: Arc<AtomicBool>,
|
||||
/// MCP servers (plus the reserved `config` group) activated for this turn;
|
||||
/// `activate_tools` mutates it, and the next round sees the new tools.
|
||||
pub grants: Arc<RwLock<HashSet<String>>>,
|
||||
|
||||
// ── tool material a child agent derives its own set from ──
|
||||
pub base_defs: Arc<Vec<Value>>,
|
||||
pub config_defs: Arc<Vec<Value>>,
|
||||
pub memory_tools: Arc<Vec<Arc<dyn Tool>>>,
|
||||
pub image_tools: Arc<Vec<Arc<dyn Tool>>>,
|
||||
pub root_only: Arc<Vec<String>>,
|
||||
}
|
||||
|
||||
impl TurnScope {
|
||||
/// The scope of the turn a call belongs to.
|
||||
///
|
||||
/// Absence is a wiring bug, not a runtime condition — every turn publishes
|
||||
/// one — so callers fail closed (deny / refuse to delegate) rather than
|
||||
/// guessing a permissive default.
|
||||
pub fn from(extensions: &agent_loop::tool::Extensions) -> Option<Arc<Self>> {
|
||||
extensions.get::<TurnScope>()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window — treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Your current task is identified in the '## Active Task' section of the summary — resume exactly from there. Your system prompt and any injected memory files are ALWAYS authoritative — never deprioritize them due to this compaction note. Respond ONLY to the latest user message that appears AFTER this summary. The current session state (files, config, etc.) may reflect work described here — avoid repeating it:\n\nEarlier they discussed ancient things.\n\n[End of context summary — the following messages are the most recent exchanges in full.]",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "old reply",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": "recent",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "first",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "reading",
|
||||
"reasoning": "(no reasoning recorded for this step)",
|
||||
"reasoning_content": "(no reasoning recorded for this step)",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"path\":\"big.txt\"}",
|
||||
"name": "read_file"
|
||||
},
|
||||
"id": "tc_1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "[read_file] read big.txt (120 chars)",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_1"
|
||||
},
|
||||
{
|
||||
"content": "second",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "reading",
|
||||
"reasoning": "(no reasoning recorded for this step)",
|
||||
"reasoning_content": "(no reasoning recorded for this step)",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"path\":\"other.txt\"}",
|
||||
"name": "read_file"
|
||||
},
|
||||
"id": "tc_2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_2"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "use gmail",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "activating",
|
||||
"reasoning": "(no reasoning recorded for this step)",
|
||||
"reasoning_content": "(no reasoning recorded for this step)",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "read_file"
|
||||
},
|
||||
"id": "tc_1",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"groups\":[\"gmail\"]}",
|
||||
"name": "activate_tools"
|
||||
},
|
||||
"id": "tc_2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "f",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_1"
|
||||
},
|
||||
{
|
||||
"_tool_references": [
|
||||
"mcp__gmail__send"
|
||||
],
|
||||
"content": "activated",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_2"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "use gmail",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "activating",
|
||||
"reasoning": "(no reasoning recorded for this step)",
|
||||
"reasoning_content": "(no reasoning recorded for this step)",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "read_file"
|
||||
},
|
||||
"id": "tc_1",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"groups\":[\"gmail\"]}",
|
||||
"name": "activate_tools"
|
||||
},
|
||||
"id": "tc_2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "f",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_1"
|
||||
},
|
||||
{
|
||||
"content": "activated",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_2"
|
||||
},
|
||||
{
|
||||
"role": "system",
|
||||
"tools": [
|
||||
{
|
||||
"function": {
|
||||
"description": "[gmail] send mail",
|
||||
"name": "mcp__gmail__send",
|
||||
"parameters": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "use gmail",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "activating",
|
||||
"reasoning": "(no reasoning recorded for this step)",
|
||||
"reasoning_content": "(no reasoning recorded for this step)",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "read_file"
|
||||
},
|
||||
"id": "tc_1",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"groups\":[\"gmail\"]}",
|
||||
"name": "activate_tools"
|
||||
},
|
||||
"id": "tc_2",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "f",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_1"
|
||||
},
|
||||
{
|
||||
"content": "activated",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_2"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "old shot\n\n[SYSTEM INFO]\n1 attached file:\n* uploads/1/shot.png",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "seen",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "new shot",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,iVBORw0KGgqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
|
||||
},
|
||||
"type": "image_url"
|
||||
}
|
||||
],
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "run it",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "running",
|
||||
"reasoning": "(no reasoning recorded for this step)",
|
||||
"reasoning_content": "(no reasoning recorded for this step)",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"command\":\"sleep 100\"}",
|
||||
"name": "execute_cmd"
|
||||
},
|
||||
"id": "tc_1",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "Error: tool call was interrupted (connection lost before user approval). Please retry the operation.",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_1"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "hello",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "hi there",
|
||||
"reasoning": "thinking",
|
||||
"reasoning_content": "thinking",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"content": "one\n\ntwo",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"cache_control": {
|
||||
"type": "ephemeral"
|
||||
},
|
||||
"text": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "<scratchpad>\n <!-- Temporary notes shared by all agents in this session. Not persisted across sessions. -->\n <note key=\"plan\">step one</note>\n</scratchpad>",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "go",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
[
|
||||
{
|
||||
"content": "You are the parity fixture agent.\n\n\n---\nFORMAT RULES",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "work",
|
||||
"role": "user"
|
||||
},
|
||||
{
|
||||
"content": "calling",
|
||||
"reasoning": "(no reasoning recorded for this step)",
|
||||
"reasoning_content": "(no reasoning recorded for this step)",
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{\"path\":\"a.md\"}",
|
||||
"name": "read_file"
|
||||
},
|
||||
"id": "tc_1",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "write_file"
|
||||
},
|
||||
"id": "tc_2",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "execute_cmd"
|
||||
},
|
||||
"id": "tc_3",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"arguments": "{}",
|
||||
"name": "glob"
|
||||
},
|
||||
"id": "tc_4",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": "content",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_1"
|
||||
},
|
||||
{
|
||||
"content": "Error: disk full",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_2"
|
||||
},
|
||||
{
|
||||
"content": "no",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_3"
|
||||
},
|
||||
{
|
||||
"content": "Cancelled by user.",
|
||||
"role": "tool",
|
||||
"tool_call_id": "tc_4"
|
||||
},
|
||||
{
|
||||
"content": "MEMORY BLOCK",
|
||||
"role": "system"
|
||||
},
|
||||
{
|
||||
"content": "REMEMBER THE RULES",
|
||||
"role": "system"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! Shared scaffolding for the projection tests: a real owner database seeded
|
||||
//! **through `SqliteHistory`** (the production write path), a real `agents/`
|
||||
//! directory, a fake MCP provider, and the assembler a Skald turn runs on.
|
||||
//!
|
||||
//! One consumer: [`super::projection_snapshots`], the durable oracle — each
|
||||
//! scenario's expected wire array lives in `snapshots/*.json`. The arrays were
|
||||
//! frozen while the old `MessageBuilder` was still alive and a parity harness
|
||||
//! asserted the two produced the same bytes; that harness is gone with the
|
||||
//! builder, the snapshots outlived it.
|
||||
//!
|
||||
//! Everything volatile is neutralized here rather than scrubbed afterwards:
|
||||
//! the datetime block is disabled, the agent opts out of the skills index, and
|
||||
//! the fixture's own identifiers never reach the wire.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::context::{AssembleInput, ContextAssembler, SystemContextSource, TurnInfo};
|
||||
use agent_loop::ids::{ConversationId, FrameId};
|
||||
use agent_loop::model::ModelInfo;
|
||||
use agent_loop::store::{CallOutcome, FrameSpec, HistoryStore, NewCall, NewMessage, NewSummary, Role};
|
||||
use agent_loop::tool::ToolOutput;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use core_api::message_meta::{Attachment, MessageMetadata};
|
||||
use core_api::user_fs::UserFs;
|
||||
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::llm::DtlMode;
|
||||
use crate::loop_adapters::activation::SkaldActivationSource;
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::projection_cfg::skald_assembler;
|
||||
use crate::loop_adapters::selector::tool_rendering_of;
|
||||
use crate::loop_adapters::system::AgentSystemContext;
|
||||
use crate::mcp::{McpProvider, McpTool};
|
||||
use crate::tools::{ToolResult, tool_names as tn};
|
||||
|
||||
pub const AGENT_PROMPT: &str = "You are the parity fixture agent."; // frozen: the snapshots contain it
|
||||
pub const EXTRA_STATIC: &str = "FORMAT RULES";
|
||||
pub const EXTRA_DYNAMIC: &str = "MEMORY BLOCK";
|
||||
pub const REMINDER: &str = "REMEMBER THE RULES";
|
||||
pub const HISTORY_LIMIT: usize = 100;
|
||||
pub const TOOL_RESULT_LIMIT: usize = 40;
|
||||
|
||||
// ── fixture plumbing ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn unique(tag: &str) -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!("{tag}-{}-{nanos}", std::process::id())
|
||||
}
|
||||
|
||||
/// The scenarios share one cwd-relative directory (`agents/`, see
|
||||
/// [`AgentFixture`]), so they run one at a time: a fixture torn down while a
|
||||
/// sibling is mid-projection would fail it spuriously.
|
||||
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// An `agents/<id>/` directory, since `crate::agents` resolves agents relative
|
||||
/// to the process cwd and the projection loads the prompt through it. Removed
|
||||
/// on drop, so a panicking test does not leave it behind.
|
||||
pub struct AgentFixture {
|
||||
pub id: String,
|
||||
dir: PathBuf,
|
||||
/// Held for the fixture's lifetime (see [`SERIAL`]). Poisoning is expected:
|
||||
/// a failing scenario panics while holding it, and the next may proceed.
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl AgentFixture {
|
||||
pub fn new() -> Self {
|
||||
let _lock = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let id = unique("parity-agent");
|
||||
let dir = Path::new("agents").join(&id);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("AGENT.md"), AGENT_PROMPT).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("meta.json"),
|
||||
json!({
|
||||
"name": "Parity fixture",
|
||||
"description": "projection parity",
|
||||
"type": "task",
|
||||
"inject_skills": false,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
Self { id, dir, _lock }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgentFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// An owner database with one session and its root frame.
|
||||
pub struct Db {
|
||||
pub pool: Arc<SqlitePool>,
|
||||
pub store: Arc<dyn HistoryStore>,
|
||||
pub frame: FrameId,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn new(tag: &str) -> Self {
|
||||
let path = std::env::temp_dir().join(format!("{}.db", unique(tag)));
|
||||
let pool = Arc::new(crate::db::create_user_pool(&path, None).await.unwrap());
|
||||
sqlx::query("INSERT INTO chat_sessions (id, title) VALUES (1, 'parity')")
|
||||
.execute(&*pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
|
||||
let frame = store
|
||||
.open_frame(&ConversationId::new("session:1"), None, FrameSpec::root("parity"))
|
||||
.await
|
||||
.unwrap();
|
||||
Self { pool, store, frame, path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Db {
|
||||
fn drop(&mut self) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", self.path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeMcp {
|
||||
tools: Vec<McpTool>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpProvider for FakeMcp {
|
||||
fn tools(&self) -> Vec<McpTool> {
|
||||
self.tools.clone()
|
||||
}
|
||||
fn tools_for(&self, names: &[String]) -> Vec<McpTool> {
|
||||
self.tools
|
||||
.iter()
|
||||
.filter(|t| names.contains(&t.server_name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
fn server_descriptions(&self) -> HashMap<String, Option<String>> {
|
||||
HashMap::new()
|
||||
}
|
||||
fn server_infos(&self) -> Vec<Value> {
|
||||
Vec::new()
|
||||
}
|
||||
fn tool_display_name(&self, _server: &str, _tool: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn call(&self, _s: &str, _t: &str, _a: Value) -> anyhow::Result<ToolResult> {
|
||||
unimplemented!("the projection never calls a tool")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mcp() -> Arc<dyn McpProvider> {
|
||||
Arc::new(FakeMcp {
|
||||
tools: vec![McpTool {
|
||||
server_name: "gmail".into(),
|
||||
name: "send".into(),
|
||||
description: "send mail".into(),
|
||||
input_schema: json!({ "type": "object" }),
|
||||
title: None,
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
task_support: None,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// The datetime block is disabled: it embeds `now()`, which no snapshot can
|
||||
/// pin down.
|
||||
pub fn datetime() -> DatetimeConfig {
|
||||
DatetimeConfig { enabled: false, round_minutes: None, timezone: None }
|
||||
}
|
||||
|
||||
/// The base tool definitions the projection is handed.
|
||||
pub fn config_defs() -> Arc<Vec<Value>> {
|
||||
Arc::new(vec![json!({
|
||||
"type": "function",
|
||||
"function": { "name": "config_get", "parameters": { "type": "object" } }
|
||||
})])
|
||||
}
|
||||
|
||||
/// What the projection is run with, so a difference can only come from the
|
||||
/// stored state.
|
||||
pub struct Case {
|
||||
pub dtl: DtlMode,
|
||||
pub cache_hints: bool,
|
||||
pub capabilities: Vec<String>,
|
||||
pub fs: Option<Arc<UserFs>>,
|
||||
}
|
||||
|
||||
impl Default for Case {
|
||||
fn default() -> Self {
|
||||
Self { dtl: DtlMode::None, cache_hints: false, capabilities: Vec::new(), fs: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects the seeded state into the wire messages a model would receive.
|
||||
pub async fn project(db: &Db, agent: &AgentFixture, case: &Case) -> Vec<Value> {
|
||||
let config_defs = config_defs();
|
||||
|
||||
let system_source = AgentSystemContext {
|
||||
agent_id: agent.id.clone(),
|
||||
extra_static: Some(EXTRA_STATIC.to_string()),
|
||||
extra_dynamic: Some(EXTRA_DYNAMIC.to_string()),
|
||||
tail_reminder: Some(REMINDER.to_string()),
|
||||
substitutions: HashMap::new(),
|
||||
pool: db.pool.clone(),
|
||||
shared_pool: db.pool.clone(),
|
||||
user_id: "u1".into(),
|
||||
mcp: mcp(),
|
||||
project_root: None,
|
||||
scratchpad_sid: 1,
|
||||
datetime: datetime(),
|
||||
};
|
||||
let system = system_source
|
||||
.system_context(&TurnInfo {
|
||||
conversation: ConversationId::new("session:1"),
|
||||
frame: db.frame,
|
||||
agent: agent.id.clone(),
|
||||
user_message: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let assembler = skald_assembler(
|
||||
Arc::new(SkaldActivationSource::new(
|
||||
db.pool.clone(),
|
||||
mcp(),
|
||||
config_defs.clone(),
|
||||
1,
|
||||
None,
|
||||
)),
|
||||
case.fs.clone(),
|
||||
HISTORY_LIMIT,
|
||||
// `compaction_enabled: false` mirrors the builder's `compactor: None`.
|
||||
false,
|
||||
Some(TOOL_RESULT_LIMIT),
|
||||
);
|
||||
assembler
|
||||
.build(&db.store, &AssembleInput {
|
||||
frame: db.frame,
|
||||
system,
|
||||
model: ModelInfo {
|
||||
prompt_cache: case.cache_hints,
|
||||
capabilities: case.capabilities.clone(),
|
||||
tool_rendering: tool_rendering_of(case.dtl),
|
||||
extras: Value::Null,
|
||||
},
|
||||
round: 0,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Compares message by message, so a failure names the first divergence instead
|
||||
/// of dumping two arrays.
|
||||
pub fn assert_same(expected: &[Value], actual: &[Value], label: &str) {
|
||||
for (i, (e, a)) in expected.iter().zip(actual.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
e,
|
||||
a,
|
||||
"{label}: message {i} diverges\n expected: {}\n actual: {}",
|
||||
serde_json::to_string_pretty(e).unwrap(),
|
||||
serde_json::to_string_pretty(a).unwrap()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
expected.len(),
|
||||
actual.len(),
|
||||
"{label}: message COUNT diverges ({} expected vs {} actual); first extra: {:?}",
|
||||
expected.len(),
|
||||
actual.len(),
|
||||
expected
|
||||
.get(actual.len().min(expected.len()))
|
||||
.or_else(|| actual.get(expected.len().min(actual.len()))),
|
||||
);
|
||||
}
|
||||
|
||||
// ── snapshots ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Set to `1` to rewrite the stored arrays from the current projection. Review
|
||||
/// the diff: a snapshot changing means the bytes a model receives changed.
|
||||
pub const UPDATE_ENV: &str = "UPDATE_PROJECTION_SNAPSHOTS";
|
||||
|
||||
pub fn snapshot_path(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/loop_adapters/snapshots")
|
||||
.join(format!("{name}.json"))
|
||||
}
|
||||
|
||||
/// Asserts `actual` against the stored array, or rewrites it under [`UPDATE_ENV`].
|
||||
pub fn assert_snapshot(name: &str, actual: &[Value]) {
|
||||
let path = snapshot_path(name);
|
||||
if std::env::var(UPDATE_ENV).as_deref() == Ok("1") {
|
||||
write_snapshot(name, actual);
|
||||
return;
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"missing snapshot {}: {e}\nrun with {UPDATE_ENV}=1 to create it",
|
||||
path.display()
|
||||
)
|
||||
});
|
||||
let expected: Vec<Value> = serde_json::from_str(&raw).unwrap();
|
||||
assert_same(&expected, actual, name);
|
||||
}
|
||||
|
||||
/// Writes the stored array (pretty, newline-terminated: it is reviewed as a diff).
|
||||
pub fn write_snapshot(name: &str, value: &[Value]) {
|
||||
let path = snapshot_path(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut json = serde_json::to_string_pretty(value).unwrap();
|
||||
json.push('\n');
|
||||
std::fs::write(&path, json).unwrap();
|
||||
}
|
||||
|
||||
// ── scenarios: the seeded state ──────────────────────────────────────────────
|
||||
//
|
||||
// One function per scenario: the state, separate from what is asserted about it.
|
||||
|
||||
/// A plain exchange, including the two consecutive user rows that exercise the
|
||||
/// coalescing rule.
|
||||
pub async fn seed_plain(db: &Db) {
|
||||
db.store.append(db.frame, NewMessage::user("hello")).await.unwrap();
|
||||
db.store
|
||||
.append(db.frame, NewMessage::assistant("hi there", Some("thinking".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
db.store.append(db.frame, NewMessage::user("one")).await.unwrap();
|
||||
db.store.append(db.frame, NewMessage::user("two")).await.unwrap();
|
||||
}
|
||||
|
||||
pub async fn seed_scratchpad(db: &Db) {
|
||||
crate::db::scratchpad::upsert(&db.pool, 1, "plan", "step one").await.unwrap();
|
||||
db.store.append(db.frame, NewMessage::user("go")).await.unwrap();
|
||||
}
|
||||
|
||||
/// One assistant turn with a call in each terminal state.
|
||||
pub async fn seed_tool_round(db: &Db) {
|
||||
db.store.append(db.frame, NewMessage::user("work")).await.unwrap();
|
||||
let msg = db.store.append(db.frame, NewMessage::assistant("calling", None)).await.unwrap();
|
||||
|
||||
let done = db
|
||||
.store
|
||||
.append_call(msg, NewCall::new("read_file", json!({ "path": "a.md" })))
|
||||
.await
|
||||
.unwrap();
|
||||
db.store
|
||||
.resolve_call(done, &CallOutcome::Completed(ToolOutput::Text("content".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let failed = db.store.append_call(msg, NewCall::new("write_file", json!({}))).await.unwrap();
|
||||
db.store.resolve_call(failed, &CallOutcome::Failed("disk full".into())).await.unwrap();
|
||||
|
||||
let rejected = db.store.append_call(msg, NewCall::new("execute_cmd", json!({}))).await.unwrap();
|
||||
db.store
|
||||
.resolve_call(rejected, &CallOutcome::Rejected { reason: "no".into() })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cancelled = db.store.append_call(msg, NewCall::new("glob", json!({}))).await.unwrap();
|
||||
db.store.resolve_call(cancelled, &CallOutcome::Cancelled).await.unwrap();
|
||||
}
|
||||
|
||||
/// A call left `running`, exactly as a crash leaves it.
|
||||
pub async fn seed_interrupted(db: &Db) {
|
||||
db.store.append(db.frame, NewMessage::user("run it")).await.unwrap();
|
||||
let msg = db.store.append(db.frame, NewMessage::assistant("running", None)).await.unwrap();
|
||||
db.store
|
||||
.append_call(msg, NewCall::new("execute_cmd", json!({ "command": "sleep 100" })))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Two turns with an over-limit result each: only the first is condensed.
|
||||
pub async fn seed_condensed(db: &Db) {
|
||||
for (q, path) in [("first", "big.txt"), ("second", "other.txt")] {
|
||||
db.store.append(db.frame, NewMessage::user(q)).await.unwrap();
|
||||
let msg = db.store.append(db.frame, NewMessage::assistant("reading", None)).await.unwrap();
|
||||
let call = db
|
||||
.store
|
||||
.append_call(msg, NewCall::new("read_file", json!({ "path": path })))
|
||||
.await
|
||||
.unwrap();
|
||||
db.store
|
||||
.resolve_call(
|
||||
call,
|
||||
&CallOutcome::Completed(ToolOutput::Text("x".repeat(TOOL_RESULT_LIMIT * 3))),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn seed_summary(db: &Db) {
|
||||
let m1 = db.store.append(db.frame, NewMessage::user("ancient")).await.unwrap();
|
||||
db.store.append(db.frame, NewMessage::assistant("old reply", None)).await.unwrap();
|
||||
db.store.append(db.frame, NewMessage::user("recent")).await.unwrap();
|
||||
db.store
|
||||
.save_summary(db.frame, NewSummary {
|
||||
text: "Earlier they discussed ancient things.".into(),
|
||||
covered_up_to: m1,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An activation round: an unrelated call first, so the DTL marker has a wrong
|
||||
/// place to land if the anchor rule regresses.
|
||||
pub async fn seed_activation(db: &Db) {
|
||||
db.store.append(db.frame, NewMessage::user("use gmail")).await.unwrap();
|
||||
let anchor = db
|
||||
.store
|
||||
.append(db.frame, NewMessage::assistant("activating", None))
|
||||
.await
|
||||
.unwrap();
|
||||
let other = db.store.append_call(anchor, NewCall::new("read_file", json!({}))).await.unwrap();
|
||||
db.store
|
||||
.resolve_call(other, &CallOutcome::Completed(ToolOutput::Text("f".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
let act = db
|
||||
.store
|
||||
.append_call(anchor, NewCall::new(tn::ACTIVATE_TOOLS, json!({ "groups": ["gmail"] })))
|
||||
.await
|
||||
.unwrap();
|
||||
db.store
|
||||
.resolve_call(act, &CallOutcome::Completed(ToolOutput::Text("activated".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::activated_tools::grant(&db.pool, 1, None, anchor.get(), "mcp", "gmail")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A real PNG under the caller's uploads dir, plus the `UserFs` that authorizes
|
||||
/// it. Removed on drop.
|
||||
pub struct MediaHome {
|
||||
root: PathBuf,
|
||||
pub fs: Arc<UserFs>,
|
||||
}
|
||||
|
||||
impl MediaHome {
|
||||
pub fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(unique("parity-home"));
|
||||
let uploads = root.join("uploads/1");
|
||||
std::fs::create_dir_all(&uploads).unwrap();
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
png.extend_from_slice(&[0xAA; 64]);
|
||||
std::fs::write(uploads.join("shot.png"), png).unwrap();
|
||||
let fs = Arc::new(UserFs::new(
|
||||
"u1",
|
||||
root.clone(),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
));
|
||||
Self { root, fs }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MediaHome {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same attachment on an older turn (textual path) and on the current one
|
||||
/// (inlined when the model can see it).
|
||||
pub async fn seed_media(db: &Db) {
|
||||
let meta = MessageMetadata {
|
||||
attachments: vec![Attachment {
|
||||
path: "uploads/1/shot.png".into(),
|
||||
name: "shot.png".into(),
|
||||
mimetype: Some("image/png".into()),
|
||||
filesize: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let with_attachment = |content: &str| NewMessage {
|
||||
role: Role::User,
|
||||
content: content.to_string(),
|
||||
synthetic: false,
|
||||
reasoning: None,
|
||||
metadata: Some(serde_json::to_value(&meta).unwrap()),
|
||||
};
|
||||
|
||||
db.store.append(db.frame, with_attachment("old shot")).await.unwrap();
|
||||
db.store.append(db.frame, NewMessage::assistant("seen", None)).await.unwrap();
|
||||
db.store.append(db.frame, with_attachment("new shot")).await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! `SkaldDigest` — how an over-long tool result is condensed
|
||||
//! (`agent_loop::projection::ToolResultDigest`).
|
||||
//!
|
||||
//! The crate decides *when* a result is too long (its `ResultLimit` gate, which
|
||||
//! only shrinks turns the agent has already moved past); this decides *what to
|
||||
//! say instead*, and that needs to know what each tool does — so it lives here,
|
||||
//! next to the tools, not in the library.
|
||||
//!
|
||||
//! The replacement is always one informative line: the model must be able to
|
||||
//! tell that a call succeeded and on what, without re-reading its output.
|
||||
|
||||
use agent_loop::projection::ToolResultDigest;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session::handler::preview_truncate;
|
||||
use crate::tools::tool_names as tn;
|
||||
|
||||
pub struct SkaldDigest;
|
||||
|
||||
#[agent_loop::async_trait]
|
||||
impl ToolResultDigest for SkaldDigest {
|
||||
async fn condense(&self, name: &str, args: &Value, result: &str) -> Option<String> {
|
||||
Some(summarize_tool_result(name, args, result))
|
||||
}
|
||||
}
|
||||
|
||||
/// An informative 1-line summary of a tool call result.
|
||||
pub fn summarize_tool_result(tool_name: &str, arguments: &Value, result: &str) -> String {
|
||||
let args = arguments;
|
||||
|
||||
let char_count = result.len();
|
||||
let line_count = if result.trim().is_empty() { 0 } else { result.lines().count() };
|
||||
|
||||
fn arg_str<'a>(args: &'a Value, key: &str) -> &'a str {
|
||||
args[key].as_str().unwrap_or("?")
|
||||
}
|
||||
|
||||
match tool_name {
|
||||
tn::EXECUTE_CMD => {
|
||||
let cmd = args["command"].as_str().unwrap_or("");
|
||||
let cmd_display = preview_truncate(cmd, 77);
|
||||
let exit_code = result
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|l| l.strip_prefix("exit: "))
|
||||
.unwrap_or("?");
|
||||
format!("[execute_cmd] ran `{cmd_display}` → exit {exit_code}, {line_count} lines output")
|
||||
}
|
||||
|
||||
"read_file" | "read_file_chunk" => {
|
||||
let path = arg_str(args, "path");
|
||||
format!("[{tool_name}] read {path} ({char_count} chars)")
|
||||
}
|
||||
|
||||
"write_file" => {
|
||||
let path = arg_str(args, "path");
|
||||
format!("[write_file] wrote to {path}")
|
||||
}
|
||||
|
||||
"edit_file" | "patch_file" => {
|
||||
let path = arg_str(args, "path");
|
||||
format!("[{tool_name}] edited {path}")
|
||||
}
|
||||
|
||||
"list_dir" | "glob" => {
|
||||
let path = args["path"].as_str()
|
||||
.or_else(|| args["pattern"].as_str())
|
||||
.unwrap_or("?");
|
||||
format!("[{tool_name}] {path} ({char_count} chars)")
|
||||
}
|
||||
|
||||
"list_items" => {
|
||||
let kind = arg_str(args, "type");
|
||||
format!("[list_items] {kind} ({char_count} chars)")
|
||||
}
|
||||
|
||||
"toggle_item" => {
|
||||
let kind = arg_str(args, "kind");
|
||||
let id = arg_str(args, "id");
|
||||
let enabled = args["enabled"].as_bool().unwrap_or(false);
|
||||
format!("[toggle_item] {kind} '{id}' → {}", if enabled { "enabled" } else { "disabled" })
|
||||
}
|
||||
|
||||
tn::READ_NOTIFICATION => {
|
||||
let count = serde_json::from_str::<Vec<Value>>(result)
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
format!("[read_notification] {count} notification(s)")
|
||||
}
|
||||
|
||||
tn::EXECUTE_TASK | tn::EXECUTE_SUBTASK => {
|
||||
let agent = arg_str(args, "agent_id");
|
||||
format!("[{tool_name}] → {agent} ({char_count} chars result)")
|
||||
}
|
||||
|
||||
tn::ACTIVATE_TOOLS => {
|
||||
let groups = args["groups"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!("[activate_tools] loaded: {groups}")
|
||||
}
|
||||
|
||||
_ if tool_name.starts_with("mcp__") => {
|
||||
format!("[{tool_name}] ({char_count} chars result)")
|
||||
}
|
||||
|
||||
_ => {
|
||||
let first_arg = args.as_object()
|
||||
.and_then(|m| m.iter().next())
|
||||
.map(|(k, v)| {
|
||||
let sv = preview_truncate(v.as_str().unwrap_or_default(), 40);
|
||||
format!(" {k}={sv}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("[{tool_name}]{first_arg} ({char_count} chars result)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn execute_cmd_reports_the_command_exit_code_and_size() {
|
||||
let s = summarize_tool_result(
|
||||
tn::EXECUTE_CMD,
|
||||
&json!({ "command": "ls -la /tmp" }),
|
||||
"exit: 0\nfile a\nfile b",
|
||||
);
|
||||
assert_eq!(s, "[execute_cmd] ran `ls -la /tmp` → exit 0, 3 lines output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tools_report_the_path() {
|
||||
assert_eq!(
|
||||
summarize_tool_result("read_file", &json!({ "path": "notes.md" }), "0123456789"),
|
||||
"[read_file] read notes.md (10 chars)"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_tool_result("write_file", &json!({ "path": "a.txt" }), "ok"),
|
||||
"[write_file] wrote to a.txt"
|
||||
);
|
||||
// A missing argument degrades, never panics.
|
||||
assert_eq!(
|
||||
summarize_tool_result("edit_file", &json!({}), "ok"),
|
||||
"[edit_file] edited ?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_agent_and_activation_calls_name_their_target() {
|
||||
assert_eq!(
|
||||
summarize_tool_result(tn::EXECUTE_TASK, &json!({ "agent_id": "researcher" }), "abc"),
|
||||
"[execute_task] → researcher (3 chars result)"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_tool_result(tn::ACTIVATE_TOOLS, &json!({ "groups": ["gmail", "config"] }), ""),
|
||||
"[activate_tools] loaded: gmail, config"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tools_fall_back_to_the_first_argument() {
|
||||
assert_eq!(
|
||||
summarize_tool_result("mcp__gmail__send", &json!({ "to": "x@y.z" }), "sent"),
|
||||
"[mcp__gmail__send] (4 chars result)"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_tool_result("weird_tool", &json!({ "q": "hello" }), "res"),
|
||||
"[weird_tool] q=hello (3 chars result)"
|
||||
);
|
||||
assert_eq!(
|
||||
summarize_tool_result("weird_tool", &json!({}), "res"),
|
||||
"[weird_tool] (3 chars result)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! The `LoopEvent → ServerEvent` translator (blueprint §10): ONE subscriber of
|
||||
//! the loop manager's bus, forwarding to the session's WS channel with the
|
||||
//! host enrichments the frontend expects (display meta, diff previews, file
|
||||
//! changes). Byte-parity with the old `TurnEmitter` sequence is the contract.
|
||||
//! changes). Byte-parity with the pre-kernel event sequence is the contract.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::events::{DeltaKind, Event, LoopEvent};
|
||||
use agent_loop::ids::ConversationId;
|
||||
use agent_loop::store::{CallOutcome, HistoryStore};
|
||||
use core_api::message_meta::MessageMetadata;
|
||||
use serde_json::Value;
|
||||
@@ -15,12 +16,17 @@ use crate::events::{ServerEvent, TokenDeltaKind};
|
||||
use crate::mcp::McpProvider;
|
||||
use crate::tools::{ToolRegistry, is_file_write_tool};
|
||||
|
||||
/// Forwards one conversation's loop events to the session's WS `tx`.
|
||||
/// Forwards ONE conversation's loop events to that session's WS `tx`.
|
||||
///
|
||||
/// The bus is per **user** (one `LoopManager` per owner), so every session of
|
||||
/// that user sees every other session's events: the `conv` filter is what keeps
|
||||
/// them apart, not an accident of wiring.
|
||||
pub struct EventTranslator {
|
||||
tx: mpsc::Sender<ServerEvent>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
tx: mpsc::Sender<ServerEvent>,
|
||||
conv: ConversationId,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
shared: Arc<std::sync::Mutex<TranslateShared>>,
|
||||
}
|
||||
|
||||
@@ -37,29 +43,48 @@ pub struct TranslateShared {
|
||||
impl EventTranslator {
|
||||
pub fn new(
|
||||
tx: mpsc::Sender<ServerEvent>,
|
||||
conv: ConversationId,
|
||||
tools: Arc<ToolRegistry>,
|
||||
mcp: Arc<dyn McpProvider>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
) -> (Self, Arc<std::sync::Mutex<TranslateShared>>) {
|
||||
let shared = Arc::new(std::sync::Mutex::new(TranslateShared::default()));
|
||||
(Self { tx, tools, mcp, store, shared: shared.clone() }, shared)
|
||||
(Self { tx, conv, tools, mcp, store, shared: shared.clone() }, shared)
|
||||
}
|
||||
|
||||
/// Subscribe and forward until `stop` is cancelled (the turn's end).
|
||||
pub fn spawn(self, mut rx: tokio::sync::broadcast::Receiver<Event<LoopEvent>>, stop: tokio_util::sync::CancellationToken) -> tokio::task::JoinHandle<()> {
|
||||
/// Subscribe and forward until `stop` is cancelled — then **drain what is
|
||||
/// already buffered** before exiting.
|
||||
///
|
||||
/// The caller cancels `stop` right after the turn joins, at which point the
|
||||
/// kernel's last events (`Done`, the final `ToolDone`) are in the channel
|
||||
/// but may not have been forwarded yet. Exiting on the token alone would
|
||||
/// drop them, and the frontend treats `Done` as the turn's truth — the
|
||||
/// pending bubble would hang forever. Hence: `recv` wins the select, and the
|
||||
/// stop branch drains before breaking.
|
||||
pub fn spawn(
|
||||
self,
|
||||
mut rx: tokio::sync::broadcast::Receiver<Event<LoopEvent>>,
|
||||
stop: tokio_util::sync::CancellationToken,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop.cancelled() => break,
|
||||
ev = rx.recv() => {
|
||||
match ev {
|
||||
Ok(ev) => self.forward(ev).await,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(skipped = n, "event translator lagged; some events were dropped");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
let ev = tokio::select! {
|
||||
biased;
|
||||
ev = rx.recv() => ev,
|
||||
_ = stop.cancelled() => {
|
||||
// Drain the tail, then done.
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
self.forward(ev).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
match ev {
|
||||
Ok(ev) => self.forward(ev).await,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(skipped = n, "event translator lagged; some events were dropped");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -70,6 +95,10 @@ impl EventTranslator {
|
||||
}
|
||||
|
||||
pub async fn forward(&self, ev: Event<LoopEvent>) {
|
||||
// Another session of the same user: not ours to report.
|
||||
if ev.conversation != self.conv {
|
||||
return;
|
||||
}
|
||||
let is_root = ev.parent_frame.is_none();
|
||||
match ev.inner {
|
||||
LoopEvent::TurnStarted | LoopEvent::RoundStarted { .. } | LoopEvent::AsyncResultReady { .. } => {}
|
||||
|
||||
Reference in New Issue
Block a user