Skip to main content

unitycatalog_server/api/
agents.rs

1use itertools::Itertools;
2
3use unitycatalog_common::models::ObjectLabel;
4use unitycatalog_common::models::agents::v0alpha1::*;
5use unitycatalog_common::models::{ResourceIdent, ResourceName, ResourceRef};
6
7use super::{RequestContext, SecuredAction};
8pub use crate::codegen::agents::AgentHandler;
9use crate::policy::{Permission, Policy, process_resources};
10use crate::store::ResourceStore;
11use crate::{Error, Result};
12
13#[async_trait::async_trait]
14impl<T: ResourceStore + Policy<RequestContext>> AgentHandler<RequestContext> for T {
15    #[tracing::instrument(skip(self, context), fields(resource_name))]
16    async fn create_agent(
17        &self,
18        request: CreateAgentRequest,
19        context: RequestContext,
20    ) -> Result<Agent> {
21        tracing::Span::current().record("resource_name", &request.name);
22        self.check_required(&request, &context).await?;
23
24        // Agents are metadata-only: validate the invocation protocol is a known
25        // variant (a 2-arg `try_from` rejects unspecified/unknown values) and
26        // persist the record. There is no storage location to resolve.
27        let protocol = request
28            .invocation_protocol
29            .as_known()
30            .filter(|p| *p != InvocationProtocol::InvocationProtocolUnspecified)
31            .ok_or_else(|| {
32                Error::invalid_argument("invocation_protocol must be a known protocol")
33            })?;
34
35        let full_name = format!(
36            "{}.{}.{}",
37            request.catalog_name, request.schema_name, request.name
38        );
39        // Pre-allocate the id so the created record (and its response) carries it;
40        // the store honors a pre-set id (else it mints a v7), matching catalogs.
41        let resource = Agent {
42            full_name,
43            name: request.name,
44            catalog_name: request.catalog_name,
45            schema_name: request.schema_name,
46            agent_id: uuid::Uuid::now_v7().hyphenated().to_string(),
47            invocation_protocol: protocol.into(),
48            endpoint: request.endpoint,
49            description: request.description,
50            capabilities: request.capabilities,
51            input_schema: request.input_schema,
52            comment: request.comment,
53            ..Default::default()
54        };
55        Ok(self.create(resource.into()).await?.0.try_into()?)
56    }
57
58    #[tracing::instrument(skip(self, context))]
59    async fn list_agents(
60        &self,
61        request: ListAgentsRequest,
62        context: RequestContext,
63    ) -> Result<ListAgentsResponse> {
64        self.check_required(&request, &context).await?;
65        let (mut resources, next_page_token) = self
66            .list(
67                &ObjectLabel::Agent,
68                Some(&ResourceName::new([
69                    &request.catalog_name,
70                    &request.schema_name,
71                ])),
72                request.max_results.map(|v| v as usize),
73                request.page_token,
74            )
75            .await?;
76        process_resources(self, &context, &Permission::Read, &mut resources).await?;
77        Ok(ListAgentsResponse {
78            agents: resources.into_iter().map(|r| r.try_into()).try_collect()?,
79            next_page_token,
80            ..Default::default()
81        })
82    }
83
84    #[tracing::instrument(skip(self, context), fields(resource_name))]
85    async fn get_agent(&self, request: GetAgentRequest, context: RequestContext) -> Result<Agent> {
86        tracing::Span::current().record("resource_name", &request.name);
87        self.check_required(&request, &context).await?;
88        Ok(self.get(&request.resource()).await?.0.try_into()?)
89    }
90
91    #[tracing::instrument(skip(self, context), fields(resource_name))]
92    async fn update_agent(
93        &self,
94        request: UpdateAgentRequest,
95        context: RequestContext,
96    ) -> Result<Agent> {
97        tracing::Span::current().record("resource_name", &request.name);
98        self.check_required(&request, &context).await?;
99        let ident = request.resource();
100        let name = ResourceName::from_naive_str_split(request.name.as_str());
101        let [catalog_name, schema_name, agent_name] = name.as_ref() else {
102            return Err(Error::invalid_argument(
103                "Invalid agent name - expected <catalog_name>.<schema_name>.<agent_name>",
104            ));
105        };
106        // Load the current record so omitted update fields keep their value.
107        let current: Agent = self.get(&ident).await?.0.try_into()?;
108        let new_name = request.new_name.as_deref().unwrap_or(agent_name);
109        let protocol = match request.invocation_protocol {
110            Some(p) => p
111                .as_known()
112                .filter(|p| *p != InvocationProtocol::InvocationProtocolUnspecified)
113                .ok_or_else(|| {
114                    Error::invalid_argument("invocation_protocol must be a known protocol")
115                })?
116                .into(),
117            None => current.invocation_protocol,
118        };
119        let resource = Agent {
120            name: new_name.to_owned(),
121            catalog_name: catalog_name.to_owned(),
122            schema_name: schema_name.to_owned(),
123            full_name: format!("{}.{}.{}", catalog_name, schema_name, new_name),
124            invocation_protocol: protocol,
125            endpoint: request.endpoint.unwrap_or(current.endpoint),
126            description: request.description.or(current.description),
127            capabilities: if request.capabilities.is_empty() {
128                current.capabilities
129            } else {
130                request.capabilities
131            },
132            input_schema: request.input_schema.or(current.input_schema),
133            comment: request.comment.or(current.comment),
134            owner: request.owner.or(current.owner),
135            ..Default::default()
136        };
137        Ok(self.update(&ident, resource.into()).await?.0.try_into()?)
138    }
139
140    #[tracing::instrument(skip(self, context), fields(resource_name))]
141    async fn delete_agent(
142        &self,
143        request: DeleteAgentRequest,
144        context: RequestContext,
145    ) -> Result<()> {
146        tracing::Span::current().record("resource_name", &request.name);
147        self.check_required(&request, &context).await?;
148        Ok(self.delete(&request.resource()).await?)
149    }
150}
151
152impl SecuredAction for CreateAgentRequest {
153    fn resource(&self) -> ResourceIdent {
154        ResourceIdent::agent(ResourceName::new([
155            self.catalog_name.as_str(),
156            self.schema_name.as_str(),
157            self.name.as_str(),
158        ]))
159    }
160
161    fn permission(&self) -> &'static Permission {
162        &Permission::Create
163    }
164}
165
166impl SecuredAction for ListAgentsRequest {
167    fn resource(&self) -> ResourceIdent {
168        ResourceIdent::agent(ResourceRef::Undefined)
169    }
170
171    fn permission(&self) -> &'static Permission {
172        &Permission::Read
173    }
174}
175
176impl SecuredAction for GetAgentRequest {
177    fn resource(&self) -> ResourceIdent {
178        ResourceIdent::agent(ResourceName::from_naive_str_split(self.name.as_str()))
179    }
180
181    fn permission(&self) -> &'static Permission {
182        &Permission::Read
183    }
184}
185
186impl SecuredAction for UpdateAgentRequest {
187    fn resource(&self) -> ResourceIdent {
188        ResourceIdent::agent(ResourceName::from_naive_str_split(self.name.as_str()))
189    }
190
191    fn permission(&self) -> &'static Permission {
192        &Permission::Manage
193    }
194}
195
196impl SecuredAction for DeleteAgentRequest {
197    fn resource(&self) -> ResourceIdent {
198        ResourceIdent::agent(ResourceName::from_naive_str_split(self.name.as_str()))
199    }
200
201    fn permission(&self) -> &'static Permission {
202        &Permission::Manage
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use std::sync::Arc;
209
210    use unitycatalog_common::models::catalogs::v1::CreateCatalogRequest;
211    use unitycatalog_common::models::credentials::v1::{
212        AwsIamRoleConfig, CreateCredentialRequest, Purpose,
213    };
214    use unitycatalog_common::models::external_locations::v1::CreateExternalLocationRequest;
215    use unitycatalog_common::models::schemas::v1::CreateSchemaRequest;
216    use unitycatalog_common::services::encryption::{EnvelopeEncryptor, LocalKeyProvider};
217
218    use super::*;
219    use crate::api::{CatalogHandler, CredentialHandler, ExternalLocationHandler, SchemaHandler};
220    use crate::memory::InMemoryResourceStore;
221    use crate::policy::ConstantPolicy;
222    use crate::services::ServerHandler;
223
224    fn handler() -> ServerHandler<RequestContext> {
225        let encryptor =
226            EnvelopeEncryptor::local(LocalKeyProvider::single("test", vec![0x42; 32]).unwrap());
227        let store = Arc::new(InMemoryResourceStore::new(encryptor));
228        let policy: Arc<dyn Policy<RequestContext>> = Arc::new(ConstantPolicy::default());
229        ServerHandler::try_new_tokio(policy, store).unwrap()
230    }
231
232    fn ctx() -> RequestContext {
233        RequestContext {
234            recipient: crate::policy::Principal::anonymous(),
235        }
236    }
237
238    /// Create catalog `cat` (rooted at a covered storage root) and schema `sch`
239    /// so agents can be created beneath them. Agents are metadata-only, but the
240    /// parent managed catalog still requires a resolvable storage root.
241    async fn setup_namespace(h: &ServerHandler<RequestContext>) {
242        h.create_credential(
243            CreateCredentialRequest {
244                name: "cred".to_string(),
245                purpose: Purpose::Storage.into(),
246                aws_iam_role: Some(AwsIamRoleConfig {
247                    role_arn: "arn:aws:iam::123456789012:role/test".to_string(),
248                    ..Default::default()
249                })
250                .into(),
251                ..Default::default()
252            },
253            ctx(),
254        )
255        .await
256        .unwrap();
257        h.create_external_location(
258            CreateExternalLocationRequest {
259                name: "el".to_string(),
260                url: "s3://bucket/cat".to_string(),
261                credential_name: "cred".to_string(),
262                ..Default::default()
263            },
264            ctx(),
265        )
266        .await
267        .unwrap();
268        h.create_catalog(
269            CreateCatalogRequest {
270                name: "cat".to_string(),
271                storage_root: Some("s3://bucket/cat".to_string()),
272                ..Default::default()
273            },
274            ctx(),
275        )
276        .await
277        .unwrap();
278        h.create_schema(
279            CreateSchemaRequest {
280                name: "sch".to_string(),
281                catalog_name: "cat".to_string(),
282                ..Default::default()
283            },
284            ctx(),
285        )
286        .await
287        .unwrap();
288    }
289
290    fn create_request(name: &str, protocol: InvocationProtocol) -> CreateAgentRequest {
291        CreateAgentRequest {
292            catalog_name: "cat".to_string(),
293            schema_name: "sch".to_string(),
294            name: name.to_string(),
295            invocation_protocol: protocol.into(),
296            endpoint: "https://agent.example.com".to_string(),
297            description: Some("does things".to_string()),
298            capabilities: vec!["sql_query".to_string()],
299            input_schema: Some("{\"type\":\"object\"}".to_string()),
300            comment: None,
301            ..Default::default()
302        }
303    }
304
305    #[tokio::test]
306    async fn create_get_round_trip() {
307        let h = handler();
308        setup_namespace(&h).await;
309
310        let created = h
311            .create_agent(create_request("agt", InvocationProtocol::Mcp), ctx())
312            .await
313            .unwrap();
314        assert_eq!(created.full_name, "cat.sch.agt");
315        assert_eq!(created.invocation_protocol, InvocationProtocol::Mcp);
316        assert_eq!(created.endpoint, "https://agent.example.com");
317        assert_eq!(created.capabilities, vec!["sql_query".to_string()]);
318        assert!(uuid::Uuid::parse_str(&created.agent_id).is_ok());
319
320        let got = h
321            .get_agent(
322                GetAgentRequest {
323                    name: "cat.sch.agt".to_string(),
324                    ..Default::default()
325                },
326                ctx(),
327            )
328            .await
329            .unwrap();
330        assert_eq!(got.agent_id, created.agent_id);
331        assert_eq!(got.input_schema.as_deref(), Some("{\"type\":\"object\"}"));
332    }
333
334    #[tokio::test]
335    async fn unspecified_protocol_is_rejected() {
336        let h = handler();
337        setup_namespace(&h).await;
338        let res = h
339            .create_agent(
340                create_request("agt", InvocationProtocol::InvocationProtocolUnspecified),
341                ctx(),
342            )
343            .await;
344        assert!(matches!(res, Err(Error::InvalidArgument(_))), "{res:?}");
345    }
346
347    #[tokio::test]
348    async fn list_and_delete() {
349        let h = handler();
350        setup_namespace(&h).await;
351        h.create_agent(create_request("a1", InvocationProtocol::Rest), ctx())
352            .await
353            .unwrap();
354        h.create_agent(create_request("a2", InvocationProtocol::Anthropic), ctx())
355            .await
356            .unwrap();
357
358        let listed = h
359            .list_agents(
360                ListAgentsRequest {
361                    catalog_name: "cat".to_string(),
362                    schema_name: "sch".to_string(),
363                    ..Default::default()
364                },
365                ctx(),
366            )
367            .await
368            .unwrap();
369        assert_eq!(listed.agents.len(), 2);
370
371        h.delete_agent(
372            DeleteAgentRequest {
373                name: "cat.sch.a1".to_string(),
374                ..Default::default()
375            },
376            ctx(),
377        )
378        .await
379        .unwrap();
380        let listed = h
381            .list_agents(
382                ListAgentsRequest {
383                    catalog_name: "cat".to_string(),
384                    schema_name: "sch".to_string(),
385                    ..Default::default()
386                },
387                ctx(),
388            )
389            .await
390            .unwrap();
391        assert_eq!(listed.agents.len(), 1);
392    }
393
394    #[tokio::test]
395    async fn update_preserves_omitted_fields() {
396        let h = handler();
397        setup_namespace(&h).await;
398        h.create_agent(create_request("agt", InvocationProtocol::Mcp), ctx())
399            .await
400            .unwrap();
401
402        // Update only the endpoint; protocol/description/capabilities are preserved.
403        let updated = h
404            .update_agent(
405                UpdateAgentRequest {
406                    name: "cat.sch.agt".to_string(),
407                    endpoint: Some("https://new.example.com".to_string()),
408                    ..Default::default()
409                },
410                ctx(),
411            )
412            .await
413            .unwrap();
414        assert_eq!(updated.endpoint, "https://new.example.com");
415        assert_eq!(updated.invocation_protocol, InvocationProtocol::Mcp);
416        assert_eq!(updated.capabilities, vec!["sql_query".to_string()]);
417        assert_eq!(updated.description.as_deref(), Some("does things"));
418    }
419
420    #[tokio::test]
421    async fn duplicate_name_is_rejected() {
422        let h = handler();
423        setup_namespace(&h).await;
424        h.create_agent(create_request("agt", InvocationProtocol::Mcp), ctx())
425            .await
426            .unwrap();
427        let err = h
428            .create_agent(create_request("agt", InvocationProtocol::Mcp), ctx())
429            .await
430            .expect_err("duplicate name must be rejected");
431        assert_eq!(err.error_code(), "RESOURCE_ALREADY_EXISTS", "{err:?}");
432    }
433}