Skip to main content

llm_tool/types/truncation/
sink.rs

1//! Pluggable observation spill sink and contextual handle types.
2
3use alloc::string::String;
4
5/// Typed identifier/handle for full tool output spilled to an external observation store.
6#[derive(
7    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
8)]
9#[serde(transparent)]
10pub struct SpillRef(String);
11
12impl SpillRef {
13    /// Create a new typed spill reference from a string or identifier.
14    #[must_use]
15    pub fn new(id: impl Into<String>) -> Self {
16        Self(id.into())
17    }
18
19    /// Return the string slice of the spill reference.
20    #[must_use]
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl core::fmt::Display for SpillRef {
27    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
28        f.write_str(&self.0)
29    }
30}
31
32impl core::ops::Deref for SpillRef {
33    type Target = str;
34
35    fn deref(&self) -> &str {
36        &self.0
37    }
38}
39
40impl AsRef<str> for SpillRef {
41    fn as_ref(&self) -> &str {
42        &self.0
43    }
44}
45
46impl From<String> for SpillRef {
47    fn from(s: String) -> Self {
48        Self(s)
49    }
50}
51
52impl From<&str> for SpillRef {
53    fn from(s: &str) -> Self {
54        Self(s.into())
55    }
56}
57
58/// Contextual metadata provided to an [`OutputSpillSink`] during a spill operation.
59///
60/// Keeps `llm-tool` transport-agnostic and free from domain concepts like
61/// conversations, provenance, or file systems.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SpillContext<'a> {
64    /// Name of the tool whose output is being truncated.
65    pub tool_name: &'a str,
66    /// Optional caller-supplied opaque tag or provenance token.
67    pub opaque_tag: Option<&'a str>,
68}
69
70impl<'a> SpillContext<'a> {
71    /// Anonymous context with default tool name.
72    pub const ANONYMOUS: Self = Self {
73        tool_name: "anonymous",
74        opaque_tag: None,
75    };
76
77    /// Create a new spill context for a specific tool.
78    #[must_use]
79    pub const fn new(tool_name: &'a str) -> Self {
80        Self {
81            tool_name,
82            opaque_tag: None,
83        }
84    }
85
86    /// Attach an opaque provenance or session tag.
87    #[must_use]
88    pub const fn with_tag(mut self, opaque_tag: &'a str) -> Self {
89        self.opaque_tag = Some(opaque_tag);
90        self
91    }
92}
93
94/// Strongly typed error returned when persisting tool output to an [`OutputSpillSink`].
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum SpillError {
97    /// The storage backend or buffer capacity was exceeded.
98    CapacityExceeded {
99        /// Configured limit in bytes.
100        limit: usize,
101        /// Attempted spill size in bytes.
102        attempted: usize,
103    },
104    /// Storage I/O or persistence failure.
105    StorageFailed(String),
106    /// The spill operation timed out.
107    Timeout,
108    /// The spill operation was rejected by policy or authorization.
109    Rejected(String),
110    /// Other backend-specific error.
111    Other(String),
112}
113
114impl core::fmt::Display for SpillError {
115    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
116        match self {
117            Self::CapacityExceeded { limit, attempted } => {
118                write!(
119                    f,
120                    "spill capacity exceeded: attempted {attempted} bytes with limit of {limit} bytes"
121                )
122            }
123            Self::StorageFailed(msg) => write!(f, "spill storage failed: {msg}"),
124            Self::Timeout => write!(f, "spill operation timed out"),
125            Self::Rejected(msg) => write!(f, "spill rejected: {msg}"),
126            Self::Other(msg) => write!(f, "spill error: {msg}"),
127        }
128    }
129}
130
131impl core::error::Error for SpillError {}
132
133/// Resolved outcome of attempting to spill an oversized observation.
134///
135/// Truncation must never silently lose the fact that full-output retention failed.
136/// This enum makes the three possible states explicit so the marker renderer can
137/// distinguish "no sink configured" (nothing was promised) from "sink failed"
138/// (retention was promised and did not happen), and surface the latter in-band.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum SpillDisposition {
141    /// No [`OutputSpillSink`] was configured; omitted bytes were never retained.
142    NotConfigured,
143    /// The full output was retained and is recallable via the returned handle.
144    Spilled(SpillRef),
145    /// A sink was configured but failed; the omitted bytes are unrecoverable.
146    Failed(SpillError),
147}
148
149impl SpillDisposition {
150    /// Return the spill handle when retention succeeded.
151    #[must_use]
152    pub const fn spill_ref(&self) -> Option<&SpillRef> {
153        match self {
154            Self::Spilled(handle) => Some(handle),
155            Self::NotConfigured | Self::Failed(_) => None,
156        }
157    }
158
159    /// Return the spill error when a configured sink failed.
160    #[must_use]
161    pub const fn error(&self) -> Option<&SpillError> {
162        match self {
163            Self::Failed(error) => Some(error),
164            Self::NotConfigured | Self::Spilled(_) => None,
165        }
166    }
167}
168
169/// Pluggable sink for retaining full un-truncated tool output observations.
170///
171/// Implementations may store output in memory, write to an artifact store,
172/// or upload to a remote recall cache. When configured on a [`crate::TruncationPolicy`],
173/// output exceeding `max_bytes` is first saved via [`OutputSpillSink::spill`]
174/// before middle-out truncation occurs.
175pub trait OutputSpillSink: Send + Sync + core::fmt::Debug {
176    /// Spill the full tool return content, returning a recallable [`SpillRef`].
177    ///
178    /// # Errors
179    ///
180    /// Returns [`SpillError`] if storage fails, capacity is exceeded, or the
181    /// operation is rejected.
182    fn spill(&self, ctx: &SpillContext<'_>, full: &str) -> Result<SpillRef, SpillError>;
183}