agent-loop: projection, recovery, compaction into the crate (phase 3)
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:
2026-07-26 17:09:01 +01:00
parent 3fca7867fa
commit 24ee5b89d7
74 changed files with 7661 additions and 5982 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ fn resolve_includes(content: &str) -> Result<String> {
} else if trimmed == "<!-- AGENTS_LIST -->" {
out.push_str(&render_agents_list()?);
} else if trimmed == "<!-- MCP_LIST -->" {
// Replaced at request time in build_openai_messages with dynamic
// Replaced at request time by the system-context source with dynamic
// active/hidden sections. Leave a sentinel so the injection point
// is preserved and positioned correctly in the prompt.
out.push_str("__MCP_LIST__\n");
+1 -1
View File
@@ -10,7 +10,7 @@
//! pile up while the turn runs are drained, one row each, at the turn's round
//! boundaries (`drain_leading_user`) and injected live into the running turn.
//! Coalescing for the LLM (merging consecutive user rows into one `role:user`)
//! happens later in the `MessageBuilder`, not here, so the DB keeps each message
//! happens later in the projection, not here, so the DB keeps each message
//! distinct while the model still sees a single clean user turn.
//!
//! Serialization of the turns themselves still lives in
+33 -6
View File
@@ -18,7 +18,9 @@ use crate::cron::TaskManager;
use crate::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, config, sources};
use crate::events::{GlobalEvent, ServerEvent};
use crate::notification::Notification;
use crate::session::handler::{ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput};
use crate::session::handler::{
ApprovalDecision, ChatSessionHandler, InterfaceTool, PendingMsg, PendingUserInput,
};
use crate::session::manager::ChatSessionManager;
use crate::tools::tool_names as tn;
@@ -370,7 +372,7 @@ impl ChatHub {
}
/// Resume any interrupted turn for a source's active session.
/// Calls `resume_turn` which re-executes pending tool calls (approval or
/// Calls `recover_turn`, which re-executes pending tool calls (approval or
/// clarification) and re-runs the LLM loop if needed.
/// Safe to call unconditionally — returns immediately if there is nothing to resume.
/// Events are published to the global broadcast bus so existing subscribers
@@ -382,7 +384,7 @@ impl ChatHub {
};
// Guard against double-driving. A client sends `resume` on connect whenever
// history shows a pending/interrupted tool — including when the turn is still
// live and merely awaiting an approval. Without this check `resume_turn` would
// live and merely awaiting an approval. Without this check the recovery would
// block on the `processing` lock and, once the approval unblocks the original
// turn and it finishes, run a spurious *second* turn on the just-completed
// conversation. If a turn is already in flight it owns the session and emits
@@ -408,11 +410,36 @@ impl ChatHub {
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
let interface_tools = self.execute_task_tools(session_id, &handler).await;
handler.resume_turn(None, None, interface_tools, tx).await
handler.recover_turn(interface_tools, tx).await
}
/// Apply a human decision to a tool call nothing is waiting on anymore (an
/// approval answered after a restart), then continue the conversation.
/// Events reach the reconnected client through the global bus, as for
/// [`Self::resume_session`].
pub async fn resolve_pending_call(
&self,
session_id: i64,
call: i64,
decision: ApprovalDecision,
) -> anyhow::Result<()> {
let decision = match decision {
ApprovalDecision::Approved => agent_loop::recovery::HumanDecision::Approved,
ApprovalDecision::Rejected { note } => agent_loop::recovery::HumanDecision::Rejected {
reason: ApprovalDecision::rejection_message(&note),
},
};
let source = chat_sessions::find_by_id(&self.db, session_id).await?
.map(|s| s.source)
.unwrap_or_else(|| "web".to_string());
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
let interface_tools = self.execute_task_tools(session_id, &handler).await;
handler.resolve_pending_call(call, decision, interface_tools, tx).await
}
/// Builds the `execute_task` interface tool for a session, mirroring the injection
/// done for live turns (`run_agent_turn`). Empty when no TaskManager is configured
/// done for live turns. Empty when no TaskManager is configured
/// so `execute_task mode=async` can be rebuilt by `build_execution` during resume.
async fn execute_task_tools(
&self,
@@ -728,7 +755,7 @@ impl ChatHub {
let count = notes.len();
// Build a synthetic assistant message with a reasoning trace and a
// pre-completed read_notification tool call carrying the notifications as results.
// The agent is then woken via resume() — resume_turn sees the tool calls on
// The agent is then woken via resume() — recovery sees the tool calls on
// the last assistant message and runs the LLM loop so the agent can respond.
let result_json = serde_json::to_string(&notes).unwrap_or_else(|_| "[]".to_string());
+83 -425
View File
@@ -1,61 +1,52 @@
//! Context compaction — reduces LLM context size by summarising old messages.
//!
//! # Responsibility
//! [`ContextCompactor`] is a stateless service (all state lives in the DB).
//! It is shared via `Arc` across all [`ChatSessionHandler`]s.
//! [`ContextCompactor`] is Skald's **policy**: when to compact (the token
//! threshold, the ephemeral guard), which model summarises, and telling the
//! rest of the app it happened. The mechanics — split point, transcript,
//! prompt, the summariser call, the saved row — are the library's
//! (`agent_loop::compaction`), so a compaction is the same operation whether
//! Skald or another host triggers it.
//!
//! It is triggered **at the start of a turn** when the previous turn's
//! `input_tokens` exceeds the configured threshold (Opzione C from the design
//! doc), or manually via `force_compact`. Ephemeral sessions (cron, tic)
//! It is a stateless service (all state lives in the DB), shared via `Arc`
//! across every [`ChatSessionHandler`](crate::session::handler). Triggered at
//! the **start of a turn** when the previous turn's `input_tokens` exceeded the
//! threshold, or manually via `force_compact`. Ephemeral sessions (cron, tic)
//! are always skipped.
//!
//! # Compaction flow
//! ```text
//! handle_message()
//! └─► ContextCompactor::try_compact(pool, stack_id, last_input_tokens)
//!
//! ├─ guard: tokens < threshold → return Ok(false)
//! ├─ guard: is_ephemeral → return Ok(false)
//!
//! └─► do_compact(pool, session_id, stack_id, effective_tokens)
//! ├─ load latest summary (if any)
//! load raw messages since last summary boundary
//! │ (or all messages if no prior summary)
//! ├─ split: to_summarise = messages[0 .. len - keep_recent]
//! │ to_keep_raw = messages[len - keep_recent ..]
//! ├─ if to_summarise is empty → return Ok(false)
//! ├─ build compaction prompt (system hard-coded + user = conversation text)
//! ├─ call LLM (no tools, strength-based AUTO selection)
//! ├─ save summary to chat_summaries
//! └─ publish BusEvent::CompactionDone
//!
//! force_compact() skips the threshold guard and calls do_compact() directly.
//! └─► ContextCompactor::try_compact(manager, …, last_input_tokens)
//! ├─ guard: is_ephemeral → Ok(false)
//! ├─ guard: tokens (or estimate) < threshold → Ok(false)
//! └─► manager.new_compaction(conv, frame).run()
//! ├─ split at the keep_recent boundary, on a user/agent message
//! ├─ summarise (one call, no tools)
//! ├─ save the summary row
//! hooks.on_compacted → DTL re-anchor (loop_adapters::hooks)
//! ```
//!
//! # build_openai_messages after compaction
//! ```text
//! latest_summary = chat_summaries::latest_for_stack(pool, stack_id)
//! if let Some(s) = latest_summary:
//! inject <summary>…</summary> after system prompt
//! load messages with id > s.covers_up_to_message_id
//! else:
//! load all messages (current behaviour)
//! apply max_history_messages drain as safety floor (only when compaction is disabled)
//! ```
//! The next turn needs nothing from this: the assembler reads the latest
//! summary from the store and projects it in front of the surviving messages.
use std::sync::Arc;
use agent_loop::compaction::{CompactionMode, should_compact};
use agent_loop::manager::LoopManager;
use agent_loop::model::ModelHint;
use serde_json::json;
use sqlx::SqlitePool;
use tracing::{debug, info, warn};
use tracing::{info, warn};
use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::chat_event_bus::{ChatEventBus, CompactionEvent};
use crate::config::CompactionConfig;
use crate::config_store::GlobalConfigManager;
use crate::db::{chat_history, chat_llm_tools, chat_summaries};
use crate::db::chat_history;
use crate::llm::LlmManager;
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::selector::SkaldSelector;
/// Registry `config` key holding the name of the LLM model to use for
/// compaction summaries. Set from the Settings page (instance-wide); empty /
@@ -82,105 +73,14 @@ pub fn config_set() -> ConfigSet {
}
}
// ── Compaction constants (ported from Hermes context_compressor.py) ──────────
//
// SUMMARY_PREFIX — prepended to every stored summary when injected as context.
// Tells the LLM this is historical reference, not live instructions.
// SUMMARIZER_PREAMBLE — system/user-message preamble for the summarisation LLM call.
// SUMMARY_TEMPLATE — structured section template the LLM must follow.
// ── The summariser's wording ─────────────────────────────────────────────────
/// Prefix prepended to the summary content when it is injected into the
/// message array as context for the main agent. Exposed as `pub` so that
/// `build_openai_messages` can use the same wording.
pub const SUMMARY_PREFIX: &str = "\
[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:";
/// Prefix prepended to a stored summary when it is projected back into the
/// context. Re-exported from the library, which owns the wording along with the
/// preamble and the section template: the assembler on the other side of the
/// projection reads the same constant, so the two can never drift.
pub use agent_loop::compaction::SUMMARY_PREFIX;
/// Preamble shared by both first-compaction and iterative-update prompts.
/// Wording is deliberately plain to avoid content-filter false positives.
const SUMMARIZER_PREAMBLE: &str = "\
You are a summarization agent creating a context checkpoint. \
Treat the conversation turns below as source material for a \
compact record of prior work. \
Produce only the structured summary; do not add a greeting, \
preamble, or prefix. \
Write the summary in the same language the user was using in the \
conversation — do not translate or switch to English. \
NEVER include API keys, tokens, passwords, secrets, credentials, \
or connection strings in the summary — replace any that appear \
with [REDACTED]. Note that the user may have had credentials present, \
but do not preserve their values.";
/// Structured section template the summariser must fill in.
const SUMMARY_TEMPLATE: &str = "\
## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or \
task assignment verbatim — the exact words they used. If multiple tasks \
were requested and only some are done, list only the ones NOT yet completed. \
Continuation should pick up exactly here. Example: \
\"User asked: 'Now refactor the auth module to use JWT instead of sessions'\" \
If no outstanding task exists, write \"None.\"]
## Goal
[What the user is trying to accomplish overall]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Completed Actions
[Numbered list of concrete actions taken — include tool used, target, and outcome.
Format each as: N. ACTION target — outcome [tool: name]
Example:
1. READ config.rs:45 — found == should be != [tool: read_file]
2. EDIT config.rs:45 — changed == to != [tool: write_file]
3. BUILD `cargo build` — succeeded, 0 errors [tool: execute_cmd]
Be specific with file paths, commands, line numbers, and results.]
## Active State
[Current working state — include:
- Working directory and branch (if applicable)
- Modified/created files with brief note on each
- Build/test status
- Any running processes or servers
- Environment details that matter]
## In Progress
[Work currently underway — what was being done when compaction fired]
## Blocked
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
## Key Decisions
[Important technical decisions and WHY they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write \"None.\"]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would \
be lost without explicit preservation. NEVER include API keys, tokens, passwords, \
or credentials — write [REDACTED] instead.]
Write only the summary body. Do not include any preamble or prefix.";
// ── Public API ────────────────────────────────────────────────────────────────
@@ -211,6 +111,7 @@ impl ContextCompactor {
/// Returns `true` if a new summary was written, `false` if skipped.
pub async fn try_compact(
&self,
manager: &Arc<LoopManager>,
pool: &SqlitePool,
session_id: i64,
stack_id: i64,
@@ -221,17 +122,12 @@ impl ContextCompactor {
return Ok(false);
}
let effective_tokens = if last_input_tokens > 0 {
last_input_tokens
} else {
let est = chat_history::estimate_tokens_for_stack(pool, stack_id).await?;
debug!(stack_id, estimate = est, "compactor: no usage data, using char estimate");
est
};
if effective_tokens < self.config.threshold_tokens {
// A provider that reported no usage leaves only the character estimate.
let estimated = chat_history::estimate_tokens_for_stack(pool, stack_id).await?;
if !should_compact(Some(last_input_tokens), estimated, self.config.threshold_tokens) {
return Ok(false);
}
let effective_tokens = if last_input_tokens > 0 { last_input_tokens } else { estimated };
info!(
stack_id,
@@ -240,7 +136,7 @@ impl ContextCompactor {
"compactor: threshold exceeded, starting compaction"
);
self.do_compact(pool, session_id, stack_id, effective_tokens).await
self.do_compact(manager, session_id, stack_id, effective_tokens).await
}
/// Force compaction regardless of the token threshold.
@@ -249,6 +145,7 @@ impl ContextCompactor {
/// Returns `true` if a new summary was written, `false` if skipped.
pub async fn force_compact(
&self,
manager: &Arc<LoopManager>,
pool: &SqlitePool,
session_id: i64,
stack_id: i64,
@@ -265,309 +162,70 @@ impl ContextCompactor {
"compactor: manual compaction triggered"
);
self.do_compact(pool, session_id, stack_id, effective_tokens).await
self.do_compact(manager, session_id, stack_id, effective_tokens).await
}
/// Core compaction logic shared by `try_compact` and `force_compact`.
/// Loads messages, splits at the keep_recent boundary, calls the summariser
/// LLM, persists the summary, and publishes a `CompactionDone` event.
/// Runs the library's compaction on the frame with Skald's model policy,
/// then publishes the result on the app's event bus.
///
/// Model: the instance-wide Settings pick (`compaction_model`) wins; empty,
/// unset, or naming a model that no longer exists all degrade to AUTO
/// selection by `compaction.strength` from config.yml.
async fn do_compact(
&self,
pool: &SqlitePool,
manager: &Arc<LoopManager>,
session_id: i64,
stack_id: i64,
effective_tokens: u32,
) -> anyhow::Result<bool> {
let prior_summary = chat_summaries::latest_for_stack(pool, stack_id).await?;
let hint = self.model_hint().await;
let conv = SqliteHistory::conversation(session_id);
let messages = match &prior_summary {
Some(s) => chat_history::for_stack_since(pool, stack_id, s.covers_up_to_message_id).await?,
None => chat_history::for_stack(pool, stack_id).await?,
};
let keep = self.config.keep_recent;
if messages.len() <= keep {
debug!(
stack_id,
messages = messages.len(),
keep,
"compactor: not enough messages to summarise beyond keep_recent, skipping"
);
return Ok(false);
}
let raw_split = messages.len() - keep;
let split = (0..=raw_split)
.rev()
.find(|&i| {
i == 0 || matches!(
messages[i].role,
chat_history::Role::User | chat_history::Role::Agent
)
})
.unwrap_or(0);
if split == 0 {
debug!(stack_id, "compactor: no suitable split point found, skipping");
return Ok(false);
}
let to_summarise = &messages[..split];
let last_covered_id = to_summarise.last().expect("to_summarise is non-empty").id;
let conversation_text = self
.format_for_summary(pool, to_summarise, prior_summary.as_ref().map(|s| s.content.as_str()))
let outcome = manager
.new_compaction(conv, agent_loop::ids::FrameId(stack_id))
.mode(CompactionMode::Auto { keep_tail: self.config.keep_recent })
// Strength is Skald's, captured here (D14): a pin bypasses it.
.selector(Arc::new(SkaldSelector::new(
Arc::clone(&self.llm_manager),
self.config.strength,
)))
.model(hint)
.log(json!({ "session_id": session_id, "stack_id": stack_id }))
.run()
.await?;
// Model for the summary call: the instance-wide Settings pick
// (`compaction_model`) wins; empty/unset falls back to AUTO selection by
// `compaction.strength` from config.yml. A configured model that no
// longer exists (renamed/deleted) degrades to the same AUTO path.
let configured = self.config_store.get(COMPACTION_MODEL_KEY).await
.ok()
.flatten()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let (client_name, llm) = match configured {
Some(name) => match self.llm_manager.resolve(Some(&name), None).await {
Ok(r) => r,
Err(e) => {
warn!(model = %name, error = %e, "compactor: configured compaction model unavailable, falling back to AUTO selection");
self.llm_manager.resolve(None, self.config.strength).await?
}
},
None => self.llm_manager.resolve(None, self.config.strength).await?,
};
info!(
stack_id,
client = %client_name,
messages_covered = to_summarise.len(),
last_covered_id,
"compactor: calling LLM for summary"
);
let messages_payload = vec![
json!({ "role": "user", "content": conversation_text }),
];
let request = agent_loop::model::ModelRequest {
messages: messages_payload,
tools: Vec::new(),
model: llm.model.clone(),
max_tokens: None,
temperature: Some(0.3),
request_id: uuid::Uuid::new_v4().to_string(),
conversation: agent_loop::ids::ConversationId::new(format!("session:{session_id}")),
frame: agent_loop::ids::FrameId(stack_id),
extras: serde_json::Value::Null,
log: Some(json!({ "session_id": session_id, "stack_id": stack_id })),
};
let resp = llm.client.complete(&request, None).await
.map_err(|e| {
warn!(stack_id, error = %e, "compactor: LLM call failed");
e
})?;
let summary_text = match resp {
agent_loop::model::ModelResponse::Message { content, .. } => content,
agent_loop::model::ModelResponse::ToolCalls { content, .. } => {
warn!(stack_id, "compactor: unexpected tool calls in summary response, using content");
content
}
};
if summary_text.trim().is_empty() {
warn!(stack_id, "compactor: LLM returned empty summary, skipping save");
return Ok(false);
}
let summary_id = chat_summaries::save(pool, stack_id, &summary_text, last_covered_id).await?;
// DTL: activations pinned to a message that was just compacted away would
// otherwise lose their render position (the Kimi `system`+`tools` block).
// Re-anchor them onto the first surviving message. Best-effort — a failure
// only means the model may re-activate a tool after compaction.
let first_surviving_id = messages[split].id;
if let Err(e) = crate::db::activated_tools::reanchor_compacted(
pool, stack_id, last_covered_id, first_surviving_id,
).await {
warn!(stack_id, error = %e, "compactor: failed to re-anchor DTL activations");
}
info!(
stack_id,
summary_id,
last_covered_id,
"compactor: summary saved"
);
let Some(outcome) = outcome else { return Ok(false) };
self.event_bus.compaction_done(CompactionEvent {
session_id,
stack_id,
summary_id,
covers_up_to_message_id: last_covered_id,
triggered_by_tokens: effective_tokens,
summary_id: outcome.summary_id.get(),
covers_up_to_message_id: outcome.covered_up_to.get(),
triggered_by_tokens: effective_tokens,
});
Ok(true)
}
// ── Private helpers ───────────────────────────────────────────────────────
/// The summariser's model pin, or `ModelHint::default()` (AUTO) when none is
/// configured or the configured one is gone.
async fn model_hint(&self) -> ModelHint {
let configured = self
.config_store
.get(COMPACTION_MODEL_KEY)
.await
.ok()
.flatten()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let Some(name) = configured else { return ModelHint::default() };
/// Builds the full prompt for the summarisation LLM call (Hermes-style).
///
/// Returns a single string intended to be sent as a `user` message.
/// The preamble, conversation transcript, and structured template are all
/// concatenated, matching how Hermes' `_generate_summary` works.
///
/// * First compaction — `prior_summary` is `None`.
/// * Subsequent compaction — `prior_summary` contains the previous summary body
/// (without `SUMMARY_PREFIX`) so the LLM can produce an updated, non-nested summary.
async fn format_for_summary(
&self,
pool: &SqlitePool,
messages: &[chat_history::ChatMessage],
prior_summary: Option<&str>,
) -> anyhow::Result<String> {
let transcript = self.serialize_for_summary(pool, messages).await?;
let prompt = if let Some(prev) = prior_summary {
format!(
"{SUMMARIZER_PREAMBLE}\n\n\
You are updating a context compaction summary. A previous compaction produced \
the summary below. New conversation turns have occurred since then and need \
to be incorporated.\n\n\
PREVIOUS SUMMARY:\n{prev}\n\n\
NEW TURNS TO INCORPORATE:\n{transcript}\n\n\
Update the summary using this exact structure. PRESERVE all existing information \
that is still relevant. ADD new completed actions to the numbered list (continue \
numbering). Move items from \"In Progress\" to \"Completed Actions\" when done. \
Move answered questions to \"Resolved Questions\". Update \"Active State\" to \
reflect current state. Remove information only if it is clearly obsolete. \
CRITICAL: Update \"## Active Task\" to reflect the user's most recent unfulfilled \
request — this is the most important field for task continuity.\n\n\
{SUMMARY_TEMPLATE}"
)
} else {
format!(
"{SUMMARIZER_PREAMBLE}\n\n\
Create a structured checkpoint summary for the conversation after earlier turns \
are compacted. The summary should preserve enough detail for continuity without \
re-reading the original turns.\n\n\
TURNS TO SUMMARIZE:\n{transcript}\n\n\
Use this exact structure:\n\n\
{SUMMARY_TEMPLATE}"
)
};
Ok(prompt)
}
/// Serialises conversation messages into Hermes-style labeled text for the summariser.
///
/// Format:
/// ```text
/// [USER]: text…
///
/// [ASSISTANT]: text…
/// [Tool calls:
/// tool_name(args…)
/// ]
///
/// [TOOL RESULT tc_N]: result…
/// ```
///
/// Long content is truncated with a head+tail strategy (preserving the start and
/// end of the text) rather than a simple prefix cut.
async fn serialize_for_summary(
&self,
pool: &SqlitePool,
messages: &[chat_history::ChatMessage],
) -> anyhow::Result<String> {
let mut parts: Vec<String> = Vec::new();
for msg in messages {
match msg.role {
chat_history::Role::User | chat_history::Role::Agent => {
let content = truncate_head_tail(msg.content.trim(), 6000, 1500);
parts.push(format!("[USER]: {content}"));
}
chat_history::Role::Assistant => {
let mut content = truncate_head_tail(msg.content.trim(), 6000, 1500);
let tool_calls = chat_llm_tools::for_message(pool, msg.id).await?;
if !tool_calls.is_empty() {
let tc_lines: String = tool_calls
.iter()
.map(|tc| {
let args = tc.arguments.as_deref()
.map(|a| truncate(a, 1200))
.unwrap_or_default();
format!(" {}({})", tc.name, args)
})
.collect::<Vec<_>>()
.join("\n");
content.push_str(&format!("\n[Tool calls:\n{tc_lines}\n]"));
}
parts.push(format!("[ASSISTANT]: {content}"));
// Tool results as separate labeled entries — mirrors Hermes'
// `[TOOL RESULT {call_id}]` entries in the serialised transcript.
for tc in &tool_calls {
let result = match tc.status.as_str() {
"done" => tc.result.as_deref()
.map(|r| truncate_head_tail(r, 4000, 1500))
.unwrap_or_default(),
_ => "(failed or interrupted)".to_string(),
};
parts.push(format!("[TOOL RESULT tc_{}]: {result}", tc.id));
}
}
match self.llm_manager.resolve(Some(&name), None).await {
Ok((resolved, _)) => ModelHint::name(resolved),
Err(e) => {
warn!(model = %name, error = %e,
"compactor: configured compaction model unavailable, falling back to AUTO");
ModelHint::default()
}
}
Ok(parts.join("\n\n"))
}
}
/// Truncate a string to at most `max_chars`, appending "…" if truncated.
fn truncate(s: &str, max_chars: usize) -> String {
let s = s.trim();
if s.chars().count() <= max_chars {
s.to_string()
} else {
let end = s.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}", &s[..end])
}
}
/// Keep the first `head_chars` and last `tail_chars` of a string, inserting
/// `\n...[truncated]...\n` in the middle when the string is longer than their sum.
///
/// Mirrors Hermes' `_CONTENT_HEAD` + `_CONTENT_TAIL` strategy so the summariser
/// always sees both the beginning context and the ending result of verbose outputs.
fn truncate_head_tail(s: &str, head_chars: usize, tail_chars: usize) -> String {
let s = s.trim();
let char_count = s.chars().count();
let total = head_chars + tail_chars;
if char_count <= total {
return s.to_string();
}
let head_end = s.char_indices()
.nth(head_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
let tail_start = s.char_indices()
.nth(char_count - tail_chars)
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}\n...[truncated]...\n{}", &s[..head_end], &s[tail_start..])
}
+24 -56
View File
@@ -413,7 +413,7 @@ async fn run_job(
});
// Drain events concurrently. rx closes when the last tx clone is dropped,
// which happens only after resume_turn() completes the full sub-agent chain.
// which happens only after the turn completes the full sub-agent chain.
while let Some(_) = rx.recv().await {}
let handle_result = jh.await
@@ -465,7 +465,7 @@ async fn run_job(
if let Some(parent_id) = job.parent_session_id {
if let Some(hub) = hub {
inject_async_result(
pool,
&task_mgr.pool,
hub,
parent_id,
job.id,
@@ -512,69 +512,37 @@ async fn run_job(
}
}
/// Injects an async task result into the parent session using the same pattern as
/// the notification system: writes a synthetic assistant message + completed
/// `task_completed` tool call directly to the DB, then calls `hub.resume()` so
/// the parent LLM wakes up and events are properly bridged to the WebSocket.
/// Delivers an async task's result to the parent session through the loop's
/// [`AsyncResultSink`] seam (blueprint §7.2): the library writes the synthetic
/// assistant message + completed `task_completed` call, and Skald's
/// [`DurableSink`] resumes the parent so the model reads it right away.
///
/// Failures are logged, never propagated: the job itself succeeded, and losing
/// the delivery must not mark it failed.
async fn inject_async_result(
pool: &SqlitePool,
pool: &Arc<SqlitePool>,
hub: &Arc<ChatHub>,
parent_session_id: i64,
task_id: i64,
task_title: &str,
result: &str,
) {
// Resolve source_id from the parent session row.
let source_id = match crate::db::chat_sessions::find_by_id(pool, parent_session_id).await {
Ok(Some(s)) => s.source,
Ok(None) => { error!("inject_async_result: session {parent_session_id} not found"); return; }
Err(e) => { error!("inject_async_result: DB error: {e}"); return; }
};
use agent_loop::delegate::{AsyncResultSink, CompletedTask};
use crate::loop_adapters::async_task::DurableSink;
use crate::loop_adapters::history::SqliteHistory;
// Get the active stack for the parent session.
let stack = match crate::db::chat_sessions_stack::active_for_session(pool, parent_session_id).await {
Ok(Some(s)) => s,
Ok(None) => { error!("inject_async_result: no active stack for session {parent_session_id}"); return; }
Err(e) => { error!("inject_async_result: stack lookup failed: {e}"); return; }
};
info!(parent_session_id, task_id, task_title, "delivering async task result");
// Write a synthetic assistant message (reasoning trace).
let reasoning = format!(
"The system is notifying me that async task #{task_id} ('{}') has completed. \
Let me process the result via task_completed.",
task_title,
);
let assistant_id = match crate::db::chat_history::append(
pool, stack.id, &crate::db::chat_history::Role::Assistant,
"", true, Some(&reasoning),
).await {
Ok(id) => id,
Err(e) => { error!("inject_async_result: append assistant failed: {e}"); return; }
};
// Write the completed task_completed tool call with the result payload.
let result_json = serde_json::to_string(&serde_json::json!({
"task_id": task_id,
"title": task_title,
"result": result,
})).unwrap_or_else(|_| "{}".to_string());
let tool_call_id = match crate::db::chat_llm_tools::append(
pool, assistant_id, "task_completed",
&serde_json::json!({"task_id": task_id}).to_string(),
).await {
Ok(id) => id,
Err(e) => { error!("inject_async_result: append tool call failed: {e}"); return; }
};
if let Err(e) = crate::db::chat_llm_tools::complete(pool, tool_call_id, &result_json, "string").await {
error!("inject_async_result: complete tool call failed: {e}"); return;
}
info!(parent_session_id, task_id, task_title, "inject_async_result: resuming parent session");
if let Err(e) = hub.resume(&source_id).await {
error!("inject_async_result: hub.resume failed: {e}");
let sink = DurableSink::new(Arc::clone(pool), Arc::clone(hub));
let delivered = sink
.deliver(SqliteHistory::conversation(parent_session_id), CompletedTask {
id: agent_loop::ids::TaskId(task_id),
title: task_title.to_string(),
result: result.to_string(),
})
.await;
if let Err(e) = delivered {
error!(parent_session_id, task_id, "async result delivery failed: {e}");
}
}
+1 -1
View File
@@ -192,7 +192,7 @@ pub async fn for_stack_all(
}
/// Ok messages for a stack frame whose id is strictly greater than `after_id`,
/// ordered chronologically. Used by `build_openai_messages` when a compaction
/// ordered chronologically. Used by the projection when a compaction
/// summary exists: only the "raw" messages after the summary boundary are loaded.
pub async fn for_stack_since(
pool: &SqlitePool,
+1 -1
View File
@@ -12,7 +12,7 @@ pub struct ChatSummary {
pub stack_id: i64,
pub content: String,
/// All chat_history rows with `id <= covers_up_to_message_id` are covered
/// by this summary. `build_openai_messages` loads only rows *after* this id.
/// by this summary. The projection loads only rows *after* this id.
pub covers_up_to_message_id: i64,
pub created_at: String,
}
@@ -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
}
}
+20 -14
View File
@@ -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)
+89 -102
View File
@@ -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(),
+101 -89
View File
@@ -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;
+26 -1
View File
@@ -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)
+50 -4
View File
@@ -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;
}
}
+22 -4
View File
@@ -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"
}
]
+333 -19
View File
@@ -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 &notes {
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 { .. } => {}
@@ -1,375 +0,0 @@
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
use crate::db::{activated_tools, chat_history, chat_llm_tools, chat_sessions_stack, scratchpad};
use crate::events::ServerEvent;
use super::{ChatSessionHandler, MAX_AGENT_DEPTH, TurnOutcome};
use super::emitter::TurnEmitter;
use super::interface_tools::{AgentRunConfig, InterfaceTool, ToolFuture};
use super::config::activate_tools_tool_def;
impl ChatSessionHandler {
/// Dispatches a sub-agent as a child stack frame within the current session.
/// Used by `execute_task` (mode=sync) and `execute_subtask` interceptions in `llm_loop`.
/// Args must contain `agent_id` and `prompt`; optionally `client`.
pub(super) async fn dispatch_sub_agent(
&self,
parent_stack_id: i64,
parent_config: &AgentRunConfig,
parent_tool_call_id: i64,
args: &Value,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<String> {
let pool = &self.db;
let em = TurnEmitter::new(tx);
let target_id = args["agent_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("dispatch_sub_agent: missing required argument `agent_id`"))?;
let prompt = args["prompt"].as_str()
.ok_or_else(|| anyhow::anyhow!("dispatch_sub_agent: missing required argument `prompt`"))?;
if target_id == parent_config.agent_id {
anyhow::bail!("dispatch_sub_agent: an agent cannot call itself (`{target_id}`)");
}
// Only `task` agents are dispatchable: this rejects `chat` (e.g. `main`,
// `project-coordinator`) and `system` (e.g. `tic`) agents, and surfaces a
// not-found error for unknown ids — all in one gate.
let target_meta = crate::agents::load_task_meta(target_id)
.map_err(|e| anyhow::anyhow!("dispatch_sub_agent: {e}"))?;
let parent_frame = chat_sessions_stack::find_by_id(pool, parent_stack_id).await?
.ok_or_else(|| anyhow::anyhow!("dispatch_sub_agent: parent stack frame not found"))?;
let new_depth = parent_frame.depth + 1;
if new_depth > MAX_AGENT_DEPTH {
anyhow::bail!(
"dispatch_sub_agent: maximum agent depth ({}) exceeded — refusing to recurse further",
MAX_AGENT_DEPTH
);
}
let explicit_client = args["client"].as_str().or(target_meta.client.as_deref());
let (resolved_client, _) = self.llm_manager.resolve(
explicit_client,
target_meta.strength,
).await.map_err(|e| anyhow::anyhow!("dispatch_sub_agent: {e}"))?;
let child = chat_sessions_stack::create(
pool,
self.session_id,
target_id,
Some(prompt),
new_depth,
Some(parent_tool_call_id),
).await?;
// Single source of the sub-agent's config (base tools + augmentation + grants
// + activate_tools), shared with restart recovery so the two can't drift (B3).
let child_config = self.build_sub_agent_config(
parent_config, target_id, resolved_client.clone(), child.id, new_depth,
).await?;
chat_history::append(pool, child.id, &chat_history::Role::Agent, prompt, false, None).await?;
let prompt_preview = super::preview_truncate(prompt, 500);
em.agent_start(
child.id,
parent_tool_call_id,
target_id.to_string(),
parent_config.agent_id.clone(),
new_depth,
prompt_preview,
).await;
info!(
session_id = self.session_id,
parent_stack = parent_stack_id,
child_stack = child.id,
target_agent = target_id,
client = %resolved_client,
"dispatch_sub_agent: running child inline"
);
// Run the child synchronously in the SAME task, holding the same
// `processing` lock and sharing the same cancellation token. The returned
// string becomes the parent tool call's result, which `run_agent_turn`
// persists and emits as `ToolDone` — so completion lives in one place.
// Boxed: `resume_pending_tools` now dispatches sub-agents via `execute_tool_call`,
// which re-enters here — box this edge so the recursive async future stays sized.
let _ = Box::pin(self.resume_pending_tools(child.id, &child_config, token, tx)).await;
// Sub-agents never inject live user input.
let outcome = self.run_agent_turn(child.id, &child_config, token, tx, None).await;
if let Err(e) = activated_tools::delete_for_stack(pool, child.id).await {
tracing::warn!(stack_id = child.id, error = %e, "dispatch_sub_agent: failed to delete stack activations");
}
let parent_agent_id = parent_config.agent_id.clone();
let child_agent_id = target_id.to_string();
let preview = |s: &str| super::preview_truncate(s, 500);
let result = match outcome {
Ok(TurnOutcome::Final { content, .. }) => {
em.agent_done(child.id, child_agent_id, parent_agent_id, preview(&content)).await;
Ok(content)
}
Ok(TurnOutcome::Cancelled) => {
// The parent shares this token: if the cancel came from the user,
// its next round check returns Cancelled too. We still record a
// tool result so the history stays well-formed.
em.agent_done(child.id, child_agent_id, parent_agent_id, "⚠️ Cancelled.".to_string()).await;
Ok(format!("Sub-agent `{target_id}` was cancelled."))
}
Ok(TurnOutcome::Exhausted) => {
em.agent_done(child.id, child_agent_id, parent_agent_id, "⚠️ Exhausted tool-call rounds.".to_string()).await;
Ok(format!(
"Sub-agent `{target_id}` exceeded {} tool-call rounds without producing a final answer.",
self.max_tool_rounds
))
}
Err(e) => {
let msg = e.to_string();
em.agent_done(child.id, child_agent_id, parent_agent_id, format!("⚠️ Error: {msg}")).await;
Err(e)
}
};
let _ = chat_sessions_stack::terminate(pool, child.id).await;
result
}
/// Builds the [`AgentRunConfig`] for a sub-agent stack frame: base tools derived
/// from `parent_config`, plus the sub-agent augmentation (sub-agents-only tools,
/// `ask_user_clarification`, `execute_subtask` while `depth` still permits
/// recursion), the approval-visibility filter, the frame's persisted MCP grants,
/// and a stack-scoped `activate_tools`.
///
/// The **single** source of a sub-agent's config, shared by live dispatch
/// (`dispatch_sub_agent`) and post-restart recovery (`build_recovery_frame_config`),
/// so a resumed child runs with the same prompt/tools it had live — never the root
/// agent's (bug B3). `depth` is passed explicitly (not `parent.depth + 1`) so
/// recovery can build a config for a frame at any depth straight from the root.
pub(super) async fn build_sub_agent_config(
&self,
parent_config: &AgentRunConfig,
agent_id: &str,
client_name: String,
stack_id: i64,
depth: i64,
) -> anyhow::Result<AgentRunConfig> {
let persisted_grants = activated_tools::list_refs_stack(&self.db, stack_id)
.await
.unwrap_or_default();
let active_mcp_grants: Arc<RwLock<HashSet<String>>> =
Arc::new(RwLock::new(persisted_grants.into_iter().collect()));
let mut child_config = parent_config.for_sub_agent(agent_id.to_string(), client_name);
child_config.depth = depth;
child_config.active_mcp_grants = Arc::clone(&active_mcp_grants);
child_config.base_tool_defs.extend(self.tools.openai_definitions_sub_agents_only());
child_config.base_tool_defs.push(super::ask_user_clarification_tool_def());
// Expose `execute_subtask` only while the child can still recurse — at the
// depth limit `dispatch_sub_agent` would reject it.
if depth < MAX_AGENT_DEPTH {
child_config.base_tool_defs.push(super::execute_subtask_tool_def());
}
{
let group_id = self.tool_group_id().await;
let gid = group_id.as_deref().unwrap_or("default");
// Registry table — read from the registry pool, not the owner pool
// (see the same filter in `config.rs::build_agent_config`).
let group_rules = match crate::db::approval_rules::list_for_group(
&self.shared_pool, Some(gid),
).await {
Ok(rules) => rules,
Err(e) => {
tracing::warn!(group = gid, error = %e, "sub-agent approval-rules visibility filter: list_for_group failed; leaving all tools visible");
Vec::new()
}
};
child_config.base_tool_defs.retain(|def| {
let name = def["function"]["name"].as_str().unwrap_or("");
self.approval.is_tool_visible(&group_rules, name)
});
}
{
let activate_tool = crate::tools::activate_tools::ActivateTools {
stack_id: Some(stack_id),
mcp: Arc::clone(&self.mcp),
active_mcp_grants: Arc::clone(&active_mcp_grants),
};
let activate_tool = Arc::new(activate_tool);
child_config.interface_tools.push(InterfaceTool {
definition: activate_tools_tool_def(),
handler: Arc::new(move |args| -> ToolFuture {
use crate::tools::Tool as _;
let tool = Arc::clone(&activate_tool);
Box::pin(async move {
tokio::task::spawn_blocking(move || tool.execute(args))
.await
.map_err(|e| anyhow::anyhow!("activate_tools task panicked: {e}"))?
})
}),
});
}
Ok(child_config)
}
/// Config to re-run a sub-agent frame during app-restart recovery: resolves the
/// frame's **own** agent (prompt/meta/client) and builds its sub-agent config, so
/// `resume_turn`'s cascade resumes a child as itself, not as the root agent (bug
/// B3). The root frame is not passed here — the caller keeps the session's root
/// config for it. Base tools derive from `root_config`; the per-dispatch `client`
/// override isn't persisted, so the frame's agent meta drives model resolution.
pub(super) async fn build_recovery_frame_config(
&self,
root_config: &AgentRunConfig,
frame: &chat_sessions_stack::SessionStack,
) -> anyhow::Result<AgentRunConfig> {
let meta = crate::agents::load_task_meta(&frame.agent_id)
.map_err(|e| anyhow::anyhow!("resume: cannot load sub-agent `{}`: {e}", frame.agent_id))?;
let (client, _) = self.llm_manager.resolve(
meta.client.as_deref(), meta.strength,
).await?;
self.build_sub_agent_config(root_config, &frame.agent_id, client.to_string(), frame.id, frame.depth).await
}
/// Handles the `update_scratchpad` built-in.
///
/// The scratchpad is a session-scoped shared blackboard (`scratchpad_sid()` is
/// the session_id, identical for every frame). When a homogeneous batch of
/// sub-agents runs concurrently (`handle_sub_agent_batch`), two siblings writing
/// the *same* key race to last-writer-wins — this is inherent to a shared
/// blackboard and accepted by design, not a correctness bug. Sub-agents that must
/// not clobber each other should write distinct keys.
pub(super) async fn dispatch_update_scratchpad(
&self,
args: &Value,
) -> anyhow::Result<String> {
let key = args["key"].as_str().unwrap_or("").to_string();
let value = args["value"].as_str().unwrap_or("").to_string();
scratchpad::upsert(&self.db, self.scratchpad_sid(), &key, &value).await
.map(|_| format!("Scratchpad updated: {key}"))
}
/// Handles the `write_todos` built-in.
///
/// Stateless: the list is not persisted anywhere — it lives only in this
/// agent's tool-result history (per-stack, so it is never seen by sub-agents
/// or the caller). We just validate/normalise the items and echo back a
/// formatted checklist the model re-reads from its own tool result.
pub(super) async fn dispatch_write_todos(
&self,
args: &Value,
) -> anyhow::Result<String> {
let items = args["todos"].as_array().ok_or_else(|| {
anyhow::anyhow!("`write_todos` requires a `todos` array. Re-send the full list, e.g. [{{\"content\":\"...\",\"status\":\"pending\"}}].")
})?;
if items.is_empty() {
return Err(anyhow::anyhow!("`todos` is empty — send at least one item, or omit the call entirely."));
}
let mut lines = Vec::with_capacity(items.len());
let (mut done, mut active, mut pending) = (0usize, 0usize, 0usize);
for item in items {
let content = item["content"].as_str().unwrap_or("").trim();
if content.is_empty() {
continue;
}
// Normalise unknown statuses to `pending`.
let marker = match item["status"].as_str() {
Some("completed") => { done += 1; "x" }
Some("in_progress") => { active += 1; "~" }
_ => { pending += 1; " " }
};
lines.push(format!("[{marker}] {content}"));
}
if lines.is_empty() {
return Err(anyhow::anyhow!("No valid todo items (every `content` was empty)."));
}
Ok(format!(
"Todo list ({total}): {done} done, {active} in progress, {pending} pending\n{body}",
total = lines.len(),
body = lines.join("\n"),
))
}
/// Handles the `ask_user_clarification` built-in.
///
/// Interactive sessions (web, telegram): sends `AgentQuestion` over the WS channel
/// and waits for the user to answer inline in the chat.
///
/// Background sessions (cron, tic): registers in `ClarificationManager` so the
/// Agent Inbox page can surface and resolve the request.
///
/// `tool_call_id` is used to mark the DB row as `pending` before blocking,
/// so page refreshes and app restarts can distinguish "waiting for input" from
/// "was executing" and re-ask the question correctly.
pub(super) async fn dispatch_ask_user_clarification(
&self,
tool_call_id: i64,
args: &Value,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<String> {
let title = args["title"].as_str().unwrap_or("Clarification needed").to_string();
let question = args["question"].as_str().unwrap_or("?").to_string();
let suggested: Vec<String> = args["suggested_answers"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
// Mark as pending before suspending so restart/refresh can re-ask the question.
chat_llm_tools::set_approval_pending(&self.db, tool_call_id).await?;
let context_label = self.context_label.read().ok().and_then(|g| g.clone());
// Always register in ClarificationManager so the question appears in the
// Agent Inbox for ALL sessions (both interactive web/telegram and background cron/tic).
let (request_id, rx) = self.clarification.register(
self.session_id,
&self.agent_id,
&self.source,
context_label.as_deref(),
&title,
&question,
suggested.clone(),
).await;
tracing::debug!(session_id = self.session_id, request_id, is_interactive = self.is_interactive, source = %self.source, "dispatch_ask_user_clarification: routing");
if self.is_interactive {
// For interactive sessions, also send the question over WS so it appears
// inline in the chat. The user can answer from either the chat or the Inbox.
info!(session_id = self.session_id, request_id, %question, source = %self.source, "agent asking user for clarification (interactive) — sending AgentQuestion");
let send_result = tx.send(ServerEvent::AgentQuestion {
request_id,
tool_call_id,
title,
question,
suggested_answers: suggested,
}).await;
if send_result.is_err() {
tracing::warn!(session_id = self.session_id, request_id, "AgentQuestion send failed — tx receiver dropped");
} else {
info!(session_id = self.session_id, request_id, "AgentQuestion sent to bridge");
}
} else {
info!(session_id = self.session_id, request_id, %question, source = %self.source, "background session waiting for clarification");
}
// Wait for the answer (from WS via resolve_question → clarification.resolve,
// or directly from the Inbox REST endpoint).
rx.await.map_err(|_| anyhow::Error::new(super::AgentFlowSignal::QuestionChannelClosed))
}
}
@@ -1,128 +0,0 @@
use serde_json::Value;
use tracing::debug;
use super::ChatSessionHandler;
use super::emitter::TurnEmitter;
use crate::tools::{is_file_write_tool, tool_names as tn};
impl ChatSessionHandler {
/// Emits the appropriate frontend approval event for the given tool call.
///
/// | Tool kind | Event emitted |
/// |------------------|-------------------------------------------------------|
/// | file-write tools | `PendingWrite` with before/after diff (IO concurrent) |
/// | `execute_cmd` | `PendingWrite` with command preview |
/// | `restart` | `PendingWrite` with restart description |
/// | everything else | `ApprovalRequired` |
///
/// Called from both `llm_loop` and `resume_pending_tools` to avoid duplication.
pub(super) async fn emit_approval_event(
&self,
em: &TurnEmitter<'_>,
request_id: i64,
tool_call_id: i64,
tool_name: &str,
arguments: &Value,
) {
if is_file_write_tool(tool_name) {
let path = arguments["path"].as_str().unwrap_or("").to_string();
// Read current file and compute new content concurrently — both are disk I/O.
let (old_content, new_content) = tokio::join!(
self.read_current_content(&path),
self.compute_new_content(tool_name, arguments),
);
if let Some(new_content) = new_content {
em.pending_write(request_id, tool_call_id, path, old_content, new_content).await;
} else {
// File doesn't exist yet or diff can't be computed — fall back to generic.
debug!(tool = tool_name, "emit_approval_event: no diff available, using ApprovalRequired");
em.approval_required(request_id, tool_call_id, tool_name.to_string(), arguments.clone()).await;
}
} else if tool_name == tn::EXECUTE_CMD {
let cmd = arguments["command"].as_str().unwrap_or("");
em.pending_write(request_id, tool_call_id, "$ execute_cmd".to_string(), None, format!("$ {cmd}")).await;
} else {
em.approval_required(request_id, tool_call_id, tool_name.to_string(), arguments.clone()).await;
}
}
/// Reads the current content of a file for the diff in a `PendingWrite` event.
///
/// Routes **exactly like the fs-tools** (blueprint §6), so the diff the user
/// approves reflects the real target — not the server's cwd:
/// - `user-memory/…` / `shared-memory/…` → the `memory_docs` note on the right
/// pool (owner vs `system.db`), never disk;
/// - every other agent path → the caller's per-user host workspace via `self.fs`,
/// containment-checked by `resolve_host_path`.
///
/// A resolve failure or a missing note/file yields `None` (rendered as "new file").
/// The old cwd-relative `fs::resolve` was wrong for every agent path: it showed a
/// bogus "new file" on overwrites and, worse, the diff of a same-named cwd file.
pub(super) async fn read_current_content(&self, path: &str) -> Option<String> {
use crate::tools::fs::{classify_memory, resolve_host_path, MemScope};
if let Some(m) = classify_memory(path) {
let pool = match m.scope {
MemScope::User => &self.db,
MemScope::Shared => &self.shared_pool,
};
return crate::db::memory_docs::get(pool, &m.rel)
.await.ok().flatten().map(|d| d.content);
}
let abs = resolve_host_path(&self.fs.load(), path).ok()?;
tokio::fs::read_to_string(&abs).await.ok()
}
/// Computes what a file would look like after the tool runs, without writing it.
/// Returns `None` if the result cannot be determined (e.g. edit_file on a missing file).
pub(super) async fn compute_new_content(&self, name: &str, args: &Value) -> Option<String> {
match name {
"write_file" => args["content"].as_str().map(|s| s.to_string()),
"edit_file" => {
let path = args["path"].as_str()?;
let old_text = args["old"].as_str()?;
let new_text = args["new"].as_str()?;
let current = self.read_current_content(path).await?;
if current.contains(old_text) {
Some(current.replacen(old_text, new_text, 1))
} else {
None
}
}
"insert_at_line" => {
let path = args["path"].as_str()?;
let line_num = args["line"].as_u64()? as usize;
let new_text = args["content"].as_str()?;
let placement = args["placement"].as_str().unwrap_or("after");
if line_num == 0 { return None; }
let current = self.read_current_content(path).await?;
let mut lines: Vec<&str> = current.split('\n').collect();
let idx = (line_num - 1).min(lines.len().saturating_sub(1));
let insert_idx = if placement == "before" { idx } else { idx + 1 };
let new_lines: Vec<&str> = new_text.split('\n').collect();
for (i, l) in new_lines.iter().enumerate() {
lines.insert(insert_idx + i, l);
}
Some(lines.join("\n"))
}
"replace_lines" => {
let path = args["path"].as_str()?;
let from_line = args["from_line"].as_u64()? as usize;
let to_line = args["to_line"].as_u64()? as usize;
let new_text = args["new"].as_str()?;
if from_line == 0 || to_line < from_line { return None; }
let current = self.read_current_content(path).await?;
let mut lines: Vec<&str> = current.lines().collect();
let total = lines.len();
if from_line > total { return None; }
let to_clamped = to_line.min(total);
let new_lines: Vec<&str> = new_text.lines().collect();
lines.splice((from_line - 1)..to_clamped, new_lines);
let has_trailing = current.ends_with('\n');
let mut result = lines.join("\n");
if has_trailing { result.push('\n'); }
Some(result)
}
_ => None,
}
}
}
@@ -38,7 +38,7 @@ pub(crate) fn activate_tools_tool_def() -> Value {
impl ChatSessionHandler {
/// Resolves the LLM client and assembles `AgentRunConfig` for a top-level turn
/// (depth = 0). Extracted to avoid duplicating the same ~15 lines in both
/// `handle_message` and `resume_turn`.
/// `handle_message` and the recovery paths.
pub(super) async fn build_agent_config(
&self,
client_name: Option<String>,
@@ -1,177 +0,0 @@
//! Per-tool-call dispatch router.
//!
//! Extracted from `run_agent_turn`: `execute_tool_call` routes an approved call to
//! the right executor (special non-cancellable paths + the unified cancellable
//! `ToolExecution` path). The session working directory is always the user's home
//! (`~`); tool calls receive their arguments unchanged, and the agent references
//! project files via the absolute agent path `projects/{owner}/{slug}/…`.
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::events::ServerEvent;
use crate::tools::{drive_execution, is_file_write_tool, tool_names as tn, ExecutionOutcome, ToolResult};
use super::ChatSessionHandler;
use super::interface_tools::AgentRunConfig;
/// Max bytes captured per side of a file-write diff preview. Beyond this the side is
/// dropped (`None`) so a huge file never bloats a row or the WS payload — the detail
/// page then shows no diff for it.
const MAX_PREVIEW_BYTES: usize = 256 * 1024;
/// A file-write tool's before/after snapshot, captured by `execute_tool_call` around
/// the write so the diff renders inline and survives a reload (Phase 2). `None` sides
/// mean unreadable / new file / over the cap.
pub(super) struct WritePreview {
pub old: Option<String>,
pub new: Option<String>,
}
/// Drops a captured snapshot over the size cap (a truncated snapshot would render a
/// misleading diff, so omit it entirely).
fn cap_preview(s: Option<String>) -> Option<String> {
s.filter(|c| c.len() <= MAX_PREVIEW_BYTES)
}
/// Whether a tool call is a synchronous sub-agent dispatch, i.e. one intercepted
/// by `execute_tool_call` and routed to `dispatch_sub_agent` rather than the
/// registry. Covers `execute_task` (mode=sync), `execute_subtask`, and the legacy
/// `run_subtask` alias (only reachable via a `pending` call left across a restart).
/// Shared by the router below and the parallel-batch detection in `run_agent_turn`.
pub(super) fn is_sync_sub_agent(tool_name: &str, args: &Value) -> bool {
(tool_name == tn::EXECUTE_TASK && args["mode"].as_str() == Some("sync") && args.get("agent_id").is_some())
|| tool_name == tn::EXECUTE_SUBTASK
|| tool_name == "run_subtask"
}
/// Result of routing a single tool call to its executor.
pub(super) enum DispatchResult {
/// Normal completion / failure / cancellation — the caller records it. `preview`
/// carries a file-write's before/after snapshot (else `None`) for the diff card.
Outcome {
outcome: ExecutionOutcome,
preview: Option<WritePreview>,
},
/// The turn must end now and the tool row must stay `pending`: the
/// `ask_user_clarification` WS channel closed while awaiting an answer. The
/// caller returns `TurnOutcome::Cancelled` **without** recording the tool, so
/// `resume_pending_tools` re-asks it on reconnect.
AbortPending,
}
impl ChatSessionHandler {
/// Routes one already-approved tool call to the right executor. Covers the
/// special, non-cancellable paths (sub-agent, scratchpad, todos, clarification,
/// the `task_completed` stub) and the unified cancellable `ToolExecution` path
/// (registry / memory / image / interface / MCP). `restart` is handled by the
/// caller before this is reached (it calls `_exit` and never returns).
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_tool_call(
&self,
stack_id: i64,
config: &AgentRunConfig,
tool_call_id: i64,
tool_name: &str,
args: &Value,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
) -> DispatchResult {
let outcome: ExecutionOutcome = if is_sync_sub_agent(tool_name, args) {
plain_outcome(self.dispatch_sub_agent(stack_id, config, tool_call_id, args, token, tx).await)
} else if tool_name == tn::UPDATE_SCRATCHPAD {
plain_outcome(self.dispatch_update_scratchpad(args).await)
} else if tool_name == tn::WRITE_TODOS {
plain_outcome(self.dispatch_write_todos(args).await)
} else if tool_name == tn::ASK_USER_CLARIFICATION {
match self.dispatch_ask_user_clarification(tool_call_id, args, tx).await {
Ok(answer) => ExecutionOutcome::Completed(ToolResult::Text(answer)),
Err(err) => {
// WS disconnected while waiting for a clarification answer.
// Tool stays 'pending' in DB — resume_pending_tools re-dispatches on reconnect.
if matches!(err.downcast_ref::<super::AgentFlowSignal>(), Some(super::AgentFlowSignal::QuestionChannelClosed)) {
warn!(session_id = self.session_id, tool_call_id, "clarification channel closed — aborting turn (tool stays pending)");
return DispatchResult::AbortPending;
}
ExecutionOutcome::Failed(err.to_string())
}
}
} else if tool_name == "task_completed" {
// Defensive stub: if the LLM somehow calls this itself, return a hint.
// Real delivery is via inject_async_result (synthetic message from the system).
let task_id = args["task_id"].as_i64().unwrap_or(0);
ExecutionOutcome::Completed(ToolResult::Text(format!(r#"{{"status":"not_ready","task_id":{task_id},"message":"This tool is invoked by the system, not by you. Do not call it again — the result will arrive automatically as a new message in this conversation."}}"#)))
} else {
// Unified cancellable path. The execution owns its in-flight state and
// its own stop(); on /stop the work future is dropped (aborting I/O /
// killing the child) and the tool is recorded as Cancelled, not Failed.
//
// For a file-write tool, bracket the execution with a before/after
// snapshot so its diff renders inline and survives a reload (Phase 2).
// The reads route memory-vs-disk exactly like the write itself
// (`read_current_content`); `new` is captured only on success.
let write_path = if is_file_write_tool(tool_name) {
args["path"].as_str().map(str::to_string)
} else {
None
};
let preview_old = match &write_path {
Some(p) => cap_preview(self.read_current_content(p).await),
None => None,
};
let outcome = match self.build_execution(tool_name, args.clone(), config) {
Some(exec) => drive_execution(exec.as_ref(), token).await,
None => ExecutionOutcome::Failed(format!("Unknown tool: {tool_name}")),
};
let preview = match &write_path {
Some(p) => {
let new = if matches!(outcome, ExecutionOutcome::Completed(_)) {
cap_preview(self.read_current_content(p).await)
} else {
None
};
Some(WritePreview { old: preview_old, new })
}
None => None,
};
return DispatchResult::Outcome { outcome, preview };
};
DispatchResult::Outcome { outcome, preview: None }
}
}
/// Maps a plain dispatch `Result<String>` to an [`ExecutionOutcome`]. Used by the
/// non-cancellable special paths (sub-agent, scratchpad, todos), which can only
/// complete or fail — never `Cancelled`.
fn plain_outcome(result: anyhow::Result<String>) -> ExecutionOutcome {
match result {
Ok(s) => ExecutionOutcome::Completed(ToolResult::Text(s)),
Err(e) => ExecutionOutcome::Failed(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::is_sync_sub_agent;
use serde_json::json;
#[test]
fn recognises_sync_sub_agent_calls() {
assert!(is_sync_sub_agent("execute_task", &json!({"mode": "sync", "agent_id": "x"})));
assert!(is_sync_sub_agent("execute_subtask", &json!({})));
assert!(is_sync_sub_agent("run_subtask", &json!({}))); // legacy alias
}
#[test]
fn rejects_everything_else() {
// execute_task without mode=sync + agent_id is NOT a sync sub-agent.
assert!(!is_sync_sub_agent("execute_task", &json!({"mode": "async", "agent_id": "x"})));
assert!(!is_sync_sub_agent("execute_task", &json!({"mode": "sync"}))); // no agent_id
assert!(!is_sync_sub_agent("execute_task", &json!({})));
// Regular tools never qualify (they must keep the sequential path).
assert!(!is_sync_sub_agent("read_file", &json!({"path": "/x"})));
assert!(!is_sync_sub_agent("execute_cmd", &json!({"cmd": "ls"})));
}
}
@@ -1,170 +0,0 @@
//! Typed, fire-and-forget event seam for a running agent turn.
//!
//! Every event a turn produces used to be sent inline as
//! `tx.send(ServerEvent::X { .. }).await.ok()`, scattered across `llm_loop`,
//! `resume`, `agent_dispatch`, and `approval`. `TurnEmitter` wraps the per-turn
//! `mpsc::Sender<ServerEvent>` (which `ChatHub` bridges onto the global broadcast
//! bus) and exposes one semantic method per event, so the loop speaks in domain
//! terms (`emitter.tool_done(..)`) instead of constructing wire enums by hand.
//!
//! It is a zero-cost borrow wrapper: construct one at the top of a function that
//! emits and pass `&TurnEmitter` to any helper. This is also the single seam a
//! future event-bus / UI-vs-domain split would hook into.
use serde_json::Value;
use tokio::sync::mpsc;
use core_api::message_meta::Attachment;
use crate::events::ServerEvent;
/// Borrows the per-turn event sender and emits typed [`ServerEvent`]s.
pub(super) struct TurnEmitter<'a> {
tx: &'a mpsc::Sender<ServerEvent>,
}
impl<'a> TurnEmitter<'a> {
pub(super) fn new(tx: &'a mpsc::Sender<ServerEvent>) -> Self {
Self { tx }
}
/// Send an event, dropping it silently if the receiver is gone (the same
/// `.await.ok()` semantics every call site used before).
async fn emit(&self, event: ServerEvent) {
self.tx.send(event).await.ok();
}
// ── User / assistant turn events ────────────────────────────────────────
/// A user message row was persisted (telnet-style echo).
pub(super) async fn user_message(&self, message_id: i64, content: String, attachments: Vec<Attachment>) {
self.emit(ServerEvent::UserMessage { message_id, content, attachments }).await;
}
/// The assistant produced text alongside tool calls (reasoning before acting).
pub(super) async fn thinking(&self, message_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>, reasoning_content: Option<String>) {
self.emit(ServerEvent::Thinking { message_id, content, input_tokens, output_tokens, reasoning_content }).await;
}
/// Clone of the underlying sender, for spawning side-channel tasks that
/// emit alongside the turn (e.g. the token-delta forwarder).
pub(super) fn sender(&self) -> mpsc::Sender<ServerEvent> {
self.tx.clone()
}
/// The assistant response is complete.
pub(super) async fn done(&self, message_id: i64, stack_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>, reasoning_content: Option<String>) {
self.emit(ServerEvent::Done { message_id, stack_id, content, input_tokens, output_tokens, reasoning_content }).await;
}
/// The LLM was cut off by the token limit.
pub(super) async fn truncated(&self, output_tokens: Option<u32>) {
self.emit(ServerEvent::Truncated { output_tokens }).await;
}
/// A fatal error occurred processing the request.
pub(super) async fn error(&self, message: String) {
self.emit(ServerEvent::Error { message }).await;
}
// ── Tool-call lifecycle ─────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn tool_start(
&self,
tool_call_id: i64,
message_id: i64,
name: String,
arguments: Value,
display_name: String,
icon: String,
label_short: String,
label_full: String,
path: Option<String>,
) {
self.emit(ServerEvent::ToolStart {
tool_call_id, message_id, name, arguments, display_name, icon, label_short, label_full, path,
}).await;
}
pub(super) async fn tool_done(
&self,
tool_call_id: i64,
result: String,
result_type: String,
preview_old: Option<String>,
preview_new: Option<String>,
) {
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type, preview_old, preview_new }).await;
}
pub(super) async fn tool_error(&self, tool_call_id: i64, error: String) {
self.emit(ServerEvent::ToolError { tool_call_id, error }).await;
}
pub(super) async fn tool_cancelled(&self, tool_call_id: i64) {
self.emit(ServerEvent::ToolCancelled { tool_call_id }).await;
}
pub(super) async fn tool_rejected(&self, tool_call_id: i64, reason: String) {
self.emit(ServerEvent::ToolRejected { tool_call_id, reason }).await;
}
/// A file-write tool completed; ask clients holding the file to reload.
pub(super) async fn file_changed(&self, path: String) {
self.emit(ServerEvent::FileChanged { path }).await;
}
// ── Approval / clarification prompts ────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn pending_write(
&self,
request_id: i64,
tool_call_id: i64,
path: String,
old_content: Option<String>,
new_content: String,
) {
self.emit(ServerEvent::PendingWrite { request_id, tool_call_id, path, old_content, new_content }).await;
}
pub(super) async fn approval_required(&self, request_id: i64, tool_call_id: i64, tool_name: String, arguments: Value) {
self.emit(ServerEvent::ApprovalRequired { request_id, tool_call_id, tool_name, arguments }).await;
}
// Note: `AgentQuestion` is emitted directly in `dispatch_ask_user_clarification`
// because that one site inspects the send Result for diagnostic logging — it is
// deliberately not wrapped here.
// ── Sub-agent stack frames ──────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) async fn agent_start(
&self,
stack_id: i64,
parent_tool_call_id: i64,
agent_id: String,
parent_agent_id: String,
depth: i64,
prompt_preview: String,
) {
self.emit(ServerEvent::AgentStart {
stack_id, parent_tool_call_id, agent_id, parent_agent_id, depth, prompt_preview,
}).await;
}
pub(super) async fn agent_done(&self, stack_id: i64, agent_id: String, parent_agent_id: String, result_preview: String) {
self.emit(ServerEvent::AgentDone { stack_id, agent_id, parent_agent_id, result_preview }).await;
}
// ── LLM model fallback ──────────────────────────────────────────────────
pub(super) async fn model_fallback(&self, from: String, to: String, reason: String) {
self.emit(ServerEvent::ModelFallback { from, to, reason }).await;
}
pub(super) async fn llm_failed(&self, tried: Vec<String>, last_error: String) {
self.emit(ServerEvent::LlmFailed { tried, last_error }).await;
}
}
@@ -1,138 +0,0 @@
//! Shared approval gate for a single tool call.
//!
//! The decision + human-approval flow (approval-engine check, RunContext
//! fast-path, auto-deny, register + await) was duplicated in `run_agent_turn` and
//! `resume_pending_tools`, and had already drifted (only the live loop applied the
//! RunContext fast-path and the auto-deny short-circuit). `run_approval_gate` is the
//! single implementation both call, so the two paths gate identically.
use std::sync::atomic::Ordering;
use serde_json::Value;
use tracing::{info, warn};
use crate::approval::GateResult;
use crate::db::chat_llm_tools;
use crate::run_context::RunContext;
use crate::tools::{is_file_read_tool, is_file_write_tool};
use super::{ApprovalDecision, ChatSessionHandler};
use super::emitter::TurnEmitter;
/// Result of the approval gate for a single tool call.
pub(super) enum GateOutcome {
/// The tool may execute.
Proceed,
/// Denied by policy, auto-denied, or rejected by a human. The DB row has been
/// marked `rejected` and the `ToolRejected` event emitted — the caller just
/// skips the call.
Rejected,
/// The approval channel closed (WS disconnected) while awaiting a decision.
/// The caller must end the turn / resume.
ChannelClosed,
}
impl ChatSessionHandler {
/// Runs a tool call through the approval engine and, when human approval is
/// required, registers the request, emits the approval event, and awaits the
/// decision. Shared by `run_agent_turn` and `resume_pending_tools`.
pub(super) async fn run_approval_gate(
&self,
tool_call_id: i64,
tool_name: &str,
args: &Value,
agent_id: &str,
em: &TurnEmitter<'_>,
) -> anyhow::Result<GateOutcome> {
let pool = &self.db;
// Post-restart manual resolve: this exact tool_call was already approved by the
// user via a resolve endpoint, which then triggered this resume. There is no
// live oneshot to unblock, so skip re-gating (and re-prompting) and dispatch it.
if self.pre_approved.lock().unwrap().remove(&tool_call_id) {
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "approval: pre-approved (post-restart resolve) — skipping gate");
return Ok(GateOutcome::Proceed);
}
let category = self.tools.category_of(tool_name);
let group_id = self.tool_group_id().await;
// The approval engine decides first: an explicit Deny/Allow rule always wins.
let mut gate = self.approval.check(
self.session_id, category,
agent_id, &self.source, tool_name, args,
group_id.as_deref(),
).await;
// RunContext fast-path: relax `Require` to `Allow` for pre-authorized
// filesystem paths. It never overrides a `Deny` (same semantics as session
// bypass), so e.g. the `secrets/` deny rule holds even inside an auto-read
// working directory.
if matches!(gate, GateResult::Require) {
let path = args["path"].as_str().unwrap_or("");
let guard = self.run_context.read().await;
let dflt = RunContext::default();
let rc = guard.as_ref().unwrap_or(&dflt);
let pre_allowed = if is_file_read_tool(tool_name) {
rc.is_read_allowed(path)
} else if is_file_write_tool(tool_name) {
rc.is_write_allowed(path)
} else {
false
};
if pre_allowed { gate = GateResult::Allow; }
}
match gate {
GateResult::Allow => Ok(GateOutcome::Proceed),
GateResult::Deny => {
let msg = "Tool call denied by approval policy.".to_string();
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "approval: denied");
chat_llm_tools::reject(pool, tool_call_id, &msg).await?;
em.tool_rejected(tool_call_id, msg).await;
Ok(GateOutcome::Rejected)
}
GateResult::Require => {
if self.auto_deny_approvals.load(Ordering::Relaxed) {
let msg = "Tool call auto-denied: this session does not support approval requests.".to_string();
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "auto_deny_approvals: denied");
chat_llm_tools::reject(pool, tool_call_id, &msg).await?;
em.tool_rejected(tool_call_id, msg).await;
return Ok(GateOutcome::Rejected);
}
// Mark as pending before suspending so restart/refresh shows the
// approval form (not "Interrupted") and auto-resume re-gates.
chat_llm_tools::set_approval_pending(pool, tool_call_id).await?;
let ctx_label = self.context_label.read().ok().and_then(|g| g.clone());
let (request_id, approve_rx) = self.approval.register(
self.session_id, tool_call_id, tool_name,
args.clone(), agent_id, &self.source,
ctx_label.as_deref(), category,
).await;
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, request_id, "approval: waiting for human");
self.emit_approval_event(em, request_id, tool_call_id, tool_name, args).await;
match approve_rx.await {
Ok(ApprovalDecision::Approved) => {
info!(session_id = self.session_id, request_id, tool = %tool_name, "approval: approved");
Ok(GateOutcome::Proceed)
}
Ok(ApprovalDecision::Rejected { note }) => {
info!(session_id = self.session_id, request_id, tool = %tool_name, %note, "approval: rejected");
let msg = ApprovalDecision::rejection_message(&note);
chat_llm_tools::reject(pool, tool_call_id, &msg).await?;
em.tool_rejected(tool_call_id, msg).await;
Ok(GateOutcome::Rejected)
}
Err(_) => {
// WS closed while waiting — session is orphaned.
warn!(session_id = self.session_id, request_id, "approval channel closed (WS disconnected), aborting");
Ok(GateOutcome::ChannelClosed)
}
}
}
}
}
}
@@ -12,7 +12,7 @@ pub use core_api::interface_tool::{InterfaceTool, ToolFuture};
/// All configuration for a single agent run (root or sub-agent).
///
/// Passed by reference to `run_agent_turn` and `dispatch_call_agent`.
/// Passed by reference to the turn builder (`UserLoopRuntime::turn_params`).
/// Callers build this once in `handle_message`; sub-agents receive a derived
/// config with an empty `interface_tools` (except `activate_tools`) and fresh
/// `active_mcp_grants`.
@@ -138,11 +138,11 @@ impl AgentRunConfig {
root_only(&mut defs);
// Strip the per-level augmentations that the config builders re-derive, so
// they are never inherited: `ask_user_clarification` is added by
// `build_agent_config` (root) and re-added by `dispatch_sub_agent`;
// `execute_subtask` is added by `dispatch_sub_agent`. Leaving them in the
// `build_agent_config` (root) and re-added by the agent catalog;
// `execute_subtask` is added by the catalog too. Leaving them in the
// inherited set would duplicate them (depth ≥ 1 for `ask_user_clarification`,
// depth ≥ 2 for `execute_subtask`) and the OpenAI-compat APIs reject
// non-unique tool names with HTTP 400. With this strip, `dispatch_sub_agent`
// non-unique tool names with HTTP 400. With this strip, the catalog
// is the single owner of sub-agent augmentation and duplication is
// structurally impossible — no dedup pass needed anywhere.
{
@@ -1,315 +1,128 @@
//! Kernel-driven root turn (phase 2, blueprint §14): `handle_message` builds
//! the turn's `TurnParams` from its fields and drives the `agent-loop` kernel
//! instead of `run_agent_turn`. The translator (`EventTranslator`) is the ONE
//! bus subscriber producing the session's `ServerEvent`s.
//! The session's turns, driven by the `agent-loop` kernel (blueprint §14).
//!
//! Sub-agents run on the same kernel via `DelegateTool` (sync); async
//! `execute_task` still rides the legacy interface handler until phase 3.
//! Recovery/resume stays on the old path until phase 3 as well.
//! Everything shared lives on the user's `UserLoopRuntime` (manager, store,
//! gate, catalog, delegate); this only assembles the turn's own state —
//! [`TurnScope`] plus the run config — and reads the outcome back. The
//! translator (`EventTranslator`) is the ONE bus subscriber producing the
//! session's `ServerEvent`s.
//!
//! Three entry points, one path:
//!
//! - [`run_kernel_turn`](ChatSessionHandler::run_kernel_turn) — a user message.
//! It repairs first: a call left dangling by a crash is resolved before the
//! new turn appends anything.
//! - [`recover_turn`](ChatSessionHandler::recover_turn) — no new message:
//! continue a turn that was interrupted (a client reconnecting, a background
//! job, a decision taken out of band).
//! - [`resolve_pending_call`](ChatSessionHandler::resolve_pending_call) — a
//! human answered an approval nothing is waiting on anymore.
//!
//! Sub-agents run on the same kernel via `DelegateTool`, sync and async alike.
use std::collections::HashMap;
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, NewMessage};
use agent_loop::tool::{Extensions, Tool as LoopTool, ToolSet};
use core_api::interface_tool::InterfaceTool;
use agent_loop::recovery::{HumanDecision, RecoveryPolicy, RecoveryReport};
use agent_loop::store::{NewMessage, Role};
use core_api::message_meta::MessageMetadata;
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
use crate::chat_event_bus::ToolCallEvent;
use crate::events::ServerEvent;
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
use crate::loop_adapters::assembler::SkaldAssembler;
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::SkaldWritePreviewHook;
use crate::loop_adapters::live_input::PendingLiveInput;
use crate::loop_adapters::preview::PreviewContext;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::{CallerUserId, SkaldToolSet};
use crate::loop_adapters::runtime::{TurnInputs, UserLoopRuntime};
use crate::loop_adapters::scope::TurnScope;
use crate::loop_adapters::translate::EventTranslator;
use crate::tools::tool_names as tn;
use super::interface_tools::AgentRunConfig;
use super::{ChatSessionHandler, MAX_AGENT_DEPTH, PendingUserInput, TurnOutcome};
use super::interface_tools::{AgentRunConfig, InterfaceTool};
use super::{ChatSessionHandler, PendingUserInput, TurnOutcome};
/// Special-cased names handled natively (never legacy-wrapped).
const NATIVE_NAMES: &[&str] = &[tn::ACTIVATE_TOOLS, tn::EXECUTE_TASK];
/// What Skald does with a conversation a crash left mid-flight.
///
/// `ReExecute` + `ReAsk` is the historical behavior: an interrupted call runs
/// again and an approval card reappears — except where the tool itself says
/// otherwise (`execute_cmd` declares `MarkInterrupted`, D7: a command may
/// already have had its effect).
fn policy() -> RecoveryPolicy {
RecoveryPolicy {
interrupted_text: "Error: this tool call was interrupted by a restart and was NOT \
re-run automatically (its effects may be partial). Re-run it if \
the task still needs it."
.to_string(),
..RecoveryPolicy::default()
}
}
impl ChatSessionHandler {
/// Runs the root turn on the `agent-loop` kernel. Same observable contract
/// as `run_agent_turn` on the root: events over `tx`, `TurnOutcome` back.
/// Runs the root turn on the `agent-loop` kernel: events over `tx`, the
/// turn's outcome back.
pub(super) async fn run_kernel_turn(
&self,
stack_id: i64,
config: &AgentRunConfig,
user_content: &str,
is_synthetic: bool,
metadata: Option<&MessageMetadata>,
pending_input: Option<&Arc<dyn PendingUserInput>>,
tx: &mpsc::Sender<ServerEvent>,
config: &AgentRunConfig,
user_content: &str,
is_synthetic: bool,
metadata: Option<&MessageMetadata>,
pending_input: Option<&Arc<dyn PendingUserInput>>,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<TurnOutcome> {
let pool = self.db.clone();
let shared_pool = self.shared_pool.clone();
let conv = ConversationId::new(format!("session:{}", self.session_id));
let rt = self.loop_runtime.clone();
let conv = UserLoopRuntime::conversation(self.session_id);
// ── Store ──
let store = Arc::new(SqliteHistory::new(pool.clone()));
// ── The turn's own state, read by the long-lived gate and catalog ──
let scope = Arc::new(self.turn_scope(config).await);
// ── Selector (root strength from the agent meta, D14) ──
let strength = crate::agents::load_meta(&config.agent_id)
.ok()
.and_then(|m| m.strength);
let selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(self.llm_manager.clone(), strength));
// ── Gate ──
let group_id = self.tool_group_id().await;
let gate = ApprovalGate::new(
self.approval.clone(),
store.clone(),
self.tools.clone(),
self.session_id,
&self.source,
group_id,
self.run_context.clone(),
self.pre_approved.clone(),
self.auto_deny_approvals.clone(),
self.context_label.clone(),
pool.clone(),
shared_pool.clone(),
Some(self.fs.clone()),
);
// ── Hooks ──
let preview_hook = Arc::new(SkaldWritePreviewHook::new(PreviewContext {
pool: pool.clone(),
shared_pool: shared_pool.clone(),
fs: Some(self.fs.clone()),
}));
// ── Manager ──
let manager = Arc::new(
LoopManager::builder()
.models(selector)
.store(store.clone())
.gate_arc(Arc::new(gate))
.hook(preview_hook)
.max_rounds(self.max_tool_rounds)
.max_parallel_calls(self.max_parallel_subagents)
.build()?,
);
// ── Catalog + delegate ──
let config_defs = Arc::new(config.config_tool_defs.clone());
let catalog = Arc::new(SkaldAgentCatalog::new(
pool.clone(),
shared_pool.clone(),
self.user_id.clone(),
self.session_id,
self.source.clone(),
self.is_interactive,
self.context_label.clone(),
self.llm_manager.clone(),
self.approval.clone(),
self.clarification.clone(),
self.mcp.clone(),
self.tools.clone(),
config.base_tool_defs.clone(),
config_defs.clone(),
config.memory_tools.clone(),
config.image_tools.clone(),
config.root_only_tool_names.clone(),
self.datetime_config.clone(),
self.max_history_messages,
self.max_tool_result_chars,
self.compactor.is_some(),
Some(self.fs.load()),
self.run_context.read().await.as_ref().and_then(|rc| rc.project_root.clone()),
));
let delegate = DelegateTool::new(manager.clone(), catalog.clone(), store.clone(), MAX_AGENT_DEPTH as u32);
catalog.set_delegate(delegate.clone());
// ── Tool set ──
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
// activate_tools (root scope — shares the config's grant set so the
// next round sees the new tools, exactly like today).
native.push(Arc::new(
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
pool.clone(),
self.mcp.clone(),
config.active_mcp_grants.clone(),
self.session_id,
None,
)))
.with_definition(super::config::activate_tools_tool_def()),
));
// execute_task: sync → DelegateTool; async → the legacy interface handler.
{
let et = native_interface(config, tn::EXECUTE_TASK);
let (def, handler) = match et {
Some(it) => (it.definition.clone(), Some(it.handler.clone())),
None => (legacy_execute_task_def(), None),
};
native.push(Arc::new(ExecuteTaskAliasTool::new(
delegate.clone().with_name(tn::EXECUTE_TASK),
def,
handler,
)));
}
native.push(Arc::new(SkaldAskUserTool::new(
Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
self.session_id,
&config.agent_id,
&self.source,
self.is_interactive,
self.context_label.clone(),
)),
store.clone(),
)));
native.push(Arc::new(UpdateScratchpadTool::new(pool.clone(), self.scratchpad_sid())));
native.push(Arc::new(WriteTodosTool));
// Legacy interface tools (per-surface, minus the native ones).
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())));
}
let mut toolset = SkaldToolSet::new(
config.base_tool_defs.clone(),
config_defs.clone(),
self.mcp.clone(),
config.active_mcp_grants.clone(),
config.memory_tools.clone(),
config.image_tools.clone(),
legacy,
self.tools.all_tools(),
)
.with_discovery(self.tool_discovery.clone());
for t in native {
toolset = toolset.with_native(t);
}
let tools: Arc<dyn ToolSet> = Arc::new(toolset);
// ── System context ──
let system = Arc::new(AgentSystemContext {
agent_id: config.agent_id.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: pool.clone(),
shared_pool: shared_pool.clone(),
user_id: self.user_id.clone(),
mcp: self.mcp.clone(),
project_root: self.run_context.read().await.as_ref().and_then(|rc| rc.project_root.clone()),
});
// ── Assembler ──
let assembler = Arc::new(SkaldAssembler {
pool: pool.clone(),
scratchpad_sid: self.scratchpad_sid(),
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.is_some(),
fs: Some(self.fs.load()),
activation: Some(SkaldActivationSource::new(
pool.clone(),
self.mcp.clone(),
config_defs.clone(),
self.session_id,
None,
)),
});
// ── Extensions (tool bridge context) ──
let mut extensions = Extensions::new();
extensions.insert(pool.clone());
extensions.insert(self.fs.load());
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
// ── Live input ──
let live_input: Option<Arc<dyn LiveInput>> =
pending_input.map(|p| Arc::new(PendingLiveInput::new(p.clone())) as Arc<dyn LiveInput>);
// ── Translator ──
// ── The one bus subscriber for this session's events ──
let (translator, shared) = EventTranslator::new(
tx.clone(),
conv.clone(),
self.tools.clone(),
self.mcp.clone(),
store.clone(),
rt.store().clone(),
);
let stop = CancellationToken::new();
let translator_task = translator.spawn(manager.events(), stop.clone());
let translator_task = translator.spawn(rt.manager().events(), stop.clone());
// ── Frame + turn ──
let frame = store
.open_frame(&conv, None, agent_loop::store::FrameSpec::root(&config.agent_id))
// ── Drive ──
let mut params = rt
.turn_params(TurnInputs { scope, config, live_input: pending_input.cloned() })
.await?;
// The frame opened at session provisioning is the one the old path
// used — assert the mapping (defensive; remove once bedded in).
debug_assert_eq!(frame.get(), stack_id);
params.meta.synthetic = is_synthetic;
// A previous turn may have died with a call still in flight. Repair it
// before appending anything: the model must never be shown a call with
// no result, and the resumed result belongs to the OLD turn, so it has
// to land before the new message. This does not re-drive that turn —
// the user has moved on.
let repaired = self.recovery().repair(&conv, &params).await?;
if repaired != agent_loop::recovery::RecoveryReport::default() {
info!(session_id = self.session_id, ?repaired, "repaired an interrupted turn");
}
let msg = NewMessage {
role: agent_loop::store::Role::User,
content: user_content.to_string(),
role: Role::User,
content: user_content.to_string(),
synthetic: is_synthetic,
reasoning: None,
metadata: metadata.and_then(|m| serde_json::to_value(m).ok()),
};
let params = TurnParams {
frame,
agent: config.agent_id.clone(),
system,
tools,
model_hint: ModelHint::name(config.client_name.clone()),
live_input,
extensions,
meta: TurnMeta {
synthetic: is_synthetic,
interactive: self.is_interactive,
..TurnMeta::default()
},
assembler: Some(assembler),
metadata: metadata.and_then(|m| serde_json::to_value(m).ok()),
};
// Register for /stop, then drive.
*self.kernel_live.lock().unwrap() = Some((manager.clone(), conv.clone()));
let handle = manager.start_turn(conv.clone(), msg, params).await
.map_err(|e| anyhow::anyhow!("kernel turn failed to start: {e}"))?;
let outcome = handle.join().await;
*self.kernel_live.lock().unwrap() = None;
let outcome = rt
.manager()
.start_turn(conv, msg, params)
.await
.map_err(|e| anyhow::anyhow!("kernel turn failed to start: {e}"))?
.join()
.await;
// Let the translator drain what the kernel emitted, then stop it.
stop.cancel();
let _ = translator_task.await;
let shared_state = std::mem::take(&mut *shared.lock().unwrap());
match outcome? {
agent_loop::kernel::TurnOutcome::Final { content, message_id, usage, reasoning } => {
agent_loop::kernel::TurnOutcome::Final { content, message_id, usage, .. } => {
let tool_calls: Vec<ToolCallEvent> = shared_state.tool_calls;
info!(
session_id = self.session_id,
@@ -321,8 +134,6 @@ impl ChatSessionHandler {
message_id: message_id.get(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
truncated: usage.truncated,
reasoning_content: reasoning,
tool_calls,
})
}
@@ -331,46 +142,138 @@ impl ChatSessionHandler {
}
}
/// `/stop` for the kernel-driven turn: cancels the live loop (the legacy
/// `current_cancel` path keeps covering resume/recovery).
pub(super) fn cancel_kernel_turn(&self) {
let live = self.kernel_live.lock().unwrap().clone();
if let Some((manager, conv)) = live {
manager.cancel(&conv);
/// Continues a turn nobody is driving: a client reconnecting to a session
/// that was mid-tool when the process died, a background job's parent, or a
/// conversation woken by an async result.
///
/// No new user message — the history already says what to do. Sub-agent
/// frames cascade back to the root, each running as **its own** agent.
pub async fn recover_turn(
&self,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
let report = self.drive_recovery(interface_tools, tx, None).await?;
info!(session_id = self.session_id, ?report, "recover_turn done");
Ok(())
}
/// Applies a human's decision to a call that has no loop waiting on it — an
/// approval card answered after a restart, or from the Inbox — then
/// continues the conversation.
///
/// Approval **skips the gate** (the human just decided) but not the
/// context: the tool runs with this session's `ToolContext`, so a write
/// lands in the caller's workspace and a command in their container, never
/// on the host (blueprint §6).
pub async fn resolve_pending_call(
&self,
call: i64,
decision: HumanDecision,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
let report = self.drive_recovery(interface_tools, tx, Some((call, decision))).await?;
info!(session_id = self.session_id, call, ?report, "resolve_pending_call done");
Ok(())
}
/// The shared body of the two entry points above: build the root turn's
/// parameters, subscribe the translator, run recovery (optionally applying
/// a human decision first), drain the events.
async fn drive_recovery(
&self,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
decision: Option<(i64, HumanDecision)>,
) -> anyhow::Result<RecoveryReport> {
let rt = self.loop_runtime.clone();
let conv = UserLoopRuntime::conversation(self.session_id);
let mut config = self
.build_agent_config(None, None, None, interface_tools, HashMap::new())
.await?;
// The tail reminder belongs to a fresh user message, not to finishing
// work that was already under way.
config.tail_reminder = None;
let scope = Arc::new(self.turn_scope(&config).await);
let (translator, _shared) = EventTranslator::new(
tx.clone(),
conv.clone(),
self.tools.clone(),
self.mcp.clone(),
rt.store().clone(),
);
let stop = CancellationToken::new();
let translator_task = translator.spawn(rt.manager().events(), stop.clone());
let params = rt
.turn_params(TurnInputs { scope, config: &config, live_input: None })
.await?;
let result = match decision {
Some((call, decision)) => {
rt.manager()
.resolve_pending(
agent_loop::ids::ToolCallId(call),
decision,
rt.catalog().clone(),
&params,
)
.await
}
None => self.recovery().run(&conv, &params).await,
};
stop.cancel();
let _ = translator_task.await;
result
}
/// Recovery bound to this user's manager, with Skald's policy.
fn recovery(&self) -> agent_loop::recovery::Recovery {
let rt = &self.loop_runtime;
rt.manager().recovery(rt.catalog().clone(), policy())
}
/// The turn's scope: identity, the live cells the gate watches, and the tool
/// material a sub-agent derives its own set from.
async fn turn_scope(&self, config: &AgentRunConfig) -> TurnScope {
TurnScope {
session_id: self.session_id,
source: self.source.clone(),
is_interactive: self.is_interactive,
agent_id: config.agent_id.clone(),
scratchpad_sid: self.scratchpad_sid(),
project_root: self
.run_context
.read()
.await
.as_ref()
.and_then(|rc| rc.project_root.clone()),
context_label: self.context_label.clone(),
run_context: self.run_context.clone(),
group_id: self.tool_group_id().await,
pre_approved: self.pre_approved.clone(),
auto_deny: self.auto_deny_approvals.clone(),
grants: config.active_mcp_grants.clone(),
base_defs: Arc::new(config.base_tool_defs.clone()),
config_defs: Arc::new(config.config_tool_defs.clone()),
memory_tools: Arc::new(config.memory_tools.clone()),
image_tools: Arc::new(config.image_tools.clone()),
root_only: Arc::new(config.root_only_tool_names.clone()),
}
}
}
/// Finds an interface tool by name in the run config.
fn native_interface(config: &AgentRunConfig, name: &str) -> Option<InterfaceTool> {
config
.interface_tools
.iter()
.find(|it| it.definition["function"]["name"].as_str() == Some(name))
.cloned()
}
/// 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"]
}
}
})
/// `/stop` for the kernel-driven turn: the manager cancels the live loop of
/// this conversation (the legacy `current_cancel` path still covers
/// resume/recovery).
pub(super) fn cancel_kernel_turn(&self) {
self.loop_runtime
.manager()
.cancel(&UserLoopRuntime::conversation(self.session_id));
}
}
@@ -1,283 +0,0 @@
//! One LLM call per round, with automatic model fallback.
//!
//! Extracted from `run_agent_turn`: on a retriable error (5xx / network) it retries
//! up to `MAX_LLM_ATTEMPTS` models in priority order, rebuilding the message list
//! when the replacement model has a different `prompt_cache` setting, and emits
//! `ModelFallback` / `LlmFailed` along the way. The call itself goes through the
//! `agent_loop::model::Model` trait (blueprint D13) — clients and protocols live
//! in the `agent-loop` crate.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{error, warn};
use agent_loop::ids::{ConversationId, FrameId};
use agent_loop::model::{ModelRequest, ModelResponse, StreamDelta};
use crate::db::llm_request_payloads;
use crate::events::{ServerEvent, TokenDeltaKind};
use crate::llm::{LlmEntry, LlmStrength};
use super::ChatSessionHandler;
use super::emitter::TurnEmitter;
use super::interface_tools::AgentRunConfig;
/// Outcome of one round's LLM call.
pub(super) enum RoundLlm {
/// The model responded (message or tool calls). Boxed: `ModelResponse`
/// dwarfs the other variants.
Turn(Box<ModelResponse>),
/// The turn was cancelled (`/stop`) while the request was in flight.
Cancelled,
/// All fallback attempts were exhausted, or an error is non-retriable.
Failed(anyhow::Error),
}
/// Maximum number of models tried in one round before giving up.
const MAX_LLM_ATTEMPTS: usize = 3;
impl ChatSessionHandler {
/// Calls the current model and, on a retriable failure, falls back to the next
/// model in priority order. Mutates `cur_name` / `cur_llm` / `messages` in place
/// so the caller keeps using the model that actually produced the turn.
#[allow(clippy::too_many_arguments)]
pub(super) async fn call_llm_round(
&self,
stack_id: i64,
config: &AgentRunConfig,
active_grants: &HashSet<String>,
req_strength: Option<LlmStrength>,
cur_name: &mut String,
cur_llm: &mut Arc<LlmEntry>,
messages: &mut Vec<Value>,
token: &CancellationToken,
em: &TurnEmitter<'_>,
) -> RoundLlm {
let mut tried_this_round: Vec<String> = vec![cur_name.clone()];
loop {
// Re-derive the tool defs for the model actually serving this attempt:
// a fallback across DTL modes must re-shape (deferred candidates or not).
let cur_tool_defs = config.all_tool_defs(cur_llm.dtl);
let request_id = uuid::Uuid::new_v4().to_string();
// Tell the model, in read_file's description, which media formats it can
// open directly — keyed on the model actually serving this attempt, so a
// fallback to a text-only model drops the claim. `None` (no media
// capability) leaves the shared defs untouched, avoiding a clone.
let annotated = media_annotated_tools(&cur_tool_defs, &cur_llm.capabilities);
let defs: &[Value] = annotated.as_deref().unwrap_or(&cur_tool_defs);
// Clone the Arc so the in-flight future does not borrow `cur_llm` across
// the fallback reassignment below. On cancel we drop the future
// (aborting the request) and return immediately.
let client = cur_llm.client.clone();
let request = ModelRequest {
messages: messages.clone(),
tools: defs.to_vec(),
model: cur_llm.model.clone(),
max_tokens: None,
temperature: None,
request_id: request_id.clone(),
conversation: ConversationId::new(format!("session:{}", self.session_id)),
frame: FrameId(stack_id),
extras: Value::Null,
// Correlation for the LoggingModel decorator (never sent).
log: Some(json!({
"session_id": self.session_id,
"stack_id": stack_id,
"user_id": self.user_id,
})),
};
// Streaming side-channel: providers that support SSE push deltas here;
// the forwarder re-emits them as `TokenDelta` events on the turn bus.
// Best-effort — the round's final events remain authoritative.
let (delta_tx, delta_rx) = mpsc::channel::<StreamDelta>(256);
let forwarder = spawn_delta_forwarder(delta_rx, em.sender());
let call_result = tokio::select! {
_ = token.cancelled() => return RoundLlm::Cancelled,
r = client.complete(&request, Some(delta_tx)) => r,
};
// The client's sender dropped with the completed future: the forwarder
// drains any queued deltas and exits, so every `TokenDelta` precedes the
// round's outcome events (Thinking / Done) in bus order.
forwarder.await.ok();
let e = match call_result {
Ok(resp) => {
self.llm_manager.mark_success(cur_name).await;
// Persist the payload (request/response bodies + headers) to the
// user's own database. Fire-and-forget — a failed write must not
// break the turn. The metadata row is already written by the
// LoggingModel decorator to system.db with the same request_id.
if let Some(meta) = resp.raw() {
let pool = Arc::clone(&self.db);
let rid = request_id.clone();
let row = llm_request_payloads::PayloadRow {
request_id: rid,
request_json: meta.request_body.as_ref().map(|v| v.to_string()).unwrap_or_default(),
request_headers: meta.request_headers.as_ref().map(|v| v.to_string()),
response_json: meta.response_body.as_ref().map(|v| v.to_string()),
response_headers: meta.response_headers.as_ref().map(|v| v.to_string()),
};
tokio::spawn(async move {
if let Err(e) = llm_request_payloads::insert(&pool, row).await {
tracing::warn!(error = %e, "llm_request_payloads: failed to insert");
}
});
}
return RoundLlm::Turn(Box::new(resp));
}
Err(e) => e,
};
// Persist the payload even on failure so the debug log shows the request
// that was rejected (e.g. a provider 400). Only HTTP failures attach a
// body (`ModelError::raw`); a network/parse/cancel error carries none.
// Fire-and-forget, keyed on the same `request_id` as the metadata row the
// LoggingModel decorator wrote to system.db.
if let Some(meta) = e.raw.as_ref() {
let row = llm_request_payloads::PayloadRow {
request_id: request_id.clone(),
request_json: meta.request_body.as_ref().map(|v| v.to_string()).unwrap_or_default(),
request_headers: meta.request_headers.as_ref().map(|v| v.to_string()),
response_json: meta.response_body.as_ref().map(|v| v.to_string()),
response_headers: meta.response_headers.as_ref().map(|v| v.to_string()),
};
let pool = Arc::clone(&self.db);
tokio::spawn(async move {
if let Err(e) = llm_request_payloads::insert(&pool, row).await {
tracing::warn!(error = %e, "llm_request_payloads: failed to insert error payload");
}
});
}
error!(session_id = self.session_id, client = %cur_name, error = %e, "LLM call failed");
self.llm_manager.mark_failure(cur_name, &e.to_string()).await;
let can_fallback = tried_this_round.len() < MAX_LLM_ATTEMPTS
&& client.is_retriable(&e);
if !can_fallback {
em.llm_failed(tried_this_round.clone(), e.to_string()).await;
return RoundLlm::Failed(e.into());
}
let excluded: Vec<&str> = tried_this_round.iter().map(String::as_str).collect();
match self.llm_manager.select_excluding(&excluded, req_strength).await {
Ok((next_name, next_llm)) => {
warn!(session_id = self.session_id, from = %cur_name, to = %next_name, "LLM fallback");
em.model_fallback(cur_name.clone(), next_name.clone(), first_line(&e.to_string())).await;
tried_this_round.push(next_name.clone());
*cur_name = next_name;
*cur_llm = next_llm;
// Rebuild messages if the new model uses different prompt_cache
// settings (e.g. switching from OpenRouter/Anthropic to DeepSeek)
// or different input capabilities (a non-vision fallback drops
// inline media back to the textual path block).
let activation_stack = if config.depth == 0 { None } else { Some(stack_id) };
match self.build_openai_messages(
&self.db, stack_id, &config.agent_id,
config.extra_system.as_deref(), config.extra_system_dynamic.as_deref(),
config.tail_reminder.as_deref(), active_grants,
&config.system_substitutions, cur_llm.prompt_cache, &cur_llm.capabilities,
cur_llm.dtl, &config.config_tool_defs, activation_stack,
).await {
Ok(m) => *messages = m,
Err(e) => return RoundLlm::Failed(e),
}
}
Err(_) => {
em.llm_failed(tried_this_round.clone(), e.to_string()).await;
return RoundLlm::Failed(e.into());
}
}
}
}
}
/// Forwards streaming deltas from the LLM client onto the turn's event channel
/// as `TokenDelta` events. Exits when the client drops its sender (call
/// completed or aborted) or when the turn receiver is gone.
fn spawn_delta_forwarder(
mut rx: mpsc::Receiver<StreamDelta>,
tx: mpsc::Sender<ServerEvent>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(d) = rx.recv().await {
let (kind, delta) = match d {
StreamDelta::Text(t) => (TokenDeltaKind::Content, t),
StreamDelta::Reasoning(t) => (TokenDeltaKind::Reasoning, t),
};
if tx.send(ServerEvent::TokenDelta { kind, delta }).await.is_err() {
break;
}
}
})
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).to_string()
}
/// Appends a per-model media hint to `read_file`'s description when the resolved
/// model can view images/video/PDFs, so the model knows reading one of those shows
/// it the content natively. Returns `None` (leaving the shared, model-independent
/// defs untouched — no clone) when the model has no media modality. Done here, per
/// attempt, so a fallback to a different model re-derives the hint from its caps.
fn media_annotated_tools(tool_defs: &[Value], capabilities: &[String]) -> Option<Vec<Value>> {
let hint = super::media::media_capability_hint(capabilities)?;
let mut out = tool_defs.to_vec();
for def in &mut out {
if def["function"]["name"].as_str() == Some("read_file") {
if let Some(d) = def["function"]["description"].as_str() {
def["function"]["description"] = Value::String(format!("{d}{hint}"));
}
break;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use agent_loop::model::{Model, ModelError, ModelRequest, ModelResponse, StreamDelta};
use async_trait::async_trait;
use tokio::sync::mpsc;
struct Dummy;
#[async_trait]
impl agent_loop::model::Model for Dummy {
async fn complete(
&self,
_req: &ModelRequest,
_d: Option<mpsc::Sender<StreamDelta>>,
) -> Result<ModelResponse, ModelError> {
unreachable!()
}
}
/// Retriability classification lives on the `Model` trait default (the crate
/// owns the protocols, blueprint D13): 401/403/404/422 don't retry,
/// 400/429/5xx/network do. Classification keys on the structured status,
/// never on the message string (bug B6 regression).
#[test]
fn retriability_keys_on_structured_status() {
let m = Dummy;
for code in [401, 403, 404, 422] {
assert!(!m.is_retriable(&ModelError::new(Some(code), "nope")), "{code} must not retry");
}
for code in [400, 429, 500, 502, 503] {
assert!(m.is_retriable(&ModelError::new(Some(code), "retry")), "{code} must retry");
}
// A 500 whose body mentions "1401 tokens" / "code 404" must still retry.
assert!(m.is_retriable(&ModelError::new(
Some(500),
"provider error: too many (1401) tokens, see code 404 in docs"
)));
assert!(m.is_retriable(&ModelError::new(None, "connection reset by peer")));
}
}
@@ -1,472 +0,0 @@
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, trace};
use crate::chat_event_bus::ToolCallEvent;
use agent_loop::model::{ModelResponse, ToolCall};
use crate::db::{chat_history, chat_llm_tools};
use crate::events::ServerEvent;
use crate::tools::{
ExecutionOutcome, SimpleExecution, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
};
use futures::stream::{self, StreamExt};
use super::{ChatSessionHandler, PendingUserInput, TurnOutcome};
use super::dispatch::{is_sync_sub_agent, DispatchResult};
use super::emitter::TurnEmitter;
use super::gate::GateOutcome;
use super::llm_call::RoundLlm;
use super::outcome::RecordFlow;
use super::interface_tools::AgentRunConfig;
/// Whether, after handling one tool call, the round loop should continue to the
/// next call or the whole turn should end.
enum CallFlow {
Continue,
End(TurnOutcome),
}
/// Outcome of gating + dispatching one call inside a concurrent sub-agent batch,
/// carried from the concurrent phase to the ordered recording phase.
enum GatedExec {
/// Gate passed; the sub-agent produced an outcome to record. `arguments` is
/// the call's args (used for FileChanged / logging).
Done { arguments: serde_json::Value, outcome: ExecutionOutcome },
/// Approval gate rejected the call — already marked/emitted by the gate; skip it.
Rejected,
/// The turn must end now: the clarification WS channel closed (dispatch returned
/// `AbortPending`) or the approval gate's channel closed.
AbortTurn,
}
impl ChatSessionHandler {
/// Inner loop of an agent (root or sub). Persists messages to `stack_id`,
/// emits Thinking/ToolStart/ToolDone/PendingWrite/ApprovalRequired/AgentStart/AgentDone events.
/// Returns the outcome; the caller decides what to emit on completion
/// (Done for root, AgentDone+tool-result for sub-agents).
pub(super) fn run_agent_turn<'a>(
&'a self,
stack_id: i64,
config: &'a AgentRunConfig,
token: &'a CancellationToken,
tx: &'a mpsc::Sender<ServerEvent>,
// Queued user input for live injection (root interactive turn only).
// `None` for sub-agents / resume / non-interactive runners.
pending_input: Option<&'a Arc<dyn PendingUserInput>>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<TurnOutcome>> + Send + 'a>> {
Box::pin(async move {
let pool = &self.db;
let em = TurnEmitter::new(tx);
// Resolve the initial model. `cur_name`/`cur_llm` are updated in-place
// when the fallback logic switches to a different model mid-turn.
let mut cur_name = config.client_name.clone();
let mut cur_llm = self.llm_manager.get(&cur_name).await
.ok_or_else(|| anyhow::anyhow!("LLM client '{}' not found", cur_name))?;
// Strength needed for fallback re-selection.
let meta = crate::agents::load_meta(&config.agent_id).ok();
let req_strength = meta.as_ref().and_then(|m| m.strength);
// Accumulates tool calls across all rounds for the event bus.
let mut all_tool_calls: Vec<ToolCallEvent> = Vec::new();
for round in 0..self.max_tool_rounds {
if token.is_cancelled() {
return Ok(TurnOutcome::Cancelled);
}
// ── Live user-message injection ─────────────────────────────────────
// A round boundary is the one clean ordering point: the previous
// round's assistant message + tool results are all persisted, so a
// `user` row appended here is well-ordered. Each queued message is
// saved individually and echoed (telnet-style: the bubble appears only
// now), then picked up by `build_openai_messages` below in this same
// round — so the model sees it immediately. The MessageBuilder merges
// consecutive user rows into one `role:user` for the LLM. Does not
// reset the round budget. Only ever `Some` for the root interactive turn.
if let Some(input) = pending_input {
for msg in input.drain_user().await {
let attachments = msg.metadata.as_ref()
.map(|m| m.attachments.clone())
.unwrap_or_default();
// A custom slash command persists its expanded template (for LLM
// replay) but the bubble must show the typed command — emit the
// command's `display` form when present.
let echo = msg.metadata.as_ref()
.and_then(|m| m.command.as_ref())
.map(|c| c.display.clone())
.unwrap_or_else(|| msg.content.clone());
let id = chat_history::append_with_metadata(
pool, stack_id, &chat_history::Role::User,
&msg.content, false, None, msg.metadata.as_ref(),
).await?;
em.user_message(id, echo, attachments).await;
}
}
trace!(session_id = self.session_id, stack_id, agent_id = config.agent_id, round, "starting round");
let active_grants_snapshot = config.active_mcp_grants
.read()
.map(|g| g.clone())
.unwrap_or_default();
// Messages are (re)built with the current model's prompt_cache flag.
// On fallback within the same round `call_llm_round` rebuilds them again
// if the replacement model has a different prompt_cache setting.
// Activation scope for the DTL serializer: session-scoped for the root
// agent (stack_id NULL), the frame itself for a sub-agent.
let activation_stack = if config.depth == 0 { None } else { Some(stack_id) };
let mut messages = self.build_openai_messages(pool, stack_id, &config.agent_id, config.extra_system.as_deref(), config.extra_system_dynamic.as_deref(), config.tail_reminder.as_deref(), &active_grants_snapshot, &config.system_substitutions, cur_llm.prompt_cache, &cur_llm.capabilities, cur_llm.dtl, &config.config_tool_defs, activation_stack).await?;
let tool_defs = config.all_tool_defs(cur_llm.dtl);
// Record every tool actually offered to the LLM so the Security-groups
// UI can list/gate dynamically-injected tools. Cheap no-op once each
// name is known; new names are persisted off the turn's critical path.
self.tool_discovery.observe(&tool_defs);
// One LLM call for this round, with automatic model fallback on
// retriable errors. `cur_name`/`cur_llm`/`messages` are updated in place.
let turn_result = match self.call_llm_round(
stack_id, config, &active_grants_snapshot,
req_strength,
&mut cur_name, &mut cur_llm, &mut messages, token, &em,
).await {
RoundLlm::Turn(t) => t,
RoundLlm::Cancelled => return Ok(TurnOutcome::Cancelled),
RoundLlm::Failed(e) => return Err(e),
};
match *turn_result {
ModelResponse::Message { content, reasoning, usage, .. } => {
let message_id = chat_history::append(
pool, stack_id, &chat_history::Role::Assistant, &content, false,
reasoning.as_deref(),
).await?;
if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
chat_history::set_usage(pool, message_id, i, o, 0, usage.cost_usd).await?;
}
return Ok(TurnOutcome::Final {
content,
message_id,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
truncated: usage.truncated,
reasoning_content: reasoning,
tool_calls: all_tool_calls,
});
}
ModelResponse::ToolCalls { content: assistant_text, calls, usage, reasoning, .. } => {
let (input_tokens, output_tokens) = (usage.input_tokens, usage.output_tokens);
let message_id = chat_history::append(
pool, stack_id, &chat_history::Role::Assistant, &assistant_text, false,
reasoning.as_deref(),
).await?;
if let (Some(i), Some(o)) = (input_tokens, output_tokens) {
chat_history::set_usage(pool, message_id, i, o, 0, usage.cost_usd).await?;
}
if !assistant_text.trim().is_empty() || input_tokens.is_some() {
em.thinking(message_id, assistant_text, input_tokens, output_tokens, reasoning).await;
}
// A homogeneous batch of ≥2 synchronous sub-agent calls is fanned
// out concurrently (bounded by `max_parallel_subagents`). Any other
// shape — a single call, or a mix with regular tools — keeps the
// strictly sequential path, so tool ordering and side-effects are
// unchanged for everything except this well-defined case.
if calls.len() >= 2 && calls.iter().all(|c| is_sync_sub_agent(&c.name, &c.arguments)) {
match self.handle_sub_agent_batch(
stack_id, config, message_id, &calls, token, tx, &em, &mut all_tool_calls,
).await? {
CallFlow::Continue => {}
CallFlow::End(outcome) => return Ok(outcome),
}
} else {
for call in &calls {
// Stop before each call so a /stop (or a cancelled sub-agent,
// which shares this token) aborts the rest of the round.
if token.is_cancelled() {
return Ok(TurnOutcome::Cancelled);
}
match self.handle_tool_call(
stack_id, config, message_id, call, token, tx, &em, &mut all_tool_calls,
).await? {
CallFlow::Continue => {}
CallFlow::End(outcome) => return Ok(outcome),
}
}
}
}
}
}
Ok(TurnOutcome::Exhausted)
}) // end Box::pin
}
/// Handles a single tool call within a round: persists the call row, emits
/// `ToolStart`, resolves the working directory, runs the approval gate, handles
/// `restart`, dispatches, and records the outcome. Returns [`CallFlow::Continue`]
/// Card metadata (friendly display name + semantic icon key) for a tool call.
/// Delegates to the registry seam [`ToolRegistry::display_meta`], then layers the
/// MCP display-name override on for an `mcp__server__tool` name (manifest title >
/// live MCP `title` > the prettified name the seam already produced). The single
/// place the live loop resolves a card title, mirroring `describe_call`.
pub(super) fn tool_ui_meta(&self, name: &str, args: &serde_json::Value) -> (String, String) {
let mut meta = self.tools.display_meta(name, args);
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name) {
if let Some(friendly) = self.mcp.tool_display_name(server, tool) {
meta.display_name = friendly;
}
}
(meta.display_name, meta.icon)
}
/// to move on to the next call, or [`CallFlow::End`] to end the whole turn.
#[allow(clippy::too_many_arguments)]
async fn handle_tool_call(
&self,
stack_id: i64,
config: &AgentRunConfig,
message_id: i64,
call: &ToolCall,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
em: &TurnEmitter<'_>,
all_tool_calls: &mut Vec<ToolCallEvent>,
) -> anyhow::Result<CallFlow> {
let pool = &self.db;
let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
display_name, icon,
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments),
).await;
// Tool calls receive their arguments unchanged — the session working
// directory is always the user's home (`~`), and the agent references
// project files via their absolute agent path. `call.arguments` is both
// logged and executed.
match self.run_approval_gate(tool_call_id, &call.name, &call.arguments, &config.agent_id, em).await? {
GateOutcome::Proceed => {}
GateOutcome::Rejected => return Ok(CallFlow::Continue),
GateOutcome::ChannelClosed => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
}
debug!(session_id = self.session_id, tool = %call.name, tool_call_id, "dispatching");
// Route the approved call to its executor. `AbortPending` means the
// clarification WS channel closed — end the turn and leave the tool
// `pending` for resume to re-ask.
let (outcome, preview) = match self.execute_tool_call(
stack_id, config, tool_call_id, &call.name, &call.arguments, token, tx,
).await {
DispatchResult::Outcome { outcome, preview } => (outcome, preview),
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
};
// Persist the durable effect of `activate_tools`, anchored at the assistant
// `message_id` that triggered it (the anchor the DTL serializer positions
// injected tool blocks against). The in-memory grant set was already updated
// inside the tool; this records it across turns and restarts.
if call.name == crate::tools::tool_names::ACTIVATE_TOOLS {
if let Some(groups) = call.arguments.get("groups").and_then(|g| g.as_array()) {
// Root (depth 0) → session-scoped (stack_id NULL); sub-agent → its frame.
let anchor_stack = if config.depth == 0 { None } else { Some(stack_id) };
for g in groups.iter().filter_map(|v| v.as_str()) {
let kind = if g == crate::tools::tool_names::CONFIG_GROUP { "builtin" } else { "mcp" };
if let Err(e) = crate::db::activated_tools::grant(
pool, self.session_id, anchor_stack, message_id, kind, g,
).await {
tracing::warn!(session_id = self.session_id, group = g, error = %e, "activate_tools: failed to persist activation");
}
}
}
}
match self.record_tool_outcome(
tool_call_id, &call.name, &call.arguments, outcome, preview, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => Ok(CallFlow::Continue),
RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)),
}
}
/// Concurrent variant of the tool-call loop for a homogeneous batch of
/// synchronous sub-agent calls (`execute_task` mode=sync / `execute_subtask`).
/// Only called when every call in the round is such a sub-agent (see the
/// dispatch in `run_agent_turn`), so `restart` and side-effecting tools can
/// never appear here and the sequential path is left byte-for-byte intact.
///
/// Ordering invariant: the LLM reconstructs tool results by autoincrement id
/// (`chat_llm_tools ORDER BY id ASC`). **Phase 1** therefore allocates every
/// call's row in `calls` order *before* any concurrent work, so completion
/// order is irrelevant. **Phase 2** runs the approval gate + dispatch for all
/// calls concurrently, bounded by `max_parallel_subagents`. **Phase 3** records
/// the outcomes back in `calls` order, so `all_tool_calls` ordering and the
/// shared-token cancellation semantics match the sequential path.
#[allow(clippy::too_many_arguments)]
async fn handle_sub_agent_batch(
&self,
stack_id: i64,
config: &AgentRunConfig,
message_id: i64,
calls: &[ToolCall],
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
em: &TurnEmitter<'_>,
all_tool_calls: &mut Vec<ToolCallEvent>,
) -> anyhow::Result<CallFlow> {
let pool = &self.db;
// ── Phase 1: allocate tool_call_id rows in `calls` order ────────────────────
// The id fixes the LLM-visible order regardless of which sub-agent finishes
// first, so this pre-pass MUST stay sequential and precede the fan-out.
let mut started: Vec<(&ToolCall, i64)> = Vec::with_capacity(calls.len());
for call in calls {
let args_str = serde_json::to_string(&call.arguments)
.unwrap_or_else(|_| "{}".to_string());
let tool_call_id = chat_llm_tools::append(pool, message_id, &call.name, &args_str).await?;
let (display_name, icon) = self.tool_ui_meta(&call.name, &call.arguments);
em.tool_start(
tool_call_id, message_id,
call.name.clone(),
call.arguments.clone(),
display_name, icon,
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Short),
self.tools.describe_call(&call.name, &call.arguments, ToolDescriptionLength::Full),
self.tools.target_path(&call.name, &call.arguments),
).await;
started.push((call, tool_call_id));
}
// ── Phase 2: gate + dispatch concurrently, bounded ──────────────────────────
// Every future borrows `&self`/`config`/`token`/`tx`/`em` (all shared refs)
// and writes only to its own distinct child stack + tool_call_id, so there is
// no shared mutable state between siblings. Results are keyed back by index.
let limit = self.max_parallel_subagents.max(1);
let mut results: Vec<Option<GatedExec>> = (0..started.len()).map(|_| None).collect();
// Feed the stream fully-owned items `(idx, tool_call_id, name, arguments)`.
// Passing a borrowed `&ToolCall` as the closure input makes the returned async
// block's lifetime higher-ranked ("FnOnce is not general enough"); owning the
// per-call data means each future only borrows `self`/`config`/`token`/`tx`/`em`
// from the enclosing scope, all at the single concrete turn lifetime.
let jobs: Vec<(usize, i64, String, serde_json::Value)> = started.iter().enumerate()
.map(|(idx, (call, id))| (idx, *id, call.name.clone(), call.arguments.clone()))
.collect();
{
let mut stream = stream::iter(jobs)
.map(|(idx, tool_call_id, name, arguments)| async move {
let gated = match self.run_approval_gate(
tool_call_id, &name, &arguments, &config.agent_id, em,
).await {
Ok(GateOutcome::Proceed) => match self.execute_tool_call(
stack_id, config, tool_call_id, &name, &arguments, token, tx,
).await {
// Sub-agent batches never carry a file-write preview.
DispatchResult::Outcome { outcome, .. } => Ok(GatedExec::Done { arguments, outcome }),
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
},
Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected),
Ok(GateOutcome::ChannelClosed) => Ok(GatedExec::AbortTurn),
Err(e) => Err(e),
};
(idx, gated)
})
.buffer_unordered(limit);
while let Some((idx, gated)) = stream.next().await {
results[idx] = Some(gated?);
}
}
// ── Phase 3: record outcomes in `calls` order ───────────────────────────────
let mut abort = false;
for (idx, (call, tool_call_id)) in started.iter().enumerate() {
match results[idx].take().expect("every started sub-agent call produced a result") {
// The gate already marked the row rejected and emitted the event.
GatedExec::Rejected => {}
GatedExec::AbortTurn => abort = true,
GatedExec::Done { arguments, outcome } => {
match self.record_tool_outcome(
*tool_call_id, &call.name, &arguments, outcome, None, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => abort = true,
}
}
}
}
// The shared token means a /stop (or a cancelled sibling) has already stopped
// the others; ending the turn here mirrors the sequential path's early return.
if abort || token.is_cancelled() {
Ok(CallFlow::End(TurnOutcome::Cancelled))
} else {
Ok(CallFlow::Continue)
}
}
/// Builds a [`ToolExecution`] for a single tool call, covering every tool that
/// flows through the unified (cancellable) dispatch path: interface tools,
/// memory/image tools, MCP tools, and the built-in registry (incl.
/// `execute_cmd`). Returns `None` only for an unknown tool name. The handle
/// borrows `self` and `config`, both of which outlive the turn.
pub(super) fn build_execution<'a>(
&'a self,
name: &str,
args: serde_json::Value,
config: &'a AgentRunConfig,
) -> Option<Box<dyn ToolExecution + 'a>> {
// Interface tools (closures injected per-interface, e.g. activate_tools).
if let Some(tool) = config.interface_tools.iter().find(|t| t.name() == name) {
let handler = std::sync::Arc::clone(&tool.handler);
return Some(Box::new(SimpleExecution::new(
Box::pin(async move { handler(args).await.map(ToolResult::Text) }),
)));
}
// The ToolContext carries this session's id, owner user id and owner pool
// so owner-bound tools (cron management, the Honcho memory peer) act on the
// caller's own data. Built once and shared by memory tools and the registry.
let ctx = ToolContext {
session_id: self.session_id,
user_id: self.user_id.clone(),
pool: Arc::clone(&self.db),
// Snapshot the fs cell for the duration of this tool call — a concurrent
// shared-folder remount swaps the cell, the next call picks it up (§6).
fs: self.fs.load(),
};
// Memory + image tools (registered ad-hoc on the config). Memory tools route
// through `run_with` so the Honcho tools reach the caller's own peer.
if let Some(tool) = config.memory_tools.iter().find(|t| t.name() == name) {
return Some(tool.run_with(&ctx, args));
}
if let Some(tool) = config.image_tools.iter().find(|t| t.name() == name) {
return Some(tool.run(args));
}
// MCP tools (`server::tool`). Clone the Arc so the work future is 'static.
if let Some((srv, mcp_tool)) = crate::mcp::parse_mcp_tool_name(name) {
let mcp = std::sync::Arc::clone(&self.mcp);
let srv = srv.to_string();
let mcp_tool = mcp_tool.to_string();
let fut: std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<ToolResult>> + Send>> =
Box::pin(async move { mcp.call(&srv, &mcp_tool, args).await });
return Some(Box::new(SimpleExecution::new(fut)));
}
// Built-in registry tools (incl. execute_cmd, whose SimpleExecution kills
// the child via kill_on_drop when the work future is dropped on /stop).
self.tools.run(name, &ctx, args)
}
}
+38 -495
View File
@@ -1,282 +1,28 @@
//! Inline multimodal media for chat attachments.
//! Media helpers that are Skald's, not the protocol's.
//!
//! Attachments normally reach the model as a textual list of paths (see
//! `attachments_block`) and the agent decides whether to read them. When the
//! resolved model declares a matching capability (`vision`, `video`), media
//! attachments of the **current turn** are instead sent as native content
//! parts — `image_url` / `video_url` data URLs, the OpenAI wire shape, which
//! non-OpenAI clients translate — so the model actually sees the bytes.
//! The wire half — which modality a model can take, the content-part shapes,
//! the data-URL encoding, the byte budgets, the magic-byte sniffing — lives in
//! `agent_loop::projection::media`. What is left here is the app's own:
//!
//! Promotion is deliberately strict: an attachment is inlined only when ALL of
//! these hold —
//! - the model has the modality's capability;
//! - the file lives under the caller's `~/uploads/` (where the upload handler
//! saves it), resolved through their per-user filesystem — attachments stored
//! anywhere else stay textual;
//! - the sniffed magic bytes match an allowed MIME — the client-supplied
//! `mimetype` is never trusted;
//! - the per-file and per-turn byte/count budgets are not exhausted.
//! - [`probe_media`] / [`media_capability_hint`]: what `read_file` tells the
//! agent it can hand back as native model input.
//!
//! Anything failing a check silently stays on the textual path.
//! Everything that decides WHICH files may be inlined is
//! `loop_adapters::media_source::SkaldMediaSource` (§6 containment), and the
//! projection itself is the library's — neither lives here.
use std::path::{Path, PathBuf};
use std::path::Path;
use base64::Engine as _;
use serde_json::{json, Value};
use tracing::debug;
use agent_loop::projection::media::MediaKind;
use core_api::message_meta::Attachment;
use core_api::tool::MediaRef;
use core_api::user_fs::{UserFs, UPLOADS_SUBDIR};
pub use agent_loop::projection::media::sniff_mime;
/// Max media parts inlined per turn.
const MAX_MEDIA_PER_TURN: usize = 4;
/// Max bytes for one inlined image.
const MAX_IMAGE_BYTES: u64 = 10 * 1024 * 1024;
/// Max bytes for one inlined video.
const MAX_VIDEO_BYTES: u64 = 32 * 1024 * 1024;
/// Max bytes for one inlined PDF (Anthropic's per-request document ceiling).
const MAX_PDF_BYTES: u64 = 32 * 1024 * 1024;
/// Max combined media bytes inlined per turn.
const MAX_TOTAL_MEDIA_BYTES: u64 = 48 * 1024 * 1024;
/// A model-input modality: the capability that unlocks it, the content-part
/// type it maps to, its byte cap, the sniffed MIME types accepted, and a
/// human-readable format list for the `read_file` description.
struct Modality {
capability: &'static str,
part_type: &'static str,
max_bytes: u64,
mimes: &'static [&'static str],
formats: &'static str,
}
const MODALITIES: &[Modality] = &[
Modality {
capability: "vision",
part_type: "image_url",
max_bytes: MAX_IMAGE_BYTES,
mimes: &["image/png", "image/jpeg", "image/gif", "image/webp"],
formats: "images (PNG, JPEG, GIF, WebP)",
},
Modality {
capability: "video",
part_type: "video_url",
max_bytes: MAX_VIDEO_BYTES,
mimes: &[
"video/mp4",
"video/mpeg",
"video/quicktime",
"video/webm",
"video/x-msvideo",
"video/x-flv",
"video/3gpp",
],
formats: "video (MP4, WebM, MOV, …)",
},
// PDF documents. The `file` part is the OpenAI file-input shape
// (`{"type":"file","file":{"filename","file_data"}}`), forwarded verbatim by
// OpenAI-compatible clients and translated to a native `document` block by the
// Anthropic client. Gated on the `document` capability, so a model row without
// it (any OpenAI-compat endpoint that can't take a `file` part) never receives
// one — set the capability only on rows whose endpoint accepts PDFs.
Modality {
capability: "document",
part_type: "file",
max_bytes: MAX_PDF_BYTES,
mimes: &["application/pdf"],
formats: "PDF documents",
},
];
/// Builds the OpenAI-wire content part for one inlined medium. Images/video use the
/// `{"type":"image_url"|"video_url","…":{"url":data-URL}}` shape; PDFs use the
/// `file` shape carrying a filename + `file_data` data-URL.
fn build_media_part(part_type: &str, mime: &str, b64: &str, filename: &str) -> Value {
let url = format!("data:{mime};base64,{b64}");
match part_type {
"file" => json!({ "type": "file", "file": { "filename": filename, "file_data": url } }),
t => json!({ "type": t, t: { "url": url } }),
}
}
/// The result of partitioning a message's attachments.
pub struct MediaPartition {
/// OpenAI-style content parts, ready to append after the text part.
pub parts: Vec<Value>,
/// Attachments that stay on the textual path block.
pub rest: Vec<Attachment>,
}
/// Splits a message's attachments into inline media parts and leftovers.
///
/// Each attachment path is resolved through the caller's per-user [`UserFs`] —
/// the same resolver the fs-tools use, fail-closed on traversal / workspace
/// escape — and inlined only when it lands under their `~/uploads/` directory,
/// where the upload handler saves them. Attachments stored anywhere else (a
/// path outside the home, or another surface's directory) stay textual.
pub async fn partition(
attachments: &[Attachment],
capabilities: &[String],
fs: &UserFs,
) -> MediaPartition {
let capable = MODALITIES
.iter()
.any(|m| capabilities.iter().any(|c| c == m.capability));
let root = std::fs::canonicalize(fs.home_host.join(UPLOADS_SUBDIR)).ok();
if !capable || root.is_none() {
return MediaPartition { parts: Vec::new(), rest: attachments.to_vec() };
}
let root = root.unwrap();
let mut parts: Vec<Value> = Vec::new();
let mut rest: Vec<Attachment> = Vec::new();
let mut total: u64 = 0;
for a in attachments {
if parts.len() >= MAX_MEDIA_PER_TURN {
debug!(path = %a.path, "media not inlined: per-turn count budget exhausted");
rest.push(a.clone());
continue;
}
match try_inline(a, capabilities, fs, &root, total).await {
Some((part, bytes)) => {
total += bytes;
parts.push(part);
}
None => rest.push(a.clone()),
}
}
MediaPartition { parts, rest }
}
/// Promotes one uploaded attachment to a content part, or `None` when any check
/// fails (logged at debug level; the caller keeps it on the textual path). The
/// agent path is resolved through the per-user filesystem (fail-closed) and then
/// re-checked to land under the uploads `root`; the rest is [`promote`].
async fn try_inline(
a: &Attachment,
capabilities: &[String],
fs: &UserFs,
root: &Path,
used_total: u64,
) -> Option<(Value, u64)> {
let abs = crate::tools::fs::resolve_host_path(fs, &a.path).ok()?;
if !abs.starts_with(root) {
debug!(path = %a.path, "media not inlined: outside the uploads root");
return None;
}
promote(&abs, &a.name, capabilities, used_total).await
}
/// Read + sniff + capability/budget check + build the content part for one file at
/// an **already-contained** absolute path. Shared by the uploaded-attachment path
/// ([`try_inline`]) and the tool-produced-media path ([`inline_paths`]); neither
/// containment nor per-turn count budget is enforced here — the callers do that.
/// `None` (logged at debug) when the file is not a recognized medium, the model
/// lacks the modality, or a byte budget is exhausted.
async fn promote(
abs: &Path,
filename: &str,
capabilities: &[String],
used_total: u64,
) -> Option<(Value, u64)> {
let mut file = tokio::fs::File::open(abs).await.ok()?;
let mut head = [0u8; 16];
let n = tokio::io::AsyncReadExt::read(&mut file, &mut head).await.ok()?;
let mime = sniff_mime(&head[..n])?;
let modality = MODALITIES.iter().find(|m| m.mimes.contains(&mime))?;
if !capabilities.iter().any(|c| c == modality.capability) {
debug!(path = %abs.display(), mime, "media not inlined: model lacks the capability");
return None;
}
let size = file.metadata().await.ok()?.len();
if size > modality.max_bytes {
debug!(path = %abs.display(), size, "media not inlined: file too large");
return None;
}
if used_total + size > MAX_TOTAL_MEDIA_BYTES {
debug!(path = %abs.display(), "media not inlined: per-turn byte budget exhausted");
return None;
}
let bytes = tokio::fs::read(abs).await.ok()?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some((build_media_part(modality.part_type, mime, &b64, filename), size))
}
/// Inline media a tool produced (e.g. `read_file` on an image) as content parts,
/// for the current turn only. Mirrors [`partition`] but contains against the
/// caller's **workspace roots** (home + shared + projects + docs) rather than the
/// uploads dir — the tool already resolved + contained the path, so this is a
/// fail-closed re-check against a symlink swap since the read (§6). Same per-file,
/// per-count and per-turn byte budgets; the capability gate lives here, so a
/// tool always records the media and the model only sees it when able.
pub async fn inline_paths(
refs: &[MediaRef],
capabilities: &[String],
fs: &UserFs,
) -> Vec<Value> {
let capable = MODALITIES
.iter()
.any(|m| capabilities.iter().any(|c| c == m.capability));
if !capable || refs.is_empty() {
return Vec::new();
}
let roots = workspace_roots(fs);
if roots.is_empty() {
return Vec::new();
}
let mut parts: Vec<Value> = Vec::new();
let mut total: u64 = 0;
for r in refs {
if parts.len() >= MAX_MEDIA_PER_TURN {
break;
}
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");
continue;
}
let filename = canon
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
if let Some((part, bytes)) = promote(&canon, &filename, capabilities, total).await {
total += bytes;
parts.push(part);
}
}
parts
}
/// The caller's workspace roots, canonicalized for prefix-checking: 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
}
/// Sentence appended to `read_file`'s description when the resolved model can view
/// media, naming the formats it takes as native input. `None` when the model has
/// no media modality (description stays unchanged). See `call_llm_round`.
/// Sentence appended to `read_file`'s description when the resolved model can
/// view media, naming the formats it takes as native input. `None` when the
/// model has no media modality (the description stays unchanged).
pub fn media_capability_hint(capabilities: &[String]) -> Option<String> {
let forms: Vec<&'static str> = MODALITIES
.iter()
.filter(|m| capabilities.iter().any(|c| c == m.capability))
.map(|m| m.formats)
.collect();
let forms: Vec<&'static str> =
MediaKind::enabled(capabilities).into_iter().map(|k| k.formats()).collect();
if forms.is_empty() {
return None;
}
@@ -296,10 +42,9 @@ fn join_human(items: &[&str]) -> String {
}
}
/// Opens a file and sniffs its first bytes, returning a recognized media MIME
/// (`image/*`, `video/*`, `application/pdf`) or `None` for an ordinary/unreadable
/// file. Used by `read_file` to decide whether to hand a file back as native media
/// rather than trying to read it as UTF-8 text.
/// Opens a file and sniffs its first bytes, returning a recognized media MIME or
/// `None` for an ordinary/unreadable file. Used by `read_file` to decide whether
/// to hand a file back as native media rather than reading it as UTF-8 text.
pub async fn probe_media(path: &Path) -> Option<&'static str> {
let mut file = tokio::fs::File::open(path).await.ok()?;
let mut head = [0u8; 16];
@@ -307,234 +52,14 @@ pub async fn probe_media(path: &Path) -> Option<&'static str> {
sniff_mime(&head[..n])
}
/// Sniffs the magic bytes of a medium we know how to inline, returning its
/// canonical MIME type. `None` = not a recognized medium (not an error —
/// ordinary files simply stay on the textual path).
pub fn sniff_mime(head: &[u8]) -> Option<&'static str> {
if head.starts_with(b"\x89PNG\r\n\x1a\n") {
return Some("image/png");
}
if head.starts_with(b"\xff\xd8\xff") {
return Some("image/jpeg");
}
if head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a") {
return Some("image/gif");
}
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WEBP" {
return Some("image/webp");
}
if head.len() >= 12 && &head[4..8] == b"ftyp" {
let brand = &head[8..12];
if brand.starts_with(b"3gp") || brand.starts_with(b"3g2") {
return Some("video/3gpp");
}
if brand == b"qt " {
return Some("video/quicktime");
}
// isom / mp41 / mp42 / avc1 / M4V …
return Some("video/mp4");
}
// EBML header — WebM (and Matroska, close enough for the video models).
if head.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) {
return Some("video/webm");
}
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"AVI " {
return Some("video/x-msvideo");
}
if head.starts_with(b"FLV\x01") {
return Some("video/x-flv");
}
if head.starts_with(&[0x00, 0x00, 0x01, 0xBA]) || head.starts_with(&[0x00, 0x00, 0x01, 0xB3]) {
return Some("video/mpeg");
}
if head.starts_with(b"%PDF-") {
return Some("application/pdf");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
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 caps(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn sniff_known_signatures() {
assert_eq!(sniff_mime(b"\x89PNG\r\n\x1a\n...."), Some("image/png"));
assert_eq!(sniff_mime(b"\xff\xd8\xff\xe0...."), Some("image/jpeg"));
assert_eq!(sniff_mime(b"GIF89a...."), Some("image/gif"));
assert_eq!(sniff_mime(b"RIFF\x00\x00\x00\x00WEBP"), Some("image/webp"));
assert_eq!(sniff_mime(b"\x00\x00\x00\x18ftypisom"), Some("video/mp4"));
assert_eq!(sniff_mime(b"\x00\x00\x00\x18ftypqt "), Some("video/quicktime"));
assert_eq!(sniff_mime(b"\x00\x00\x00\x18ftyp3gp4"), Some("video/3gpp"));
assert_eq!(sniff_mime(&[0x1A, 0x45, 0xDF, 0xA3, 0, 0]), Some("video/webm"));
assert_eq!(sniff_mime(b"RIFF\x00\x00\x00\x00AVI "), Some("video/x-msvideo"));
assert_eq!(sniff_mime(b"FLV\x01\x05"), Some("video/x-flv"));
assert_eq!(sniff_mime(&[0x00, 0x00, 0x01, 0xBA]), Some("video/mpeg"));
assert_eq!(sniff_mime(b"%PDF-1.7"), Some("application/pdf"));
assert_eq!(sniff_mime(b""), None);
}
#[tokio::test]
async fn partition_inlines_png_for_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 p = partition(&[att("uploads/1/a.png")], &caps(&["vision"]), &fs).await;
assert!(p.rest.is_empty());
assert_eq!(p.parts.len(), 1);
let url = p.parts[0]["image_url"]["url"].as_str().unwrap();
assert!(url.starts_with("data:image/png;base64,"));
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn partition_gates_on_capability_and_containment() {
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 p = partition(&[att("uploads/1/a.png")], &caps(&[]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
// vision capability does not unlock video parts.
let p = partition(&[att("uploads/1/a.png")], &caps(&["video"]), &fs).await;
assert_eq!(p.rest.len(), 1);
// A real image in the home but outside the uploads dir is never inlined.
let p = partition(&[att("secret.png")], &caps(&["vision"]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
// Traversal out of the workspace is rejected fail-closed.
let p = partition(&[att("uploads/../../secret.png")], &caps(&["vision"]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn partition_enforces_count_budget() {
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();
let mut atts = Vec::new();
for i in 0..(MAX_MEDIA_PER_TURN + 2) {
tokio::fs::write(dir.join(format!("{i}.png")), png_bytes()).await.unwrap();
atts.push(att(&format!("uploads/1/{i}.png")));
}
let fs = fs_home(&home);
let p = partition(&atts, &caps(&["vision"]), &fs).await;
assert_eq!(p.parts.len(), MAX_MEDIA_PER_TURN);
assert_eq!(p.rest.len(), 2);
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
fn pdf_bytes() -> Vec<u8> {
let mut v = b"%PDF-1.7\n".to_vec();
v.extend_from_slice(&[0x00; 64]);
v
}
#[tokio::test]
async fn partition_inlines_pdf_as_file_part_for_document_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.pdf"), pdf_bytes()).await.unwrap();
let fs = fs_home(&home);
// A document-capable model inlines the PDF as the OpenAI `file` part shape.
let p = partition(&[att("uploads/1/a.pdf")], &caps(&["document"]), &fs).await;
assert!(p.rest.is_empty());
assert_eq!(p.parts.len(), 1);
assert_eq!(p.parts[0]["type"], "file");
assert_eq!(p.parts[0]["file"]["filename"], "a.pdf");
let fd = p.parts[0]["file"]["file_data"].as_str().unwrap();
assert!(fd.starts_with("data:application/pdf;base64,"), "{fd}");
// vision alone does not unlock PDFs.
let p = partition(&[att("uploads/1/a.pdf")], &caps(&["vision"]), &fs).await;
assert_eq!(p.rest.len(), 1);
assert!(p.parts.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
/// A throwaway [`UserFs`] whose private home is `root/homes/u1`.
fn fs_home(home: &std::path::Path) -> UserFs {
UserFs::new(
"u1",
home.to_path_buf(),
"skald-u1",
PathBuf::from("/root"),
vec![],
vec![],
None,
)
}
#[tokio::test]
async fn inline_paths_contains_and_gates_on_capability() {
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() };
// capable + inside the home → one image part.
let parts = inline_paths(std::slice::from_ref(&inside), &caps(&["vision"]), &fs).await;
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["type"], "image_url");
assert!(parts[0]["image_url"]["url"].as_str().unwrap().starts_with("data:image/png;base64,"));
// no capability → nothing inlined.
assert!(inline_paths(std::slice::from_ref(&inside), &caps(&[]), &fs).await.is_empty());
// a real image outside the workspace is rejected fail-closed.
assert!(inline_paths(std::slice::from_ref(&outside), &caps(&["vision"]), &fs).await.is_empty());
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[test]
fn media_capability_hint_lists_enabled_formats_only() {
assert!(media_capability_hint(&caps(&[])).is_none());
@@ -544,4 +69,22 @@ mod tests {
let h = media_capability_hint(&caps(&["vision", "document"])).unwrap();
assert!(h.contains("images (PNG, JPEG, GIF, WebP)") && h.contains("PDF documents"), "{h}");
}
#[tokio::test]
async fn probe_media_recognizes_a_png_and_ignores_text() {
let dir = std::env::temp_dir().join(format!("skald-probe-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&dir).await.unwrap();
let png = dir.join("a.png");
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend_from_slice(&[0xAA; 32]);
tokio::fs::write(&png, bytes).await.unwrap();
let txt = dir.join("a.txt");
tokio::fs::write(&txt, b"hello").await.unwrap();
assert_eq!(probe_media(&png).await, Some("image/png"));
assert_eq!(probe_media(&txt).await, None);
assert_eq!(probe_media(&dir.join("missing")).await, None);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,54 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::Value;
use crate::llm::DtlMode;
use super::ChatSessionHandler;
use super::message_builder::MessageBuilder;
impl ChatSessionHandler {
/// Thin wrapper: constructs a `MessageBuilder` from this handler's fields
/// and delegates to `MessageBuilder::build`.
///
/// See `MessageBuilder::build` for the full documentation and message ordering.
pub(super) async fn build_openai_messages(
&self,
pool: &sqlx::SqlitePool,
stack_id: i64,
agent_id: &str,
extra_system_static: Option<&str>,
extra_system_dynamic: Option<&str>,
tail_reminder: Option<&str>,
active_mcp_grants: &HashSet<String>,
system_substitutions: &HashMap<String, String>,
cache_hints: bool,
capabilities: &[String],
dtl: DtlMode,
config_tool_defs: &[Value],
activation_stack: Option<i64>,
) -> anyhow::Result<Vec<Value>> {
let project_root = self.run_context.read().await
.as_ref()
.and_then(|rc| rc.project_root.clone());
let builder = MessageBuilder {
pool: Arc::clone(&self.db),
shared_pool: Arc::clone(&self.shared_pool),
user_id: self.user_id.clone(),
session_id: self.scratchpad_sid(),
mcp: Arc::clone(&self.mcp),
datetime_config: self.datetime_config.clone(),
max_history_messages: self.max_history_messages,
max_tool_result_chars: self.max_tool_result_chars,
compactor: self.compactor.clone(),
project_root,
// Snapshot the fs cell for this build — its workspace roots contain the
// tool-produced media inlined into the current turn (§6 remount-safe).
fs: Some(self.fs.load()),
};
// `pool` is passed in from the caller (always `&self.db`) but we take
// ownership via Arc::clone above so the signature stays backward-compatible.
let _ = pool; // suppress unused-variable warning; MessageBuilder uses its own Arc
builder.build(stack_id, agent_id, extra_system_static, extra_system_dynamic, tail_reminder, active_mcp_grants, system_substitutions, cache_hints, capabilities, dtl, config_tool_defs, activation_stack).await
}
}
+48 -114
View File
@@ -6,7 +6,6 @@ use async_trait::async_trait;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use tokio::sync::{Mutex, mpsc};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, trace, warn};
@@ -16,7 +15,6 @@ use crate::tools::tool_names as tn;
use crate::chat_event_bus::{ChatEvent, ChatEventBus, ChatEventRole};
use crate::clarification::ClarificationManager;
use crate::compactor::ContextCompactor;
use crate::config::DatetimeConfig;
use crate::db::{chat_history, chat_sessions_stack};
use crate::events::ServerEvent;
use core_api::message_meta::MessageMetadata;
@@ -25,24 +23,12 @@ use crate::llm::LlmManager;
use crate::mcp::McpProvider;
use crate::image_generate::ImageGeneratorManager;
use crate::memory::MemoryManager;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
mod approval;
mod agent_dispatch;
mod config;
mod dispatch;
mod emitter;
mod gate;
pub(crate) mod config;
mod kernel_turn;
mod interface_tools;
mod llm_call;
mod llm_loop;
pub(crate) mod interface_tools;
pub mod media;
pub mod message_builder;
mod messages;
mod outcome;
mod resume;
pub use interface_tools::{InterfaceTool, ToolFuture};
@@ -64,7 +50,7 @@ pub struct PendingMsg {
}
/// Source of queued user input for the in-flight turn. Implemented by `ChatHub`
/// over a source's inbox; it lets `run_agent_turn` pull newly-queued user
/// over a source's inbox; it lets the kernel pull newly-queued user
/// messages at each round boundary and inject them live into the running turn.
///
/// Passed as `Some` only for the root interactive turn. Sub-agents, resume, and
@@ -76,35 +62,18 @@ pub trait PendingUserInput: Send + Sync {
async fn drain_user(&self) -> Vec<PendingMsg>;
}
/// Control-flow signals returned as `anyhow::Error` by internal dispatch methods.
/// Using a typed enum instead of two separate sentinel structs allows a single
/// `downcast_ref` in `llm_loop` instead of two separate type checks.
#[derive(Debug)]
pub(super) enum AgentFlowSignal {
/// The WS disconnected while `dispatch_ask_user_clarification` was blocking.
/// The tool stays `'pending'` in DB so `resume_pending_tools` can re-ask on reconnect.
QuestionChannelClosed,
}
impl std::fmt::Display for AgentFlowSignal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::QuestionChannelClosed => write!(f, "question channel closed (WS disconnected)"),
}
}
}
impl std::error::Error for AgentFlowSignal {}
/// What a turn ended as, for the caller of `handle_message`. Deliberately
/// thinner than the kernel's outcome: the content the UI shows (`Done`,
/// `Truncated`, the reasoning trace) is already on the wire by the time a turn
/// returns — the event translator emitted it live — so what is left here is
/// what the app still has to do afterwards (publish on the chat bus, record
/// token counts for the compaction threshold).
pub(super) enum TurnOutcome {
Final {
content: String,
message_id: i64,
input_tokens: Option<u32>,
output_tokens: Option<u32>,
truncated: bool,
/// Chain-of-thought produced by the final round, when any.
reasoning_content: Option<String>,
/// All tool calls executed during this turn, across all rounds.
tool_calls: Vec<crate::chat_event_bus::ToolCallEvent>,
},
@@ -188,10 +157,8 @@ pub(crate) fn write_todos_tool_def() -> Value {
}
/// Tool definition that lets a sub-agent (depth > 0) dispatch a further
/// synchronous sub-agent. The call is intercepted in `run_agent_turn` and routed
/// to `dispatch_sub_agent` (the InterfaceTool handler is never reached), so only
/// the definition is needed here. `agent_id` is required because
/// `dispatch_sub_agent` rejects calls without it.
/// synchronous sub-agent. The behaviour is the crate's `DelegateTool`; this is
/// the legacy schema it is advertised with, kept byte-for-byte (D11).
pub(crate) fn execute_subtask_tool_def() -> Value {
json!({
"type": "function",
@@ -280,16 +247,10 @@ pub struct ChatSessionHandler {
/// tool call without being rebuilt — see [`SharedFs`].
pub(super) fs: SharedFs,
pub(super) llm_manager: Arc<LlmManager>,
pub(super) max_history_messages: usize,
pub(super) max_tool_rounds: usize,
/// Max synchronous sub-agents dispatched concurrently for a homogeneous batch
/// of sub-agent calls in a single LLM response (`1` = sequential).
pub(super) max_parallel_subagents: usize,
/// If `Some(n)`, tool results from previous turns that exceed `n` characters
/// are replaced with a placeholder when building the LLM context.
/// The database always retains the original content.
pub(super) max_tool_result_chars: Option<usize>,
pub(super) datetime_config: DatetimeConfig,
/// Round budget, for the error message when a turn exhausts it. Every other
/// loop limit (history window, result caps, fan-out width, datetime block)
/// belongs to the turn, so it lives on the `UserLoopRuntime`'s `LoopConfig`.
pub(super) max_tool_rounds: usize,
pub(super) agent_id: String,
/// Source of the session: "web", "telegram", "cron", etc.
pub(super) source: String,
@@ -299,9 +260,6 @@ pub struct ChatSessionHandler {
pub(super) is_ephemeral: bool,
pub(super) tools: Arc<ToolRegistry>,
pub(super) mcp: Arc<dyn McpProvider>,
/// Records tools offered to the LLM each round so the Security-groups UI can
/// list/gate dynamically-injected tools (interface/plugin/provider tools).
pub(super) tool_discovery: Arc<ToolDiscovery>,
pub(super) approval: Arc<ApprovalManager>,
pub(super) clarification: Arc<ClarificationManager>,
pub(super) event_bus: Arc<ChatEventBus>,
@@ -311,14 +269,6 @@ pub struct ChatSessionHandler {
pub(super) image_generator_manager: Arc<ImageGeneratorManager>,
/// Prevents concurrent handle_message calls on the same session.
pub(super) processing: Mutex<()>,
/// Cancellation scope for the in-flight turn. A fresh token is minted per
/// user message (`handle_message`) and per resume (`resume_turn`), then a
/// clone is threaded by value through the whole (possibly recursive) call
/// tree. `cancel()` cancels whatever token is currently stored, which the
/// running chain observes because it holds its own clone of that same token.
/// Replacing the field only affects the *next* turn — that is what makes a
/// stop sticky across sub-agent recursion (it is never reset mid-turn).
pub(super) current_cancel: std::sync::Mutex<CancellationToken>,
/// When true, any tool call that would require human approval is automatically
/// denied instead of blocking. Used by TicManager and other headless runners
/// that cannot process approval requests.
@@ -330,9 +280,9 @@ pub struct ChatSessionHandler {
/// Context compactor, shared across all sessions. `None` when compaction
/// is disabled (no `compaction` section in config).
pub(super) compactor: Option<Arc<ContextCompactor>>,
/// The live kernel-driven turn (manager + conversation) for `/stop`
/// routing (phase 2). `None` between turns / on legacy paths.
pub(super) kernel_live: std::sync::Mutex<Option<(Arc<agent_loop::manager::LoopManager>, agent_loop::ids::ConversationId)>>,
/// This user's loop stack (manager, store, gate, catalog, delegate), built
/// once per `ChatSessionManager` and shared by every session of the owner.
pub(super) loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>,
/// Input token count from the most recently completed turn, stored
/// atomically so the next `handle_message` call can decide whether to
/// compact before processing the new message. Zero means unknown
@@ -353,11 +303,7 @@ impl ChatSessionHandler {
user_id: String,
fs: SharedFs,
llm_manager: Arc<LlmManager>,
max_history_messages: usize,
max_tool_rounds: usize,
max_parallel_subagents: usize,
max_tool_result_chars: Option<usize>,
datetime_config: DatetimeConfig,
agent_id: String,
source: String,
is_interactive: bool,
@@ -371,7 +317,7 @@ impl ChatSessionHandler {
image_generator_manager: Arc<ImageGeneratorManager>,
compactor: Option<Arc<ContextCompactor>>,
run_context: Option<RunContext>,
tool_discovery: Arc<ToolDiscovery>,
loop_runtime: Arc<crate::loop_adapters::runtime::UserLoopRuntime>,
) -> Self {
Self {
session_id,
@@ -380,18 +326,13 @@ impl ChatSessionHandler {
user_id,
fs,
llm_manager,
max_history_messages,
max_tool_rounds,
max_parallel_subagents,
max_tool_result_chars,
datetime_config,
agent_id,
source,
is_interactive,
is_ephemeral,
tools,
mcp,
tool_discovery,
approval,
clarification,
event_bus,
@@ -400,13 +341,12 @@ impl ChatSessionHandler {
compactor,
context_label: Arc::new(std::sync::RwLock::new(None)),
processing: Mutex::new(()),
current_cancel: std::sync::Mutex::new(CancellationToken::new()),
auto_deny_approvals: Arc::new(AtomicBool::new(false)),
pre_approved: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
last_input_tokens: AtomicU32::new(0),
run_context: Arc::new(tokio::sync::RwLock::new(run_context)),
scratchpad_session_id: std::sync::OnceLock::new(),
kernel_live: std::sync::Mutex::new(None),
loop_runtime,
}
}
@@ -451,17 +391,17 @@ impl ChatSessionHandler {
self.run_context.read().await.as_ref().and_then(|rc| rc.tool_group_id().map(str::to_owned))
}
/// Cancels the in-flight turn. The running call tree holds its own clone of
/// the same token, so it stops at the next round boundary, on the in-flight
/// LLM call, and on cancellable tools (e.g. `execute_cmd`). Sticky across
/// sub-agent recursion: the token is never reset mid-turn.
/// Cancels the in-flight turn. The manager cancels the conversation's live
/// loop, and every frame under it holds a child of that token — so a `/stop`
/// is sticky across sub-agent recursion, and lands on the next round
/// boundary, on the in-flight LLM call, and on cancellable tools
/// (e.g. `execute_cmd`).
pub fn cancel(&self) {
self.current_cancel.lock().unwrap().cancel();
self.cancel_kernel_turn();
}
/// True if a turn is currently in flight (the `processing` mutex is held for
/// the whole duration of `handle_message` / `resume_turn`). Used to tell a
/// the whole duration of `handle_message` / a recovery). Used to tell a
/// freshly (re)connected client to show the STOP button.
pub fn is_processing(&self) -> bool {
self.processing.try_lock().is_err()
@@ -495,7 +435,7 @@ impl ChatSessionHandler {
/// Cancels all pending clarification requests for this session (WS disconnected).
/// The blocked `rx.await` in dispatch_ask_user_clarification returns Err → TurnOutcome::Cancelled,
/// leaving the tool as 'pending' so resume_pending_tools re-dispatches on reconnect.
/// leaving the tool as 'pending' so the next recovery re-asks on reconnect.
pub async fn cancel_pending_questions(&self) {
self.clarification.cancel_for_session(self.session_id).await;
}
@@ -511,7 +451,9 @@ impl ChatSessionHandler {
};
match self.compactor {
Some(ref compactor) => {
compactor.force_compact(pool, self.session_id, stack.id, self.is_ephemeral).await
compactor.force_compact(
self.loop_runtime.manager(), pool, self.session_id, stack.id, self.is_ephemeral,
).await
}
None => Ok(false),
}
@@ -538,19 +480,17 @@ impl ChatSessionHandler {
// (TicManager ticks, notification briefings from ChatHub).
is_synthetic: bool,
// Structured metadata persisted on the user turn (e.g. file attachments).
// The MessageBuilder derives the LLM-facing block; the UI renders chips.
// The projection derives the LLM-facing block; the UI renders chips.
metadata: Option<MessageMetadata>,
// Queued user input for this source. When `Some`, `run_agent_turn` drains
// Queued user input for this source. When `Some`, the kernel drains
// it at each round boundary and injects newly-arrived user messages into
// the running turn. `None` for sub-agents / resume / non-interactive runners.
pending_input: Option<Arc<dyn PendingUserInput>>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
// Fresh cancellation scope for this user message. Stored so `cancel()`
// can reach it, and cloned-by-value into the call tree so a /stop during
// the turn is sticky across sub-agent recursion (never reset mid-turn).
let token = CancellationToken::new();
*self.current_cancel.lock().unwrap() = token.clone();
// NB: the turn's cancellation scope is the manager's — minted by
// `start_turn` and cloned by value down the whole call tree, so a /stop
// is sticky across sub-agent recursion (see `cancel`).
let pool = &self.db;
let user_content = content.to_string(); // saved for the ChatEvent publication
@@ -614,7 +554,9 @@ impl ChatSessionHandler {
// happens here, before the LLM loop, and is not a separate turn.
if let Some(ref compactor) = self.compactor {
let last_tokens = self.last_input_tokens.load(Ordering::Relaxed);
match compactor.try_compact(pool, self.session_id, stack.id, last_tokens, self.is_ephemeral).await {
match compactor.try_compact(
self.loop_runtime.manager(), pool, self.session_id, stack.id, last_tokens, self.is_ephemeral,
).await {
Ok(true) => info!(session_id = self.session_id, stack_id = stack.id, "handle_message: context compacted"),
Ok(false) => {}
Err(e) => warn!(session_id = self.session_id, error = %e, "handle_message: compaction failed (non-fatal), continuing"),
@@ -622,30 +564,22 @@ impl ChatSessionHandler {
}
// ─────────────────────────────────────────────────────────────────────
// If the previous turn was cancelled before the LLM responded, the history ends on a
// User message with no following assistant. This breaks the user→assistant alternation
// required by strict APIs (e.g. OpenRouter). Mark the orphaned message as failed so
// for_stack() excludes it from the context we send to the LLM.
let prior = chat_history::for_stack(pool, stack.id).await?;
if let Some(last) = prior.last() {
if matches!(last.role, chat_history::Role::User | chat_history::Role::Agent) {
warn!(session_id = self.session_id, message_id = last.id, "orphaned user message (cancelled turn) — marking failed");
chat_history::mark_failed(pool, last.id).await?;
}
}
// NB: a trailing orphan User/Agent message (a turn cancelled before the
// LLM answered, which breaks the alternation strict APIs require) is
// marked failed by `LoopManager::start_turn` — it is a well-formedness
// rule of the history, so the library owns it, and it runs there at the
// right moment: right before the new user message is appended.
// Resume any tool calls left pending from a previous interrupted session.
// They are re-gated (rules may have changed) and executed before the LLM runs.
// (Runs before the kernel turn, which appends the user message itself —
// resumed results belong to the previous turn and land first.)
self.resume_pending_tools(stack.id, &config, &token, &tx).await?;
// NB: tool calls left dangling by an interrupted session are repaired
// inside `run_kernel_turn` — it owns the event translator, so the
// re-execution's cards reach the client like any other.
let outcome = self.run_kernel_turn(
stack.id, &config, content, is_synthetic, metadata.as_ref(), pending_input.as_ref(), &tx,
&config, content, is_synthetic, metadata.as_ref(), pending_input.as_ref(), &tx,
).await?;
match outcome {
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated: _, reasoning_content: _, tool_calls } => {
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, tool_calls } => {
// Persist token count so the *next* handle_message call knows
// whether to compact before running the LLM loop.
if let Some(t) = input_tokens {
@@ -1,111 +0,0 @@
//! Shared recording of a single tool-call outcome.
//!
//! The persist-then-emit tail of a tool call (`ExecutionOutcome` → DB row +
//! `ToolDone`/`ToolError`/`ToolCancelled` event) was copy-pasted into both the live
//! loop (`run_agent_turn`) and `resume_pending_tools`. `record_tool_outcome` is the
//! single implementation both call.
use serde_json::Value;
use tracing::{debug, info, warn};
use crate::chat_event_bus::ToolCallEvent;
use crate::db::chat_llm_tools;
use crate::tools::{is_file_write_tool, ExecutionOutcome};
use super::ChatSessionHandler;
use super::dispatch::WritePreview;
use super::emitter::TurnEmitter;
/// Whether the enclosing loop should keep going after an outcome is recorded.
pub(super) enum RecordFlow {
/// Continue with the next tool call / round.
Continue,
/// The tool was cancelled by the user — the caller must end the turn.
Abort,
}
impl ChatSessionHandler {
/// Persists one tool-call outcome and emits the matching lifecycle event.
/// Returns [`RecordFlow::Abort`] for a user cancellation (the caller ends the
/// turn), [`RecordFlow::Continue`] otherwise.
///
/// When `accumulate` is `Some` (the live turn), the call is also appended to the
/// turn's `ToolCallEvent` list for the chat-event bus, and a `FileChanged` event
/// is emitted for a successful file-write tool. `resume_pending_tools` passes
/// `None`: it neither accumulates nor re-emits `FileChanged`.
pub(super) async fn record_tool_outcome(
&self,
tool_call_id: i64,
tool_name: &str,
args: &Value,
outcome: ExecutionOutcome,
preview: Option<WritePreview>,
em: &TurnEmitter<'_>,
accumulate: Option<&mut Vec<ToolCallEvent>>,
) -> anyhow::Result<RecordFlow> {
let pool = &self.db;
match outcome {
ExecutionOutcome::Completed(result) => {
let wire = result.to_wire();
let kind = result.kind();
debug!(session_id = self.session_id, tool = %tool_name, tool_call_id, result_len = wire.len(), "tool done");
chat_llm_tools::complete(pool, tool_call_id, &wire, kind).await?;
// Media the tool produced (e.g. read_file on an image/PDF) rides
// out of band in the `media` column; the message builder inlines it
// as a synthetic user message for a capable model on the current turn.
let media = result.media();
if !media.is_empty() {
let media_json = serde_json::to_string(media).unwrap_or_else(|_| "[]".to_string());
chat_llm_tools::set_media(pool, tool_call_id, &media_json).await?;
}
// Persist a file-write's diff snapshot so it re-renders after a reload,
// and carry it on the event so an auto-allowed write shows the diff live.
let (preview_old, preview_new) = match preview {
Some(WritePreview { old, new }) => {
chat_llm_tools::set_preview(pool, tool_call_id, old.as_deref(), new.as_deref()).await?;
(old, new)
}
None => (None, None),
};
if let Some(acc) = accumulate {
if is_file_write_tool(tool_name)
&& let Some(p) = args["path"].as_str()
{
em.file_changed(crate::approval::normalize_path(p)).await;
}
acc.push(ToolCallEvent {
name: tool_name.to_string(),
arguments: Some(serde_json::to_string(args).unwrap_or_default()),
result: Some(wire.clone()),
status: "done".to_string(),
});
}
em.tool_done(tool_call_id, wire, kind.to_string(), preview_old, preview_new).await;
Ok(RecordFlow::Continue)
}
ExecutionOutcome::Failed(msg) => {
warn!(session_id = self.session_id, tool = %tool_name, tool_call_id, error = %msg, "tool failed");
chat_llm_tools::fail(pool, tool_call_id, &msg).await?;
if let Some(acc) = accumulate {
acc.push(ToolCallEvent {
name: tool_name.to_string(),
arguments: Some(serde_json::to_string(args).unwrap_or_default()),
result: Some(msg.clone()),
status: "failed".to_string(),
});
}
em.tool_error(tool_call_id, msg).await;
Ok(RecordFlow::Continue)
}
ExecutionOutcome::Cancelled => {
// A /stop hit this tool mid-flight. Record it as cancelled (not
// failed); the sticky token cancels the rest of the loop by
// construction, so the caller just ends the turn.
info!(session_id = self.session_id, tool = %tool_name, tool_call_id, "tool cancelled by user");
chat_llm_tools::cancel(pool, tool_call_id, "Cancelled by user.").await?;
em.tool_cancelled(tool_call_id).await;
Ok(RecordFlow::Abort)
}
}
}
}
@@ -1,438 +0,0 @@
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack};
use crate::events::ServerEvent;
use crate::tools::{drive_execution, ExecutionOutcome, ToolDescriptionLength, ToolResult, tool_names as tn};
use super::{ChatSessionHandler, TurnOutcome};
use super::emitter::TurnEmitter;
use super::gate::GateOutcome;
use super::outcome::RecordFlow;
use super::interface_tools::{AgentRunConfig, InterfaceTool};
impl ChatSessionHandler {
/// Dispatches a single already-approved tool call by name+args, without running
/// the LLM loop. The sole caller is the REST `resolve` endpoint's post-restart
/// "simple tools" branch (no live oneshot to unblock; sub-agent and `restart`
/// tools are handled earlier there). Does NOT touch the DB — the caller records
/// `complete`/`fail`.
///
/// Runs through the **same canonical path as the live loop** — `build_execution`
/// (which constructs the [`ToolContext`]: owner pool + per-user container fs)
/// driven by `drive_execution`. The previous `self.tools.dispatch(name, args)`
/// bypassed the context entirely, so a resolved `write_file` landed in the server
/// cwd (no containment, memory paths hit disk) and `execute_cmd` ran on the host —
/// a blueprint §6 sandbox escape (bug B1). MCP tools are covered by
/// `build_execution` too, so no name special-casing is needed here.
pub async fn execute_tool(&self, name: &str, args: Value) -> anyhow::Result<ToolResult> {
// No interface tools post-restart: a pending-approval tool is a built-in /
// memory / MCP call, never a per-interface closure like `activate_tools`.
let config = self.build_agent_config(
None, None, None, Vec::new(), std::collections::HashMap::new(),
).await?;
let exec = self.build_execution(name, args, &config)
.ok_or_else(|| anyhow::anyhow!("unknown tool: {name}"))?;
// A resolve is a one-shot; nothing wires /stop to it, so a fresh (never
// cancelled) token satisfies the driver contract.
let token = CancellationToken::new();
match drive_execution(exec.as_ref(), &token).await {
ExecutionOutcome::Completed(result) => Ok(result),
ExecutionOutcome::Failed(msg) => Err(anyhow::anyhow!(msg)),
ExecutionOutcome::Cancelled => Err(anyhow::anyhow!("tool execution cancelled")),
}
}
/// Resumes the LLM loop for the current session WITHOUT appending a new user message.
/// Intended for use after pending tool calls have been resolved externally
/// (e.g. via the REST approve endpoint) so the LLM can produce a final response
/// or make further tool calls using the now-complete history.
pub async fn resume_turn(
&self,
client_name: Option<String>,
extra_system_context: Option<String>,
interface_tools: Vec<InterfaceTool>,
tx: mpsc::Sender<ServerEvent>,
) -> anyhow::Result<()> {
let _guard = self.processing.lock().await;
// A resume is a fresh unit of work (async result injection, app-restart
// recovery, WS resume): mint a new token so it does not inherit a stale
// cancellation, while a /stop *during* the resume still cancels this token.
let token = CancellationToken::new();
*self.current_cancel.lock().unwrap() = token.clone();
let pool = &self.db;
let em = TurnEmitter::new(&tx);
let mut config = self.build_agent_config(
client_name, extra_system_context, None, interface_tools, std::collections::HashMap::new(),
).await?;
config.tail_reminder = None;
// Prune any interrupted parallel sub-agent batch before the linear cascade,
// which assumes a single active frame per depth (see method doc).
self.reap_interrupted_parallel_batches().await?;
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
Some(s) => s,
None => {
warn!(session_id = self.session_id, "resume_turn: no active stack, nothing to resume");
return Ok(());
}
};
info!(session_id = self.session_id, stack_id = stack.id, depth = stack.depth, "resume_turn start");
// B3: resume each frame with ITS OWN agent's config (prompt/tools/client), not
// the session root's. After a restart the deepest active frame may be a
// sub-agent; running it under `config` would resume e.g. a `researcher` as the
// `assistant`. The root frame keeps `config`; a sub-agent frame gets a freshly
// built sub-agent config for its own agent (deferred-init so the root path
// borrows `config` and the sub-agent path borrows the owned value).
let seed_frame_config;
let seed_config: &AgentRunConfig = if stack.parent_tool_call_id.is_none() {
&config
} else {
seed_frame_config = self.build_recovery_frame_config(&config, &stack).await?;
&seed_frame_config
};
// Resume pending/interrupted tools before running the LLM loop.
let had_pending = self.resume_pending_tools(stack.id, seed_config, &token, &tx).await?;
// Seed the cascade. Normally we (re)run the deepest active frame's LLM loop
// (live injection only applies to a fresh interactive turn from handle_message).
// Two special cases when nothing was pending AND the frame's last message is a
// pure-text assistant reply (its own turn is already complete):
// • root frame (no parent) → nothing to do, skip the LLM.
// • child frame (has parent) → its result was produced but never propagated
// (e.g. the turn task died right after the child finished). Seed the cascade
// from the existing final message — without re-running the LLM — so the
// parent's tool call is completed and the parent continues. Skipping here
// (as the old guard did unconditionally) left the parent wedged forever.
let (mut current_outcome, mut current_stack) = 'seed: {
if !had_pending {
if let Some(msg) = chat_history::last_message_for_stack(pool, stack.id).await? {
if matches!(msg.role, chat_history::Role::Assistant)
&& chat_llm_tools::for_message(pool, msg.id).await?.is_empty()
{
if stack.parent_tool_call_id.is_none() {
info!(session_id = self.session_id, stack_id = stack.id, "resume_turn: last message is pure-text assistant, turn already complete — skipping LLM");
return Ok(());
}
info!(session_id = self.session_id, stack_id = stack.id, "resume_turn: deepest frame is a completed child — cascading its existing result to the parent");
let outcome = TurnOutcome::Final {
content: msg.content,
message_id: msg.id,
input_tokens: None,
output_tokens: None,
truncated: false,
reasoning_content: msg.reasoning_content,
tool_calls: Vec::new(),
};
break 'seed (outcome, stack);
}
}
}
(self.run_agent_turn(stack.id, seed_config, &token, &tx, None).await?, stack)
};
// Cascade completion upward through parent stacks (handles app-restart recovery
// when a sub-agent was running — child completes, then parent continues).
loop {
let Some(parent_tool_call_id) = current_stack.parent_tool_call_id else { break };
// Determine the result string to propagate to the parent's call_agent tool.
let (result_str, is_error) = match &current_outcome {
TurnOutcome::Final { content, .. } => (content.clone(), false),
TurnOutcome::Cancelled => (format!("Sub-agent `{}` was cancelled.", current_stack.agent_id), true),
TurnOutcome::Exhausted => (format!("Sub-agent `{}` exhausted tool-call rounds.", current_stack.agent_id), true),
};
let result_preview = super::preview_truncate(&result_str, 500);
// Complete or fail the parent's call_agent tool call.
if is_error {
chat_llm_tools::fail(pool, parent_tool_call_id, &result_str).await?;
} else {
chat_llm_tools::complete(pool, parent_tool_call_id, &result_str, "string").await?;
}
// Terminate the child stack so active_for_session() returns the parent next.
let _ = chat_sessions_stack::terminate(pool, current_stack.id).await;
// Emit events to the frontend.
if is_error {
em.tool_error(parent_tool_call_id, result_str).await;
} else {
em.tool_done(parent_tool_call_id, result_str, "string".to_string(), None, None).await;
}
// Now the parent is the deepest active stack.
let parent_stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
Some(s) => s,
None => {
warn!(session_id = self.session_id, "resume_turn cascade: no active stack after child terminated");
break;
}
};
em.agent_done(
current_stack.id,
current_stack.agent_id.clone(),
parent_stack.agent_id.clone(),
result_preview,
).await;
info!(
session_id = self.session_id,
child_stack = current_stack.id,
parent_stack = parent_stack.id,
depth = parent_stack.depth,
"resume_turn: cascading to parent stack"
);
// B3: run the parent under its own agent's config (the root keeps `config`).
let parent_frame_config;
let parent_run_config: &AgentRunConfig = if parent_stack.parent_tool_call_id.is_none() {
&config
} else {
parent_frame_config = self.build_recovery_frame_config(&config, &parent_stack).await?;
&parent_frame_config
};
self.resume_pending_tools(parent_stack.id, parent_run_config, &token, &tx).await?;
current_outcome = self.run_agent_turn(parent_stack.id, parent_run_config, &token, &tx, None).await?;
current_stack = parent_stack;
}
// current_stack is now the root (depth=0); emit the final event.
match current_outcome {
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated, reasoning_content, .. } => {
info!(session_id = self.session_id, "resume_turn done");
if truncated {
warn!(session_id = self.session_id, "response truncated");
em.truncated(output_tokens).await;
}
em.done(message_id, current_stack.id, content, input_tokens, output_tokens, reasoning_content).await;
}
TurnOutcome::Cancelled => {
info!(session_id = self.session_id, "resume_turn cancelled");
em.error("Cancelled by user.".to_string()).await;
}
TurnOutcome::Exhausted => {
error!(session_id = self.session_id, "resume_turn exhausted tool rounds");
em.error("Exceeded tool-call rounds without a final answer.".to_string()).await;
}
}
Ok(())
}
/// Restart recovery for an interrupted **parallel** sub-agent batch.
///
/// A purely linear stack has at most one active frame per depth. Two or more
/// active frames at the same depth can only mean a concurrent sub-agent batch
/// (`handle_sub_agent_batch`) was in flight when the process died. This app is
/// single-user and deliberately tolerates losing mid-turn work on restart, so
/// rather than a complex multi-sibling re-drive we simply prune the batch:
/// terminate every active frame from the shallowest multi-frame depth downward
/// and fail the sub-agent tool call that spawned each. The parent frame is then
/// left with a clean, fully-resolved set of tool calls and the normal linear
/// cascade resumes it. A single interrupted sub-agent (one frame at its depth)
/// is untouched and still recovers via the existing cascade.
async fn reap_interrupted_parallel_batches(&self) -> anyhow::Result<()> {
let pool = &self.db;
let active = chat_sessions_stack::active_all_for_session(pool, self.session_id).await?;
let Some(d_min) = shallowest_parallel_depth(&active) else {
return Ok(()); // linear stack — nothing to reap
};
warn!(
session_id = self.session_id, depth = d_min,
"restart recovery: pruning interrupted parallel sub-agent batch"
);
for frame in active.iter().filter(|f| f.depth >= d_min) {
if let Some(parent_tool_call_id) = frame.parent_tool_call_id {
let _ = chat_llm_tools::fail(
pool, parent_tool_call_id, "Sub-agent interrupted by restart (parallel batch).",
).await;
}
let _ = chat_sessions_stack::terminate(pool, frame.id).await;
}
Ok(())
}
/// Called at the start of `handle_message` (and by the REST endpoint after a manual
/// resolve). Finds any `pending` tool calls left from a previous interrupted session,
/// re-runs them through the approval gate, executes approved ones, and fails rejected
/// or denied ones — so `run_agent_turn` sees complete history and can continue cleanly.
pub async fn resume_pending_tools(
&self,
stack_id: i64,
config: &AgentRunConfig,
token: &CancellationToken,
tx: &mpsc::Sender<ServerEvent>,
) -> anyhow::Result<bool> {
let pool = &self.db;
let em = TurnEmitter::new(tx);
let pending = chat_llm_tools::pending_for_stack(pool, stack_id).await?;
if pending.is_empty() {
return Ok(false);
}
info!(
session_id = self.session_id, stack_id,
count = pending.len(), "resuming pending tool calls"
);
for tc in pending {
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Object(Default::default()));
// A pending `execute_task` (mode=sync) or `execute_subtask` means a
// sub-agent stack was active. The cascade in resume_turn() handles it
// by running the child stack to completion and propagating the result
// up — skip it here.
if tc.name == tn::EXECUTE_TASK || tc.name == tn::EXECUTE_SUBTASK {
info!(session_id = self.session_id, tool_call_id = tc.id, "resume: skipping sub-agent dispatch (handled by stack cascade)");
continue;
}
// `ask_user_clarification` is a synthetic tool (not in the registry).
// Re-dispatch it directly so the question is re-asked to the user.
if tc.name == tn::ASK_USER_CLARIFICATION {
info!(session_id = self.session_id, tool_call_id = tc.id, "resume: re-asking clarification question");
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args),
).await;
let result = self.dispatch_ask_user_clarification(tc.id, &args, tx).await;
match result {
Ok(answer) => {
chat_llm_tools::complete(pool, tc.id, &answer, "string").await?;
em.tool_done(tc.id, answer, "string".to_string(), None, None).await;
}
Err(e) if matches!(e.downcast_ref::<super::AgentFlowSignal>(), Some(super::AgentFlowSignal::QuestionChannelClosed)) => {
// WS disconnected again mid-resume. Tool stays 'pending' — next resume re-asks.
warn!(session_id = self.session_id, tool_call_id = tc.id, "clarification channel closed during resume — aborting");
return Ok(true);
}
Err(e) => {
let msg = e.to_string();
chat_llm_tools::fail(pool, tc.id, &msg).await?;
em.tool_error(tc.id, msg).await;
}
}
continue;
}
// Announce the tool is being re-tried.
let (display_name, icon) = self.tool_ui_meta(&tc.name, &args);
em.tool_start(
tc.id,
tc.message_id,
tc.name.clone(),
args.clone(),
display_name, icon,
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short),
self.tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full),
self.tools.target_path(&tc.name, &args),
).await;
// Re-run through the same approval gate as a live turn (current rules,
// RunContext fast-path, auto-deny). Deny/reject paths mark the DB row and
// emit the event internally; a closed channel leaves the tool pending.
match self.run_approval_gate(tc.id, &tc.name, &args, &config.agent_id, &em).await? {
GateOutcome::Proceed => {}
GateOutcome::Rejected => continue,
GateOutcome::ChannelClosed => return Ok(true), // pending still, WS disconnected
}
// Re-run the persisted intent through the SAME dispatcher as a live turn
// (`execute_tool_call`), not the flat `build_execution`. This routes
// sub-agent tools (`execute_task` mode=sync, `execute_subtask`,
// `run_subtask`) through the recursive interception in `dispatch.rs`;
// `build_execution` alone does not know them and would fail with
// "Unknown tool: execute_task". Args are passed through unchanged.
let (outcome, preview) = match self.execute_tool_call(
stack_id, config, tc.id, &tc.name, &args, token, tx,
).await {
super::dispatch::DispatchResult::Outcome { outcome, preview } => (outcome, preview),
// Clarification WS channel closed mid-resume — leave the tool pending
// so the next resume re-asks (mirrors the live turn's AbortPending).
super::dispatch::DispatchResult::AbortPending => return Ok(true),
};
// resume passes `None` for accumulate: it does not accumulate ToolCallEvents
// nor re-emit FileChanged (only a live turn does). The write preview IS
// persisted so a re-run write's diff survives. A /stop mid-resume returns Abort.
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, preview, &em, None).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => return Ok(true),
}
}
Ok(true)
}
}
/// Shallowest stack depth that has more than one active (non-terminated) frame —
/// the top of an interrupted parallel sub-agent batch. Returns `None` for a linear
/// stack, where every depth has at most one active frame. Pure (see tests).
fn shallowest_parallel_depth(active: &[chat_sessions_stack::SessionStack]) -> Option<i64> {
let mut by_depth: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
for f in active {
*by_depth.entry(f.depth).or_default() += 1;
}
by_depth.iter()
.filter_map(|(depth, count)| (*count > 1).then_some(*depth))
.min()
}
#[cfg(test)]
mod tests {
use super::shallowest_parallel_depth;
use crate::db::chat_sessions_stack::SessionStack;
fn frame(id: i64, depth: i64, parent: Option<i64>) -> SessionStack {
SessionStack { id, agent_id: "agent".into(), depth, parent_tool_call_id: parent }
}
#[test]
fn linear_stack_is_not_a_batch() {
let frames = vec![frame(1, 0, None), frame(2, 1, Some(10)), frame(3, 2, Some(20))];
assert_eq!(shallowest_parallel_depth(&frames), None);
assert_eq!(shallowest_parallel_depth(&[]), None);
}
#[test]
fn detects_shallowest_multi_frame_depth() {
// Two siblings at depth 1 (parallel batch) plus a grandchild at depth 2.
let frames = vec![
frame(1, 0, None),
frame(2, 1, Some(10)), frame(3, 1, Some(11)),
frame(4, 2, Some(30)),
];
assert_eq!(shallowest_parallel_depth(&frames), Some(1));
}
#[test]
fn detects_deeper_batch_when_upper_levels_linear() {
let frames = vec![
frame(1, 0, None),
frame(2, 1, Some(10)),
frame(3, 2, Some(20)), frame(4, 2, Some(21)),
];
assert_eq!(shallowest_parallel_depth(&frames), Some(2));
}
}
+38 -20
View File
@@ -13,6 +13,7 @@ use crate::compactor::ContextCompactor;
use crate::config::DatetimeConfig;
use crate::db::{chat_sessions, chat_sessions_stack};
use crate::llm::LlmManager;
use crate::loop_adapters::runtime::{LoopConfig, UserLoopRuntime};
use crate::mcp::McpProvider;
use crate::image_generate::ImageGeneratorManager;
use crate::memory::MemoryManager;
@@ -33,11 +34,7 @@ pub struct ChatSessionManager {
/// membership change ([`refresh_fs`](Self::refresh_fs)) reaches live sessions.
user_fs: SharedFs,
llm_manager: Arc<LlmManager>,
max_history_messages: usize,
max_tool_rounds: usize,
max_parallel_subagents: usize,
max_tool_result_chars: Option<usize>,
datetime_config: DatetimeConfig,
tools: Arc<ToolRegistry>,
/// The MCP tools visible to this owner: the access-filtered global runtime
/// unioned with their per-user runtime (blueprint §7), behind one trait.
@@ -50,9 +47,10 @@ pub struct ChatSessionManager {
/// Shared compactor instance, `None` when compaction is disabled.
compactor: Option<Arc<ContextCompactor>>,
run_context_manager: Arc<RunContextManager>,
/// Shared tool-discovery recorder, passed to every handler so each turn can
/// register the tools it actually offers to the LLM (see `ToolDiscovery`).
tool_discovery: Arc<ToolDiscovery>,
/// This user's loop stack (blueprint D12): built once here and shared by
/// every session of the owner, so the manager keeps a global view of what
/// is running and a turn only contributes its own parameters.
loop_runtime: Arc<UserLoopRuntime>,
active: Mutex<HashMap<i64, Arc<ChatSessionHandler>>>,
}
@@ -78,18 +76,36 @@ impl ChatSessionManager {
compactor: Option<Arc<ContextCompactor>>,
run_context_manager: Arc<RunContextManager>,
tool_discovery: Arc<ToolDiscovery>,
) -> Self {
Self {
) -> anyhow::Result<Self> {
let loop_runtime = UserLoopRuntime::build(
db.clone(),
shared_pool.clone(),
user_id.clone(),
user_fs.clone(),
tools.clone(),
mcp.clone(),
llm_manager.clone(),
approval.clone(),
clarification.clone(),
tool_discovery.clone(),
LoopConfig {
max_rounds: max_tool_rounds,
max_parallel_calls: max_parallel_subagents,
max_history_messages,
max_tool_result_chars,
compaction_enabled: compactor.is_some(),
datetime: datetime_config.clone(),
max_agent_depth: crate::session::handler::MAX_AGENT_DEPTH as u32,
},
)?;
Ok(Self {
db,
shared_pool,
user_id,
user_fs,
llm_manager,
max_history_messages,
max_tool_rounds,
max_parallel_subagents,
max_tool_result_chars,
datetime_config,
tools,
mcp,
approval,
@@ -99,9 +115,9 @@ impl ChatSessionManager {
image_generator_manager,
compactor,
run_context_manager,
tool_discovery,
loop_runtime,
active: Mutex::new(HashMap::new()),
}
})
}
pub fn llm_manager(&self) -> Arc<LlmManager> {
@@ -112,6 +128,12 @@ impl ChatSessionManager {
Arc::clone(&self.run_context_manager)
}
/// This owner's loop stack (blueprint D12) — the wiring hands it the pieces
/// that only exist after the session manager does (the `TaskManager`).
pub fn loop_runtime(&self) -> &Arc<UserLoopRuntime> {
&self.loop_runtime
}
/// Returns the live handler for `session_id` if it is currently loaded,
/// without creating a new one. Used by the API for in-place updates.
pub async fn active_handler(&self, session_id: i64) -> Option<Arc<ChatSessionHandler>> {
@@ -178,11 +200,7 @@ impl ChatSessionManager {
self.user_id.clone(),
self.user_fs.clone(),
Arc::clone(&self.llm_manager),
self.max_history_messages,
self.max_tool_rounds,
self.max_parallel_subagents,
self.max_tool_result_chars,
self.datetime_config.clone(),
session.agent_id,
session.source,
session.is_interactive,
@@ -196,7 +214,7 @@ impl ChatSessionManager {
Arc::clone(&self.image_generator_manager),
self.compactor.clone(),
run_context,
Arc::clone(&self.tool_discovery),
Arc::clone(&self.loop_runtime),
));
self.active.lock().await.insert(session_id, handler.clone());
+1 -1
View File
@@ -373,7 +373,7 @@ impl Conversation {
compactor,
Arc::clone(&run_context_manager),
Arc::new(ToolDiscovery::new(Arc::clone(&rt.db))),
));
)?);
let chat_hub = ChatHub::new(
Arc::clone(&rt.db),
+4 -1
View File
@@ -321,7 +321,7 @@ impl UserContextFactory {
Arc::clone(&self.run_context_manager),
// known_tools is registry data → discovery writes to the registry pool.
Arc::new(ToolDiscovery::new(Arc::clone(&self.registry_pool))),
));
)?);
// The owner's default entry agent, snapshotted at login from their role
// (like fs membership / MCP access above): every lazy session-creation path
@@ -346,6 +346,9 @@ impl UserContextFactory {
cron.set_hub(Arc::clone(&chat_hub));
cron.set_self_arc(Arc::clone(&cron));
chat_hub.set_task_mgr(Arc::clone(&cron));
// …and the loop's async executor, so `execute_task mode=async` runs as a
// durable cron job (blueprint §7.2) instead of an interface-tool call.
manager.loop_runtime().set_task_manager(Arc::clone(&cron));
// Per-user cron loop. `start()` observes the shutdown token, so it stops on
// shutdown; adopting it lets the supervisor also join it. The name is leaked