agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s
Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call) and the ChatSessionHandler. Everything that shaped a Value — projection, recovery, compaction mechanics, the LLM loop, message building — lives in agent-loop or behind a loop_adapters trait. agent-loop: - projection/ (mod + media): stored history -> wire messages, the one place provider divergence lives; well-formedness contract, DTL injections (append-only), media parts. LinearAssembler is now a Projection + ProjectionHooks config, not its own implementation - recovery.rs: reap interrupted batches -> resolve the deepest frame's non-terminal calls (Running by policy + RestartHint, AwaitingHuman re-asked) -> un-wedge finished children -> cascade up, every frame on its own agent (B3) - compaction.rs: split point (never assistant+tool group), transcript, SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row - manager: resolve_pending (gate skipped, real ToolContext, then continue incl. sub-agent); start_loop used by recovery; LiveInput - delegate: AsyncExecutor + StoreSink for mode:async (durable cron row, result delivered back into the parent conversation) - kernel/context/store: support the above (TurnScope via Extensions, frame lookups, aligned result-text semantics) skald-core: - loop_adapters: UserLoopRuntime (D12 - one LoopManager per user), TurnScope (per-turn state in the Extensions type-map; no scope is denied), projection_cfg/media_source/tool_digest (Skald's projection knobs without owning projection code), async_task (CronExecutor + DurableSink) - session/handler: stripped to mod.rs + kernel_turn.rs + config.rs + interface_tools.rs + media.rs; deleted agent_dispatch, approval, dispatch, emitter, gate, llm_call, llm_loop, message_builder, messages, outcome, resume - compactor.rs: policy only (threshold, model pick, CompactionEvent); mechanics are the crate's CLAUDE.md updated (recovery, compaction, sub-agents, approval gate, projection sections now describe the crate-owned flow).
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
//! Compaction (blueprint §9, D6) — summarising the old part of a frame's
|
||||
//! history so the context stops growing.
|
||||
//!
|
||||
//! It is **not a turn**: one model call, no tools, no rounds, no kernel. That
|
||||
//! is the whole reason it is its own component — a host can compact a
|
||||
//! conversation nothing is driving, and the loop never learns it happened.
|
||||
//!
|
||||
//! The result is a row, not a return value: the next loop reads
|
||||
//! `latest_summary` through the assembler and projects
|
||||
//! `system → summary → messages after covered_up_to`. Callers get a
|
||||
//! [`CompactionOutcome`] for telemetry, not for threading anywhere.
|
||||
//!
|
||||
//! What the host still owns: **when** (see [`should_compact`]), which model,
|
||||
//! and what to do afterwards ([`LoopHooks::on_compacted`] — re-anchoring
|
||||
//! anything pinned to a message that just went away).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::events::{EventSink, LoopEvent};
|
||||
use crate::hooks::LoopHooks;
|
||||
use crate::ids::{ConversationId, FrameId, MessageId, SummaryId};
|
||||
use crate::model::{ModelHint, ModelRequest, ModelResponse, ModelSelector, Usage};
|
||||
use crate::store::{CallState, HistoryStore, NewSummary, Role, StoredMessage};
|
||||
|
||||
// ── The shipped prompt ───────────────────────────────────────────────────────
|
||||
|
||||
/// Prepended to the stored summary when it is projected back into the context.
|
||||
/// It tells the model this is a handoff from a previous context window, not a
|
||||
/// set of live instructions — without it, a model happily re-answers questions
|
||||
/// the summary merely *mentions*.
|
||||
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:";
|
||||
|
||||
/// Preamble shared by the first-compaction and the update prompts. The wording
|
||||
/// is deliberately plain: a summariser is the one call most likely to trip a
|
||||
/// content filter, since it restates whatever the conversation contained.
|
||||
pub 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.";
|
||||
|
||||
/// The sections the summariser must fill in. Structure beats prose here: the
|
||||
/// next context window is resumed from `## Active Task`, so that field is
|
||||
/// worth more than everything else combined.
|
||||
pub 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.";
|
||||
|
||||
/// How the summariser is asked. Override to change the wording or the sections
|
||||
/// without touching the mechanics.
|
||||
pub trait CompactionPrompt: Send + Sync {
|
||||
/// The single user message sent to the summariser. `prior` is the previous
|
||||
/// summary's body (without [`SUMMARY_PREFIX`]) when this is an update, so
|
||||
/// summaries never nest.
|
||||
fn build(&self, transcript: &str, prior: Option<&str>) -> String;
|
||||
}
|
||||
|
||||
/// The shipped prompt: preamble + transcript + template, in an update or a
|
||||
/// first-time shape.
|
||||
pub struct DefaultPrompt;
|
||||
|
||||
impl CompactionPrompt for DefaultPrompt {
|
||||
fn build(&self, transcript: &str, prior: Option<&str>) -> String {
|
||||
match prior {
|
||||
Some(prev) => 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}"
|
||||
),
|
||||
None => 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}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode / outcome ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CompactionMode {
|
||||
/// Summarise everything except the last `keep_tail` messages, cutting on a
|
||||
/// user/agent boundary so an assistant turn is never split from its tool
|
||||
/// results.
|
||||
Auto { keep_tail: usize },
|
||||
/// Summarise up to an explicit message (a UI that lets the user pick).
|
||||
UpTo(MessageId),
|
||||
}
|
||||
|
||||
impl Default for CompactionMode {
|
||||
fn default() -> Self {
|
||||
Self::Auto { keep_tail: 6 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactionOutcome {
|
||||
pub summary_id: SummaryId,
|
||||
pub covered_up_to: MessageId,
|
||||
/// The first message the summary does NOT cover — what anything pinned to
|
||||
/// a compacted message must be re-anchored onto.
|
||||
pub first_surviving: MessageId,
|
||||
pub summary_text: String,
|
||||
pub messages_covered: usize,
|
||||
pub usage: Usage,
|
||||
}
|
||||
|
||||
/// Is it time? `usage` is the previous turn's reported input tokens; when the
|
||||
/// provider reported none, `estimated` (the host's own count) decides.
|
||||
pub fn should_compact(usage: Option<u32>, estimated: u32, threshold: u32) -> bool {
|
||||
usage.filter(|t| *t > 0).unwrap_or(estimated) >= threshold
|
||||
}
|
||||
|
||||
// ── Compaction ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// One compaction, ready to run. Built via
|
||||
/// [`LoopManager::new_compaction`](crate::manager::LoopManager::new_compaction)
|
||||
/// so it shares the manager's store, hooks and event bus.
|
||||
pub struct Compaction {
|
||||
pub(crate) store: Arc<dyn HistoryStore>,
|
||||
pub(crate) selector: Arc<dyn ModelSelector>,
|
||||
pub(crate) hooks: Vec<Arc<dyn LoopHooks>>,
|
||||
pub(crate) events: EventSink,
|
||||
pub(crate) conversation: ConversationId,
|
||||
pub(crate) frame: FrameId,
|
||||
pub(crate) mode: CompactionMode,
|
||||
pub(crate) hint: ModelHint,
|
||||
pub(crate) prompt: Arc<dyn CompactionPrompt>,
|
||||
pub(crate) temperature: Option<f32>,
|
||||
/// Host free-form, forwarded on the request (payload logging).
|
||||
pub(crate) log: Option<Value>,
|
||||
}
|
||||
|
||||
impl Compaction {
|
||||
pub fn mode(mut self, mode: CompactionMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Pin the summariser's model. Default: whatever the selector picks.
|
||||
pub fn model(mut self, hint: ModelHint) -> Self {
|
||||
self.hint = hint;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the selector for this call (a cheaper tier, say).
|
||||
pub fn selector(mut self, selector: Arc<dyn ModelSelector>) -> Self {
|
||||
self.selector = selector;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn prompt(mut self, prompt: Arc<dyn CompactionPrompt>) -> Self {
|
||||
self.prompt = prompt;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn log(mut self, log: Value) -> Self {
|
||||
self.log = Some(log);
|
||||
self
|
||||
}
|
||||
|
||||
/// Summarise and save. `Ok(None)` means there was nothing worth compacting
|
||||
/// — not an error: too few messages, no clean split point, or a summariser
|
||||
/// that came back empty.
|
||||
pub async fn run(&self) -> crate::Result<Option<CompactionOutcome>> {
|
||||
let prior = self.store.latest_summary(self.frame).await?;
|
||||
let messages = match &prior {
|
||||
Some(s) => self.store.load_since(self.frame, s.covered_up_to).await?,
|
||||
None => self.store.load(self.frame).await?,
|
||||
};
|
||||
|
||||
let Some(split) = self.split_point(&messages) else {
|
||||
debug!(frame = %self.frame, "compaction: nothing to summarise");
|
||||
return Ok(None);
|
||||
};
|
||||
let (to_summarise, surviving) = messages.split_at(split);
|
||||
let covered_up_to = to_summarise.last().expect("split > 0").id;
|
||||
let first_surviving = surviving.first().expect("split < len").id;
|
||||
|
||||
let transcript = transcript(to_summarise);
|
||||
let body = self.prompt.build(&transcript, prior.as_ref().map(|s| s.text.as_str()));
|
||||
|
||||
let handle = self.selector.select(&self.hint, &[]).await?;
|
||||
info!(
|
||||
frame = %self.frame,
|
||||
model = %handle.id,
|
||||
messages = to_summarise.len(),
|
||||
"compaction: summarising"
|
||||
);
|
||||
let request = ModelRequest {
|
||||
messages: vec![json!({ "role": "user", "content": body })],
|
||||
tools: Vec::new(),
|
||||
model: handle.id.clone(),
|
||||
max_tokens: None,
|
||||
temperature: self.temperature,
|
||||
request_id: uuid_like(),
|
||||
conversation: self.conversation.clone(),
|
||||
frame: self.frame,
|
||||
extras: handle.info.extras.clone(),
|
||||
log: self.log.clone(),
|
||||
};
|
||||
let response = handle.model.complete(&request, None).await.map_err(|e| {
|
||||
warn!(frame = %self.frame, error = %e, "compaction: the summariser failed");
|
||||
anyhow::anyhow!("compaction: {e}")
|
||||
})?;
|
||||
|
||||
let (summary_text, usage) = match response {
|
||||
ModelResponse::Message { content, usage, .. } => (content, usage),
|
||||
// A summariser has no tools; if one hallucinates a call, its text is
|
||||
// still the summary.
|
||||
ModelResponse::ToolCalls { content, usage, .. } => {
|
||||
warn!(frame = %self.frame, "compaction: unexpected tool calls, using the content");
|
||||
(content, usage)
|
||||
}
|
||||
};
|
||||
if summary_text.trim().is_empty() {
|
||||
warn!(frame = %self.frame, "compaction: empty summary, nothing saved");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let summary_id = self
|
||||
.store
|
||||
.save_summary(self.frame, NewSummary { text: summary_text.clone(), covered_up_to })
|
||||
.await?;
|
||||
|
||||
self.events.emit(self.frame, None, LoopEvent::Compacted {
|
||||
frame: self.frame,
|
||||
covered_up_to,
|
||||
});
|
||||
for h in &self.hooks {
|
||||
h.on_compacted(self.frame, covered_up_to, first_surviving).await;
|
||||
}
|
||||
|
||||
info!(frame = %self.frame, %summary_id, %covered_up_to, "compaction: summary saved");
|
||||
Ok(Some(CompactionOutcome {
|
||||
summary_id,
|
||||
covered_up_to,
|
||||
first_surviving,
|
||||
summary_text,
|
||||
messages_covered: to_summarise.len(),
|
||||
usage,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Where to cut. Never between an assistant message and its tool results —
|
||||
/// the surviving half would be a tool result answering a call the model
|
||||
/// cannot see, which strict APIs reject outright.
|
||||
fn split_point(&self, messages: &[StoredMessage]) -> Option<usize> {
|
||||
match self.mode {
|
||||
CompactionMode::UpTo(id) => {
|
||||
let idx = messages.iter().position(|m| m.id == id)? + 1;
|
||||
(idx < messages.len()).then_some(idx)
|
||||
}
|
||||
CompactionMode::Auto { keep_tail } => {
|
||||
if messages.len() <= keep_tail {
|
||||
return None;
|
||||
}
|
||||
let raw = messages.len() - keep_tail;
|
||||
let split = (0..=raw)
|
||||
.rev()
|
||||
.find(|&i| i == 0 || matches!(messages[i].role, Role::User | Role::Agent))
|
||||
.unwrap_or(0);
|
||||
(split > 0).then_some(split)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transcript ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Head+tail truncation: a summariser needs both how a long output started and
|
||||
/// how it ended; a prefix cut throws the conclusion away.
|
||||
fn truncate_head_tail(s: &str, head_chars: usize, tail_chars: usize) -> String {
|
||||
let s = s.trim();
|
||||
let char_count = s.chars().count();
|
||||
if char_count <= head_chars + tail_chars {
|
||||
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..])
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
let s = s.trim();
|
||||
if s.chars().count() <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
let end = s.char_indices().nth(max_chars).map(|(i, _)| i).unwrap_or(s.len());
|
||||
format!("{}…", &s[..end])
|
||||
}
|
||||
|
||||
/// The messages as labeled text. Not the wire projection: a summariser reads
|
||||
/// better prose than JSON, and tool results are worth more than tool schemas.
|
||||
fn transcript(messages: &[StoredMessage]) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for msg in messages {
|
||||
match msg.role {
|
||||
Role::User | Role::Agent => {
|
||||
parts.push(format!("[USER]: {}", truncate_head_tail(&msg.content, 6000, 1500)));
|
||||
}
|
||||
Role::Assistant => {
|
||||
let mut content = truncate_head_tail(&msg.content, 6000, 1500);
|
||||
if !msg.calls.is_empty() {
|
||||
let lines: Vec<String> = msg
|
||||
.calls
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let args = c
|
||||
.arguments_raw
|
||||
.clone()
|
||||
.unwrap_or_else(|| c.arguments.to_string());
|
||||
format!(" {}({})", c.name, truncate(&args, 1200))
|
||||
})
|
||||
.collect();
|
||||
content.push_str(&format!("\n[Tool calls:\n{}\n]", lines.join("\n")));
|
||||
}
|
||||
parts.push(format!("[ASSISTANT]: {content}"));
|
||||
|
||||
for call in &msg.calls {
|
||||
let result = match call.state {
|
||||
CallState::Done => call
|
||||
.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}", call.id));
|
||||
}
|
||||
}
|
||||
// System messages are built per turn, never stored (see `store`).
|
||||
Role::System => {}
|
||||
}
|
||||
}
|
||||
parts.join("\n\n")
|
||||
}
|
||||
|
||||
/// Correlation id for the summariser call (the crate carries no uuid crate).
|
||||
fn uuid_like() -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("compaction-{nanos:032x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::{CallOutcome, NewCall, NewMessage};
|
||||
use crate::store_memory::InMemoryStore;
|
||||
use crate::tool::ToolOutput;
|
||||
|
||||
#[test]
|
||||
fn the_threshold_falls_back_to_the_estimate_when_usage_is_missing() {
|
||||
assert!(should_compact(Some(120), 0, 100));
|
||||
assert!(!should_compact(Some(80), 999, 100));
|
||||
// No usage reported (or zero) → the host's own estimate decides.
|
||||
assert!(should_compact(None, 120, 100));
|
||||
assert!(should_compact(Some(0), 120, 100));
|
||||
assert!(!should_compact(None, 80, 100));
|
||||
}
|
||||
|
||||
async fn seeded() -> (Arc<InMemoryStore>, FrameId, Vec<StoredMessage>) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let conv = ConversationId::new("c");
|
||||
let frame = store
|
||||
.open_frame(&conv, None, crate::store::FrameSpec::root("a"))
|
||||
.await
|
||||
.unwrap();
|
||||
for i in 0..4 {
|
||||
store.append(frame, NewMessage::user(format!("q{i}"))).await.unwrap();
|
||||
let m = store
|
||||
.append(frame, NewMessage::assistant(format!("a{i}"), None))
|
||||
.await
|
||||
.unwrap();
|
||||
let c = store
|
||||
.append_call(m, NewCall::new("read_file", json!({ "path": "x" })))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.resolve_call(c, &CallOutcome::Completed(ToolOutput::Text("body".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let msgs = store.load(frame).await.unwrap();
|
||||
(store, frame, msgs)
|
||||
}
|
||||
|
||||
fn compaction(store: Arc<InMemoryStore>, frame: FrameId, mode: CompactionMode) -> Compaction {
|
||||
let (bus, _) = tokio::sync::broadcast::channel(16);
|
||||
Compaction {
|
||||
store,
|
||||
// The split-point tests never reach the model.
|
||||
selector: Arc::new(crate::model::SingleModel::new(crate::testing::FakeModel::new(
|
||||
"unused",
|
||||
Vec::new(),
|
||||
))),
|
||||
hooks: Vec::new(),
|
||||
events: EventSink::new(ConversationId::new("c"), bus),
|
||||
conversation: ConversationId::new("c"),
|
||||
frame,
|
||||
mode,
|
||||
hint: ModelHint::default(),
|
||||
prompt: Arc::new(DefaultPrompt),
|
||||
temperature: None,
|
||||
log: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_cut_never_splits_an_assistant_turn_from_its_tool_results() {
|
||||
let (store, frame, msgs) = seeded().await;
|
||||
// 8 messages: user/assistant × 4. keep_tail = 3 would cut at index 5 —
|
||||
// an assistant message — so it must walk back to the user before it.
|
||||
let c = compaction(store, frame, CompactionMode::Auto { keep_tail: 3 });
|
||||
let split = c.split_point(&msgs).unwrap();
|
||||
assert!(matches!(msgs[split].role, Role::User), "cut at {split}: {:?}", msgs[split].role);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn there_is_nothing_to_compact_in_a_short_conversation() {
|
||||
let (store, frame, msgs) = seeded().await;
|
||||
let c = compaction(store, frame, CompactionMode::Auto { keep_tail: 99 });
|
||||
assert!(c.split_point(&msgs).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_explicit_cut_point_covers_it_and_keeps_the_rest() {
|
||||
let (store, frame, msgs) = seeded().await;
|
||||
let c = compaction(store.clone(), frame, CompactionMode::UpTo(msgs[2].id));
|
||||
assert_eq!(c.split_point(&msgs), Some(3));
|
||||
// Cutting at the very last message would leave nothing surviving.
|
||||
let c = compaction(store, frame, CompactionMode::UpTo(msgs.last().unwrap().id));
|
||||
assert_eq!(c.split_point(&msgs), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_transcript_carries_calls_and_their_results() {
|
||||
let (_store, _frame, msgs) = seeded().await;
|
||||
let text = transcript(&msgs[..2]);
|
||||
assert!(text.contains("[USER]: q0"), "{text}");
|
||||
assert!(text.contains("[ASSISTANT]: a0"), "{text}");
|
||||
assert!(text.contains("read_file({\"path\":\"x\"})"), "{text}");
|
||||
assert!(text.contains("[TOOL RESULT tc_1]: body"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,23 @@
|
||||
//! The system context (layered) and the `ContextAssembler` — from system +
|
||||
//! history to wire messages.
|
||||
//!
|
||||
//! **Well-formedness contract** (every assembler MUST honor it):
|
||||
//!
|
||||
//! 1. Order: static system → compaction summary (if any) → messages after
|
||||
//! `covered_up_to` → dynamic tail → tail reminder.
|
||||
//! 2. Every assistant `tool_call` has a tool-result: `Done`→result,
|
||||
//! `Failed`→error, `Cancelled`/`Rejected`→note, **`Running`/`AwaitingHuman`
|
||||
//! surviving a crash → synthetic "interrupted" result**.
|
||||
//! 3. No `failed` messages (orphans) — already filtered by the store.
|
||||
//! 4. DTL injection (§4.10 of the blueprint): when `model.tool_rendering` is
|
||||
//! not `Inline` and an `ActivationSource` is present, each activation is
|
||||
//! projected at its anchor (marker vs system+tools block, append-only).
|
||||
//! The projection itself lives in [`crate::projection`], which owns the
|
||||
//! well-formedness contract and every provider-shaped decision. This module is
|
||||
//! the seam: hosts implement [`SystemContextSource`] to say *what* goes in the
|
||||
//! system prompt, and [`LinearAssembler`] configures the projection.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::activation::{ActivationSource, ToolRendering};
|
||||
use crate::activation::ActivationSource;
|
||||
use crate::ids::{ConversationId, FrameId};
|
||||
use crate::model::ModelInfo;
|
||||
use crate::store::{HistoryStore, Role, StoredMessage};
|
||||
use crate::projection::{
|
||||
MediaSource, Projection, ProjectionHooks, ResultLimit, ToolResultDigest,
|
||||
};
|
||||
use crate::store::HistoryStore;
|
||||
|
||||
// ── SystemContext ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -115,36 +111,55 @@ pub trait ContextAssembler: Send + Sync {
|
||||
|
||||
// ── LinearAssembler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// The shipped assembler: system + summary + history, with an optional
|
||||
/// message window and tool-result truncation. Honors the DTL injection
|
||||
/// contract when given an `ActivationSource`.
|
||||
/// The shipped assembler: a [`Projection`] plus the host hooks it may use.
|
||||
///
|
||||
/// Out of the box it produces a correct OpenAI-shaped conversation. A host with
|
||||
/// stricter models overrides the projection (`with_projection`) and plugs in its
|
||||
/// media authorization and result-digest policy.
|
||||
pub struct LinearAssembler {
|
||||
/// Keep at most this many history messages (cut at a User/Agent boundary,
|
||||
/// never mid assistant+tool group).
|
||||
pub max_messages: Option<usize>,
|
||||
/// Truncate each tool result to this many chars.
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
/// DTL activations (only consulted when `tool_rendering != Inline`).
|
||||
pub activation: Option<Arc<dyn ActivationSource>>,
|
||||
pub projection: Projection,
|
||||
pub hooks: ProjectionHooks,
|
||||
}
|
||||
|
||||
impl LinearAssembler {
|
||||
pub fn new() -> Self {
|
||||
Self { max_messages: None, max_tool_result_chars: None, activation: None }
|
||||
Self { projection: Projection::default(), hooks: ProjectionHooks::default() }
|
||||
}
|
||||
|
||||
/// Replace the whole protocol configuration.
|
||||
pub fn with_projection(mut self, projection: Projection) -> Self {
|
||||
self.projection = projection;
|
||||
self
|
||||
}
|
||||
|
||||
/// Keep at most this many history messages (cut boundary-safely).
|
||||
pub fn with_max_messages(mut self, n: usize) -> Self {
|
||||
self.max_messages = Some(n);
|
||||
self.projection.max_messages = Some(n);
|
||||
self
|
||||
}
|
||||
|
||||
/// Shrink every tool result longer than `n` chars.
|
||||
pub fn with_tool_result_limit(mut self, n: usize) -> Self {
|
||||
self.max_tool_result_chars = Some(n);
|
||||
self.projection.max_tool_result =
|
||||
Some(ResultLimit { max_chars: n, previous_turns_only: false });
|
||||
self
|
||||
}
|
||||
|
||||
/// DTL activations (consulted only when `tool_rendering != Inline`).
|
||||
pub fn with_activation(mut self, src: Arc<dyn ActivationSource>) -> Self {
|
||||
self.activation = Some(src);
|
||||
self.hooks.activation = Some(src);
|
||||
self
|
||||
}
|
||||
|
||||
/// Which media a message may inline.
|
||||
pub fn with_media(mut self, src: Arc<dyn MediaSource>) -> Self {
|
||||
self.hooks.media = Some(src);
|
||||
self
|
||||
}
|
||||
|
||||
/// How an over-long tool result is condensed.
|
||||
pub fn with_digest(mut self, digest: Arc<dyn ToolResultDigest>) -> Self {
|
||||
self.hooks.digest = Some(digest);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -153,9 +168,8 @@ impl Default for LinearAssembler {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
/// The summary block is prefixed so the model understands what it is (Skald
|
||||
/// keeps its own SUMMARY_PREFIX in its assembler).
|
||||
pub const SUMMARY_PREFIX: &str = "[CONTEXT SUMMARY — earlier messages were compacted into this summary]";
|
||||
/// Re-exported for hosts that only need the default summary header.
|
||||
pub use crate::projection::SUMMARY_PREFIX;
|
||||
|
||||
#[async_trait]
|
||||
impl ContextAssembler for LinearAssembler {
|
||||
@@ -164,183 +178,6 @@ impl ContextAssembler for LinearAssembler {
|
||||
store: &Arc<dyn HistoryStore>,
|
||||
input: &AssembleInput,
|
||||
) -> crate::Result<Vec<Value>> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
|
||||
// 1. static system
|
||||
if !input.system.base.is_empty() {
|
||||
out.push(json!({ "role": "system", "content": input.system.base }));
|
||||
}
|
||||
for s in &input.system.extra_static {
|
||||
out.push(json!({ "role": "system", "content": s }));
|
||||
}
|
||||
|
||||
// 2. 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{}", 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 let Some(max) = self.max_messages {
|
||||
history = window(history, max);
|
||||
}
|
||||
|
||||
// 3. DTL activations (consulted only in non-Inline modes)
|
||||
let activations = match (&self.activation, input.model.tool_rendering) {
|
||||
(Some(src), ToolRendering::Inline) => {
|
||||
let _ = src;
|
||||
Vec::new()
|
||||
}
|
||||
(Some(src), _) => src.activations(input.frame).await.unwrap_or_default(),
|
||||
(None, _) => Vec::new(),
|
||||
};
|
||||
|
||||
for msg in &history {
|
||||
project_message(&mut out, msg, self.max_tool_result_chars);
|
||||
inject_activations(&mut out, msg, &activations, &input.model.tool_rendering);
|
||||
}
|
||||
|
||||
// 4. dynamic tail + reminder
|
||||
for s in &input.system.dynamic_tail {
|
||||
out.push(json!({ "role": "system", "content": s }));
|
||||
}
|
||||
if let Some(r) = &input.system.tail_reminder {
|
||||
out.push(json!({ "role": "system", "content": r }));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut the history to at most `max` messages, at a User/Agent boundary so an
|
||||
/// assistant+tool group is never split.
|
||||
fn window(history: Vec<StoredMessage>, max: usize) -> Vec<StoredMessage> {
|
||||
if history.len() <= max {
|
||||
return history;
|
||||
}
|
||||
let start = history.len() - max;
|
||||
let cut = history[start..]
|
||||
.iter()
|
||||
.position(|m| matches!(m.role, Role::User | Role::Agent))
|
||||
.map(|p| start + p)
|
||||
.unwrap_or(start);
|
||||
history[cut..].to_vec()
|
||||
}
|
||||
|
||||
/// Project one stored message (and its tool results) to wire messages.
|
||||
fn project_message(out: &mut Vec<Value>, msg: &StoredMessage, result_limit: Option<usize>) {
|
||||
match msg.role {
|
||||
Role::System => {
|
||||
out.push(json!({ "role": "system", "content": msg.content }));
|
||||
}
|
||||
Role::User | Role::Agent => {
|
||||
out.push(json!({ "role": "user", "content": msg.content }));
|
||||
}
|
||||
Role::Assistant => {
|
||||
let mut wire = json!({ "role": "assistant", "content": msg.content });
|
||||
if let Some(r) = &msg.reasoning {
|
||||
// Echoed under both names: DeepSeek expects reasoning_content,
|
||||
// others reasoning (the clients normalize on read).
|
||||
wire["reasoning_content"] = json!(r);
|
||||
}
|
||||
if !msg.calls.is_empty() {
|
||||
let calls: Vec<Value> = msg
|
||||
.calls
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.provider_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": c.name,
|
||||
"arguments": serde_json::to_string(&c.arguments)
|
||||
.unwrap_or_else(|_| "{}".into()),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
wire["tool_calls"] = Value::Array(calls);
|
||||
}
|
||||
out.push(wire);
|
||||
|
||||
for call in &msg.calls {
|
||||
let mut content = match call.state {
|
||||
crate::store::CallState::Running | crate::store::CallState::AwaitingHuman => {
|
||||
"[interrupted: this tool call did not complete — the session restarted \
|
||||
before a result was recorded]"
|
||||
.to_string()
|
||||
}
|
||||
crate::store::CallState::Failed => {
|
||||
format!("Error: {}", call.result.as_deref().unwrap_or("unknown error"))
|
||||
}
|
||||
_ => call.result.clone().unwrap_or_default(),
|
||||
};
|
||||
if let Some(limit) = result_limit
|
||||
&& content.chars().count() > limit
|
||||
{
|
||||
content = format!(
|
||||
"{}… [truncated]",
|
||||
content.chars().take(limit).collect::<String>()
|
||||
);
|
||||
}
|
||||
out.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.provider_id,
|
||||
"content": content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DTL injection at an activation anchor (blueprint §4.10):
|
||||
/// - `DeferredToolReference`: `_tool_references` marker on the FIRST tool
|
||||
/// result of the anchored assistant message (the client converts it).
|
||||
/// - `SystemToolBlock`: a `{role:"system", tools:[defs]}` message appended
|
||||
/// right after the anchored message's tool-result group.
|
||||
fn inject_activations(
|
||||
out: &mut Vec<Value>,
|
||||
msg: &StoredMessage,
|
||||
activations: &[crate::activation::Activation],
|
||||
mode: &ToolRendering,
|
||||
) {
|
||||
let acts: Vec<&crate::activation::Activation> =
|
||||
activations.iter().filter(|a| a.anchor == msg.id).collect();
|
||||
if acts.is_empty() {
|
||||
return;
|
||||
}
|
||||
match mode {
|
||||
ToolRendering::Inline => {}
|
||||
ToolRendering::DeferredToolReference => {
|
||||
let names: Vec<Value> = acts
|
||||
.iter()
|
||||
.flat_map(|a| &a.defs)
|
||||
.filter_map(|d| d["function"]["name"].as_str())
|
||||
.map(|n| json!(n))
|
||||
.collect();
|
||||
if names.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Attach to the first tool result just emitted for this message.
|
||||
if let Some(tool_msg) = out
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.take(msg.calls.len())
|
||||
.find(|m| m["role"].as_str() == Some("tool"))
|
||||
{
|
||||
tool_msg["_tool_references"] = Value::Array(names);
|
||||
}
|
||||
}
|
||||
ToolRendering::SystemToolBlock => {
|
||||
let defs: Vec<Value> = acts.iter().flat_map(|a| a.defs.clone()).collect();
|
||||
if !defs.is_empty() {
|
||||
out.push(json!({ "role": "system", "tools": defs }));
|
||||
}
|
||||
}
|
||||
crate::projection::project(store, input, &self.projection, &self.hooks).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
//! parent awaits; a homogeneous batch of sync delegates fans out through the
|
||||
//! kernel's generic concurrency (`concurrency_safe`).
|
||||
//!
|
||||
//! The crate ships the SYNC flow. Async delegation rides the host's
|
||||
//! `AsyncExecutor` (phase-3 concern: Skald wires its durable cron executor
|
||||
//! there); calling it here fails with a clear error.
|
||||
//! Both flows ship. A SYNC child is awaited in place; an ASYNC one is handed to
|
||||
//! the host's [`AsyncExecutor`] and its result comes back later through an
|
||||
//! [`AsyncResultSink`] — a tool call the model already has an id for, resolved
|
||||
//! whenever the work finishes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -15,11 +16,11 @@ use serde_json::{Value, json};
|
||||
use crate::async_trait;
|
||||
use crate::context::SystemContextSource;
|
||||
use crate::events::{EventSink, LoopEvent};
|
||||
use crate::ids::FrameId;
|
||||
use crate::ids::{ConversationId, FrameId, TaskId, ToolCallId};
|
||||
use crate::manager::{LoopManager, LoopParams, TurnMeta};
|
||||
use crate::model::{ModelHint, ModelSelector};
|
||||
use crate::store::{FrameSpec, HistoryStore, NewMessage};
|
||||
use crate::tool::{SharedToolSet, Tool, ToolCtx, ToolFailure, ToolOutput, ToolSet};
|
||||
use crate::store::{CallOutcome, FrameSpec, HistoryStore, NewCall, NewMessage};
|
||||
use crate::tool::{Extensions, SharedToolSet, Tool, ToolCtx, ToolFailure, ToolOutput, ToolSet};
|
||||
|
||||
// ── AgentCatalog ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -84,7 +85,16 @@ pub trait AgentCatalog: Send + Sync {
|
||||
/// Load a dispatchable profile, built for `child_frame` (already opened by
|
||||
/// the DelegateTool — frame-scoped pieces like grants/activation anchor to
|
||||
/// it). MUST reject non-`Task` kinds and unknown ids.
|
||||
async fn get(&self, id: &str, child_frame: FrameId) -> crate::Result<AgentProfile>;
|
||||
///
|
||||
/// `ctx` is the delegating call's context: a catalog that lives as long as
|
||||
/// the tenant reads the turn's own state (session, source, permissions)
|
||||
/// from `ctx.extensions` instead of having captured it at construction.
|
||||
async fn get(
|
||||
&self,
|
||||
id: &str,
|
||||
child_frame: FrameId,
|
||||
ctx: &ToolCtx,
|
||||
) -> crate::Result<AgentProfile>;
|
||||
async fn list(&self, kind: AgentKind) -> Vec<AgentSummary>;
|
||||
/// Frame-exit hook (host cleanup, e.g. deleting stack-scoped activations).
|
||||
async fn on_child_closed(&self, _frame: crate::ids::FrameId) {}
|
||||
@@ -99,6 +109,18 @@ pub struct FilteredToolSet {
|
||||
add: Vec<Arc<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl FilteredToolSet {
|
||||
/// A child's set derived from the parent's. Used by the delegate at
|
||||
/// dispatch and by [`crate::recovery`] when it rebuilds a resumed frame.
|
||||
pub fn derive(inner: Arc<dyn ToolSet>, selection: &ToolSelection) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
remove: selection.remove.clone(),
|
||||
add: selection.add.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolSet for FilteredToolSet {
|
||||
fn defs(&self, model: &crate::model::ModelInfo) -> Vec<Value> {
|
||||
let mut defs: Vec<Value> = self
|
||||
@@ -125,6 +147,255 @@ impl ToolSet for FilteredToolSet {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Async delegation ─────────────────────────────────────────────────────────
|
||||
|
||||
/// What the host is asked to run out of band (blueprint §7.2).
|
||||
///
|
||||
/// The parent's turn does **not** wait for it: `delegate` returns a receipt and
|
||||
/// the loop moves on. Everything needed to run the work later is in here, so an
|
||||
/// executor backed by a durable queue can pick it up after a restart.
|
||||
#[derive(Clone)]
|
||||
pub struct AsyncSpec {
|
||||
pub conversation: ConversationId,
|
||||
/// The delegating frame — where the result is delivered.
|
||||
pub parent_frame: FrameId,
|
||||
/// The delegating call, so a host can correlate its own record with ours.
|
||||
pub parent_call: ToolCallId,
|
||||
/// The agent that delegated (the child's is `agent`).
|
||||
pub parent_agent: String,
|
||||
pub agent: String,
|
||||
pub prompt: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// The delegating turn's extensions (the host's own context).
|
||||
pub extensions: Extensions,
|
||||
}
|
||||
|
||||
/// The host's receipt for a submitted task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskHandle {
|
||||
pub id: TaskId,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Runs a delegated task out of band. **Durability is the host's**: the crate's
|
||||
/// [`InProcessExecutor`] is lossy across restarts, a queue-backed one is not.
|
||||
#[async_trait]
|
||||
pub trait AsyncExecutor: Send + Sync {
|
||||
async fn submit(&self, spec: AsyncSpec) -> crate::Result<TaskHandle>;
|
||||
}
|
||||
|
||||
/// A task that finished, whatever ran it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompletedTask {
|
||||
pub id: TaskId,
|
||||
pub title: String,
|
||||
pub result: String,
|
||||
}
|
||||
|
||||
/// Where a finished task's result goes.
|
||||
#[async_trait]
|
||||
pub trait AsyncResultSink: Send + Sync {
|
||||
async fn deliver(&self, parent: ConversationId, task: CompletedTask) -> crate::Result<()>;
|
||||
}
|
||||
|
||||
/// The wire name of the synthetic call carrying a delivered result. The model
|
||||
/// sees it as a tool call it never made — which is exactly what it is: the
|
||||
/// system reporting back.
|
||||
pub const DELIVERY_CALL: &str = "task_completed";
|
||||
|
||||
/// The shipped sink: writes the delivery into the store, as a synthetic
|
||||
/// assistant message plus one completed call.
|
||||
///
|
||||
/// Durable by construction — it is a normal state transition, so the result is
|
||||
/// in the history the instant it lands, whether or not anything is driving the
|
||||
/// conversation. **Waking the parent is the host's job**: a live loop picks the
|
||||
/// result up on its own (it reads the store each round), and an idle
|
||||
/// conversation needs a resume, which only the host knows how to trigger for
|
||||
/// its surfaces. Wrap this sink to add that.
|
||||
pub struct StoreSink {
|
||||
store: Arc<dyn HistoryStore>,
|
||||
call_name: String,
|
||||
}
|
||||
|
||||
impl StoreSink {
|
||||
pub fn new(store: Arc<dyn HistoryStore>) -> Self {
|
||||
Self { store, call_name: DELIVERY_CALL.to_string() }
|
||||
}
|
||||
|
||||
/// Rename the synthetic call (hosts with their own legacy name).
|
||||
pub fn with_call_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.call_name = name.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncResultSink for StoreSink {
|
||||
async fn deliver(&self, parent: ConversationId, task: CompletedTask) -> crate::Result<()> {
|
||||
// The deepest active frame is where the conversation currently is: a
|
||||
// result delivered to a closed frame would never be read.
|
||||
let frame = self
|
||||
.store
|
||||
.deepest_active(&parent)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("deliver: no active frame on conversation {parent}"))?;
|
||||
|
||||
let reasoning = format!(
|
||||
"The system is notifying me that async task #{} ('{}') has completed. \
|
||||
Let me process the result via {}.",
|
||||
task.id, task.title, self.call_name,
|
||||
);
|
||||
let msg = self
|
||||
.store
|
||||
.append(
|
||||
frame.id,
|
||||
NewMessage {
|
||||
role: crate::store::Role::Assistant,
|
||||
content: String::new(),
|
||||
synthetic: true,
|
||||
reasoning: Some(reasoning),
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let call = self
|
||||
.store
|
||||
.append_call(msg, NewCall::new(&self.call_name, json!({ "task_id": task.id.get() })))
|
||||
.await?;
|
||||
let payload = json!({
|
||||
"task_id": task.id.get(),
|
||||
"title": task.title,
|
||||
"result": task.result,
|
||||
});
|
||||
self.store
|
||||
.resolve_call(call, &CallOutcome::Completed(ToolOutput::Text(payload.to_string())))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The lossy executor: runs the task on the current process, on the same
|
||||
/// manager, and delivers through the given sink.
|
||||
///
|
||||
/// **A restart loses in-flight tasks** — nothing records that the work was
|
||||
/// owed. Fine for a single-process host that treats async delegation as
|
||||
/// best-effort; a host that must not lose one wires an executor over its own
|
||||
/// durable queue (Skald: a `scheduled_jobs` row).
|
||||
pub struct InProcessExecutor {
|
||||
manager: Arc<LoopManager>,
|
||||
catalog: Arc<dyn AgentCatalog>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
sink: Arc<dyn AsyncResultSink>,
|
||||
tools: Arc<dyn ToolSet>,
|
||||
next_id: std::sync::atomic::AtomicI64,
|
||||
}
|
||||
|
||||
impl InProcessExecutor {
|
||||
pub fn new(
|
||||
manager: Arc<LoopManager>,
|
||||
catalog: Arc<dyn AgentCatalog>,
|
||||
store: Arc<dyn HistoryStore>,
|
||||
sink: Arc<dyn AsyncResultSink>,
|
||||
tools: Arc<dyn ToolSet>,
|
||||
) -> Self {
|
||||
Self { manager, catalog, store, sink, tools, next_id: std::sync::atomic::AtomicI64::new(1) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AsyncExecutor for InProcessExecutor {
|
||||
async fn submit(&self, spec: AsyncSpec) -> crate::Result<TaskHandle> {
|
||||
let id = TaskId(self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
let title = spec.title.clone().unwrap_or_else(|| spec.agent.clone());
|
||||
|
||||
// Its own frame, child of the delegating one: the task is a sub-agent
|
||||
// that nobody awaits.
|
||||
let parent = self
|
||||
.store
|
||||
.get_frame(spec.parent_frame)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("submit: parent frame not found"))?;
|
||||
let frame = self
|
||||
.store
|
||||
.open_frame(&spec.conversation, Some(spec.parent_frame), FrameSpec {
|
||||
agent: spec.agent.clone(),
|
||||
prompt: Some(spec.prompt.clone()),
|
||||
depth: parent.spec.depth + 1,
|
||||
// NOT the delegating call: that one is already resolved with the
|
||||
// receipt, and recovery must not try to complete it twice.
|
||||
parent_call: None,
|
||||
meta: Value::Null,
|
||||
})
|
||||
.await?;
|
||||
// The delegating call's context, minus its cancellation: the profile is
|
||||
// resolved against the turn that asked for the work.
|
||||
let ctx = ToolCtx {
|
||||
conversation: spec.conversation.clone(),
|
||||
frame: spec.parent_frame,
|
||||
agent: spec.parent_agent.clone(),
|
||||
call_id: spec.parent_call,
|
||||
cancel: tokio_util::sync::CancellationToken::new(),
|
||||
extensions: spec.extensions.clone(),
|
||||
};
|
||||
let profile = self.catalog.get(&spec.agent, frame, &ctx).await?;
|
||||
self.store.append(frame, NewMessage::agent(&spec.prompt)).await?;
|
||||
|
||||
let manager = self.manager.clone();
|
||||
let store = self.store.clone();
|
||||
let catalog = self.catalog.clone();
|
||||
let sink = self.sink.clone();
|
||||
let tools = profile.toolset.clone().unwrap_or_else(|| self.tools.clone());
|
||||
let task_title = title.clone();
|
||||
tokio::spawn(async move {
|
||||
let outcome = match manager
|
||||
.start_loop(LoopParams {
|
||||
conversation: spec.conversation.clone(),
|
||||
frame,
|
||||
parent_frame: Some(spec.parent_frame),
|
||||
agent: spec.agent.clone(),
|
||||
system: profile.context,
|
||||
tools,
|
||||
model_hint: profile.model.unwrap_or_default(),
|
||||
selector: profile.selector,
|
||||
// Detached from the parent turn: the point of async is that
|
||||
// the parent's /stop does not kill the background work.
|
||||
token: None,
|
||||
live_input: None,
|
||||
extensions: spec.extensions.clone(),
|
||||
meta: TurnMeta::default(),
|
||||
assembler: profile.assembler,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(handle) => handle.join().await,
|
||||
Err(e) => Err(anyhow::anyhow!("{e}")),
|
||||
};
|
||||
|
||||
catalog.on_child_closed(frame).await;
|
||||
let _ = store.close_frame(frame).await;
|
||||
|
||||
let result = match outcome {
|
||||
Ok(crate::kernel::TurnOutcome::Final { content, .. }) => content,
|
||||
Ok(crate::kernel::TurnOutcome::Cancelled) => "(cancelled)".to_string(),
|
||||
Ok(crate::kernel::TurnOutcome::Exhausted) => {
|
||||
"(no output: tool-call round budget exhausted)".to_string()
|
||||
}
|
||||
Err(e) => format!("(failed: {e})"),
|
||||
};
|
||||
if let Err(e) = sink
|
||||
.deliver(spec.conversation.clone(), CompletedTask { id, title: task_title, result })
|
||||
.await
|
||||
{
|
||||
tracing::error!(task = %id, "async task delivery failed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(TaskHandle { id, title })
|
||||
}
|
||||
}
|
||||
|
||||
// ── DelegateTool ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The shipped `delegate` tool. The parent loop simply awaits a slow tool —
|
||||
@@ -137,6 +408,8 @@ pub struct DelegateTool {
|
||||
max_depth: u32,
|
||||
name: String,
|
||||
definition_override: Option<Value>,
|
||||
/// `None` → `mode: "async"` is refused instead of silently running sync.
|
||||
async_exec: Option<Arc<dyn AsyncExecutor>>,
|
||||
}
|
||||
|
||||
impl DelegateTool {
|
||||
@@ -146,7 +419,23 @@ impl DelegateTool {
|
||||
store: Arc<dyn HistoryStore>,
|
||||
max_depth: u32,
|
||||
) -> Self {
|
||||
Self { manager, catalog, store, max_depth, name: "delegate".to_string(), definition_override: None }
|
||||
Self {
|
||||
manager,
|
||||
catalog,
|
||||
store,
|
||||
max_depth,
|
||||
name: "delegate".to_string(),
|
||||
definition_override: None,
|
||||
async_exec: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire `mode: "async"` to an executor. Without one the mode is refused —
|
||||
/// running it synchronously instead would block a turn that asked not to
|
||||
/// wait.
|
||||
pub fn with_async(mut self, exec: Arc<dyn AsyncExecutor>) -> Self {
|
||||
self.async_exec = Some(exec);
|
||||
self
|
||||
}
|
||||
|
||||
/// Register under a different wire name (Skald's legacy aliases
|
||||
@@ -182,6 +471,57 @@ impl DelegateTool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Hands the work to the host and returns the receipt immediately. The
|
||||
/// result arrives later as its own call (see [`AsyncResultSink`]), so the
|
||||
/// model is told plainly not to poll for it.
|
||||
async fn run_async(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
prompt: &str,
|
||||
args: &Value,
|
||||
ctx: &ToolCtx,
|
||||
) -> Result<ToolOutput, ToolFailure> {
|
||||
let Some(exec) = &self.async_exec else {
|
||||
return Err(ToolFailure::Failed(
|
||||
"delegate: async mode is not available in this session".to_string(),
|
||||
));
|
||||
};
|
||||
if agent_id == ctx.agent {
|
||||
return Err(ToolFailure::Failed(format!(
|
||||
"delegate: an agent cannot call itself (`{agent_id}`)"
|
||||
)));
|
||||
}
|
||||
|
||||
let handle = exec
|
||||
.submit(AsyncSpec {
|
||||
conversation: ctx.conversation.clone(),
|
||||
parent_frame: ctx.frame,
|
||||
parent_call: ctx.call_id,
|
||||
parent_agent: ctx.agent.clone(),
|
||||
agent: agent_id.to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
title: args["title"].as_str().map(str::to_string),
|
||||
description: args["description"].as_str().map(str::to_string),
|
||||
extensions: ctx.extensions.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolFailure::Failed(format!("delegate: async submit failed: {e}")))?;
|
||||
|
||||
Ok(ToolOutput::Text(
|
||||
json!({
|
||||
"task_id": handle.id.get(),
|
||||
"status": "started",
|
||||
"message": format!(
|
||||
"Task {} ('{}') is running in the background. \
|
||||
The system will automatically deliver the result to this conversation when complete. \
|
||||
Do NOT poll for it. Continue the conversation normally.",
|
||||
handle.id, handle.title,
|
||||
),
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn run_sync(&self, agent_id: &str, prompt: &str, ctx: &ToolCtx) -> Result<ToolOutput, ToolFailure> {
|
||||
if agent_id == ctx.agent {
|
||||
return Err(ToolFailure::Failed(format!(
|
||||
@@ -218,7 +558,7 @@ impl DelegateTool {
|
||||
|
||||
// Profile AFTER the frame exists (frame-scoped pieces anchor to it).
|
||||
// On rejection the frame is closed so nothing dangles.
|
||||
let profile = match self.catalog.get(agent_id, child_frame).await {
|
||||
let profile = match self.catalog.get(agent_id, child_frame, ctx).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = self.store.close_frame(child_frame).await;
|
||||
@@ -258,11 +598,7 @@ impl DelegateTool {
|
||||
.extensions
|
||||
.get::<SharedToolSet>()
|
||||
.ok_or_else(|| ToolFailure::Failed("delegate: no ToolSet in extensions".into()))?;
|
||||
Arc::new(FilteredToolSet {
|
||||
inner: parent_tools.0.clone(),
|
||||
remove: profile.tools.remove.clone(),
|
||||
add: profile.tools.add.clone(),
|
||||
})
|
||||
Arc::new(FilteredToolSet::derive(parent_tools.0.clone(), &profile.tools))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -361,11 +697,8 @@ impl Tool for DelegateTool {
|
||||
.ok_or_else(|| ToolFailure::Failed("delegate: missing required argument `prompt`".into()))?;
|
||||
|
||||
match args["mode"].as_str() {
|
||||
Some("async") => Err(ToolFailure::Failed(
|
||||
"delegate: async mode rides the host's AsyncExecutor, which is not wired on this path"
|
||||
.to_string(),
|
||||
)),
|
||||
_ => self.run_sync(agent_id, prompt, ctx).await,
|
||||
Some("async") => self.run_async(agent_id, prompt, &args, ctx).await,
|
||||
_ => self.run_sync(agent_id, prompt, ctx).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +732,12 @@ impl Default for StaticCatalog {
|
||||
|
||||
#[async_trait]
|
||||
impl AgentCatalog for StaticCatalog {
|
||||
async fn get(&self, id: &str, _child_frame: FrameId) -> crate::Result<AgentProfile> {
|
||||
async fn get(
|
||||
&self,
|
||||
id: &str,
|
||||
_child_frame: FrameId,
|
||||
_ctx: &ToolCtx,
|
||||
) -> crate::Result<AgentProfile> {
|
||||
self.profiles
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
|
||||
@@ -86,12 +86,7 @@ pub(crate) async fn run(
|
||||
// ToolCtx extensions: host extensions + the event sink + the turn's tool
|
||||
// set, so shipped tools (ask_user, activate_tools, delegate) reach what
|
||||
// they need.
|
||||
let tool_extensions = || {
|
||||
let mut ext = params.extensions.clone();
|
||||
ext.insert(Arc::new(events.clone()));
|
||||
ext.insert(Arc::new(crate::tool::SharedToolSet(params.tools.clone())));
|
||||
ext
|
||||
};
|
||||
let tool_extensions = || tool_extensions(¶ms, &events);
|
||||
|
||||
events.emit(frame, parent, LoopEvent::TurnStarted);
|
||||
|
||||
@@ -289,6 +284,20 @@ pub(crate) async fn run(
|
||||
finish(TurnOutcome::Exhausted, &deps, &hook_ctx(), &events, frame, parent).await
|
||||
}
|
||||
|
||||
/// What a tool call sees: the host's extensions plus the event sink and the
|
||||
/// turn's tool set (shipped tools — ask_user, activate_tools, delegate — reach
|
||||
/// what they need through them). Shared with [`crate::recovery`], which
|
||||
/// re-executes a call outside a round and must hand it the same context.
|
||||
pub(crate) fn tool_extensions(
|
||||
params: &LoopParams,
|
||||
events: &EventSink,
|
||||
) -> crate::tool::Extensions {
|
||||
let mut ext = params.extensions.clone();
|
||||
ext.insert(Arc::new(events.clone()));
|
||||
ext.insert(Arc::new(crate::tool::SharedToolSet(params.tools.clone())));
|
||||
ext
|
||||
}
|
||||
|
||||
/// Terminal helper: hooks.on_turn_end (+ Cancelled event) then return.
|
||||
async fn finish(
|
||||
outcome: TurnOutcome,
|
||||
@@ -510,7 +519,7 @@ async fn record_call(
|
||||
})
|
||||
}
|
||||
|
||||
enum PreExecution {
|
||||
pub(crate) enum PreExecution {
|
||||
Run(Arc<dyn crate::tool::Tool>),
|
||||
Resolved(CallOutcome),
|
||||
TurnCancelled,
|
||||
@@ -519,8 +528,9 @@ enum PreExecution {
|
||||
Suspended,
|
||||
}
|
||||
|
||||
/// Gate + hooks.pre + tool lookup — shared by sequential and fan-out paths.
|
||||
async fn pre_execution(
|
||||
/// Gate + hooks.pre + tool lookup — shared by the sequential path, the
|
||||
/// fan-out and [`crate::recovery`]'s re-execution of an interrupted call.
|
||||
pub(crate) async fn pre_execution(
|
||||
deps: &Arc<KernelDeps>,
|
||||
params: &LoopParams,
|
||||
events: &EventSink,
|
||||
@@ -572,8 +582,8 @@ async fn pre_execution(
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase-3 shared by both paths: hooks.post → resolve → emit.
|
||||
async fn record_outcome(
|
||||
/// Phase-3 shared by both paths (and by recovery): hooks.post → resolve → emit.
|
||||
pub(crate) async fn record_outcome(
|
||||
deps: &Arc<KernelDeps>,
|
||||
params: &LoopParams,
|
||||
events: &EventSink,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! Design document: `blueprint/project-loop.md` (Skald workspace).
|
||||
|
||||
pub mod activation;
|
||||
pub mod compaction;
|
||||
pub mod context;
|
||||
pub mod delegate;
|
||||
pub mod events;
|
||||
@@ -23,6 +24,8 @@ pub mod kernel;
|
||||
pub mod manager;
|
||||
pub mod model;
|
||||
pub mod models;
|
||||
pub mod projection;
|
||||
pub mod recovery;
|
||||
pub mod store;
|
||||
pub mod store_memory;
|
||||
pub mod testing;
|
||||
@@ -43,13 +46,17 @@ pub mod prelude {
|
||||
pub use crate::activation::{
|
||||
ActivateToolsTool, Activation, ActivationSource, ToolActivator, ToolRendering,
|
||||
};
|
||||
pub use crate::compaction::{
|
||||
Compaction, CompactionMode, CompactionOutcome, CompactionPrompt, should_compact,
|
||||
};
|
||||
pub use crate::context::{
|
||||
AssembleInput, ContextAssembler, LinearAssembler, StaticSystemContext, SystemContext,
|
||||
SystemContextSource, TurnInfo,
|
||||
};
|
||||
pub use crate::delegate::{
|
||||
AgentCatalog, AgentKind, AgentProfile, AgentSummary, DelegateTool, FilteredToolSet,
|
||||
StaticCatalog, ToolSelection,
|
||||
AgentCatalog, AgentKind, AgentProfile, AgentSummary, AsyncExecutor, AsyncResultSink,
|
||||
AsyncSpec, CompletedTask, DelegateTool, FilteredToolSet, InProcessExecutor, StaticCatalog,
|
||||
StoreSink, TaskHandle, ToolSelection,
|
||||
};
|
||||
pub use crate::events::{DeltaKind, Event, EventSink, LoopEvent};
|
||||
pub use crate::gate::{AllowAll, DenyList, Gate, GateDecision, PendingCall};
|
||||
@@ -67,6 +74,13 @@ pub mod prelude {
|
||||
ModelSelector, RawMeta, RetryPolicy, SingleModel, StaticModels, StreamDelta, ToolCall,
|
||||
Usage,
|
||||
};
|
||||
pub use crate::recovery::{
|
||||
HumanDecision, PendingPolicy, Recovery, RecoveryPolicy, RecoveryReport, RunningPolicy,
|
||||
};
|
||||
pub use crate::projection::{
|
||||
MediaBlob, MediaBudget, MediaKind, MediaSource, Projection, ProjectionHooks,
|
||||
ReasoningEcho, ResultLimit, ToolResultDigest,
|
||||
};
|
||||
pub use crate::store::{
|
||||
CallOutcome, CallState, FrameRecord, FrameSpec, HistoryStore, NewCall, NewMessage,
|
||||
NewSummary, Role, StoredCall, StoredMessage, StoredSummary,
|
||||
|
||||
@@ -58,6 +58,10 @@ pub struct TurnParams {
|
||||
/// Already filtered (visibility/approval).
|
||||
pub tools: Arc<dyn ToolSet>,
|
||||
pub model_hint: ModelHint,
|
||||
/// Per-turn selector override — e.g. this agent's required strength, which
|
||||
/// is host policy (D14) and varies turn to turn while the manager lives as
|
||||
/// long as the tenant. `None` = the manager's.
|
||||
pub selector: Option<Arc<dyn ModelSelector>>,
|
||||
/// None for sub-agents / cron / resume.
|
||||
pub live_input: Option<Arc<dyn LiveInput>>,
|
||||
/// Flows into `ToolCtx.extensions`.
|
||||
@@ -139,6 +143,29 @@ struct RunningEntry {
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
/// Holds a conversation in the live registry for work that is not one spawned
|
||||
/// loop (see [`LoopManager::claim`]). Releases on drop, including on an early
|
||||
/// return or a panic — a leaked claim would lock the conversation for the
|
||||
/// process's lifetime.
|
||||
pub(crate) struct ConversationClaim {
|
||||
conversation: ConversationId,
|
||||
registry: Arc<Mutex<HashMap<ConversationId, RunningEntry>>>,
|
||||
token: CancellationToken,
|
||||
}
|
||||
|
||||
impl ConversationClaim {
|
||||
/// The claim's cancellation token — `/stop` cancels it through the registry.
|
||||
pub(crate) fn token(&self) -> CancellationToken {
|
||||
self.token.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConversationClaim {
|
||||
fn drop(&mut self) {
|
||||
self.registry.lock().unwrap().remove(&self.conversation);
|
||||
}
|
||||
}
|
||||
|
||||
// ── LoopManager ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct LoopManager {
|
||||
@@ -212,7 +239,7 @@ impl LoopManager {
|
||||
system: params.system,
|
||||
tools: params.tools,
|
||||
model_hint: params.model_hint,
|
||||
selector: None,
|
||||
selector: params.selector,
|
||||
token: None,
|
||||
live_input: params.live_input,
|
||||
extensions: params.extensions,
|
||||
@@ -287,6 +314,108 @@ impl LoopManager {
|
||||
self.registry.lock().unwrap().contains_key(conv)
|
||||
}
|
||||
|
||||
/// Take the conversation for something that is not a single spawned loop —
|
||||
/// a recovery pass, an out-of-band tool resolution. `None` when another
|
||||
/// loop already holds it (anti double-driving, same rule as `start_turn`).
|
||||
///
|
||||
/// The claim registers in the live registry, so `/stop` cancels it and
|
||||
/// `list_running` shows it; dropping the guard releases it.
|
||||
pub(crate) fn claim(
|
||||
&self,
|
||||
conv: &ConversationId,
|
||||
frame: FrameId,
|
||||
agent: &str,
|
||||
) -> Option<ConversationClaim> {
|
||||
let token = CancellationToken::new();
|
||||
let mut registry = self.registry.lock().unwrap();
|
||||
if registry.contains_key(conv) {
|
||||
return None;
|
||||
}
|
||||
registry.insert(conv.clone(), RunningEntry {
|
||||
frame,
|
||||
agent: agent.to_string(),
|
||||
cancel: token.clone(),
|
||||
});
|
||||
Some(ConversationClaim {
|
||||
conversation: conv.clone(),
|
||||
registry: self.registry.clone(),
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
// ── recovery (blueprint §8) ──
|
||||
|
||||
/// A [`Recovery`](crate::recovery::Recovery) bound to this manager.
|
||||
pub fn recovery(
|
||||
self: &Arc<Self>,
|
||||
catalog: Arc<dyn crate::delegate::AgentCatalog>,
|
||||
policy: crate::recovery::RecoveryPolicy,
|
||||
) -> crate::recovery::Recovery {
|
||||
crate::recovery::Recovery::new(self.clone(), catalog, policy)
|
||||
}
|
||||
|
||||
/// Resume a conversation left mid-turn: recovery with the default policy.
|
||||
pub async fn resume(
|
||||
self: &Arc<Self>,
|
||||
conv: &ConversationId,
|
||||
catalog: Arc<dyn crate::delegate::AgentCatalog>,
|
||||
root: &TurnParams,
|
||||
) -> crate::Result<crate::recovery::RecoveryReport> {
|
||||
self.recovery(catalog, crate::recovery::RecoveryPolicy::default())
|
||||
.run(conv, root)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a call a human answered out of band — the approval card clicked
|
||||
/// after a restart, when no loop is left holding the oneshot.
|
||||
///
|
||||
/// On approval the tool runs with the **gate skipped**: the human just
|
||||
/// decided, and asking the rules again would either re-prompt or overturn
|
||||
/// them. The conversation is then recovered, so the model sees the result
|
||||
/// and continues.
|
||||
pub async fn resolve_pending(
|
||||
self: &Arc<Self>,
|
||||
call: crate::ids::ToolCallId,
|
||||
decision: crate::recovery::HumanDecision,
|
||||
catalog: Arc<dyn crate::delegate::AgentCatalog>,
|
||||
root: &TurnParams,
|
||||
) -> crate::Result<crate::recovery::RecoveryReport> {
|
||||
crate::recovery::resolve_pending(self, call, decision, catalog, root).await
|
||||
}
|
||||
|
||||
// ── compaction (blueprint §9) ──
|
||||
|
||||
/// A [`Compaction`](crate::compaction::Compaction) on one frame, sharing
|
||||
/// this manager's store, hooks and event bus. Configure it with the
|
||||
/// builder methods, then `run()`.
|
||||
pub fn new_compaction(
|
||||
&self,
|
||||
conv: ConversationId,
|
||||
frame: FrameId,
|
||||
) -> crate::compaction::Compaction {
|
||||
crate::compaction::Compaction {
|
||||
store: self.deps.store.clone(),
|
||||
selector: self.deps.models.clone(),
|
||||
hooks: self.deps.hooks.clone(),
|
||||
events: self.sink(conv.clone()),
|
||||
conversation: conv,
|
||||
frame,
|
||||
mode: crate::compaction::CompactionMode::default(),
|
||||
hint: ModelHint::default(),
|
||||
prompt: Arc::new(crate::compaction::DefaultPrompt),
|
||||
temperature: None,
|
||||
log: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deps(&self) -> &Arc<KernelDeps> {
|
||||
&self.deps
|
||||
}
|
||||
|
||||
pub(crate) fn sink_for(&self, conv: ConversationId) -> EventSink {
|
||||
self.sink(conv)
|
||||
}
|
||||
|
||||
/// Global view (UI "running agents").
|
||||
pub fn list_running(&self) -> Vec<RunningInfo> {
|
||||
self.registry
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! The wire half of multimodal media: which files a model can take, in which
|
||||
//! content-part shape, within which budgets.
|
||||
//!
|
||||
//! The host supplies **blobs** it has already authorized (containment, upload
|
||||
//! rules, ownership — its policy); this module decides whether a blob reaches
|
||||
//! the model and in what shape. The split is deliberate: the part shapes and
|
||||
//! the byte ceilings are protocol (`MAX_DOCUMENT_BYTES` is literally
|
||||
//! Anthropic's per-request document ceiling), the authorization is not.
|
||||
//!
|
||||
//! Promotion is strict: a blob is inlined only when the model declares the
|
||||
//! modality's capability, the **sniffed magic bytes** match an allowed MIME (a
|
||||
//! host-claimed MIME is never trusted — there is no seam to pass one), and the
|
||||
//! per-file / per-turn budgets hold. Anything failing a check is reported back
|
||||
//! as skipped so the host can keep it on its textual path.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
use tracing::debug;
|
||||
|
||||
/// Max media parts inlined per turn.
|
||||
pub const MAX_MEDIA_PER_TURN: usize = 4;
|
||||
/// Max bytes for one inlined image.
|
||||
pub const MAX_IMAGE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
/// Max bytes for one inlined video.
|
||||
pub const MAX_VIDEO_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Max bytes for one inlined document (Anthropic's per-request ceiling).
|
||||
pub const MAX_DOCUMENT_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Max combined media bytes inlined per turn.
|
||||
pub const MAX_TOTAL_MEDIA_BYTES: u64 = 48 * 1024 * 1024;
|
||||
|
||||
// ── MediaKind ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A model-input modality: the capability that unlocks it and the content-part
|
||||
/// shape it maps to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MediaKind {
|
||||
Image,
|
||||
Video,
|
||||
/// PDFs, as the OpenAI file-input part (`{"type":"file","file":{…}}`) —
|
||||
/// forwarded verbatim by OpenAI-compatible clients and translated to a
|
||||
/// native `document` block by the Anthropic client.
|
||||
Document,
|
||||
}
|
||||
|
||||
impl MediaKind {
|
||||
/// The `ModelInfo::capabilities` entry that unlocks this modality.
|
||||
pub fn capability(self) -> &'static str {
|
||||
match self {
|
||||
Self::Image => "vision",
|
||||
Self::Video => "video",
|
||||
Self::Document => "document",
|
||||
}
|
||||
}
|
||||
|
||||
/// The OpenAI content-part type.
|
||||
pub fn part_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Image => "image_url",
|
||||
Self::Video => "video_url",
|
||||
Self::Document => "file",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable format list (hosts use it in tool descriptions).
|
||||
pub fn formats(self) -> &'static str {
|
||||
match self {
|
||||
Self::Image => "images (PNG, JPEG, GIF, WebP)",
|
||||
Self::Video => "video (MP4, WebM, MOV, …)",
|
||||
Self::Document => "PDF documents",
|
||||
}
|
||||
}
|
||||
|
||||
/// The modality a sniffed MIME belongs to.
|
||||
pub fn for_mime(mime: &str) -> Option<Self> {
|
||||
match mime {
|
||||
"image/png" | "image/jpeg" | "image/gif" | "image/webp" => Some(Self::Image),
|
||||
"video/mp4" | "video/mpeg" | "video/quicktime" | "video/webm" | "video/x-msvideo"
|
||||
| "video/x-flv" | "video/3gpp" => Some(Self::Video),
|
||||
"application/pdf" => Some(Self::Document),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The modalities a model with these capabilities can take, in a stable order.
|
||||
pub fn enabled(capabilities: &[String]) -> Vec<Self> {
|
||||
[Self::Image, Self::Video, Self::Document]
|
||||
.into_iter()
|
||||
.filter(|k| capabilities.iter().any(|c| c == k.capability()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── MediaBudget ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-file and per-turn ceilings.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MediaBudget {
|
||||
pub max_per_turn: usize,
|
||||
pub max_image_bytes: u64,
|
||||
pub max_video_bytes: u64,
|
||||
pub max_document_bytes: u64,
|
||||
pub max_total_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for MediaBudget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_per_turn: MAX_MEDIA_PER_TURN,
|
||||
max_image_bytes: MAX_IMAGE_BYTES,
|
||||
max_video_bytes: MAX_VIDEO_BYTES,
|
||||
max_document_bytes: MAX_DOCUMENT_BYTES,
|
||||
max_total_bytes: MAX_TOTAL_MEDIA_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaBudget {
|
||||
pub fn max_bytes(&self, kind: MediaKind) -> u64 {
|
||||
match kind {
|
||||
MediaKind::Image => self.max_image_bytes,
|
||||
MediaKind::Video => self.max_video_bytes,
|
||||
MediaKind::Document => self.max_document_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── MediaBlob ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A candidate medium the host has already authorized. Reads are lazy so a
|
||||
/// blob rejected on capability or size is never fully loaded.
|
||||
#[async_trait]
|
||||
pub trait MediaBlob: Send + Sync {
|
||||
/// Display name (the `filename` of a `file` part).
|
||||
fn name(&self) -> &str;
|
||||
/// Byte length; `None` (unknown) means "do not inline".
|
||||
async fn size(&self) -> Option<u64>;
|
||||
/// The first bytes, for magic-byte sniffing (16 are enough).
|
||||
async fn head(&self) -> Option<Vec<u8>>;
|
||||
/// The whole content.
|
||||
async fn read_all(&self) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
// ── projection ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// The OpenAI-wire content part for one inlined medium.
|
||||
pub fn media_part(kind: MediaKind, mime: &str, bytes: &[u8], filename: &str) -> Value {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let url = format!("data:{mime};base64,{b64}");
|
||||
match kind {
|
||||
MediaKind::Document => {
|
||||
json!({ "type": "file", "file": { "filename": filename, "file_data": url } })
|
||||
}
|
||||
k => {
|
||||
let t = k.part_type();
|
||||
json!({ "type": t, t: { "url": url } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits blobs into inline content parts and the indices left out.
|
||||
///
|
||||
/// Skipped blobs are the host's business: it typically renders them as a
|
||||
/// textual path list so the agent can still read them with a tool.
|
||||
pub async fn partition(
|
||||
blobs: &[Arc<dyn MediaBlob>],
|
||||
capabilities: &[String],
|
||||
budget: &MediaBudget,
|
||||
) -> (Vec<Value>, Vec<usize>) {
|
||||
if blobs.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
if MediaKind::enabled(capabilities).is_empty() {
|
||||
return (Vec::new(), (0..blobs.len()).collect());
|
||||
}
|
||||
|
||||
let mut parts: Vec<Value> = Vec::new();
|
||||
let mut skipped: Vec<usize> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
|
||||
for (idx, blob) in blobs.iter().enumerate() {
|
||||
if parts.len() >= budget.max_per_turn {
|
||||
debug!(name = blob.name(), "media not inlined: per-turn count budget exhausted");
|
||||
skipped.push(idx);
|
||||
continue;
|
||||
}
|
||||
match promote(blob.as_ref(), capabilities, budget, total).await {
|
||||
Some((part, bytes)) => {
|
||||
total += bytes;
|
||||
parts.push(part);
|
||||
}
|
||||
None => skipped.push(idx),
|
||||
}
|
||||
}
|
||||
(parts, skipped)
|
||||
}
|
||||
|
||||
/// Sniff + capability + budget + build, for one blob. `None` (logged at debug)
|
||||
/// when it is not a recognized medium, the model lacks the modality, or a byte
|
||||
/// budget is exhausted. The per-turn **count** budget is the caller's.
|
||||
async fn promote(
|
||||
blob: &dyn MediaBlob,
|
||||
capabilities: &[String],
|
||||
budget: &MediaBudget,
|
||||
used_total: u64,
|
||||
) -> Option<(Value, u64)> {
|
||||
let head = blob.head().await?;
|
||||
let mime = sniff_mime(&head)?;
|
||||
let kind = MediaKind::for_mime(mime)?;
|
||||
if !capabilities.iter().any(|c| c == kind.capability()) {
|
||||
debug!(name = blob.name(), mime, "media not inlined: model lacks the capability");
|
||||
return None;
|
||||
}
|
||||
|
||||
let size = blob.size().await?;
|
||||
if size > budget.max_bytes(kind) {
|
||||
debug!(name = blob.name(), size, "media not inlined: file too large");
|
||||
return None;
|
||||
}
|
||||
if used_total + size > budget.max_total_bytes {
|
||||
debug!(name = blob.name(), "media not inlined: per-turn byte budget exhausted");
|
||||
return None;
|
||||
}
|
||||
|
||||
let bytes = blob.read_all().await?;
|
||||
Some((media_part(kind, mime, &bytes, blob.name()), size))
|
||||
}
|
||||
|
||||
/// 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 are not model input).
|
||||
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::*;
|
||||
|
||||
/// An in-memory blob.
|
||||
struct Blob {
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A blob as the trait object the engine takes.
|
||||
fn blob(name: &str, bytes: Vec<u8>) -> Arc<dyn MediaBlob> {
|
||||
Arc::new(Blob { name: name.to_string(), bytes })
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaBlob for Blob {
|
||||
fn name(&self) -> &str { &self.name }
|
||||
async fn size(&self) -> Option<u64> { Some(self.bytes.len() as u64) }
|
||||
async fn head(&self) -> Option<Vec<u8>> {
|
||||
Some(self.bytes.iter().copied().take(16).collect())
|
||||
}
|
||||
async fn read_all(&self) -> Option<Vec<u8>> { Some(self.bytes.clone()) }
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
v.extend_from_slice(&[0xAA; 64]);
|
||||
v
|
||||
}
|
||||
|
||||
fn pdf() -> 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()
|
||||
}
|
||||
|
||||
#[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 inlines_png_for_a_vision_model() {
|
||||
let (parts, skipped) =
|
||||
partition(&[blob("a.png", png())], &caps(&["vision"]), &MediaBudget::default()).await;
|
||||
assert!(skipped.is_empty());
|
||||
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,")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inlines_pdf_as_a_file_part_for_a_document_model() {
|
||||
let (parts, skipped) =
|
||||
partition(&[blob("a.pdf", pdf())], &caps(&["document"]), &MediaBudget::default()).await;
|
||||
assert!(skipped.is_empty());
|
||||
assert_eq!(parts[0]["type"], "file");
|
||||
assert_eq!(parts[0]["file"]["filename"], "a.pdf");
|
||||
assert!(
|
||||
parts[0]["file"]["file_data"].as_str().unwrap().starts_with("data:application/pdf;base64,")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gates_on_capability_per_modality() {
|
||||
let b = |bytes: Vec<u8>| vec![blob("x", bytes)];
|
||||
let budget = MediaBudget::default();
|
||||
|
||||
// No capability at all.
|
||||
let (parts, skipped) = partition(&b(png()), &caps(&[]), &budget).await;
|
||||
assert!(parts.is_empty() && skipped == vec![0]);
|
||||
|
||||
// vision does not unlock PDFs, document does not unlock images.
|
||||
let (parts, skipped) = partition(&b(pdf()), &caps(&["vision"]), &budget).await;
|
||||
assert!(parts.is_empty() && skipped == vec![0]);
|
||||
let (parts, skipped) = partition(&b(png()), &caps(&["document"]), &budget).await;
|
||||
assert!(parts.is_empty() && skipped == vec![0]);
|
||||
|
||||
// An unrecognized medium is never inlined.
|
||||
let (parts, skipped) = partition(&b(b"plain text".to_vec()), &caps(&["vision"]), &budget).await;
|
||||
assert!(parts.is_empty() && skipped == vec![0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enforces_count_per_file_and_total_budgets() {
|
||||
let budget = MediaBudget::default();
|
||||
let blobs: Vec<Arc<dyn MediaBlob>> = (0..budget.max_per_turn + 2)
|
||||
.map(|i| blob(&format!("{i}.png"), png()))
|
||||
.collect();
|
||||
let (parts, skipped) = partition(&blobs, &caps(&["vision"]), &budget).await;
|
||||
assert_eq!(parts.len(), budget.max_per_turn);
|
||||
assert_eq!(skipped.len(), 2);
|
||||
|
||||
// Per-file ceiling.
|
||||
let tight = MediaBudget { max_image_bytes: 8, ..MediaBudget::default() };
|
||||
let (parts, skipped) = partition(&[blob("a.png", png())], &caps(&["vision"]), &tight).await;
|
||||
assert!(parts.is_empty() && skipped == vec![0]);
|
||||
|
||||
// Per-turn total: the first fits, the second does not.
|
||||
let total = MediaBudget { max_total_bytes: 100, ..MediaBudget::default() };
|
||||
let (parts, skipped) = partition(
|
||||
&[blob("a.png", png()), blob("b.png", png())],
|
||||
&caps(&["vision"]),
|
||||
&total,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(parts.len(), 1);
|
||||
assert_eq!(skipped, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_modalities_are_capability_driven() {
|
||||
assert!(MediaKind::enabled(&caps(&[])).is_empty());
|
||||
assert_eq!(MediaKind::enabled(&caps(&["vision"])), vec![MediaKind::Image]);
|
||||
assert_eq!(
|
||||
MediaKind::enabled(&caps(&["document", "vision"])),
|
||||
vec![MediaKind::Image, MediaKind::Document],
|
||||
"the order is the enum's, not the capability list's"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
//! The projection: stored history → wire messages. **This is where provider
|
||||
//! divergence lives**, so it belongs to the crate rather than to any host.
|
||||
//!
|
||||
//! What the crate owns here: the shape of every message (string content vs
|
||||
//! content-part array, `cache_control` placement, `tool_calls`/`tool` shapes,
|
||||
//! media parts), the well-formedness rules (a result for every tool call, no
|
||||
//! orphans, role alternation, boundary-safe windowing), the dynamic-tool-loading
|
||||
//! injections, and the byte fidelity of what goes back on the wire.
|
||||
//!
|
||||
//! What the host owns: the **content** — the system prompt layers
|
||||
//! ([`crate::context::SystemContextSource`]), which media a message may inline
|
||||
//! ([`MediaSource`]) and how an over-long tool result is condensed
|
||||
//! ([`ToolResultDigest`]). Everything is optional: with no hooks at all the
|
||||
//! projection is a complete, correct OpenAI-shaped conversation.
|
||||
//!
|
||||
//! **Well-formedness contract** (the reason a resumed turn can just re-run):
|
||||
//!
|
||||
//! 1. Order: static system → extra static → summary → history after
|
||||
//! `covered_up_to` → dynamic tail → tail reminder.
|
||||
//! 2. Every assistant `tool_call` has a tool result: `Done` → the result,
|
||||
//! `Failed` → an error, `Cancelled`/`Rejected` → a note, and a `Running` /
|
||||
//! `AwaitingHuman` call that survived a crash → a synthetic "interrupted"
|
||||
//! result. A model must never see a call it gets no answer for.
|
||||
//! 3. No `failed` messages (orphans of cancelled turns) — the store filters them.
|
||||
//! 4. DTL injections are **append-only**: the cacheable prefix stays
|
||||
//! byte-identical, so activating a tool never invalidates the prompt cache.
|
||||
|
||||
pub mod media;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::activation::{Activation, ActivationSource, ToolRendering};
|
||||
use crate::context::AssembleInput;
|
||||
use crate::ids::MessageId;
|
||||
use crate::store::{CallState, HistoryStore, Role, StoredCall, StoredMessage};
|
||||
|
||||
pub use media::{MediaBlob, MediaBudget, MediaKind};
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
/// How a stored `reasoning_content` is echoed back.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ReasoningEcho {
|
||||
/// `reasoning_content` only (DeepSeek).
|
||||
#[default]
|
||||
ContentOnly,
|
||||
/// Both `reasoning_content` and `reasoning` — some OpenAI-compatible
|
||||
/// endpoints read one, some the other, and neither rejects the extra key.
|
||||
Both,
|
||||
}
|
||||
|
||||
/// When and how far tool results are shrunk.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ResultLimit {
|
||||
/// Gate: results longer than this (in bytes — cheap and stable) are shrunk.
|
||||
/// The fallback truncation cuts on a **char** boundary, never mid-codepoint.
|
||||
pub max_chars: usize,
|
||||
/// Shrink only results of turns before the current one, so the in-flight
|
||||
/// turn always sees its own tool output in full.
|
||||
pub previous_turns_only: bool,
|
||||
}
|
||||
|
||||
/// The protocol-shaped knobs of the projection. [`Default`] is a correct
|
||||
/// OpenAI-shaped conversation; a host overrides only what its models need.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Projection {
|
||||
/// Header of the compaction summary block.
|
||||
pub summary_prefix: String,
|
||||
/// Optional trailer, to mark where the summary ends and full history resumes.
|
||||
pub summary_suffix: Option<String>,
|
||||
/// Keep at most this many history messages (cut boundary-safely).
|
||||
pub max_messages: Option<usize>,
|
||||
pub max_tool_result: Option<ResultLimit>,
|
||||
/// Result text for a call that was still `Running`/`AwaitingHuman` when the
|
||||
/// process died.
|
||||
pub interrupted_text: String,
|
||||
/// Result text for a `Rejected` call that recorded none.
|
||||
pub rejected_default: String,
|
||||
/// Result text for a `Cancelled` call that recorded none.
|
||||
pub cancelled_default: String,
|
||||
/// Some models (DeepSeek thinking mode) reject a replayed tool-calling turn
|
||||
/// whose `reasoning_content` is empty: this stands in when none was stored.
|
||||
pub reasoning_placeholder: Option<String>,
|
||||
pub reasoning_echo: ReasoningEcho,
|
||||
/// Joins the dynamic-tail layers into the single trailing system message.
|
||||
pub tail_separator: String,
|
||||
pub media: MediaBudget,
|
||||
/// In `DeferredToolReference` mode, the tool whose result carries the
|
||||
/// `_tool_references` marker (the activation tool's name). `None` = the
|
||||
/// first result of the anchored message.
|
||||
pub activation_anchor_tool: Option<String>,
|
||||
}
|
||||
|
||||
/// The default summary header — enough for a model to know what it is reading.
|
||||
pub const SUMMARY_PREFIX: &str =
|
||||
"[CONTEXT SUMMARY — earlier messages were compacted into this summary]";
|
||||
|
||||
impl Default for Projection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
summary_prefix: SUMMARY_PREFIX.to_string(),
|
||||
summary_suffix: None,
|
||||
max_messages: None,
|
||||
max_tool_result: None,
|
||||
interrupted_text: "[interrupted: this tool call did not complete — the session \
|
||||
restarted before a result was recorded]"
|
||||
.to_string(),
|
||||
rejected_default: String::new(),
|
||||
cancelled_default: String::new(),
|
||||
reasoning_placeholder: None,
|
||||
reasoning_echo: ReasoningEcho::default(),
|
||||
tail_separator: "\n\n---\n".to_string(),
|
||||
media: MediaBudget::default(),
|
||||
activation_anchor_tool: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Host hooks ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which media a message may inline. The host authorizes (containment,
|
||||
/// ownership, upload rules); the crate decides shape and budget.
|
||||
#[async_trait]
|
||||
pub trait MediaSource: Send + Sync {
|
||||
/// Media attached to a user/agent message.
|
||||
async fn message_media(&self, _msg: &StoredMessage) -> Vec<Arc<dyn MediaBlob>> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Media produced by an assistant turn's tool calls.
|
||||
async fn call_media(&self, _calls: &[StoredCall]) -> Vec<Arc<dyn MediaBlob>> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Text appended to the message for the media that did NOT make it (a path
|
||||
/// list, so the agent can still reach them with a tool).
|
||||
///
|
||||
/// `skipped` are **positions in the vector `message_media` just returned**
|
||||
/// for this message, so the host can map them back to whatever it built
|
||||
/// them from.
|
||||
fn skipped_text(&self, _msg: &StoredMessage, _skipped: &[usize]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// How an over-long tool result is condensed. The crate decides *when*
|
||||
/// (the [`ResultLimit`] gate); the host decides *what to say*, because a good
|
||||
/// summary knows what the tool does.
|
||||
#[async_trait]
|
||||
pub trait ToolResultDigest: Send + Sync {
|
||||
/// `None` → the crate applies its generic char-boundary truncation.
|
||||
async fn condense(&self, name: &str, args: &Value, result: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
/// The host hooks, all optional.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ProjectionHooks {
|
||||
pub activation: Option<Arc<dyn ActivationSource>>,
|
||||
pub media: Option<Arc<dyn MediaSource>>,
|
||||
pub digest: Option<Arc<dyn ToolResultDigest>>,
|
||||
}
|
||||
|
||||
// ── The engine ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Project a frame's stored history into wire messages.
|
||||
pub async fn project(
|
||||
store: &Arc<dyn HistoryStore>,
|
||||
input: &AssembleInput,
|
||||
cfg: &Projection,
|
||||
hooks: &ProjectionHooks,
|
||||
) -> crate::Result<Vec<Value>> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
|
||||
// 1. Static system message — the cacheable prefix. With prompt caching the
|
||||
// content becomes a one-part array carrying the cache breakpoint.
|
||||
if !input.system.base.is_empty() {
|
||||
out.push(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 })
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extra static layers (per-interface rules, session-scoped blocks).
|
||||
for s in &input.system.extra_static {
|
||||
out.push(json!({ "role": "system", "content": s }));
|
||||
}
|
||||
|
||||
// 3. Compaction summary, then the history it did not cover.
|
||||
let summary = store.latest_summary(input.frame).await?;
|
||||
if let Some(s) = &summary {
|
||||
let mut content = format!("{}\n\n{}", cfg.summary_prefix, s.text);
|
||||
if let Some(suffix) = &cfg.summary_suffix {
|
||||
content.push_str("\n\n");
|
||||
content.push_str(suffix);
|
||||
}
|
||||
out.push(json!({ "role": "system", "content": content }));
|
||||
}
|
||||
let mut history = match &summary {
|
||||
Some(s) => store.load_since(input.frame, s.covered_up_to).await?,
|
||||
None => store.load(input.frame).await?,
|
||||
};
|
||||
if let Some(max) = cfg.max_messages {
|
||||
window(&mut history, max);
|
||||
}
|
||||
|
||||
// 4. The conversation.
|
||||
let ctx = HistoryCtx::new(&history, cfg, hooks, input).await?;
|
||||
for (idx, entry) in history.iter().enumerate() {
|
||||
ctx.project_message(&mut out, idx, entry).await;
|
||||
}
|
||||
|
||||
// 5. Dynamic tail — the fresh layers, as ONE trailing system message so a
|
||||
// model reads them as a single "current state" block.
|
||||
if !input.system.dynamic_tail.is_empty() {
|
||||
let tail = input.system.dynamic_tail.join(&cfg.tail_separator);
|
||||
if !tail.is_empty() {
|
||||
out.push(json!({ "role": "system", "content": tail }));
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Tail reminder.
|
||||
if let Some(r) = &input.system.tail_reminder {
|
||||
out.push(json!({ "role": "system", "content": r }));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Cut the history to at most `max` messages. A leading assistant message is
|
||||
/// dropped as well: a window must not open on half an exchange.
|
||||
fn window(history: &mut Vec<StoredMessage>, max: usize) {
|
||||
if history.len() <= max {
|
||||
return;
|
||||
}
|
||||
history.drain(..history.len() - max);
|
||||
if matches!(history.first().map(|m| m.role), Some(Role::Assistant)) {
|
||||
history.drain(..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-build state shared by every message projection.
|
||||
struct HistoryCtx<'a> {
|
||||
cfg: &'a Projection,
|
||||
hooks: &'a ProjectionHooks,
|
||||
model: &'a crate::model::ModelInfo,
|
||||
/// Activated tool defs by anchor message (empty in `Inline` mode).
|
||||
activations: HashMap<MessageId, Vec<Value>>,
|
||||
/// Index of the last `User`/`Agent` message: everything before it belongs
|
||||
/// to a previous turn.
|
||||
boundary: Option<usize>,
|
||||
/// First index of the current turn's group — media is inlined only from
|
||||
/// here on, so images are not re-sent (and re-billed) every round.
|
||||
media_turn_start: usize,
|
||||
}
|
||||
|
||||
impl<'a> HistoryCtx<'a> {
|
||||
async fn new(
|
||||
history: &[StoredMessage],
|
||||
cfg: &'a Projection,
|
||||
hooks: &'a ProjectionHooks,
|
||||
input: &'a AssembleInput,
|
||||
) -> crate::Result<Self> {
|
||||
let activations = match (&hooks.activation, input.model.tool_rendering) {
|
||||
// Inline mode renders activated tools in the `tools` array itself:
|
||||
// nothing to inject, so the source is not even consulted.
|
||||
(_, ToolRendering::Inline) | (None, _) => HashMap::new(),
|
||||
(Some(src), _) => src
|
||||
.activations(input.frame)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::<MessageId, Vec<Value>>::new(), |mut acc, a: Activation| {
|
||||
acc.entry(a.anchor).or_default().extend(a.defs);
|
||||
acc
|
||||
}),
|
||||
};
|
||||
|
||||
let boundary = history
|
||||
.iter()
|
||||
.rposition(|e| matches!(e.role, Role::User | Role::Agent));
|
||||
|
||||
// Trailing assistant rows are the in-flight turn's own rounds; the
|
||||
// current turn's user messages sit just before them.
|
||||
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;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
cfg,
|
||||
hooks,
|
||||
model: &input.model,
|
||||
activations,
|
||||
boundary,
|
||||
media_turn_start,
|
||||
})
|
||||
}
|
||||
|
||||
async fn project_message(&self, out: &mut Vec<Value>, idx: usize, entry: &StoredMessage) {
|
||||
match entry.role {
|
||||
// System messages are BUILT (layers 1-2), never replayed from the
|
||||
// store; a host that stores them gets them back verbatim.
|
||||
Role::System => out.push(json!({ "role": "system", "content": entry.content })),
|
||||
Role::User | Role::Agent => self.push_user(out, idx, entry).await,
|
||||
Role::Assistant => self.push_assistant(out, idx, entry).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// A user/agent message: text plus, for the current turn, inlined media.
|
||||
async fn push_user(&self, out: &mut Vec<Value>, idx: usize, entry: &StoredMessage) {
|
||||
let mut text = entry.content.clone();
|
||||
let mut parts: Vec<Value> = Vec::new();
|
||||
|
||||
if let Some(src) = &self.hooks.media {
|
||||
let blobs = src.message_media(entry).await;
|
||||
if !blobs.is_empty() {
|
||||
// Older turns keep the textual path: everything is "skipped".
|
||||
let (inlined, skipped) = if idx >= self.media_turn_start {
|
||||
media::partition(&blobs, &self.model.capabilities, &self.cfg.media).await
|
||||
} else {
|
||||
(Vec::new(), (0..blobs.len()).collect())
|
||||
};
|
||||
if let Some(extra) = src.skipped_text(entry, &skipped) {
|
||||
text.push_str(&extra);
|
||||
}
|
||||
parts = inlined;
|
||||
}
|
||||
}
|
||||
|
||||
push_user_chunk(out, text, parts);
|
||||
}
|
||||
|
||||
/// An assistant message: the turn itself, then a result for every call, then
|
||||
/// the append-only DTL injections.
|
||||
async fn push_assistant(&self, out: &mut Vec<Value>, idx: usize, entry: &StoredMessage) {
|
||||
let stored_reasoning = entry.reasoning.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
if entry.calls.is_empty() {
|
||||
let mut msg = json!({ "role": "assistant", "content": entry.content });
|
||||
if let Some(r) = stored_reasoning {
|
||||
self.set_reasoning(&mut msg, r);
|
||||
}
|
||||
out.push(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
let calls: Vec<Value> = entry
|
||||
.calls
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.provider_id,
|
||||
"type": "function",
|
||||
"function": { "name": c.name, "arguments": wire_arguments(c) },
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut msg = json!({
|
||||
"role": "assistant",
|
||||
"content": entry.content,
|
||||
"tool_calls": calls,
|
||||
});
|
||||
// A tool-calling turn may need a non-empty reasoning on replay even when
|
||||
// none was recorded.
|
||||
if let Some(r) = stored_reasoning.or(self.cfg.reasoning_placeholder.as_deref()) {
|
||||
self.set_reasoning(&mut msg, r);
|
||||
}
|
||||
out.push(msg);
|
||||
|
||||
// One result per call, in call order — the model matches them by id.
|
||||
let is_previous_turn = self.boundary.is_some_and(|b| idx < b);
|
||||
let anchored = self.activations.get(&entry.id);
|
||||
let mut marked = false;
|
||||
|
||||
for call in &entry.calls {
|
||||
let mut tool_msg = json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": call.provider_id,
|
||||
"content": self.result_content(call, is_previous_turn).await,
|
||||
});
|
||||
// Anthropic DTL: the activation's result carries the marker its
|
||||
// client turns into `tool_reference` blocks.
|
||||
if self.model.tool_rendering == ToolRendering::DeferredToolReference
|
||||
&& !marked
|
||||
&& let Some(defs) = anchored
|
||||
&& self.is_anchor(call)
|
||||
{
|
||||
let names: Vec<Value> = defs
|
||||
.iter()
|
||||
.filter_map(|d| d["function"]["name"].as_str())
|
||||
.map(|n| json!(n))
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
tool_msg["_tool_references"] = Value::Array(names);
|
||||
marked = true;
|
||||
}
|
||||
}
|
||||
out.push(tool_msg);
|
||||
}
|
||||
|
||||
// Media a tool produced, as a synthetic user message right after the
|
||||
// result group (the current turn only).
|
||||
if idx >= self.media_turn_start
|
||||
&& let Some(src) = &self.hooks.media
|
||||
{
|
||||
let blobs = src.call_media(&entry.calls).await;
|
||||
if !blobs.is_empty() {
|
||||
let (parts, _) =
|
||||
media::partition(&blobs, &self.model.capabilities, &self.cfg.media).await;
|
||||
if !parts.is_empty() {
|
||||
out.push(json!({ "role": "user", "content": parts }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kimi-style DTL: the activated defs as a `system` message carrying a
|
||||
// `tools` field, appended after the group — the prefix stays identical.
|
||||
if self.model.tool_rendering == ToolRendering::SystemToolBlock
|
||||
&& let Some(defs) = anchored
|
||||
&& !defs.is_empty()
|
||||
{
|
||||
out.push(json!({ "role": "system", "tools": defs }));
|
||||
}
|
||||
}
|
||||
|
||||
fn set_reasoning(&self, msg: &mut Value, reasoning: &str) {
|
||||
msg["reasoning_content"] = json!(reasoning);
|
||||
if self.cfg.reasoning_echo == ReasoningEcho::Both {
|
||||
msg["reasoning"] = json!(reasoning);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this call is the DTL anchor within its message.
|
||||
fn is_anchor(&self, call: &StoredCall) -> bool {
|
||||
match &self.cfg.activation_anchor_tool {
|
||||
Some(name) => &call.name == name,
|
||||
None => true, // the first result of the message
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool result text: the well-formedness rule of contract point 2, then
|
||||
/// the size gate.
|
||||
async fn result_content(&self, call: &StoredCall, is_previous_turn: bool) -> String {
|
||||
let content = match call.state {
|
||||
CallState::Done => call.result.clone().unwrap_or_default(),
|
||||
CallState::Failed => {
|
||||
format!("Error: {}", call.result.as_deref().unwrap_or("unknown error"))
|
||||
}
|
||||
// A recorded reason wins; an absent or empty one falls back to the
|
||||
// configured note — a model must never read an empty tool result
|
||||
// and have to guess what happened.
|
||||
CallState::Rejected => non_empty(&call.result)
|
||||
.unwrap_or_else(|| self.cfg.rejected_default.clone()),
|
||||
CallState::Cancelled => non_empty(&call.result)
|
||||
.unwrap_or_else(|| self.cfg.cancelled_default.clone()),
|
||||
// Running / AwaitingHuman reaching the projection means the process
|
||||
// died mid-flight: the call really was interrupted.
|
||||
CallState::Running | CallState::AwaitingHuman => self.cfg.interrupted_text.clone(),
|
||||
};
|
||||
|
||||
let Some(limit) = self.cfg.max_tool_result else {
|
||||
return content;
|
||||
};
|
||||
if limit.previous_turns_only && !is_previous_turn {
|
||||
return content;
|
||||
}
|
||||
if content.len() <= limit.max_chars {
|
||||
return content;
|
||||
}
|
||||
if let Some(d) = &self.hooks.digest
|
||||
&& let Some(short) = d.condense(&call.name, &call.arguments, &content).await
|
||||
{
|
||||
return short;
|
||||
}
|
||||
format!(
|
||||
"{}… [truncated]",
|
||||
content.chars().take(limit.max_chars).collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(s: &Option<String>) -> Option<String> {
|
||||
s.clone().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// The arguments string sent back on the wire. The **raw recorded string** wins:
|
||||
/// re-serializing a parsed `Value` reorders object keys (serde_json's map is
|
||||
/// ordered), which would change the bytes the model produced and break the
|
||||
/// prompt-cache prefix.
|
||||
fn wire_arguments(call: &StoredCall) -> String {
|
||||
match &call.arguments_raw {
|
||||
Some(raw) => raw.clone(),
|
||||
None => serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one user/agent chunk, coalescing with a preceding `user` message —
|
||||
/// consecutive user rows are one wire message, so strict-alternation APIs stay
|
||||
/// happy. Media parts keep their position relative to the text.
|
||||
pub 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coalesces_consecutive_user_messages() {
|
||||
let mut out = vec![];
|
||||
push_user_chunk(&mut out, "one".into(), vec![]);
|
||||
push_user_chunk(&mut out, "two".into(), vec![]);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0]["content"], "one\n\ntwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_promotes_the_chunk_to_a_parts_array() {
|
||||
let mut out = vec![];
|
||||
let part = json!({ "type": "image_url", "image_url": { "url": "data:x" } });
|
||||
push_user_chunk(&mut out, "look".into(), vec![part.clone()]);
|
||||
assert_eq!(out[0]["content"][0]["type"], "text");
|
||||
assert_eq!(out[0]["content"][1], part);
|
||||
|
||||
// A following text chunk folds into the LAST text part, keeping the
|
||||
// media after it.
|
||||
push_user_chunk(&mut out, "more".into(), vec![]);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0]["content"][0]["text"], "look\n\nmore");
|
||||
assert_eq!(out[0]["content"][1], part);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_user_tail_starts_a_new_chunk() {
|
||||
let mut out = vec![json!({ "role": "assistant", "content": "hi" })];
|
||||
push_user_chunk(&mut out, "next".into(), vec![]);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_arguments_win_over_the_parsed_value() {
|
||||
let mut call = StoredCall {
|
||||
id: crate::ids::ToolCallId(1),
|
||||
message_id: MessageId(1),
|
||||
provider_id: "c1".into(),
|
||||
name: "write_file".into(),
|
||||
arguments: json!({ "a": 1, "z": 2 }),
|
||||
arguments_raw: Some(r#"{"z":2,"a":1}"#.to_string()),
|
||||
state: CallState::Done,
|
||||
result: None,
|
||||
result_kind: "text".into(),
|
||||
extras: Value::Null,
|
||||
};
|
||||
assert_eq!(wire_arguments(&call), r#"{"z":2,"a":1}"#);
|
||||
call.arguments_raw = None;
|
||||
assert_eq!(wire_arguments(&call), r#"{"a":1,"z":2}"#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
//! Restart recovery (blueprint §8) — turning a half-written conversation back
|
||||
//! into a well-formed one, then running a **normal loop** on it.
|
||||
//!
|
||||
//! There is no "recovery mode" in the kernel. Every state transition is written
|
||||
//! the instant it happens (see [`crate::store`]), so a crash loses RAM — the
|
||||
//! approval oneshot, the cancellation token — never the truth. What it leaves
|
||||
//! behind is a store that a model would choke on: calls with no result, a child
|
||||
//! frame whose answer nobody propagated, a half-run parallel batch. This module
|
||||
//! repairs exactly those, then hands the frame to the same `LlmLoop` a live turn
|
||||
//! uses.
|
||||
//!
|
||||
//! The order matters and mirrors `resume.rs`, the path this replaces:
|
||||
//!
|
||||
//! 1. **Reap** an interrupted parallel batch (≥2 active frames at one depth).
|
||||
//! 2. **Resolve** the deepest active frame's non-terminal calls, by policy and
|
||||
//! by each tool's [`RestartHint`].
|
||||
//! 3. **Un-wedge**: a child that finished but never told its parent.
|
||||
//! 4. **Cascade**: run the frame, resolve its parent's call with the result,
|
||||
//! close it, walk up — every frame with **its own** agent's config (B3), read
|
||||
//! from the catalog, never the root's.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::delegate::{AgentCatalog, FilteredToolSet};
|
||||
use crate::events::{EventSink, LoopEvent, PendingToolCall};
|
||||
use crate::ids::{ConversationId, FrameId};
|
||||
use crate::kernel::{PreExecution, TurnOutcome};
|
||||
use crate::manager::{LoopManager, LoopParams, TurnMeta, TurnParams};
|
||||
use crate::store::{CallOutcome, CallState, FrameRecord, Role, StoredCall};
|
||||
use crate::tool::{ExecutionOutcome, RestartHint, ToolCtx, ToolSet, drive_execution};
|
||||
|
||||
// ── Policy ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// What to do with a call that was `Running` when the process died.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum RunningPolicy {
|
||||
/// Re-gate and re-execute, unless the tool's own [`RestartHint`] says
|
||||
/// otherwise (which always wins: only the tool knows if it is idempotent).
|
||||
#[default]
|
||||
ReExecute,
|
||||
/// Never re-run: resolve every interrupted call as failed.
|
||||
MarkInterrupted,
|
||||
}
|
||||
|
||||
/// What to do with a call that was waiting on a human.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PendingPolicy {
|
||||
/// Ask again — the approval card reappears (today's behavior).
|
||||
#[default]
|
||||
ReAsk,
|
||||
/// Leave it pending for an out-of-band decision
|
||||
/// ([`LoopManager::resolve_pending`]), and stop: the frame cannot run with
|
||||
/// an unanswered call in it.
|
||||
LeavePending,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecoveryPolicy {
|
||||
pub on_running: RunningPolicy,
|
||||
pub on_awaiting_human: PendingPolicy,
|
||||
/// Recorded on a call that is not re-run.
|
||||
pub interrupted_text: String,
|
||||
/// Recorded on the delegating call of a reaped parallel batch.
|
||||
pub batch_reaped_text: String,
|
||||
}
|
||||
|
||||
impl Default for RecoveryPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
on_running: RunningPolicy::default(),
|
||||
on_awaiting_human: PendingPolicy::default(),
|
||||
interrupted_text: "Tool call interrupted by a restart.".to_string(),
|
||||
batch_reaped_text: "Sub-agent interrupted by restart (parallel batch).".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a recovery pass did — logged by hosts, asserted by tests.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RecoveryReport {
|
||||
pub frames_resumed: usize,
|
||||
pub calls_reexecuted: usize,
|
||||
pub calls_failed: usize,
|
||||
pub batches_reaped: usize,
|
||||
/// A call was left `AwaitingHuman`: the conversation waits for a decision.
|
||||
pub left_pending: bool,
|
||||
}
|
||||
|
||||
// ── Recovery ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct Recovery {
|
||||
manager: Arc<LoopManager>,
|
||||
catalog: Arc<dyn AgentCatalog>,
|
||||
policy: RecoveryPolicy,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
pub fn new(
|
||||
manager: Arc<LoopManager>,
|
||||
catalog: Arc<dyn AgentCatalog>,
|
||||
policy: RecoveryPolicy,
|
||||
) -> Self {
|
||||
Self { manager, catalog, policy }
|
||||
}
|
||||
|
||||
/// Recover one conversation. `root` is what the **root** frame runs with —
|
||||
/// the host's own turn parameters, since no catalog describes the entry
|
||||
/// agent; `root.frame` must be that root frame, and `root.live_input` is
|
||||
/// ignored (a recovery is not a live turn).
|
||||
///
|
||||
/// Refuses while a loop is already live on the conversation: that loop is
|
||||
/// already the thing driving it.
|
||||
pub async fn run(
|
||||
&self,
|
||||
conv: &ConversationId,
|
||||
root: &TurnParams,
|
||||
) -> crate::Result<RecoveryReport> {
|
||||
let Some(claim) = self.manager.claim(conv, root.frame, &root.agent) else {
|
||||
info!(%conv, "recovery: a loop is already running — nothing to do");
|
||||
return Ok(RecoveryReport::default());
|
||||
};
|
||||
let token = claim.token();
|
||||
let events = self.manager.sink_for(conv.clone());
|
||||
let store = self.manager.store();
|
||||
let mut report = RecoveryReport::default();
|
||||
|
||||
// ── 1. reap an interrupted parallel batch ──
|
||||
self.reap_batches(conv, &mut report).await?;
|
||||
|
||||
// ── 2. the deepest active frame is where the conversation stopped ──
|
||||
let Some(mut frame) = store.deepest_active(conv).await? else {
|
||||
info!(%conv, "recovery: no active frame — nothing to resume");
|
||||
return Ok(report);
|
||||
};
|
||||
|
||||
let mut params = self.params_for(&frame, root, conv).await?;
|
||||
let pending = self
|
||||
.resolve_frame_calls(conv, &frame, ¶ms, &token, &events, &mut report)
|
||||
.await?;
|
||||
if report.left_pending {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
// ── 3. un-wedge: a finished child whose result never reached its parent ──
|
||||
let mut outcome = match self.completed_without_propagating(&frame, pending).await? {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
report.frames_resumed += 1;
|
||||
self.run_frame(¶ms, &token, conv, frame.id, frame.parent).await?
|
||||
}
|
||||
};
|
||||
|
||||
// ── 4. cascade to the root ──
|
||||
while let Some(parent_call) = frame.spec.parent_call {
|
||||
let result = child_result(&outcome, &frame.spec.agent);
|
||||
match &result {
|
||||
Ok(text) => store.resolve_call(parent_call, &CallOutcome::Completed(
|
||||
crate::tool::ToolOutput::Text(text.clone()),
|
||||
)).await?,
|
||||
Err(text) => store.resolve_call(parent_call, &CallOutcome::Failed(text.clone())).await?,
|
||||
}
|
||||
let (text, failed) = match result {
|
||||
Ok(t) => (t, false),
|
||||
Err(t) => (t, true),
|
||||
};
|
||||
self.catalog.on_child_closed(frame.id).await;
|
||||
store.close_frame(frame.id).await?;
|
||||
|
||||
let parent = match store.frame_of_call(parent_call).await? {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
warn!(%conv, call = %parent_call, "recovery: the call's frame is gone");
|
||||
break;
|
||||
}
|
||||
};
|
||||
events.emit(frame.id, Some(parent.id), LoopEvent::AgentFinished {
|
||||
frame: frame.id,
|
||||
agent: frame.spec.agent.clone(),
|
||||
result_preview: crate::delegate::preview_truncate(&text, 500),
|
||||
parent_agent: parent.spec.agent.clone(),
|
||||
});
|
||||
events.emit(parent.id, parent.parent, LoopEvent::ToolCallFinished {
|
||||
id: parent_call,
|
||||
outcome: if failed {
|
||||
CallOutcome::Failed(text)
|
||||
} else {
|
||||
CallOutcome::Completed(crate::tool::ToolOutput::Text(text))
|
||||
},
|
||||
});
|
||||
|
||||
frame = parent;
|
||||
params = self.params_for(&frame, root, conv).await?;
|
||||
self.resolve_frame_calls(conv, &frame, ¶ms, &token, &events, &mut report)
|
||||
.await?;
|
||||
if report.left_pending {
|
||||
return Ok(report);
|
||||
}
|
||||
report.frames_resumed += 1;
|
||||
outcome = self.run_frame(¶ms, &token, conv, frame.id, frame.parent).await?;
|
||||
}
|
||||
|
||||
drop(claim);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Make the store well-formed **without continuing the conversation**: reap
|
||||
/// an interrupted batch, resolve the deepest frame's dangling calls.
|
||||
///
|
||||
/// This is what a host runs before starting a *new* turn on a session that
|
||||
/// died mid-tool: the user has something else to say, so nothing should
|
||||
/// re-drive the old turn, but the model must not be shown a call with no
|
||||
/// result. Unlike [`Self::run`] it does not claim the conversation — the
|
||||
/// caller is already inside its own turn.
|
||||
pub async fn repair(
|
||||
&self,
|
||||
conv: &ConversationId,
|
||||
root: &TurnParams,
|
||||
) -> crate::Result<RecoveryReport> {
|
||||
let mut report = RecoveryReport::default();
|
||||
self.reap_batches(conv, &mut report).await?;
|
||||
if let Some(frame) = self.manager.store().deepest_active(conv).await? {
|
||||
let params = self.params_for(&frame, root, conv).await?;
|
||||
let token = CancellationToken::new();
|
||||
let events = self.manager.sink_for(conv.clone());
|
||||
self.resolve_frame_calls(conv, &frame, ¶ms, &token, &events, &mut report)
|
||||
.await?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Two or more active frames at one depth can only be a concurrent batch
|
||||
/// caught mid-flight (a linear stack has at most one per depth). Recovering
|
||||
/// it properly would mean re-driving several siblings; instead the batch is
|
||||
/// pruned — deliberately lossy — and the parent continues with the failures
|
||||
/// in view.
|
||||
async fn reap_batches(
|
||||
&self,
|
||||
conv: &ConversationId,
|
||||
report: &mut RecoveryReport,
|
||||
) -> crate::Result<()> {
|
||||
let store = self.manager.store();
|
||||
let active = store.active_frames(conv).await?;
|
||||
let Some(d_min) = shallowest_parallel_depth(&active) else {
|
||||
return Ok(());
|
||||
};
|
||||
warn!(%conv, depth = d_min, "recovery: reaping an interrupted parallel batch");
|
||||
for frame in active.iter().filter(|f| f.spec.depth >= d_min) {
|
||||
if let Some(parent_call) = frame.spec.parent_call {
|
||||
let _ = store
|
||||
.resolve_call(
|
||||
parent_call,
|
||||
&CallOutcome::Failed(self.policy.batch_reaped_text.clone()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let _ = store.close_frame(frame.id).await;
|
||||
}
|
||||
report.batches_reaped += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs one frame's loop to completion, through the manager (so the turn is
|
||||
/// an ordinary loop — same kernel, same events, same rules).
|
||||
async fn run_frame(
|
||||
&self,
|
||||
params: &LoopParams,
|
||||
token: &CancellationToken,
|
||||
conv: &ConversationId,
|
||||
frame: FrameId,
|
||||
parent: Option<FrameId>,
|
||||
) -> crate::Result<TurnOutcome> {
|
||||
let handle = self
|
||||
.manager
|
||||
.start_loop(clone_params(params, conv, frame, parent, Some(token.clone())))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("recovery: {e}"))?;
|
||||
handle.join().await
|
||||
}
|
||||
|
||||
/// Every non-terminal call of a frame, resolved per policy. Returns whether
|
||||
/// anything at all was pending (the un-wedge check needs to know).
|
||||
async fn resolve_frame_calls(
|
||||
&self,
|
||||
conv: &ConversationId,
|
||||
frame: &FrameRecord,
|
||||
params: &LoopParams,
|
||||
token: &CancellationToken,
|
||||
events: &EventSink,
|
||||
report: &mut RecoveryReport,
|
||||
) -> crate::Result<bool> {
|
||||
let store = self.manager.store();
|
||||
let calls = store
|
||||
.calls_in_state(frame.id, &[CallState::Running, CallState::AwaitingHuman])
|
||||
.await?;
|
||||
if calls.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// A call that spawned a frame is the cascade's business: its result is
|
||||
// the child's answer, not a re-execution. Structural, not by name — a
|
||||
// host may register the delegate under any number of aliases.
|
||||
let children = store.active_frames(conv).await?;
|
||||
let spawned = |call: &StoredCall| {
|
||||
children.iter().any(|f| f.spec.parent_call == Some(call.id))
|
||||
};
|
||||
|
||||
for call in &calls {
|
||||
if spawned(call) {
|
||||
info!(call = %call.id, "recovery: sub-agent call left to the cascade");
|
||||
continue;
|
||||
}
|
||||
|
||||
let hint = params
|
||||
.tools
|
||||
.find(&call.name)
|
||||
.map(|t| t.restart_hint())
|
||||
.unwrap_or_default();
|
||||
let re_execute = match call.state {
|
||||
CallState::AwaitingHuman => match self.policy.on_awaiting_human {
|
||||
PendingPolicy::ReAsk => true,
|
||||
PendingPolicy::LeavePending => {
|
||||
info!(call = %call.id, "recovery: leaving the call pending for a decision");
|
||||
report.left_pending = true;
|
||||
return Ok(true);
|
||||
}
|
||||
},
|
||||
// The tool's own hint wins: only it knows whether re-running is
|
||||
// safe (a shell command may already have had its effect).
|
||||
_ => {
|
||||
self.policy.on_running == RunningPolicy::ReExecute
|
||||
&& hint == RestartHint::ReExecute
|
||||
}
|
||||
};
|
||||
|
||||
if !re_execute {
|
||||
store
|
||||
.resolve_call(
|
||||
call.id,
|
||||
&CallOutcome::Failed(self.policy.interrupted_text.clone()),
|
||||
)
|
||||
.await?;
|
||||
events.emit(frame.id, frame.parent, LoopEvent::ToolCallFinished {
|
||||
id: call.id,
|
||||
outcome: CallOutcome::Failed(self.policy.interrupted_text.clone()),
|
||||
});
|
||||
report.calls_failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.re_execute(call, params, token, events, frame).await? {
|
||||
report.calls_reexecuted += 1;
|
||||
} else {
|
||||
// Suspended again (the human is still not there, or the channel
|
||||
// closed): the call stays AwaitingHuman for the next attempt.
|
||||
report.left_pending = true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Re-runs one call through the **normal** path — gate, hooks, tool — so a
|
||||
/// rule change since the crash applies and the approval card reappears.
|
||||
/// `Ok(false)` = it suspended again and must be left pending.
|
||||
async fn re_execute(
|
||||
&self,
|
||||
call: &StoredCall,
|
||||
params: &LoopParams,
|
||||
token: &CancellationToken,
|
||||
events: &EventSink,
|
||||
frame: &FrameRecord,
|
||||
) -> crate::Result<bool> {
|
||||
let ptc = PendingToolCall {
|
||||
id: call.id,
|
||||
message_id: call.message_id,
|
||||
provider_id: Some(call.provider_id.clone()).filter(|s| !s.is_empty()),
|
||||
name: call.name.clone(),
|
||||
arguments: call.arguments.clone(),
|
||||
};
|
||||
events.emit(frame.id, frame.parent, LoopEvent::ToolCallStarted {
|
||||
id: ptc.id,
|
||||
message_id: ptc.message_id,
|
||||
name: ptc.name.clone(),
|
||||
args: ptc.arguments.clone(),
|
||||
});
|
||||
|
||||
let deps = self.manager.deps();
|
||||
match crate::kernel::pre_execution(deps, params, events, token, &ptc).await? {
|
||||
PreExecution::Run(tool) => {
|
||||
let ctx = ToolCtx {
|
||||
conversation: params.conversation.clone(),
|
||||
frame: params.frame,
|
||||
agent: params.agent.clone(),
|
||||
call_id: ptc.id,
|
||||
cancel: token.clone(),
|
||||
extensions: crate::kernel::tool_extensions(params, events),
|
||||
};
|
||||
let exec = tool.start(ptc.arguments.clone(), &ctx);
|
||||
match drive_execution(&*exec, token).await {
|
||||
ExecutionOutcome::Suspended => Ok(false),
|
||||
outcome => {
|
||||
crate::kernel::record_outcome(
|
||||
deps,
|
||||
params,
|
||||
events,
|
||||
&self.manager.store(),
|
||||
&ptc,
|
||||
outcome.into_call_outcome(),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
PreExecution::Resolved(outcome) => {
|
||||
crate::kernel::record_outcome(
|
||||
deps, params, events, &self.manager.store(), &ptc, outcome,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
PreExecution::Suspended => Ok(false),
|
||||
PreExecution::TurnCancelled => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The wedge case: nothing was pending and the frame's last message is a
|
||||
/// plain assistant reply — its turn finished, and the process died before
|
||||
/// the result reached the parent. Re-running the model would ask it to
|
||||
/// answer a question it already answered, so the stored answer is used as
|
||||
/// the outcome and only the propagation is redone.
|
||||
///
|
||||
/// On the ROOT frame the same shape means the turn is simply complete.
|
||||
async fn completed_without_propagating(
|
||||
&self,
|
||||
frame: &FrameRecord,
|
||||
had_pending: bool,
|
||||
) -> crate::Result<Option<TurnOutcome>> {
|
||||
if had_pending {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(last) = self.manager.store().last(frame.id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if last.role != Role::Assistant || !last.calls.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(TurnOutcome::Final {
|
||||
content: last.content,
|
||||
message_id: last.id,
|
||||
usage: last.usage,
|
||||
reasoning: last.reasoning,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The parameters one frame runs with: the host's for the root, the
|
||||
/// catalog's for every other (B3 — a resumed sub-agent is ITS agent, with
|
||||
/// its prompt, its tools and its model).
|
||||
async fn params_for(
|
||||
&self,
|
||||
frame: &FrameRecord,
|
||||
root: &TurnParams,
|
||||
conv: &ConversationId,
|
||||
) -> crate::Result<LoopParams> {
|
||||
let mut params = clone_params_from_turn(root, conv, frame.id, frame.parent);
|
||||
if frame.spec.parent_call.is_none() {
|
||||
return Ok(params);
|
||||
}
|
||||
|
||||
let ctx = ToolCtx {
|
||||
conversation: conv.clone(),
|
||||
frame: frame.id,
|
||||
agent: frame.spec.agent.clone(),
|
||||
// The call that spawned this frame — the same handle the live
|
||||
// dispatch had.
|
||||
call_id: frame.spec.parent_call.unwrap(),
|
||||
cancel: CancellationToken::new(),
|
||||
extensions: root.extensions.clone(),
|
||||
};
|
||||
let profile = self.catalog.get(&frame.spec.agent, frame.id, &ctx).await?;
|
||||
|
||||
params.agent = frame.spec.agent.clone();
|
||||
params.system = profile.context;
|
||||
params.tools = match profile.toolset {
|
||||
Some(ts) => ts,
|
||||
None => Arc::new(FilteredToolSet::derive(root.tools.clone(), &profile.tools))
|
||||
as Arc<dyn ToolSet>,
|
||||
};
|
||||
params.model_hint = profile.model.unwrap_or_default();
|
||||
params.selector = profile.selector;
|
||||
params.assembler = profile.assembler;
|
||||
params.meta = TurnMeta { user_message: frame.spec.prompt.clone(), ..root.meta.clone() };
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolve_pending (blueprint §8.5) ─────────────────────────────────────────
|
||||
|
||||
/// A human's answer to a call that was waiting for one.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HumanDecision {
|
||||
Approved,
|
||||
Rejected { reason: String },
|
||||
}
|
||||
|
||||
/// Apply a human decision to a call nothing is driving anymore — the approval
|
||||
/// card answered after a restart, or from the Inbox.
|
||||
///
|
||||
/// Approval **skips the gate**: the human is the gate, and re-running the rules
|
||||
/// would ask them again. The call is executed through the normal tool path
|
||||
/// (with the frame's own context, so a write lands in the caller's workspace,
|
||||
/// never the server's cwd), then the conversation is recovered so the model
|
||||
/// reads the result.
|
||||
pub(crate) async fn resolve_pending(
|
||||
manager: &Arc<LoopManager>,
|
||||
call_id: crate::ids::ToolCallId,
|
||||
decision: HumanDecision,
|
||||
catalog: Arc<dyn AgentCatalog>,
|
||||
root: &TurnParams,
|
||||
) -> crate::Result<RecoveryReport> {
|
||||
let store = manager.store();
|
||||
let call = store
|
||||
.get_call(call_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("resolve_pending: call {call_id} not found"))?;
|
||||
if call.state.is_terminal() {
|
||||
info!(call = %call_id, state = ?call.state, "resolve_pending: already resolved");
|
||||
return Ok(RecoveryReport::default());
|
||||
}
|
||||
let frame = store
|
||||
.frame_of_call(call_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("resolve_pending: no frame for call {call_id}"))?;
|
||||
let conv = frame.conversation.clone();
|
||||
|
||||
match decision {
|
||||
HumanDecision::Rejected { reason } => {
|
||||
store.resolve_call(call_id, &CallOutcome::Rejected { reason: reason.clone() }).await?;
|
||||
manager.sink_for(conv.clone()).emit(frame.id, frame.parent, LoopEvent::ToolCallFinished {
|
||||
id: call_id,
|
||||
outcome: CallOutcome::Rejected { reason },
|
||||
});
|
||||
}
|
||||
HumanDecision::Approved => {
|
||||
// Claimed for the execution only: the recovery below takes its own.
|
||||
let outcome = {
|
||||
let Some(claim) = manager.claim(&conv, frame.id, &frame.spec.agent) else {
|
||||
anyhow::bail!("resolve_pending: a loop is already running on {conv}");
|
||||
};
|
||||
let token = claim.token();
|
||||
let events = manager.sink_for(conv.clone());
|
||||
let params = clone_params_from_turn(root, &conv, frame.id, frame.parent);
|
||||
let ext = crate::kernel::tool_extensions(¶ms, &events);
|
||||
|
||||
match params.tools.find(&call.name) {
|
||||
Some(tool) => {
|
||||
let ctx = ToolCtx {
|
||||
conversation: conv.clone(),
|
||||
frame: frame.id,
|
||||
agent: frame.spec.agent.clone(),
|
||||
call_id,
|
||||
cancel: token.clone(),
|
||||
extensions: ext,
|
||||
};
|
||||
let exec = tool.start(call.arguments.clone(), &ctx);
|
||||
match drive_execution(&*exec, &token).await {
|
||||
// Suspending again would need another human: leave
|
||||
// it pending rather than resolving it as cancelled.
|
||||
ExecutionOutcome::Suspended => None,
|
||||
outcome => Some(outcome.into_call_outcome()),
|
||||
}
|
||||
}
|
||||
None => Some(CallOutcome::Failed(format!(
|
||||
"unknown tool '{}' (not in this turn's tool set)",
|
||||
call.name
|
||||
))),
|
||||
}
|
||||
};
|
||||
|
||||
let Some(outcome) = outcome else {
|
||||
return Ok(RecoveryReport { left_pending: true, ..RecoveryReport::default() });
|
||||
};
|
||||
store.resolve_call(call_id, &outcome).await?;
|
||||
manager.sink_for(conv.clone()).emit(frame.id, frame.parent, LoopEvent::ToolCallFinished {
|
||||
id: call_id,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The history is well-formed again: a normal recovery continues the turn.
|
||||
Recovery::new(manager.clone(), catalog, RecoveryPolicy::default())
|
||||
.run(&conv, root)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The text a finished child propagates to its parent's call — `Err` when the
|
||||
/// child did not produce an answer.
|
||||
fn child_result(outcome: &TurnOutcome, agent: &str) -> Result<String, String> {
|
||||
match outcome {
|
||||
TurnOutcome::Final { content, .. } => Ok(content.clone()),
|
||||
TurnOutcome::Cancelled => Err(format!("Sub-agent `{agent}` was cancelled.")),
|
||||
TurnOutcome::Exhausted => Err(format!("Sub-agent `{agent}` exhausted tool-call rounds.")),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_params_from_turn(
|
||||
root: &TurnParams,
|
||||
conv: &ConversationId,
|
||||
frame: FrameId,
|
||||
parent: Option<FrameId>,
|
||||
) -> LoopParams {
|
||||
LoopParams {
|
||||
conversation: conv.clone(),
|
||||
frame,
|
||||
parent_frame: parent,
|
||||
agent: root.agent.clone(),
|
||||
system: root.system.clone(),
|
||||
tools: root.tools.clone(),
|
||||
model_hint: root.model_hint.clone(),
|
||||
selector: root.selector.clone(),
|
||||
token: None,
|
||||
// A recovery is not a live turn: no live input, and no tail reminder
|
||||
// semantics — the host decides that when it builds `root`.
|
||||
live_input: None,
|
||||
extensions: root.extensions.clone(),
|
||||
meta: root.meta.clone(),
|
||||
assembler: root.assembler.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_params(
|
||||
p: &LoopParams,
|
||||
conv: &ConversationId,
|
||||
frame: FrameId,
|
||||
parent: Option<FrameId>,
|
||||
token: Option<CancellationToken>,
|
||||
) -> LoopParams {
|
||||
LoopParams {
|
||||
conversation: conv.clone(),
|
||||
frame,
|
||||
parent_frame: parent,
|
||||
agent: p.agent.clone(),
|
||||
system: p.system.clone(),
|
||||
tools: p.tools.clone(),
|
||||
model_hint: p.model_hint.clone(),
|
||||
selector: p.selector.clone(),
|
||||
token,
|
||||
live_input: None,
|
||||
extensions: p.extensions.clone(),
|
||||
meta: p.meta.clone(),
|
||||
assembler: p.assembler.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shallowest depth holding more than one active frame — the top of an
|
||||
/// interrupted parallel batch. `None` for a linear stack, where every depth has
|
||||
/// at most one active frame. Pure (see tests).
|
||||
pub fn shallowest_parallel_depth(active: &[FrameRecord]) -> Option<u32> {
|
||||
let mut by_depth: HashMap<u32, usize> = HashMap::new();
|
||||
for f in active {
|
||||
*by_depth.entry(f.spec.depth).or_default() += 1;
|
||||
}
|
||||
by_depth
|
||||
.iter()
|
||||
.filter_map(|(depth, count)| (*count > 1).then_some(*depth))
|
||||
.min()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ids::ToolCallId;
|
||||
use crate::store::FrameSpec;
|
||||
|
||||
fn frame(id: i64, depth: u32, parent_call: Option<i64>) -> FrameRecord {
|
||||
FrameRecord {
|
||||
id: FrameId(id),
|
||||
conversation: ConversationId::new("c"),
|
||||
parent: None,
|
||||
spec: FrameSpec {
|
||||
agent: "agent".into(),
|
||||
prompt: None,
|
||||
depth,
|
||||
parent_call: parent_call.map(ToolCallId),
|
||||
meta: serde_json::Value::Null,
|
||||
},
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,11 @@ pub struct StoredCall {
|
||||
pub provider_id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
/// The arguments **exactly as the model emitted them**, when the store kept
|
||||
/// the string. The projection replays this verbatim: re-serializing
|
||||
/// [`Self::arguments`] reorders object keys, which changes the bytes the
|
||||
/// model produced and breaks the prompt-cache prefix.
|
||||
pub arguments_raw: Option<String>,
|
||||
pub state: CallState,
|
||||
pub result: Option<String>,
|
||||
pub result_kind: String,
|
||||
@@ -280,6 +285,10 @@ pub trait HistoryStore: Send + Sync {
|
||||
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> crate::Result<()>;
|
||||
/// One call by id (translators enriching finish events, recovery).
|
||||
async fn get_call(&self, id: ToolCallId) -> crate::Result<Option<StoredCall>>;
|
||||
/// The frame a call belongs to. Recovery walks the cascade with it, and an
|
||||
/// out-of-band resolution (an approval answered from a REST endpoint) has
|
||||
/// nothing but a call id to start from.
|
||||
async fn frame_of_call(&self, id: ToolCallId) -> crate::Result<Option<FrameRecord>>;
|
||||
/// Merge host free-form extras into a call (Skald: diff preview, media).
|
||||
/// Keys not understood by the store are ignored.
|
||||
async fn set_call_extras(&self, id: ToolCallId, extras: Value) -> crate::Result<()>;
|
||||
|
||||
@@ -154,6 +154,8 @@ impl HistoryStore for InMemoryStore {
|
||||
provider_id,
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
// Nothing to replay verbatim: this store never saw a wire string.
|
||||
arguments_raw: None,
|
||||
state: CallState::Running,
|
||||
result: None,
|
||||
result_kind: String::new(),
|
||||
@@ -195,6 +197,25 @@ impl HistoryStore for InMemoryStore {
|
||||
Ok(i.calls.values().flatten().find(|c| c.id == id).cloned())
|
||||
}
|
||||
|
||||
async fn frame_of_call(&self, id: ToolCallId) -> crate::Result<Option<FrameRecord>> {
|
||||
let i = self.inner.lock().unwrap();
|
||||
let Some(msg_id) = i
|
||||
.calls
|
||||
.values()
|
||||
.flatten()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.message_id)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let frame = i
|
||||
.messages
|
||||
.iter()
|
||||
.find(|(_, msgs)| msgs.iter().any(|m| m.id == msg_id))
|
||||
.map(|(frame, _)| *frame);
|
||||
Ok(frame.and_then(|f| i.frames.get(&f).cloned()))
|
||||
}
|
||||
|
||||
async fn set_call_extras(&self, id: ToolCallId, extras: serde_json::Value) -> crate::Result<()> {
|
||||
let mut i = self.inner.lock().unwrap();
|
||||
update_call(&mut i, id, |c| {
|
||||
|
||||
Reference in New Issue
Block a user