1use crate::spec_ai_api::agent::builder::AgentBuilder;
3use crate::spec_ai_api::agent::core::AgentCore;
4use crate::spec_ai_api::api::auth::{AuthService, TokenRequest, TokenResponse};
5use crate::spec_ai_api::api::mesh::{MeshRegistry, MeshState};
6use crate::spec_ai_api::api::models::*;
7use crate::spec_ai_api::config::{AgentRegistry, AppConfig, SafetyConfig};
8use crate::spec_ai_api::persistence::Persistence;
9use crate::spec_ai_api::tools::ToolRegistry;
10use async_stream::stream;
11use axum::{
12 extract::{Json, State},
13 http::StatusCode,
14 response::{
15 IntoResponse, Response,
16 sse::{Event, Sse},
17 },
18};
19use futures::StreamExt;
20use serde_json::json;
21use std::convert::Infallible;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use std::time::{Instant, SystemTime, UNIX_EPOCH};
25use toak_rs::{JsonDatabaseGenerator, JsonDatabaseOptions, SemanticSearch};
26use tokio::sync::RwLock;
27
28const DEFAULT_PAGE_SIZE: usize = 10;
29const MAX_PAGE_SIZE: usize = 25;
30const MAX_TOTAL_RESULTS: usize = 100;
31
32#[derive(Clone)]
34pub struct AppState {
35 pub persistence: Persistence,
36 pub agent_registry: Arc<AgentRegistry>,
37 pub tool_registry: Arc<ToolRegistry>,
38 pub config: AppConfig,
39 pub start_time: Instant,
40 pub mesh_registry: MeshRegistry,
41 pub auth_service: Arc<AuthService>,
42}
43
44impl AppState {
45 pub fn new(
46 persistence: Persistence,
47 agent_registry: Arc<AgentRegistry>,
48 tool_registry: Arc<ToolRegistry>,
49 config: AppConfig,
50 ) -> Self {
51 let auth_service = AuthService::new(
53 config.auth.credentials_file.as_deref(),
54 config.auth.token_secret.as_deref(),
55 Some(config.auth.token_expiry_secs),
56 config.auth.enabled,
57 )
58 .unwrap_or_else(|e| {
59 tracing::warn!("Failed to initialize auth service: {}. Auth disabled.", e);
60 AuthService::disabled()
61 });
62
63 Self {
64 persistence: persistence.clone(),
65 agent_registry,
66 tool_registry,
67 config,
68 start_time: Instant::now(),
69 mesh_registry: MeshRegistry::with_persistence(persistence),
70 auth_service: Arc::new(auth_service),
71 }
72 }
73}
74
75impl MeshState for AppState {
76 fn mesh_registry(&self) -> &MeshRegistry {
77 &self.mesh_registry
78 }
79}
80
81pub async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
83 let uptime = state.start_time.elapsed().as_secs();
84 let active_sessions = match state.persistence.list_sessions() {
85 Ok(sessions) => sessions.len(),
86 Err(e) => {
87 tracing::warn!("Failed to count active sessions: {}", e);
88 0
89 }
90 };
91
92 let response = HealthResponse {
93 status: "healthy".to_string(),
94 version: env!("CARGO_PKG_VERSION").to_string(),
95 uptime_seconds: uptime,
96 active_sessions,
97 };
98
99 Json(response)
100}
101
102pub async fn list_agents(State(state): State<AppState>) -> impl IntoResponse {
104 let agent_names = state.agent_registry.list();
105 let mut agent_infos = Vec::new();
106
107 for name in agent_names {
108 if let Some(profile) = state.agent_registry.get(&name) {
109 agent_infos.push(AgentInfo {
110 id: name,
111 description: profile.prompt.unwrap_or_default(),
112 allowed_tools: profile.allowed_tools.unwrap_or_default(),
113 denied_tools: profile.denied_tools.unwrap_or_default(),
114 });
115 }
116 }
117
118 Json(AgentListResponse {
119 agents: agent_infos,
120 })
121 .into_response()
122}
123
124pub async fn query(State(state): State<AppState>, Json(request): Json<QueryRequest>) -> Response {
126 if request.stream {
128 return (
129 StatusCode::BAD_REQUEST,
130 Json(ErrorResponse::new(
131 "invalid_request",
132 "Streaming not supported on /query endpoint. Use /stream instead.",
133 )),
134 )
135 .into_response();
136 }
137
138 let safety_override = request.safety.clone();
140 let max_tokens_override = request.max_tokens;
141 let agent_name = request.agent.unwrap_or_else(|| "default".to_string());
142
143 let session_id = request
145 .session_id
146 .unwrap_or_else(|| format!("api_{}", uuid_v4()));
147
148 let agent_result = create_agent(
150 &state,
151 &agent_name,
152 &session_id,
153 request.temperature,
154 max_tokens_override,
155 safety_override,
156 )
157 .await;
158
159 let mut agent = match agent_result {
160 Ok(agent) => agent,
161 Err(e) => {
162 return (
163 StatusCode::BAD_REQUEST,
164 Json(ErrorResponse::new("agent_error", e.to_string())),
165 )
166 .into_response();
167 }
168 };
169
170 let start = Instant::now();
172
173 match agent.run_step(&request.message).await {
174 Ok(output) => {
175 let processing_time = start.elapsed().as_millis() as u64;
176 let tool_calls: Vec<ToolCallInfo> = output
177 .tool_invocations
178 .iter()
179 .map(|inv| ToolCallInfo {
180 name: inv.name.clone(),
181 arguments: inv.arguments.clone(),
182 success: inv.success,
183 output: inv.output.clone(),
184 error: inv.error.clone(),
185 })
186 .collect();
187
188 let response = QueryResponse {
189 response: output.response,
190 session_id,
191 agent: agent_name,
192 tool_calls,
193 token_usage: output.token_usage.clone(),
194 metadata: ResponseMetadata {
195 timestamp: current_timestamp(),
196 model: state.config.model.provider.clone(),
197 processing_time_ms: processing_time,
198 token_usage: output.token_usage.clone(),
199 run_id: output.run_id,
200 safety: output.safety.clone(),
201 },
202 };
203
204 Json(response).into_response()
205 }
206 Err(e) => (
207 StatusCode::INTERNAL_SERVER_ERROR,
208 Json(ErrorResponse::new("execution_error", e.to_string())),
209 )
210 .into_response(),
211 }
212}
213
214pub async fn stream_query(
216 State(state): State<AppState>,
217 Json(request): Json<QueryRequest>,
218) -> Response {
219 let safety_override = request.safety.clone();
220 let max_tokens_override = request.max_tokens;
221 let agent_name = request.agent.unwrap_or_else(|| "default".to_string());
222 let session_id = request
223 .session_id
224 .unwrap_or_else(|| format!("api_{}", uuid_v4()));
225
226 let agent_result = create_agent(
228 &state,
229 &agent_name,
230 &session_id,
231 request.temperature,
232 max_tokens_override,
233 safety_override,
234 )
235 .await;
236
237 let agent = match agent_result {
238 Ok(agent) => agent,
239 Err(e) => {
240 return (
241 StatusCode::BAD_REQUEST,
242 Json(ErrorResponse::new("agent_error", e.to_string())),
243 )
244 .into_response();
245 }
246 };
247
248 let agent = Arc::new(RwLock::new(agent));
250 let message = request.message.clone();
251 let session_id_clone = session_id.clone();
252 let agent_name_clone = agent_name.clone();
253 let model_id = state.config.model.provider.clone();
254
255 let sse_stream = stream! {
256 yield StreamChunk::Start {
257 session_id: session_id_clone.clone(),
258 agent: agent_name_clone.clone(),
259 };
260
261 let start = Instant::now();
262 let mut agent_lock = agent.write().await;
263
264 match agent_lock.run_step(&message).await {
265 Ok(output) => {
266 yield StreamChunk::Content { text: output.response.clone() };
267
268 for invocation in output.tool_invocations {
269 yield StreamChunk::ToolCall {
270 name: invocation.name.clone(),
271 arguments: invocation.arguments.clone(),
272 };
273 yield StreamChunk::ToolResult {
274 name: invocation.name.clone(),
275 result: json!({
276 "success": invocation.success,
277 "output": invocation.output,
278 "error": invocation.error,
279 }),
280 };
281 }
282
283 yield StreamChunk::End {
284 metadata: ResponseMetadata {
285 timestamp: current_timestamp(),
286 model: model_id.clone(),
287 processing_time_ms: start.elapsed().as_millis() as u64,
288 token_usage: output.token_usage.clone(),
289 run_id: output.run_id,
290 safety: output.safety.clone(),
291 },
292 };
293 }
294 Err(e) => {
295 yield StreamChunk::Error {
296 message: e.to_string(),
297 };
298 }
299 }
300 };
301
302 Sse::new(sse_stream.map(|chunk| {
303 let json = serde_json::to_string(&chunk).unwrap();
304 Ok::<_, Infallible>(Event::default().data(json))
305 }))
306 .into_response()
307}
308
309async fn create_agent(
311 state: &AppState,
312 agent_name: &str,
313 session_id: &str,
314 _temperature: Option<f32>,
315 max_tokens: Option<usize>,
316 safety_override: Option<SafetyConfig>,
317) -> anyhow::Result<AgentCore> {
318 let profile = state
320 .agent_registry
321 .get(agent_name)
322 .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", agent_name))?;
323
324 let mut agent = AgentBuilder::new()
326 .with_profile(profile)
327 .with_config(state.config.clone())
328 .with_session_id(session_id)
329 .with_agent_name(agent_name.to_string())
330 .with_tool_registry(state.tool_registry.clone())
331 .with_persistence(state.persistence.clone())
332 .build()?;
333
334 let mut safety_config = state.config.safety.clone();
335 if let Some(override_config) = safety_override {
336 safety_config = override_config;
337 } else if let Some(max_tokens) = max_tokens {
338 safety_config.max_output_tokens_per_call = max_tokens.min(u32::MAX as usize) as u32;
339 }
340 safety_config.validate()?;
341 agent.set_safety_config(safety_config);
342
343 Ok(agent)
344}
345
346fn uuid_v4() -> String {
348 let rng = std::collections::hash_map::RandomState::new();
349 let hash = std::hash::BuildHasher::hash_one(&rng, SystemTime::now());
350 format!("{:x}", hash)
351}
352
353fn current_timestamp() -> String {
355 let now = SystemTime::now()
356 .duration_since(UNIX_EPOCH)
357 .unwrap()
358 .as_secs();
359 chrono::DateTime::from_timestamp(now as i64, 0)
360 .unwrap()
361 .to_rfc3339()
362}
363
364pub async fn generate_token(
366 State(state): State<AppState>,
367 Json(request): Json<TokenRequest>,
368) -> Response {
369 if !state.auth_service.is_enabled() {
371 return (
372 StatusCode::SERVICE_UNAVAILABLE,
373 Json(ErrorResponse::new(
374 "auth_disabled",
375 "Authentication is not enabled on this server",
376 )),
377 )
378 .into_response();
379 }
380
381 if !state
383 .auth_service
384 .verify_password(&request.username, &request.password)
385 {
386 return (
387 StatusCode::UNAUTHORIZED,
388 Json(ErrorResponse::new(
389 "invalid_credentials",
390 "Invalid username or password",
391 )),
392 )
393 .into_response();
394 }
395
396 match state.auth_service.generate_token(&request.username) {
398 Ok(token) => {
399 let response = TokenResponse {
400 token,
401 token_type: "Bearer".to_string(),
402 expires_in: state.config.auth.token_expiry_secs,
403 };
404 Json(response).into_response()
405 }
406 Err(e) => {
407 tracing::error!("Failed to generate token: {}", e);
408 (
409 StatusCode::INTERNAL_SERVER_ERROR,
410 Json(ErrorResponse::new(
411 "token_error",
412 "Failed to generate token",
413 )),
414 )
415 .into_response()
416 }
417 }
418}
419
420pub async fn hash_password(
423 State(state): State<AppState>,
424 Json(body): Json<serde_json::Value>,
425) -> Response {
426 if !state.auth_service.is_enabled() {
428 return (
429 StatusCode::SERVICE_UNAVAILABLE,
430 Json(ErrorResponse::new(
431 "auth_disabled",
432 "Authentication is not enabled on this server",
433 )),
434 )
435 .into_response();
436 }
437
438 let Some(password) = body.get("password").and_then(|v| v.as_str()) else {
439 return (
440 StatusCode::BAD_REQUEST,
441 Json(ErrorResponse::new(
442 "invalid_request",
443 "Missing 'password' field in request body",
444 )),
445 )
446 .into_response();
447 };
448
449 match AuthService::hash_password(password) {
450 Ok(hash) => Json(json!({ "password_hash": hash })).into_response(),
451 Err(e) => {
452 tracing::error!("Failed to hash password: {}", e);
453 (
454 StatusCode::INTERNAL_SERVER_ERROR,
455 Json(ErrorResponse::new("hash_error", "Failed to hash password")),
456 )
457 .into_response()
458 }
459 }
460}
461
462pub async fn search(Json(request): Json<SearchRequest>) -> Response {
464 if request.query.trim().is_empty() {
466 return (
467 StatusCode::BAD_REQUEST,
468 Json(ErrorResponse::new(
469 "invalid_request",
470 "Query cannot be empty",
471 )),
472 )
473 .into_response();
474 }
475
476 let root = request
478 .root
479 .as_ref()
480 .map(PathBuf::from)
481 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
482
483 if !root.exists() {
484 return (
485 StatusCode::BAD_REQUEST,
486 Json(ErrorResponse::new(
487 "invalid_root",
488 format!("Search root {} does not exist", root.display()),
489 )),
490 )
491 .into_response();
492 }
493
494 let page_size = request
496 .page_size
497 .unwrap_or(DEFAULT_PAGE_SIZE)
498 .clamp(1, MAX_PAGE_SIZE);
499 let page = request.page;
500 let offset = page * page_size;
501
502 let embeddings_path = match ensure_embeddings(&root, request.refresh).await {
504 Ok(path) => path,
505 Err(e) => {
506 tracing::error!("Failed to generate embeddings: {}", e);
507 return (
508 StatusCode::INTERNAL_SERVER_ERROR,
509 Json(ErrorResponse::new(
510 "embeddings_error",
511 format!("Failed to generate embeddings: {}", e),
512 )),
513 )
514 .into_response();
515 }
516 };
517
518 let mut searcher = match SemanticSearch::new(&embeddings_path) {
520 Ok(s) => s,
521 Err(e) => {
522 tracing::error!("Failed to load embeddings: {}", e);
523 return (
524 StatusCode::INTERNAL_SERVER_ERROR,
525 Json(ErrorResponse::new(
526 "search_error",
527 format!("Failed to load embeddings database: {}", e),
528 )),
529 )
530 .into_response();
531 }
532 };
533
534 let fetch_count = MAX_TOTAL_RESULTS.min(offset + page_size);
536 let hits = match searcher.search(&request.query, fetch_count) {
537 Ok(h) => h,
538 Err(e) => {
539 tracing::error!("Search failed: {}", e);
540 return (
541 StatusCode::INTERNAL_SERVER_ERROR,
542 Json(ErrorResponse::new(
543 "search_error",
544 format!("Search failed: {}", e),
545 )),
546 )
547 .into_response();
548 }
549 };
550
551 let total_results = hits.len();
552 let total_pages = total_results.div_ceil(page_size);
553
554 let page_results: Vec<SearchResult> = hits
556 .into_iter()
557 .skip(offset)
558 .take(page_size)
559 .map(|hit| {
560 let mut snippet = hit.content;
561 if snippet.len() > 480 {
562 snippet.truncate(480);
563 snippet.push_str("...[truncated]");
564 }
565 SearchResult {
566 path: hit.file_path,
567 similarity: hit.similarity,
568 snippet,
569 }
570 })
571 .collect();
572
573 let response = SearchResponse {
574 query: request.query,
575 root: root.display().to_string(),
576 page,
577 page_size,
578 total_results,
579 total_pages,
580 results: page_results,
581 };
582
583 Json(response).into_response()
584}
585
586async fn ensure_embeddings(root: &Path, refresh: bool) -> anyhow::Result<PathBuf> {
588 let embeddings_path = root.join(".spec-ai").join("code_search_embeddings.json");
589
590 if embeddings_path.exists() && !refresh {
591 return Ok(embeddings_path);
592 }
593
594 if let Some(parent) = embeddings_path.parent() {
595 std::fs::create_dir_all(parent)?;
596 }
597
598 let options = JsonDatabaseOptions {
599 dir: root.to_path_buf(),
600 output_file_path: embeddings_path.clone(),
601 verbose: false,
602 chunker_config: Default::default(),
603 max_concurrent_files: 4,
604 ..Default::default()
605 };
606
607 let generator = JsonDatabaseGenerator::new(options)?;
608 generator.generate_database().await?;
609
610 Ok(embeddings_path)
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 #[test]
618 fn test_uuid_generation() {
619 let uuid1 = uuid_v4();
620 let uuid2 = uuid_v4();
621
622 assert!(!uuid1.is_empty());
623 assert!(!uuid2.is_empty());
624 }
627
628 #[test]
629 fn test_timestamp_format() {
630 let ts = current_timestamp();
631 assert!(ts.contains('T'));
632 assert!(ts.contains('Z') || ts.contains('+'));
633 }
634}