Skip to main content

nyx_agent_ai/
runtime.rs

1//! `AiRuntime` trait + the small `BudgetTracker` port that adapters
2//! hit on every successful round-trip to land a `BudgetTick` row in the
3//! `budgets` table. The trait stays vendor-neutral; concrete adapters
4//! live under `adapter/`.
5
6use std::sync::{Arc, Mutex};
7
8use async_trait::async_trait;
9use nyx_agent_types::agent::{
10    AgentResult, AgentTask, AiError, Budget, BudgetKind, CostEstimate, Prompt, Response,
11};
12use nyx_agent_types::event::EventSink;
13
14/// Vendor-neutral AI runtime contract. Every adapter (Anthropic SDK,
15/// Claude Code, OpenAI, ...) implements this trait so the rest of the
16/// agent depends only on `nyx-agent-ai` and `nyx-agent-types`.
17#[async_trait]
18pub trait AiRuntime: Send + Sync {
19    fn name(&self) -> &'static str;
20    fn default_model(&self) -> &str;
21    fn supports_agent_loop(&self) -> bool;
22    fn supports_prompt_cache(&self) -> bool;
23    fn supports_deterministic_sampling(&self) -> bool;
24
25    /// Single-prompt structured task. Streams `AgentEvent::Ai` events
26    /// to `sink` as tokens / cache hits / cost ticks land. Returns the
27    /// final `Response` once the model finishes.
28    async fn one_shot(
29        &self,
30        prompt: Prompt,
31        budget: Budget,
32        sink: EventSink,
33    ) -> Result<Response, AiError>;
34
35    /// Multi-turn tool-use loop. Adapters that only do one-shot return
36    /// `AiError::UnsupportedMode("agent_loop")`.
37    async fn agent_loop(
38        &self,
39        task: AgentTask,
40        budget: Budget,
41        sink: EventSink,
42    ) -> Result<AgentResult, AiError>;
43
44    fn cost_estimate(&self, prompt: &Prompt) -> Option<CostEstimate>;
45}
46
47/// Host-side port the adapter calls on every successful round-trip.
48/// The production wiring forwards into
49/// `nyx_agent_core::store::BudgetStore`; tests use [`InMemoryBudgetTracker`].
50///
51/// The trait is intentionally minimal: cap reads + monotonic spend
52/// adds. Adapters never write the `halted` flag directly - the host
53/// keeps that audit trail in the budgets table.
54#[async_trait]
55pub trait BudgetTracker: Send + Sync {
56    /// Return the cap for `(run_id, kind)` in USD micros, or `None`
57    /// if no cap is configured.
58    async fn cap(&self, run_id: &str, kind: BudgetKind) -> Result<Option<i64>, AiError>;
59
60    /// Read the current `spent_usd_micros` for `(run_id, kind)`. A
61    /// non-existent row reads as `0` so pre-call cap checks against a
62    /// brand-new run do not need to seed the row first.
63    async fn current_spend(&self, run_id: &str, kind: BudgetKind) -> Result<i64, AiError>;
64
65    /// Atomically increment `spent_usd_micros` by `micros` and return
66    /// the new total.
67    async fn add_spend(&self, run_id: &str, kind: BudgetKind, micros: i64) -> Result<i64, AiError>;
68}
69
70/// Process-local budget tracker. Used by adapter tests and any future
71/// in-memory dispatcher; production code wires a real
72/// `BudgetStore`-backed implementation in the binary.
73#[derive(Default)]
74pub struct InMemoryBudgetTracker {
75    inner: Mutex<Vec<Row>>,
76}
77
78#[derive(Clone)]
79struct Row {
80    run_id: String,
81    kind: BudgetKind,
82    cap_usd_micros: Option<i64>,
83    spent_usd_micros: i64,
84}
85
86impl InMemoryBudgetTracker {
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Pre-seed the cap for `(run_id, kind)`. Subsequent `add_spend`
92    /// calls accumulate against this cap.
93    pub fn set_cap(&self, run_id: &str, kind: BudgetKind, cap_usd_micros: i64) {
94        let mut rows = self.inner.lock().expect("tracker poisoned");
95        if let Some(row) = rows.iter_mut().find(|r| r.run_id == run_id && r.kind == kind) {
96            row.cap_usd_micros = Some(cap_usd_micros);
97        } else {
98            rows.push(Row {
99                run_id: run_id.to_string(),
100                kind,
101                cap_usd_micros: Some(cap_usd_micros),
102                spent_usd_micros: 0,
103            });
104        }
105    }
106
107    pub fn spent(&self, run_id: &str, kind: BudgetKind) -> i64 {
108        let rows = self.inner.lock().expect("tracker poisoned");
109        rows.iter()
110            .find(|r| r.run_id == run_id && r.kind == kind)
111            .map(|r| r.spent_usd_micros)
112            .unwrap_or(0)
113    }
114}
115
116#[async_trait]
117impl BudgetTracker for InMemoryBudgetTracker {
118    async fn cap(&self, run_id: &str, kind: BudgetKind) -> Result<Option<i64>, AiError> {
119        let rows = self.inner.lock().expect("tracker poisoned");
120        Ok(rows
121            .iter()
122            .find(|r| r.run_id == run_id && r.kind == kind)
123            .and_then(|r| r.cap_usd_micros))
124    }
125
126    async fn current_spend(&self, run_id: &str, kind: BudgetKind) -> Result<i64, AiError> {
127        let rows = self.inner.lock().expect("tracker poisoned");
128        Ok(rows
129            .iter()
130            .find(|r| r.run_id == run_id && r.kind == kind)
131            .map(|r| r.spent_usd_micros)
132            .unwrap_or(0))
133    }
134
135    async fn add_spend(&self, run_id: &str, kind: BudgetKind, micros: i64) -> Result<i64, AiError> {
136        let mut rows = self.inner.lock().expect("tracker poisoned");
137        if let Some(row) = rows.iter_mut().find(|r| r.run_id == run_id && r.kind == kind) {
138            row.spent_usd_micros += micros;
139            Ok(row.spent_usd_micros)
140        } else {
141            rows.push(Row {
142                run_id: run_id.to_string(),
143                kind,
144                cap_usd_micros: None,
145                spent_usd_micros: micros,
146            });
147            Ok(micros)
148        }
149    }
150}
151
152/// Convenience alias used by the Anthropic adapter constructor.
153pub type SharedBudgetTracker = Arc<dyn BudgetTracker>;
154
155/// Derive a deterministic 64-bit seed for sampling from `run_id` and
156/// `task_id`. Adapters that expose `random_seed` upstream pass this
157/// through; adapters that do not ignore it. Public so callers can mint
158/// the same seed when persisting traces.
159pub fn deterministic_seed(run_id: &str, task_id: &str) -> u64 {
160    let mut hasher = blake3::Hasher::new();
161    hasher.update(run_id.as_bytes());
162    hasher.update(b"\0");
163    hasher.update(task_id.as_bytes());
164    let hash = hasher.finalize();
165    let bytes = hash.as_bytes();
166    u64::from_le_bytes([
167        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
168    ])
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn deterministic_seed_is_stable() {
177        let a = deterministic_seed("run-1", "task-a");
178        let b = deterministic_seed("run-1", "task-a");
179        assert_eq!(a, b);
180        let c = deterministic_seed("run-1", "task-b");
181        assert_ne!(a, c);
182    }
183
184    #[tokio::test]
185    async fn in_memory_tracker_caps_and_adds() {
186        let t = InMemoryBudgetTracker::new();
187        t.set_cap("run", BudgetKind::OneShot, 10_000);
188        let cap = t.cap("run", BudgetKind::OneShot).await.unwrap();
189        assert_eq!(cap, Some(10_000));
190        let after_a = t.add_spend("run", BudgetKind::OneShot, 4_000).await.unwrap();
191        let after_b = t.add_spend("run", BudgetKind::OneShot, 1_500).await.unwrap();
192        assert_eq!(after_a, 4_000);
193        assert_eq!(after_b, 5_500);
194        assert_eq!(t.spent("run", BudgetKind::OneShot), 5_500);
195    }
196
197    #[tokio::test]
198    async fn current_spend_reads_without_mutating() {
199        let t = InMemoryBudgetTracker::new();
200        assert_eq!(t.current_spend("run", BudgetKind::OneShot).await.unwrap(), 0);
201        t.add_spend("run", BudgetKind::OneShot, 7_500).await.unwrap();
202        assert_eq!(t.current_spend("run", BudgetKind::OneShot).await.unwrap(), 7_500);
203        assert_eq!(t.current_spend("run", BudgetKind::OneShot).await.unwrap(), 7_500);
204        assert_eq!(t.spent("run", BudgetKind::OneShot), 7_500);
205    }
206}