agent-sdk-foundation 0.9.2

Shared contract types for the Agent SDK (IDs, events, LLM messages, turn outcomes)
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
//! Authoritative tool audit records.
//!
//! The audit surface that the server uses to explain **every** tool
//! lifecycle outcome — not just successful completion. This replaces the
//! `post_tool_use` hook as the sole audit surface on the authoritative
//! (server) execution path.
//!
//! # Why this exists
//!
//! `post_tool_use` only fires once per tool call and only describes the
//! terminal [`ToolResult`]. The server has to explain paths that never
//! reach a successful result, including:
//!
//! - **Blocked** — the policy hook rejected the tool.
//! - **`RequiresConfirmation`** — the policy hook yielded for user approval.
//! - **Cached** — an earlier completed execution was replayed from the
//!   execution store.
//! - **Replayed** — the caller resubmitted external tool results for an
//!   already-processed handoff.
//! - **Invalidated** — a listen-tool snapshot expired or was invalidated
//!   before the user could confirm.
//! - **Completed** — the tool ran to completion (success or failure).
//! - **`PersistenceFailed`** — the tool ran but the event / execution
//!   store refused to durably record the outcome.
//!
//! These outcomes are modelled as [`ToolAuditOutcome`] variants on a
//! single [`ToolAuditRecord`]. Sinks receive one record per lifecycle
//! transition and can persist them to a durable audit table without
//! having to reconstruct the path from scattered hook calls.
//!
//! # Trait location
//!
//! Only the **record shape** lives in `agent-sdk-foundation` (this module is
//! data-only). The async [`ToolAuditSink`](../../agent_sdk_tools/audit/trait.ToolAuditSink.html)
//! trait lives in `agent-sdk-tools` so `agent-sdk-foundation` stays free of
//! async-trait dependencies.

use crate::types::{ListenExecutionContext, ToolResult, ToolTier};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

/// Provider / model provenance for an audit record.
///
/// Captured at the moment the record is emitted so that durable audit
/// rows survive provider/model rotations. Present on every record
/// because every tool-call lifecycle event happens in the context of
/// the LLM turn that requested the tool.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditProvenance {
    /// Provider identifier (e.g. `"anthropic"`, `"openai"`, `"vertex"`).
    pub provider: String,
    /// Model identifier (e.g. `"claude-sonnet-4-5-20250929"`).
    pub model: String,
}

impl AuditProvenance {
    /// Construct a provenance record from borrowed strings.
    #[must_use]
    pub fn new(provider: impl Into<String>, model: impl Into<String>) -> Self {
        Self {
            provider: provider.into(),
            model: model.into(),
        }
    }
}

/// Lifecycle outcome for a single tool call.
///
/// Every variant is an **authoritative** terminal state the server must
/// persist — including paths that bypass tool execution entirely (blocked,
/// confirmation, cached replay) or that fail persistence after the tool
/// already ran.
///
/// Variants are ordered roughly by lifecycle position: policy check → cache
/// lookup → execution → post-execution persistence.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ToolAuditOutcome {
    /// The policy hook rejected the tool call.
    ///
    /// The tool never executed. The reason is the string returned by
    /// [`ToolDecision::Block`](../../agent_sdk_tools/hooks/enum.ToolDecision.html#variant.Block).
    Blocked {
        /// Reason provided by the policy hook.
        reason: String,
    },

    /// The policy hook yielded for user approval.
    ///
    /// The tool is paused pending a resume decision. The turn loop will
    /// emit a follow-up record on resume (either [`Completed`](Self::Completed)
    /// after execution or [`Blocked`](Self::Blocked) if policy now rejects).
    RequiresConfirmation {
        /// Human-readable confirmation description shown to the user.
        description: String,
        /// Optional listen-context captured at confirmation time.
        listen_context: Option<ListenExecutionContext>,
    },

    /// The execution store already held a completed result for this
    /// tool call — the idempotency layer replayed the cached outcome
    /// instead of calling the tool again.
    Cached {
        /// The cached [`ToolResult`] that was replayed.
        result: ToolResult,
    },

    /// The caller resubmitted external tool results for an already
    /// processed handoff, and the SDK served the previously recorded
    /// result rather than re-accepting the payload.
    ///
    /// Distinct from [`Cached`](Self::Cached) in that this fires on the
    /// **external** runtime path where the SDK did not execute the tool
    /// itself in any attempt.
    Replayed {
        /// The [`ToolResult`] previously recorded for this tool call.
        result: ToolResult,
    },

    /// A listen-tool snapshot expired or was invalidated before the
    /// user could confirm it.
    ///
    /// This is a non-completion path: no final [`ToolResult`] is
    /// produced because the confirmation window closed.
    Invalidated {
        /// Reason the listen-tool invalidated its snapshot.
        reason: String,
    },

    /// The tool ran to completion (success or failure).
    ///
    /// `result.success` indicates whether the tool itself succeeded;
    /// even a failing run is considered a completed lifecycle.
    Completed {
        /// Final [`ToolResult`] produced by the tool.
        result: ToolResult,
    },

    /// The tool executed but the server could not durably persist the
    /// outcome (event store, execution store, or message append failed).
    ///
    /// The record preserves the in-memory [`ToolResult`] so that audit
    /// consumers can reason about divergence between what the tool
    /// produced and what made it to durable storage.
    PersistenceFailed {
        /// The [`ToolResult`] that would have been persisted, if any.
        ///
        /// `None` when the persistence layer failed before a result was
        /// produced (e.g. a `tool_call_start` event failed to append).
        result: Option<ToolResult>,
        /// Short, human-readable description of the persistence failure.
        error: String,
    },
}

