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,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}"#);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user