1use 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#[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 async fn one_shot(
29 &self,
30 prompt: Prompt,
31 budget: Budget,
32 sink: EventSink,
33 ) -> Result<Response, AiError>;
34
35 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#[async_trait]
55pub trait BudgetTracker: Send + Sync {
56 async fn cap(&self, run_id: &str, kind: BudgetKind) -> Result<Option<i64>, AiError>;
59
60 async fn current_spend(&self, run_id: &str, kind: BudgetKind) -> Result<i64, AiError>;
64
65 async fn add_spend(&self, run_id: &str, kind: BudgetKind, micros: i64) -> Result<i64, AiError>;
68}
69
70#[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 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
152pub type SharedBudgetTracker = Arc<dyn BudgetTracker>;
154
155pub 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}