Skip to main content

relay_knowledge/api/contracts/
agent.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::domain::{
6    ContextPackItem, FreshnessPolicy, FusionDiagnostics, IndexCursor, IndexRefreshDiagnostics,
7    IndexStatus, RetrievalBackendStatus, RetrievalHit, 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(default)]
212    pub index_cursors: Vec<IndexCursor>,
213    #[serde(default)]
214    pub index_refresh: IndexRefreshDiagnostics,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub degraded_reason: Option<String>,
217    pub truncated: bool,
218    pub budget_used: AgentBudgetUsed,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Hash)]
222struct AgentResultKey {
223    result_id: String,
224    source_scope: String,
225    source_path: Option<String>,
226}
227
228impl AgentResultKey {
229    fn from_hit(hit: &RetrievalHit) -> Self {
230        Self {
231            result_id: hit.evidence_id.clone(),
232            source_scope: hit.source_scope.clone(),
233            source_path: agent_hit_source_path(hit),
234        }
235    }
236
237    fn from_item(item: &ContextPackItem) -> Self {
238        Self {
239            result_id: item.result_id.clone(),
240            source_scope: item.source_scope.clone(),
241            source_path: item
242                .source_path
243                .clone()
244                .or_else(|| item.code_artifact.as_ref().and_then(agent_artifact_path)),
245        }
246    }
247}
248
249fn agent_hit_source_path(hit: &RetrievalHit) -> Option<String> {
250    hit.source_path
251        .clone()
252        .or_else(|| hit.code_artifact.as_ref().and_then(agent_artifact_path))
253}
254
255fn agent_artifact_path(artifact: &crate::domain::CodeGraphArtifact) -> Option<String> {
256    (!artifact.path.is_empty()).then(|| artifact.path.clone())
257}
258
259impl AgentRetrievalResult {
260    /// Builds the canonical agent result and applies the context byte budget.
261    pub fn from_retrieval(
262        response: crate::api::HybridRetrievalResponse,
263        identity: RuntimeIdentity,
264        max_context_bytes: usize,
265        elapsed_ms: u64,
266    ) -> Self {
267        let crate::api::HybridRetrievalResponse {
268            metadata,
269            mut context_pack,
270            retrieval_mode,
271            source_scope,
272            freshness,
273            results: response_results,
274            fusion,
275            mut rerank,
276            mut backend_statuses,
277            truncated: response_truncated,
278            budget_used,
279            degraded_reason,
280            indexes,
281            index_cursors,
282            index_refresh,
283        } = response;
284        let item_bytes = context_pack
285            .items
286            .iter()
287            .map(|item| {
288                (
289                    AgentResultKey::from_item(item),
290                    serialized_context_bytes(item),
291                )
292            })
293            .collect::<HashMap<_, _>>();
294        let mut context_bytes = serialized_context_bytes(&context_pack.backend_statuses)
295            .saturating_add(serialized_context_bytes(&backend_statuses));
296        let mut truncated = response_truncated;
297        if context_bytes > max_context_bytes {
298            context_pack.backend_statuses.clear();
299            backend_statuses.clear();
300            context_bytes = 0;
301            truncated = true;
302        }
303        let mut results = Vec::new();
304
305        for hit in response_results {
306            let hit_key = AgentResultKey::from_hit(&hit);
307            let hit_bytes = serialized_context_bytes(&hit)
308                .saturating_add(item_bytes.get(&hit_key).copied().unwrap_or_default());
309            if context_bytes.saturating_add(hit_bytes) > max_context_bytes {
310                truncated = true;
311                continue;
312            }
313            context_bytes += hit_bytes;
314            results.push(hit);
315        }
316        let returned_count = results.len();
317        rerank.returned_count = returned_count;
318        let retained_result_keys = results
319            .iter()
320            .map(AgentResultKey::from_hit)
321            .collect::<HashSet<_>>();
322        context_pack.truncated = truncated;
323        context_pack
324            .items
325            .retain(|item| retained_result_keys.contains(&AgentResultKey::from_item(item)));
326        if let Some(trace) = &mut context_pack.provenance_trace {
327            trace.retain_hits(results.iter());
328            trace.mark_citations_for_hits(results.iter());
329            trace.truncated |= truncated;
330            trace.apply_budget(
331                returned_count
332                    .saturating_mul(4)
333                    .max(returned_count + 8)
334                    .min(64),
335            );
336            if trace.truncated {
337                truncated = true;
338                context_pack.truncated = true;
339            }
340        }
341        if let Some(trace) = &mut context_pack.provenance_trace {
342            let mut trace_bytes = serialized_context_bytes(trace);
343            if context_bytes.saturating_add(trace_bytes) > max_context_bytes {
344                trace.apply_budget(returned_count.max(1));
345                trace.truncated = true;
346                truncated = true;
347                context_pack.truncated = true;
348                trace_bytes = serialized_context_bytes(trace);
349            }
350            if context_bytes.saturating_add(trace_bytes) > max_context_bytes {
351                context_pack.provenance_trace = None;
352                truncated = true;
353                context_pack.truncated = true;
354            } else {
355                context_bytes += trace_bytes;
356            }
357        }
358
359        Self {
360            metadata,
361            runtime_identity: identity,
362            source_scope,
363            freshness: freshness_label(freshness).to_owned(),
364            retrieval_mode,
365            context_pack,
366            results,
367            fusion,
368            rerank,
369            backend_statuses,
370            indexes,
371            index_cursors,
372            index_refresh,
373            degraded_reason,
374            truncated,
375            budget_used: AgentBudgetUsed {
376                limit: budget_used.limit,
377                candidate_count: budget_used.candidate_count,
378                returned_count,
379                context_bytes,
380                elapsed_ms,
381            },
382        }
383    }
384}
385
386fn serialized_context_bytes<T: Serialize>(value: &T) -> usize {
387    serde_json::to_vec(value)
388        .map(|bytes| bytes.len())
389        .unwrap_or(usize::MAX / 4)
390}
391
392/// Runtime budget consumed by a completed agent retrieval.
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub struct AgentBudgetUsed {
395    pub limit: usize,
396    pub candidate_count: usize,
397    pub returned_count: usize,
398    pub context_bytes: usize,
399    pub elapsed_ms: u64,
400}
401
402pub fn freshness_label(freshness: FreshnessPolicy) -> &'static str {
403    match freshness {
404        FreshnessPolicy::AllowStale => "allow-stale",
405        FreshnessPolicy::WaitUntilFresh => "wait-until-fresh",
406        FreshnessPolicy::GraphOnly => "graph-only",
407    }
408}
409
410#[cfg(test)]
411#[path = "agent_tests.rs"]
412mod tests;