Skip to main content

unitycatalog_server/api/
agent_skills.rs

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        // Pre-allocate the id so the created record carries it (the store honors a
37        // pre-set id, else mints a v7) and a managed skill can embed it in its
38        // storage path. This mirrors managed `Volume`, since an agent skill is
39        // just a storage-backed directory.
40        let agent_skill_id = uuid::Uuid::now_v7().hyphenated().to_string();
41        let storage_location = match skill_type {
42            AgentSkillType::EXTERNAL => {
43                // External skills MUST have an explicit storage location that
44                // lives within a registered external location and does not
45                // overlap any existing table or volume.
46                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                // Managed skills derive their storage location from the managed
60                // parent location resolved for the schema/catalog, appending an
61                // `agent_skills/{id}` segment, mirroring managed volumes. The id
62                // is allocated here and persisted so the path equals the skill's
63                // id and survives renames. A caller cannot supply a location.
64                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        // Load the current record so omitted update fields (and the immutable
154        // storage location / type / id) are preserved across the rename-style update.
155        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    /// Register a credential + external location at `url`.
278    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    /// Create catalog `cat` (rooted at `storage_root`) and schema `sch` so a
308    /// managed skill can resolve a managed storage root.
309    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        // Managed namespace (catalog/schema) plus a separate external location
432        // covering the skill's own path.
433        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}