thndrs-agent 0.1.0

Provider-neutral coding-agent loop and contracts
Documentation
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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! Provider-neutral run contracts shared by application adapters.

use std::borrow::Cow;
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// Status of an executed tool call.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Serialize, Deserialize)]
pub enum ToolStatus {
    /// Tool started, not yet finished.
    #[default]
    Running,
    /// Tool finished successfully.
    Ok,
    /// Tool failed.
    Failed,
    /// Tool was cancelled while running.
    Cancelled,
}

impl ToolStatus {
    /// Compact session/transcript label for a file-write result.
    pub const fn icon(self) -> &'static str {
        match self {
            ToolStatus::Ok => "✓ wrote",
            ToolStatus::Failed => "✕ write failed",
            ToolStatus::Running => "⠋ writing",
            ToolStatus::Cancelled => "✕ write cancelled",
        }
    }

    /// Stable lowercase status label.
    pub const fn label(self) -> &'static str {
        match self {
            ToolStatus::Running => "running",
            ToolStatus::Ok => "ok",
            ToolStatus::Failed => "failed",
            ToolStatus::Cancelled => "cancelled",
        }
    }
}

/// The kind of bounded evidence associated with a tool execution.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ToolEvidenceKind {
    /// The normal result produced by a tool execution.
    Output,
}

/// Provider-neutral message supplied to or produced by an agent turn.
///
/// Provider adapters lower this representation to their wire payloads inside
/// the application. The shared contract never exposes those payloads.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AgentMessage {
    /// System or harness guidance.
    System(String),
    /// User input for the current or an earlier turn.
    User(String),
    /// Completed assistant text.
    Assistant(String),
    /// An assistant tool-use request.
    ToolUse(ToolUseRequest),
    /// A completed application-owned tool result.
    ToolResult {
        /// Provider or application id that correlates the result with a tool request.
        id: String,
        /// Application-owned result returned by the tool executor.
        output: ToolOutput,
    },
}

/// Decision returned by an application-owned tool permission hook.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolPermissionDecision {
    /// The tool call may execute.
    Allow,
    /// The tool call must be rejected before execution.
    Reject,
    /// The prompt turn was cancelled while waiting for permission.
    Cancelled,
}

impl ToolPermissionDecision {
    /// Stable outcome label for session records.
    pub const fn outcome_label(self) -> &'static str {
        match self {
            Self::Allow => "allowed",
            Self::Reject => "rejected",
            Self::Cancelled => "cancelled",
        }
    }
}

/// Provider-neutral semantic output from an agent turn.
///
/// Application adapters may attach local-tool audit details, UI state, or
/// transport state when projecting these events to their own surfaces.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AgentEvent {
    /// A turn started.
    Started,
    /// Informational provider or run status.
    Status(String),
    /// Incremental token accounting.
    Usage {
        /// Number of input tokens observed for the request or increment.
        input_tokens: u64,
        /// Number of output tokens observed for the request or increment.
        output_tokens: u64,
    },
    /// Incremental assistant text.
    AssistantDelta(String),
    /// Incremental reasoning text.
    ReasoningDelta(String),
    /// A tool call was requested.
    ToolStarted {
        /// Provider-assigned id for the tool call.
        id: String,
        /// Application catalog name selected for the call.
        name: String,
        /// Raw JSON arguments supplied for the call.
        arguments: String,
    },
    /// A tool call completed.
    ToolFinished {
        /// Provider-assigned id for the tool call.
        id: String,
        /// Output lines returned by the application-owned executor.
        output: Vec<String>,
        /// Final execution status.
        status: ToolStatus,
    },
    /// Model metadata available for application model pickers.
    ModelMetadataLoaded(Vec<(String, String)>),
    /// A retry was scheduled after a recoverable provider failure.
    Retrying {
        /// One-based retry attempt that will run next.
        attempt: u32,
        /// Maximum retry attempts configured for the request.
        max_attempts: u32,
        /// Delay before the next attempt, in milliseconds.
        delay_ms: u64,
        /// Redacted, provider-neutral failure detail.
        error: String,
    },
    /// The turn completed normally.
    Finished,
    /// The turn failed recoverably.
    Failed(String),
    /// The turn was cancelled cooperatively.
    Cancelled,
}

/// A tool-use request from a provider.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolUseRequest {
    /// Tool name chosen from the application-supplied catalog.
    pub name: String,
    /// Raw JSON arguments supplied by the provider.
    pub arguments: String,
    /// Provider-assigned id used to correlate the eventual result.
    pub tool_use_id: String,
}

