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}