Skip to main content

thndrs_agent/
contracts.rs

1//! Provider-neutral run contracts shared by application adapters.
2
3use std::borrow::Cow;
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7
8/// Status of an executed tool call.
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Serialize, Deserialize)]
10pub enum ToolStatus {
11    /// Tool started, not yet finished.
12    #[default]
13    Running,
14    /// Tool finished successfully.
15    Ok,
16    /// Tool failed.
17    Failed,
18    /// Tool was cancelled while running.
19    Cancelled,
20}
21
22impl ToolStatus {
23    /// Compact session/transcript label for a file-write result.
24    pub const fn icon(self) -> &'static str {
25        match self {
26            ToolStatus::Ok => "✓ wrote",
27            ToolStatus::Failed => "✕ write failed",
28            ToolStatus::Running => "⠋ writing",
29            ToolStatus::Cancelled => "✕ write cancelled",
30        }
31    }
32
33    /// Stable lowercase status label.
34    pub const fn label(self) -> &'static str {
35        match self {
36            ToolStatus::Running => "running",
37            ToolStatus::Ok => "ok",
38            ToolStatus::Failed => "failed",
39            ToolStatus::Cancelled => "cancelled",
40        }
41    }
42}
43
44/// The kind of bounded evidence associated with a tool execution.
45#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
46pub enum ToolEvidenceKind {
47    /// The normal result produced by a tool execution.
48    Output,
49}
50
51/// Provider-neutral message supplied to or produced by an agent turn.
52///
53/// Provider adapters lower this representation to their wire payloads inside
54/// the application. The shared contract never exposes those payloads.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum AgentMessage {
57    /// System or harness guidance.
58    System(String),
59    /// User input for the current or an earlier turn.
60    User(String),
61    /// Completed assistant text.
62    Assistant(String),
63    /// An assistant tool-use request.
64    ToolUse(ToolUseRequest),
65    /// A completed application-owned tool result.
66    ToolResult {
67        /// Provider or application id that correlates the result with a tool request.
68        id: String,
69        /// Application-owned result returned by the tool executor.
70        output: ToolOutput,
71    },
72}
73
74/// Decision returned by an application-owned tool permission hook.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum ToolPermissionDecision {
77    /// The tool call may execute.
78    Allow,
79    /// The tool call must be rejected before execution.
80    Reject,
81    /// The prompt turn was cancelled while waiting for permission.
82    Cancelled,
83}
84
85impl ToolPermissionDecision {
86    /// Stable outcome label for session records.
87    pub const fn outcome_label(self) -> &'static str {
88        match self {
89            Self::Allow => "allowed",
90            Self::Reject => "rejected",
91            Self::Cancelled => "cancelled",
92        }
93    }
94}
95
96/// Provider-neutral semantic output from an agent turn.
97///
98/// Application adapters may attach local-tool audit details, UI state, or
99/// transport state when projecting these events to their own surfaces.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum AgentEvent {
102    /// A turn started.
103    Started,
104    /// Informational provider or run status.
105    Status(String),
106    /// Incremental token accounting.
107    Usage {
108        /// Number of input tokens observed for the request or increment.
109        input_tokens: u64,
110        /// Number of output tokens observed for the request or increment.
111        output_tokens: u64,
112    },
113    /// Incremental assistant text.
114    AssistantDelta(String),
115    /// Incremental reasoning text.
116    ReasoningDelta(String),
117    /// A tool call was requested.
118    ToolStarted {
119        /// Provider-assigned id for the tool call.
120        id: String,
121        /// Application catalog name selected for the call.
122        name: String,
123        /// Raw JSON arguments supplied for the call.
124        arguments: String,
125    },
126    /// A tool call completed.
127    ToolFinished {
128        /// Provider-assigned id for the tool call.
129        id: String,
130        /// Output lines returned by the application-owned executor.
131        output: Vec<String>,
132        /// Final execution status.
133        status: ToolStatus,
134    },
135    /// Model metadata available for application model pickers.
136    ModelMetadataLoaded(Vec<(String, String)>),
137    /// A retry was scheduled after a recoverable provider failure.
138    Retrying {
139        /// One-based retry attempt that will run next.
140        attempt: u32,
141        /// Maximum retry attempts configured for the request.
142        max_attempts: u32,
143        /// Delay before the next attempt, in milliseconds.
144        delay_ms: u64,
145        /// Redacted, provider-neutral failure detail.
146        error: String,
147    },
148    /// The turn completed normally.
149    Finished,
150    /// The turn failed recoverably.
151    Failed(String),
152    /// The turn was cancelled cooperatively.
153    Cancelled,
154}
155
156/// A tool-use request from a provider.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct ToolUseRequest {
159    /// Tool name chosen from the application-supplied catalog.
160    pub name: String,
161    /// Raw JSON arguments supplied by the provider.
162    pub arguments: String,
163    /// Provider-assigned id used to correlate the eventual result.
164    pub tool_use_id: String,
165}
166
167impl ToolUseRequest {
168    /// Build a tool-use request from its provider-neutral fields.
169    pub fn new(name: impl Into<String>, arguments: impl Into<String>, tool_use_id: impl Into<String>) -> Self {
170        Self { name: name.into(), arguments: arguments.into(), tool_use_id: tool_use_id.into() }
171    }
172}
173
174/// Bounded, provider-neutral metadata about durable tool evidence.
175///
176/// `byte_count` describes the UTF-8, newline-joined compatibility rendering
177/// when constructed by [`ToolOutput::ok`] or [`ToolOutput::failed`]. Custom
178/// executors may provide their own exact measurement. `artifact_handle` is an
179/// application-owned opaque reference and never contains the evidence body.
180#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
181pub struct ToolEvidenceMetadata {
182    /// Stable identity for this evidence within the owning application.
183    pub identity: String,
184    /// Coarse evidence classification.
185    pub kind: ToolEvidenceKind,
186    /// Exact UTF-8 byte count at the application's declared boundary.
187    pub byte_count: usize,
188    /// Optional application-computed content hash.
189    pub content_hash: Option<String>,
190    /// Optional opaque handle for bounded redacted recovery.
191    pub artifact_handle: Option<String>,
192}
193
194impl ToolEvidenceMetadata {
195    /// Build compatibility metadata for newline-joined text lines.
196    pub fn for_lines(identity: impl Into<String>, lines: &[String]) -> Self {
197        Self {
198            identity: identity.into(),
199            kind: ToolEvidenceKind::Output,
200            byte_count: lines.join("\n").len(),
201            content_hash: None,
202            artifact_handle: None,
203        }
204    }
205}
206
207/// User-facing bounded projection of a tool result.
208#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
209pub struct ToolDisplayProjection {
210    /// Lines rendered by the CLI, TUI, or ACP adapter.
211    pub lines: Vec<String>,
212}
213
214impl ToolDisplayProjection {
215    /// Build a display projection from already bounded lines.
216    pub fn new(lines: Vec<String>) -> Self {
217        Self { lines }
218    }
219}
220
221/// Model-facing bounded projection of a tool result.
222#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
223pub struct ToolModelProjection {
224    /// Lines lowered into the next provider request.
225    pub lines: Vec<String>,
226}
227
228impl ToolModelProjection {
229    /// Build a model projection from already bounded lines.
230    pub fn new(lines: Vec<String>) -> Self {
231        Self { lines }
232    }
233}
234
235/// Structured output returned by an application-owned tool executor.
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub struct ToolOutput {
238    /// Tool name selected for execution.
239    pub name: String,
240    /// Execution status.
241    pub status: ToolStatus,
242    /// Metadata for bounded redacted evidence retained by the application.
243    pub evidence: ToolEvidenceMetadata,
244    /// User-facing projection. This is the source for UI and ACP surfaces.
245    pub display: ToolDisplayProjection,
246    /// Model-facing projection. This is the source for provider feedback.
247    pub model: ToolModelProjection,
248    /// Failure detail when execution did not succeed.
249    pub error: Option<String>,
250}
251
252impl ToolOutput {
253    /// Build a successful output value.
254    pub fn ok(name: impl Into<String>, output: Vec<String>) -> Self {
255        let name = name.into();
256        Self {
257            evidence: ToolEvidenceMetadata::for_lines(&name, &output),
258            name,
259            status: ToolStatus::Ok,
260            display: ToolDisplayProjection::new(output.clone()),
261            model: ToolModelProjection::new(output),
262            error: None,
263        }
264    }
265
266    /// Build a failed output value.
267    pub fn failed(name: impl Into<String>, error: impl Into<String>) -> Self {
268        let name = name.into();
269        Self {
270            evidence: ToolEvidenceMetadata::for_lines(&name, &[]),
271            name,
272            status: ToolStatus::Failed,
273            display: ToolDisplayProjection::new(Vec::new()),
274            model: ToolModelProjection::new(Vec::new()),
275            error: Some(error.into()),
276        }
277    }
278
279    /// Return display lines, materializing the structured failure detail.
280    pub fn display_lines(&self) -> Vec<String> {
281        projection_lines(&self.display.lines, self.error.as_deref())
282    }
283
284    /// Return model lines, materializing the structured failure detail.
285    pub fn model_lines(&self) -> Vec<String> {
286        projection_lines(&self.model.lines, self.error.as_deref())
287    }
288}
289
290/// A tool definition exposed to a provider/model.
291#[derive(Clone, Debug)]
292pub struct ToolDefinition {
293    /// Stable name selected by the provider in a tool-use request.
294    pub name: Cow<'static, str>,
295    /// Model-visible guidance for using the tool.
296    pub description: Cow<'static, str>,
297    /// JSON Schema for the tool arguments.
298    pub input_schema: serde_json::Value,
299}
300
301impl ToolDefinition {
302    /// Build a provider-visible tool definition.
303    pub fn new(
304        name: impl Into<Cow<'static, str>>, description: impl Into<Cow<'static, str>>, input_schema: serde_json::Value,
305    ) -> Self {
306        Self { name: name.into(), description: description.into(), input_schema }
307    }
308}
309
310/// Provider-neutral input for one agent turn.
311///
312/// Applications assemble messages and tool definitions, then their provider
313/// adapter performs the wire-level request. The turn contract remains useful
314/// to deterministic fakes and future application adapters without bringing
315/// provider protocol types into this crate.
316#[derive(Clone, Debug)]
317pub struct AgentTurn {
318    /// Ordered conversation and tool-result messages.
319    pub messages: Vec<AgentMessage>,
320    /// Tool definitions available for this turn.
321    pub tools: Vec<ToolDefinition>,
322}
323
324impl AgentTurn {
325    /// Create a turn from application-owned messages and tool definitions.
326    pub fn new(messages: Vec<AgentMessage>, tools: Vec<ToolDefinition>) -> Self {
327        Self { messages, tools }
328    }
329}
330
331/// Bounded exponential retry policy for provider requests.
332#[derive(Clone, Copy, Debug, Eq, PartialEq)]
333pub struct RetryPolicy {
334    /// Number of retry attempts after the initial request.
335    pub max_retries: u32,
336    /// Delay before the first retry.
337    pub base_delay: Duration,
338}
339
340impl RetryPolicy {
341    /// Build a retry policy with the supplied attempt limit and initial delay.
342    pub const fn new(max_retries: u32, base_delay: Duration) -> Self {
343        Self { max_retries, base_delay }
344    }
345
346    /// Return the exponential-backoff delay for a one-based retry attempt.
347    pub fn delay_for_attempt(self, attempt: u32) -> Duration {
348        self.base_delay * 2u32.saturating_pow(attempt.saturating_sub(1))
349    }
350}
351
352fn projection_lines(lines: &[String], error: Option<&str>) -> Vec<String> {
353    let mut lines = lines.to_vec();
354    let Some(error) = error.map(str::trim).filter(|error| !error.is_empty()) else {
355        return lines;
356    };
357    let error_line = format!("error: {error}");
358    if !lines.iter().any(|line| line == error || line == &error_line) {
359        lines.insert(0, error_line);
360    }
361    lines
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn permission_outcomes_have_stable_labels() {
370        assert_eq!(ToolPermissionDecision::Allow.outcome_label(), "allowed");
371        assert_eq!(ToolPermissionDecision::Reject.outcome_label(), "rejected");
372        assert_eq!(ToolPermissionDecision::Cancelled.outcome_label(), "cancelled");
373    }
374
375    #[test]
376    fn retry_delays_double_per_attempt() {
377        let policy = RetryPolicy::new(3, Duration::from_millis(25));
378        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(25));
379        assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(100));
380    }
381
382    #[test]
383    fn tool_output_preserves_success_and_failure_states() {
384        let success = ToolOutput::ok("read", vec!["ok".to_string()]);
385        assert_eq!(success.status, ToolStatus::Ok);
386        assert_eq!(success.display.lines, vec!["ok"]);
387        assert_eq!(success.model.lines, vec!["ok"]);
388        assert_eq!(success.evidence.byte_count, 2);
389
390        let failure = ToolOutput::failed("read", "missing");
391        assert_eq!(failure.error.as_deref(), Some("missing"));
392        assert_eq!(failure.display_lines(), vec!["error: missing"]);
393        assert_eq!(failure.model_lines(), vec!["error: missing"]);
394    }
395
396    #[test]
397    fn display_and_model_projections_can_diverge_without_raw_output() {
398        let mut output = ToolOutput::ok("read", vec!["full result".to_string()]);
399        output.display.lines = vec!["shown to user".to_string()];
400        output.model.lines = vec!["sent to model".to_string()];
401
402        assert_eq!(output.display.lines, vec!["shown to user"]);
403        assert_eq!(output.model.lines, vec!["sent to model"]);
404        assert_eq!(output.evidence.artifact_handle, None);
405    }
406
407    #[test]
408    fn turn_keeps_provider_neutral_messages_and_tools_together() {
409        let request = ToolUseRequest::new("read_file", r#"{"path":"README.md"}"#, "call_1");
410        let turn = AgentTurn::new(
411            vec![
412                AgentMessage::User("inspect the readme".to_string()),
413                AgentMessage::ToolUse(request),
414            ],
415            vec![ToolDefinition::new(
416                "read_file",
417                "Read a file",
418                serde_json::json!({"type": "object"}),
419            )],
420        );
421
422        assert_eq!(turn.messages.len(), 2);
423        assert_eq!(turn.tools[0].name, "read_file");
424    }
425}