1use 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 pub context: String,
91 pub extensions: Extensions,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ServiceSideInputResolution {
96 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 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}