impl ToolAuditOutcome {
    /// Static discriminant string used for metrics, tracing attributes,
    /// and durable audit rows.
    #[must_use]
    pub const fn kind(&self) -> &'static str {
        match self {
            Self::Blocked { .. } => "blocked",
            Self::RequiresConfirmation { .. } => "requires_confirmation",
            Self::Cached { .. } => "cached",
            Self::Replayed { .. } => "replayed",
            Self::Invalidated { .. } => "invalidated",
            Self::Completed { .. } => "completed",
            Self::PersistenceFailed { .. } => "persistence_failed",
        }
    }

    /// Returns the [`ToolResult`] associated with this outcome, if one
    /// is available.
    ///
    /// Present for [`Cached`](Self::Cached), [`Replayed`](Self::Replayed),
    /// [`Completed`](Self::Completed), and most
    /// [`PersistenceFailed`](Self::PersistenceFailed) paths. Absent for
    /// [`Blocked`](Self::Blocked), [`RequiresConfirmation`](Self::RequiresConfirmation),
    /// and [`Invalidated`](Self::Invalidated).
    #[must_use]
    pub const fn result(&self) -> Option<&ToolResult> {
        match self {
            Self::Cached { result } | Self::Replayed { result } | Self::Completed { result } => {
                Some(result)
            }
            Self::PersistenceFailed { result, .. } => result.as_ref(),
            Self::Blocked { .. } | Self::RequiresConfirmation { .. } | Self::Invalidated { .. } => {
                None
            }
        }
    }
}

/// Single authoritative audit record for one tool-call lifecycle event.
///
/// A tool call may produce **multiple** records over its lifetime — for
/// example a `RequiresConfirmation` followed by a `Completed` after the
/// user approves, or a `Completed` followed by a `PersistenceFailed` if
/// the event store rejects the terminal event.
///
/// Records are self-describing: consumers do **not** need to correlate
/// them with hook calls or event-store rows to understand what happened.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ToolAuditRecord {
    /// Unique tool call ID (from the LLM's `tool_use`).
    pub tool_call_id: String,
    /// Wire-format tool name.
    pub tool_name: String,
    /// Human-readable display name.
    pub display_name: String,
    /// Permission tier of the tool at the moment the record was emitted.
    pub tier: ToolTier,
    /// Input as requested by the LLM (audit trail).
    pub requested_input: serde_json::Value,
    /// Effective input after SDK preparation (may differ for listen-tools).
    pub effective_input: serde_json::Value,
    /// Turn number this record belongs to.
    pub turn: usize,
    /// Provider / model provenance for this turn's LLM call.
    pub provenance: AuditProvenance,
    /// Lifecycle outcome carrying the variant-specific payload.
    pub outcome: ToolAuditOutcome,
    /// UTC timestamp when the record was produced.
    #[serde(with = "time::serde::rfc3339")]
    pub recorded_at: OffsetDateTime,
}

/// Arguments for building a [`ToolAuditRecord`] via [`ToolAuditRecord::new`].
///
/// Replaces a 9-parameter positional constructor so each field is named
/// at the call site — three of the positional parameters were
/// `impl Into<String>` and two were `serde_json::Value`, which made
/// positional confusion a real risk for a struct that lands in the
/// durable audit log.
///
/// Every field is required; the timestamp (`recorded_at`) is the only
/// value [`ToolAuditRecord::new`] fills in automatically.
#[derive(Clone, Debug)]
pub struct ToolAuditRecordParams {
    /// Unique tool call ID (from the LLM's `tool_use`).
    pub tool_call_id: String,
    /// Wire-format tool name.
    pub tool_name: String,
    /// Human-readable display name.
    pub display_name: String,
    /// Permission tier of the tool at the moment the record was emitted.
    pub tier: ToolTier,
    /// Input as requested by the LLM (audit trail).
    pub requested_input: serde_json::Value,
    /// Effective input after SDK preparation (may differ for listen-tools).
    pub effective_input: serde_json::Value,
    /// Turn number this record belongs to.
    pub turn: usize,
    /// Provider / model provenance for this turn's LLM call.
    pub provenance: AuditProvenance,
    /// Lifecycle outcome carrying the variant-specific payload.
    pub outcome: ToolAuditOutcome,
}

