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, IndexStatus, RetrievalBackendStatus,
7    RetrievalHit, RetrievalMode, RetrievedContextPack,
8};
9use crate::project::{ACP_LOCAL_ADAPTER_NAME, MCP_ADAPTER_NAME};
10use crate::storage::{IndexCursor, IndexRefreshDiagnostics};
11
12use super::{ApiMetadata, RequestContext};
13
14/// Agent protocol family used by external resident-process adapters.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum AgentProtocolKind {
18    Mcp,
19    Acp,
20}
21
22/// Runtime identity captured from an agent protocol request.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct RuntimeIdentity {
25    pub protocol: AgentProtocolKind,
26    pub adapter_name: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub adapter_version: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub client_name: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub client_version: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub host_name: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub actor_id: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub session_id: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub tool_call_id: Option<String>,
41}
42
43impl RuntimeIdentity {
44    /// Creates the resident MCP adapter identity for a single request.
45    pub fn mcp(tool_call_id: Option<String>) -> Self {
46        Self {
47            protocol: AgentProtocolKind::Mcp,
48            adapter_name: MCP_ADAPTER_NAME.to_owned(),
49            adapter_version: Some(env!("CARGO_PKG_VERSION").to_owned()),
50            client_name: None,
51            client_version: None,
52            host_name: None,
53            actor_id: None,
54            session_id: None,
55            tool_call_id,
56        }
57    }
58
59    /// Creates the local ACP adapter identity for one session request.
60    pub fn acp(
61        client_name: Option<String>,
62        client_version: Option<String>,
63        actor_id: Option<String>,
64        session_id: String,
65        request_id: Option<String>,
66    ) -> Self {
67        Self {
68            protocol: AgentProtocolKind::Acp,
69            adapter_name: ACP_LOCAL_ADAPTER_NAME.to_owned(),
70            adapter_version: Some(env!("CARGO_PKG_VERSION").to_owned()),
71            client_name,
72            client_version,
73            host_name: None,
74            actor_id,
75            session_id: Some(session_id),
76            tool_call_id: request_id,
77        }
78    }
79}
80
81/// Unified API context plus agent protocol identity and policy provenance.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct AgentRequestContext {
84    pub request: RequestContext,
85    pub runtime_identity: RuntimeIdentity,
86    pub policy_id: String,
87}
88
89/// Local access policy applied before agent protocol requests reach services.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct AgentAccessPolicy {
92    pub allowed_scopes: Vec<String>,
93    pub allow_unspecified_scope: bool,
94    pub max_limit: usize,
95    pub max_context_bytes: usize,
96    pub max_runtime_ms: u64,
97    pub allow_remote_clients: bool,
98}
99
100impl AgentAccessPolicy {
101    pub const DEFAULT_MAX_LIMIT: usize = 10;
102    pub const DEFAULT_MAX_CONTEXT_BYTES: usize = 65_536;
103
104    /// Creates a validated access policy for agent protocol adapters.
105    pub fn new(
106        allowed_scopes: Vec<String>,
107        allow_unspecified_scope: bool,
108        max_limit: usize,
109        max_context_bytes: usize,
110        max_runtime_ms: u64,
111        allow_remote_clients: bool,
112    ) -> Result<Self, AgentPolicyError> {
113        if max_limit == 0 {
114            return Err(AgentPolicyError::ZeroMaxLimit);
115        }
116        if max_context_bytes == 0 {
117            return Err(AgentPolicyError::ZeroMaxContextBytes);
118        }
119        if max_runtime_ms == 0 {
120            return Err(AgentPolicyError::ZeroMaxRuntime);
121        }
122
123        Ok(Self {
124            allowed_scopes,
125            allow_unspecified_scope,
126            max_limit,
127            max_context_bytes,
128            max_runtime_ms,
129            allow_remote_clients,
130        })
131    }
132
133    /// Summarizes policy without exposing scope names or secrets.
134    pub fn summary(&self) -> AgentAccessPolicySummary {
135        AgentAccessPolicySummary {
136            allowed_scope_count: self.allowed_scopes.len(),
137            allow_unspecified_scope: self.allow_unspecified_scope,
138            max_limit: self.max_limit,
139            max_context_bytes: self.max_context_bytes,
140            max_runtime_ms: self.max_runtime_ms,
141            allow_remote_clients: self.allow_remote_clients,
142        }
143    }
144}
145
146/// Stable policy validation error.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum AgentPolicyError {
149    ZeroMaxLimit,
150    ZeroMaxContextBytes,
151    ZeroMaxRuntime,
152}
153
154impl std::fmt::Display for AgentPolicyError {
155    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            Self::ZeroMaxLimit => write!(formatter, "MCP max limit must be greater than zero"),
158            Self::ZeroMaxContextBytes => {
159                write!(formatter, "MCP max context bytes must be greater than zero")
160            }
161            Self::ZeroMaxRuntime => write!(formatter, "MCP max runtime must be greater than zero"),
162        }
163    }
164}
165
166impl std::error::Error for AgentPolicyError {}
167
168/// Redacted policy status for service diagnostics.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct AgentAccessPolicySummary {
171    pub allowed_scope_count: usize,
172    pub allow_unspecified_scope: bool,
173    pub max_limit: usize,
174    pub max_context_bytes: usize,
175    pub max_runtime_ms: u64,
176    pub allow_remote_clients: bool,
177}
178
179/// Service status projection for resident agent protocols.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct AgentProtocolStatus {
182    pub mcp_streamable_http_enabled: bool,
183    pub mcp_endpoint: String,
184    pub mcp_resources_enabled: bool,
185    pub mcp_prompts_enabled: bool,
186    pub metrics_endpoint: String,
187    pub http_bind: String,
188    pub allowed_origin_count: usize,
189    pub mcp_allowed_origins: Vec<String>,
190    pub policy: AgentAccessPolicySummary,
191    pub audit_sink_enabled: bool,
192    pub audit_log_path: String,
193    pub audit_queue_depth: usize,
194}
195
196/// Canonical retrieval result shared by MCP and future agent protocols.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct AgentRetrievalResult {
199    pub metadata: ApiMetadata,
200    pub runtime_identity: RuntimeIdentity,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub source_scope: Option<String>,
203    pub freshness: String,
204    pub retrieval_mode: RetrievalMode,
205    pub context_pack: RetrievedContextPack,
206    pub results: Vec<RetrievalHit>,
207    pub fusion: FusionDiagnostics,
208    pub rerank: crate::domain::RerankDiagnostics,
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub backend_statuses: Vec<RetrievalBackendStatus>,
211    pub indexes: Vec<IndexStatus>,
212    #[serde(default)]
213    pub index_cursors: Vec<IndexCursor>,
214    #[serde(default)]
215    pub index_refresh: IndexRefreshDiagnostics,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub degraded_reason: Option<String>,
218    pub truncated: bool,
219    pub budget_used: AgentBudgetUsed,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Hash)]
223struct AgentResultKey {
224    result_id: String,
225    source_scope: String,
226    source_path: Option<String>,
227}
228
229impl AgentResultKey {
230    fn from_hit(hit: &RetrievalHit) -> Self {
231        Self {
232            result_id: hit.evidence_id.clone(),
233            source_scope: hit.source_scope.clone(),
234            source_path: agent_hit_source_path(hit),
235        }
236    }
237
238    fn from_item(item: &ContextPackItem) -> Self {
239        Self {
240            result_id: item.result_id.clone(),
241            source_scope: item.source_scope.clone(),
242            source_path: item
243                .source_path
244                .clone()
245                .or_else(|| item.code_artifact.as_ref().and_then(agent_artifact_path)),
246        }
247    }
248}
249
250fn agent_hit_source_path(hit: &RetrievalHit) -> Option<String> {
251    hit.source_path
252        .clone()
253        .or_else(|| hit.code_artifact.as_ref().and_then(agent_artifact_path))
254}
255
256fn agent_artifact_path(artifact: &crate::domain::CodeGraphArtifact) -> Option<String> {
257    (!artifact.path.is_empty()).then(|| artifact.path.clone())
258}
259
260impl AgentRetrievalResult {
261    /// Builds the canonical agent result and applies the context byte budget.
262    pub fn from_retrieval(
263        response: crate::api::HybridRetrievalResponse,
264        identity: RuntimeIdentity,
265        max_context_bytes: usize,
266        elapsed_ms: u64,
267    ) -> Self {
268        let crate::api::HybridRetrievalResponse {
269            metadata,
270            mut context_pack,
271            retrieval_mode,
272            source_scope,
273            freshness,
274            results: response_results,
275            fusion,
276            mut rerank,
277            mut backend_statuses,
278            truncated: response_truncated,
279            budget_used,
280            degraded_reason,
281            indexes,
282            index_cursors,
283            index_refresh,
284        } = response;
285        let item_bytes = context_pack
286            .items
287            .iter()
288            .map(|item| {
289                (
290                    AgentResultKey::from_item(item),
291                    serialized_context_bytes(item),
292                )
293            })
294            .collect::<HashMap<_, _>>();
295        let mut context_bytes = serialized_context_bytes(&context_pack.backend_statuses)
296            .saturating_add(serialized_context_bytes(&backend_statuses));
297        let mut truncated = response_truncated;
298        if context_bytes > max_context_bytes {
299            context_pack.backend_statuses.clear();
300            backend_statuses.clear();
301            context_bytes = 0;
302            truncated = true;
303        }
304        let mut results = Vec::new();
305
306        for hit in response_results {
307            let hit_key = AgentResultKey::from_hit(&hit);
308            let hit_bytes = serialized_context_bytes(&hit)
309                .saturating_add(item_bytes.get(&hit_key).copied().unwrap_or_default());
310            if context_bytes.saturating_add(hit_bytes) > max_context_bytes {
311                truncated = true;
312                continue;
313            }
314            context_bytes += hit_bytes;
315            results.push(hit);
316        }
317        let returned_count = results.len();
318        rerank.returned_count = returned_count;
319        let retained_result_keys = results
320            .iter()
321            .map(AgentResultKey::from_hit)
322            .collect::<HashSet<_>>();
323        context_pack.truncated = truncated;
324        context_pack
325            .items
326            .retain(|item| retained_result_keys.contains(&AgentResultKey::from_item(item)));
327        if let Some(trace) = &mut context_pack.provenance_trace {
328            trace.retain_hits(results.iter());
329            trace.mark_citations_for_hits(results.iter());
330            trace.truncated |= truncated;
331            trace.apply_budget(
332                returned_count
333                    .saturating_mul(4)
334                    .max(returned_count + 8)
335                    .min(64),
336            );
337            if trace.truncated {
338                truncated = true;
339                context_pack.truncated = true;
340            }
341        }
342        if let Some(trace) = &mut context_pack.provenance_trace {
343            let mut trace_bytes = serialized_context_bytes(trace);
344            if context_bytes.saturating_add(trace_bytes) > max_context_bytes {
345                trace.apply_budget(returned_count.max(1));
346                trace.truncated = true;
347                truncated = true;
348                context_pack.truncated = true;
349                trace_bytes = serialized_context_bytes(trace);
350            }
351            if context_bytes.saturating_add(trace_bytes) > max_context_bytes {
352                context_pack.provenance_trace = None;
353                truncated = true;
354                context_pack.truncated = true;
355            } else {
356                context_bytes += trace_bytes;
357            }
358        }
359
360        Self {
361            metadata,
362            runtime_identity: identity,
363            source_scope,
364            freshness: freshness_label(freshness).to_owned(),
365            retrieval_mode,
366            context_pack,
367            results,
368            fusion,
369            rerank,
370            backend_statuses,
371            indexes,
372            index_cursors,
373            index_refresh,
374            degraded_reason,
375            truncated,
376            budget_used: AgentBudgetUsed {
377                limit: budget_used.limit,
378                candidate_count: budget_used.candidate_count,
379                returned_count,
380                context_bytes,
381                elapsed_ms,
382            },
383        }
384    }
385}
386
387fn serialized_context_bytes<T: Serialize>(value: &T) -> usize {
388    serde_json::to_vec(value)
389        .map(|bytes| bytes.len())
390        .unwrap_or(usize::MAX / 4)
391}
392
393/// Runtime budget consumed by a completed agent retrieval.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct AgentBudgetUsed {
396    pub limit: usize,
397    pub candidate_count: usize,
398    pub returned_count: usize,
399    pub context_bytes: usize,
400    pub elapsed_ms: u64,
401}
402
403pub fn freshness_label(freshness: FreshnessPolicy) -> &'static str {
404    match freshness {
405        FreshnessPolicy::AllowStale => "allow-stale",
406        FreshnessPolicy::WaitUntilFresh => "wait-until-fresh",
407        FreshnessPolicy::GraphOnly => "graph-only",
408    }
409}
410
411#[cfg(test)]
412#[path = "agent_tests.rs"]
413mod tests;