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#[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(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 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#[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}