1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
//! Provider trait and error types for LLM backends.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::mpsc;
use crate::message::{AgentEvent, Message};
pub type Receiver<T> = mpsc::Receiver<T>;
/// Non-secret, request-local progress reported by a language-model provider.
///
/// Retry ordinals are zero-based: `0` is the initial dispatch and positive values are the exact
/// ordinals returned by the provider's retry policy. Progress is transient and must not be
/// persisted as conversation history.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "stage", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProviderProgress {
/// The initial request is being dispatched.
InitialDispatch {
/// Zero-based retry ordinal. This is always `0` for the initial dispatch.
attempt: u32,
/// Configured maximum retry ordinal.
max_attempts: u32,
},
/// A retry dispatch is being attempted after its scheduled backoff.
RetryDispatch {
/// Zero-based retry ordinal returned by the provider retry decision.
attempt: u32,
/// Configured maximum retry ordinal.
max_attempts: u32,
},
/// A bounded retry backoff has been scheduled.
ScheduledBackoff {
/// Retry ordinal that will be dispatched after the backoff.
attempt: u32,
/// Configured maximum retry ordinal.
max_attempts: u32,
/// Actual bounded delay selected by the provider retry policy.
delay_ms: u64,
},
/// Response headers arrived and the provider is waiting for the first stream packet.
FirstPacketWait {
/// Zero-based retry ordinal whose response is being streamed.
attempt: u32,
/// Configured maximum retry ordinal.
max_attempts: u32,
},
}
#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
#[error("authentication failed: {0}")]
AuthenticationFailed(String),
#[error("rate limited: {0}")]
RateLimited(String),
#[error("server error: {0}")]
ServerError(String),
#[error("network error: {0}")]
NetworkError(String),
#[error("invalid response: {0}")]
InvalidResponse(String),
}
pub type ProviderResult<T> = Result<T, ProviderError>;
/// Provider-enforced limits for an isolated, tool-free decision request.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
pub struct DecisionRequestLimits {
/// Positive maximum generated tokens, including any provider reasoning.
pub max_output_tokens: u32,
/// Maximum additional transport dispatches; zero disables retries.
pub max_retries: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: Value,
}
impl ToolDefinition {
/// Creates a new tool definition.
#[must_use]
pub fn new(name: impl Into<String>, description: impl Into<String>, parameters: Value) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters,
}
}
/// Formats this tool definition as a text block suitable for inclusion
/// in the system prompt.
#[must_use]
pub fn to_prompt_text(&self) -> String {
format!(
"## {}\n{}\nParameters: {}",
self.name,
self.description,
serde_json::to_string_pretty(&self.parameters).unwrap_or_default()
)
}
}
#[async_trait::async_trait]
pub trait LanguageModel: Send + Sync {
/// Dispatches an isolated text decision with no tools or inherited reasoning settings.
///
/// Implementations must enforce the supplied token and retry limits. Unsupported
/// providers fail before dispatch; falling back to unrestricted `stream` is unsafe.
async fn stream_decision(
&self,
_messages: &[Message],
_limits: DecisionRequestLimits,
) -> ProviderResult<Receiver<AgentEvent>> {
Err(ProviderError::InvalidResponse(
"provider does not support bounded decisions".into(),
))
}
/// Dispatches an isolated Auto permission review with provider-supported low-latency reasoning.
///
/// The default retains bounded-decision isolation. Adapters may override reasoning
/// only for models with a known request contract, without mutating conversation settings.
async fn stream_auto_review(
&self,
messages: &[Message],
limits: DecisionRequestLimits,
) -> ProviderResult<Receiver<AgentEvent>> {
self.stream_decision(messages, limits).await
}
/// Returns a stable, non-secret scope for capability evidence caching.
/// `None` disables caching when the provider cannot describe its endpoint/model safely.
fn protocol_capability_scope(&self) -> Option<String> {
None
}
/// Reports non-secret evidence for automatic tool-protocol selection.
///
/// Implementations must return [`crate::tool::CapabilityProbe::Unknown`] unless
/// the evidence is tied to the configured endpoint and model. Unknown evidence
/// is handled conservatively by selecting the validated compatibility path.
fn protocol_capabilities(&self) -> crate::tool::CapabilityProbe {
crate::tool::CapabilityProbe::Unknown
}
/// Performs a request-scoped native capability probe.
///
/// Providers must return `Unknown` unless the response was validated as native
/// wire evidence for the configured endpoint and model. The conservative default
/// preserves compatibility for existing third-party implementations.
async fn probe_protocol_capabilities(
&self,
_tools: &[ToolDefinition],
) -> crate::tool::CapabilityProbe {
crate::tool::CapabilityProbe::Unknown
}
async fn stream(&self, messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>>;
async fn stream_with_tools(
&self,
messages: &[Message],
tools: &[ToolDefinition],
) -> ProviderResult<Receiver<AgentEvent>> {
let _ = tools;
self.stream(messages).await
}
/// Streams a response while optionally reporting typed request-local provider progress.
///
/// The default preserves source compatibility for third-party providers by delegating to
/// [`LanguageModel::stream_with_tools`] without emitting progress.
async fn stream_with_tools_and_progress(
&self,
messages: &[Message],
tools: &[ToolDefinition],
progress_tx: mpsc::UnboundedSender<ProviderProgress>,
) -> ProviderResult<Receiver<AgentEvent>> {
drop(progress_tx);
self.stream_with_tools(messages, tools).await
}
/// Streams a response with an explicit tool protocol selected by the caller.
///
/// Legacy providers retain their Native behavior. Compatibility modes require an
/// override implementing both request projection and validated response parsing;
/// the default rejects them before dispatch rather than silently sending native tools.
/// Auto must be resolved by the caller before dispatch.
async fn stream_with_protocol(
&self,
messages: &[Message],
tools: &[ToolDefinition],
protocol: crate::tool::ToolProtocol,
progress_tx: mpsc::UnboundedSender<ProviderProgress>,
) -> ProviderResult<Receiver<AgentEvent>> {
if protocol != crate::tool::ToolProtocol::Native {
return Err(ProviderError::InvalidResponse(
"provider has no adapter for the selected tool protocol".into(),
));
}
self.stream_with_tools_and_progress(messages, tools, progress_tx)
.await
}
fn request_preview(&self, _messages: &[Message]) -> Option<Value> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
struct LegacyModel;
struct CountingLegacyModel(std::sync::atomic::AtomicUsize);
#[async_trait::async_trait]
impl LanguageModel for CountingLegacyModel {
async fn stream(&self, _: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let (_, rx) = mpsc::channel(1);
Ok(rx)
}
}
#[tokio::test]
async fn legacy_protocol_adapter_rejects_unsupported_modes_before_dispatch() {
use crate::tool::ToolProtocol;
let model = CountingLegacyModel(std::sync::atomic::AtomicUsize::new(0));
for mode in [
ToolProtocol::Compat,
ToolProtocol::TalosStrict,
ToolProtocol::Auto,
] {
let (tx, mut rx) = mpsc::unbounded_channel();
assert!(
model
.stream_with_protocol(&[], &[], mode, tx)
.await
.is_err()
);
assert_eq!(rx.recv().await, None);
assert_eq!(model.0.load(std::sync::atomic::Ordering::SeqCst), 0);
}
let (tx, _) = mpsc::unbounded_channel();
assert!(
model
.stream_with_protocol(&[], &[], ToolProtocol::Native, tx)
.await
.is_ok()
);
assert_eq!(model.0.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[async_trait::async_trait]
impl LanguageModel for LegacyModel {
async fn stream(&self, _messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
let (_tx, rx) = mpsc::channel(1);
Ok(rx)
}
}
#[tokio::test]
async fn legacy_provider_uses_default_progress_aware_entrypoint() {
let (progress_tx, mut progress_rx) = mpsc::unbounded_channel();
let result = LegacyModel
.stream_with_tools_and_progress(&[], &[], progress_tx)
.await;
assert!(result.is_ok());
assert_eq!(progress_rx.recv().await, None);
}
#[test]
fn provider_progress_roundtrips_without_unbounded_diagnostics() {
let progress = ProviderProgress::ScheduledBackoff {
attempt: 2,
max_attempts: 3,
delay_ms: 750,
};
let encoded = serde_json::to_string(&progress).expect("serialize progress");
assert_eq!(
encoded,
r#"{"stage":"scheduled_backoff","attempt":2,"max_attempts":3,"delay_ms":750}"#
);
let decoded: ProviderProgress =
serde_json::from_str(&encoded).expect("deserialize progress");
assert_eq!(decoded, progress);
}
}