impl ToolUseRequest {
    /// Build a tool-use request from its provider-neutral fields.
    pub fn new(name: impl Into<String>, arguments: impl Into<String>, tool_use_id: impl Into<String>) -> Self {
        Self { name: name.into(), arguments: arguments.into(), tool_use_id: tool_use_id.into() }
    }
}

/// Bounded, provider-neutral metadata about durable tool evidence.
///
/// `byte_count` describes the UTF-8, newline-joined compatibility rendering
/// when constructed by [`ToolOutput::ok`] or [`ToolOutput::failed`]. Custom
/// executors may provide their own exact measurement. `artifact_handle` is an
/// application-owned opaque reference and never contains the evidence body.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ToolEvidenceMetadata {
    /// Stable identity for this evidence within the owning application.
    pub identity: String,
    /// Coarse evidence classification.
    pub kind: ToolEvidenceKind,
    /// Exact UTF-8 byte count at the application's declared boundary.
    pub byte_count: usize,
    /// Optional application-computed content hash.
    pub content_hash: Option<String>,
    /// Optional opaque handle for bounded redacted recovery.
    pub artifact_handle: Option<String>,
}

impl ToolEvidenceMetadata {
    /// Build compatibility metadata for newline-joined text lines.
    pub fn for_lines(identity: impl Into<String>, lines: &[String]) -> Self {
        Self {
            identity: identity.into(),
            kind: ToolEvidenceKind::Output,
            byte_count: lines.join("\n").len(),
            content_hash: None,
            artifact_handle: None,
        }
    }
}

/// User-facing bounded projection of a tool result.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ToolDisplayProjection {
    /// Lines rendered by the CLI, TUI, or ACP adapter.
    pub lines: Vec<String>,
}

impl ToolDisplayProjection {
    /// Build a display projection from already bounded lines.
    pub fn new(lines: Vec<String>) -> Self {
        Self { lines }
    }
}

/// Model-facing bounded projection of a tool result.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ToolModelProjection {
    /// Lines lowered into the next provider request.
    pub lines: Vec<String>,
}

impl ToolModelProjection {
    /// Build a model projection from already bounded lines.
    pub fn new(lines: Vec<String>) -> Self {
        Self { lines }
    }
}

/// Structured output returned by an application-owned tool executor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolOutput {
    /// Tool name selected for execution.
    pub name: String,
    /// Execution status.
    pub status: ToolStatus,
    /// Metadata for bounded redacted evidence retained by the application.
    pub evidence: ToolEvidenceMetadata,
    /// User-facing projection. This is the source for UI and ACP surfaces.
    pub display: ToolDisplayProjection,
    /// Model-facing projection. This is the source for provider feedback.
    pub model: ToolModelProjection,
    /// Failure detail when execution did not succeed.
    pub error: Option<String>,
}

impl ToolOutput {
    /// Build a successful output value.
    pub fn ok(name: impl Into<String>, output: Vec<String>) -> Self {
        let name = name.into();
        Self {
            evidence: ToolEvidenceMetadata::for_lines(&name, &output),
            name,
            status: ToolStatus::Ok,
            display: ToolDisplayProjection::new(output.clone()),
            model: ToolModelProjection::new(output),
            error: None,
        }
    }

    /// Build a failed output value.
    pub fn failed(name: impl Into<String>, error: impl Into<String>) -> Self {
        let name = name.into();
        Self {
            evidence: ToolEvidenceMetadata::for_lines(&name, &[]),
            name,
            status: ToolStatus::Failed,
            display: ToolDisplayProjection::new(Vec::new()),
            model: ToolModelProjection::new(Vec::new()),
            error: Some(error.into()),
        }
    }

    /// Return display lines, materializing the structured failure detail.
    pub fn display_lines(&self) -> Vec<String> {
        projection_lines(&self.display.lines, self.error.as_deref())
    }

    /// Return model lines, materializing the structured failure detail.
    pub fn model_lines(&self) -> Vec<String> {
        projection_lines(&self.model.lines, self.error.as_deref())
    }
}

/// A tool definition exposed to a provider/model.
#[derive(Clone, Debug)]
pub struct ToolDefinition {
    /// Stable name selected by the provider in a tool-use request.
    pub name: Cow<'static, str>,
    /// Model-visible guidance for using the tool.
    pub description: Cow<'static, str>,
    /// JSON Schema for the tool arguments.
    pub input_schema: serde_json::Value,
}

