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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum AgentProtocolKind {
17 Mcp,
18 Acp,
19}
20
21#[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 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 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#[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#[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 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 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#[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#[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#[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#[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 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#[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;