Skip to main content

agent_runtime_infra_adapter/
lib.rs

1//! Agent Infra SDK adapters for Runtime ports.
2
3mod 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
412/// Trace correlation is intentionally allow-listed. Request instructions may
413/// contain user data or secrets and therefore never become searchable Trace
414/// metadata. Only task-prefixed metadata already bounded by the HTTP contract
415/// is projected alongside stable resource identities.
416fn 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    // Classify before erasing the SDK error. A response proves the server saw
452    // the operation, while transport/deadline/decode failures after dispatch
453    // cannot prove whether a write committed. Callers may narrow this for a
454    // known read-only operation, but must never widen Unknown to NotCommitted.
455    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    // SDK displays may contain endpoint details from reqwest. Runtime-facing
488    // failures deliberately retain only the stable class; request IDs remain
489    // in SDK telemetry and must not turn into model/public payload.
490    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}