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