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 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 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 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 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 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}