Skip to main content

relay_knowledge/interfaces/agent/acp/
mod.rs

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/// Local ACP session adapter for resident relay-knowledge processes.
39#[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    /// Creates an ACP local session adapter without opening sockets.
52    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    /// Returns the ACP initialize capability payload.
79    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    /// Creates a bounded local ACP session and captures untrusted client identity.
93    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    /// Runs an ACP prompt turn, returning progress updates and a context artifact.
114    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    /// Cancels an active prompt request if the session still owns it.
341    pub fn cancel(&self, session_id: &str, request_id: &str) -> bool {
342        self.sessions.cancel_request(session_id, request_id)
343    }
344
345    /// Returns agent audit events retained by the bounded in-process log.
346    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;