impl ToolAuditRecord {
    /// Build a record using the current wall-clock time.
    ///
    /// See [`ToolAuditRecordParams`] for the field list.
    #[must_use]
    pub fn new(params: ToolAuditRecordParams) -> Self {
        let ToolAuditRecordParams {
            tool_call_id,
            tool_name,
            display_name,
            tier,
            requested_input,
            effective_input,
            turn,
            provenance,
            outcome,
        } = params;
        Self {
            tool_call_id,
            tool_name,
            display_name,
            tier,
            requested_input,
            effective_input,
            turn,
            provenance,
            outcome,
            recorded_at: OffsetDateTime::now_utc(),
        }
    }

    /// Return the outcome's discriminant string.
    #[must_use]
    pub const fn outcome_kind(&self) -> &'static str {
        self.outcome.kind()
    }
}

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

    fn sample_record(outcome: ToolAuditOutcome) -> ToolAuditRecord {
        ToolAuditRecord::new(ToolAuditRecordParams {
            tool_call_id: "call_1".into(),
            tool_name: "read_file".into(),
            display_name: "Read File".into(),
            tier: ToolTier::Observe,
            requested_input: serde_json::json!({"path": "/tmp/x"}),
            effective_input: serde_json::json!({"path": "/tmp/x"}),
            turn: 2,
            provenance: AuditProvenance::new("anthropic", "claude-sonnet-4-5-20250929"),
            outcome,
        })
    }

    #[test]
    fn outcome_kind_matches_variant() {
        assert_eq!(
            ToolAuditOutcome::Blocked {
                reason: "no".into(),
            }
            .kind(),
            "blocked",
        );
        assert_eq!(
            ToolAuditOutcome::RequiresConfirmation {
                description: "pls".into(),
                listen_context: None,
            }
            .kind(),
            "requires_confirmation",
        );
        assert_eq!(
            ToolAuditOutcome::Cached {
                result: ToolResult::success("ok"),
            }
            .kind(),
            "cached",
        );
        assert_eq!(
            ToolAuditOutcome::Replayed {
                result: ToolResult::success("ok"),
            }
            .kind(),
            "replayed",
        );
        assert_eq!(
            ToolAuditOutcome::Invalidated {
                reason: "expired".into(),
            }
            .kind(),
            "invalidated",
        );
        assert_eq!(
            ToolAuditOutcome::Completed {
                result: ToolResult::success("ok"),
            }
            .kind(),
            "completed",
        );
        assert_eq!(
            ToolAuditOutcome::PersistenceFailed {
                result: None,
                error: "boom".into(),
            }
            .kind(),
            "persistence_failed",
        );
    }

    #[test]
    fn outcome_result_accessor() {
        let ok = ToolResult::success("ok");
        assert!(
            ToolAuditOutcome::Blocked { reason: "n".into() }
                .result()
                .is_none()
        );
        assert_eq!(
            ToolAuditOutcome::Completed { result: ok.clone() }
                .result()
                .map(|r| r.output.as_str()),
            Some("ok"),
        );
        assert_eq!(
            ToolAuditOutcome::PersistenceFailed {
                result: Some(ok),
                error: "e".into(),
            }
            .result()
            .map(|r| r.output.as_str()),
            Some("ok"),
        );
    }

    #[test]
    fn record_round_trips_through_json() {
        let record = sample_record(ToolAuditOutcome::Completed {
            result: ToolResult::success("hello"),
        });
        let json = serde_json::to_string(&record).unwrap();
        let back: ToolAuditRecord = serde_json::from_str(&json).unwrap();
        assert_eq!(back.tool_call_id, "call_1");
        assert_eq!(back.outcome_kind(), "completed");
        assert_eq!(back.provenance.provider, "anthropic");
        assert_eq!(back.provenance.model, "claude-sonnet-4-5-20250929");
    }

    #[test]
    fn every_outcome_serialises_with_snake_case_tag() {
        // Non-trivial assertion: the external tag format must be stable
        // for durable audit tables and dashboards.
        let record = sample_record(ToolAuditOutcome::Blocked {
            reason: "policy".into(),
        });
        let json = serde_json::to_value(&record).unwrap();
        assert_eq!(json["outcome"]["kind"], "blocked");
        assert_eq!(json["outcome"]["reason"], "policy");
    }
}