Skip to main content

agent_engine/runtime/
types.rs

1use serde::Deserialize;
2
3// Stream event types now live in agent-core so rpc_dispatch can use them
4// without an upward dependency on the engine layer.
5pub use agent_core::{AgentEvent, LlmEvent, SessionEvent, StreamEvent};
6
7/// Per-turn signals shared by the host and the engine.
8///
9/// Engine → host: whether the turn reached its NORMAL end — the final
10/// response came back un-cancelled and asked for no tools (nothing left to
11/// do). Set before the engine publishes that turn's final `MessageHistory`.
12/// `Done` cannot say this: several cancelled paths also end `Ok` + `Done`.
13/// A host that cancels a turn reads it once the stream is drained: a turn
14/// that finished just before the cancel reached it (Esc a moment too late)
15/// is complete, not interrupted.
16///
17/// Host → engine: WHY the host cancelled the turn, noted before it cancels
18/// the token, so a canceled `tool_result` says so ("Canceled by user" only
19/// when it was the user). Nothing noted = the turn's default cause: the
20/// user (the historical wording), or what the host chose at turn start
21/// (`with_default_cause`: a driver turn whose token something else cancels,
22/// e.g. the grant's deadline, was cut by the driver ending).
23#[derive(Debug, Clone, Default)]
24pub struct TurnCompletion {
25    completed: std::sync::Arc<std::sync::atomic::AtomicBool>,
26    /// `0` = unset, else `CancelCause as u8`.
27    cancel_cause: std::sync::Arc<std::sync::atomic::AtomicU8>,
28    /// Used when nothing was noted; `None` = the user.
29    default_cause: Option<CancelCause>,
30}
31
32/// Why a host cancelled a turn, as far as a canceled tool result says.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[repr(u8)]
35pub enum CancelCause {
36    User = 1,
37    CostCap = 2,
38    Restart = 3,
39    Host = 4,
40    Driver = 5,
41}
42
43impl CancelCause {
44    /// Leading words of a canceled `tool_result`. Every variant starts with
45    /// "Canceled" (the phase-4 bound tests key on it); the user's is the
46    /// historical, byte-identical "Canceled by user".
47    pub fn phrase(self) -> &'static str {
48        match self {
49            CancelCause::User => "Canceled by user",
50            CancelCause::CostCap => "Canceled (session cost cap reached)",
51            CancelCause::Restart => "Canceled (Synaps restarted)",
52            CancelCause::Host => "Canceled (session stopped by the host)",
53            CancelCause::Driver => "Canceled (session driver revoked)",
54        }
55    }
56
57    fn from_u8(v: u8) -> Option<Self> {
58        Some(match v {
59            1 => CancelCause::User,
60            2 => CancelCause::CostCap,
61            3 => CancelCause::Restart,
62            4 => CancelCause::Host,
63            5 => CancelCause::Driver,
64            _ => return None,
65        })
66    }
67}
68
69impl TurnCompletion {
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// A turn whose un-noted cancels are `cause`'s (see the type docs).
75    pub fn with_default_cause(cause: CancelCause) -> Self {
76        Self {
77            default_cause: Some(cause),
78            ..Self::default()
79        }
80    }
81
82    pub fn completed(&self) -> bool {
83        self.completed.load(std::sync::atomic::Ordering::Acquire)
84    }
85
86    pub(crate) fn mark_completed(&self) {
87        self.completed
88            .store(true, std::sync::atomic::Ordering::Release);
89    }
90
91    /// Note why the host is cancelling this turn. First cause wins: a
92    /// teardown that revokes a driver and then cancels keeps its own cause.
93    pub fn note_cancel_cause(&self, cause: CancelCause) {
94        let _ = self.cancel_cause.compare_exchange(
95            0,
96            cause as u8,
97            std::sync::atomic::Ordering::AcqRel,
98            std::sync::atomic::Ordering::Acquire,
99        );
100    }
101
102    /// Leading words of this turn's canceled tool results.
103    pub(crate) fn cancel_phrase(&self) -> &'static str {
104        CancelCause::from_u8(self.cancel_cause.load(std::sync::atomic::Ordering::Acquire))
105            .or(self.default_cause)
106            .unwrap_or(CancelCause::User)
107            .phrase()
108    }
109}
110
111/// Shared mutable auth state. Lives behind `Arc<RwLock<_>>` so the spawned
112/// streaming task and the parent Runtime always see the same (freshest) token.
113#[derive(Clone)]
114pub(super) struct AuthState {
115    pub(super) auth_token: String,
116    pub(super) auth_type: String,
117    pub(super) refresh_token: Option<String>,
118    pub(super) token_expires: Option<u64>,
119    /// Local source only: mtime of auth.json observed just before the token
120    /// in `auth_token` was loaded. A different on-disk mtime means the file
121    /// was rotated externally (`synaps login`, cron, sync) and the fast path
122    /// must reload. `None` disables the check (remote / never loaded).
123    pub(super) auth_file_mtime: Option<std::time::SystemTime>,
124    /// Which credential `auth_token` was vended for:
125    /// `"<local | remote:<endpoint>#<principal fingerprint>>|<storage_key>"`
126    /// (see `runtime/auth.rs::anthropic_binding`). The principal fingerprint
127    /// is an opaque SHA-256 prefix of the machine token — never the token
128    /// itself, never an access token. `None` = unknown/legacy (api_key
129    /// harnesses, scrubbed). The pre-stream refresh refuses to serve a
130    /// cached token whose binding no longer matches the selected account on
131    /// the current source, so a config/source/principal switch can never
132    /// silently keep the previous account's token.
133    pub(super) bound_credential: Option<String>,
134}
135
136/// Redacting Debug — never print the access or refresh token.
137impl std::fmt::Debug for AuthState {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("AuthState")
140            .field(
141                "auth_token",
142                &if self.auth_token.is_empty() {
143                    "<empty>"
144                } else {
145                    "<redacted>"
146                },
147            )
148            .field("auth_type", &self.auth_type)
149            .field(
150                "refresh_token",
151                &self.refresh_token.as_ref().map(|_| "<redacted>"),
152            )
153            .field("token_expires", &self.token_expires)
154            .field("auth_file_mtime", &self.auth_file_mtime)
155            .field("bound_credential", &self.bound_credential)
156            .finish()
157    }
158}
159
160#[allow(dead_code)]
161#[derive(Debug, Deserialize)]
162pub(super) struct AnthropicAuth {
163    #[serde(rename = "type")]
164    pub(super) auth_type: String,
165    pub(super) refresh: Option<String>,
166    pub(super) access: Option<String>,
167    pub(super) expires: Option<u64>,
168    pub(super) key: Option<String>,
169}