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