1use std::{
2 collections::HashMap,
3 fmt,
4 sync::{Arc, Mutex},
5 time::{Duration, Instant},
6};
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use tokio::sync::watch;
11
12use crate::{
13 api::{
14 AgentProtocolKind, AgentRetrievalResult, ErrorKind, HybridRetrievalRequest, InterfaceKind,
15 RequestContext, RuntimeIdentity,
16 },
17 application::{AgentRuntimeConfig, RelayKnowledgeService},
18 domain::FreshnessPolicy,
19 net::{
20 NetworkRuntime,
21 qos::{QosPermit, QosRuntime, RejectReason},
22 },
23 observability::AgentProtocolMetrics,
24};
25
26use super::{
27 AgentAdapterError, AgentAdapterErrorKind, AgentAuditEvent, AgentAuditLog,
28 AgentAuditQosDecision, AgentAuditSink, AgentAuditStatus, authorize_limit, authorize_scope,
29};
30
31#[derive(Clone)]
33pub struct LocalAcpSessionAdapter {
34 service: RelayKnowledgeService,
35 network: NetworkRuntime,
36 agent: AgentRuntimeConfig,
37 qos: QosRuntime,
38 audit: AgentAuditLog,
39 metrics: AgentProtocolMetrics,
40 sessions: AcpSessionRegistry,
41}
42
43impl LocalAcpSessionAdapter {
44 pub fn new(
46 service: RelayKnowledgeService,
47 network: NetworkRuntime,
48 agent: AgentRuntimeConfig,
49 ) -> Self {
50 let metrics = service.observability().agent_metrics();
51 let audit = if agent.audit_sink_enabled {
52 AgentAuditSink::jsonl(service.agent_audit_log_path(), agent.audit_queue_depth)
53 .map(AgentAuditLog::with_sink)
54 .unwrap_or_default()
55 } else {
56 AgentAuditLog::default()
57 };
58
59 Self {
60 service,
61 network,
62 agent,
63 qos: QosRuntime::default(),
64 audit,
65 metrics,
66 sessions: AcpSessionRegistry::default(),
67 }
68 }
69
70 pub fn initialize(&self) -> AcpInitializeResponse {
72 AcpInitializeResponse {
73 meta: AcpInitializeMeta {
74 relay_knowledge: AcpRelayKnowledgeCapability {
75 graph_retrieval: true,
76 read_only: true,
77 supports_cancellation: true,
78 supports_index_refresh_permission: true,
79 },
80 },
81 }
82 }
83
84 pub fn new_session(&self, request: AcpSessionRequest) -> Result<AcpSession, AgentAdapterError> {
86 let permit = self.admit_request()?;
87 let session_id = generate_acp_id("acp-session")?;
88 let record = AcpSessionRecord {
89 client_name: normalized_optional(request.client_name),
90 client_version: normalized_optional(request.client_version),
91 actor_id: normalized_optional(request.actor_id),
92 };
93 self.sessions
94 .insert_session(session_id.clone(), record.clone());
95 drop(permit);
96
97 Ok(AcpSession {
98 session_id: session_id.clone(),
99 runtime_identity: record.identity(&session_id, None),
100 policy_id: "local-acp-policy".to_owned(),
101 authorized_scope_count: self.agent.access_policy.allowed_scopes.len(),
102 })
103 }
104
105 pub async fn prompt(
107 &self,
108 session_id: &str,
109 mut request: AcpPromptRequest,
110 ) -> AcpPromptResponse {
111 let started = Instant::now();
112 let request_id = request.request_id.take().unwrap_or_else(|| {
113 generate_acp_id("acp-request").unwrap_or_else(|_| "acp-request-unavailable".to_owned())
114 });
115 let mut updates = vec![AcpSessionUpdate::pending(&request_id, "accepted")];
116 let Some(session) = self.sessions.session(session_id) else {
117 return failed_prompt(
118 session_id,
119 request_id,
120 updates,
121 AgentAdapterError::new(
122 AgentAdapterErrorKind::InvalidArgument,
123 "unknown ACP session",
124 ),
125 elapsed_millis(started),
126 );
127 };
128 let permit = match self.admit_request() {
129 Ok(permit) => permit,
130 Err(error) => {
131 self.record_audit(AcpAuditInput {
132 operation: "session/prompt",
133 request_id: &request_id,
134 session_id,
135 session: &session,
136 qos_decision: AgentAuditQosDecision::Rejected,
137 status: AgentAuditStatus::Failed,
138 source_scope: None,
139 freshness: None,
140 limit: None,
141 result_count: None,
142 truncated: false,
143 elapsed_ms: elapsed_millis(started),
144 error_kind: Some(error.kind.as_str()),
145 });
146 return failed_prompt(
147 session_id,
148 request_id,
149 updates,
150 error,
151 elapsed_millis(started),
152 );
153 }
154 };
155 updates.push(AcpSessionUpdate::in_progress(
156 &request_id,
157 "retrieval request mapped",
158 ));
159
160 let mapped = match map_prompt_request(&self.agent, request) {
161 Ok(mapped) => mapped,
162 Err(error) => {
163 drop(permit);
164 self.record_audit(AcpAuditInput {
165 operation: "session/prompt",
166 request_id: &request_id,
167 session_id,
168 session: &session,
169 qos_decision: AgentAuditQosDecision::Admitted,
170 status: AgentAuditStatus::Failed,
171 source_scope: None,
172 freshness: None,
173 limit: None,
174 result_count: None,
175 truncated: false,
176 elapsed_ms: elapsed_millis(started),
177 error_kind: Some(error.kind.as_str()),
178 });
179 return failed_prompt(
180 session_id,
181 request_id,
182 updates,
183 error,
184 elapsed_millis(started),
185 );
186 }
187 };
188 updates.push(AcpSessionUpdate::meta(
189 &request_id,
190 "freshness checked",
191 json!({
192 "relayKnowledge": {
193 "freshness": crate::api::freshness_label(mapped.freshness),
194 "source_scope": mapped.source_scope
195 }
196 }),
197 ));
198
199 let (mut cancellation, registration) = self
200 .sessions
201 .register_request(session_id, request_id.clone());
202 let identity = session.identity(session_id, Some(request_id.clone()));
203 let context = RequestContext::with_ids(
204 InterfaceKind::Acp,
205 request_id.clone(),
206 format!("trace-acp-{request_id}"),
207 );
208 let service = self.service.clone();
209 let request_timeout = Duration::from_millis(self.agent.access_policy.max_runtime_ms);
210 let source_scope = mapped.source_scope.clone();
211 let freshness = mapped.freshness;
212 let limit = mapped.limit;
213 let max_context_bytes = self.agent.access_policy.max_context_bytes;
214 let retrieval = service.retrieve_context(mapped.into_retrieval_request(), context);
215
216 let response = tokio::select! {
217 result = tokio::time::timeout(request_timeout, retrieval) => {
218 match result {
219 Ok(Ok(response)) => {
220 let result = AgentRetrievalResult::from_retrieval(
221 response,
222 identity,
223 max_context_bytes,
224 elapsed_millis(started),
225 );
226 let artifact_id = format!("relay-context:{session_id}:{request_id}");
227 updates.push(AcpSessionUpdate::meta(
228 &request_id,
229 "context ready",
230 json!({"relayKnowledge": {"artifact_id": artifact_id}}),
231 ));
232 updates.push(AcpSessionUpdate::completed(&request_id, "completed"));
233 self.record_audit(AcpAuditInput {
234 operation: "session/prompt",
235 request_id: &request_id,
236 session_id,
237 session: &session,
238 qos_decision: AgentAuditQosDecision::Admitted,
239 status: AgentAuditStatus::Completed,
240 source_scope: source_scope.as_deref(),
241 freshness: Some(crate::api::freshness_label(freshness)),
242 limit: Some(limit),
243 result_count: Some(result.results.len()),
244 truncated: result.truncated,
245 elapsed_ms: elapsed_millis(started),
246 error_kind: None,
247 });
248 AcpPromptResponse {
249 session_id: session_id.to_owned(),
250 request_id: request_id.clone(),
251 updates,
252 context_artifact: Some(AcpContextArtifact {
253 artifact_id,
254 result,
255 }),
256 stop_reason: AcpStopReason::Completed,
257 error: None,
258 }
259 }
260 Ok(Err(error)) => {
261 let adapter_error = AgentAdapterError::new(
262 api_error_kind(error.error_kind),
263 error.message,
264 );
265 self.record_audit(AcpAuditInput {
266 operation: "session/prompt",
267 request_id: &request_id,
268 session_id,
269 session: &session,
270 qos_decision: AgentAuditQosDecision::Admitted,
271 status: AgentAuditStatus::Failed,
272 source_scope: source_scope.as_deref(),
273 freshness: Some(crate::api::freshness_label(freshness)),
274 limit: Some(limit),
275 result_count: None,
276 truncated: false,
277 elapsed_ms: elapsed_millis(started),
278 error_kind: Some(adapter_error.kind.as_str()),
279 });
280 failed_prompt(session_id, request_id.clone(), updates, adapter_error, elapsed_millis(started))
281 }
282 Err(_) => {
283 let adapter_error = AgentAdapterError::new(
284 AgentAdapterErrorKind::Timeout,
285 "ACP prompt exceeded max_runtime_ms",
286 );
287 self.record_audit(AcpAuditInput {
288 operation: "session/prompt",
289 request_id: &request_id,
290 session_id,
291 session: &session,
292 qos_decision: AgentAuditQosDecision::Admitted,
293 status: AgentAuditStatus::Failed,
294 source_scope: source_scope.as_deref(),
295 freshness: Some(crate::api::freshness_label(freshness)),
296 limit: Some(limit),
297 result_count: None,
298 truncated: false,
299 elapsed_ms: elapsed_millis(started),
300 error_kind: Some(adapter_error.kind.as_str()),
301 });
302 failed_prompt(session_id, request_id.clone(), updates, adapter_error, elapsed_millis(started))
303 }
304 }
305 }
306 _ = wait_for_cancellation(&mut cancellation) => {
307 let adapter_error = AgentAdapterError::new(
308 AgentAdapterErrorKind::Cancelled,
309 "ACP prompt was cancelled",
310 );
311 self.record_audit(AcpAuditInput {
312 operation: "session/prompt",
313 request_id: &request_id,
314 session_id,
315 session: &session,
316 qos_decision: AgentAuditQosDecision::Admitted,
317 status: AgentAuditStatus::Cancelled,
318 source_scope: source_scope.as_deref(),
319 freshness: Some(crate::api::freshness_label(freshness)),
320 limit: Some(limit),
321 result_count: None,
322 truncated: false,
323 elapsed_ms: elapsed_millis(started),
324 error_kind: Some(adapter_error.kind.as_str()),
325 });
326 failed_prompt(session_id, request_id.clone(), updates, adapter_error, elapsed_millis(started))
327 }
328 };
329
330 registration.release();
331 drop(permit);
332 response
333 }
334
335 pub fn cancel(&self, session_id: &str, request_id: &str) -> bool {
337 self.sessions.cancel_request(session_id, request_id)
338 }
339
340 pub fn audit_snapshot(&self) -> Vec<AgentAuditEvent> {
342 self.audit.snapshot()
343 }
344
345 #[cfg(test)]
346 pub fn qos_snapshot(&self) -> crate::net::qos::QosSnapshot {
347 self.qos.snapshot()
348 }
349
350 fn admit_request(&self) -> Result<QosPermit, AgentAdapterError> {
351 let policy = self.network.current().qos;
352 self.qos.admit_queued_request(&policy).map_err(qos_error)
353 }
354
355 fn record_audit(&self, input: AcpAuditInput<'_>) {
356 let event = AgentAuditEvent {
357 sequence: 0,
358 protocol: AgentProtocolKind::Acp,
359 operation: input.operation.to_owned(),
360 request_id: input.request_id.to_owned(),
361 trace_id: format!("trace-acp-{}", input.request_id),
362 runtime_identity: input
363 .session
364 .identity(input.session_id, Some(input.request_id.to_owned())),
365 qos_decision: input.qos_decision,
366 status: input.status,
367 source_scope: input.source_scope.map(str::to_owned),
368 freshness: input.freshness.map(str::to_owned),
369 limit: input.limit,
370 result_count: input.result_count,
371 truncated: input.truncated,
372 elapsed_ms: input.elapsed_ms,
373 error_kind: input.error_kind.map(str::to_owned),
374 };
375 self.audit.record(event.clone());
376 if input.qos_decision == AgentAuditQosDecision::Rejected {
377 self.metrics
378 .record_rejection("acp", input.error_kind.unwrap_or("qos_rejected"));
379 return;
380 }
381 let status_label = match event.status {
382 AgentAuditStatus::Completed => "completed",
383 AgentAuditStatus::Failed => "failed",
384 AgentAuditStatus::Cancelled => "cancelled",
385 };
386 self.metrics.record_request(
387 "acp",
388 input.operation,
389 status_label,
390 input.elapsed_ms,
391 input.truncated,
392 );
393 if event.status == AgentAuditStatus::Cancelled {
394 self.metrics.record_cancelled("acp");
395 }
396 }
397}
398
399struct AcpAuditInput<'a> {
400 operation: &'a str,
401 request_id: &'a str,
402 session_id: &'a str,
403 session: &'a AcpSessionRecord,
404 qos_decision: AgentAuditQosDecision,
405 status: AgentAuditStatus,
406 source_scope: Option<&'a str>,
407 freshness: Option<&'a str>,
408 limit: Option<usize>,
409 result_count: Option<usize>,
410 truncated: bool,
411 elapsed_ms: u64,
412 error_kind: Option<&'a str>,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct AcpInitializeResponse {
418 #[serde(rename = "_meta")]
419 pub meta: AcpInitializeMeta,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423pub struct AcpInitializeMeta {
424 #[serde(rename = "relayKnowledge")]
425 pub relay_knowledge: AcpRelayKnowledgeCapability,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct AcpRelayKnowledgeCapability {
430 #[serde(rename = "graphRetrieval")]
431 pub graph_retrieval: bool,
432 #[serde(rename = "readOnly")]
433 pub read_only: bool,
434 #[serde(rename = "supportsCancellation")]
435 pub supports_cancellation: bool,
436 #[serde(rename = "supportsIndexRefreshPermission")]
437 pub supports_index_refresh_permission: bool,
438}
439
440#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
442pub struct AcpSessionRequest {
443 #[serde(skip_serializing_if = "Option::is_none")]
444 pub client_name: Option<String>,
445 #[serde(skip_serializing_if = "Option::is_none")]
446 pub client_version: Option<String>,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub actor_id: Option<String>,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453pub struct AcpSession {
454 pub session_id: String,
455 pub runtime_identity: RuntimeIdentity,
456 pub policy_id: String,
457 pub authorized_scope_count: usize,
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462pub struct AcpPromptRequest {
463 pub prompt: String,
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub request_id: Option<String>,
466 #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
467 pub meta: Option<AcpPromptMeta>,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
471pub struct AcpPromptMeta {
472 #[serde(rename = "relayKnowledge", skip_serializing_if = "Option::is_none")]
473 pub relay_knowledge: Option<AcpRelayKnowledgePrompt>,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct AcpRelayKnowledgePrompt {
478 #[serde(skip_serializing_if = "Option::is_none")]
479 pub query: Option<String>,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 pub source_scope: Option<String>,
482 #[serde(skip_serializing_if = "Option::is_none")]
483 pub limit: Option<usize>,
484 #[serde(skip_serializing_if = "Option::is_none")]
485 pub freshness: Option<String>,
486}
487
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
490pub struct AcpPromptResponse {
491 pub session_id: String,
492 pub request_id: String,
493 pub updates: Vec<AcpSessionUpdate>,
494 #[serde(skip_serializing_if = "Option::is_none")]
495 pub context_artifact: Option<AcpContextArtifact>,
496 pub stop_reason: AcpStopReason,
497 #[serde(skip_serializing_if = "Option::is_none")]
498 pub error: Option<AcpErrorPayload>,
499}
500
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502pub struct AcpContextArtifact {
503 pub artifact_id: String,
504 pub result: AgentRetrievalResult,
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "snake_case")]
509pub enum AcpStopReason {
510 Completed,
511 Failed,
512 Cancelled,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct AcpErrorPayload {
517 pub error_kind: String,
518 pub message: String,
519}
520
521#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
522pub struct AcpSessionUpdate {
523 pub request_id: String,
524 pub kind: AcpSessionUpdateKind,
525 pub status: AcpSessionUpdateStatus,
526 pub message: String,
527 #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
528 pub meta: Option<Value>,
529}
530
531impl AcpSessionUpdate {
532 fn pending(request_id: &str, message: &str) -> Self {
533 Self::new(
534 request_id,
535 AcpSessionUpdateKind::ToolCallUpdate,
536 AcpSessionUpdateStatus::Pending,
537 message,
538 None,
539 )
540 }
541
542 fn in_progress(request_id: &str, message: &str) -> Self {
543 Self::new(
544 request_id,
545 AcpSessionUpdateKind::ToolCallUpdate,
546 AcpSessionUpdateStatus::InProgress,
547 message,
548 None,
549 )
550 }
551
552 fn meta(request_id: &str, message: &str, meta: Value) -> Self {
553 Self::new(
554 request_id,
555 AcpSessionUpdateKind::SessionUpdate,
556 AcpSessionUpdateStatus::InProgress,
557 message,
558 Some(meta),
559 )
560 }
561
562 fn completed(request_id: &str, message: &str) -> Self {
563 Self::new(
564 request_id,
565 AcpSessionUpdateKind::ToolCallUpdate,
566 AcpSessionUpdateStatus::Completed,
567 message,
568 None,
569 )
570 }
571
572 fn failed(request_id: &str, message: &str, status: AcpSessionUpdateStatus) -> Self {
573 Self::new(
574 request_id,
575 AcpSessionUpdateKind::ToolCallUpdate,
576 status,
577 message,
578 None,
579 )
580 }
581
582 fn new(
583 request_id: &str,
584 kind: AcpSessionUpdateKind,
585 status: AcpSessionUpdateStatus,
586 message: &str,
587 meta: Option<Value>,
588 ) -> Self {
589 Self {
590 request_id: request_id.to_owned(),
591 kind,
592 status,
593 message: message.to_owned(),
594 meta,
595 }
596 }
597}
598
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "snake_case")]
601pub enum AcpSessionUpdateKind {
602 SessionUpdate,
603 ToolCallUpdate,
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
607#[serde(rename_all = "snake_case")]
608pub enum AcpSessionUpdateStatus {
609 Pending,
610 InProgress,
611 Completed,
612 Failed,
613 Cancelled,
614}
615
616#[derive(Clone, Default)]
617struct AcpSessionRegistry {
618 inner: Arc<Mutex<AcpSessionState>>,
619}
620
621#[derive(Default)]
622struct AcpSessionState {
623 sessions: HashMap<String, AcpSessionRecord>,
624 active_requests: HashMap<String, watch::Sender<bool>>,
625}
626
627#[derive(Debug, Clone)]
628struct AcpSessionRecord {
629 client_name: Option<String>,
630 client_version: Option<String>,
631 actor_id: Option<String>,
632}
633
634impl AcpSessionRecord {
635 fn identity(&self, session_id: &str, request_id: Option<String>) -> RuntimeIdentity {
636 RuntimeIdentity::acp(
637 self.client_name.clone(),
638 self.client_version.clone(),
639 self.actor_id.clone(),
640 session_id.to_owned(),
641 request_id,
642 )
643 }
644}
645
646struct ActiveAcpRequest {
647 registry: AcpSessionRegistry,
648 key: String,
649 released: bool,
650}
651
652impl ActiveAcpRequest {
653 fn release(mut self) {
654 self.registry.remove_request(&self.key);
655 self.released = true;
656 }
657}
658
659impl Drop for ActiveAcpRequest {
660 fn drop(&mut self) {
661 if !self.released {
662 self.registry.remove_request(&self.key);
663 }
664 }
665}
666
667impl AcpSessionRegistry {
668 fn insert_session(&self, session_id: String, record: AcpSessionRecord) {
669 self.inner
670 .lock()
671 .unwrap_or_else(|poisoned| poisoned.into_inner())
672 .sessions
673 .insert(session_id, record);
674 }
675
676 fn session(&self, session_id: &str) -> Option<AcpSessionRecord> {
677 self.inner
678 .lock()
679 .unwrap_or_else(|poisoned| poisoned.into_inner())
680 .sessions
681 .get(session_id)
682 .cloned()
683 }
684
685 fn register_request(
686 &self,
687 session_id: &str,
688 request_id: String,
689 ) -> (watch::Receiver<bool>, ActiveAcpRequest) {
690 let (sender, receiver) = watch::channel(false);
691 let key = active_request_key(session_id, &request_id);
692 self.inner
693 .lock()
694 .unwrap_or_else(|poisoned| poisoned.into_inner())
695 .active_requests
696 .insert(key.clone(), sender);
697
698 (
699 receiver,
700 ActiveAcpRequest {
701 registry: self.clone(),
702 key,
703 released: false,
704 },
705 )
706 }
707
708 fn cancel_request(&self, session_id: &str, request_id: &str) -> bool {
709 let key = active_request_key(session_id, request_id);
710 self.inner
711 .lock()
712 .unwrap_or_else(|poisoned| poisoned.into_inner())
713 .active_requests
714 .get(&key)
715 .is_some_and(|sender| sender.send(true).is_ok())
716 }
717
718 fn remove_request(&self, key: &str) {
719 self.inner
720 .lock()
721 .unwrap_or_else(|poisoned| poisoned.into_inner())
722 .active_requests
723 .remove(key);
724 }
725}
726
727#[derive(Debug, Clone, PartialEq, Eq)]
728struct MappedPromptRequest {
729 query: String,
730 source_scope: Option<String>,
731 limit: usize,
732 freshness: FreshnessPolicy,
733}
734
735impl MappedPromptRequest {
736 fn into_retrieval_request(self) -> HybridRetrievalRequest {
737 HybridRetrievalRequest {
738 query: self.query,
739 source_scope: self.source_scope,
740 limit: self.limit,
741 freshness: self.freshness,
742 }
743 }
744}
745
746fn map_prompt_request(
747 agent: &AgentRuntimeConfig,
748 request: AcpPromptRequest,
749) -> Result<MappedPromptRequest, AgentAdapterError> {
750 let relay = request
751 .meta
752 .and_then(|meta| meta.relay_knowledge)
753 .unwrap_or(AcpRelayKnowledgePrompt {
754 query: None,
755 source_scope: None,
756 limit: None,
757 freshness: None,
758 });
759 let query = relay.query.unwrap_or(request.prompt);
760 let source_scope = authorize_scope(relay.source_scope, &agent.access_policy)?;
761 let limit = authorize_limit(relay.limit, &agent.access_policy)?;
762 let freshness = parse_freshness(relay.freshness.as_deref())?;
763
764 if query.trim().is_empty() {
765 return Err(AgentAdapterError::new(
766 AgentAdapterErrorKind::InvalidArgument,
767 "ACP prompt query must not be empty",
768 ));
769 }
770
771 Ok(MappedPromptRequest {
772 query,
773 source_scope,
774 limit,
775 freshness,
776 })
777}
778
779fn parse_freshness(value: Option<&str>) -> Result<FreshnessPolicy, AgentAdapterError> {
780 match value.unwrap_or("allow-stale") {
781 "allow-stale" => Ok(FreshnessPolicy::AllowStale),
782 "wait-until-fresh" => Ok(FreshnessPolicy::WaitUntilFresh),
783 "graph-only" => Ok(FreshnessPolicy::GraphOnly),
784 other => Err(AgentAdapterError::new(
785 AgentAdapterErrorKind::InvalidArgument,
786 format!("invalid freshness '{other}'"),
787 )),
788 }
789}
790
791async fn wait_for_cancellation(cancellation: &mut watch::Receiver<bool>) {
792 while cancellation.changed().await.is_ok() {
793 if *cancellation.borrow() {
794 return;
795 }
796 }
797
798 std::future::pending::<()>().await;
799}
800
801fn failed_prompt(
802 session_id: &str,
803 request_id: String,
804 mut updates: Vec<AcpSessionUpdate>,
805 error: AgentAdapterError,
806 _elapsed_ms: u64,
807) -> AcpPromptResponse {
808 let stop_reason = if error.kind == AgentAdapterErrorKind::Cancelled {
809 AcpStopReason::Cancelled
810 } else {
811 AcpStopReason::Failed
812 };
813 let status = if error.kind == AgentAdapterErrorKind::Cancelled {
814 AcpSessionUpdateStatus::Cancelled
815 } else {
816 AcpSessionUpdateStatus::Failed
817 };
818 updates.push(AcpSessionUpdate::failed(
819 &request_id,
820 &error.message,
821 status,
822 ));
823
824 AcpPromptResponse {
825 session_id: session_id.to_owned(),
826 request_id,
827 updates,
828 context_artifact: None,
829 stop_reason,
830 error: Some(AcpErrorPayload {
831 error_kind: error.kind.as_str().to_owned(),
832 message: error.message,
833 }),
834 }
835}
836
837fn qos_error(reason: RejectReason) -> AgentAdapterError {
838 let message = match reason {
839 RejectReason::ConnectionBudgetExceeded => "connection budget exhausted",
840 RejectReason::RequestBudgetExceeded => "request budget exhausted",
841 RejectReason::QueueBudgetExceeded => "queue budget exhausted",
842 };
843
844 AgentAdapterError::new(AgentAdapterErrorKind::QosRejected, message)
845}
846
847fn api_error_kind(kind: ErrorKind) -> AgentAdapterErrorKind {
848 match kind {
849 ErrorKind::InvalidArgument => AgentAdapterErrorKind::InvalidArgument,
850 ErrorKind::StorageUnavailable => AgentAdapterErrorKind::StorageUnavailable,
851 ErrorKind::Timeout => AgentAdapterErrorKind::Timeout,
852 ErrorKind::Internal => AgentAdapterErrorKind::Internal,
853 }
854}
855
856fn active_request_key(session_id: &str, request_id: &str) -> String {
857 format!("{session_id}|{request_id}")
858}
859
860fn normalized_optional(value: Option<String>) -> Option<String> {
861 value.and_then(|value| {
862 let trimmed = value.trim();
863 (!trimmed.is_empty()).then(|| trimmed.to_owned())
864 })
865}
866
867fn generate_acp_id(prefix: &str) -> Result<String, AgentAdapterError> {
868 let mut entropy = [0_u8; 16];
869 getrandom::getrandom(&mut entropy).map_err(|_| {
870 AgentAdapterError::new(
871 AgentAdapterErrorKind::Internal,
872 "OS session entropy is unavailable",
873 )
874 })?;
875
876 Ok(format!("{prefix}-{}", lowercase_hex(&entropy)))
877}
878
879fn lowercase_hex(bytes: &[u8]) -> String {
880 const HEX: &[u8; 16] = b"0123456789abcdef";
881 let mut output = String::with_capacity(bytes.len() * 2);
882 for byte in bytes {
883 output.push(HEX[usize::from(byte >> 4)] as char);
884 output.push(HEX[usize::from(byte & 0x0f)] as char);
885 }
886
887 output
888}
889
890fn elapsed_millis(started: Instant) -> u64 {
891 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
892}
893
894impl fmt::Debug for LocalAcpSessionAdapter {
895 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
896 formatter
897 .debug_struct("LocalAcpSessionAdapter")
898 .field("agent", &self.agent)
899 .finish_non_exhaustive()
900 }
901}
902
903#[cfg(test)]
904#[path = "acp_tests.rs"]
905mod acp_tests;