Skip to main content

perspt_sdk/toolset/
handler.rs

1//! Exact-name handler registry and capability-scoped host services.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7
8use super::{ToolCatalog, ToolEntry};
9use crate::capability::{Capability, EffectKind};
10use crate::command::CommandInvocation;
11use crate::error::{Result, SdkError};
12use crate::scheduler::Footprint;
13
14/// Scheduling class declared by a trusted handler implementation.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ConcurrencyClass {
17    /// A read-only handler whose footprint may commute with another read.
18    ParallelRead,
19    /// A handler serialized by default. Mutation, LSP, MCP, dependency, and
20    /// composition handlers belong here unless explicitly reviewed.
21    Serial,
22}
23
24/// Capability-scoped workspace access. Implementations enforce their scope;
25/// handlers never receive an unrestricted host path.
26#[async_trait]
27pub trait WorkspaceService: Send + Sync {
28    async fn read(&self, path: &str, max_bytes: usize) -> Result<Vec<u8>>;
29    async fn write(&self, path: &str, content: &[u8]) -> Result<()>;
30}
31
32/// Capability-scoped direct process execution.
33#[async_trait]
34pub trait ProcessService: Send + Sync {
35    async fn execute(&self, invocation: &CommandInvocation, max_bytes: usize) -> Result<String>;
36}
37
38/// Capability-scoped network observation service.
39#[async_trait]
40pub trait NetworkService: Send + Sync {
41    async fn fetch(&self, url: &str, max_bytes: usize) -> Result<Vec<u8>>;
42}
43
44/// Durable observation sink used by handlers.
45pub trait ObservationService: Send + Sync {
46    fn record(&self, kind: &str, observation: &serde_json::Value) -> Result<()>;
47}
48
49/// Host services available to a handler. Each service is optional so a
50/// capability role can expose only the surfaces it actually grants.
51#[derive(Clone, Default)]
52pub struct HandlerServices {
53    pub workspace: Option<Arc<dyn WorkspaceService>>,
54    pub process: Option<Arc<dyn ProcessService>>,
55    pub network: Option<Arc<dyn NetworkService>>,
56    pub observations: Option<Arc<dyn ObservationService>>,
57}
58
59impl std::fmt::Debug for HandlerServices {
60    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        formatter
62            .debug_struct("HandlerServices")
63            .field("workspace", &self.workspace.is_some())
64            .field("process", &self.process.is_some())
65            .field("network", &self.network.is_some())
66            .field("observations", &self.observations.is_some())
67            .finish()
68    }
69}
70
71/// Immutable context for one governed handler call.
72#[derive(Debug, Clone)]
73pub struct HandlerContext {
74    session_id: Arc<str>,
75    node_id: Arc<str>,
76    generation: u32,
77    capabilities: Arc<[Capability]>,
78    services: HandlerServices,
79}
80
81impl HandlerContext {
82    pub fn new(
83        session_id: impl Into<Arc<str>>,
84        node_id: impl Into<Arc<str>>,
85        generation: u32,
86        capabilities: Vec<Capability>,
87        services: HandlerServices,
88    ) -> Self {
89        Self {
90            session_id: session_id.into(),
91            node_id: node_id.into(),
92            generation,
93            capabilities: capabilities.into(),
94            services,
95        }
96    }
97
98    pub fn session_id(&self) -> &str {
99        &self.session_id
100    }
101
102    pub fn node_id(&self) -> &str {
103        &self.node_id
104    }
105
106    pub fn generation(&self) -> u32 {
107        self.generation
108    }
109
110    pub fn capabilities(&self) -> &[Capability] {
111        &self.capabilities
112    }
113
114    pub fn services(&self) -> &HandlerServices {
115        &self.services
116    }
117}
118
119/// A handler's declared result. The dispatcher rejects effect or footprint
120/// claims outside the catalog entry.
121#[derive(Debug, Clone)]
122pub struct ToolExecution {
123    pub effect: EffectKind,
124    pub footprint: Footprint,
125    pub output: serde_json::Value,
126    pub artifacts: Vec<String>,
127}
128
129#[async_trait]
130pub trait ToolHandler: Send + Sync {
131    fn catalog_name(&self) -> &str;
132
133    fn concurrency_class(&self) -> ConcurrencyClass {
134        ConcurrencyClass::Serial
135    }
136
137    async fn execute(
138        &self,
139        context: &HandlerContext,
140        arguments: &serde_json::Value,
141    ) -> Result<ToolExecution>;
142}
143
144/// Exact-name registry and governed dispatch boundary.
145#[derive(Default)]
146pub struct ToolDispatcher {
147    handlers: BTreeMap<String, Arc<dyn ToolHandler>>,
148}
149
150impl std::fmt::Debug for ToolDispatcher {
151    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        formatter
153            .debug_struct("ToolDispatcher")
154            .field("handlers", &self.handlers.keys().collect::<Vec<_>>())
155            .finish()
156    }
157}
158
159impl ToolDispatcher {
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    pub fn register(&mut self, entry: &ToolEntry, handler: Arc<dyn ToolHandler>) -> Result<()> {
165        if handler.catalog_name() != entry.name {
166            return Err(SdkError::Domain(format!(
167                "handler {:?} does not match catalog entry {:?}",
168                handler.catalog_name(),
169                entry.name
170            )));
171        }
172        if self.handlers.contains_key(&entry.name) {
173            return Err(SdkError::Domain(format!(
174                "duplicate handler for catalog entry {:?}",
175                entry.name
176            )));
177        }
178        self.handlers.insert(entry.name.clone(), handler);
179        Ok(())
180    }
181
182    /// Require a handler for every catalog entry and reject orphan handlers.
183    pub fn validate_complete(&self, catalog: &dyn ToolCatalog) -> Result<()> {
184        let entries: BTreeSet<&str> = catalog
185            .entries()
186            .iter()
187            .map(|entry| entry.name.as_str())
188            .collect();
189        if let Some(missing) = entries
190            .iter()
191            .find(|name| !self.handlers.contains_key(**name))
192        {
193            return Err(SdkError::Domain(format!(
194                "missing handler for catalog entry {missing:?}"
195            )));
196        }
197        if let Some(orphan) = self
198            .handlers
199            .keys()
200            .find(|name| !entries.contains(name.as_str()))
201        {
202            return Err(SdkError::Domain(format!(
203                "handler {orphan:?} has no catalog entry"
204            )));
205        }
206        Ok(())
207    }
208
209    pub fn concurrency_class(&self, name: &str) -> Option<ConcurrencyClass> {
210        self.handlers
211            .get(name)
212            .map(|handler| handler.concurrency_class())
213    }
214
215    pub async fn dispatch(
216        &self,
217        entry: &ToolEntry,
218        context: &HandlerContext,
219        arguments: &serde_json::Value,
220        provider: &str,
221    ) -> Result<ToolExecution> {
222        entry.validate_arguments(arguments)?;
223        let handler = self.handlers.get(&entry.name).ok_or_else(|| {
224            SdkError::Domain(format!(
225                "missing handler for catalog entry {:?}",
226                entry.name
227            ))
228        })?;
229        let result = handler.execute(context, arguments).await?;
230        if result.effect != entry.effect {
231            return Err(SdkError::Domain(format!(
232                "handler {:?} returned undeclared effect {:?}",
233                entry.name, result.effect
234            )));
235        }
236        let declared = entry.footprint.resolve(arguments, provider);
237        if !result.footprint.reads.is_subset(&declared.reads)
238            || !result.footprint.writes.is_subset(&declared.writes)
239        {
240            return Err(SdkError::Domain(format!(
241                "handler {:?} returned an undeclared resource footprint",
242                entry.name
243            )));
244        }
245        Ok(result)
246    }
247}