Skip to main content

va_ai_api_bridge/
service_side.rs

1//! Host-registered service-side capabilities.
2//!
3//! The registry owns orchestration over [`UniversalRequest`] and
4//! [`UniversalResponse`]. Embedding hosts still own every external effect:
5//! HTTP, credentials, caching, persistence, retries, and sandbox policy.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use async_trait::async_trait;
14
15use crate::{
16    ContentBlock, Extensions, UniversalItem, UniversalRequest, UniversalResponse, UniversalTool,
17};
18
19pub type ServiceSideResult<T> = std::result::Result<T, ServiceSideError>;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum ServiceSideError {
23    InvalidRegistration { message: String },
24    InvalidInput { capability: String, message: String },
25    Execution { capability: String, message: String },
26}
27
28impl ServiceSideError {
29    pub fn invalid_input(capability: impl Into<String>, message: impl Into<String>) -> Self {
30        Self::InvalidInput {
31            capability: capability.into(),
32            message: message.into(),
33        }
34    }
35
36    pub fn execution(capability: impl Into<String>, message: impl Into<String>) -> Self {
37        Self::Execution {
38            capability: capability.into(),
39            message: message.into(),
40        }
41    }
42
43    fn invalid_registration(message: impl Into<String>) -> Self {
44        Self::InvalidRegistration {
45            message: message.into(),
46        }
47    }
48}
49
50impl fmt::Display for ServiceSideError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::InvalidRegistration { message } => {
54                write!(f, "invalid service-side capability registration: {message}")
55            }
56            Self::InvalidInput {
57                capability,
58                message,
59            } => write!(
60                f,
61                "service-side capability '{capability}' rejected input: {message}"
62            ),
63            Self::Execution {
64                capability,
65                message,
66            } => write!(
67                f,
68                "service-side capability '{capability}' failed: {message}"
69            ),
70        }
71    }
72}
73
74impl std::error::Error for ServiceSideError {}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
77pub enum ServiceSideInputKind {
78    Image,
79    File,
80}
81
82#[derive(Debug, Clone, PartialEq)]
83pub struct ServiceSideInput {
84    pub kind: ServiceSideInputKind,
85    pub media_type: Option<String>,
86    pub filename: Option<String>,
87    pub url: Option<String>,
88    pub data: Option<String>,
89    /// Text blocks adjacent to this input in the same message or tool result.
90    pub context: String,
91    pub extensions: Extensions,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ServiceSideInputResolution {
96    /// Replacement text inserted at the original input position.
97    pub text: String,
98}
99
100#[async_trait]
101pub trait ServiceSideInputResolver: Send + Sync {
102    fn id(&self) -> &str;
103    fn input_kind(&self) -> ServiceSideInputKind;
104    async fn resolve(
105        &self,
106        input: ServiceSideInput,
107    ) -> ServiceSideResult<ServiceSideInputResolution>;
108}
109
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct ServiceSideResolutionReport {
112    pub images_resolved: usize,
113    pub files_resolved: usize,
114}
115
116impl ServiceSideResolutionReport {
117    pub fn changed(&self) -> bool {
118        self.images_resolved > 0 || self.files_resolved > 0
119    }
120
121    fn record(&mut self, kind: ServiceSideInputKind) {
122        match kind {
123            ServiceSideInputKind::Image => self.images_resolved += 1,
124            ServiceSideInputKind::File => self.files_resolved += 1,
125        }
126    }
127}
128
129#[derive(Debug, Clone, PartialEq)]
130pub struct ServiceSideToolCall {
131    pub id: String,
132    pub name: String,
133    pub arguments: serde_json::Value,
134}
135
136#[derive(Debug, Clone, PartialEq)]
137pub struct ServiceSideToolOutput {
138    pub content: Vec<ContentBlock>,
139    pub is_error: bool,
140}
141
142#[async_trait]
143pub trait ServiceSideTool: Send + Sync {
144    fn definition(&self) -> UniversalTool;
145    async fn call(&self, call: ServiceSideToolCall) -> ServiceSideResult<ServiceSideToolOutput>;
146}
147
148#[derive(Debug, Clone, Default, PartialEq)]
149pub struct ServiceSideToolExecution {
150    pub results: Vec<UniversalItem>,
151    pub unhandled_calls: Vec<ServiceSideToolCall>,
152}
153
154impl ServiceSideToolExecution {
155    pub fn handled_count(&self) -> usize {
156        self.results.len()
157    }
158
159    pub fn append_results_to(self, request: &mut UniversalRequest) {
160        request.input.extend(self.results);
161    }
162}
163
164struct RegisteredTool {
165    definition: UniversalTool,
166    handler: Arc<dyn ServiceSideTool>,
167}
168
169#[derive(Default)]
170pub struct ServiceSideCapabilityRegistry {
171    input_resolvers: BTreeMap<ServiceSideInputKind, Arc<dyn ServiceSideInputResolver>>,
172    tools: BTreeMap<String, RegisteredTool>,
173}
174
175impl ServiceSideCapabilityRegistry {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    pub fn register_input_resolver<R>(&mut self, resolver: R) -> ServiceSideResult<()>
181    where
182        R: ServiceSideInputResolver + 'static,
183    {
184        let id = resolver.id().trim();
185        if id.is_empty() {
186            return Err(ServiceSideError::invalid_registration(
187                "input resolver id cannot be empty",
188            ));
189        }
190        let kind = resolver.input_kind();
191        if let Some(existing) = self.input_resolvers.get(&kind) {
192            return Err(ServiceSideError::invalid_registration(format!(
193                "input kind {kind:?} is already owned by resolver '{}'",
194                existing.id()
195            )));
196        }
197        self.input_resolvers.insert(kind, Arc::new(resolver));
198        Ok(())
199    }
200
201    pub fn register_tool<T>(&mut self, tool: T) -> ServiceSideResult<()>
202    where
203        T: ServiceSideTool + 'static,
204    {
205        let definition = tool.definition();
206        let name = definition.name.trim();
207        if name.is_empty() {
208            return Err(ServiceSideError::invalid_registration(
209                "host tool name cannot be empty",
210            ));
211        }
212        if self.tools.contains_key(name) {
213            return Err(ServiceSideError::invalid_registration(format!(
214                "host tool '{name}' is already registered"
215            )));
216        }
217        self.tools.insert(
218            name.to_string(),
219            RegisteredTool {
220                definition,
221                handler: Arc::new(tool),
222            },
223        );
224        Ok(())
225    }
226
227    /// Inject every registered host tool into the request, preserving any
228    /// client declaration with the same name as the request owner.
229    pub fn inject_tools(&self, request: &mut UniversalRequest) -> usize {
230        let existing = request
231            .tools
232            .iter()
233            .map(|tool| tool.name.as_str())
234            .collect::<BTreeSet<_>>();
235        let additions = self
236            .tools
237            .values()
238            .filter(|tool| !existing.contains(tool.definition.name.as_str()))
239            .map(|tool| tool.definition.clone())
240            .collect::<Vec<_>>();
241        let count = additions.len();
242        request.tools.extend(additions);
243        count
244    }
245
246    pub async fn resolve_inputs(
247        &self,
248        request: &mut UniversalRequest,
249    ) -> ServiceSideResult<ServiceSideResolutionReport> {
250        let mut report = ServiceSideResolutionReport::default();
251        self.resolve_blocks(&mut request.instructions, &mut report)
252            .await?;
253        for item in &mut request.input {
254            match item {
255                UniversalItem::Message { content, .. }
256                | UniversalItem::ToolResult { content, .. } => {
257                    self.resolve_blocks(content, &mut report).await?;
258                }
259                _ => {}
260            }
261        }
262        Ok(report)
263    }
264
265    pub async fn execute_tool_calls(
266        &self,
267        response: &UniversalResponse,
268    ) -> ServiceSideResult<ServiceSideToolExecution> {
269        let mut execution = ServiceSideToolExecution::default();
270        for call in collect_tool_calls(response) {
271            let Some(tool) = self.tools.get(&call.name) else {
272                execution.unhandled_calls.push(call);
273                continue;
274            };
275            let output = tool.handler.call(call.clone()).await?;
276            execution.results.push(UniversalItem::ToolResult {
277                tool_call_id: call.id,
278                content: output.content,
279                is_error: output.is_error,
280                extensions: Extensions::default(),
281            });
282        }
283        Ok(execution)
284    }
285
286    fn resolve_blocks<'a>(
287        &'a self,
288        blocks: &'a mut [ContentBlock],
289        report: &'a mut ServiceSideResolutionReport,
290    ) -> Pin<Box<dyn Future<Output = ServiceSideResult<()>> + Send + 'a>> {
291        Box::pin(async move {
292            let context = adjacent_text(blocks);
293            for block in blocks {
294                let input = match block {
295                    ContentBlock::Image {
296                        media_type,
297                        url,
298                        data,
299                        extensions,
300                    } => Some(ServiceSideInput {
301                        kind: ServiceSideInputKind::Image,
302                        media_type: media_type.clone(),
303                        filename: None,
304                        url: url.clone(),
305                        data: data.clone(),
306                        context: context.clone(),
307                        extensions: extensions.clone(),
308                    }),
309                    ContentBlock::File {
310                        media_type,
311                        filename,
312                        url,
313                        data,
314                        extensions,
315                    } => Some(ServiceSideInput {
316                        kind: ServiceSideInputKind::File,
317                        media_type: media_type.clone(),
318                        filename: filename.clone(),
319                        url: url.clone(),
320                        data: data.clone(),
321                        context: context.clone(),
322                        extensions: extensions.clone(),
323                    }),
324                    ContentBlock::ToolResult { content, .. } => {
325                        self.resolve_blocks(content, report).await?;
326                        None
327                    }
328                    _ => None,
329                };
330                let Some(input) = input else {
331                    continue;
332                };
333                let Some(resolver) = self.input_resolvers.get(&input.kind) else {
334                    continue;
335                };
336                let kind = input.kind;
337                let resolution = resolver.resolve(input).await?;
338                if resolution.text.trim().is_empty() {
339                    return Err(ServiceSideError::Execution {
340                        capability: resolver.id().to_string(),
341                        message: "input resolver returned empty replacement text".to_string(),
342                    });
343                }
344                *block = ContentBlock::Text {
345                    text: resolution.text,
346                };
347                report.record(kind);
348            }
349            Ok(())
350        })
351    }
352}
353
354pub fn request_contains_service_side_input(
355    request: &UniversalRequest,
356    kind: ServiceSideInputKind,
357) -> bool {
358    blocks_contain_input(&request.instructions, kind)
359        || request.input.iter().any(|item| match item {
360            UniversalItem::Message { content, .. } | UniversalItem::ToolResult { content, .. } => {
361                blocks_contain_input(content, kind)
362            }
363            _ => false,
364        })
365}
366
367fn blocks_contain_input(blocks: &[ContentBlock], kind: ServiceSideInputKind) -> bool {
368    blocks.iter().any(|block| match block {
369        ContentBlock::Image { .. } => kind == ServiceSideInputKind::Image,
370        ContentBlock::File { .. } => kind == ServiceSideInputKind::File,
371        ContentBlock::ToolResult { content, .. } => blocks_contain_input(content, kind),
372        _ => false,
373    })
374}
375
376fn adjacent_text(blocks: &[ContentBlock]) -> String {
377    blocks
378        .iter()
379        .filter_map(|block| match block {
380            ContentBlock::Text { text } => Some(text.as_str()),
381            _ => None,
382        })
383        .collect::<Vec<_>>()
384        .join("\n")
385}
386
387fn collect_tool_calls(response: &UniversalResponse) -> Vec<ServiceSideToolCall> {
388    let mut calls = Vec::new();
389    let mut ids = BTreeSet::new();
390    for item in &response.output {
391        match item {
392            UniversalItem::ToolCall {
393                id,
394                name,
395                arguments,
396                ..
397            } => push_tool_call(&mut calls, &mut ids, id, name, arguments),
398            UniversalItem::Message { content, .. } | UniversalItem::ToolResult { content, .. } => {
399                collect_block_tool_calls(content, &mut calls, &mut ids)
400            }
401            _ => {}
402        }
403    }
404    calls
405}
406
407fn collect_block_tool_calls(
408    blocks: &[ContentBlock],
409    calls: &mut Vec<ServiceSideToolCall>,
410    ids: &mut BTreeSet<String>,
411) {
412    for block in blocks {
413        match block {
414            ContentBlock::ToolCall {
415                id,
416                name,
417                arguments,
418                ..
419            } => push_tool_call(calls, ids, id, name, arguments),
420            ContentBlock::ToolResult { content, .. } => {
421                collect_block_tool_calls(content, calls, ids)
422            }
423            _ => {}
424        }
425    }
426}
427
428fn push_tool_call(
429    calls: &mut Vec<ServiceSideToolCall>,
430    ids: &mut BTreeSet<String>,
431    id: &str,
432    name: &str,
433    arguments: &serde_json::Value,
434) {
435    if ids.insert(id.to_string()) {
436        calls.push(ServiceSideToolCall {
437            id: id.to_string(),
438            name: name.to_string(),
439            arguments: arguments.clone(),
440        });
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use std::sync::atomic::{AtomicUsize, Ordering};
447
448    use serde_json::json;
449
450    use super::*;
451    use crate::Role;
452
453    struct ImageResolver;
454
455    #[async_trait]
456    impl ServiceSideInputResolver for ImageResolver {
457        fn id(&self) -> &str {
458            "test_image_resolver"
459        }
460
461        fn input_kind(&self) -> ServiceSideInputKind {
462            ServiceSideInputKind::Image
463        }
464
465        async fn resolve(
466            &self,
467            input: ServiceSideInput,
468        ) -> ServiceSideResult<ServiceSideInputResolution> {
469            assert_eq!(input.context, "What is visible?");
470            assert_eq!(input.data.as_deref(), Some("aW1hZ2U="));
471            Ok(ServiceSideInputResolution {
472                text: "A terminal window.".to_string(),
473            })
474        }
475    }
476
477    #[test]
478    fn rejects_duplicate_input_resolvers_for_one_kind() {
479        let mut registry = ServiceSideCapabilityRegistry::new();
480        registry
481            .register_input_resolver(ImageResolver)
482            .expect("first resolver registers");
483
484        let error = registry
485            .register_input_resolver(ImageResolver)
486            .expect_err("second image resolver is ambiguous");
487
488        assert!(error.to_string().contains("already owned"));
489    }
490
491    #[test]
492    fn registered_resolver_replaces_media_in_place() {
493        let mut registry = ServiceSideCapabilityRegistry::new();
494        registry
495            .register_input_resolver(ImageResolver)
496            .expect("resolver registers");
497        let mut request = UniversalRequest {
498            input: vec![UniversalItem::Message {
499                role: Role::User,
500                id: None,
501                content: vec![
502                    ContentBlock::Text {
503                        text: "What is visible?".to_string(),
504                    },
505                    ContentBlock::Image {
506                        media_type: Some("image/png".to_string()),
507                        url: None,
508                        data: Some("aW1hZ2U=".to_string()),
509                        extensions: Extensions::default(),
510                    },
511                ],
512                extensions: Extensions::default(),
513            }],
514            ..UniversalRequest::default()
515        };
516
517        let report = futures_lite::future::block_on(registry.resolve_inputs(&mut request))
518            .expect("image resolves");
519
520        assert_eq!(report.images_resolved, 1);
521        let UniversalItem::Message { content, .. } = &request.input[0] else {
522            panic!("message remains a message");
523        };
524        assert_eq!(
525            content[1],
526            ContentBlock::Text {
527                text: "A terminal window.".to_string()
528            }
529        );
530    }
531
532    #[test]
533    fn detects_registered_input_kind_inside_nested_tool_result() {
534        let request = UniversalRequest {
535            input: vec![UniversalItem::ToolResult {
536                tool_call_id: "call-image".to_string(),
537                content: vec![ContentBlock::ToolResult {
538                    tool_call_id: "nested-image".to_string(),
539                    content: vec![ContentBlock::Image {
540                        media_type: Some("image/png".to_string()),
541                        url: None,
542                        data: Some("aW1hZ2U=".to_string()),
543                        extensions: Extensions::default(),
544                    }],
545                    is_error: false,
546                    extensions: Extensions::default(),
547                }],
548                is_error: false,
549                extensions: Extensions::default(),
550            }],
551            ..UniversalRequest::default()
552        };
553
554        assert!(request_contains_service_side_input(
555            &request,
556            ServiceSideInputKind::Image
557        ));
558        assert!(!request_contains_service_side_input(
559            &request,
560            ServiceSideInputKind::File
561        ));
562    }
563
564    struct EchoTool {
565        calls: Arc<AtomicUsize>,
566    }
567
568    #[async_trait]
569    impl ServiceSideTool for EchoTool {
570        fn definition(&self) -> UniversalTool {
571            UniversalTool {
572                name: "host_echo".to_string(),
573                description: Some("Echo host-side text".to_string()),
574                input_schema: Some(json!({
575                    "type": "object",
576                    "properties": { "text": { "type": "string" } }
577                })),
578                strict: None,
579                extensions: Extensions::default(),
580            }
581        }
582
583        async fn call(
584            &self,
585            call: ServiceSideToolCall,
586        ) -> ServiceSideResult<ServiceSideToolOutput> {
587            self.calls.fetch_add(1, Ordering::SeqCst);
588            Ok(ServiceSideToolOutput {
589                content: vec![ContentBlock::Text {
590                    text: call.arguments["text"]
591                        .as_str()
592                        .unwrap_or_default()
593                        .to_string(),
594                }],
595                is_error: false,
596            })
597        }
598    }
599
600    #[test]
601    fn registry_injects_and_executes_registered_tools_only() {
602        let calls = Arc::new(AtomicUsize::new(0));
603        let mut registry = ServiceSideCapabilityRegistry::new();
604        registry
605            .register_tool(EchoTool {
606                calls: calls.clone(),
607            })
608            .expect("tool registers");
609        let mut request = UniversalRequest::default();
610        assert_eq!(registry.inject_tools(&mut request), 1);
611        assert_eq!(registry.inject_tools(&mut request), 0);
612        let response = UniversalResponse {
613            output: vec![
614                UniversalItem::ToolCall {
615                    id: "call-host".to_string(),
616                    name: "host_echo".to_string(),
617                    arguments: json!({ "text": "hello" }),
618                    extensions: Extensions::default(),
619                },
620                UniversalItem::ToolCall {
621                    id: "call-client".to_string(),
622                    name: "client_tool".to_string(),
623                    arguments: json!({}),
624                    extensions: Extensions::default(),
625                },
626            ],
627            ..UniversalResponse::default()
628        };
629
630        let execution = futures_lite::future::block_on(registry.execute_tool_calls(&response))
631            .expect("registered tool executes");
632
633        assert_eq!(calls.load(Ordering::SeqCst), 1);
634        assert_eq!(execution.handled_count(), 1);
635        assert_eq!(execution.unhandled_calls.len(), 1);
636        let UniversalItem::ToolResult { content, .. } = &execution.results[0] else {
637            panic!("host call becomes tool result");
638        };
639        assert_eq!(
640            content[0],
641            ContentBlock::Text {
642                text: "hello".to_string()
643            }
644        );
645    }
646}