agent_core/orchestration/
capability.rs1use serde::Serialize;
10use sha2::{Digest, Sha256};
11use thiserror::Error;
12
13pub const TOOL_ID_MAX_BYTES: usize = 200;
15pub const TOOL_ID_NAMESPACE_MAX_BYTES: usize = 64;
17pub const TOOL_ID_NAME_MAX_BYTES: usize = 128;
19pub const SESSION_ID_MAX_BYTES: usize = 256;
21
22#[derive(Clone, Debug, Error, PartialEq, Eq)]
24pub enum ToolIdError {
25 #[error("tool id is empty")]
26 Empty,
27 #[error("tool id is oversized: {actual} bytes exceeds limit {limit}")]
28 Oversized { actual: usize, limit: usize },
29 #[error("tool id is missing a `namespace:name` separator")]
30 MissingNamespace,
31 #[error("tool id namespace segment is empty, oversized, or non-canonical")]
32 InvalidNamespace,
33 #[error("tool id name segment is empty, oversized, or non-canonical")]
34 InvalidName,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
44#[serde(transparent)]
45pub struct ToolId(String);
46
47fn is_canonical_segment(segment: &str, max_bytes: usize) -> bool {
48 if segment.is_empty() || segment.len() > max_bytes {
49 return false;
50 }
51 let mut chars = segment.chars();
52 let first_ok = chars
53 .next()
54 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
55 first_ok
56 && chars
57 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.'))
58}
59
60const ENCODED_SEGMENT_PREFIX: &str = "enc-";
63const DIGEST_SEGMENT_PREFIX: &str = "sha-";
65const DIGEST_SEGMENT_HEX_LEN: usize = 40;
67
68fn hex_lower(bytes: &[u8]) -> String {
69 let mut out = String::with_capacity(bytes.len() * 2);
70 for b in bytes {
71 use std::fmt::Write as _;
72 let _ = write!(out, "{b:02x}");
73 }
74 out
75}
76
77fn encode_segment(raw: &str, max_bytes: usize) -> String {
88 debug_assert!(max_bytes >= DIGEST_SEGMENT_PREFIX.len() + DIGEST_SEGMENT_HEX_LEN);
89 if is_canonical_segment(raw, max_bytes)
90 && !raw.starts_with(ENCODED_SEGMENT_PREFIX)
91 && !raw.starts_with(DIGEST_SEGMENT_PREFIX)
92 {
93 return raw.to_string();
94 }
95 let encoded = format!("{ENCODED_SEGMENT_PREFIX}{}", hex_lower(raw.as_bytes()));
96 if encoded.len() <= max_bytes {
97 return encoded;
98 }
99 let digest = Sha256::digest(raw.as_bytes());
100 let mut hex = hex_lower(&digest);
101 hex.truncate(DIGEST_SEGMENT_HEX_LEN);
102 format!("{DIGEST_SEGMENT_PREFIX}{hex}")
103}
104
105impl ToolId {
106 pub fn parse(raw: &str) -> Result<Self, ToolIdError> {
109 if raw.is_empty() {
110 return Err(ToolIdError::Empty);
111 }
112 if raw.len() > TOOL_ID_MAX_BYTES {
113 return Err(ToolIdError::Oversized {
114 actual: raw.len(),
115 limit: TOOL_ID_MAX_BYTES,
116 });
117 }
118 let (namespace, name) = raw.split_once(':').ok_or(ToolIdError::MissingNamespace)?;
119 if !is_canonical_segment(namespace, TOOL_ID_NAMESPACE_MAX_BYTES) {
120 return Err(ToolIdError::InvalidNamespace);
121 }
122 if !is_canonical_segment(name, TOOL_ID_NAME_MAX_BYTES) {
123 return Err(ToolIdError::InvalidName);
124 }
125 Ok(Self(raw.to_string()))
126 }
127
128 const BUILTIN_NAMESPACE: &'static str = "builtin";
132 const UNKNOWN_NAMESPACE: &'static str = "unknown";
133 const EXTENSION_NAMESPACE_PREFIX: &'static str = "ext.";
134 const MCP_NAMESPACE_PREFIX: &'static str = "mcp.";
135 const PLUGIN_NAMESPACE_PREFIX: &'static str = "plugin.";
136
137 fn from_source(namespace: String, raw_name: &str) -> Self {
138 debug_assert!(namespace.len() <= TOOL_ID_NAMESPACE_MAX_BYTES);
139 let name = encode_segment(raw_name, TOOL_ID_NAME_MAX_BYTES);
140 Self(format!("{namespace}:{name}"))
141 }
142
143 fn from_prefixed_source(prefix: &str, raw_source: &str, raw_name: &str) -> Self {
144 let budget = TOOL_ID_NAMESPACE_MAX_BYTES - prefix.len();
145 let namespace = format!("{prefix}{}", encode_segment(raw_source, budget));
146 Self::from_source(namespace, raw_name)
147 }
148
149 pub fn builtin(runtime_name: &str) -> Self {
151 Self::from_source(Self::BUILTIN_NAMESPACE.to_string(), runtime_name)
152 }
153
154 pub fn extension(extension_id: &str, tool_name: &str) -> Self {
159 Self::from_prefixed_source(Self::EXTENSION_NAMESPACE_PREFIX, extension_id, tool_name)
160 }
161
162 pub fn mcp(server_id: &str, server_tool_name: &str) -> Self {
165 Self::from_prefixed_source(Self::MCP_NAMESPACE_PREFIX, server_id, server_tool_name)
166 }
167
168 pub fn plugin(plugin_id: &str, tool_name: &str) -> Self {
170 Self::from_prefixed_source(Self::PLUGIN_NAMESPACE_PREFIX, plugin_id, tool_name)
171 }
172
173 pub fn unclassified(runtime_name: &str) -> Self {
177 Self::from_source(Self::UNKNOWN_NAMESPACE.to_string(), runtime_name)
178 }
179
180 pub fn as_str(&self) -> &str {
181 &self.0
182 }
183
184 pub fn namespace(&self) -> &str {
186 self.0.split_once(':').map(|(ns, _)| ns).unwrap_or("")
187 }
188
189 pub fn name(&self) -> &str {
191 self.0.split_once(':').map(|(_, name)| name).unwrap_or("")
192 }
193}
194
195impl std::fmt::Display for ToolId {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.write_str(&self.0)
198 }
199}
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
204#[serde(transparent)]
205pub struct CatalogGeneration(u64);
206
207#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
210#[error("catalog generation counter exhausted at u64::MAX")]
211pub struct CatalogGenerationOverflow;
212
213impl CatalogGeneration {
214 pub const fn initial() -> Self {
215 Self(0)
216 }
217
218 pub const fn new(value: u64) -> Self {
221 Self(value)
222 }
223
224 #[must_use = "checked_next() returns the advanced generation without mutating self"]
228 pub fn checked_next(self) -> Result<Self, CatalogGenerationOverflow> {
229 self.0
230 .checked_add(1)
231 .map(Self)
232 .ok_or(CatalogGenerationOverflow)
233 }
234
235 pub const fn value(self) -> u64 {
236 self.0
237 }
238}
239
240#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
249#[serde(transparent)]
250pub struct SchemaDigest(String);
251
252const DIGEST_TAG_NULL: u8 = 0;
253const DIGEST_TAG_BOOL: u8 = 1;
254const DIGEST_TAG_NUMBER: u8 = 2;
255const DIGEST_TAG_STRING: u8 = 3;
256const DIGEST_TAG_ARRAY: u8 = 4;
257const DIGEST_TAG_OBJECT: u8 = 5;
258const DIGEST_TAG_KEY: u8 = 6;
259
260impl SchemaDigest {
261 pub fn of_schema(schema: &serde_json::Value) -> Self {
262 use serde_json::Value;
263
264 enum Task<'a> {
265 Value(&'a Value),
266 Key(&'a str),
267 }
268
269 let mut hasher = Sha256::new();
270 let mut stack: Vec<Task<'_>> = vec![Task::Value(schema)];
271 while let Some(task) = stack.pop() {
272 match task {
273 Task::Key(key) => {
274 hasher.update([DIGEST_TAG_KEY]);
275 hasher.update((key.len() as u64).to_le_bytes());
276 hasher.update(key.as_bytes());
277 }
278 Task::Value(value) => match value {
279 Value::Null => hasher.update([DIGEST_TAG_NULL]),
280 Value::Bool(b) => hasher.update([DIGEST_TAG_BOOL, u8::from(*b)]),
281 Value::Number(n) => {
282 let repr = n.to_string();
283 hasher.update([DIGEST_TAG_NUMBER]);
284 hasher.update((repr.len() as u64).to_le_bytes());
285 hasher.update(repr.as_bytes());
286 }
287 Value::String(s) => {
288 hasher.update([DIGEST_TAG_STRING]);
289 hasher.update((s.len() as u64).to_le_bytes());
290 hasher.update(s.as_bytes());
291 }
292 Value::Array(items) => {
293 hasher.update([DIGEST_TAG_ARRAY]);
294 hasher.update((items.len() as u64).to_le_bytes());
295 for item in items.iter().rev() {
296 stack.push(Task::Value(item));
297 }
298 }
299 Value::Object(map) => {
300 hasher.update([DIGEST_TAG_OBJECT]);
301 hasher.update((map.len() as u64).to_le_bytes());
302 let mut entries: Vec<(&String, &Value)> = map.iter().collect();
306 entries.sort_by_key(|(key, _)| *key);
307 for (key, child) in entries.into_iter().rev() {
308 stack.push(Task::Value(child));
309 stack.push(Task::Key(key));
310 }
311 }
312 },
313 }
314 }
315 Self(format!("{:x}", hasher.finalize()))
316 }
317
318 pub fn as_hex(&self) -> &str {
319 &self.0
320 }
321}
322
323#[derive(Clone, Debug, Error, PartialEq, Eq)]
325pub enum SessionIdError {
326 #[error("session id is empty")]
327 Empty,
328 #[error("session id is oversized: {actual} bytes exceeds limit {limit}")]
329 Oversized { actual: usize, limit: usize },
330 #[error("session id contains control characters")]
331 ControlCharacters,
332}
333
334#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
339#[serde(transparent)]
340pub struct SessionId(String);
341
342impl SessionId {
343 pub fn parse(raw: &str) -> Result<Self, SessionIdError> {
344 if raw.is_empty() {
345 return Err(SessionIdError::Empty);
346 }
347 if raw.len() > SESSION_ID_MAX_BYTES {
348 return Err(SessionIdError::Oversized {
349 actual: raw.len(),
350 limit: SESSION_ID_MAX_BYTES,
351 });
352 }
353 if raw.chars().any(char::is_control) {
358 return Err(SessionIdError::ControlCharacters);
359 }
360 Ok(Self(raw.to_string()))
361 }
362
363 pub fn as_str(&self) -> &str {
364 &self.0
365 }
366}
367
368impl std::fmt::Display for SessionId {
369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370 f.write_str(&self.0)
371 }
372}
373
374#[derive(Clone, Debug, Error, PartialEq, Eq)]
376pub enum ActivationGrantError {
377 #[error("activation grant session id is empty")]
378 EmptySessionId,
379 #[error("activation grant session id is oversized: {actual} bytes exceeds limit {limit}")]
380 OversizedSessionId { actual: usize, limit: usize },
381 #[error("activation grant session id contains control characters")]
382 ControlCharacterSessionId,
383}
384
385#[derive(Clone, Debug, Serialize)]
392pub struct SessionActivationGrant {
393 session_id: String,
394 tool_id: ToolId,
395 catalog_generation: CatalogGeneration,
396 schema_digest: SchemaDigest,
397}
398
399impl SessionActivationGrant {
400 pub fn new(
401 session_id: &str,
402 tool_id: ToolId,
403 catalog_generation: CatalogGeneration,
404 schema_digest: SchemaDigest,
405 ) -> Result<Self, ActivationGrantError> {
406 let session_id = SessionId::parse(session_id).map_err(|err| match err {
409 SessionIdError::Empty => ActivationGrantError::EmptySessionId,
410 SessionIdError::Oversized { actual, limit } => {
411 ActivationGrantError::OversizedSessionId { actual, limit }
412 }
413 SessionIdError::ControlCharacters => ActivationGrantError::ControlCharacterSessionId,
414 })?;
415 Ok(Self {
416 session_id: session_id.0,
417 tool_id,
418 catalog_generation,
419 schema_digest,
420 })
421 }
422
423 pub fn session_id(&self) -> &str {
424 &self.session_id
425 }
426
427 pub fn tool_id(&self) -> &ToolId {
428 &self.tool_id
429 }
430
431 pub fn catalog_generation(&self) -> CatalogGeneration {
432 self.catalog_generation
433 }
434
435 pub fn schema_digest(&self) -> &SchemaDigest {
436 &self.schema_digest
437 }
438
439 pub fn covers(
443 &self,
444 session_id: &str,
445 tool_id: &ToolId,
446 catalog_generation: CatalogGeneration,
447 schema_digest: &SchemaDigest,
448 ) -> bool {
449 self.session_id == session_id
450 && &self.tool_id == tool_id
451 && self.catalog_generation == catalog_generation
452 && &self.schema_digest == schema_digest
453 }
454}