1use std::{
2 fmt,
3 time::{Duration, Instant},
4};
5
6use serde_json::json;
7use tokio::sync::watch;
8
9mod prompt_context;
10mod prompt_mapping;
11mod protocol;
12mod session_registry;
13
14use crate::{
15 api::{AgentProtocolKind, ErrorKind, InterfaceKind, RequestContext},
16 application::{AgentRuntimeConfig, RelayKnowledgeService},
17 net::{
18 NetworkRuntime,
19 qos::{QosPermit, QosRuntime, RejectReason},
20 },
21 observability::AgentProtocolMetrics,
22};
23
24use super::{
25 AgentAdapterError, AgentAdapterErrorKind, AgentAuditEvent, AgentAuditLog,
26 AgentAuditQosDecision, AgentAuditSink, AgentAuditStatus,
27};
28use prompt_context::run_mapped_prompt;
29use prompt_mapping::map_prompt_request;
30pub use protocol::{
31 AcpContextArtifact, AcpErrorPayload, AcpInitializeMeta, AcpInitializeResponse, AcpPromptMeta,
32 AcpPromptRequest, AcpPromptResponse, AcpRelayKnowledgeCapability, AcpRelayKnowledgePrompt,
33 AcpSession, AcpSessionRequest, AcpSessionUpdate, AcpSessionUpdateKind, AcpSessionUpdateStatus,
34 AcpStopReason,
35};
36use session_registry::{AcpSessionRecord, AcpSessionRegistry};
37
38#[derive(Clone)]
40pub struct LocalAcpSessionAdapter {
41 service: RelayKnowledgeService,
42 network: NetworkRuntime,
43 agent: AgentRuntimeConfig,
44 qos: QosRuntime,
45 audit: AgentAuditLog,
46 metrics: AgentProtocolMetrics,
47 sessions: AcpSessionRegistry,
48}
49
50impl LocalAcpSessionAdapter {
51 pub fn new(
53 service: RelayKnowledgeService,
54 network: NetworkRuntime,
55 agent: AgentRuntimeConfig,
56 ) -> Self {
57 let qos = network.qos_runtime();
58 let metrics = service.observability().agent_metrics();
59 let audit = if agent.audit_sink_enabled {
60 AgentAuditSink::jsonl(service.agent_audit_log_path(), agent.audit_queue_depth)
61 .map(AgentAuditLog::with_sink)
62 .unwrap_or_default()
63 } else {
64 AgentAuditLog::default()
65 };
66
67 Self {
68 service,
69 network,
70 agent,
71 qos,
72 audit,
73 metrics,
74 sessions: AcpSessionRegistry::default(),
75 }
76 }
77
78 pub fn initialize(&self) -> AcpInitializeResponse {
80 AcpInitializeResponse {
81 meta: AcpInitializeMeta {
82 relay_knowledge: AcpRelayKnowledgeCapability {
83 graph_retrieval: true,
84 read_only: true,
85 supports_cancellation: true,
86 supports_index_refresh_permission: true,
87 },
88 },
89 }
90 }
91
92 pub fn new_session(&self, request: AcpSessionRequest) -> Result<AcpSession, AgentAdapterError> {
94 let permit = self.admit_request()?;
95 let session_id = generate_acp_id("acp-session")?;
96 let record = AcpSessionRecord::new(
97 request.client_name,
98 request.client_version,
99 request.actor_id,
100 );
101 self.sessions
102 .insert_session(session_id.clone(), record.clone());
103 drop(permit);
104
105 Ok(AcpSession {
106 session_id: session_id.clone(),
107 runtime_identity: record.identity(&session_id, None),
108 policy_id: "local-acp-policy".to_owned(),
109 authorized_scope_count: self.agent.access_policy.allowed_scopes.len(),
110 })
111 }
112
113 pub async fn prompt(
115 &self,
116 session_id: &str,
117 mut request: AcpPromptRequest,
118 ) -> AcpPromptResponse {
119 let started = Instant::now();
120 let request_id = request.request_id.take().unwrap_or_else(|| {
121 generate_acp_id("acp-request").unwrap_or_else(|_| "acp-request-unavailable".to_owned())
122 });
123 let mut updates = vec![AcpSessionUpdate::pending(&request_id, "accepted")];
124 let Some(session) = self.sessions.session(session_id) else {
125 return failed_prompt(
126 session_id,
127 request_id,
128 updates,
129 AgentAdapterError::new(
130 AgentAdapterErrorKind::InvalidArgument,
131 "unknown ACP session",
132 ),
133 elapsed_millis(started),
134 );
135 };
136 let permit = match self.admit_request() {
137 Ok(permit) => permit,
138 Err(error) => {
139 self.record_audit(AcpAuditInput {
140 operation: "session/prompt",
141 request_id: &request_id,
142 session_id,
143 session: &session,
144 qos_decision: AgentAuditQosDecision::Rejected,
145 status: AgentAuditStatus::Failed,
146 source_scope: None,
147 freshness: None,
148 limit: None,
149 result_count: None,
150 truncated: false,
151 elapsed_ms: elapsed_millis(started),
152 error_kind: Some(error.kind.as_str()),
153 });
154 return failed_prompt(
155 session_id,
156 request_id,
157 updates,
158 error,
159 elapsed_millis(started),
160 );
161 }
162 };
163 updates.push(AcpSessionUpdate::in_progress(
164 &request_id,
165 "retrieval request mapped",
166 ));
167
168 let mapped = match map_prompt_request(&self.agent, request) {
169 Ok(mapped) => mapped,
170 Err(error) => {
171 drop(permit);
172 self.record_audit(AcpAuditInput {
173 operation: "session/prompt",
174 request_id: &request_id,
175 session_id,
176 session: &session,
177 qos_decision: AgentAuditQosDecision::Admitted,
178 status: AgentAuditStatus::Failed,
179 source_scope: None,
180 freshness: None,
181 limit: None,
182 result_count: None,
183 truncated: false,
184 elapsed_ms: elapsed_millis(started),
185 error_kind: Some(error.kind.as_str()),
186 });
187 return failed_prompt(
188 session_id,
189 request_id,
190 updates,
191 error,
192 elapsed_millis(started),
193 );
194 }
195 };
196 updates.push(AcpSessionUpdate::meta(
197 &request_id,
198 "freshness checked",
199 json!({
200 "relayKnowledge": {
201 "freshness": crate::api::freshness_label(mapped.freshness),
202 "source_scope": mapped.source_scope
203 }
204 }),
205 ));
206
207 let (mut cancellation, registration) = self
208 .sessions
209 .register_request(session_id, request_id.clone());
210 let identity = session.identity(session_id, Some(request_id.clone()));
211 let context = RequestContext::with_ids(
212 InterfaceKind::Acp,
213 request_id.clone(),
214 format!("trace-acp-{request_id}"),
215 );
216 let service = self.service.clone();
217 let request_timeout = Duration::from_millis(self.agent.access_policy.max_runtime_ms);
218 let source_scope = mapped.audit_scope();
219 let freshness = mapped.freshness;
220 let limit = mapped.limit;
221 let retrieval =
222 run_mapped_prompt(service, mapped, context, identity, elapsed_millis(started));
223
224 let response = tokio::select! {
225 result = tokio::time::timeout(request_timeout, retrieval) => {
226 match result {
227 Ok(Ok(result)) => {
228 let artifact_id = format!("relay-context:{session_id}:{request_id}");
229 updates.push(AcpSessionUpdate::meta(
230 &request_id,
231 "context ready",
232 json!({"relayKnowledge": {"artifact_id": artifact_id}}),
233 ));
234 updates.push(AcpSessionUpdate::completed(&request_id, "completed"));
235 self.record_audit(AcpAuditInput {
236 operation: "session/prompt",
237 request_id: &request_id,
238 session_id,
239 session: &session,
240 qos_decision: AgentAuditQosDecision::Admitted,
241 status: AgentAuditStatus::Completed,
242 source_scope: source_scope.as_deref(),
243 freshness: Some(crate::api::freshness_label(freshness)),
244 limit: Some(limit),
245 result_count: Some(result.result_count()),
246 truncated: result.truncated(),
247 elapsed_ms: elapsed_millis(started),
248 error_kind: None,
249 });
250 AcpPromptResponse {
251 session_id: session_id.to_owned(),
252 request_id: request_id.clone(),
253 updates,
254 context_artifact: Some(AcpContextArtifact {
255 artifact_id,
256 result: result.retrieval,
257 codegraph_context: result.codegraph,
258 }),
259 stop_reason: AcpStopReason::Completed,
260 error: None,
261 }
262 }
263 Ok(Err(error)) => {
264 let adapter_error = AgentAdapterError::new(
265 api_error_kind(error.error_kind),
266 error.message,
267 );
268 self.record_audit(AcpAuditInput {
269 operation: "session/prompt",
270 request_id: &request_id,
271 session_id,
272 session: &session,
273 qos_decision: AgentAuditQosDecision::Admitted,
274 status: AgentAuditStatus::Failed,
275 source_scope: source_scope.as_deref(),
276 freshness: Some(crate::api::freshness_label(freshness)),
277 limit: Some(limit),
278 result_count: None,
279 truncated: false,
280 elapsed_ms: elapsed_millis(started),
281 error_kind: Some(adapter_error.kind.as_str()),
282 });
283 failed_prompt(session_id, request_id.clone(), updates, adapter_error, elapsed_millis(started))
284 }
285 Err(_) => {
286 self.qos.record_timed_out();
287 let adapter_error = AgentAdapterError::new(
288 AgentAdapterErrorKind::Timeout,
289 "ACP prompt exceeded max_runtime_ms",
290 );
291 self.record_audit(AcpAuditInput {
292 operation: "session/prompt",
293 request_id: &request_id,
294 session_id,
295 session: &session,
296 qos_decision: AgentAuditQosDecision::Admitted,
297 status: AgentAuditStatus::Failed,
298 source_scope: source_scope.as_deref(),
299 freshness: Some(crate::api::freshness_label(freshness)),
300 limit: Some(limit),
301 result_count: None,
302 truncated: false,
303 elapsed_ms: elapsed_millis(started),
304 error_kind: Some(adapter_error.kind.as_str()),
305 });
306 failed_prompt(session_id, request_id.clone(), updates, adapter_error, elapsed_millis(started))
307 }
308 }
309 }
310 _ = wait_for_cancellation(&mut cancellation) => {
311 self.qos.record_cancelled();
312 let adapter_error = AgentAdapterError::new(
313 AgentAdapterErrorKind::Cancelled,
314 "ACP prompt was cancelled",
315 );
316 self.record_audit(AcpAuditInput {
317 operation: "session/prompt",
318 request_id: &request_id,
319 session_id,
320 session: &session,
321 qos_decision: AgentAuditQosDecision::Admitted,
322 status: AgentAuditStatus::Cancelled,
323 source_scope: source_scope.as_deref(),
324 freshness: Some(crate::api::freshness_label(freshness)),
325 limit: Some(limit),
326 result_count: None,
327 truncated: false,
328 elapsed_ms: elapsed_millis(started),
329 error_kind: Some(adapter_error.kind.as_str()),
330 });
331 failed_prompt(session_id, request_id.clone(), updates, adapter_error, elapsed_millis(started))
332 }
333 };
334
335 registration.release();
336 drop(permit);
337 response
338 }
339
340 pub fn cancel(&self, session_id: &str, request_id: &str) -> bool {
342 self.sessions.cancel_request(session_id, request_id)
343 }
344
345 pub fn audit_snapshot(&self) -> Vec<AgentAuditEvent> {
347 self.audit.snapshot()
348 }
349
350 #[cfg(test)]
351 pub fn qos_snapshot(&self) -> crate::net::qos::QosSnapshot {
352 self.qos.snapshot()
353 }
354
355 #[cfg(test)]
356 pub fn qos_diagnostics_snapshot(&self) -> crate::net::qos::QosDiagnosticsSnapshot {
357 self.qos.diagnostics_snapshot()
358 }
359
360 fn admit_request(&self) -> Result<QosPermit, AgentAdapterError> {
361 let policy = self.network.current().qos;
362 self.qos.admit_queued_request(&policy).map_err(qos_error)
363 }
364
365 fn record_audit(&self, input: AcpAuditInput<'_>) {
366 let event = AgentAuditEvent {
367 sequence: 0,
368 protocol: AgentProtocolKind::Acp,
369 operation: input.operation.to_owned(),
370 request_id: input.request_id.to_owned(),
371 trace_id: format!("trace-acp-{}", input.request_id),
372 runtime_identity: input
373 .session
374 .identity(input.session_id, Some(input.request_id.to_owned())),
375 qos_decision: input.qos_decision,
376 status: input.status,
377 source_scope: input.source_scope.map(str::to_owned),
378 freshness: input.freshness.map(str::to_owned),
379 limit: input.limit,
380 result_count: input.result_count,
381 truncated: input.truncated,
382 elapsed_ms: input.elapsed_ms,
383 error_kind: input.error_kind.map(str::to_owned),
384 };
385 self.audit.record(event.clone());
386 if input.qos_decision == AgentAuditQosDecision::Rejected {
387 self.metrics
388 .record_rejection("acp", input.error_kind.unwrap_or("qos_rejected"));
389 return;
390 }
391 let status_label = match event.status {
392 AgentAuditStatus::Completed => "completed",
393 AgentAuditStatus::Failed => "failed",
394 AgentAuditStatus::Cancelled => "cancelled",
395 };
396 self.metrics.record_request(
397 "acp",
398 input.operation,
399 status_label,
400 input.elapsed_ms,
401 input.truncated,
402 );
403 if event.status == AgentAuditStatus::Cancelled {
404 self.metrics.record_cancelled("acp");
405 }
406 }
407}
408
409struct AcpAuditInput<'a> {
410 operation: &'a str,
411 request_id: &'a str,
412 session_id: &'a str,
413 session: &'a AcpSessionRecord,
414 qos_decision: AgentAuditQosDecision,
415 status: AgentAuditStatus,
416 source_scope: Option<&'a str>,
417 freshness: Option<&'a str>,
418 limit: Option<usize>,
419 result_count: Option<usize>,
420 truncated: bool,
421 elapsed_ms: u64,
422 error_kind: Option<&'a str>,
423}
424
425async fn wait_for_cancellation(cancellation: &mut watch::Receiver<bool>) {
426 while cancellation.changed().await.is_ok() {
427 if *cancellation.borrow() {
428 return;
429 }
430 }
431
432 std::future::pending::<()>().await;
433}
434
435fn failed_prompt(
436 session_id: &str,
437 request_id: String,
438 mut updates: Vec<AcpSessionUpdate>,
439 error: AgentAdapterError,
440 _elapsed_ms: u64,
441) -> AcpPromptResponse {
442 let stop_reason = if error.kind == AgentAdapterErrorKind::Cancelled {
443 AcpStopReason::Cancelled
444 } else {
445 AcpStopReason::Failed
446 };
447 let status = if error.kind == AgentAdapterErrorKind::Cancelled {
448 AcpSessionUpdateStatus::Cancelled
449 } else {
450 AcpSessionUpdateStatus::Failed
451 };
452 updates.push(AcpSessionUpdate::failed(
453 &request_id,
454 &error.message,
455 status,
456 ));
457
458 AcpPromptResponse {
459 session_id: session_id.to_owned(),
460 request_id,
461 updates,
462 context_artifact: None,
463 stop_reason,
464 error: Some(AcpErrorPayload {
465 error_kind: error.kind.as_str().to_owned(),
466 message: error.message,
467 }),
468 }
469}
470
471fn qos_error(reason: RejectReason) -> AgentAdapterError {
472 let message = match reason {
473 RejectReason::ConnectionBudgetExceeded => "connection budget exhausted",
474 RejectReason::RequestBudgetExceeded => "request budget exhausted",
475 RejectReason::QueueBudgetExceeded => "queue budget exhausted",
476 };
477
478 AgentAdapterError::new(AgentAdapterErrorKind::QosRejected, message)
479}
480
481fn api_error_kind(kind: ErrorKind) -> AgentAdapterErrorKind {
482 match kind {
483 ErrorKind::InvalidArgument => AgentAdapterErrorKind::InvalidArgument,
484 ErrorKind::StorageUnavailable => AgentAdapterErrorKind::StorageUnavailable,
485 ErrorKind::QosRejected => AgentAdapterErrorKind::QosRejected,
486 ErrorKind::Timeout => AgentAdapterErrorKind::Timeout,
487 ErrorKind::Internal => AgentAdapterErrorKind::Internal,
488 }
489}
490
491fn generate_acp_id(prefix: &str) -> Result<String, AgentAdapterError> {
492 let mut entropy = [0_u8; 16];
493 getrandom::getrandom(&mut entropy).map_err(|_| {
494 AgentAdapterError::new(
495 AgentAdapterErrorKind::Internal,
496 "OS session entropy is unavailable",
497 )
498 })?;
499
500 Ok(format!("{prefix}-{}", lowercase_hex(&entropy)))
501}
502
503fn lowercase_hex(bytes: &[u8]) -> String {
504 const HEX: &[u8; 16] = b"0123456789abcdef";
505 let mut output = String::with_capacity(bytes.len() * 2);
506 for byte in bytes {
507 output.push(HEX[usize::from(byte >> 4)] as char);
508 output.push(HEX[usize::from(byte & 0x0f)] as char);
509 }
510
511 output
512}
513
514fn elapsed_millis(started: Instant) -> u64 {
515 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
516}
517
518impl fmt::Debug for LocalAcpSessionAdapter {
519 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
520 formatter
521 .debug_struct("LocalAcpSessionAdapter")
522 .field("agent", &self.agent)
523 .finish_non_exhaustive()
524 }
525}
526
527#[cfg(test)]
528#[path = "mod_tests.rs"]
529mod tests;