impl ToolDefinition {
    /// Build a provider-visible tool definition.
    pub fn new(
        name: impl Into<Cow<'static, str>>, description: impl Into<Cow<'static, str>>, input_schema: serde_json::Value,
    ) -> Self {
        Self { name: name.into(), description: description.into(), input_schema }
    }
}

/// Provider-neutral input for one agent turn.
///
/// Applications assemble messages and tool definitions, then their provider
/// adapter performs the wire-level request. The turn contract remains useful
/// to deterministic fakes and future application adapters without bringing
/// provider protocol types into this crate.
#[derive(Clone, Debug)]
pub struct AgentTurn {
    /// Ordered conversation and tool-result messages.
    pub messages: Vec<AgentMessage>,
    /// Tool definitions available for this turn.
    pub tools: Vec<ToolDefinition>,
}

impl AgentTurn {
    /// Create a turn from application-owned messages and tool definitions.
    pub fn new(messages: Vec<AgentMessage>, tools: Vec<ToolDefinition>) -> Self {
        Self { messages, tools }
    }
}

/// Bounded exponential retry policy for provider requests.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryPolicy {
    /// Number of retry attempts after the initial request.
    pub max_retries: u32,
    /// Delay before the first retry.
    pub base_delay: Duration,
}

impl RetryPolicy {
    /// Build a retry policy with the supplied attempt limit and initial delay.
    pub const fn new(max_retries: u32, base_delay: Duration) -> Self {
        Self { max_retries, base_delay }
    }

    /// Return the exponential-backoff delay for a one-based retry attempt.
    pub fn delay_for_attempt(self, attempt: u32) -> Duration {
        self.base_delay * 2u32.saturating_pow(attempt.saturating_sub(1))
    }
}

fn projection_lines(lines: &[String], error: Option<&str>) -> Vec<String> {
    let mut lines = lines.to_vec();
    let Some(error) = error.map(str::trim).filter(|error| !error.is_empty()) else {
        return lines;
    };
    let error_line = format!("error: {error}");
    if !lines.iter().any(|line| line == error || line == &error_line) {
        lines.insert(0, error_line);
    }
    lines
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn permission_outcomes_have_stable_labels() {
        assert_eq!(ToolPermissionDecision::Allow.outcome_label(), "allowed");
        assert_eq!(ToolPermissionDecision::Reject.outcome_label(), "rejected");
        assert_eq!(ToolPermissionDecision::Cancelled.outcome_label(), "cancelled");
    }

    #[test]
    fn retry_delays_double_per_attempt() {
        let policy = RetryPolicy::new(3, Duration::from_millis(25));
        assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(25));
        assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(100));
    }

    #[test]
    fn tool_output_preserves_success_and_failure_states() {
        let success = ToolOutput::ok("read", vec!["ok".to_string()]);
        assert_eq!(success.status, ToolStatus::Ok);
        assert_eq!(success.display.lines, vec!["ok"]);
        assert_eq!(success.model.lines, vec!["ok"]);
        assert_eq!(success.evidence.byte_count, 2);

        let failure = ToolOutput::failed("read", "missing");
        assert_eq!(failure.error.as_deref(), Some("missing"));
        assert_eq!(failure.display_lines(), vec!["error: missing"]);
        assert_eq!(failure.model_lines(), vec!["error: missing"]);
    }

    #[test]
    fn display_and_model_projections_can_diverge_without_raw_output() {
        let mut output = ToolOutput::ok("read", vec!["full result".to_string()]);
        output.display.lines = vec!["shown to user".to_string()];
        output.model.lines = vec!["sent to model".to_string()];

        assert_eq!(output.display.lines, vec!["shown to user"]);
        assert_eq!(output.model.lines, vec!["sent to model"]);
        assert_eq!(output.evidence.artifact_handle, None);
    }

    #[test]
    fn turn_keeps_provider_neutral_messages_and_tools_together() {
        let request = ToolUseRequest::new("read_file", r#"{"path":"README.md"}"#, "call_1");
        let turn = AgentTurn::new(
            vec![
                AgentMessage::User("inspect the readme".to_string()),
                AgentMessage::ToolUse(request),
            ],
            vec![ToolDefinition::new(
                "read_file",
                "Read a file",
                serde_json::json!({"type": "object"}),
            )],
        );

        assert_eq!(turn.messages.len(), 2);
        assert_eq!(turn.tools[0].name, "read_file");
    }
}