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