1use itertools::Itertools;
2
3use unitycatalog_common::models::ObjectLabel;
4use unitycatalog_common::models::agent_skills::v0alpha1::*;
5use unitycatalog_common::models::{ResourceIdent, ResourceName, ResourceRef};
6
7use super::staging_tables::{child_location, resolve_managed_parent_location};
8use super::{RequestContext, SecuredAction};
9pub use crate::codegen::agent_skills::AgentSkillHandler;
10use crate::policy::{Permission, Policy, process_resources};
11use crate::services::location::StorageLocationUrl;
12use crate::services::object_store::validate_external_storage_location;
13use crate::services::{ProvidesLocalStoragePolicy, ProvidesManagedStorageRoot};
14use crate::store::ResourceStore;
15use crate::{Error, Result};
16
17#[async_trait::async_trait]
18impl<
19 T: ResourceStore
20 + Policy<RequestContext>
21 + ProvidesLocalStoragePolicy
22 + ProvidesManagedStorageRoot,
23> AgentSkillHandler<RequestContext> for T
24{
25 #[tracing::instrument(skip(self, context), fields(resource_name))]
26 async fn create_agent_skill(
27 &self,
28 request: CreateAgentSkillRequest,
29 context: RequestContext,
30 ) -> Result<AgentSkill> {
31 tracing::Span::current().record("resource_name", &request.name);
32 self.check_required(&request, &context).await?;
33
34 let skill_type = request.agent_skill_type.as_known().unwrap_or_default();
35
36 let agent_skill_id = uuid::Uuid::now_v7().hyphenated().to_string();
41 let storage_location = match skill_type {
42 AgentSkillType::EXTERNAL => {
43 let location = request
47 .storage_location
48 .filter(|s| !s.is_empty())
49 .ok_or_else(|| {
50 Error::invalid_argument(
51 "storage_location is required for EXTERNAL agent skills",
52 )
53 })?;
54 let parsed = StorageLocationUrl::parse(&location)?;
55 validate_external_storage_location(self, &parsed).await?;
56 location
57 }
58 AgentSkillType::MANAGED => {
59 let parent = resolve_managed_parent_location(
65 self,
66 &request.catalog_name,
67 &request.schema_name,
68 )
69 .await?;
70 child_location(&parent, "agent_skills", &agent_skill_id)
71 }
72 AgentSkillType::AGENT_SKILL_TYPE_UNSPECIFIED => {
73 return Err(Error::invalid_argument(
74 "agent_skill_type must be specified (EXTERNAL or MANAGED)",
75 ));
76 }
77 };
78
79 let full_name = format!(
80 "{}.{}.{}",
81 request.catalog_name, request.schema_name, request.name
82 );
83 let resource = AgentSkill {
84 full_name,
85 name: request.name,
86 catalog_name: request.catalog_name,
87 schema_name: request.schema_name,
88 agent_skill_type: request.agent_skill_type,
89 storage_location,
90 agent_skill_id,
91 description: request.description,
92 license: request.license,
93 allowed_tools: request.allowed_tools,
94 metadata: request.metadata,
95 comment: request.comment,
96 ..Default::default()
97 };
98 Ok(self.create(resource.into()).await?.0.try_into()?)
99 }
100
101 #[tracing::instrument(skip(self, context))]
102 async fn list_agent_skills(
103 &self,
104 request: ListAgentSkillsRequest,
105 context: RequestContext,
106 ) -> Result<ListAgentSkillsResponse> {
107 self.check_required(&request, &context).await?;
108 let (mut resources, next_page_token) = self
109 .list(
110 &ObjectLabel::AgentSkill,
111 Some(&ResourceName::new([
112 &request.catalog_name,
113 &request.schema_name,
114 ])),
115 request.max_results.map(|v| v as usize),
116 request.page_token,
117 )
118 .await?;
119 process_resources(self, &context, &Permission::Read, &mut resources).await?;
120 Ok(ListAgentSkillsResponse {
121 agent_skills: resources.into_iter().map(|r| r.try_into()).try_collect()?,
122 next_page_token,
123 ..Default::default()
124 })
125 }
126
127 #[tracing::instrument(skip(self, context), fields(resource_name))]
128 async fn get_agent_skill(
129 &self,
130 request: GetAgentSkillRequest,
131 context: RequestContext,
132 ) -> Result<AgentSkill> {
133 tracing::Span::current().record("resource_name", &request.name);
134 self.check_required(&request, &context).await?;
135 Ok(self.get(&request.resource()).await?.0.try_into()?)
136 }
137
138 #[tracing::instrument(skip(self, context), fields(resource_name))]
139 async fn update_agent_skill(
140 &self,
141 request: UpdateAgentSkillRequest,
142 context: RequestContext,
143 ) -> Result<AgentSkill> {
144 tracing::Span::current().record("resource_name", &request.name);
145 self.check_required(&request, &context).await?;
146 let ident = request.resource();
147 let name = ResourceName::from_naive_str_split(request.name.as_str());
148 let [catalog_name, schema_name, skill_name] = name.as_ref() else {
149 return Err(Error::invalid_argument(
150 "Invalid agent skill name - expected <catalog_name>.<schema_name>.<skill_name>",
151 ));
152 };
153 let current: AgentSkill = self.get(&ident).await?.0.try_into()?;
156 let new_name = request.new_name.as_deref().unwrap_or(skill_name);
157 let resource = AgentSkill {
158 name: new_name.to_owned(),
159 catalog_name: catalog_name.to_owned(),
160 schema_name: schema_name.to_owned(),
161 full_name: format!("{}.{}.{}", catalog_name, schema_name, new_name),
162 agent_skill_type: current.agent_skill_type,
163 storage_location: current.storage_location,
164 description: request.description.or(current.description),
165 license: current.license,
166 allowed_tools: if request.allowed_tools.is_empty() {
167 current.allowed_tools
168 } else {
169 request.allowed_tools
170 },
171 metadata: current.metadata,
172 comment: request.comment.or(current.comment),
173 owner: request.owner.or(current.owner),
174 ..Default::default()
175 };
176 Ok(self.update(&ident, resource.into()).await?.0.try_into()?)
177 }
178
179 #[tracing::instrument(skip(self, context), fields(resource_name))]
180 async fn delete_agent_skill(
181 &self,
182 request: DeleteAgentSkillRequest,
183 context: RequestContext,
184 ) -> Result<()> {
185 tracing::Span::current().record("resource_name", &request.name);
186 self.check_required(&request, &context).await?;
187 Ok(self.delete(&request.resource()).await?)
188 }
189}
190
191impl SecuredAction for CreateAgentSkillRequest {
192 fn resource(&self) -> ResourceIdent {
193 ResourceIdent::agent_skill(ResourceName::new([
194 self.catalog_name.as_str(),
195 self.schema_name.as_str(),
196 self.name.as_str(),
197 ]))
198 }
199
200 fn permission(&self) -> &'static Permission {
201 &Permission::Create
202 }
203}
204
205impl SecuredAction for ListAgentSkillsRequest {
206 fn resource(&self) -> ResourceIdent {
207 ResourceIdent::agent_skill(ResourceRef::Undefined)
208 }
209
210 fn permission(&self) -> &'static Permission {
211 &Permission::Read
212 }
213}
214
215impl SecuredAction for GetAgentSkillRequest {
216 fn resource(&self) -> ResourceIdent {
217 ResourceIdent::agent_skill(ResourceName::from_naive_str_split(self.name.as_str()))
218 }
219
220 fn permission(&self) -> &'static Permission {
221 &Permission::Read
222 }
223}
224
225impl SecuredAction for UpdateAgentSkillRequest {
226 fn resource(&self) -> ResourceIdent {
227 ResourceIdent::agent_skill(ResourceName::from_naive_str_split(self.name.as_str()))
228 }
229
230 fn permission(&self) -> &'static Permission {
231 &Permission::Manage
232 }
233}
234
235impl SecuredAction for DeleteAgentSkillRequest {
236 fn resource(&self) -> ResourceIdent {
237 ResourceIdent::agent_skill(ResourceName::from_naive_str_split(self.name.as_str()))
238 }
239
240 fn permission(&self) -> &'static Permission {
241 &Permission::Manage
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use std::sync::Arc;
248
249 use unitycatalog_common::models::catalogs::v1::CreateCatalogRequest;
250 use unitycatalog_common::models::credentials::v1::{
251 AwsIamRoleConfig, CreateCredentialRequest, Purpose,
252 };
253 use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
254 use unitycatalog_common::models::schemas::v1::CreateSchemaRequest;
255 use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
256
257 use super::*;
258 use crate::api::{CatalogHandler, CredentialHandler, ExternalLocationHandler, SchemaHandler};
259 use crate::memory::InMemoryResourceStore;
260 use crate::policy::ConstantPolicy;
261 use crate::services::ServerHandler;
262
263 fn handler() -> ServerHandler<RequestContext> {
264 let encryptor =
265 EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
266 let store = Arc::new(InMemoryResourceStore::new(encryptor));
267 let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
268 ServerHandler::try_new_tokio(policy, store).unwrap()
269 }
270
271 fn ctx() -> RequestContext {
272 RequestContext {
273 recipient: crate::policy::Principal::anonymous(),
274 }
275 }
276
277 async fn make_covering_location(h: &ServerHandler<RequestContext>, tag: &str, url: &str) {
279 h.create_credential(
280 CreateCredentialRequest {
281 name: format!("{tag}-cred"),
282 purpose: Purpose::Storage.into(),
283 aws_iam_role: Some(AwsIamRoleConfig {
284 role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
285 ..Default::default()
286 })
287 .into(),
288 ..Default::default()
289 },
290 ctx(),
291 )
292 .await
293 .unwrap();
294 h.create_external_location(
295 CreateExternalLocationRequest {
296 name: format!("{tag}-el"),
297 url: url.to_string(),
298 credential_name: format!("{tag}-cred"),
299 ..Default::default()
300 },
301 ctx(),
302 )
303 .await
304 .unwrap();
305 }
306
307 async fn setup_managed_namespace(h: &ServerHandler<RequestContext>, storage_root: &str) {
310 make_covering_location(h, "cat", storage_root).await;
311 h.create_catalog(
312 CreateCatalogRequest {
313 name: "cat".to_string(),
314 storage_root: Some(storage_root.to_string()),
315 ..Default::default()
316 },
317 ctx(),
318 )
319 .await
320 .unwrap();
321 h.create_schema(
322 CreateSchemaRequest {
323 name: "sch".to_string(),
324 catalog_name: "cat".to_string(),
325 ..Default::default()
326 },
327 ctx(),
328 )
329 .await
330 .unwrap();
331 }
332
333 fn create_external_skill(name: &str, location: Option<&str>) -> CreateAgentSkillRequest {
334 CreateAgentSkillRequest {
335 catalog_name: "cat".to_string(),
336 schema_name: "sch".to_string(),
337 name: name.to_string(),
338 agent_skill_type: AgentSkillType::External.into(),
339 storage_location: location.map(str::to_string),
340 description: Some("formats things".to_string()),
341 license: Some("MIT".to_string()),
342 allowed_tools: vec!["bash".to_string()],
343 metadata: Default::default(),
344 comment: None,
345 ..Default::default()
346 }
347 }
348
349 fn create_managed_skill(name: &str) -> CreateAgentSkillRequest {
350 CreateAgentSkillRequest {
351 catalog_name: "cat".to_string(),
352 schema_name: "sch".to_string(),
353 name: name.to_string(),
354 agent_skill_type: AgentSkillType::Managed.into(),
355 storage_location: None,
356 description: None,
357 license: None,
358 allowed_tools: vec![],
359 metadata: Default::default(),
360 comment: None,
361 ..Default::default()
362 }
363 }
364
365 #[tokio::test]
366 async fn managed_skill_location_uses_skill_id() {
367 let h = handler();
368 setup_managed_namespace(&h, "s3://bucket/cat").await;
369
370 let s = h
371 .create_agent_skill(create_managed_skill("fmt"), ctx())
372 .await
373 .unwrap();
374
375 assert!(uuid::Uuid::parse_str(&s.agent_skill_id).is_ok());
376 assert!(
377 s.storage_location
378 .ends_with(&format!("/agent_skills/{}", s.agent_skill_id)),
379 "got {}",
380 s.storage_location
381 );
382 assert!(
383 s.storage_location
384 .starts_with("s3://bucket/cat/__unitystorage/catalogs/"),
385 "got {}",
386 s.storage_location
387 );
388
389 let got = h
390 .get_agent_skill(
391 GetAgentSkillRequest {
392 name: "cat.sch.fmt".to_string(),
393 ..Default::default()
394 },
395 ctx(),
396 )
397 .await
398 .unwrap();
399 assert_eq!(got.agent_skill_id, s.agent_skill_id);
400 assert_eq!(got.storage_location, s.storage_location);
401 }
402
403 #[tokio::test]
404 async fn managed_skill_recreate_yields_new_path() {
405 let h = handler();
406 setup_managed_namespace(&h, "s3://bucket/cat").await;
407 let first = h
408 .create_agent_skill(create_managed_skill("fmt"), ctx())
409 .await
410 .unwrap();
411 h.delete_agent_skill(
412 DeleteAgentSkillRequest {
413 name: "cat.sch.fmt".to_string(),
414 ..Default::default()
415 },
416 ctx(),
417 )
418 .await
419 .unwrap();
420 let second = h
421 .create_agent_skill(create_managed_skill("fmt"), ctx())
422 .await
423 .unwrap();
424 assert_ne!(first.agent_skill_id, second.agent_skill_id);
425 assert_ne!(first.storage_location, second.storage_location);
426 }
427
428 #[tokio::test]
429 async fn external_skill_within_location_succeeds_and_round_trips_metadata() {
430 let h = handler();
431 setup_managed_namespace(&h, "s3://bucket/cat").await;
434 make_covering_location(&h, "ext", "s3://bucket/ext").await;
435
436 let created = h
437 .create_agent_skill(
438 create_external_skill("fmt", Some("s3://bucket/ext/skill")),
439 ctx(),
440 )
441 .await
442 .unwrap();
443 assert_eq!(created.storage_location, "s3://bucket/ext/skill");
444 assert_eq!(created.license.as_deref(), Some("MIT"));
445 assert_eq!(created.allowed_tools, vec!["bash".to_string()]);
446 assert_eq!(created.description.as_deref(), Some("formats things"));
447 }
448
449 #[tokio::test]
450 async fn external_skill_outside_location_is_rejected() {
451 let h = handler();
452 setup_managed_namespace(&h, "s3://bucket/cat").await;
453 let res = h
454 .create_agent_skill(
455 create_external_skill("fmt", Some("s3://bucket/other/skill")),
456 ctx(),
457 )
458 .await;
459 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
460 }
461
462 #[tokio::test]
463 async fn external_skill_requires_storage_location() {
464 let h = handler();
465 setup_managed_namespace(&h, "s3://bucket/cat").await;
466 let res = h
467 .create_agent_skill(create_external_skill("fmt", None), ctx())
468 .await;
469 assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
470 }
471
472 #[tokio::test]
473 async fn managed_skill_duplicate_name_is_rejected() {
474 let h = handler();
475 setup_managed_namespace(&h, "s3://bucket/cat").await;
476 h.create_agent_skill(create_managed_skill("fmt"), ctx())
477 .await
478 .unwrap();
479 let err = h
480 .create_agent_skill(create_managed_skill("fmt"), ctx())
481 .await
482 .expect_err("duplicate name must be rejected");
483 assert_eq!(err.error_code(), "RESOURCE_ALREADY_EXISTS", "{err:?}");
484 }
485}