Skip to main content

relay_knowledge/api/
agent.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::domain::{
6    FreshnessPolicy, FusionDiagnostics, IndexStatus, RetrievalBackendStatus, RetrievalHit,
7    RetrievalMode, RetrievedContextPack,
8};
9use crate::project::{ACP_LOCAL_ADAPTER_NAME, MCP_ADAPTER_NAME};
10
11use super::{ApiMetadata, RequestContext};
12
13/// Agent protocol family used by external resident-process adapters.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum AgentProtocolKind {
17    Mcp,
18    Acp,
19}
20
21/// Runtime identity captured from an agent protocol request.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct RuntimeIdentity {
24    pub protocol: AgentProtocolKind,
25    pub adapter_name: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub adapter_version: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub client_name: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub client_version: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub host_name: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub actor_id: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub session_id: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub tool_call_id: Option<String>,
40}
41
42impl RuntimeIdentity {
43    /// Creates the resident MCP adapter identity for a single request.
44    pub fn mcp(tool_call_id: Option<String>) -> Self {
45        Self {
46            protocol: AgentProtocolKind::Mcp,
47            adapter_name: MCP_ADAPTER_NAME.to_owned(),
48            adapter_version: Some(env!("CARGO_PKG_VERSION").to_owned()),
49            client_name: None,
50            client_version: None,
51            host_name: None,
52            actor_id: None,
53            session_id: None,
54            tool_call_id,
55        }
56    }
57
58    /// Creates the local ACP adapter identity for one session request.
59    pub fn acp(
60        client_name: Option<String>,
61        client_version: Option<String>,
62        actor_id: Option<String>,
63        session_id: String,
64        request_id: Option<String>,
65    ) -> Self {
66        Self {
67            protocol: AgentProtocolKind::Acp,
68            adapter_name: ACP_LOCAL_ADAPTER_NAME.to_owned(),
69            adapter_version: Some(env!("CARGO_PKG_VERSION").to_owned()),
70            client_name,
71            client_version,
72            host_name: None,
73            actor_id,
74            session_id: Some(session_id),
75            tool_call_id: request_id,
76        }
77    }
78}
79
80/// Unified API context plus agent protocol identity and policy provenance.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct AgentRequestContext {
83    pub request: RequestContext,
84    pub runtime_identity: RuntimeIdentity,
85    pub policy_id: String,
86}
87
88/// Local access policy applied before agent protocol requests reach services.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct AgentAccessPolicy {
91    pub allowed_scopes: Vec<String>,
92    pub allow_unspecified_scope: bool,
93    pub max_limit: usize,
94    pub max_context_bytes: usize,
95    pub max_runtime_ms: u64,
96    pub allow_remote_clients: bool,
97}
98
99impl AgentAccessPolicy {
100    pub const DEFAULT_MAX_LIMIT: usize = 10;
101    pub const DEFAULT_MAX_CONTEXT_BYTES: usize = 65_536;
102
103    /// Creates a validated access policy for agent protocol adapters.
104    pub fn new(
105        allowed_scopes: Vec<String>,
106        allow_unspecified_scope: bool,
107        max_limit: usize,
108        max_context_bytes: usize,
109        max_runtime_ms: u64,
110        allow_remote_clients: bool,
111    ) -> Result<Self, AgentPolicyError> {
112        if max_limit == 0 {
113            return Err(AgentPolicyError::ZeroMaxLimit);
114        }
115        if max_context_bytes == 0 {
116            return Err(AgentPolicyError::ZeroMaxContextBytes);
117        }
118        if max_runtime_ms == 0 {
119            return Err(AgentPolicyError::ZeroMaxRuntime);
120        }
121
122        Ok(Self {
123            allowed_scopes,
124            allow_unspecified_scope,
125            max_limit,
126            max_context_bytes,
127            max_runtime_ms,
128            allow_remote_clients,
129        })
130    }
131
132    /// Summarizes policy without exposing scope names or secrets.
133    pub fn summary(&self) -> AgentAccessPolicySummary {
134        AgentAccessPolicySummary {
135            allowed_scope_count: self.allowed_scopes.len(),
136            allow_unspecified_scope: self.allow_unspecified_scope,
137            max_limit: self.max_limit,
138            max_context_bytes: self.max_context_bytes,
139            max_runtime_ms: self.max_runtime_ms,
140            allow_remote_clients: self.allow_remote_clients,
141        }
142    }
143}
144
145/// Stable policy validation error.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum AgentPolicyError {
148    ZeroMaxLimit,
149    ZeroMaxContextBytes,
150    ZeroMaxRuntime,
151}
152
153impl std::fmt::Display for AgentPolicyError {
154    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            Self::ZeroMaxLimit => write!(formatter, "MCP max limit must be greater than zero"),
157            Self::ZeroMaxContextBytes => {
158                write!(formatter, "MCP max context bytes must be greater than zero")
159            }
160            Self::ZeroMaxRuntime => write!(formatter, "MCP max runtime must be greater than zero"),
161        }
162    }
163}
164
165impl std::error::Error for AgentPolicyError {}
166
167/// Redacted policy status for service diagnostics.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct AgentAccessPolicySummary {
170    pub allowed_scope_count: usize,
171    pub allow_unspecified_scope: bool,
172    pub max_limit: usize,
173    pub max_context_bytes: usize,
174    pub max_runtime_ms: u64,
175    pub allow_remote_clients: bool,
176}
177
178/// Service status projection for resident agent protocols.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct AgentProtocolStatus {
181    pub mcp_streamable_http_enabled: bool,
182    pub mcp_endpoint: String,
183    pub mcp_resources_enabled: bool,
184    pub mcp_prompts_enabled: bool,
185    pub metrics_endpoint: String,
186    pub http_bind: String,
187    pub allowed_origin_count: usize,
188    pub mcp_allowed_origins: Vec<String>,
189    pub policy: AgentAccessPolicySummary,
190    pub audit_sink_enabled: bool,
191    pub audit_log_path: String,
192    pub audit_queue_depth: usize,
193}
194
195/// Canonical retrieval result shared by MCP and future agent protocols.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct AgentRetrievalResult {
198    pub metadata: ApiMetadata,
199    pub runtime_identity: RuntimeIdentity,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub source_scope: Option<String>,
202    pub freshness: String,
203    pub retrieval_mode: RetrievalMode,
204    pub context_pack: RetrievedContextPack,
205    pub results: Vec<RetrievalHit>,
206    pub fusion: FusionDiagnostics,
207    pub rerank: crate::domain::RerankDiagnostics,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub backend_statuses: Vec<RetrievalBackendStatus>,
210    pub indexes: Vec<IndexStatus>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub degraded_reason: Option<String>,
213    pub truncated: bool,
214    pub budget_used: AgentBudgetUsed,
215}
216
217impl AgentRetrievalResult {
218    /// Builds the canonical agent result and applies the context byte budget.
219    pub fn from_retrieval(
220        response: crate::api::HybridRetrievalResponse,
221        identity: RuntimeIdentity,
222        max_context_bytes: usize,
223        elapsed_ms: u64,
224    ) -> Self {
225        let crate::api::HybridRetrievalResponse {
226            metadata,
227            mut context_pack,
228            retrieval_mode,
229            source_scope,
230            freshness,
231            results: response_results,
232            fusion,
233            mut rerank,
234            mut backend_statuses,
235            truncated: response_truncated,
236            budget_used,
237            degraded_reason,
238            indexes,
239        } = response;
240        let item_bytes = context_pack
241            .items
242            .iter()
243            .map(|item| (item.result_id.clone(), serialized_context_bytes(item)))
244            .collect::<HashMap<_, _>>();
245        let mut context_bytes = serialized_context_bytes(&context_pack.backend_statuses)
246            .saturating_add(serialized_context_bytes(&backend_statuses));
247        let mut truncated = response_truncated;
248        if context_bytes > max_context_bytes {
249            context_pack.backend_statuses.clear();
250            backend_statuses.clear();
251            context_bytes = 0;
252            truncated = true;
253        }
254        let mut results = Vec::new();
255
256        for hit in response_results {
257            let hit_bytes = serialized_context_bytes(&hit).saturating_add(
258                item_bytes
259                    .get(hit.evidence_id.as_str())
260                    .copied()
261                    .unwrap_or_default(),
262            );
263            if context_bytes.saturating_add(hit_bytes) > max_context_bytes {
264                truncated = true;
265                continue;
266            }
267            context_bytes += hit_bytes;
268            results.push(hit);
269        }
270        let returned_count = results.len();
271        rerank.returned_count = returned_count;
272        let retained_result_ids = results
273            .iter()
274            .map(|hit| hit.evidence_id.as_str())
275            .collect::<HashSet<_>>();
276        context_pack.truncated = truncated;
277        context_pack
278            .items
279            .retain(|item| retained_result_ids.contains(item.result_id.as_str()));
280
281        Self {
282            metadata,
283            runtime_identity: identity,
284            source_scope,
285            freshness: freshness_label(freshness).to_owned(),
286            retrieval_mode,
287            context_pack,
288            results,
289            fusion,
290            rerank,
291            backend_statuses,
292            indexes,
293            degraded_reason,
294            truncated,
295            budget_used: AgentBudgetUsed {
296                limit: budget_used.limit,
297                candidate_count: budget_used.candidate_count,
298                returned_count,
299                context_bytes,
300                elapsed_ms,
301            },
302        }
303    }
304}
305
306fn serialized_context_bytes<T: Serialize>(value: &T) -> usize {
307    serde_json::to_vec(value)
308        .map(|bytes| bytes.len())
309        .unwrap_or(usize::MAX / 4)
310}
311
312/// Runtime budget consumed by a completed agent retrieval.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct AgentBudgetUsed {
315    pub limit: usize,
316    pub candidate_count: usize,
317    pub returned_count: usize,
318    pub context_bytes: usize,
319    pub elapsed_ms: u64,
320}
321
322pub fn freshness_label(freshness: FreshnessPolicy) -> &'static str {
323    match freshness {
324        FreshnessPolicy::AllowStale => "allow-stale",
325        FreshnessPolicy::WaitUntilFresh => "wait-until-fresh",
326        FreshnessPolicy::GraphOnly => "graph-only",
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::{
334        api::InterfaceKind,
335        domain::{
336            ContextPackItem, FusionDiagnostics, GraphVersion, RerankDiagnostics, RerankMode,
337            RetrievalBackendState, RetrievalBackendStatus, RetrievalBudgetUsed, RetrievalHit,
338            RetrievedContextPack, RetrieverSource,
339        },
340    };
341
342    #[test]
343    fn truncates_retrieval_results_to_context_byte_budget() {
344        let items = vec![pack_item("ev-1"), pack_item("ev-2"), pack_item("ev-3")];
345        let results = vec![
346            hit("ev-1", "abcd"),
347            hit("ev-2", "efgh"),
348            hit("ev-3", "ijkl"),
349        ];
350        let max_context_bytes = serialized_context_bytes(&Vec::<RetrievalBackendStatus>::new())
351            + serialized_context_bytes(&Vec::<RetrievalBackendStatus>::new())
352            + serialized_context_bytes(&results[0])
353            + serialized_context_bytes(&items[0])
354            + serialized_context_bytes(&results[1])
355            + serialized_context_bytes(&items[1]);
356        let response = crate::api::HybridRetrievalResponse {
357            metadata: ApiMetadata {
358                trace_id: "trace".to_owned(),
359                request_id: "req".to_owned(),
360                graph_version: 1,
361                index_version: None,
362                indexed_graph_version: None,
363                stale: false,
364            },
365            context_pack: RetrievedContextPack {
366                graph_version: GraphVersion::new(1),
367                source_scope: Some("docs".to_owned()),
368                freshness: FreshnessPolicy::AllowStale,
369                truncated: false,
370                backend_statuses: Vec::new(),
371                items,
372            },
373            retrieval_mode: RetrievalMode::Hybrid,
374            source_scope: Some("docs".to_owned()),
375            freshness: FreshnessPolicy::AllowStale,
376            results,
377            fusion: FusionDiagnostics {
378                algorithm: "reciprocal_rank_fusion".to_owned(),
379                k: 60.0,
380                candidate_count: 3,
381            },
382            rerank: rerank_diagnostics(3, 3),
383            backend_statuses: Vec::new(),
384            truncated: false,
385            budget_used: RetrievalBudgetUsed {
386                limit: 3,
387                candidate_count: 3,
388                returned_count: 3,
389                context_bytes: 12,
390            },
391            degraded_reason: None,
392            indexes: Vec::new(),
393        };
394
395        let result = AgentRetrievalResult::from_retrieval(
396            response,
397            RuntimeIdentity::mcp(Some("call-1".to_owned())),
398            max_context_bytes,
399            4,
400        );
401
402        assert!(result.truncated);
403        assert_eq!(result.results.len(), 2);
404        assert_eq!(result.context_pack.items.len(), 2);
405        assert_eq!(result.budget_used.returned_count, 2);
406        assert_eq!(result.rerank.returned_count, 2);
407        assert_eq!(result.budget_used.context_bytes, max_context_bytes);
408        assert_eq!(result.freshness, "allow-stale");
409    }
410
411    #[test]
412    fn omits_backend_metadata_when_it_exceeds_agent_context_budget() {
413        let backend_statuses = vec![RetrievalBackendStatus {
414            source: RetrieverSource::Semantic,
415            state: RetrievalBackendState::Unavailable,
416            scope_post_filter: true,
417            indexed_graph_version: Some(GraphVersion::new(1)),
418            reason: Some("semantic backend disabled by local policy".repeat(8)),
419        }];
420        let response = crate::api::HybridRetrievalResponse {
421            metadata: ApiMetadata {
422                trace_id: "trace".to_owned(),
423                request_id: "req".to_owned(),
424                graph_version: 1,
425                index_version: None,
426                indexed_graph_version: None,
427                stale: false,
428            },
429            context_pack: RetrievedContextPack {
430                graph_version: GraphVersion::new(1),
431                source_scope: Some("docs".to_owned()),
432                freshness: FreshnessPolicy::AllowStale,
433                truncated: false,
434                backend_statuses: backend_statuses.clone(),
435                items: Vec::new(),
436            },
437            retrieval_mode: RetrievalMode::Hybrid,
438            source_scope: Some("docs".to_owned()),
439            freshness: FreshnessPolicy::AllowStale,
440            results: Vec::new(),
441            fusion: FusionDiagnostics {
442                algorithm: "reciprocal_rank_fusion".to_owned(),
443                k: 60.0,
444                candidate_count: 0,
445            },
446            rerank: rerank_diagnostics(0, 0),
447            backend_statuses,
448            truncated: false,
449            budget_used: RetrievalBudgetUsed {
450                limit: 3,
451                candidate_count: 0,
452                returned_count: 0,
453                context_bytes: 0,
454            },
455            degraded_reason: None,
456            indexes: Vec::new(),
457        };
458
459        let result = AgentRetrievalResult::from_retrieval(
460            response,
461            RuntimeIdentity::mcp(Some("call-1".to_owned())),
462            8,
463            4,
464        );
465
466        assert!(result.truncated);
467        assert!(result.backend_statuses.is_empty());
468        assert!(result.context_pack.backend_statuses.is_empty());
469        assert!(result.budget_used.context_bytes <= 8);
470    }
471
472    #[test]
473    fn rejects_zero_policy_budgets() {
474        let error = AgentAccessPolicy::new(Vec::new(), false, 0, 1, 1, false).expect_err("zero");
475
476        assert_eq!(error, AgentPolicyError::ZeroMaxLimit);
477    }
478
479    fn hit(evidence_id: &str, content: &str) -> RetrievalHit {
480        RetrievalHit {
481            evidence_id: evidence_id.to_owned(),
482            source_scope: "docs".to_owned(),
483            source_path: None,
484            source_span: None,
485            content: content.to_owned(),
486            entity_labels: Vec::new(),
487            entities: Vec::new(),
488            graph_facts: Vec::new(),
489            code_artifact: None,
490            retriever_sources: Vec::new(),
491            ranking: Vec::new(),
492            rerank: None,
493            score: 1.0,
494        }
495    }
496
497    fn pack_item(result_id: &str) -> ContextPackItem {
498        ContextPackItem {
499            result_id: result_id.to_owned(),
500            source_scope: "docs".to_owned(),
501            source_path: None,
502            source_span: None,
503            entities: Vec::new(),
504            graph_facts: Vec::new(),
505            graph_paths: Vec::new(),
506            code_artifact: None,
507            retriever_sources: Vec::new(),
508            ranking: Vec::new(),
509            rerank: None,
510        }
511    }
512
513    fn rerank_diagnostics(candidate_count: usize, returned_count: usize) -> RerankDiagnostics {
514        RerankDiagnostics {
515            requested_mode: RerankMode::Local,
516            effective_mode: RerankMode::Local,
517            algorithm: "deterministic_feature_rerank".to_owned(),
518            candidate_count,
519            returned_count,
520            degraded: false,
521            reason: None,
522        }
523    }
524
525    #[test]
526    fn carries_agent_context_without_domain_identity_leakage() {
527        let context = AgentRequestContext {
528            request: RequestContext::with_ids(InterfaceKind::Mcp, "req", "trace"),
529            runtime_identity: RuntimeIdentity::mcp(Some("tool".to_owned())),
530            policy_id: "default".to_owned(),
531        };
532
533        assert_eq!(context.request.interface, InterfaceKind::Mcp);
534        assert_eq!(context.runtime_identity.protocol, AgentProtocolKind::Mcp);
535    }
536}