1use std::collections::BTreeMap;
2use std::convert::Infallible;
3use std::future::Future;
4
5use tonic::codegen::async_trait;
6
7use crate::agent_provider::AgentToolRef;
8use crate::catalog::Catalog;
9use crate::error::{Error, Result};
10use crate::proto::v1;
11use crate::rpc_support::GestaltError;
12
13pub fn parse_subject_id(subject_id: &str) -> Option<(&str, &str)> {
15 let trimmed = subject_id.trim();
16 let (kind, id) = trimmed.split_once(':')?;
17 let kind = kind.trim();
18 let id = id.trim();
19 if kind.is_empty() || id.is_empty() {
20 return None;
21 }
22 Some((kind, id))
23}
24
25#[derive(Clone, Debug, Default, Eq, PartialEq)]
26pub struct Subject {
28 pub id: String,
30 pub email: String,
32 pub display_name: String,
34}
35
36#[derive(Clone, Debug, Default, Eq, PartialEq)]
37pub struct Credential {
39 pub mode: String,
41 pub subject_id: String,
43 pub connection: String,
45 pub instance: String,
47}
48
49#[derive(Clone, Debug, Default, Eq, PartialEq)]
50pub struct Access {
52 pub policy: String,
54 pub role: String,
56}
57
58#[derive(Clone, Debug, Default, Eq, PartialEq)]
59pub struct Host {
61 pub public_base_url: String,
63}
64
65#[derive(Clone, Debug, Default, PartialEq)]
66pub struct Request {
68 pub token: String,
70 pub connection_params: BTreeMap<String, String>,
72 pub subject: Subject,
74 pub agent_subject: Subject,
76 pub credential: Credential,
78 pub access: Access,
80 pub host: Host,
82 pub idempotency_key: String,
84 pub workflow: serde_json::Map<String, serde_json::Value>,
88 pub tool_refs: Vec<AgentToolRef>,
90 pub tool_refs_set: bool,
92}
93
94tokio::task_local! {
95 static REQUEST_CONTEXT: Option<v1::RequestContext>;
96}
97
98impl Request {
99 pub fn connection_param(&self, name: &str) -> Option<&str> {
101 self.connection_params.get(name).map(String::as_str)
102 }
103
104 pub async fn gestalt(
106 &self,
107 ) -> std::result::Result<crate::public::bound::BoundGestaltClient, GestaltError> {
108 crate::public::gestalt_from_context().await
109 }
110}
111
112pub fn current_request_context() -> Option<v1::RequestContext> {
114 REQUEST_CONTEXT.try_with(Clone::clone).ok().flatten()
115}
116
117pub fn current_native_request_context() -> Option<crate::app::RequestContext> {
121 current_request_context().map(native_request_context)
122}
123
124fn native_request_context(value: v1::RequestContext) -> crate::app::RequestContext {
125 use crate::codec::app::{from_wire_agent_tool_ref, from_wire_subject_context};
126 use crate::codec::support::from_wire_struct;
127
128 crate::app::RequestContext {
129 subject: value.subject.map(from_wire_subject_context),
130 credential: value
131 .credential
132 .map(|credential| crate::app::CredentialContext {
133 mode: credential.mode,
134 subject_id: credential.subject_id,
135 connection: credential.connection,
136 instance: credential.instance,
137 }),
138 access: value.access.map(|access| crate::app::AccessContext {
139 policy: access.policy,
140 role: access.role,
141 }),
142 workflow: value.workflow.map(from_wire_struct),
143 host: value.host.map(|host| crate::app::HostContext {
144 public_base_url: host.public_base_url,
145 }),
146 agent_subject: value.agent_subject.map(from_wire_subject_context),
147 caller: value.caller.map(|caller| crate::app::ProviderContext {
148 kind: caller.kind,
149 name: caller.name,
150 }),
151 invocation: value
152 .invocation
153 .map(|invocation| crate::app::InvocationContext {
154 request_id: invocation.request_id,
155 depth: invocation.depth,
156 call_chain: invocation.call_chain,
157 surface: invocation.surface,
158 internal_connection_access: invocation.internal_connection_access,
159 connection: invocation.connection,
160 }),
161 tool_refs: value
162 .tool_refs
163 .into_iter()
164 .map(from_wire_agent_tool_ref)
165 .collect(),
166 tool_refs_set: value.tool_refs_set,
167 request_meta: value
168 .request_meta
169 .map(|meta| crate::app::RequestMetaContext {
170 client_ip: meta.client_ip,
171 remote_addr: meta.remote_addr,
172 user_agent: meta.user_agent,
173 }),
174 agent: value.agent.map(|agent| crate::app::AgentInvocationContext {
175 provider_name: agent.provider_name,
176 session_id: agent.session_id,
177 turn_id: agent.turn_id,
178 }),
179 }
180}
181
182pub async fn with_request_context<F>(context: Option<v1::RequestContext>, future: F) -> F::Output
184where
185 F: Future,
186{
187 REQUEST_CONTEXT.scope(context, future).await
188}
189
190pub(crate) async fn scope_request_context<F>(
191 context: Option<v1::RequestContext>,
192 future: F,
193) -> F::Output
194where
195 F: Future,
196{
197 with_request_context(context, future).await
198}
199
200#[derive(Clone, Debug, Default, PartialEq)]
201pub struct HTTPSubjectRequest {
203 pub binding: String,
205 pub method: String,
207 pub path: String,
209 pub content_type: String,
211 pub headers: BTreeMap<String, Vec<String>>,
213 pub query: BTreeMap<String, Vec<String>>,
215 pub params: serde_json::Map<String, serde_json::Value>,
217 pub raw_body: Vec<u8>,
219 pub security_scheme: String,
221 pub verified_subject: String,
223 pub verified_claims: BTreeMap<String, String>,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct Response<T> {
230 pub status: Option<u16>,
232 pub headers: BTreeMap<String, Vec<String>>,
234 pub body: T,
236}
237
238impl<T> Response<T> {
239 pub fn new(status: u16, body: T) -> Self {
241 Self {
242 status: Some(status),
243 headers: BTreeMap::new(),
244 body,
245 }
246 }
247
248 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
250 self.headers
251 .entry(name.into())
252 .or_default()
253 .push(value.into());
254 self
255 }
256}
257
258pub fn ok<T>(body: T) -> Response<T> {
260 Response::new(200, body)
261}
262
263pub trait IntoResponse<T> {
265 fn into_response(self) -> Response<T>;
267}
268
269impl<T> IntoResponse<T> for Response<T> {
270 fn into_response(self) -> Response<T> {
271 self
272 }
273}
274
275impl<T> IntoResponse<T> for T {
276 fn into_response(self) -> Response<T> {
277 ok(self)
278 }
279}
280
281#[derive(Clone, Debug, Default, Eq, PartialEq)]
282pub struct RuntimeMetadata {
284 pub name: String,
286 pub display_name: String,
288 pub description: String,
290 pub version: String,
292}
293
294#[async_trait]
295pub trait Provider: Send + Sync + 'static {
297 async fn configure(
299 &self,
300 _name: &str,
301 _config: serde_json::Map<String, serde_json::Value>,
302 ) -> Result<()> {
303 Ok(())
304 }
305
306 fn metadata(&self) -> Option<RuntimeMetadata> {
308 None
309 }
310
311 fn warnings(&self) -> Vec<String> {
313 Vec::new()
314 }
315
316 async fn health_check(&self) -> Result<()> {
318 Ok(())
319 }
320
321 async fn start(&self) -> Result<()> {
323 Ok(())
324 }
325
326 fn supports_session_catalog(&self) -> bool {
329 false
330 }
331
332 fn workflow_definitions(&self) -> Vec<crate::workflow::WorkflowDefinitionSpec> {
334 Vec::new()
335 }
336
337 async fn catalog_for_request(&self, _request: &Request) -> Result<Option<Catalog>> {
339 Ok(None)
340 }
341
342 async fn resolve_http_subject(
344 &self,
345 _request: HTTPSubjectRequest,
346 _context: &Request,
347 ) -> Result<Option<Subject>> {
348 Ok(None)
349 }
350
351 async fn close(&self) -> Result<()> {
353 Ok(())
354 }
355}
356
357impl From<Infallible> for Error {
358 fn from(_value: Infallible) -> Self {
359 Error::internal("unreachable infallible error")
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::protocol;
367
368 #[tokio::test]
369 async fn converts_fully_populated_request_context() {
370 let wire = v1::RequestContext {
371 subject: Some(v1::SubjectContext {
372 id: "user:ada".to_string(),
373 email: "ada@example.test".to_string(),
374 display_name: "Ada".to_string(),
375 scopes: vec!["repo:read".to_string()],
376 permissions: vec![v1::SubjectPermissionContext {
377 app: "github".to_string(),
378 operations: vec!["issues.get".to_string()],
379 all_operations: false,
380 }],
381 }),
382 credential: Some(v1::CredentialContext {
383 mode: "user".to_string(),
384 subject_id: "user:cred".to_string(),
385 connection: "work".to_string(),
386 instance: "primary".to_string(),
387 }),
388 access: Some(v1::AccessContext {
389 policy: "default".to_string(),
390 role: "admin".to_string(),
391 }),
392 workflow: Some(
393 protocol::struct_from_json(serde_json::json!({
394 "runId": "run-1",
395 "trigger": { "activationId": "act-1" },
396 }))
397 .expect("workflow struct"),
398 ),
399 host: Some(v1::HostContext {
400 public_base_url: "https://gestalt.example.test".to_string(),
401 }),
402 agent_subject: Some(v1::SubjectContext {
403 id: "agent:caller".to_string(),
404 ..Default::default()
405 }),
406 caller: Some(v1::ProviderContext {
407 kind: "app".to_string(),
408 name: "hermes".to_string(),
409 }),
410 invocation: Some(v1::InvocationContext {
411 request_id: "req-1".to_string(),
412 depth: 2,
413 call_chain: vec!["hermes".to_string(), "github".to_string()],
414 surface: "mcp".to_string(),
415 internal_connection_access: true,
416 connection: "work".to_string(),
417 }),
418 tool_refs: vec![v1::AgentToolRef {
419 app: "slack".to_string(),
420 operation: "chat.postMessage".to_string(),
421 connection: "workspace".to_string(),
422 instance: "primary".to_string(),
423 title: "Send Slack message".to_string(),
424 description: "Post a Slack message".to_string(),
425 credential_mode: "user".to_string(),
426 system: "slack".to_string(),
427 run_as: Some(v1::SubjectContext {
428 id: "user:run-as".to_string(),
429 ..Default::default()
430 }),
431 }],
432 tool_refs_set: true,
433 request_meta: Some(v1::RequestMetaContext {
434 client_ip: "203.0.113.7".to_string(),
435 remote_addr: "203.0.113.7:443".to_string(),
436 user_agent: "gestalt-test".to_string(),
437 }),
438 agent: Some(v1::AgentInvocationContext {
439 provider_name: "openai".to_string(),
440 session_id: "session-1".to_string(),
441 turn_id: "turn-1".to_string(),
442 }),
443 };
444
445 let native = with_request_context(Some(wire), async { current_native_request_context() })
446 .await
447 .expect("native context");
448
449 assert_eq!(
450 native,
451 crate::app::RequestContext {
452 subject: Some(crate::app::SubjectContext {
453 id: "user:ada".to_string(),
454 email: "ada@example.test".to_string(),
455 display_name: "Ada".to_string(),
456 scopes: vec!["repo:read".to_string()],
457 permissions: vec![crate::app::SubjectPermissionContext {
458 app: "github".to_string(),
459 operations: vec!["issues.get".to_string()],
460 all_operations: false,
461 }],
462 }),
463 credential: Some(crate::app::CredentialContext {
464 mode: "user".to_string(),
465 subject_id: "user:cred".to_string(),
466 connection: "work".to_string(),
467 instance: "primary".to_string(),
468 }),
469 access: Some(crate::app::AccessContext {
470 policy: "default".to_string(),
471 role: "admin".to_string(),
472 }),
473 workflow: serde_json::json!({
474 "runId": "run-1",
475 "trigger": { "activationId": "act-1" },
476 })
477 .as_object()
478 .cloned(),
479 host: Some(crate::app::HostContext {
480 public_base_url: "https://gestalt.example.test".to_string(),
481 }),
482 agent_subject: Some(crate::app::SubjectContext {
483 id: "agent:caller".to_string(),
484 ..Default::default()
485 }),
486 caller: Some(crate::app::ProviderContext {
487 kind: "app".to_string(),
488 name: "hermes".to_string(),
489 }),
490 invocation: Some(crate::app::InvocationContext {
491 request_id: "req-1".to_string(),
492 depth: 2,
493 call_chain: vec!["hermes".to_string(), "github".to_string()],
494 surface: "mcp".to_string(),
495 internal_connection_access: true,
496 connection: "work".to_string(),
497 }),
498 tool_refs: vec![crate::app::AgentToolRef {
499 app: "slack".to_string(),
500 operation: "chat.postMessage".to_string(),
501 connection: "workspace".to_string(),
502 instance: "primary".to_string(),
503 title: "Send Slack message".to_string(),
504 description: "Post a Slack message".to_string(),
505 credential_mode: "user".to_string(),
506 system: "slack".to_string(),
507 run_as: Some(crate::app::SubjectContext {
508 id: "user:run-as".to_string(),
509 ..Default::default()
510 }),
511 }],
512 tool_refs_set: true,
513 request_meta: Some(crate::app::RequestMetaContext {
514 client_ip: "203.0.113.7".to_string(),
515 remote_addr: "203.0.113.7:443".to_string(),
516 user_agent: "gestalt-test".to_string(),
517 }),
518 agent: Some(crate::app::AgentInvocationContext {
519 provider_name: "openai".to_string(),
520 session_id: "session-1".to_string(),
521 turn_id: "turn-1".to_string(),
522 }),
523 }
524 );
525 }
526
527 #[tokio::test]
528 async fn converts_sparse_request_context() {
529 let native = with_request_context(Some(v1::RequestContext::default()), async {
530 current_native_request_context()
531 })
532 .await
533 .expect("native context");
534
535 assert_eq!(native, crate::app::RequestContext::default());
536 }
537
538 #[test]
539 fn returns_none_outside_request_scope() {
540 assert!(current_native_request_context().is_none());
541 }
542}