1use crate::pipeline::AppState;
4use av_core::time::elapsed_us;
5use av_events::StopReason;
6use av_sandbox::ToolVerdict;
7use axum::body::{Body, Bytes};
8use axum::extract::{Path, State};
9use axum::http::{HeaderMap, HeaderValue, Request, StatusCode};
10use axum::middleware::Next;
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use futures::stream::BoxStream;
15use futures::{Stream, StreamExt};
16use serde_json::{json, Value};
17use std::future::Future as _;
18use std::pin::Pin;
19use std::sync::Arc;
20use std::task::{Context, Poll};
21use tracing::Instrument as _;
22
23pub fn build_router(state: AppState) -> Router {
25 let max_body = state.config.max_request_bytes;
26 let dashboard_enabled = state.config.dashboard_enabled;
27 let mut router = Router::new()
28 .route("/health", get(health))
29 .route("/metrics", get(metrics))
30 .route("/v1/chat/completions", post(chat_completions).options(cors_deny))
31 .route("/v1/mcp", post(mcp_call).options(cors_deny))
32 .route("/mcp", post(mcp_call).options(cors_deny))
33 .route("/v1/sessions/{id}/close", post(close_session).options(cors_deny))
34 .route(
35 "/v1/sessions/{id}/promote",
36 post(promote_session).options(cors_deny),
37 );
38 if dashboard_enabled {
39 router = router
40 .route("/dashboard", get(crate::dashboard::index))
41 .route("/dashboard/", get(crate::dashboard::index))
42 .route("/dashboard/style.css", get(crate::dashboard::style_css))
43 .route("/dashboard/app.js", get(crate::dashboard::app_js))
44 .route("/api/v1/dashboard/stats", get(crate::dashboard::stats))
45 .route("/api/v1/dashboard/sessions", get(crate::dashboard::list_sessions))
46 .route(
47 "/api/v1/dashboard/sessions/{id}",
48 get(crate::dashboard::session_detail),
49 );
50 }
51 router
52 .layer(axum::middleware::from_fn(trace_request))
53 .layer(axum::extract::DefaultBodyLimit::max(max_body))
59 .with_state(state)
60}
61
62async fn trace_request(request: Request<Body>, next: Next) -> Response {
63 let method = request.method().clone();
64 let path = request.uri().path().to_owned();
65 let headers = request.headers();
84 let session_id = match crate::pipeline::single_header(headers, crate::pipeline::SESSION_HEADER) {
85 Ok(Some(value)) => value
86 .to_str()
87 .ok()
88 .and_then(|v| av_core::SessionId::parse(v).ok())
89 .map(|id| id.to_string())
90 .unwrap_or_else(|| " rejected".to_owned()),
91 Ok(None) => "unbound".to_owned(),
92 Err(_) => " duplicate-header".to_owned(),
93 };
94 let span = tracing::info_span!(
95 "agentvisor.request",
96 otel.kind = "server",
97 http.request.method = %method,
98 url.path = %path,
99 session.id = %session_id,
100 http.response.status_code = tracing::field::Empty,
101 );
102 let response = next.run(request).instrument(span.clone()).await;
103 span.record("http.response.status_code", response.status().as_u16());
104 response
105}
106
107async fn health() -> impl IntoResponse {
108 Json(json!({
116 "status": "ok",
117 "service": "agentvisor",
122 }))
123}
124
125async fn cors_deny() -> Response {
137 let mut response = Response::new(Body::empty());
138 *response.status_mut() = StatusCode::NO_CONTENT;
139 response.headers_mut().insert(
140 axum::http::header::CACHE_CONTROL,
141 HeaderValue::from_static("no-store"),
142 );
143 response
147}
148
149async fn metrics(State(state): State<AppState>) -> Response {
150 let mut response = Response::new(Body::from(state.metrics.render()));
151 response.headers_mut().insert(
152 axum::http::header::CONTENT_TYPE,
153 HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
154 );
155 response
156}
157
158async fn chat_completions(
159 State(state): State<AppState>,
160 headers: HeaderMap,
161 Json(payload): Json<Value>,
162) -> Response {
163 let admission_started = std::time::Instant::now();
164 let mut prepared = match state.prepare_chat_nonblocking(&headers, payload).await {
165 Ok(prepared) => prepared,
166 Err(error) => return pipeline_error(error),
167 };
168 prepared.middleware_us = elapsed_us(admission_started);
169 let session = Arc::clone(&prepared.session);
170 let identity = prepared.identity.clone();
171 let session_id = prepared.session.id.clone();
172 let middleware_us = prepared.middleware_us;
173 let forwarded = match state.forward_chat(prepared).await {
174 Ok(response) => response,
175 Err(error) => return pipeline_error(error),
176 };
177 let crate::pipeline::ForwardedResponse {
178 response: upstream,
179 lease,
180 response_permit,
181 response_marker,
182 response_attempt_id,
183 } = forwarded;
184 let Some(response_permit) = response_permit else {
185 return lifecycle_error("durable response capture permit is missing".to_owned());
186 };
187
188 let status = upstream.status();
189 let upstream_headers = upstream.headers().clone();
190 for value in upstream_headers.get_all(axum::http::header::CONTENT_ENCODING) {
207 let raw = value.to_str().unwrap_or_default();
208 for token in raw.split(',') {
209 let token = token.trim();
210 if token.is_empty() || token.eq_ignore_ascii_case("identity") {
211 continue;
212 }
213 return pipeline_error(crate::pipeline::PipelineError::Upstream(format!(
214 "upstream responded with unsupported Content-Encoding token {token:?} \
215 (full header: {raw:?}) — the proxy is built without decompression \
216 support; enable it upstream (Accept-Encoding: identity) or rebuild \
217 with the reqwest `gzip` feature"
218 )));
219 }
220 }
221 let is_sse = is_sse_content_type(&upstream_headers);
232 let stream = upstream
233 .bytes_stream()
234 .map(|chunk| chunk.map_err(std::io::Error::other));
235 let stream = AbortFinalizingStream {
236 inner: stream.boxed(),
237 session,
238 identity,
239 response_permit: Some(response_permit),
240 worker: state.worker.clone(),
241 store: Arc::clone(&state.store),
242 budget: state.config.budget.clone(),
243 finalizer: state.finalizer.clone(),
244 _lease: lease,
245 response_marker,
246 response_attempt_id,
247 response_message: String::new(),
248 response_reasoning: String::new(),
249 response_model: None,
250 response_finish_reason: None,
251 upstream_status: status,
252 response_cost_usd_micros: 0,
253 response_tool_calls: std::collections::BTreeMap::new(),
254 response_metrics: av_events::EventMetrics::default(),
255 charged_completion_tokens: 0,
256 last_reported_completion_tokens: None,
257 last_reported_prompt_tokens: None,
258 last_reported_cached_tokens: None,
259 last_reported_cost_usd_micros: None,
260 saw_chunk: false,
261 capture_submitted: false,
262 is_sse,
263 protocol_buffer: Vec::new(),
264 pending_output: std::collections::VecDeque::new(),
265 pending_budget: None,
266 captured_bytes: 0,
267 completed: false,
268 };
269 let mut response = if is_sse {
270 Response::new(Body::from_stream(stream))
271 } else {
272 let mut relay = Box::pin(stream);
280 let mut buffered: Vec<u8> = Vec::new();
281 loop {
282 match relay.next().await {
283 Some(Ok(bytes)) => buffered.extend_from_slice(&bytes),
284 Some(Err(error)) => {
285 let refusal_status = if error.kind() == std::io::ErrorKind::QuotaExceeded {
286 StatusCode::TOO_MANY_REQUESTS
287 } else {
288 StatusCode::BAD_GATEWAY
289 };
290 return (refusal_status, Json(json!({"error": error.to_string()}))).into_response();
294 }
295 None => break,
296 }
297 }
298 Response::new(Body::from(buffered))
299 };
300 *response.status_mut() = status;
301 for (name, value) in &upstream_headers {
302 if is_forwardable_upstream_header(name) {
303 response.headers_mut().append(name.clone(), value.clone());
307 }
308 }
309 if let Ok(value) = HeaderValue::from_str(&session_id) {
310 response
311 .headers_mut()
312 .insert(crate::pipeline::SESSION_HEADER, value);
313 }
314 if let Ok(value) = HeaderValue::from_str(&middleware_us.to_string()) {
315 response
316 .headers_mut()
317 .insert(crate::pipeline::MIDDLEWARE_US_HEADER, value);
318 }
319 response.headers_mut().insert(
331 axum::http::HeaderName::from_static("x-content-type-options"),
332 HeaderValue::from_static("nosniff"),
333 );
334 response
335}
336
337fn is_forwardable_upstream_header(name: &axum::http::HeaderName) -> bool {
356 use axum::http::header;
357 let is_denied = *name == header::CONTENT_LENGTH
358 || *name == header::TRANSFER_ENCODING
359 || *name == header::CONNECTION
360 || *name == header::UPGRADE
361 || *name == header::TE
362 || *name == header::TRAILER
363 || *name == header::PROXY_AUTHENTICATE
364 || *name == header::PROXY_AUTHORIZATION
365 || *name == header::SET_COOKIE
366 || *name == header::SERVER
367 || *name == header::VIA
368 || name.as_str().eq_ignore_ascii_case("keep-alive")
369 || name.as_str().eq_ignore_ascii_case("x-powered-by")
370 || name.as_str().eq_ignore_ascii_case("x-request-id")
371 || name.as_str().to_ascii_lowercase().starts_with("access-control-");
374 !is_denied
375}
376
377fn is_sse_content_type(headers: &HeaderMap) -> bool {
386 headers
387 .get(axum::http::header::CONTENT_TYPE)
388 .and_then(|value| value.to_str().ok())
389 .is_some_and(|value| {
390 let head = value.split(';').next().unwrap_or("").trim();
391 head.eq_ignore_ascii_case("text/event-stream")
392 })
393}
394
395async fn mcp_call(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
396 let (execution, unaudited_outcome) = if state.config.tool_upstream_url.is_some() {
397 match ToolExecution::from_request(&state.config.atif_spool_dir, &headers, &body, state.journal_key) {
398 Ok(mut execution) => {
399 let required_scope = crate::pipeline::tool_scope(&execution.tool);
400 let identity = match state.resolve_identity(&headers, Some(&required_scope)) {
401 Ok(identity) => identity,
402 Err(error) => return pipeline_error(error),
403 };
404 if let Err(error) = execution.bind_principal(&identity) {
405 return pipeline_error(error);
406 }
407 match execution.load().await {
408 Ok(ToolExecutionState::Completed(outcome)) => {
409 let Some(session) = state.sessions.get(&execution.session_id) else {
410 return pipeline_error(crate::pipeline::PipelineError::BadRequest(
411 "unknown session for cached tool result".to_owned(),
412 ));
413 };
414 if let Err(error) = state.authorize_session(
415 &headers,
416 &session,
417 &crate::pipeline::tool_scope(&execution.tool),
418 ) {
419 return pipeline_error(error);
420 }
421 return outcome.into_response();
422 }
423 Ok(ToolExecutionState::Unaudited(outcome)) => {
424 let Some(session) = state.sessions.get(&execution.session_id) else {
425 return pipeline_error(crate::pipeline::PipelineError::BadRequest(
426 "unknown session for pending tool audit".to_owned(),
427 ));
428 };
429 if let Err(error) = state.authorize_session(
430 &headers,
431 &session,
432 &crate::pipeline::tool_scope(&execution.tool),
433 ) {
434 return pipeline_error(error);
435 }
436 (Some(execution), Some(outcome))
437 }
438 Ok(ToolExecutionState::Pending) => {
439 let Some(session) = state.sessions.get(&execution.session_id) else {
440 return pipeline_error(crate::pipeline::PipelineError::BadRequest(
441 "unknown session for pending tool execution".to_owned(),
442 ));
443 };
444 if let Err(error) = state.authorize_session(
445 &headers,
446 &session,
447 &crate::pipeline::tool_scope(&execution.tool),
448 ) {
449 return pipeline_error(error);
450 }
451 return (
452 StatusCode::CONFLICT,
453 Json(json!({"error": TOOL_OUTCOME_UNCERTAIN})),
454 )
455 .into_response();
456 }
457 Ok(ToolExecutionState::Missing) => (Some(execution), None),
458 Err(error) if error == TOOL_REQUEST_MISMATCH => {
459 return (
460 StatusCode::CONFLICT,
461 Json(json!({"error": TOOL_REQUEST_MISMATCH})),
462 )
463 .into_response();
464 }
465 Err(error) => return lifecycle_error(error),
466 }
467 }
468 Err(error) => return pipeline_error(error),
469 }
470 } else {
471 (None, None)
472 };
473 if let (Some(execution), Some(outcome)) = (execution.as_ref(), unaudited_outcome) {
474 let _lease = match state.lease_session(&headers) {
475 Ok(lease) => lease,
476 Err(error) => return pipeline_error(error),
477 };
478 let completion_permit = match state.worker.try_reserve(&execution.session_id) {
479 Ok(permit) => permit,
480 Err(error) => {
481 return pipeline_error(crate::pipeline::PipelineError::Unavailable(error.to_string()));
482 }
483 };
484 let Some(session) = state.sessions.get(&execution.session_id) else {
485 return lifecycle_error("tool session disappeared".to_owned());
486 };
487 return complete_tool_audit(execution, outcome, completion_permit, session).await;
488 }
489 match state.intercept_tool_nonblocking(&headers, &body).await {
496 Ok(ToolVerdict::Allowed {
497 tool,
498 budget_remaining,
499 elapsed_us,
500 payout_micros,
501 }) => {
502 if let Some(url) = state.config.tool_upstream_url.as_deref() {
503 let _lease = match state.lease_session(&headers) {
504 Ok(lease) => lease,
505 Err(error) => return pipeline_error(error),
506 };
507 let execution = match execution {
508 Some(execution) => execution,
509 None => return lifecycle_error("tool execution state is missing".to_owned()),
510 };
511 let completion_permit = match state.worker.try_reserve(&execution.session_id) {
512 Ok(permit) => permit,
513 Err(error) => {
514 return pipeline_error(crate::pipeline::PipelineError::Unavailable(
515 error.to_string(),
516 ));
517 }
518 };
519 if let Err(error) = execution.claim().await {
520 tracing::warn!(
524 session = %execution.session_id,
525 error = %error,
526 "concurrent tool execution claim lost; refunding budget"
527 );
528 av_state::ActionBudget::new(
532 state.store.as_ref(),
533 &execution.session_id,
534 &state.config.budget,
535 )
536 .refund_tool_call(&execution.tool, payout_micros);
537 return (
538 StatusCode::CONFLICT,
539 Json(json!({"error": TOOL_OUTCOME_UNCERTAIN})),
540 )
541 .into_response();
542 }
543 let mut tool_request = state
544 .client
545 .post(url)
546 .header(axum::http::header::CONTENT_TYPE, "application/json")
547 .body(body);
548 if let Some(bearer) = &state.tool_auth {
549 tool_request = tool_request.header(axum::http::header::AUTHORIZATION, bearer.clone());
550 }
551 match tool_request.send().await {
552 Ok(upstream) => {
553 let status = upstream.status();
554 match read_limited_tool_response(upstream).await {
555 Ok((bytes, content_type)) => {
556 let outcome = ToolOutcome {
557 status: status.as_u16(),
558 body_hex: hex::encode(&bytes),
559 content_type,
560 };
561 if let Err(error) = execution.persist(&outcome).await {
562 return lifecycle_error(error);
563 }
564 let session = match state.sessions.get(&execution.session_id) {
565 Some(session) => session,
566 None => return lifecycle_error("tool session disappeared".to_owned()),
567 };
568 complete_tool_audit(&execution, outcome, completion_permit, session).await
569 }
570 Err(error) => pipeline_error(crate::pipeline::PipelineError::Upstream(format!(
571 "read tool response: {error}"
572 ))),
573 }
574 }
575 Err(error) => {
576 let category = crate::pipeline::classify_upstream_error(&error);
591 tracing::warn!(
592 category = category,
593 error.status = ?error.status(),
594 error.is_timeout = error.is_timeout(),
595 error.is_connect = error.is_connect(),
596 error.is_request = error.is_request(),
597 "tool upstream forwarding failed"
598 );
599 pipeline_error(crate::pipeline::PipelineError::Upstream(format!(
603 "forward tool call: {category}"
604 )))
605 }
606 }
607 } else {
608 (
609 StatusCode::OK,
610 Json(json!({
611 "allowed": true,
612 "tool": tool,
613 "budget_remaining": budget_remaining,
614 "decision_us": elapsed_us,
615 })),
616 )
617 .into_response()
618 }
619 }
620 Ok(ToolVerdict::Blocked { response, .. }) => (StatusCode::FORBIDDEN, Json(response)).into_response(),
621 Err(error) => pipeline_error(error),
622 }
623}
624
625const MAX_TOOL_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
626
627async fn read_limited_tool_response(response: reqwest::Response) -> Result<(Bytes, Option<String>), String> {
636 let content_type = response
637 .headers()
638 .get(axum::http::header::CONTENT_TYPE)
639 .and_then(|value| value.to_str().ok())
640 .map(str::to_owned);
641 let mut stream = response.bytes_stream();
642 let mut body = Vec::new();
643 while let Some(chunk) = stream.next().await {
644 let chunk = chunk.map_err(|error| {
650 let category = crate::pipeline::classify_upstream_error(&error);
651 tracing::warn!(
652 category = category,
653 error.status = ?error.status(),
654 error.is_timeout = error.is_timeout(),
655 error.is_connect = error.is_connect(),
656 error.is_body = error.is_body(),
657 "tool upstream stream chunk failed"
658 );
659 category.to_owned()
660 })?;
661 let next = body
662 .len()
663 .checked_add(chunk.len())
664 .ok_or_else(|| "tool response size overflow".to_owned())?;
665 if next > MAX_TOOL_RESPONSE_BYTES {
666 return Err(format!("tool response exceeds {MAX_TOOL_RESPONSE_BYTES} bytes"));
667 }
668 body.extend_from_slice(&chunk);
669 }
670 Ok((Bytes::from(body), content_type))
671}
672
673fn tool_response(status: StatusCode, bytes: Bytes, content_type: Option<&str>) -> Response {
678 let mut response = (status, bytes).into_response();
679 let value = content_type
680 .and_then(|ct| HeaderValue::from_str(ct).ok())
681 .unwrap_or_else(|| HeaderValue::from_static("application/json"));
682 response
683 .headers_mut()
684 .insert(axum::http::header::CONTENT_TYPE, value);
685 response
686}
687
688async fn complete_tool_audit(
689 execution: &ToolExecution,
690 outcome: ToolOutcome,
691 completion_permit: crate::worker::WorkerPermit,
692 session: Arc<crate::session::Session>,
693) -> Response {
694 let status = StatusCode::from_u16(outcome.status).unwrap_or(StatusCode::BAD_GATEWAY);
695 let bytes = match hex::decode(&outcome.body_hex) {
696 Ok(bytes) => Bytes::from(bytes),
697 Err(error) => return lifecycle_error(format!("decode tool response: {error}")),
698 };
699 let success = status.is_success();
700 completion_permit.submit(crate::worker::WorkerJob {
701 session: Arc::clone(&session),
702 identity: session.current_identity(),
703 class: av_events::EventClass::Session,
704 payload: json!({
705 "action": "tool_completed",
706 "execution_key": &execution.key,
707 "status": status.as_u16(),
708 "response_sha256": av_core::digest::sha256_hex(&bytes),
709 }),
710 text: String::new(),
711 analyze_loop: false,
712 status: if success {
713 av_events::StatusId::Success
714 } else {
715 av_events::StatusId::Failure
716 },
717 stop_reason: (!success).then_some(StopReason::Other),
718 native_stop_reason: None,
719 metrics: av_events::EventMetrics::default(),
720 cost_usd_micros: 0,
721 atif: Some(crate::worker::AtifCapture {
722 source: av_atif::Source::System,
723 message: Value::String(String::from_utf8_lossy(&bytes).into_owned()),
724 reasoning_content: None,
725 model_name: None,
726 tool_calls: None,
727 observation: None,
728 llm_call_count: None,
729 }),
730 response_marker: None,
731 response_attempt: None,
732 });
733 session.wait_for_worker_jobs().await;
734 if session.capture_failed() {
735 return lifecycle_error("tool completed but completion audit failed".to_owned());
736 }
737 if let Err(error) = execution.mark_audited().await {
738 return lifecycle_error(error);
739 }
740 tool_response(status, bytes, outcome.content_type.as_deref())
745}
746
747#[derive(Clone, serde::Serialize, serde::Deserialize)]
748struct ToolOutcome {
749 status: u16,
750 body_hex: String,
751 #[serde(default)]
758 content_type: Option<String>,
759}
760
761impl ToolOutcome {
762 fn into_response(self) -> Response {
763 let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::BAD_GATEWAY);
764 match hex::decode(self.body_hex) {
765 Ok(body) => tool_response(status, Bytes::from(body), self.content_type.as_deref()),
766 Err(error) => lifecycle_error(format!("decode cached tool response: {error}")),
767 }
768 }
769}
770
771enum ToolExecutionState {
772 Missing,
773 Pending,
774 Unaudited(ToolOutcome),
775 Completed(ToolOutcome),
776}
777
778const TOOL_REQUEST_MISMATCH: &str = "JSON-RPC id is already bound to a different tool request or principal";
779const TOOL_OUTCOME_UNCERTAIN: &str = "tool execution outcome is uncertain; refusing duplicate execution";
783
784#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
785struct ToolIntent {
786 pub(crate) execution_key: String,
787 pub(crate) session_id: String,
788 pub(crate) tool: String,
789 pub(crate) request_digest: String,
790 pub(crate) principal_digest: String,
791}
792
793#[cfg(test)]
794pub(crate) async fn ensure_no_unresolved_tool_executions(
795 spool: &std::path::Path,
796 control_key: &[u8; 32],
797) -> Result<(), String> {
798 let sessions = unresolved_tool_sessions(spool, control_key).await?;
799 if let Some(session_id) = sessions.into_iter().next() {
800 Err(format!("session {session_id} has an unresolved tool execution"))
801 } else {
802 Ok(())
803 }
804}
805
806pub(crate) async fn unresolved_tool_sessions(
807 spool: &std::path::Path,
808 control_key: &[u8; 32],
809) -> Result<std::collections::HashSet<String>, String> {
810 let directory = spool.join(crate::spool::TOOL_EXECUTIONS);
811 let control_key = *control_key;
812 tokio::task::spawn_blocking(move || {
813 let entries = match std::fs::read_dir(&directory) {
814 Ok(entries) => entries,
815 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
816 return Ok(std::collections::HashSet::new());
817 }
818 Err(error) => return Err(error.to_string()),
819 };
820 let mut intent_keys = std::collections::HashSet::new();
821 let mut unresolved_sessions = std::collections::HashSet::new();
822 for entry in entries {
823 let path = entry.map_err(|error| error.to_string())?.path();
824 let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
825 continue;
826 };
827 let Some(key) = name.strip_suffix(crate::spool::TOOL_INTENT_SUFFIX) else {
828 continue;
829 };
830 intent_keys.insert(key.to_owned());
831 let intent_bytes = std::fs::read(&path).map_err(|error| error.to_string())?;
841 let intent: ToolIntent = match crate::journal::open(
842 &control_key,
843 &format!("{}:{key}", crate::journal::TOOL_INTENT_DOMAIN),
844 0,
845 &intent_bytes,
846 ) {
847 Ok(intent) => intent,
848 Err(error) => {
849 let quarantine = path.with_extension("intent.torn");
850 tracing::warn!(
851 %error,
852 original = %av_core::fsutil::basename(&path),
853 quarantine = %av_core::fsutil::basename(&quarantine),
854 "torn tool-execution intent quarantined so recovery can proceed"
855 );
856 if let Err(rename_err) = std::fs::rename(&path, &quarantine) {
857 tracing::warn!(%rename_err, "failed to quarantine torn intent — leaving in place");
858 }
859 intent_keys.remove(key);
860 continue;
861 }
862 };
863 if intent.execution_key != key {
864 return Err("tool intent path does not match authenticated execution key".to_owned());
865 }
866 let outcome_path = directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX));
867 if !outcome_path.exists() {
868 unresolved_sessions.insert(intent.session_id);
869 continue;
870 }
871 let _: ToolOutcome = crate::journal::open(
872 &control_key,
873 &format!("{}:{key}", crate::journal::TOOL_OUTCOME_DOMAIN),
874 0,
875 &std::fs::read(&outcome_path).map_err(|error| error.to_string())?,
876 )?;
877 let audited_path = directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX));
878 if !audited_path.exists() {
879 unresolved_sessions.insert(intent.session_id);
880 continue;
881 }
882 let _: serde_json::Value = crate::journal::open(
883 &control_key,
884 &format!("{}:{key}", crate::journal::TOOL_AUDITED_DOMAIN),
885 0,
886 &std::fs::read(&audited_path).map_err(|error| error.to_string())?,
887 )?;
888 }
889 for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? {
890 let path = entry.map_err(|error| error.to_string())?.path();
891 let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
892 continue;
893 };
894 if let Some(key) = name.strip_suffix(crate::spool::TOOL_OUTCOME_SUFFIX) {
895 if !intent_keys.contains(key) {
896 return Err("tool outcome exists without an authenticated intent".to_owned());
897 }
898 }
899 }
900 Ok(unresolved_sessions)
901 })
902 .await
903 .map_err(|error| error.to_string())?
904}
905
906#[derive(Clone)]
907struct ToolExecution {
908 key: String,
909 session_id: String,
910 tool: String,
911 request_digest: String,
912 principal_digest: String,
913 control_key: [u8; 32],
914 intent_path: std::path::PathBuf,
915 outcome_path: std::path::PathBuf,
916 audited_path: std::path::PathBuf,
917}
918
919impl ToolExecution {
920 fn from_request(
921 spool: &str,
922 headers: &HeaderMap,
923 body: &[u8],
924 control_key: [u8; 32],
925 ) -> Result<Self, crate::pipeline::PipelineError> {
926 let session_id = crate::pipeline::single_header(headers, crate::pipeline::SESSION_HEADER)?
932 .and_then(|value| value.to_str().ok())
933 .ok_or_else(|| crate::pipeline::PipelineError::BadRequest("missing x-av-session".to_owned()))?;
934 let session_id = av_core::SessionId::parse(session_id)
937 .map(|id| id.to_string())
938 .map_err(|error| crate::pipeline::PipelineError::BadRequest(error.to_string()))?;
939 let call = av_sandbox::parse_tool_call(body)
940 .map_err(|error| crate::pipeline::PipelineError::BadRequest(error.to_string()))?;
941 let id = call.id.ok_or_else(|| {
942 crate::pipeline::PipelineError::BadRequest(
943 "forwarded tool calls require a JSON-RPC id for idempotency".to_owned(),
944 )
945 })?;
946 let request: Value = serde_json::from_slice(body)
947 .map_err(|error| crate::pipeline::PipelineError::BadRequest(error.to_string()))?;
948 let canonical = av_receipts::canonicalize(&request)
949 .map_err(|error| crate::pipeline::PipelineError::BadRequest(error.to_string()))?;
950 let request_digest = av_core::digest::sha256_hex(canonical.as_bytes());
951 let key_material = format!("{session_id}:{}", id);
952 let key = av_core::digest::sha256_hex(key_material.as_bytes());
953 let directory = std::path::Path::new(spool).join(crate::spool::TOOL_EXECUTIONS);
954 Ok(Self {
955 intent_path: directory.join(format!("{key}{}", crate::spool::TOOL_INTENT_SUFFIX)),
956 outcome_path: directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX)),
957 audited_path: directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX)),
958 key,
959 session_id,
960 tool: call.tool,
961 request_digest,
962 principal_digest: String::new(),
963 control_key,
964 })
965 }
966
967 fn bind_principal(
968 &mut self,
969 identity: &av_events::AgentIdentity,
970 ) -> Result<(), crate::pipeline::PipelineError> {
971 let stable_identity = json!({
972 "version": identity.version,
973 "charter": identity.charter,
974 "instance_uid": identity.instance_uid,
975 });
976 let canonical = av_receipts::canonicalize(&stable_identity)
977 .map_err(|error| crate::pipeline::PipelineError::BadRequest(error.to_string()))?;
978 self.principal_digest = av_core::digest::sha256_hex(canonical.as_bytes());
979 Ok(())
980 }
981
982 async fn load(&self) -> Result<ToolExecutionState, String> {
983 let execution = self.clone();
984 tokio::task::spawn_blocking(move || execution.load_sync())
985 .await
986 .map_err(|error| error.to_string())?
987 }
988
989 fn load_sync(&self) -> Result<ToolExecutionState, String> {
990 if !self.intent_path.exists() {
991 if self.outcome_path.exists() || self.audited_path.exists() {
992 return Err("tool outcome exists without an authenticated intent".to_owned());
993 }
994 return Ok(ToolExecutionState::Missing);
995 }
996 let intent: ToolIntent = crate::journal::open(
997 &self.control_key,
998 &format!("{}:{}", crate::journal::TOOL_INTENT_DOMAIN, self.key),
999 0,
1000 &std::fs::read(&self.intent_path).map_err(|error| error.to_string())?,
1001 )?;
1002 if intent != self.intent() {
1003 return Err(TOOL_REQUEST_MISMATCH.to_owned());
1004 }
1005 if self.outcome_path.exists() {
1006 let outcome: ToolOutcome = crate::journal::open(
1007 &self.control_key,
1008 &format!("{}:{}", crate::journal::TOOL_OUTCOME_DOMAIN, self.key),
1009 0,
1010 &std::fs::read(&self.outcome_path).map_err(|error| error.to_string())?,
1011 )?;
1012 return if self.audited_path.exists()
1013 && crate::journal::open::<serde_json::Value>(
1014 &self.control_key,
1015 &format!("{}:{}", crate::journal::TOOL_AUDITED_DOMAIN, self.key),
1016 0,
1017 &std::fs::read(&self.audited_path).map_err(|error| error.to_string())?,
1018 )
1019 .is_ok()
1020 {
1021 Ok(ToolExecutionState::Completed(outcome))
1022 } else {
1023 Ok(ToolExecutionState::Unaudited(outcome))
1024 };
1025 }
1026 Ok(ToolExecutionState::Pending)
1027 }
1028
1029 async fn claim(&self) -> Result<(), String> {
1030 let execution = self.clone();
1031 tokio::task::spawn_blocking(move || execution.claim_sync())
1032 .await
1033 .map_err(|error| error.to_string())?
1034 }
1035
1036 fn claim_sync(&self) -> Result<(), String> {
1037 use std::io::Write as _;
1038 let directory = self
1039 .intent_path
1040 .parent()
1041 .ok_or_else(|| "tool execution directory is missing".to_owned())?;
1042 std::fs::create_dir_all(directory).map_err(|error| error.to_string())?;
1043 let mut file = std::fs::OpenOptions::new()
1044 .write(true)
1045 .create_new(true)
1046 .open(&self.intent_path)
1047 .map_err(|error| format!("tool execution already claimed or unavailable: {error}"))?;
1048 let intent = crate::journal::seal(
1049 &self.control_key,
1050 &format!("{}:{}", crate::journal::TOOL_INTENT_DOMAIN, self.key),
1051 0,
1052 &self.intent(),
1053 )?;
1054 file.write_all(&intent).map_err(|error| error.to_string())?;
1055 file.sync_all().map_err(|error| error.to_string())?;
1056 std::fs::File::open(directory)
1057 .and_then(|directory| directory.sync_all())
1058 .map_err(|error| error.to_string())
1059 }
1060
1061 async fn persist(&self, outcome: &ToolOutcome) -> Result<(), String> {
1062 let execution = self.clone();
1063 let outcome = outcome.clone();
1064 tokio::task::spawn_blocking(move || {
1065 let sealed = crate::journal::seal(
1066 &execution.control_key,
1067 &format!("{}:{}", crate::journal::TOOL_OUTCOME_DOMAIN, execution.key),
1068 0,
1069 &outcome,
1070 )?;
1071 write_atomic_bytes(&execution.outcome_path, &sealed)
1072 })
1073 .await
1074 .map_err(|error| error.to_string())?
1075 }
1076
1077 async fn mark_audited(&self) -> Result<(), String> {
1078 let execution = self.clone();
1079 tokio::task::spawn_blocking(move || {
1080 let sealed = crate::journal::seal(
1081 &execution.control_key,
1082 &format!("{}:{}", crate::journal::TOOL_AUDITED_DOMAIN, execution.key),
1083 0,
1084 &json!({"audited": true}),
1085 )?;
1086 write_atomic_bytes(&execution.audited_path, &sealed)
1087 })
1088 .await
1089 .map_err(|error| error.to_string())?
1090 }
1091
1092 fn intent(&self) -> ToolIntent {
1093 ToolIntent {
1094 execution_key: self.key.clone(),
1095 session_id: self.session_id.clone(),
1096 tool: self.tool.clone(),
1097 request_digest: self.request_digest.clone(),
1098 principal_digest: self.principal_digest.clone(),
1099 }
1100 }
1101}
1102
1103fn write_atomic_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
1104 av_core::fsutil::write_atomic(path, bytes).map_err(|error| error.to_string())
1105}
1106
1107async fn close_session(
1108 State(state): State<AppState>,
1109 Path(id): Path<String>,
1110 headers: HeaderMap,
1111) -> Response {
1112 let Some(session) = state.sessions.get(&id) else {
1113 return (StatusCode::NOT_FOUND, Json(json!({"error": "unknown session"}))).into_response();
1114 };
1115 if let Err(error) = state.authorize_session(&headers, &session, &state.config.session_close_scope) {
1116 return pipeline_error(error);
1117 }
1118 match state
1119 .finalizer
1120 .close_session(session, StopReason::SessionClosed)
1121 .await
1122 {
1123 Ok(outcome) => Json(outcome).into_response(),
1124 Err(error) => lifecycle_error(error.to_string()),
1125 }
1126}
1127
1128async fn promote_session(
1129 State(state): State<AppState>,
1130 Path(id): Path<String>,
1131 headers: HeaderMap,
1132) -> Response {
1133 let Some(session) = state.sessions.get(&id) else {
1134 return (StatusCode::NOT_FOUND, Json(json!({"error": "unknown session"}))).into_response();
1135 };
1136 if let Err(error) = state.authorize_session(&headers, &session, &state.config.session_promote_scope) {
1137 return pipeline_error(error);
1138 }
1139 if !session.is_closed() {
1148 if let Err(error) = state.authorize_session(&headers, &session, &state.config.session_close_scope) {
1149 return pipeline_error(error);
1150 }
1151 }
1152 match state.finalizer.promote(session).await {
1153 Ok(receipt) => Json(receipt).into_response(),
1154 Err(error) => lifecycle_error(error.to_string()),
1155 }
1156}
1157
1158fn pipeline_error(error: crate::pipeline::PipelineError) -> Response {
1159 use crate::pipeline::PipelineError;
1160 let close = matches!(error, PipelineError::Abort(_));
1161 let status = error.status();
1162 let mut response = (status, Json(json!({"error": error.to_string()}))).into_response();
1163 if close {
1164 response
1165 .headers_mut()
1166 .insert(axum::http::header::CONNECTION, HeaderValue::from_static("close"));
1167 }
1168 match status.as_u16() {
1177 429 | 502 | 503 | 504 => {
1182 response
1183 .headers_mut()
1184 .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
1185 }
1186 401 => {
1189 response.headers_mut().insert(
1190 axum::http::header::WWW_AUTHENTICATE,
1191 HeaderValue::from_static("Bearer realm=\"agentvisor\", error=\"invalid_token\""),
1192 );
1193 }
1194 _ => {}
1195 }
1196 response
1197}
1198
1199fn lifecycle_error(error: String) -> Response {
1200 (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": error}))).into_response()
1201}
1202
1203struct AbortFinalizingStream {
1204 inner: BoxStream<'static, Result<Bytes, std::io::Error>>,
1205 session: Arc<crate::session::Session>,
1206 identity: av_events::AgentIdentity,
1207 response_permit: Option<crate::worker::ResponsePermit>,
1208 worker: crate::worker::WorkerHandle,
1213 store: Arc<dyn av_state::StateStore>,
1214 budget: av_state::BudgetSpec,
1215 finalizer: crate::reconciler::Finalizer,
1216 _lease: crate::session::SessionLease,
1217 response_marker: Option<String>,
1218 response_attempt_id: String,
1219 response_message: String,
1220 response_reasoning: String,
1221 response_model: Option<String>,
1222 response_finish_reason: Option<String>,
1223 upstream_status: StatusCode,
1224 response_cost_usd_micros: u64,
1225 response_tool_calls: std::collections::BTreeMap<u64, PartialToolCall>,
1226 response_metrics: av_events::EventMetrics,
1227 charged_completion_tokens: u64,
1228 last_reported_completion_tokens: Option<u64>,
1229 last_reported_prompt_tokens: Option<u64>,
1230 last_reported_cached_tokens: Option<u64>,
1231 last_reported_cost_usd_micros: Option<u64>,
1232 saw_chunk: bool,
1233 capture_submitted: bool,
1234 is_sse: bool,
1235 protocol_buffer: Vec<u8>,
1236 pending_output: std::collections::VecDeque<Bytes>,
1237 pending_budget: Option<PendingBudget>,
1238 captured_bytes: usize,
1239 completed: bool,
1240}
1241
1242const MAX_PROVIDER_CAPTURE_BYTES: usize = 16 * 1024 * 1024;
1243const MAX_PROVIDER_FIELD_BYTES: usize = 8 * 1024 * 1024;
1244const MAX_PROVIDER_TOOL_CALLS: usize = 128;
1245
1246impl AbortFinalizingStream {
1247 fn absorb_network_chunk(&mut self, raw: &[u8]) -> Result<u64, String> {
1248 self.saw_chunk |= !raw.is_empty();
1249 self.captured_bytes = self
1250 .captured_bytes
1251 .checked_add(raw.len())
1252 .ok_or_else(|| "provider response size overflow".to_owned())?;
1253 if self.captured_bytes > MAX_PROVIDER_CAPTURE_BYTES {
1254 return Err(format!(
1255 "provider response exceeds {MAX_PROVIDER_CAPTURE_BYTES} capture bytes"
1256 ));
1257 }
1258 self.protocol_buffer.extend_from_slice(raw);
1259 if !self.is_sse {
1260 return Ok(0);
1261 }
1262 if !self.upstream_status.is_success() {
1271 self.protocol_buffer.clear();
1272 return Ok(0);
1273 }
1274 let mut budget_delta = 0u64;
1275 while let Some(end) = sse_frame_end(&self.protocol_buffer) {
1276 let frame: Vec<u8> = self.protocol_buffer.drain(..end).collect();
1277 let frame = std::str::from_utf8(&frame)
1278 .map_err(|error| format!("provider SSE frame is not UTF-8: {error}"))?;
1279 budget_delta = budget_delta
1280 .checked_add(self.absorb_frame(frame)?)
1281 .ok_or_else(|| "provider completion-token delta overflow".to_owned())?;
1282 }
1283 if self.protocol_buffer.len() > MAX_PROVIDER_FIELD_BYTES {
1284 return Err("unterminated provider SSE frame exceeds limit".to_owned());
1285 }
1286 Ok(budget_delta)
1287 }
1288
1289 fn flush_protocol_buffer(&mut self) -> Result<u64, String> {
1290 if self.protocol_buffer.is_empty() {
1291 return Ok(0);
1292 }
1293 if !self.upstream_status.is_success() {
1306 let _ = std::mem::take(&mut self.protocol_buffer);
1307 return Ok(0);
1308 }
1309 let frame = std::mem::take(&mut self.protocol_buffer);
1310 let frame = std::str::from_utf8(&frame)
1311 .map_err(|error| format!("provider response is not UTF-8: {error}"))?;
1312 self.absorb_frame(frame)
1313 }
1314
1315 fn absorb_frame(&mut self, raw: &str) -> Result<u64, String> {
1316 let Some(parsed) = parse_provider_chunk(raw)? else {
1317 return Ok(0);
1318 };
1319 if self.upstream_status.is_success() && !parsed.has_choices {
1320 return Err("successful provider response has no choices array".to_owned());
1321 }
1322 push_bounded(&mut self.response_message, &parsed.message, "response message")?;
1323 if let Some(reasoning) = parsed.reasoning {
1324 push_bounded(&mut self.response_reasoning, &reasoning, "response reasoning")?;
1325 }
1326 if self.response_model.is_none() {
1327 self.response_model = parsed.model_name;
1328 }
1329 if parsed.finish_reason.is_some() {
1330 self.response_finish_reason = parsed.finish_reason;
1331 }
1332 self.response_cost_usd_micros = self.response_cost_usd_micros.max(parsed.cost_usd_micros);
1333 for delta in parsed.tool_call_deltas {
1334 if delta.index >= MAX_PROVIDER_TOOL_CALLS as u64 {
1335 return Err(format!(
1336 "provider tool-call index {} is out of range",
1337 delta.index
1338 ));
1339 }
1340 if !self.response_tool_calls.contains_key(&delta.index)
1341 && self.response_tool_calls.len() >= MAX_PROVIDER_TOOL_CALLS
1342 {
1343 return Err(format!(
1344 "provider response exceeds {MAX_PROVIDER_TOOL_CALLS} tool calls"
1345 ));
1346 }
1347 let partial = self.response_tool_calls.entry(delta.index).or_default();
1348 if delta.id.is_some() {
1349 partial.id = delta.id;
1350 }
1351 if delta.name.is_some() {
1352 partial.name = delta.name;
1353 }
1354 push_bounded(&mut partial.arguments, &delta.arguments, "tool-call arguments")?;
1355 }
1356 if parsed.usage_reported {
1357 reject_metric_regression(
1358 "prompt tokens",
1359 &mut self.last_reported_prompt_tokens,
1360 parsed.metrics.prompt_tokens,
1361 )?;
1362 reject_metric_regression(
1363 "cached tokens",
1364 &mut self.last_reported_cached_tokens,
1365 parsed.metrics.cached_tokens,
1366 )?;
1367 }
1368 if parsed.cost_reported {
1369 reject_metric_regression(
1370 "cost",
1371 &mut self.last_reported_cost_usd_micros,
1372 Some(parsed.cost_usd_micros),
1373 )?;
1374 }
1375 if let Some(prompt) = parsed.metrics.prompt_tokens {
1376 self.response_metrics.prompt_tokens = Some(
1377 self.response_metrics
1378 .prompt_tokens
1379 .map_or(prompt, |current| current.max(prompt)),
1380 );
1381 }
1382 if let Some(cached) = parsed.metrics.cached_tokens {
1383 self.response_metrics.cached_tokens = Some(
1384 self.response_metrics
1385 .cached_tokens
1386 .map_or(cached, |current| current.max(cached)),
1387 );
1388 }
1389 let reported_completion = parsed.metrics.completion_tokens.unwrap_or(0);
1390 let delta = if parsed.usage_reported {
1391 let previous = self.last_reported_completion_tokens.unwrap_or(0);
1392 if reported_completion < previous {
1393 return Err("provider completion usage regressed".to_owned());
1394 }
1395 self.last_reported_completion_tokens = Some(reported_completion);
1396 let delta = reported_completion.saturating_sub(self.charged_completion_tokens);
1397 self.response_metrics.completion_tokens = Some(
1398 self.response_metrics
1399 .completion_tokens
1400 .map_or(reported_completion, |current| current.max(reported_completion)),
1401 );
1402 delta
1403 } else {
1404 let total = self
1405 .response_metrics
1406 .completion_tokens
1407 .unwrap_or(0)
1408 .checked_add(reported_completion)
1409 .filter(|total| *total <= av_core::error::JCS_SAFE_MAX)
1410 .ok_or_else(|| "provider completion-token total exceeds JCS-safe bounds".to_owned())?;
1411 self.response_metrics.completion_tokens = Some(total);
1412 reported_completion
1413 };
1414 if delta == 0 {
1415 return Ok(0);
1416 }
1417 self.charged_completion_tokens = self
1418 .charged_completion_tokens
1419 .checked_add(delta)
1420 .ok_or_else(|| "charged completion-token counter overflow".to_owned())?;
1421 Ok(delta)
1422 }
1423
1424 fn begin_budget_check(&mut self, delta: u64, continuation: BudgetContinuation) {
1425 let store = Arc::clone(&self.store);
1426 let session_id = self.session.id.clone();
1427 let budget = self.budget.clone();
1428 let task = tokio::task::spawn_blocking(move || {
1429 av_state::ActionBudget::new(store.as_ref(), &session_id, &budget)
1430 .try_tokens(delta)
1431 .map_err(|error| format!("token budget backend failed closed: {error}"))
1432 });
1433 self.pending_budget = Some(PendingBudget { task, continuation });
1434 }
1435
1436 fn submit_response_capture(&mut self, failure: Option<String>) -> Result<(), crate::worker::SubmitError> {
1437 if self.capture_submitted {
1438 return Ok(());
1439 }
1440 self.capture_submitted = true;
1441 let reasoning =
1442 (!self.response_reasoning.is_empty()).then(|| std::mem::take(&mut self.response_reasoning));
1443 let analysis_text = reasoning.clone().unwrap_or_else(|| self.response_message.clone());
1444 let native_finish_reason = self.response_finish_reason.clone();
1445 let (class, status, stop_reason, payload) = if let Some(reason) = failure {
1446 (
1447 av_events::EventClass::StopReason,
1448 av_events::StatusId::Failure,
1449 Some(av_events::StopReason::BudgetExceeded),
1450 json!({"reason": reason, "direction": "upstream_response"}),
1451 )
1452 } else if !self.upstream_status.is_success() {
1453 (
1454 av_events::EventClass::StopReason,
1455 av_events::StatusId::Failure,
1456 Some(av_events::StopReason::Other),
1457 json!({
1458 "direction": "upstream_response",
1459 "http_status": self.upstream_status.as_u16(),
1460 }),
1461 )
1462 } else if let Some(native) = &native_finish_reason {
1463 (
1464 av_events::EventClass::StopReason,
1465 av_events::StatusId::Success,
1466 Some(map_finish_reason(native)),
1467 json!({
1468 "direction": "upstream_response",
1469 "finish_reason": native,
1470 "http_status": self.upstream_status.as_u16(),
1471 }),
1472 )
1473 } else {
1474 (
1475 av_events::EventClass::Session,
1476 av_events::StatusId::Success,
1477 None,
1478 json!({
1479 "direction": "upstream_response",
1480 "http_status": self.upstream_status.as_u16(),
1481 }),
1482 )
1483 };
1484 let tool_calls: Vec<av_atif::ToolCall> = std::mem::take(&mut self.response_tool_calls)
1485 .into_values()
1486 .map(|partial| {
1487 let arguments = serde_json::from_str(&partial.arguments)
1488 .unwrap_or_else(|_| json!({"raw": partial.arguments}));
1489 av_atif::ToolCall {
1490 tool_call_id: partial.id.unwrap_or_else(av_core::new_event_uid),
1491 function_name: partial.name.unwrap_or_else(|| "unknown".to_owned()),
1492 arguments,
1493 extra: None,
1494 }
1495 })
1496 .collect();
1497 let permit = self
1498 .response_permit
1499 .take()
1500 .ok_or(crate::worker::SubmitError::Closed)?;
1501 permit.submit(
1502 &self.worker,
1503 crate::worker::WorkerJob {
1504 session: Arc::clone(&self.session),
1505 identity: self.identity.clone(),
1506 class,
1507 payload,
1508 text: analysis_text,
1509 analyze_loop: true,
1510 status,
1511 stop_reason,
1512 native_stop_reason: native_finish_reason,
1513 metrics: self.response_metrics,
1514 cost_usd_micros: self.response_cost_usd_micros,
1515 atif: Some(crate::worker::AtifCapture {
1516 source: av_atif::Source::Agent,
1517 message: Value::String(std::mem::take(&mut self.response_message)),
1518 reasoning_content: reasoning,
1519 model_name: self.response_model.take(),
1520 tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
1521 observation: None,
1522 llm_call_count: Some(1),
1523 }),
1524 response_marker: self.response_marker.take(),
1525 response_attempt: Some(crate::worker::ResponseAttempt {
1526 id: self.response_attempt_id.clone(),
1527 terminal: true,
1528 }),
1529 },
1530 )?;
1531 Ok(())
1532 }
1533}
1534
1535enum BudgetContinuation {
1536 Emit(Bytes),
1537 ContinueNonSse,
1538 FinishSse,
1539 FinishNonSse,
1540}
1541
1542struct PendingBudget {
1543 task: tokio::task::JoinHandle<Result<av_state::BudgetDecision, String>>,
1544 continuation: BudgetContinuation,
1545}
1546
1547#[derive(Default)]
1548struct PartialToolCall {
1549 id: Option<String>,
1550 name: Option<String>,
1551 arguments: String,
1552}
1553
1554struct ProviderToolCallDelta {
1555 index: u64,
1556 id: Option<String>,
1557 name: Option<String>,
1558 arguments: String,
1559}
1560
1561struct ParsedProviderChunk {
1562 message: String,
1563 reasoning: Option<String>,
1564 model_name: Option<String>,
1565 metrics: av_events::EventMetrics,
1566 usage_reported: bool,
1567 finish_reason: Option<String>,
1568 cost_usd_micros: u64,
1569 cost_reported: bool,
1570 has_choices: bool,
1571 tool_call_deltas: Vec<ProviderToolCallDelta>,
1572}
1573
1574fn reject_metric_regression(
1575 field: &str,
1576 previous: &mut Option<u64>,
1577 current: Option<u64>,
1578) -> Result<(), String> {
1579 let Some(current) = current else {
1580 return Ok(());
1581 };
1582 if previous.is_some_and(|previous| current < previous) {
1583 return Err(format!("provider {field} regressed"));
1584 }
1585 *previous = Some(current);
1586 Ok(())
1587}
1588
1589fn push_bounded(target: &mut String, value: &str, field: &str) -> Result<(), String> {
1590 let size = target
1591 .len()
1592 .checked_add(value.len())
1593 .ok_or_else(|| format!("{field} size overflow"))?;
1594 if size > MAX_PROVIDER_FIELD_BYTES {
1595 return Err(format!("{field} exceeds {MAX_PROVIDER_FIELD_BYTES} bytes"));
1596 }
1597 target.push_str(value);
1598 Ok(())
1599}
1600
1601fn sse_frame_end(buffer: &[u8]) -> Option<usize> {
1602 let mut line_start = 0usize;
1603 let mut index = 0usize;
1604 while index < buffer.len() {
1605 let byte = buffer.get(index).copied()?;
1606 let newline = match byte {
1607 b'\r' if buffer.get(index + 1) == Some(&b'\n') => 2,
1608 b'\r' if buffer.get(index + 1).is_none() => return None,
1609 b'\r' | b'\n' => 1,
1610 _ => {
1611 index += 1;
1612 continue;
1613 }
1614 };
1615 if index == line_start {
1616 return Some(index + newline);
1617 }
1618 index += newline;
1619 line_start = index;
1620 }
1621 None
1622}
1623
1624impl AbortFinalizingStream {
1625 fn abort_error(&self, reason: String) -> std::io::Error {
1631 tracing::warn!(
1632 session = %self.session.id,
1633 %reason,
1634 "aborting client response; upstream reply could not be captured"
1635 );
1636 std::io::Error::other(reason)
1637 }
1638}
1639
1640impl Stream for AbortFinalizingStream {
1641 type Item = Result<Bytes, std::io::Error>;
1642
1643 fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1644 if self.pending_budget.is_some() {
1645 let result = {
1646 let Some(pending) = self.pending_budget.as_mut() else {
1647 self.session.mark_capture_failed();
1648 return Poll::Ready(Some(Err(std::io::Error::other(
1649 "pending completion budget state disappeared",
1650 ))));
1651 };
1652 match Pin::new(&mut pending.task).poll(context) {
1653 Poll::Pending => return Poll::Pending,
1654 Poll::Ready(result) => result,
1655 }
1656 };
1657 let Some(pending) = self.pending_budget.take() else {
1658 self.session.mark_capture_failed();
1659 return Poll::Ready(Some(Err(std::io::Error::other(
1660 "completed completion budget state disappeared",
1661 ))));
1662 };
1663 let failure = match result {
1664 Ok(Ok(av_state::BudgetDecision::Allowed { .. })) => None,
1665 Ok(Ok(av_state::BudgetDecision::Refused { limit, cap })) => {
1666 Some(format!("{limit} exceeded (cap {cap})"))
1667 }
1668 Ok(Err(reason)) => Some(reason),
1669 Err(error) => {
1670 self.session.mark_capture_failed();
1671 self.pending_output.clear();
1672 return Poll::Ready(Some(Err(std::io::Error::other(format!(
1673 "completion budget task failed: {error}"
1674 )))));
1675 }
1676 };
1677 if let Some(reason) = failure {
1678 if let Err(error) = self.submit_response_capture(Some(reason.clone())) {
1679 self.session.mark_capture_failed();
1680 self.pending_output.clear();
1681 return Poll::Ready(Some(Err(std::io::Error::other(format!(
1682 "response capture failed closed: {error}"
1683 )))));
1684 }
1685 self.pending_output.clear();
1686 return Poll::Ready(Some(Err(std::io::Error::new(
1690 std::io::ErrorKind::QuotaExceeded,
1691 format!("response blocked by token budget: {reason}"),
1692 ))));
1693 }
1694 match pending.continuation {
1695 BudgetContinuation::Emit(bytes) => return Poll::Ready(Some(Ok(bytes))),
1696 BudgetContinuation::ContinueNonSse => {}
1697 BudgetContinuation::FinishSse => {
1698 if let Err(error) = self.submit_response_capture(None) {
1699 self.session.mark_capture_failed();
1700 return Poll::Ready(Some(Err(std::io::Error::other(format!(
1701 "response capture failed closed: {error}"
1702 )))));
1703 }
1704 self.completed = true;
1705 return Poll::Ready(None);
1706 }
1707 BudgetContinuation::FinishNonSse => {
1708 if let Err(error) = self.submit_response_capture(None) {
1709 self.session.mark_capture_failed();
1710 self.pending_output.clear();
1711 return Poll::Ready(Some(Err(std::io::Error::other(format!(
1712 "response capture failed closed: {error}"
1713 )))));
1714 }
1715 self.completed = true;
1716 return Poll::Ready(self.pending_output.pop_front().map(Ok));
1717 }
1718 }
1719 }
1720
1721 if !self.is_sse {
1722 if self.completed {
1723 return Poll::Ready(self.pending_output.pop_front().map(Ok));
1724 }
1725 loop {
1726 match self.inner.as_mut().poll_next(context) {
1727 Poll::Ready(Some(Ok(bytes))) => {
1728 let delta = match self.absorb_network_chunk(&bytes) {
1729 Ok(delta) => delta,
1730 Err(error) => {
1731 self.session.mark_capture_failed();
1732 self.pending_output.clear();
1733 let error = self.abort_error(error);
1734 return Poll::Ready(Some(Err(error)));
1735 }
1736 };
1737 self.pending_output.push_back(bytes);
1738 if delta > 0 {
1739 self.begin_budget_check(delta, BudgetContinuation::ContinueNonSse);
1740 context.waker().wake_by_ref();
1741 return Poll::Pending;
1742 }
1743 }
1744 Poll::Ready(Some(Err(error))) => {
1745 self.session.mark_capture_failed();
1746 self.pending_output.clear();
1747 let error = self.abort_error(error.to_string());
1748 return Poll::Ready(Some(Err(error)));
1749 }
1750 Poll::Pending => return Poll::Pending,
1751 Poll::Ready(None) => {
1752 let delta = match self.flush_protocol_buffer() {
1753 Ok(delta) => delta,
1754 Err(error) => {
1755 self.session.mark_capture_failed();
1756 self.pending_output.clear();
1757 let error = self.abort_error(error);
1758 return Poll::Ready(Some(Err(error)));
1759 }
1760 };
1761 if delta > 0 {
1762 self.begin_budget_check(delta, BudgetContinuation::FinishNonSse);
1763 context.waker().wake_by_ref();
1764 return Poll::Pending;
1765 }
1766 if let Err(error) = self.submit_response_capture(None) {
1767 self.session.mark_capture_failed();
1768 self.pending_output.clear();
1769 return Poll::Ready(Some(Err(std::io::Error::other(format!(
1770 "response capture failed closed: {error}"
1771 )))));
1772 }
1773 self.completed = true;
1774 return Poll::Ready(self.pending_output.pop_front().map(Ok));
1775 }
1776 }
1777 }
1778 }
1779
1780 match self.inner.as_mut().poll_next(context) {
1781 Poll::Ready(Some(Ok(bytes))) => match self.absorb_network_chunk(&bytes) {
1782 Ok(0) => Poll::Ready(Some(Ok(bytes))),
1783 Ok(delta) => {
1784 self.begin_budget_check(delta, BudgetContinuation::Emit(bytes));
1785 context.waker().wake_by_ref();
1786 Poll::Pending
1787 }
1788 Err(error) => {
1789 self.session.mark_capture_failed();
1790 let error = self.abort_error(error);
1791 Poll::Ready(Some(Err(error)))
1792 }
1793 },
1794 Poll::Ready(Some(Err(error))) => {
1795 self.session.mark_capture_failed();
1796 let error = self.abort_error(error.to_string());
1797 Poll::Ready(Some(Err(error)))
1798 }
1799 Poll::Pending => Poll::Pending,
1800 Poll::Ready(None) => {
1801 let delta = match self.flush_protocol_buffer() {
1802 Ok(delta) => delta,
1803 Err(error) => {
1804 self.session.mark_capture_failed();
1805 let error = self.abort_error(error);
1806 return Poll::Ready(Some(Err(error)));
1807 }
1808 };
1809 if delta > 0 {
1810 self.begin_budget_check(delta, BudgetContinuation::FinishSse);
1811 context.waker().wake_by_ref();
1812 return Poll::Pending;
1813 }
1814 if let Err(error) = self.submit_response_capture(None) {
1815 self.session.mark_capture_failed();
1816 return Poll::Ready(Some(Err(std::io::Error::other(format!(
1817 "response capture failed closed: {error}"
1818 )))));
1819 }
1820 self.completed = true;
1821 Poll::Ready(None)
1822 }
1823 }
1824 }
1825}
1826
1827fn parse_provider_chunk(raw: &str) -> Result<Option<ParsedProviderChunk>, String> {
1828 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
1837 let mut message = String::new();
1838 let mut reasoning = String::new();
1839 let mut prompt_tokens = None;
1840 let mut completion_tokens = None;
1841 let mut cached_tokens = None;
1842 let mut usage_reported = false;
1843 let mut finish_reason = None;
1844 let mut cost_usd_micros = 0u64;
1845 let mut cost_reported = false;
1846 let mut tool_call_deltas = Vec::new();
1847 let mut is_sse = false;
1848 let mut data = Vec::new();
1849 let mut event_type = String::new();
1864 for line in raw.split(['\r', '\n']) {
1865 if let Some(value) = line.strip_prefix("data:") {
1866 is_sse = true;
1867 data.push(value.strip_prefix(' ').unwrap_or(value));
1873 } else if let Some(value) = line.strip_prefix("event:") {
1874 is_sse = true;
1875 event_type = value.strip_prefix(' ').unwrap_or(value).to_owned();
1876 } else if line == "data"
1877 || line.starts_with("id:")
1878 || line.starts_with("retry:")
1879 || line.starts_with(':')
1880 {
1881 is_sse = true;
1882 }
1883 }
1884 if is_sse && !event_type.is_empty() && event_type != "message" {
1885 return Err(format!(
1886 "provider SSE frame carries unsupported event type {event_type:?}; \
1887 AgentVisor AI only captures the default `message` event because non-message \
1888 events (error, ping, custom) are dispatched to different client listeners \
1889 per SSE §9.2.8 and would be attributed to the wrong audit surface"
1890 ));
1891 }
1892 let candidate = if is_sse {
1893 if data.is_empty() {
1894 return Ok(None);
1895 }
1896 data.join("\n")
1897 } else {
1898 raw.trim().to_owned()
1899 };
1900 if candidate.is_empty()
1906 || candidate.trim() == "[DONE]"
1907 || data
1908 .iter()
1909 .all(|entry| entry.trim().is_empty() || entry.trim() == "[DONE]")
1910 && !data.is_empty()
1911 {
1912 return Ok(None);
1913 }
1914 let value = serde_json::from_str::<Value>(&candidate)
1915 .map_err(|error| format!("invalid provider JSON frame: {error}"))?;
1916 let model_name = value.get("model").and_then(Value::as_str).map(str::to_owned);
1917 if let Some(usage) = value.get("usage") {
1918 usage_reported = true;
1919 prompt_tokens = provider_u64(usage.get("prompt_tokens"), "prompt_tokens")?.or(prompt_tokens);
1920 completion_tokens =
1921 provider_u64(usage.get("completion_tokens"), "completion_tokens")?.or(completion_tokens);
1922 cached_tokens = provider_u64(
1923 usage.pointer("/prompt_tokens_details/cached_tokens"),
1924 "cached_tokens",
1925 )?
1926 .or(cached_tokens);
1927 }
1928 let cost_value = value.pointer("/usage/cost_usd").or_else(|| value.get("cost_usd"));
1929 if let Some(cost_value) = cost_value {
1930 let cost = cost_value
1931 .as_f64()
1932 .ok_or_else(|| "provider cost_usd is not a number".to_owned())?;
1933 if !cost.is_finite() || cost < 0.0 {
1934 return Err("provider cost_usd is not finite and nonnegative".to_owned());
1935 }
1936 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1937 {
1938 let micros = (cost * av_core::units::USD_MICROS_PER_DOLLAR as f64).round();
1939 if micros > av_core::error::JCS_SAFE_MAX as f64 {
1940 return Err("provider cost exceeds JCS-safe receipt bounds".to_owned());
1941 }
1942 cost_usd_micros = micros as u64;
1943 cost_reported = true;
1944 }
1945 }
1946 let has_choices = value.get("choices").is_some();
1947 let choices = match value.get("choices") {
1948 Some(Value::Array(choices)) => Some(choices),
1949 Some(_) => return Err("provider choices is not an array".to_owned()),
1950 None => None,
1951 };
1952 if let Some(choices) = choices {
1953 for choice in choices {
1954 if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
1955 finish_reason = Some(reason.to_owned());
1956 }
1957 let content = choice
1958 .pointer("/delta/content")
1959 .or_else(|| choice.pointer("/message/content"))
1960 .and_then(Value::as_str);
1961 if let Some(content) = content {
1962 message.push_str(content);
1963 }
1964 let reasoning_content = choice
1965 .pointer("/delta/reasoning_content")
1966 .or_else(|| choice.pointer("/message/reasoning_content"))
1967 .and_then(Value::as_str);
1968 if let Some(content) = reasoning_content {
1969 reasoning.push_str(content);
1970 }
1971 let streaming_calls = choice.pointer("/delta/tool_calls");
1972 let calls = streaming_calls
1973 .or_else(|| choice.pointer("/message/tool_calls"))
1974 .map(|calls| {
1975 calls
1976 .as_array()
1977 .ok_or_else(|| "provider tool_calls is not an array".to_owned())
1978 })
1979 .transpose()?;
1980 if let Some(calls) = calls {
1981 for (position, call) in calls.iter().enumerate() {
1982 let index = match call.get("index").and_then(Value::as_u64) {
1983 Some(index) => index,
1984 None if streaming_calls.is_some() => {
1985 return Err("streaming provider tool call has no index".to_owned());
1986 }
1987 None => u64::try_from(position)
1988 .map_err(|_| "provider tool-call index overflow".to_owned())?,
1989 };
1990 tool_call_deltas.push(ProviderToolCallDelta {
1991 index,
1992 id: call.get("id").and_then(Value::as_str).map(str::to_owned),
1993 name: call
1994 .pointer("/function/name")
1995 .and_then(Value::as_str)
1996 .map(str::to_owned),
1997 arguments: call
1998 .pointer("/function/arguments")
1999 .and_then(Value::as_str)
2000 .unwrap_or_default()
2001 .to_owned(),
2002 });
2003 }
2004 }
2005 }
2006 }
2007 let completion_tokens = completion_tokens.unwrap_or_else(|| {
2008 let mut estimated = av_core::tokens::approx_tokens(&message)
2009 .saturating_add(av_core::tokens::approx_tokens(&reasoning));
2010 for call in &tool_call_deltas {
2011 estimated = estimated
2012 .saturating_add(call.name.as_deref().map_or(0, av_core::tokens::approx_tokens))
2013 .saturating_add(av_core::tokens::approx_tokens(&call.arguments));
2014 }
2015 estimated
2016 });
2017 Ok(Some(ParsedProviderChunk {
2018 message,
2019 reasoning: (!reasoning.is_empty()).then_some(reasoning),
2020 model_name,
2021 metrics: av_events::EventMetrics {
2022 prompt_tokens,
2023 completion_tokens: Some(completion_tokens),
2024 cached_tokens,
2025 pruned_tokens: None,
2026 pruning_ratio_millis: None,
2027 },
2028 usage_reported,
2029 finish_reason,
2030 cost_usd_micros,
2031 cost_reported,
2032 has_choices,
2033 tool_call_deltas,
2034 }))
2035}
2036
2037fn provider_u64(value: Option<&Value>, field: &str) -> Result<Option<u64>, String> {
2038 let Some(value) = value else {
2039 return Ok(None);
2040 };
2041 let value = value
2042 .as_u64()
2043 .ok_or_else(|| format!("provider {field} is not a nonnegative integer"))?;
2044 if value > av_core::error::JCS_SAFE_MAX {
2045 return Err(format!("provider {field} exceeds JCS-safe bounds"));
2046 }
2047 Ok(Some(value))
2048}
2049
2050fn map_finish_reason(native: &str) -> av_events::StopReason {
2051 match native {
2052 "stop" | "stop_sequence" | "end_turn" => av_events::StopReason::Stop,
2053 "length" | "max_tokens" => av_events::StopReason::MaxTokens,
2054 "tool_calls" | "function_call" | "tool_use" => av_events::StopReason::ToolUse,
2055 "content_filter" => av_events::StopReason::ContentFilter,
2056 _ => av_events::StopReason::Other,
2057 }
2058}
2059
2060impl Drop for AbortFinalizingStream {
2061 fn drop(&mut self) {
2062 if self.completed {
2067 return;
2068 }
2069 let is_closed = self.session.is_closed();
2081 if !is_closed && self.pending_budget.is_some() {
2082 self.session.mark_capture_failed();
2083 }
2084 if let Some(pending) = self.pending_budget.take() {
2097 pending.task.abort();
2098 }
2099 let budget_delta = match self.flush_protocol_buffer() {
2100 Ok(delta) => delta,
2101 Err(error) => {
2102 tracing::warn!(%error, "provider stream flush failed on drop");
2103 if !is_closed {
2104 self.session.mark_capture_failed();
2105 }
2106 0
2107 }
2108 };
2109 if !is_closed && budget_delta > 0 {
2110 self.session.mark_capture_failed();
2111 }
2112 if !self.session.capture_failed() {
2113 if let Err(error) = self.submit_response_capture(None) {
2114 tracing::warn!(%error, "response-capture submit failed on drop");
2115 self.session.mark_capture_failed();
2116 }
2117 }
2118 if !is_closed {
2119 let session = Arc::clone(&self.session);
2120 let finalizer = self.finalizer.clone();
2121 let session_id = session.id.clone();
2122 match tokio::runtime::Handle::try_current() {
2123 Ok(runtime) => {
2124 runtime.spawn(async move {
2130 if let Err(error) = finalizer.close_session(session, StopReason::Other).await {
2131 finalizer
2132 .metrics()
2133 .counter(
2134 "av_stream_abort_close_failures_total",
2135 "Background close after a stream abort failed; \
2136 the session is left open until the idle sweeper reaps it",
2137 )
2138 .inc();
2139 tracing::warn!(
2140 %error,
2141 %session_id,
2142 "background close on stream abort failed"
2143 );
2144 }
2145 });
2146 }
2147 Err(error) => {
2148 self.finalizer
2154 .metrics()
2155 .counter(
2156 "av_stream_abort_no_runtime_total",
2157 "Stream abort observed no tokio runtime; capture marked failed for \
2158 reconciler retry",
2159 )
2160 .inc();
2161 tracing::warn!(
2162 %error,
2163 %session_id,
2164 "no tokio runtime available for stream-abort close; marking capture failed for reconciler retry"
2165 );
2166 self.session.mark_capture_failed();
2167 }
2168 }
2169 }
2170 }
2171}
2172
2173#[cfg(test)]
2174mod tests {
2175 #![allow(
2176 clippy::expect_used,
2177 clippy::indexing_slicing,
2178 clippy::panic,
2179 clippy::unwrap_used
2180 )]
2181
2182 use super::*;
2183 use av_bridge::{BusError, EventBus, PublishAck, StoredEvent};
2184 use av_receipts::Ed25519Signer;
2185 use av_sandbox::{Sandbox, SandboxConfig};
2186 use av_state::{InMemoryStore, Spend, StateError, StateStore};
2187 use axum::http::Request;
2188 use std::time::Duration;
2189 use tower::ServiceExt;
2190
2191 struct NullBus;
2192
2193 struct SlowStore {
2194 inner: InMemoryStore,
2195 calls: Arc<std::sync::atomic::AtomicUsize>,
2196 }
2197
2198 impl StateStore for SlowStore {
2199 fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
2200 self.inner.add(key, delta)
2201 }
2202
2203 fn get(&self, key: &str) -> Result<u64, StateError> {
2204 self.inner.get(key)
2205 }
2206
2207 fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError> {
2208 self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
2209 std::thread::sleep(Duration::from_millis(100));
2210 self.inner.try_spend(key, amount, limit)
2211 }
2212
2213 fn try_spend_many(&self, spends: &[Spend]) -> Result<Option<usize>, StateError> {
2214 self.inner.try_spend_many(spends)
2215 }
2216
2217 fn remove(&self, key: &str) {
2218 self.inner.remove(key);
2219 }
2220 }
2221
2222 impl EventBus for NullBus {
2223 fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
2224 Ok(PublishAck {
2225 topic: topic.to_owned(),
2226 partition: 0,
2227 offset: 0,
2228 })
2229 }
2230
2231 fn fetch(
2232 &self,
2233 _topic: &str,
2234 _partition: u32,
2235 _offset: u64,
2236 _max: usize,
2237 ) -> Result<Vec<StoredEvent>, BusError> {
2238 Ok(Vec::new())
2239 }
2240
2241 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
2242 Ok(1)
2243 }
2244
2245 fn topics(&self) -> Vec<String> {
2246 av_events::EventClass::all()
2247 .iter()
2248 .map(|class| class.topic().to_owned())
2249 .collect()
2250 }
2251 }
2252
2253 async fn mock_chat(Json(payload): Json<Value>) -> Response {
2254 if payload.get("model").and_then(Value::as_str) == Some("empty-error") {
2255 let mut response = Response::new(Body::empty());
2256 *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
2257 response.headers_mut().insert(
2258 axum::http::header::CONTENT_TYPE,
2259 HeaderValue::from_static("application/json"),
2260 );
2261 return response;
2262 }
2263 if payload.get("model").and_then(Value::as_str) == Some("json-error") {
2264 return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))).into_response();
2265 }
2266 if payload.get("model").and_then(Value::as_str) == Some("empty-success") {
2267 return Json(json!({})).into_response();
2268 }
2269 if payload.get("model").and_then(Value::as_str) == Some("provisional-usage") {
2270 let chunks = vec![
2271 Ok::<_, std::convert::Infallible>(Bytes::from_static(
2272 b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
2273 )),
2274 Ok(Bytes::from_static(
2275 b"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2}}\n\n",
2276 )),
2277 ];
2278 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
2279 response.headers_mut().insert(
2280 axum::http::header::CONTENT_TYPE,
2281 HeaderValue::from_static("text/event-stream"),
2282 );
2283 return response;
2284 }
2285 if payload.get("model").and_then(Value::as_str) == Some("tool-no-usage") {
2286 let arguments = "x".repeat(4_096);
2287 let event = format!(
2288 "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":0,\"function\":{{\"name\":\"read\",\"arguments\":\"{arguments}\"}}}}]}}}}]}}\n\n"
2289 );
2290 let mut response = Response::new(Body::from(event));
2291 response.headers_mut().insert(
2292 axum::http::header::CONTENT_TYPE,
2293 HeaderValue::from_static("text/event-stream"),
2294 );
2295 return response;
2296 }
2297 if payload.get("model").and_then(Value::as_str) == Some("tool-oob-index") {
2298 let event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":18446744073709551615,\"function\":{\"name\":\"read\",\"arguments\":\"{}\"}}]}}]}\n\n";
2299 let mut response = Response::new(Body::from(event));
2300 response.headers_mut().insert(
2301 axum::http::header::CONTENT_TYPE,
2302 HeaderValue::from_static("text/event-stream"),
2303 );
2304 return response;
2305 }
2306 if payload.get("model").and_then(Value::as_str) == Some("regressive-usage") {
2307 let chunks = vec![
2308 Ok::<_, std::convert::Infallible>(Bytes::from_static(
2309 b"data: {\"choices\":[{\"delta\":{\"content\":\"first\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":100,\"prompt_tokens_details\":{\"cached_tokens\":5}},\"cost_usd\":0.01}\n\n",
2310 )),
2311 Ok(Bytes::from_static(
2312 b"data: {\"choices\":[{\"delta\":{\"content\":\"second\"}}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"prompt_tokens_details\":{\"cached_tokens\":4}},\"cost_usd\":0.001}\n\n",
2313 )),
2314 ];
2315 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
2316 response.headers_mut().insert(
2317 axum::http::header::CONTENT_TYPE,
2318 HeaderValue::from_static("text/event-stream"),
2319 );
2320 return response;
2321 }
2322 if payload.get("model").and_then(Value::as_str) == Some("split-json") {
2323 let body = serde_json::to_vec(&json!({
2324 "model": "split-json",
2325 "choices": [{"message": {"role": "assistant", "content": "héllo"}}],
2326 "usage": {"prompt_tokens": 5, "completion_tokens": 2}
2327 }))
2328 .unwrap();
2329 let split = body.iter().position(|byte| *byte == 0xc3).unwrap() + 1;
2330 let chunks = vec![
2331 Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&body[..split])),
2332 Ok(Bytes::copy_from_slice(&body[split..])),
2333 ];
2334 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
2335 response.headers_mut().insert(
2336 axum::http::header::CONTENT_TYPE,
2337 HeaderValue::from_static("application/json"),
2338 );
2339 return response;
2340 }
2341 if payload.get("model").and_then(Value::as_str) == Some("malformed-json") {
2342 let mut response = Response::new(Body::from("{\"choices\":["));
2343 response.headers_mut().insert(
2344 axum::http::header::CONTENT_TYPE,
2345 HeaderValue::from_static("application/json"),
2346 );
2347 return response;
2348 }
2349 if payload.get("stream").and_then(Value::as_bool).unwrap_or(false) {
2350 let event = "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.00125,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\ndata: [DONE]\n\n";
2351 let bytes = event.as_bytes();
2352 let chunks = vec![
2353 Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&bytes[..19])),
2354 Ok(Bytes::copy_from_slice(&bytes[19..97])),
2355 Ok(Bytes::copy_from_slice(&bytes[97..])),
2356 ];
2357 let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
2358 response.headers_mut().insert(
2359 axum::http::header::CONTENT_TYPE,
2360 HeaderValue::from_static("text/event-stream"),
2361 );
2362 response
2363 } else {
2364 Json(json!({"choices": [{"message": {"role": "assistant", "content": "hello"}}]})).into_response()
2365 }
2366 }
2367
2368 async fn counting_tool(
2369 State(calls): State<Arc<std::sync::atomic::AtomicUsize>>,
2370 body: Bytes,
2371 ) -> Response {
2372 calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
2373 (StatusCode::OK, body).into_response()
2374 }
2375
2376 async fn redirecting_tool(State(calls): State<Arc<std::sync::atomic::AtomicUsize>>) -> Response {
2377 calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
2378 (
2379 StatusCode::TEMPORARY_REDIRECT,
2380 [(axum::http::header::LOCATION, "/effect")],
2381 )
2382 .into_response()
2383 }
2384
2385 async fn redirected_effect(State(calls): State<Arc<std::sync::atomic::AtomicUsize>>) -> Response {
2386 calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
2387 StatusCode::OK.into_response()
2388 }
2389
2390 struct HeldToolState {
2391 arrived: std::sync::atomic::AtomicBool,
2392 release: tokio::sync::Notify,
2393 }
2394
2395 async fn held_tool(State(state): State<Arc<HeldToolState>>, body: Bytes) -> Response {
2396 state.arrived.store(true, std::sync::atomic::Ordering::Release);
2397 state.release.notified().await;
2398 (StatusCode::OK, body).into_response()
2399 }
2400
2401 async fn test_state(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
2402 test_state_with_token_cap(spool, None).await
2403 }
2404
2405 async fn test_state_with_token_cap(
2406 spool: &std::path::Path,
2407 max_tokens: Option<u64>,
2408 ) -> (AppState, tokio::task::JoinHandle<()>) {
2409 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2410 let address = listener.local_addr().unwrap();
2411 let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
2412 let server = tokio::spawn(async move {
2413 axum::serve(listener, provider).await.unwrap();
2414 });
2415 let mut config = crate::config::HarnessConfig::for_tests(
2416 &format!("http://{address}"),
2417 &spool.to_string_lossy(),
2418 &spool.to_string_lossy(),
2419 );
2420 config.budget.max_tokens = max_tokens;
2421 let state = AppState::new(
2422 config,
2423 Arc::new(InMemoryStore::new()),
2424 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
2425 Arc::new(NullBus),
2426 None,
2427 Arc::new(Ed25519Signer::from_seed(&[11; 32])),
2428 )
2429 .unwrap();
2430 (state, server)
2431 }
2432
2433 fn chat_request(session: &str) -> Request<Body> {
2434 chat_request_with_payload(session, chat_payload())
2435 }
2436
2437 fn chat_request_with_payload(session: &str, payload: Value) -> Request<Body> {
2438 Request::builder()
2439 .method("POST")
2440 .uri("/v1/chat/completions")
2441 .header(axum::http::header::CONTENT_TYPE, "application/json")
2442 .header("x-av-session", session)
2443 .header("x-av-workflow", "unsigned")
2444 .body(Body::from(serde_json::to_vec(&payload).unwrap()))
2445 .unwrap()
2446 }
2447
2448 fn chat_payload() -> Value {
2449 json!({
2450 "model": "mock",
2451 "stream": true,
2452 "messages": [{"role": "user", "content": "hello"}],
2453 })
2454 }
2455
2456 fn active_records(
2457 directory: &std::path::Path,
2458 state: &AppState,
2459 session_id: &str,
2460 ) -> Vec<crate::worker::ActiveJournalRecord> {
2461 let digest = av_core::digest::sha256_hex(session_id.as_bytes());
2462 let journal =
2463 std::fs::read_to_string(directory.join(format!("{}.events.ndjson", &digest[..32]))).unwrap();
2464 journal
2465 .lines()
2466 .enumerate()
2467 .map(|(index, line)| {
2468 crate::journal::open(
2469 &state.journal_key,
2470 &format!("{session_id}:active"),
2471 index as u64,
2472 line.as_bytes(),
2473 )
2474 .unwrap()
2475 })
2476 .collect()
2477 }
2478
2479 #[tokio::test]
2480 async fn full_chat_close_and_promotion_flow() {
2481 let directory = tempfile::tempdir().unwrap();
2482 let (state, provider) = test_state(directory.path()).await;
2483 let app = build_router(state.clone());
2484
2485 let response = app.clone().oneshot(chat_request("http-flow")).await.unwrap();
2486 assert_eq!(response.status(), StatusCode::OK);
2487 assert_eq!(response.headers().get("x-av-session").unwrap(), "http-flow");
2488 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
2489 .await
2490 .unwrap();
2491 assert!(String::from_utf8_lossy(&body).contains("[DONE]"));
2492
2493 let close = app
2494 .clone()
2495 .oneshot(
2496 Request::builder()
2497 .method("POST")
2498 .uri("/v1/sessions/http-flow/close")
2499 .body(Body::empty())
2500 .unwrap(),
2501 )
2502 .await
2503 .unwrap();
2504 let close_status = close.status();
2505 let close_bytes = axum::body::to_bytes(close.into_body(), 64 * 1024).await.unwrap();
2506 assert_eq!(
2507 close_status,
2508 StatusCode::OK,
2509 "{}",
2510 String::from_utf8_lossy(&close_bytes)
2511 );
2512 let close_body: Value = serde_json::from_slice(&close_bytes).unwrap();
2513 assert_eq!(close_body["kind"], "atif");
2514 let artifact = state
2515 .sessions
2516 .get("http-flow")
2517 .unwrap()
2518 .atif_path
2519 .lock()
2520 .clone()
2521 .unwrap();
2522 let trajectory: av_atif::Trajectory =
2523 serde_json::from_slice(&tokio::fs::read(&artifact).await.unwrap()).unwrap();
2524 if let Some(destination) = std::env::var_os("AV_HARBOR_INTEROP_OUT") {
2525 std::fs::copy(&artifact, destination).unwrap();
2526 }
2527 assert_eq!(
2528 trajectory.steps.len(),
2529 2,
2530 "request and response must both be captured"
2531 );
2532 assert_eq!(trajectory.steps[0].source, av_atif::Source::User);
2533 assert!(trajectory.steps[0].metrics.is_none());
2534 assert_eq!(trajectory.steps[1].source, av_atif::Source::Agent);
2535 assert_eq!(trajectory.steps[1].message, Value::String("hello".to_owned()));
2536 assert_eq!(
2537 trajectory.steps[1].metrics.as_ref().unwrap().cached_tokens,
2538 Some(4)
2539 );
2540
2541 let promoted = app
2542 .oneshot(
2543 Request::builder()
2544 .method("POST")
2545 .uri("/v1/sessions/http-flow/promote")
2546 .body(Body::empty())
2547 .unwrap(),
2548 )
2549 .await
2550 .unwrap();
2551 assert_eq!(promoted.status(), StatusCode::OK);
2552 let receipt: av_receipts::Receipt = serde_json::from_slice(
2553 &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
2554 .await
2555 .unwrap(),
2556 )
2557 .unwrap();
2558 receipt.verify_embedded().unwrap();
2559 assert_eq!(receipt.body.stop_reason_id, av_events::StopReason::Stop.id());
2560 assert_eq!(receipt.body.cost.completion_tokens, 3);
2561 assert_eq!(receipt.body.cost.cached_tokens, 4);
2562 assert_eq!(receipt.body.cost.cost_usd_micros, 1_250);
2563 assert_eq!(
2564 receipt.body.cost.prompt_tokens,
2565 av_core::tokens::approx_tokens(&chat_payload().to_string()),
2566 "provider prompt usage must not be added a second time"
2567 );
2568 provider.abort();
2569 }
2570
2571 #[tokio::test]
2572 async fn mcp_metrics_and_abort_paths_are_enforced() {
2573 let directory = tempfile::tempdir().unwrap();
2574 let (state, provider) = test_state(directory.path()).await;
2575 let app = build_router(state.clone());
2576 let allowed = app
2577 .clone()
2578 .oneshot(
2579 Request::builder()
2580 .method("POST")
2581 .uri("/v1/mcp")
2582 .header("x-av-session", "mcp-flow")
2583 .body(Body::from(
2584 r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
2585 ))
2586 .unwrap(),
2587 )
2588 .await
2589 .unwrap();
2590 assert_eq!(allowed.status(), StatusCode::OK);
2591 let allowed_body: Value = serde_json::from_slice(
2592 &axum::body::to_bytes(allowed.into_body(), 64 * 1024)
2593 .await
2594 .unwrap(),
2595 )
2596 .unwrap();
2597 assert!(allowed_body["decision_us"].as_u64().unwrap() < 5_000);
2598
2599 let blocked = app
2600 .clone()
2601 .oneshot(
2602 Request::builder()
2603 .method("POST")
2604 .uri("/v1/mcp")
2605 .header("x-av-session", "mcp-flow")
2606 .body(Body::from("not-json"))
2607 .unwrap(),
2608 )
2609 .await
2610 .unwrap();
2611 assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
2612
2613 let metrics_response = app
2614 .clone()
2615 .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
2616 .await
2617 .unwrap();
2618 let metrics = String::from_utf8_lossy(
2619 &axum::body::to_bytes(metrics_response.into_body(), 128 * 1024)
2620 .await
2621 .unwrap(),
2622 )
2623 .into_owned();
2624 assert!(metrics.contains("av_stage_duration_seconds") || metrics.contains("av_sessions"));
2625
2626 let aborted = app.clone().oneshot(chat_request("abort-flow")).await.unwrap();
2627 drop(aborted);
2628 tokio::time::timeout(Duration::from_secs(1), async {
2629 loop {
2630 if state
2631 .sessions
2632 .get("abort-flow")
2633 .is_some_and(|session| session.is_closed())
2634 {
2635 break;
2636 }
2637 tokio::task::yield_now().await;
2638 }
2639 })
2640 .await
2641 .expect("abort finalization timed out");
2642 provider.abort();
2643 }
2644
2645 #[tokio::test]
2657 async fn stream_drop_during_concurrent_close_still_captures_response() {
2658 let directory = tempfile::tempdir().unwrap();
2659 let (state, provider) = test_state(directory.path()).await;
2660 let app = build_router(state.clone());
2661
2662 let request = Request::builder()
2663 .method("POST")
2664 .uri("/v1/chat/completions")
2665 .header(axum::http::header::CONTENT_TYPE, "application/json")
2666 .header("x-av-session", "stream-close-race")
2667 .header("x-av-workflow", "signed")
2668 .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
2669 .unwrap();
2670 let response = app.oneshot(request).await.unwrap();
2671 assert_eq!(response.status(), StatusCode::OK);
2672
2673 let session = state.sessions.get("stream-close-race").unwrap();
2674
2675 let close_task = {
2676 let finalizer = state.finalizer.clone();
2677 let session = Arc::clone(&session);
2678 tokio::spawn(async move { finalizer.close_session(session, StopReason::SessionClosed).await })
2679 };
2680
2681 tokio::time::timeout(Duration::from_secs(1), async {
2682 while !session.is_closed() {
2683 tokio::task::yield_now().await;
2684 }
2685 })
2686 .await
2687 .expect("concurrent close did not initiate before the response was dropped");
2688
2689 drop(response);
2690
2691 close_task
2692 .await
2693 .expect("close_session task panicked")
2694 .expect("close_session returned an error");
2695
2696 let final_count = session.chain.lock().count();
2697 assert!(
2698 final_count >= 2,
2699 "AbortFinalizingStream::drop must submit the response capture even when the session is already being closed — otherwise a concurrent close during an active stream discards the response and the receipt attests to fewer events than actually flowed; got chain.count() = {final_count}",
2700 );
2701
2702 provider.abort();
2703 }
2704
2705 #[tokio::test]
2729 async fn drop_with_pending_budget_during_concurrent_close_does_not_stick_session() {
2730 let directory = tempfile::tempdir().unwrap();
2731 let (state, provider) = test_state(directory.path()).await;
2732
2733 let identity = av_events::AgentIdentity {
2734 version: "1".into(),
2735 charter: "c".into(),
2736 instance_uid: "i".into(),
2737 ttl_remaining_s: None,
2738 };
2739 let session = state.sessions.get_or_open(
2740 "pending-budget-close",
2741 crate::session::Workflow::Signed,
2742 &identity,
2743 &state.config.breaker,
2744 );
2745 let permits = state
2750 .worker
2751 .try_reserve_pair("pending-budget-close")
2752 .expect("test setup: fused worker/response permit");
2753 drop(permits.worker);
2757 let permit = permits.response;
2758 let lease = crate::session::SessionLease::new(Arc::clone(&session));
2761
2762 let budget_task =
2766 tokio::spawn(async { Ok::<_, String>(av_state::BudgetDecision::Allowed { remaining: 100 }) });
2767
2768 let stream = AbortFinalizingStream {
2769 inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
2770 session: Arc::clone(&session),
2771 identity: identity.clone(),
2772 response_permit: Some(permit),
2773 worker: state.worker.clone(),
2774 store: Arc::clone(&state.store),
2775 budget: state.config.budget.clone(),
2776 finalizer: state.finalizer.clone(),
2777 _lease: lease,
2778 response_marker: None,
2779 response_attempt_id: "pending-budget-attempt".into(),
2780 response_message: "captured content".into(),
2781 response_reasoning: String::new(),
2782 response_model: None,
2783 response_finish_reason: Some("stop".into()),
2784 upstream_status: StatusCode::OK,
2785 response_cost_usd_micros: 0,
2786 response_tool_calls: std::collections::BTreeMap::new(),
2787 response_metrics: av_events::EventMetrics::default(),
2788 charged_completion_tokens: 3,
2789 last_reported_completion_tokens: Some(3),
2790 last_reported_prompt_tokens: None,
2791 last_reported_cached_tokens: None,
2792 last_reported_cost_usd_micros: None,
2793 saw_chunk: true,
2794 capture_submitted: false,
2795 is_sse: true,
2796 protocol_buffer: Vec::new(),
2797 pending_output: std::collections::VecDeque::new(),
2798 pending_budget: Some(PendingBudget {
2799 task: budget_task,
2800 continuation: BudgetContinuation::FinishSse,
2801 }),
2802 captured_bytes: 100,
2803 completed: false,
2804 };
2805
2806 assert!(session.try_close(), "test setup: try_close should have won");
2808 assert!(session.is_closed());
2809
2810 drop(stream);
2813
2814 assert!(
2815 !session.capture_failed(),
2816 "AbortFinalizingStream::drop must not fail the capture just because a completion-token budget check was still in flight while a concurrent close is already draining this stream via wait_for_streams — otherwise close_session_locked's capture_failed guard seals the session and returns CaptureIncomplete: the receipt is never signed and the capture is lost",
2817 );
2818
2819 let outcome = tokio::time::timeout(
2825 Duration::from_secs(3),
2826 state
2827 .finalizer
2828 .close_session(Arc::clone(&session), StopReason::SessionClosed),
2829 )
2830 .await
2831 .expect("close_session hung after drop released the stream");
2832 outcome.expect(
2833 "close_session must succeed after AbortFinalizingStream::drop submits the response capture — a session sealed without its artifact is worse than a marginally-over-budget response record, because the receipt is lost entirely",
2834 );
2835
2836 provider.abort();
2837 }
2838
2839 #[tokio::test]
2840 async fn duplicate_tool_id_replays_outcome_without_reexecution() {
2841 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2842 let tool_address = tool_listener.local_addr().unwrap();
2843 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2844 let tool_router = Router::new()
2845 .route("/mcp", post(counting_tool))
2846 .with_state(Arc::clone(&calls));
2847 let tool_server = tokio::spawn(async move {
2848 axum::serve(tool_listener, tool_router).await.unwrap();
2849 });
2850 let directory = tempfile::tempdir().unwrap();
2851 let (mut state, provider) = test_state(directory.path()).await;
2852 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
2853 Some(format!("http://{tool_address}/mcp"));
2854 let app = build_router(state.clone());
2855 let body = r#"{"jsonrpc":"2.0","id":"once","method":"tools/call","params":{"name":"read","arguments":{"id":7}}}"#;
2856 for _ in 0..2 {
2857 let response = app
2858 .clone()
2859 .oneshot(
2860 Request::builder()
2861 .method("POST")
2862 .uri("/v1/mcp")
2863 .header("x-av-session", "tool-once")
2864 .body(Body::from(body))
2865 .unwrap(),
2866 )
2867 .await
2868 .unwrap();
2869 assert_eq!(response.status(), StatusCode::OK);
2870 assert_eq!(
2871 axum::body::to_bytes(response.into_body(), 64 * 1024)
2872 .await
2873 .unwrap(),
2874 body
2875 );
2876 }
2877 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
2878 let session = state.sessions.get("tool-once").unwrap();
2879 assert_eq!(
2880 session
2881 .totals
2882 .tool_calls
2883 .load(std::sync::atomic::Ordering::Acquire),
2884 1
2885 );
2886 let audited_path = std::fs::read_dir(directory.path().join(crate::spool::TOOL_EXECUTIONS))
2887 .unwrap()
2888 .map(|entry| entry.unwrap().path())
2889 .find(|path| path.extension().is_some_and(|extension| extension == "audited"))
2890 .unwrap();
2891 std::fs::remove_file(audited_path).unwrap();
2892 let unaudited_replay = app
2893 .clone()
2894 .oneshot(
2895 Request::builder()
2896 .method("POST")
2897 .uri("/v1/mcp")
2898 .header("x-av-session", "tool-once")
2899 .body(Body::from(body))
2900 .unwrap(),
2901 )
2902 .await
2903 .unwrap();
2904 assert_eq!(unaudited_replay.status(), StatusCode::OK);
2905 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
2906 assert_eq!(
2907 session
2908 .totals
2909 .tool_calls
2910 .load(std::sync::atomic::Ordering::Acquire),
2911 1,
2912 "unaudited outcome recovery must not spend tool budget again"
2913 );
2914
2915 let changed_request = app
2916 .clone()
2917 .oneshot(
2918 Request::builder()
2919 .method("POST")
2920 .uri("/v1/mcp")
2921 .header("x-av-session", "tool-once")
2922 .body(Body::from(
2923 r#"{"jsonrpc":"2.0","id":"once","method":"tools/call","params":{"name":"read","arguments":{"id":8}}}"#,
2924 ))
2925 .unwrap(),
2926 )
2927 .await
2928 .unwrap();
2929 assert_eq!(changed_request.status(), StatusCode::CONFLICT);
2930 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
2931 tool_server.abort();
2932 provider.abort();
2933 }
2934
2935 #[tokio::test]
2940 async fn upstream_auth_header_reaches_provider_wire() {
2941 type Captured = Arc<parking_lot::Mutex<Vec<(Option<String>, Option<String>)>>>;
2942 async fn capture_chat(
2943 State(captured): State<Captured>,
2944 headers: HeaderMap,
2945 Json(_): Json<Value>,
2946 ) -> Response {
2947 captured.lock().push((
2948 headers
2949 .get(axum::http::header::AUTHORIZATION)
2950 .and_then(|value| value.to_str().ok())
2951 .map(str::to_owned),
2952 headers
2953 .get("api-key")
2954 .and_then(|value| value.to_str().ok())
2955 .map(str::to_owned),
2956 ));
2957 Json(json!({"choices": [], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}))
2958 .into_response()
2959 }
2960 let captured: Captured = Arc::new(parking_lot::Mutex::new(Vec::new()));
2961 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2962 let address = listener.local_addr().unwrap();
2963 let provider_router = Router::new()
2964 .route("/v1/chat/completions", post(capture_chat))
2965 .route("/openai/deployments/d1/chat/completions", post(capture_chat))
2966 .with_state(Arc::clone(&captured));
2967 let provider = tokio::spawn(async move {
2968 axum::serve(listener, provider_router).await.unwrap();
2969 });
2970 let directory = tempfile::tempdir().unwrap();
2971 let key_path = directory.path().join("upstream.key");
2972 std::fs::write(&key_path, "sk-wire-test\n").unwrap();
2973 #[cfg(unix)]
2974 {
2975 use std::os::unix::fs::PermissionsExt as _;
2976 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
2977 }
2978 let spool = directory.path().to_string_lossy();
2979 let build_state = |config: crate::config::HarnessConfig| {
2980 AppState::new(
2981 config,
2982 Arc::new(InMemoryStore::new()),
2983 Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
2984 Arc::new(NullBus),
2985 None,
2986 Arc::new(Ed25519Signer::from_seed(&[12; 32])),
2987 )
2988 .unwrap()
2989 };
2990 let send_chat = |state: AppState, authorization: Option<&'static str>| async move {
2991 let mut request = Request::builder()
2992 .method("POST")
2993 .uri("/v1/chat/completions")
2994 .header(axum::http::header::CONTENT_TYPE, "application/json")
2995 .header("x-av-session", "auth-wire");
2996 if let Some(value) = authorization {
2997 request = request.header(axum::http::header::AUTHORIZATION, value);
2998 }
2999 let response = build_router(state)
3000 .oneshot(request.body(Body::from(chat_payload().to_string())).unwrap())
3001 .await
3002 .unwrap();
3003 assert_eq!(response.status(), StatusCode::OK);
3004 };
3005
3006 let mut config =
3008 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
3009 config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
3010 send_chat(build_state(config), None).await;
3011 assert_eq!(
3012 captured.lock().pop().unwrap(),
3013 (Some("Bearer sk-wire-test".to_owned()), None),
3014 "Bearer credential must reach the provider"
3015 );
3016
3017 let mut config =
3019 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
3020 config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
3021 config.upstream_auth_header = "api-key".into();
3022 config.upstream_auth_scheme = String::new();
3023 config.upstream_chat_path = "/openai/deployments/d1/chat/completions".into();
3024 send_chat(build_state(config), None).await;
3025 assert_eq!(
3026 captured.lock().pop().unwrap(),
3027 (None, Some("sk-wire-test".to_owned())),
3028 "raw key must reach the provider on the configured path and header"
3029 );
3030
3031 let mut config =
3033 crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
3034 config.upstream_authorization_passthrough = true;
3035 send_chat(build_state(config), Some("Bearer client-owned-key")).await;
3036 assert_eq!(
3037 captured.lock().pop().unwrap(),
3038 (Some("Bearer client-owned-key".to_owned()), None),
3039 "passthrough must relay the client credential"
3040 );
3041
3042 let config = crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
3049 let request = Request::builder()
3050 .method("POST")
3051 .uri("/v1/chat/completions")
3052 .header(axum::http::header::CONTENT_TYPE, "application/json")
3053 .header("x-av-session", "auth-wire")
3054 .header(axum::http::header::AUTHORIZATION, "Bearer client-owned-key")
3055 .body(Body::from(chat_payload().to_string()))
3056 .unwrap();
3057 let response = build_router(build_state(config)).oneshot(request).await.unwrap();
3058 assert_eq!(
3059 response.status(),
3060 StatusCode::UNAUTHORIZED,
3061 "presenting a bearer with no validator configured must be refused rather than \
3062 silently recorded as anonymous — that would let a signed request end up \
3063 attributed to `charter=anonymous` in the receipt"
3064 );
3065 assert!(
3066 captured.lock().is_empty(),
3067 "no credential must reach upstream when the request was refused"
3068 );
3069 provider.abort();
3070 }
3071
3072 #[tokio::test]
3075 async fn tool_upstream_bearer_reaches_tool_server() {
3076 type Captured = Arc<parking_lot::Mutex<Vec<Option<String>>>>;
3077 async fn capture_tool(State(captured): State<Captured>, headers: HeaderMap, body: Bytes) -> Response {
3078 captured.lock().push(
3079 headers
3080 .get(axum::http::header::AUTHORIZATION)
3081 .and_then(|value| value.to_str().ok())
3082 .map(str::to_owned),
3083 );
3084 (StatusCode::OK, body).into_response()
3085 }
3086 let captured: Captured = Arc::new(parking_lot::Mutex::new(Vec::new()));
3087 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3088 let address = listener.local_addr().unwrap();
3089 let tool_router = Router::new()
3090 .route("/mcp", post(capture_tool))
3091 .with_state(Arc::clone(&captured));
3092 let tool_server = tokio::spawn(async move {
3093 axum::serve(listener, tool_router).await.unwrap();
3094 });
3095 let directory = tempfile::tempdir().unwrap();
3096 let token_path = directory.path().join("mcp.token");
3097 std::fs::write(&token_path, "tool-secret\n").unwrap();
3098 #[cfg(unix)]
3099 {
3100 use std::os::unix::fs::PermissionsExt as _;
3101 std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600)).unwrap();
3102 }
3103 let (mut state, provider) = test_state(directory.path()).await;
3104 {
3105 let config = Arc::get_mut(&mut state.config).unwrap();
3106 config.tool_upstream_url = Some(format!("http://{address}/mcp"));
3107 config.tool_upstream_bearer_file = Some(token_path.to_string_lossy().into_owned());
3108 }
3109 state.tool_auth = crate::pipeline::resolve_tool_auth(&state.config).unwrap();
3111 let response = build_router(state)
3112 .oneshot(
3113 Request::builder()
3114 .method("POST")
3115 .uri("/v1/mcp")
3116 .header("x-av-session", "tool-auth")
3117 .body(Body::from(
3118 r#"{"jsonrpc":"2.0","id":"t1","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
3119 ))
3120 .unwrap(),
3121 )
3122 .await
3123 .unwrap();
3124 assert_eq!(response.status(), StatusCode::OK);
3125 assert_eq!(
3126 captured.lock().pop().unwrap().as_deref(),
3127 Some("Bearer tool-secret"),
3128 "tool bearer must reach the tool server"
3129 );
3130 tool_server.abort();
3131 provider.abort();
3132 }
3133
3134 #[tokio::test]
3135 async fn startup_refuses_pending_and_unaudited_tool_effects() {
3136 let directory = tempfile::tempdir().unwrap();
3137 let control_key = [31; 32];
3138 let mut headers = HeaderMap::new();
3139 headers.insert("x-av-session", HeaderValue::from_static("tool-recovery"));
3140 let body = br#"{"jsonrpc":"2.0","id":"recover","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
3141 let mut execution =
3142 ToolExecution::from_request(&directory.path().to_string_lossy(), &headers, body, control_key)
3143 .unwrap();
3144 execution
3145 .bind_principal(&av_events::AgentIdentity {
3146 version: "dev".to_owned(),
3147 charter: "anonymous".into(),
3148 instance_uid: "anonymous".to_owned(),
3149 ttl_remaining_s: None,
3150 })
3151 .unwrap();
3152 execution.claim().await.unwrap();
3153 assert!(
3154 ensure_no_unresolved_tool_executions(directory.path(), &control_key)
3155 .await
3156 .is_err()
3157 );
3158 execution
3159 .persist(&ToolOutcome {
3160 status: 200,
3161 body_hex: hex::encode(body),
3162 content_type: Some("application/json".to_owned()),
3163 })
3164 .await
3165 .unwrap();
3166 assert!(
3167 ensure_no_unresolved_tool_executions(directory.path(), &control_key)
3168 .await
3169 .is_err()
3170 );
3171 execution.mark_audited().await.unwrap();
3172 ensure_no_unresolved_tool_executions(directory.path(), &control_key)
3173 .await
3174 .unwrap();
3175 }
3176
3177 #[tokio::test]
3178 async fn tool_redirect_is_terminal_and_never_replays_post() {
3179 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3180 let tool_address = tool_listener.local_addr().unwrap();
3181 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3182 let tool_router = Router::new()
3183 .route("/redirect", post(redirecting_tool))
3184 .route("/effect", post(redirected_effect))
3185 .with_state(Arc::clone(&calls));
3186 let tool_server = tokio::spawn(async move {
3187 axum::serve(tool_listener, tool_router).await.unwrap();
3188 });
3189 let directory = tempfile::tempdir().unwrap();
3190 let (mut state, provider) = test_state(directory.path()).await;
3191 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
3192 Some(format!("http://{tool_address}/redirect"));
3193 let response = build_router(state)
3194 .oneshot(
3195 Request::builder()
3196 .method("POST")
3197 .uri("/v1/mcp")
3198 .header("x-av-session", "tool-redirect")
3199 .body(Body::from(
3200 r#"{"jsonrpc":"2.0","id":"redirect","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
3201 ))
3202 .unwrap(),
3203 )
3204 .await
3205 .unwrap();
3206 assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
3207 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
3208 tool_server.abort();
3209 provider.abort();
3210 }
3211
3212 #[tokio::test]
3213 async fn concurrent_duplicate_tool_claims_conflict_without_leaking_io_detail() {
3214 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3215 let tool_address = tool_listener.local_addr().unwrap();
3216 let held = Arc::new(HeldToolState {
3217 arrived: std::sync::atomic::AtomicBool::new(false),
3218 release: tokio::sync::Notify::new(),
3219 });
3220 let tool_router = Router::new()
3221 .route("/mcp", post(held_tool))
3222 .with_state(Arc::clone(&held));
3223 let tool_server = tokio::spawn(async move {
3224 axum::serve(tool_listener, tool_router).await.unwrap();
3225 });
3226 let directory = tempfile::tempdir().unwrap();
3227 let (mut state, provider) = test_state(directory.path()).await;
3228 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
3229 Some(format!("http://{tool_address}/mcp"));
3230 let app = build_router(state);
3231 let request = || {
3232 Request::builder()
3233 .method("POST")
3234 .uri("/v1/mcp")
3235 .header("x-av-session", "claim-race")
3236 .body(Body::from(
3237 r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
3238 ))
3239 .unwrap()
3240 };
3241 let winner_app = app.clone();
3242 let winner = tokio::spawn(async move { winner_app.oneshot(request()).await.unwrap() });
3243 tokio::time::timeout(Duration::from_secs(1), async {
3244 while !held.arrived.load(std::sync::atomic::Ordering::Acquire) {
3245 tokio::task::yield_now().await;
3246 }
3247 })
3248 .await
3249 .unwrap();
3250 let loser = app.oneshot(request()).await.unwrap();
3254 assert_eq!(loser.status(), StatusCode::CONFLICT);
3255 let body = axum::body::to_bytes(loser.into_body(), 64 * 1024).await.unwrap();
3256 let text = String::from_utf8(body.to_vec()).unwrap();
3257 assert!(text.contains(TOOL_OUTCOME_UNCERTAIN), "{text}");
3258 assert!(!text.contains("os error"), "leaked io detail: {text}");
3259 held.release.notify_one();
3260 assert_eq!(winner.await.unwrap().status(), StatusCode::OK);
3261 tool_server.abort();
3262 provider.abort();
3263 }
3264
3265 #[tokio::test]
3266 async fn close_waits_for_tool_execution_and_completion_capture() {
3267 let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3268 let tool_address = tool_listener.local_addr().unwrap();
3269 let held = Arc::new(HeldToolState {
3270 arrived: std::sync::atomic::AtomicBool::new(false),
3271 release: tokio::sync::Notify::new(),
3272 });
3273 let tool_router = Router::new()
3274 .route("/mcp", post(held_tool))
3275 .with_state(Arc::clone(&held));
3276 let tool_server = tokio::spawn(async move {
3277 axum::serve(tool_listener, tool_router).await.unwrap();
3278 });
3279 let directory = tempfile::tempdir().unwrap();
3280 let (mut state, provider) = test_state(directory.path()).await;
3281 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
3282 Some(format!("http://{tool_address}/mcp"));
3283 let app = build_router(state);
3284 let tool_app = app.clone();
3285 let tool_task = tokio::spawn(async move {
3286 tool_app
3287 .oneshot(
3288 Request::builder()
3289 .method("POST")
3290 .uri("/v1/mcp")
3291 .header("x-av-session", "held-tool")
3292 .body(Body::from(
3293 r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
3294 ))
3295 .unwrap(),
3296 )
3297 .await
3298 .unwrap()
3299 });
3300 tokio::time::timeout(Duration::from_secs(1), async {
3301 while !held.arrived.load(std::sync::atomic::Ordering::Acquire) {
3302 tokio::task::yield_now().await;
3303 }
3304 })
3305 .await
3306 .unwrap();
3307 let close_task = tokio::spawn(async move {
3308 app.oneshot(
3309 Request::builder()
3310 .method("POST")
3311 .uri("/v1/sessions/held-tool/close")
3312 .body(Body::empty())
3313 .unwrap(),
3314 )
3315 .await
3316 .unwrap()
3317 });
3318 tokio::task::yield_now().await;
3319 assert!(!close_task.is_finished(), "close overtook the executing tool");
3320 held.release.notify_one();
3321 assert_eq!(tool_task.await.unwrap().status(), StatusCode::OK);
3322 let close_response = close_task.await.unwrap();
3323 let close_status = close_response.status();
3324 let close_body = axum::body::to_bytes(close_response.into_body(), 64 * 1024)
3325 .await
3326 .unwrap();
3327 assert_eq!(
3328 close_status,
3329 StatusCode::OK,
3330 "close failed: {}",
3331 String::from_utf8_lossy(&close_body)
3332 );
3333 tool_server.abort();
3334 provider.abort();
3335 }
3336
3337 #[test]
3338 fn provider_usage_and_reasoning_are_preserved() {
3339 let raw = concat!(
3340 "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{",
3341 "\"content\":\"answer\",\"reasoning_content\":\"thought\"},\"finish_reason\":\"stop\"}],",
3342 "\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.00125,",
3343 "\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n"
3344 );
3345 let parsed = parse_provider_chunk(raw).unwrap().unwrap();
3346 assert_eq!(parsed.message, "answer");
3347 assert_eq!(parsed.reasoning.as_deref(), Some("thought"));
3348 assert_eq!(parsed.model_name.as_deref(), Some("gpt-test"));
3349 assert_eq!(parsed.metrics.prompt_tokens, Some(10));
3350 assert_eq!(parsed.metrics.completion_tokens, Some(3));
3351 assert_eq!(parsed.metrics.cached_tokens, Some(4));
3352 assert!(parsed.usage_reported);
3353 assert_eq!(parsed.finish_reason.as_deref(), Some("stop"));
3354 assert_eq!(parsed.cost_usd_micros, 1250);
3355 assert!(parsed.tool_call_deltas.is_empty());
3356 }
3357
3358 #[test]
3359 fn hostile_provider_metrics_are_rejected() {
3360 for raw in [
3361 r#"{"usage":{"completion_tokens":9007199254740993}}"#,
3362 r#"{"usage":{"prompt_tokens":-1}}"#,
3363 r#"{"usage":{"prompt_tokens_details":{"cached_tokens":"many"}}}"#,
3364 r#"{"usage":{"cost_usd":1e30}}"#,
3365 r#"{"usage":{"cost_usd":-1}}"#,
3366 ] {
3367 assert!(
3368 parse_provider_chunk(raw).is_err(),
3369 "accepted hostile metrics: {raw}"
3370 );
3371 }
3372 }
3373
3374 #[test]
3375 fn streaming_tool_call_arguments_reassemble() {
3376 let first = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"db_write","arguments":"{\"table\":"}}]}}]}"#;
3377 let second = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"items\",\"row\":{}}"}}]},"finish_reason":"tool_calls"}]}"#;
3378 let first_delta = parse_provider_chunk(first).unwrap().unwrap().tool_call_deltas;
3379 let second_delta = parse_provider_chunk(second).unwrap().unwrap().tool_call_deltas;
3380 assert_eq!(first_delta.len(), 1);
3381 assert_eq!(second_delta.len(), 1);
3382 let arguments = format!("{}{}", first_delta[0].arguments, second_delta[0].arguments);
3383 assert_eq!(
3384 serde_json::from_str::<Value>(&arguments).unwrap(),
3385 json!({"table": "items", "row": {}})
3386 );
3387 assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
3388 }
3389
3390 #[test]
3391 fn hostile_provider_tool_call_index_is_rejected() {
3392 let raw = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":18446744073709551615,"function":{"name":"n","arguments":"{}"}}]}}]}"#;
3393 let parsed = parse_provider_chunk(raw).unwrap().unwrap();
3394 assert_eq!(parsed.tool_call_deltas.len(), 1);
3395 assert_eq!(parsed.tool_call_deltas[0].index, u64::MAX);
3396 }
3397
3398 #[test]
3399 fn non_stream_tool_calls_receive_distinct_positional_indices() {
3400 let parsed = parse_provider_chunk(
3401 r#"{"choices":[{"message":{"tool_calls":[{"id":"a","function":{"name":"read","arguments":"{}"}},{"id":"b","function":{"name":"write","arguments":"{}"}}]}}]}"#,
3402 )
3403 .unwrap()
3404 .unwrap();
3405 assert_eq!(parsed.tool_call_deltas.len(), 2);
3406 assert_eq!(parsed.tool_call_deltas[0].index, 0);
3407 assert_eq!(parsed.tool_call_deltas[1].index, 1);
3408 }
3409
3410 #[test]
3411 fn sse_frame_boundary_accepts_crlf_and_cr() {
3412 assert_eq!(sse_frame_end(b"data: {}\r\n\r"), None);
3413 assert_eq!(sse_frame_end(b"data: {}\r\n\r\nrest"), Some(12));
3414 assert_eq!(sse_frame_end(b"data: {}\r\rrest"), Some(10));
3415 }
3416
3417 #[test]
3424 fn parse_provider_chunk_strips_leading_bom() {
3425 let raw = "\u{feff}data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
3426 let parsed = parse_provider_chunk(raw)
3427 .expect("BOM-prefixed SSE frame must parse")
3428 .expect("must yield a chunk");
3429 assert_eq!(parsed.message, "hi");
3430 }
3431
3432 #[test]
3443 fn parse_provider_chunk_refuses_non_message_sse_event_types() {
3444 for event in ["error", "ping", "custom_signal"] {
3445 let raw = format!(
3446 "event: {event}\ndata: {{\"choices\":[{{\"delta\":{{\"content\":\"forged\"}}}}]}}\n\n"
3447 );
3448 let err = match parse_provider_chunk(&raw) {
3449 Ok(_) => panic!("event type {event:?} must be refused"),
3450 Err(error) => error,
3451 };
3452 assert!(
3453 err.contains("unsupported event type") && err.contains(event),
3454 "expected diagnostic naming event type {event:?}, got: {err}"
3455 );
3456 }
3457 let ok_raw = "event: message\ndata: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
3460 let parsed = match parse_provider_chunk(ok_raw) {
3461 Ok(Some(chunk)) => chunk,
3462 Ok(None) => panic!("explicit event: message must yield a chunk"),
3463 Err(error) => panic!("explicit event: message must be accepted, got: {error}"),
3464 };
3465 assert_eq!(parsed.message, "hi");
3466 }
3467
3468 #[test]
3469 fn parse_provider_chunk_treats_done_variants_as_stream_end() {
3470 for raw in [
3471 "data: [DONE]\ndata: [DONE]\n\n",
3472 "data: [DONE] \n\n",
3473 "data:\ndata: [DONE]\n\n",
3474 ] {
3475 let result = parse_provider_chunk(raw);
3476 match result {
3477 Ok(None) => {}
3478 Ok(Some(_)) => panic!("[DONE] variant must terminate stream (None) for {raw:?}"),
3479 Err(error) => panic!("[DONE] variant must not fail parse: {raw:?}: {error}"),
3480 }
3481 }
3482 }
3483
3484 #[test]
3515 fn parse_provider_chunk_strips_exactly_one_leading_space() {
3516 let raw = "data: \thello\n\n";
3525 let result = parse_provider_chunk(raw);
3526 let error = match result {
3527 Ok(_) => panic!("bare hello must not parse as valid provider frame"),
3528 Err(error) => error,
3529 };
3530 assert!(
3531 error.contains("column 2") || error.contains("column 3"),
3532 "expected error to name a column past the retained leading space+tab; \
3533 got: {error}"
3534 );
3535 }
3536
3537 #[tokio::test]
3538 async fn split_non_sse_utf8_is_captured_as_one_json_document() {
3539 let directory = tempfile::tempdir().unwrap();
3540 let (state, provider) = test_state(directory.path()).await;
3541 let app = build_router(state.clone());
3542 let response = app
3543 .oneshot(chat_request_with_payload(
3544 "split-json",
3545 json!({
3546 "model": "split-json",
3547 "stream": false,
3548 "messages": [{"role": "user", "content": "hello"}]
3549 }),
3550 ))
3551 .await
3552 .unwrap();
3553 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
3554 .await
3555 .unwrap();
3556 assert_eq!(
3557 serde_json::from_slice::<Value>(&body).unwrap()["choices"][0]["message"]["content"],
3558 "héllo"
3559 );
3560 let session = state.sessions.get("split-json").unwrap();
3561 session.wait_for_worker_jobs().await;
3562 let crate::reconciler::FinalizeOutcome::Atif { path } = state
3563 .finalizer
3564 .close_session(session, StopReason::SessionClosed)
3565 .await
3566 .unwrap()
3567 else {
3568 panic!("expected ATIF")
3569 };
3570 let trajectory: av_atif::Trajectory = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
3571 assert_eq!(trajectory.steps[1].message, Value::String("héllo".to_owned()));
3572 provider.abort();
3573 }
3574
3575 #[tokio::test]
3576 async fn malformed_non_sse_json_fails_capture_before_delivery() {
3577 let directory = tempfile::tempdir().unwrap();
3578 let (state, provider) = test_state(directory.path()).await;
3579 let app = build_router(state.clone());
3580 let response = app
3581 .oneshot(chat_request_with_payload(
3582 "malformed-json",
3583 json!({
3584 "model": "malformed-json",
3585 "stream": false,
3586 "messages": [{"role": "user", "content": "hello"}]
3587 }),
3588 ))
3589 .await
3590 .unwrap();
3591 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
3595 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
3596 .await
3597 .unwrap();
3598 assert!(serde_json::from_slice::<Value>(&body).unwrap()["error"].is_string());
3599 let session = state.sessions.get("malformed-json").unwrap();
3600 assert!(session.capture_failed());
3601 provider.abort();
3602 }
3603
3604 #[tokio::test]
3605 async fn repeated_provider_responses_open_the_loop_breaker() {
3606 let directory = tempfile::tempdir().unwrap();
3607 let (mut state, provider) = test_state(directory.path()).await;
3608 let config = Arc::get_mut(&mut state.config).unwrap();
3609 config.breaker.min_tokens = 0;
3610 config.breaker.window = 3;
3611 let app = build_router(state.clone());
3612 for _ in 0..4 {
3613 let response = app.clone().oneshot(chat_request("response-loop")).await.unwrap();
3614 assert_eq!(response.status(), StatusCode::OK);
3615 axum::body::to_bytes(response.into_body(), 64 * 1024)
3616 .await
3617 .unwrap();
3618 state
3619 .sessions
3620 .get("response-loop")
3621 .unwrap()
3622 .wait_for_worker_jobs()
3623 .await;
3624 }
3625 let blocked = app.oneshot(chat_request("response-loop")).await.unwrap();
3626 assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
3631 provider.abort();
3632 }
3633
3634 #[tokio::test]
3635 async fn empty_and_failed_upstream_responses_are_audited_as_failures() {
3636 let directory = tempfile::tempdir().unwrap();
3637 let (state, provider) = test_state(directory.path()).await;
3638 let app = build_router(state.clone());
3639 for (session_id, model) in [("empty-error", "empty-error"), ("json-error", "json-error")] {
3640 let response = app
3641 .clone()
3642 .oneshot(chat_request_with_payload(
3643 session_id,
3644 json!({
3645 "model": model,
3646 "stream": false,
3647 "messages": [{"role": "user", "content": "hello"}]
3648 }),
3649 ))
3650 .await
3651 .unwrap();
3652 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
3653 axum::body::to_bytes(response.into_body(), 64 * 1024)
3654 .await
3655 .unwrap();
3656 let session = state.sessions.get(session_id).unwrap();
3657 session.wait_for_worker_jobs().await;
3658 assert!(!session.capture_failed());
3659 let records = active_records(directory.path(), &state, session_id);
3660 assert_eq!(records.len(), 2);
3661 let event: av_events::OcsfEvent =
3662 serde_json::from_value(records.last().unwrap().event.clone()).unwrap();
3663 assert_eq!(event.class_name, av_events::EventClass::StopReason);
3664 assert_eq!(event.status_id, av_events::StatusId::Failure.id());
3665 assert_eq!(event.payload["http_status"], 500);
3666 }
3667 provider.abort();
3668 }
3669
3670 #[tokio::test]
3671 async fn regressing_provider_usage_fails_capture_before_later_bytes() {
3672 let directory = tempfile::tempdir().unwrap();
3673 let (state, provider) = test_state(directory.path()).await;
3674 let response = build_router(state.clone())
3675 .oneshot(chat_request_with_payload(
3676 "regressive-usage",
3677 json!({
3678 "model": "regressive-usage",
3679 "stream": true,
3680 "messages": [{"role": "user", "content": "hello"}]
3681 }),
3682 ))
3683 .await
3684 .unwrap();
3685 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
3686 .await
3687 .is_err());
3688 let session = state.sessions.get("regressive-usage").unwrap();
3689 assert!(session.capture_failed());
3690 session.wait_for_worker_jobs().await;
3691 let records = active_records(directory.path(), &state, "regressive-usage");
3692 assert_eq!(records.len(), 1);
3693 assert!(records[0]
3694 .response_attempt
3695 .as_ref()
3696 .is_some_and(|attempt| !attempt.terminal));
3697 provider.abort();
3698 }
3699
3700 #[tokio::test]
3701 async fn successful_provider_response_requires_choices() {
3702 let directory = tempfile::tempdir().unwrap();
3703 let (state, provider) = test_state(directory.path()).await;
3704 let response = build_router(state.clone())
3705 .oneshot(chat_request_with_payload(
3706 "empty-success",
3707 json!({
3708 "model": "empty-success",
3709 "stream": false,
3710 "messages": [{"role": "user", "content": "hello"}]
3711 }),
3712 ))
3713 .await
3714 .unwrap();
3715 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
3718 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
3719 .await
3720 .unwrap();
3721 assert!(serde_json::from_slice::<Value>(&body).unwrap()["error"].is_string());
3722 assert!(state.sessions.get("empty-success").unwrap().capture_failed());
3723 provider.abort();
3724 }
3725
3726 #[tokio::test]
3727 async fn cumulative_usage_settles_against_provisional_charge() {
3728 let directory = tempfile::tempdir().unwrap();
3729 let payload = json!({
3730 "model": "provisional-usage",
3731 "stream": true,
3732 "messages": [{"role": "user", "content": "hello"}]
3733 });
3734 let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
3735 let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens + 2)).await;
3736 let response = build_router(state.clone())
3737 .oneshot(chat_request_with_payload("provisional-usage", payload))
3738 .await
3739 .unwrap();
3740 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
3741 .await
3742 .is_ok());
3743 let session = state.sessions.get("provisional-usage").unwrap();
3744 session.wait_for_worker_jobs().await;
3745 assert_eq!(
3746 session
3747 .totals
3748 .completion_tokens
3749 .load(std::sync::atomic::Ordering::Acquire),
3750 2
3751 );
3752 provider.abort();
3753 }
3754
3755 #[tokio::test]
3756 async fn provider_tool_call_index_out_of_range_fails_capture() {
3757 let directory = tempfile::tempdir().unwrap();
3758 let (state, provider) = test_state(directory.path()).await;
3759 let response = build_router(state.clone())
3760 .oneshot(chat_request_with_payload(
3761 "tool-oob-index",
3762 json!({
3763 "model": "tool-oob-index",
3764 "stream": true,
3765 "messages": [{"role": "user", "content": "hello"}]
3766 }),
3767 ))
3768 .await
3769 .unwrap();
3770 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
3771 .await
3772 .is_err());
3773 assert!(state.sessions.get("tool-oob-index").unwrap().capture_failed());
3774 provider.abort();
3775 }
3776
3777 #[tokio::test]
3778 async fn tool_arguments_without_usage_are_budgeted_before_delivery() {
3779 let directory = tempfile::tempdir().unwrap();
3780 let payload = json!({
3781 "model": "tool-no-usage",
3782 "stream": true,
3783 "messages": [{"role": "user", "content": "hello"}]
3784 });
3785 let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
3786 let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens)).await;
3787 let response = build_router(state)
3788 .oneshot(chat_request_with_payload("tool-no-usage", payload))
3789 .await
3790 .unwrap();
3791 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
3792 .await
3793 .is_err());
3794 provider.abort();
3795 }
3796
3797 #[tokio::test(flavor = "current_thread")]
3798 async fn completion_budget_store_does_not_block_stream_runtime() {
3799 let directory = tempfile::tempdir().unwrap();
3800 let (mut state, provider) = test_state_with_token_cap(directory.path(), Some(10_000)).await;
3801 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3802 state.store = Arc::new(SlowStore {
3803 inner: InMemoryStore::new(),
3804 calls: Arc::clone(&calls),
3805 });
3806 let response = build_router(state)
3807 .oneshot(chat_request("slow-budget"))
3808 .await
3809 .unwrap();
3810 let body = tokio::spawn(async move {
3811 axum::body::to_bytes(response.into_body(), 64 * 1024)
3812 .await
3813 .unwrap()
3814 });
3815
3816 let started = std::time::Instant::now();
3817 tokio::time::sleep(Duration::from_millis(10)).await;
3818 assert!(
3819 started.elapsed() < Duration::from_millis(75),
3820 "completion budget storage blocked the Tokio reactor"
3821 );
3822 assert!(!body.await.unwrap().is_empty());
3823 assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 2);
3824 provider.abort();
3825 }
3826
3827 #[tokio::test]
3828 async fn completion_tokens_are_blocked_before_delivery() {
3829 let directory = tempfile::tempdir().unwrap();
3830 let prompt_tokens = av_core::tokens::approx_tokens(&chat_payload().to_string());
3831 let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens)).await;
3832 let app = build_router(state.clone());
3833 let response = app.oneshot(chat_request("completion-budget")).await.unwrap();
3834 assert_eq!(response.status(), StatusCode::OK);
3835 assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
3836 .await
3837 .is_err());
3838 tokio::time::timeout(Duration::from_secs(1), async {
3839 loop {
3840 if state
3841 .sessions
3842 .get("completion-budget")
3843 .is_some_and(|session| session.is_closed())
3844 {
3845 break;
3846 }
3847 tokio::task::yield_now().await;
3848 }
3849 })
3850 .await
3851 .unwrap();
3852 provider.abort();
3853 }
3854
3855 #[test]
3873 fn upstream_response_headers_do_not_cross_proxy_trust_boundary() {
3874 use axum::http::HeaderName;
3875 let dangerous = [
3876 "set-cookie",
3878 "access-control-allow-origin",
3880 "access-control-allow-credentials",
3881 "access-control-allow-methods",
3882 "access-control-allow-headers",
3883 "access-control-expose-headers",
3884 "access-control-max-age",
3885 "connection",
3889 "keep-alive",
3890 "transfer-encoding",
3891 "upgrade",
3892 "te",
3893 "trailer",
3894 "proxy-authenticate",
3895 "proxy-authorization",
3896 "content-length",
3899 "server",
3901 "via",
3902 "x-powered-by",
3903 "x-request-id",
3904 ];
3905 for name in dangerous {
3906 let header = HeaderName::from_static(name);
3907 assert!(
3908 !is_forwardable_upstream_header(&header),
3909 "dangerous header {name:?} must not be forwarded to the client"
3910 );
3911 }
3912
3913 let benign = [
3914 "content-type",
3915 "content-encoding",
3916 "cache-control",
3917 "etag",
3918 "last-modified",
3919 "vary",
3920 "expires",
3921 ];
3922 for name in benign {
3923 let header = HeaderName::from_static(name);
3924 assert!(
3925 is_forwardable_upstream_header(&header),
3926 "benign header {name:?} must still be forwarded"
3927 );
3928 }
3929 }
3930
3931 #[tokio::test]
3944 async fn mcp_tool_upstream_failure_does_not_leak_configured_url() {
3945 let sentinel_host = "internal-mcp-sentinel-host.corp.example";
3947 let sentinel_url = format!("http://{sentinel_host}:65002/mcp");
3948 let directory = tempfile::tempdir().unwrap();
3949 let (mut state, provider) = test_state(directory.path()).await;
3950 Arc::get_mut(&mut state.config).unwrap().tool_upstream_url = Some(sentinel_url);
3951 let response = build_router(state)
3952 .oneshot(
3953 Request::builder()
3954 .method("POST")
3955 .uri("/v1/mcp")
3956 .header("x-av-session", "mcp-cwe-209-check")
3957 .body(Body::from(
3958 r#"{"jsonrpc":"2.0","id":"leak","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
3959 ))
3960 .unwrap(),
3961 )
3962 .await
3963 .unwrap();
3964 assert_eq!(
3965 response.status(),
3966 StatusCode::BAD_GATEWAY,
3967 "tool-upstream faults must surface as 502 like the chat relay, not 500"
3968 );
3969 let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
3970 .await
3971 .unwrap();
3972 let body = String::from_utf8(bytes.to_vec()).unwrap();
3973 assert!(
3974 !body.contains(sentinel_host),
3975 "MCP forward-tool error body {body:?} leaks the configured tool-upstream host"
3976 );
3977 assert!(
3978 !body.contains("65002"),
3979 "MCP forward-tool error body {body:?} leaks the configured tool-upstream port"
3980 );
3981 assert!(
3982 !body.contains("corp.example"),
3983 "MCP forward-tool error body {body:?} leaks the configured tool-upstream domain"
3984 );
3985 provider.abort();
3986 }
3987
3988 #[tokio::test]
3989 async fn pipeline_error_carries_retry_after_and_www_authenticate() {
3990 use crate::pipeline::PipelineError;
3997
3998 let unavailable = pipeline_error(PipelineError::Unavailable("worker queue full".into()));
3999 assert_eq!(unavailable.status(), StatusCode::SERVICE_UNAVAILABLE);
4000 assert_eq!(
4001 unavailable
4002 .headers()
4003 .get(axum::http::header::RETRY_AFTER)
4004 .and_then(|v| v.to_str().ok()),
4005 Some("5")
4006 );
4007
4008 let upstream = pipeline_error(PipelineError::Upstream("bad gateway".into()));
4009 assert_eq!(upstream.status(), StatusCode::BAD_GATEWAY);
4010 assert!(upstream.headers().get(axum::http::header::RETRY_AFTER).is_some());
4011
4012 let unauthorized = pipeline_error(PipelineError::Unauthorized("bad token".into()));
4013 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4014 let auth = unauthorized
4015 .headers()
4016 .get(axum::http::header::WWW_AUTHENTICATE)
4017 .and_then(|v| v.to_str().ok())
4018 .unwrap_or_default();
4019 assert!(
4020 auth.starts_with("Bearer"),
4021 "WWW-Authenticate must be a Bearer challenge, got {auth:?}"
4022 );
4023
4024 let blocked = pipeline_error(PipelineError::Blocked("loop breaker".into()));
4027 assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
4028 assert!(blocked.headers().get(axum::http::header::RETRY_AFTER).is_none());
4029 let abort = pipeline_error(PipelineError::Abort("stop retrying".into()));
4030 assert_eq!(abort.status(), StatusCode::CONFLICT);
4031 assert!(abort.headers().get(axum::http::header::RETRY_AFTER).is_none());
4032 }
4033
4034 #[test]
4042 fn is_sse_content_type_is_case_insensitive_and_param_tolerant() {
4043 fn ct(value: &str) -> HeaderMap {
4044 let mut headers = HeaderMap::new();
4045 headers.insert(
4046 axum::http::header::CONTENT_TYPE,
4047 value.parse().expect("header value"),
4048 );
4049 headers
4050 }
4051 assert!(is_sse_content_type(&ct("text/event-stream")));
4052 assert!(is_sse_content_type(&ct("Text/Event-Stream")));
4053 assert!(is_sse_content_type(&ct("TEXT/EVENT-STREAM")));
4054 assert!(is_sse_content_type(&ct("text/event-stream; charset=utf-8")));
4055 assert!(is_sse_content_type(&ct("Text/Event-Stream ; charset=utf-8")));
4056 assert!(!is_sse_content_type(&ct("application/json")));
4058 assert!(!is_sse_content_type(&ct("text/event-stream-json")));
4059 assert!(!is_sse_content_type(&HeaderMap::new()));
4061 }
4062
4063 #[tokio::test]
4070 async fn options_returns_204_without_cors_headers() {
4071 let directory = tempfile::tempdir().unwrap();
4072 let (state, provider) = test_state(directory.path()).await;
4073 for path in [
4074 "/v1/chat/completions",
4075 "/v1/mcp",
4076 "/mcp",
4077 "/v1/sessions/some-id/close",
4078 "/v1/sessions/some-id/promote",
4079 ] {
4080 let response = build_router(state.clone())
4081 .oneshot(
4082 Request::builder()
4083 .method(axum::http::Method::OPTIONS)
4084 .uri(path)
4085 .header("origin", "http://attacker.example")
4086 .header("access-control-request-method", "POST")
4087 .body(Body::empty())
4088 .unwrap(),
4089 )
4090 .await
4091 .unwrap();
4092 assert_eq!(
4093 response.status(),
4094 StatusCode::NO_CONTENT,
4095 "OPTIONS {path} should return 204, got {}",
4096 response.status()
4097 );
4098 for header in [
4099 "access-control-allow-origin",
4100 "access-control-allow-methods",
4101 "access-control-allow-headers",
4102 "access-control-allow-credentials",
4103 ] {
4104 assert!(
4105 response.headers().get(header).is_none(),
4106 "OPTIONS {path} must not emit {header} — got {:?}",
4107 response.headers().get(header)
4108 );
4109 }
4110 }
4111 provider.abort();
4112 }
4113
4114 #[test]
4128 fn round_32_f2_tool_outcome_preserves_content_type() {
4129 let outcome = ToolOutcome {
4131 status: 200,
4132 body_hex: hex::encode(b"{\"jsonrpc\":\"2.0\"}"),
4133 content_type: Some("application/problem+json".to_owned()),
4134 };
4135 let response = outcome.into_response();
4136 assert_eq!(
4137 response
4138 .headers()
4139 .get(axum::http::header::CONTENT_TYPE)
4140 .and_then(|v| v.to_str().ok()),
4141 Some("application/problem+json")
4142 );
4143 let outcome_no_ct = ToolOutcome {
4145 status: 200,
4146 body_hex: hex::encode(b"{}"),
4147 content_type: None,
4148 };
4149 let response = outcome_no_ct.into_response();
4150 assert_eq!(
4151 response
4152 .headers()
4153 .get(axum::http::header::CONTENT_TYPE)
4154 .and_then(|v| v.to_str().ok()),
4155 Some("application/json")
4156 );
4157 let sealed = serde_json::to_string(&ToolOutcome {
4159 status: 429,
4160 body_hex: hex::encode(b"{}"),
4161 content_type: Some("text/plain".to_owned()),
4162 })
4163 .unwrap();
4164 let recovered: ToolOutcome = serde_json::from_str(&sealed).unwrap();
4165 assert_eq!(recovered.content_type.as_deref(), Some("text/plain"));
4166 let legacy = r#"{"status":200,"body_hex":"7b7d"}"#;
4168 let outcome: ToolOutcome = serde_json::from_str(legacy).unwrap();
4169 assert!(outcome.content_type.is_none());
4170 }
4171}