Skip to main content

kcode_kennedy_session_services/
lib.rs

1//! Typed access to Kennedy's in-process session capabilities.
2
3#![forbid(unsafe_code)]
4
5use std::sync::Arc;
6
7use kcode_kennedy_session_kmap_services::Service as KmapService;
8use kcode_kweb_db::{Node, Provenance};
9use kcode_kweb_manager::KwebManager;
10use kcode_server_object_envelopes::StoredFile;
11use serde_json::Value;
12use uuid::Uuid;
13
14#[derive(Clone)]
15pub struct LocalServices {
16    pub load_fixed_connections: bool,
17    pub kmap: KwebManager,
18    pub intelligence: kcode_intelligence_router::Intelligence,
19    pub history: kcode_session_history::SessionHistory,
20    pub speech_classifier: Arc<kcode_speaker_system::SpeechClassifier>,
21    pub dev_tools: kcode_dev_tools::Service,
22    pub agents: kcode_agent_runtime::AgentRuntime,
23    pub telegram: kcode_telegram_session_coordinator::Service,
24}
25
26#[derive(Debug, Clone)]
27pub struct ApiError {
28    message: String,
29    pub receipt: Option<Box<kcode_intelligence_router::UsageReceipt>>,
30}
31
32impl std::fmt::Display for ApiError {
33    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        formatter.write_str(&self.message)
35    }
36}
37
38impl std::error::Error for ApiError {}
39
40#[derive(Clone)]
41pub struct Api {
42    services: Arc<LocalServices>,
43    kmap: KmapService,
44    task_board: Option<kcode_task_board::TaskBoard>,
45}
46
47impl Api {
48    pub fn new(services: LocalServices) -> Self {
49        let kmap = KmapService::new(services.kmap.clone());
50        Self {
51            services: Arc::new(services),
52            kmap,
53            task_board: None,
54        }
55    }
56
57    pub fn with_task_board(mut self, task_board: kcode_task_board::TaskBoard) -> Self {
58        self.task_board = Some(task_board);
59        self
60    }
61
62    pub fn kmap(&self) -> &KwebManager {
63        self.kmap.manager()
64    }
65
66    pub fn loads_fixed_connections(&self) -> bool {
67        self.services.load_fixed_connections
68    }
69
70    pub fn telegram(&self) -> &kcode_telegram_session_coordinator::Service {
71        &self.services.telegram
72    }
73
74    pub fn task_board(&self) -> Option<&kcode_task_board::TaskBoard> {
75        self.task_board.as_ref()
76    }
77
78    pub fn create_history_session(
79        &self,
80        input: kcode_session_history::NewSession,
81    ) -> anyhow::Result<kcode_session_history::Session> {
82        self.services.history.create_session(input)
83    }
84
85    pub fn history_session(
86        &self,
87        metadata: kcode_session_history::chatend::SessionMetadata,
88        provider_model: &str,
89    ) -> anyhow::Result<kcode_session_history::Session> {
90        self.services
91            .history
92            .open_session_with_provider_model(metadata, Some(provider_model))
93    }
94
95    pub async fn launch_session(
96        &self,
97        input: kcode_session_history::LaunchSession,
98    ) -> Result<kcode_session_history::SessionLaunch, kcode_session_history::Error> {
99        self.services.history.launch_session(input).await
100    }
101
102    pub fn kmap_node(&self, node_id: &str) -> Result<Node, ApiError> {
103        self.kmap.node(node_id).map_err(kmap_service_error)
104    }
105
106    pub fn commit_kweb_session(
107        &self,
108        input: kcode_commit_session::CommitRequest,
109    ) -> Result<kcode_commit_session::CommitReceipt, ApiError> {
110        self.kmap.commit_session(input).map_err(kmap_service_error)
111    }
112
113    pub fn kmap_file(&self, object_id: &str) -> Result<StoredFile, ApiError> {
114        self.kmap.file(object_id).map_err(kmap_service_error)
115    }
116
117    pub fn save_generated_image(
118        &self,
119        bytes: Vec<u8>,
120        file_name: &str,
121        media_type: &str,
122        model: &str,
123    ) -> Result<String, ApiError> {
124        self.kmap
125            .save_generated_image(bytes, file_name, media_type, model)
126            .map_err(kmap_service_error)
127    }
128
129    pub fn agent_runtime(&self) -> kcode_agent_runtime::AgentRuntime {
130        self.services.agents.clone()
131    }
132
133    pub async fn search(
134        &self,
135        user_id: &str,
136        request: kcode_intelligence_router::SearchRequest,
137    ) -> Result<
138        kcode_intelligence_router::Accounted<kcode_intelligence_router::SearchResponse>,
139        ApiError,
140    > {
141        self.services
142            .intelligence
143            .for_user(user_id)
144            .map_err(intelligence_error)?
145            .search(request)
146            .await
147            .map_err(intelligence_error)
148    }
149
150    pub async fn fetch(
151        &self,
152        user_id: &str,
153        request: kcode_intelligence_router::FetchRequest,
154    ) -> Result<kcode_intelligence_router::FetchResponse, ApiError> {
155        self.services
156            .intelligence
157            .for_user(user_id)
158            .map_err(intelligence_error)?
159            .fetch(request)
160            .await
161            .map_err(intelligence_error)
162    }
163
164    pub async fn managed_source_execute(
165        &self,
166        session_id: &str,
167        name: &str,
168        arguments: Value,
169        objects: Vec<Vec<u8>>,
170    ) -> Result<kcode_dev_tools::ToolExecution, ApiError> {
171        let mut execution = self
172            .services
173            .dev_tools
174            .execute(session_id.to_owned(), name.to_owned(), arguments, objects)
175            .await
176            .map_err(dev_tools_error)?;
177        let mut object_ids = Vec::with_capacity(execution.objects.len());
178        for bytes in std::mem::take(&mut execution.objects) {
179            object_ids.push(
180                self.kmap
181                    .manager()
182                    .store_object(
183                        Provenance {
184                            author: "Kennedy".into(),
185                            source: "kennedy-rust-binary".into(),
186                            source_created_at: chrono::Utc::now(),
187                            data: "Output payload from a managed Rust-binary call.".into(),
188                        },
189                        bytes,
190                    )
191                    .map_err(kmap_error)?
192                    .to_string(),
193            );
194        }
195        append_object_ids(&mut execution.text, &object_ids);
196        Ok(execution)
197    }
198
199    pub async fn execute_speech_classification_tool(
200        &self,
201        name: &str,
202        arguments: Value,
203    ) -> Result<String, ApiError> {
204        let call =
205            kcode_speaker_system::decode_ktool(name, &arguments).map_err(speech_ktool_error)?;
206        let classifier = Arc::clone(&self.services.speech_classifier);
207        tokio::task::spawn_blocking(move || classifier.execute_ktool(call))
208            .await
209            .map_err(speech_task_error)?
210            .map_err(speech_ktool_error)
211    }
212
213    pub async fn release_managed_sources(&self, session_id: &str) {
214        if let Err(error) = self.services.dev_tools.release(session_id.to_owned()).await {
215            tracing::warn!(error=%error.message, "Managed-source session release failed");
216        }
217    }
218
219    #[allow(clippy::too_many_arguments)]
220    pub async fn transcribe_audio(
221        &self,
222        user_id: &str,
223        model: &str,
224        prompt: &str,
225        bytes: Vec<u8>,
226        filename: String,
227        mime: &str,
228        temperature: Option<f32>,
229        parent_operation_id: Uuid,
230    ) -> Result<
231        kcode_intelligence_router::Accounted<kcode_intelligence_router::TranscriptionResponse>,
232        ApiError,
233    > {
234        self.services
235            .intelligence
236            .for_user(user_id)
237            .map_err(intelligence_error)?
238            .transcribe(kcode_intelligence_router::TranscriptionRequest {
239                prompt: prompt.to_owned(),
240                model: model.to_owned(),
241                media: kcode_intelligence_router::Media::audio(bytes, filename, mime)
242                    .map_err(intelligence_error)?,
243                temperature,
244                operation_id: Uuid::new_v4(),
245                parent_operation_id: Some(parent_operation_id),
246            })
247            .await
248            .map_err(intelligence_error)
249    }
250
251    pub async fn extract_document(
252        &self,
253        bytes: Vec<u8>,
254        filename: String,
255        mime: &str,
256    ) -> Result<kcode_intelligence_router::DocumentExtraction, ApiError> {
257        self.services
258            .intelligence
259            .extract_document(kcode_intelligence_router::Document {
260                bytes,
261                file_name: filename,
262                content_type: mime.to_owned(),
263            })
264            .await
265            .map_err(intelligence_error)
266    }
267
268    #[allow(clippy::too_many_arguments)]
269    pub async fn annotate_media(
270        &self,
271        user_id: &str,
272        model: &str,
273        prompt: &str,
274        bytes: Vec<u8>,
275        filename: String,
276        mime: &str,
277        parent_operation_id: Uuid,
278    ) -> Result<
279        kcode_intelligence_router::Accounted<kcode_intelligence_router::AnnotationResponse>,
280        ApiError,
281    > {
282        self.services
283            .intelligence
284            .for_user(user_id)
285            .map_err(intelligence_error)?
286            .annotate(kcode_intelligence_router::AnnotationRequest {
287                prompt: prompt.to_owned(),
288                model: model.to_owned(),
289                media: media_for_annotation(bytes, filename, mime).map_err(intelligence_error)?,
290                operation_id: Uuid::new_v4(),
291                parent_operation_id: Some(parent_operation_id),
292            })
293            .await
294            .map_err(intelligence_error)
295    }
296
297    pub async fn generate_image(
298        &self,
299        user_id: &str,
300        model: &str,
301        prompt: &str,
302        references: Vec<(Vec<u8>, String, String)>,
303        parent_operation_id: Uuid,
304    ) -> Result<
305        kcode_intelligence_router::Accounted<kcode_intelligence_router::ImageResponse>,
306        ApiError,
307    > {
308        let references = references
309            .into_iter()
310            .map(|(bytes, filename, mime)| {
311                media_for_image(bytes, filename, &mime).map_err(intelligence_error)
312            })
313            .collect::<Result<Vec<_>, _>>()?;
314        self.services
315            .intelligence
316            .for_user(user_id)
317            .map_err(intelligence_error)?
318            .generate_image(kcode_intelligence_router::ImageRequest {
319                model: model.to_owned(),
320                prompt: prompt.to_owned(),
321                references,
322                operation_id: Uuid::new_v4(),
323                parent_operation_id: Some(parent_operation_id),
324            })
325            .await
326            .map_err(intelligence_error)
327    }
328}
329
330fn kmap_service_error(error: kcode_kennedy_session_kmap_services::Error) -> ApiError {
331    ApiError {
332        message: error.to_string(),
333        receipt: None,
334    }
335}
336
337fn kmap_error(error: kcode_kweb_manager::Error) -> ApiError {
338    let message = match error.kind() {
339        kcode_kweb_manager::ErrorKind::InvalidInput
340        | kcode_kweb_manager::ErrorKind::NotFound
341        | kcode_kweb_manager::ErrorKind::Conflict => error.to_string(),
342        _ => "An unexpected Kmap database error occurred.".into(),
343    };
344    ApiError {
345        message,
346        receipt: None,
347    }
348}
349
350fn intelligence_error(error: kcode_intelligence_router::Error) -> ApiError {
351    ApiError {
352        message: error.message().into(),
353        receipt: error.receipt().cloned().map(Box::new),
354    }
355}
356
357fn append_object_ids(text: &mut String, object_ids: &[String]) {
358    if object_ids.is_empty() {
359        return;
360    }
361    if !text.is_empty() && !text.ends_with('\n') {
362        text.push('\n');
363    }
364    text.push_str(&object_ids.join("\n"));
365}
366
367fn dev_tools_error(error: kcode_dev_tools::ToolError) -> ApiError {
368    ApiError {
369        message: error.message,
370        receipt: None,
371    }
372}
373
374fn speech_task_error(error: tokio::task::JoinError) -> ApiError {
375    tracing::error!(%error, "In-process speaker-classification task stopped unexpectedly");
376    ApiError {
377        message: "An unexpected Kennedy speaker-classification error occurred.".into(),
378        receipt: None,
379    }
380}
381
382fn speech_ktool_error(error: kcode_speaker_system::KtoolError) -> ApiError {
383    let kcode_speaker_system::KtoolError::Classifier(error) = error else {
384        return ApiError {
385            message: error.to_string(),
386            receipt: None,
387        };
388    };
389    let internal = matches!(
390        error,
391        kcode_speaker_system::Error::Storage(_)
392            | kcode_speaker_system::Error::CorruptStorage(_)
393            | kcode_speaker_system::Error::Model(_)
394    );
395    ApiError {
396        message: if internal {
397            tracing::error!(%error, "Speaker-classification storage failed");
398            "An unexpected Kennedy speaker-classification error occurred.".into()
399        } else {
400            error.to_string()
401        },
402        receipt: None,
403    }
404}
405
406fn media_for_annotation(
407    bytes: Vec<u8>,
408    filename: String,
409    mime: &str,
410) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
411    let normalized = mime
412        .split(';')
413        .next()
414        .unwrap_or("application/octet-stream")
415        .trim()
416        .to_ascii_lowercase();
417    let kind = if normalized.starts_with("image/") {
418        kcode_intelligence_router::MediaKind::Image
419    } else if normalized.starts_with("audio/")
420        || matches!(normalized.as_str(), "application/ogg" | "video/ogg")
421        || filename.rsplit_once('.').is_some_and(|(_, extension)| {
422            matches!(
423                extension.to_ascii_lowercase().as_str(),
424                "ogg" | "oga" | "opus"
425            )
426        })
427    {
428        kcode_intelligence_router::MediaKind::Audio
429    } else if normalized.starts_with("video/") {
430        kcode_intelligence_router::MediaKind::Video
431    } else {
432        return Err(kcode_intelligence_router::Error::invalid(
433            "annotation requires image, audio, or video media",
434        ));
435    };
436    kcode_intelligence_router::Media::new(kind, bytes, filename, normalized)
437}
438
439fn media_for_image(
440    bytes: Vec<u8>,
441    filename: String,
442    mime: &str,
443) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
444    let normalized = mime
445        .split(';')
446        .next()
447        .unwrap_or("application/octet-stream")
448        .trim()
449        .to_ascii_lowercase();
450    if !normalized.starts_with("image/") {
451        return Err(kcode_intelligence_router::Error::invalid(
452            "image references must use an image content type",
453        ));
454    }
455    kcode_intelligence_router::Media::new(
456        kcode_intelligence_router::MediaKind::Image,
457        bytes,
458        filename,
459        normalized,
460    )
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn speech_ktool_errors_keep_the_existing_public_failure_boundary() {
469        let malformed = speech_ktool_error(kcode_speaker_system::KtoolError::InvalidArguments {
470            tool: kcode_speaker_system::IDENTIFY_TOOL,
471            source: serde_json::from_str::<Value>("{").unwrap_err(),
472        });
473        assert_eq!(
474            malformed.message,
475            "decoding kcode-speaker-system/identify arguments"
476        );
477
478        let validation = speech_ktool_error(kcode_speaker_system::KtoolError::Classifier(
479            kcode_speaker_system::Error::Validation {
480                field: "speaker_id".into(),
481                message: "must not be empty".into(),
482            },
483        ));
484        assert_eq!(validation.message, "speaker_id: must not be empty");
485
486        let storage = speech_ktool_error(kcode_speaker_system::KtoolError::Classifier(
487            kcode_speaker_system::Error::Storage("private detail".into()),
488        ));
489        assert_eq!(
490            storage.message,
491            "An unexpected Kennedy speaker-classification error occurred."
492        );
493    }
494}