1mod binding;
4mod context;
5mod interaction;
6mod model;
7mod resources;
8mod trace;
9mod workspace;
10
11use std::{collections::BTreeMap, sync::Arc, time::Duration};
12
13use agent_infra_sdk::{
14 BearerCredential, CallOptions, CredentialsProvider, DelegationLeaseCredentials, InfraClient,
15 InfraClientBuilder, InfraClientError, StaticCredentials,
16};
17use async_trait::async_trait;
18use runtime_ports::{
19 CommitDisposition, ExecutionDelegationLease, ExecutionSessionFactory, ExecutionSessions,
20 OperationContext, PortFailure, PortFailureKind, SessionScope,
21};
22use runtime_types::{
23 CallerScope, CredentialHandle, DelegationLeaseRef, ExecutionId, OperationId, RequestAuthority,
24};
25
26pub use binding::{RuntimeBinding, StaticRuntimeBindingResolver};
27use context::{
28 AgentInfraContextAuditSession, AgentInfraConversationSession, NullContextAuditSession,
29 NullConversationSession,
30};
31use interaction::{InMemoryInteractionBroker, PendingInteractionSession};
32use model::AgentInfraModelSession;
33use resources::BindingResourceSession;
34use trace::AgentInfraTraceSession;
35use workspace::AgentInfraWorkspaceSession;
36
37#[derive(Debug, Clone)]
38pub struct AgentInfraConfig {
39 pub gateway_url: String,
40 pub request_timeout: Duration,
41 pub max_response_bytes: usize,
42 pub trusted_mesh_http: bool,
43 pub workload_subject: String,
44 pub workload_credential: CredentialHandle,
45 pub delegation_ttl: Duration,
46}
47
48impl AgentInfraConfig {
49 pub fn validate(&self) -> Result<(), PortFailure> {
50 if self.gateway_url.trim().is_empty() {
51 return Err(PortFailure::new(
52 PortFailureKind::Invalid,
53 "GATEWAY_URL_EMPTY",
54 "Agent Infra Gateway URL must not be empty",
55 ));
56 }
57 if self.workload_subject.trim().is_empty()
58 || !(Duration::from_secs(30)..=Duration::from_secs(7 * 24 * 60 * 60))
59 .contains(&self.delegation_ttl)
60 {
61 return Err(PortFailure::new(
62 PortFailureKind::Invalid,
63 "DELEGATION_CONFIG_INVALID",
64 "workload subject and delegation TTL within 30s..=7d are required",
65 ));
66 }
67 if self.request_timeout.is_zero() || self.request_timeout > Duration::from_secs(600) {
68 return Err(PortFailure::new(
69 PortFailureKind::Invalid,
70 "GATEWAY_TIMEOUT_INVALID",
71 "Agent Infra request timeout must be within 1ns..=600s",
72 ));
73 }
74 if !(1024..=1024 * 1024 * 1024).contains(&self.max_response_bytes) {
75 return Err(PortFailure::new(
76 PortFailureKind::Invalid,
77 "GATEWAY_RESPONSE_LIMIT_INVALID",
78 "Agent Infra response limit must be within 1 KiB..=1 GiB",
79 ));
80 }
81 Ok(())
82 }
83}
84
85pub struct AgentInfraSessionFactory {
86 config: AgentInfraConfig,
87 interactions: Arc<InMemoryInteractionBroker>,
88 workload_credentials: Arc<dyn CredentialsProvider>,
89}
90
91impl AgentInfraSessionFactory {
92 pub fn new(config: AgentInfraConfig) -> Result<Self, PortFailure> {
93 config.validate()?;
94 let workload_credentials = Arc::new(
95 StaticCredentials::new(config.workload_credential.expose()).map_err(|_| {
96 PortFailure::new(
97 PortFailureKind::Invalid,
98 "WORKLOAD_CREDENTIAL_INVALID",
99 "Agent Infra workload credential is invalid",
100 )
101 })?,
102 );
103 Ok(Self {
104 config,
105 interactions: Arc::new(InMemoryInteractionBroker::new(10_000)),
106 workload_credentials,
107 })
108 }
109
110 pub async fn check_ready(&self) -> Result<(), PortFailure> {
111 InfraClientBuilder::new(self.config.gateway_url.clone())
112 .trusted_mesh_http(self.config.trusted_mesh_http)
113 .request_timeout(self.config.request_timeout)
114 .build()
115 .map_err(map_sdk_error)?
116 .gateway()
117 .ready()
118 .await
119 .map_err(map_sdk_error)
120 .and_then(|status| {
121 if status.is_ready() {
122 Ok(())
123 } else {
124 Err(PortFailure::new(
125 PortFailureKind::Unavailable,
126 "GATEWAY_NOT_READY",
127 "Agent Infra Gateway is not ready",
128 ))
129 }
130 })
131 }
132
133 fn delegated_client(
134 &self,
135 delegation: &ExecutionDelegationLease,
136 scope: &SessionScope,
137 ) -> Result<Arc<InfraClient>, PortFailure> {
138 let capabilities = delegation_capabilities(scope);
139 let provider: Arc<dyn CredentialsProvider> = Arc::new(
140 if self.config.trusted_mesh_http {
141 DelegationLeaseCredentials::new_trusted_mesh_http(
142 self.config.gateway_url.clone(),
143 delegation.lease_ref.as_str(),
144 self.workload_credentials.clone(),
145 capabilities,
146 )
147 } else {
148 DelegationLeaseCredentials::new(
149 self.config.gateway_url.clone(),
150 delegation.lease_ref.as_str(),
151 self.workload_credentials.clone(),
152 capabilities,
153 )
154 }
155 .map_err(|_| {
156 PortFailure::new(
157 PortFailureKind::Invalid,
158 "DELEGATION_PROVIDER_INVALID",
159 "execution delegation credential provider could not be configured",
160 )
161 })?,
162 );
163 InfraClientBuilder::new(self.config.gateway_url.clone())
164 .credentials(provider)
165 .request_timeout(self.config.request_timeout)
166 .max_response_bytes(self.config.max_response_bytes)
167 .trusted_mesh_http(self.config.trusted_mesh_http)
168 .user_agent(format!("agent-runtime/{}", env!("CARGO_PKG_VERSION")))
169 .build()
170 .map(Arc::new)
171 .map_err(map_sdk_error)
172 }
173}
174
175#[async_trait]
176impl ExecutionSessionFactory for AgentInfraSessionFactory {
177 async fn establish_delegation(
178 &self,
179 operation: &OperationContext,
180 authority: &RequestAuthority,
181 scope: &SessionScope,
182 ) -> Result<ExecutionDelegationLease, PortFailure> {
183 let client = InfraClientBuilder::new(self.config.gateway_url.clone())
184 .credentials(self.workload_credentials.clone())
185 .trusted_mesh_http(self.config.trusted_mesh_http)
186 .request_timeout(self.config.request_timeout)
187 .build()
188 .map_err(map_sdk_error)?;
189 let caller = BearerCredential::new(authority.credential.expose()).map_err(|_| {
190 PortFailure::new(
191 PortFailureKind::Forbidden,
192 "CALLER_CREDENTIAL_INVALID",
193 "transient caller credential is invalid",
194 )
195 })?;
196 let lease = client
197 .gateway()
198 .establish_execution_delegation(
199 caller,
200 &agent_infra_sdk::gateway_contract::EstablishExecutionDelegationRequest {
201 execution_id: scope.execution_id.to_string(),
202 workload_subject: self.config.workload_subject.clone(),
203 audience_capabilities: delegation_capabilities(scope),
204 expires_in_seconds: self
205 .config
206 .delegation_ttl
207 .as_secs()
208 .max(operation.remaining()?.as_secs().saturating_add(30))
209 .min(7 * 24 * 60 * 60),
210 idempotency_key: format!("{}:delegation", scope.execution_id),
211 },
212 CallOptions::default()
213 .deadline(operation.remaining()?)
214 .request_id(operation.id.to_string()),
215 )
216 .await
217 .map_err(map_sdk_error)?;
218 if lease.execution_id != scope.execution_id.as_str() {
219 return Err(PortFailure::new(
220 PortFailureKind::Protocol,
221 "DELEGATION_EXECUTION_MISMATCH",
222 "Agent Infra delegation is bound to a different execution",
223 ));
224 }
225 Ok(ExecutionDelegationLease {
226 lease_ref: DelegationLeaseRef::new(lease.lease_ref).map_err(|_| {
227 PortFailure::new(
228 PortFailureKind::Protocol,
229 "DELEGATION_REFERENCE_INVALID",
230 "Agent Infra returned an invalid delegation reference",
231 )
232 })?,
233 expires_at_seconds: lease.expires_at_seconds,
234 revision: lease.revision,
235 })
236 }
237
238 async fn create(
239 &self,
240 _: &OperationContext,
241 _: &CallerScope,
242 delegation: &ExecutionDelegationLease,
243 scope: &SessionScope,
244 ) -> Result<ExecutionSessions, PortFailure> {
245 let client = self.delegated_client(delegation, scope)?;
246 let (conversation, context_audit): (
247 Arc<dyn runtime_ports::ConversationSession>,
248 Arc<dyn runtime_ports::ContextAuditSession>,
249 ) = match &scope.conversation_id {
250 Some(id) => (
251 Arc::new(AgentInfraConversationSession::new(
252 client.clone(),
253 id.clone(),
254 scope.execution_id.clone(),
255 scope.resolved.agent_id.clone(),
256 scope.resolved.runtime_type.clone(),
257 )),
258 Arc::new(AgentInfraContextAuditSession::new(
259 client.clone(),
260 id.clone(),
261 scope.resolved.agent_id.clone(),
262 scope.resolved.runtime_type.clone(),
263 )),
264 ),
265 None => (
266 Arc::new(NullConversationSession),
267 Arc::new(NullContextAuditSession),
268 ),
269 };
270 Ok(ExecutionSessions {
271 model: Arc::new(AgentInfraModelSession::new(client.clone())),
272 conversation,
273 context_audit,
274 workspace: Arc::new(AgentInfraWorkspaceSession::new(
275 client.clone(),
276 scope.resolved.workspace_id.clone(),
277 )),
278 trace: Arc::new(AgentInfraTraceSession::new(
279 client,
280 scope.execution_id.clone(),
281 scope.conversation_id.clone(),
282 scope.resolved.agent_id.clone(),
283 scope.resolved.runtime_type.clone(),
284 trace_metadata(&scope.resolved),
285 )),
286 interaction: Arc::new(PendingInteractionSession::new(self.interactions.clone())),
287 subagent: Arc::new(UnsupportedSubagentSession),
288 resources: Arc::new(BindingResourceSession::new(scope.resolved.clone())),
289 })
290 }
291
292 async fn submit_input(
293 &self,
294 _: &OperationContext,
295 _: &CallerScope,
296 execution_id: &ExecutionId,
297 operation_id: &OperationId,
298 request_id: &str,
299 response: runtime_ports::InteractionResponse,
300 ) -> Result<(), PortFailure> {
301 self.interactions
302 .submit(execution_id, operation_id, request_id, response)
303 .await
304 }
305
306 async fn renew_delegation(
307 &self,
308 operation: &OperationContext,
309 _: &CallerScope,
310 delegation: &ExecutionDelegationLease,
311 ) -> Result<ExecutionDelegationLease, PortFailure> {
312 let client = InfraClientBuilder::new(self.config.gateway_url.clone())
313 .credentials(self.workload_credentials.clone())
314 .trusted_mesh_http(self.config.trusted_mesh_http)
315 .build()
316 .map_err(map_sdk_error)?;
317 let renewed = client
318 .gateway()
319 .renew_execution_delegation(
320 &agent_infra_sdk::gateway_contract::RenewExecutionDelegationRequest {
321 lease_ref: delegation.lease_ref.as_str().into(),
322 expected_revision: delegation.revision,
323 extend_by_seconds: self
324 .config
325 .delegation_ttl
326 .as_secs()
327 .max(operation.remaining()?.as_secs().saturating_add(30))
328 .min(7 * 24 * 60 * 60),
329 idempotency_key: format!(
330 "{}:renew:{}",
331 operation.execution_id, delegation.revision
332 ),
333 },
334 CallOptions::default()
335 .deadline(operation.remaining()?)
336 .request_id(operation.id.to_string()),
337 )
338 .await
339 .map_err(map_sdk_error)?;
340 Ok(ExecutionDelegationLease {
341 lease_ref: DelegationLeaseRef::new(renewed.lease_ref).map_err(|_| {
342 PortFailure::new(
343 PortFailureKind::Protocol,
344 "DELEGATION_REFERENCE_INVALID",
345 "Agent Infra returned an invalid delegation reference",
346 )
347 })?,
348 expires_at_seconds: renewed.expires_at_seconds,
349 revision: renewed.revision,
350 })
351 }
352
353 async fn revoke_delegation(
354 &self,
355 operation: &OperationContext,
356 _: &CallerScope,
357 delegation: &ExecutionDelegationLease,
358 ) -> Result<(), PortFailure> {
359 let client = InfraClientBuilder::new(self.config.gateway_url.clone())
360 .credentials(self.workload_credentials.clone())
361 .trusted_mesh_http(self.config.trusted_mesh_http)
362 .build()
363 .map_err(map_sdk_error)?;
364 client
365 .gateway()
366 .revoke_execution_delegation(
367 &agent_infra_sdk::gateway_contract::RevokeExecutionDelegationRequest {
368 lease_ref: delegation.lease_ref.as_str().into(),
369 idempotency_key: format!("{}:revoke", operation.execution_id),
370 },
371 CallOptions::default()
372 .deadline(operation.remaining()?)
373 .request_id(operation.id.to_string()),
374 )
375 .await
376 .map_err(map_sdk_error)
377 }
378}
379
380fn delegation_capabilities(scope: &SessionScope) -> BTreeMap<String, Vec<String>> {
381 let mut capabilities = BTreeMap::from([
382 (
383 "agent-model-infra".into(),
384 vec![format!("model-profile:{}:invoke", scope.resolved.model)],
385 ),
386 (
387 "workspace".into(),
388 vec![
389 format!("workspace:{}:read", scope.resolved.workspace_id),
390 format!("workspace:{}:write", scope.resolved.workspace_id),
391 format!("workspace:{}:execute", scope.resolved.workspace_id),
392 ],
393 ),
394 (
395 "agent-trace-infra".into(),
396 vec![format!("trace:{}:write", scope.execution_id)],
397 ),
398 ]);
399 if let Some(conversation_id) = &scope.conversation_id {
400 capabilities.insert(
401 "agent-context-infra".into(),
402 vec![
403 format!("conversation:{conversation_id}:read"),
404 format!("conversation:{conversation_id}:append"),
405 format!("context-audit:{conversation_id}:write"),
406 ],
407 );
408 }
409 capabilities
410}
411
412fn trace_metadata(resolved: &runtime_ports::ResolvedExecutionContext) -> serde_json::Value {
417 let task = resolved
418 .metadata
419 .iter()
420 .filter_map(|(key, value)| {
421 key.strip_prefix("task.")
422 .map(|key| (key.to_string(), serde_json::Value::String(value.clone())))
423 })
424 .collect::<serde_json::Map<_, _>>();
425 serde_json::json!({
426 "workspaceId": resolved.workspace_id.as_str(),
427 "model": resolved.model,
428 "definitionId": resolved.definition_id,
429 "definitionVersion": resolved.definition_version,
430 "task": task,
431 })
432}
433
434struct UnsupportedSubagentSession;
435#[async_trait]
436impl runtime_ports::SubagentSession for UnsupportedSubagentSession {
437 async fn execute(
438 &self,
439 _: &OperationContext,
440 _: runtime_ports::SubagentRequest,
441 ) -> Result<runtime_ports::SubagentOutcome, PortFailure> {
442 Err(PortFailure::new(
443 PortFailureKind::Unavailable,
444 "SUBAGENT_LINK_NOT_CONFIGURED",
445 "subagent execution requires the Runtime Link child-execution adapter",
446 ))
447 }
448}
449
450pub(crate) fn map_sdk_error(error: InfraClientError) -> PortFailure {
451 let commit = match &error {
456 InfraClientError::ClientBuild(_)
457 | InfraClientError::InvalidOptions { .. }
458 | InfraClientError::Credential { .. }
459 | InfraClientError::InvalidEndpoint { .. } => CommitDisposition::NotCommitted,
460 InfraClientError::OperationTerminal { .. } => CommitDisposition::Committed,
461 InfraClientError::HttpStatus { status, .. }
462 if matches!(*status, 400 | 401 | 403 | 404 | 405 | 413 | 415 | 422 | 429) =>
463 {
464 CommitDisposition::NotCommitted
465 }
466 InfraClientError::HttpStatus { .. } => CommitDisposition::Unknown,
467 InfraClientError::Request { .. }
468 | InfraClientError::DeadlineExceeded { .. }
469 | InfraClientError::Canceled { .. }
470 | InfraClientError::ResponseTooLarge { .. }
471 | InfraClientError::Decode { .. }
472 | InfraClientError::Protocol { .. } => CommitDisposition::Unknown,
473 };
474 let retryable = error.retryable();
475 let code = error.code().to_string();
476 let kind = match code.as_str() {
477 "TIMEOUT" => PortFailureKind::Timeout,
478 "CANCELED" => PortFailureKind::Canceled,
479 "UNAUTHENTICATED" => PortFailureKind::Forbidden,
480 "FORBIDDEN" => PortFailureKind::Forbidden,
481 "NOT_FOUND" => PortFailureKind::NotFound,
482 "CONFLICT" => PortFailureKind::Conflict,
483 "RATE_LIMITED" => PortFailureKind::RateLimited,
484 "DECODE" | "PROTOCOL" => PortFailureKind::Protocol,
485 _ => PortFailureKind::Unavailable,
486 };
487 let message = match kind {
491 PortFailureKind::Timeout => "Agent Infra operation exceeded its deadline",
492 PortFailureKind::Canceled => "Agent Infra operation was canceled",
493 PortFailureKind::Forbidden => "Agent Infra rejected the execution authority",
494 PortFailureKind::NotFound => "Agent Infra resource was not found",
495 PortFailureKind::Conflict => "Agent Infra operation conflicted with current state",
496 PortFailureKind::RateLimited => "Agent Infra rate limit was exceeded",
497 PortFailureKind::Protocol => "Agent Infra returned an invalid protocol response",
498 _ => "Agent Infra dependency is unavailable",
499 };
500 let mut failure = PortFailure::new(kind, code, message);
501 failure.retryable = retryable;
502 failure.commit = commit;
503 failure
504}
505
506#[cfg(test)]
507mod sdk_error_tests {
508 use super::*;
509
510 #[test]
511 fn trace_metadata_includes_task_correlation_but_not_instructions() {
512 let resolved = runtime_ports::ResolvedExecutionContext {
513 runtime_instance_id: runtime_types::RuntimeInstanceId::new("runtime-1").unwrap(),
514 agent_id: "agt_test".into(),
515 runtime_type: "test".into(),
516 model: "model-1".into(),
517 workspace_id: runtime_types::WorkspaceId::new("workspace-1").unwrap(),
518 definition_id: "definition-1".into(),
519 definition_version: "1".into(),
520 definition_digest: None,
521 metadata: BTreeMap::from([
522 ("instructions".into(), "secret prompt".into()),
523 ("task.traceId".into(), "trace-1".into()),
524 ]),
525 };
526 let metadata = trace_metadata(&resolved);
527 assert_eq!(metadata["workspaceId"], "workspace-1");
528 assert_eq!(metadata["task"]["traceId"], "trace-1");
529 assert!(!metadata.to_string().contains("secret prompt"));
530 }
531
532 #[test]
533 fn preserves_commit_disposition_when_erasing_sdk_errors() {
534 let before_send = map_sdk_error(InfraClientError::InvalidOptions {
535 message: "bad options".into(),
536 });
537 assert_eq!(before_send.commit, CommitDisposition::NotCommitted);
538
539 let server_failure = map_sdk_error(InfraClientError::HttpStatus {
540 service: "model",
541 status: 503,
542 code: "UNAVAILABLE".into(),
543 message: "unavailable".into(),
544 retryable: true,
545 request_id: Some("request-1".into()),
546 retry_after: None,
547 });
548 assert_eq!(server_failure.commit, CommitDisposition::Unknown);
549
550 let rejected = map_sdk_error(InfraClientError::HttpStatus {
551 service: "model",
552 status: 422,
553 code: "INVALID".into(),
554 message: "invalid".into(),
555 retryable: false,
556 request_id: Some("request-2".into()),
557 retry_after: None,
558 });
559 assert_eq!(rejected.commit, CommitDisposition::NotCommitted);
560
561 let invalid_response = map_sdk_error(InfraClientError::Protocol {
562 service: "model",
563 message: "invalid response".into(),
564 });
565 assert_eq!(invalid_response.commit, CommitDisposition::Unknown);
566 }
567}