Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
2299443
feat: queue scope and mark event publication
willkill07 Jul 28, 2026
7eeb523
test: flush queued FFI event sanitizers
willkill07 Jul 28, 2026
fb4cc1a
perf: skip queued sanitizers without subscribers
willkill07 Jul 28, 2026
ec5b6c5
perf: avoid unsanitized event clones
willkill07 Jul 28, 2026
7254dca
fix: preserve sanitized snapshots after callback panics
willkill07 Jul 28, 2026
e701c9b
docs: clarify deferred scope-end publication
willkill07 Jul 28, 2026
d51bb0f
fix(node): avoid subscriber flush deadlock
willkill07 Jul 28, 2026
64ab12a
fix(node): await subscriber flush in OpenClaw
willkill07 Jul 28, 2026
e048edc
feat!: make middleware async across primary bindings
willkill07 Jul 28, 2026
941ca37
test: preserve legacy FFI sanitizer errors
willkill07 Jul 28, 2026
1f64cee
fix: address async middleware review feedback
willkill07 Jul 28, 2026
02303bc
docs: clarify Python awaitable middleware callers
willkill07 Jul 28, 2026
86162ff
fix: isolate async event sanitizer panics
willkill07 Jul 28, 2026
71386c2
fix: preserve queued sanitizer execution semantics
willkill07 Jul 28, 2026
c97baa3
fix: address async middleware review follow-ups
willkill07 Jul 28, 2026
436d46b
test: align sanitizer failure expectations
willkill07 Jul 28, 2026
00ccbd7
test: move runtime panic coverage out of source
willkill07 Jul 28, 2026
fa55b02
test: cover all sanitizer panic paths
willkill07 Jul 28, 2026
165b080
test: assert middleware panic error variants
willkill07 Jul 28, 2026
0c20cc1
fix: address hidden middleware review findings
willkill07 Jul 28, 2026
b3df9db
fix: retain Python loop context for sanitizers
willkill07 Jul 28, 2026
b229880
fix: preserve progressive event sanitizer context
willkill07 Jul 28, 2026
74289a9
fix: address async middleware review findings
willkill07 Jul 28, 2026
669a529
fix: prevent async sanitizer flush deadlocks
willkill07 Jul 28, 2026
6e194ff
fix: scope reentrant flush guards to callbacks
willkill07 Jul 28, 2026
609afe8
fix(python): scope sanitizer flush reentrancy
willkill07 Jul 29, 2026
82ee862
fix: address async middleware review regressions
willkill07 Jul 29, 2026
e2ec4c7
fix: address async binding review findings
willkill07 Jul 29, 2026
dea33aa
fix: resolve async middleware review findings
willkill07 Jul 29, 2026
4f47567
test(go): encode streaming fixtures as valid JSON
willkill07 Jul 29, 2026
2f1aef2
fix: prevent queued sanitizer flush deadlocks
willkill07 Jul 29, 2026
b7c5e1c
fix: make publication barriers flush-safe
willkill07 Jul 29, 2026
c21131e
fix: preserve async publication flush ordering
willkill07 Jul 29, 2026
7af6674
feat: add async middleware C and Go APIs
willkill07 Jul 28, 2026
3bbbe9e
fix: address async FFI and Go review feedback
willkill07 Jul 28, 2026
e1c2913
fix: address follow-up FFI and Go review feedback
willkill07 Jul 28, 2026
7f672f7
fix: retain async next callback ownership
willkill07 Jul 28, 2026
31cd595
fix: contain Go async callback panics
willkill07 Jul 28, 2026
1a4d041
test: isolate null propagation context coverage
willkill07 Jul 28, 2026
c0a6907
test: cover completion cancellation lifecycle
willkill07 Jul 28, 2026
d5b0cbd
fix: fail fast on FFI header generation
willkill07 Jul 28, 2026
fda6bf9
refactor: consolidate async event registrations
willkill07 Jul 28, 2026
32ac7f1
test: cover async conditional rejection paths
willkill07 Jul 28, 2026
f8185db
fix: tighten async FFI parity and isolation
willkill07 Jul 28, 2026
23daaf8
test: cover async next callback failures
willkill07 Jul 28, 2026
91dd5c5
fix: scope reentrant flush guards to callbacks
willkill07 Jul 28, 2026
ca0293f
fix: address async FFI review findings
willkill07 Jul 28, 2026
c1e22da
fix: tighten async FFI review safeguards
willkill07 Jul 29, 2026
fcce724
perf(ffi): avoid buffering stream size checks
willkill07 Jul 29, 2026
0c17250
fix(ffi): restore async completion JSON parsing
willkill07 Jul 29, 2026
4110c06
fix(ffi): retain pending callback ownership safely
willkill07 Jul 29, 2026
c4ce063
fix(ffi): stream async next incrementally
willkill07 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 26 additions & 20 deletions crates/adaptive/src/acg_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,26 +582,32 @@ pub(crate) fn create_acg_llm_request_intercept(
provider: String,
plugin: Arc<dyn ProviderPlugin>,
) -> LlmRequestInterceptFn {
Arc::new(move |_name: &str, request: LlmRequest, annotated| {
let input_content = request.content.clone();
let translated =
translate_request(&request, &agent_id, &provider, plugin.as_ref(), &hot_cache)
.unwrap_or(request);
if annotated.is_some() && translated.content != input_content {
let translated_annotated = build_semantic_request_view(&translated)
.map_err(|error| nemo_relay::error::FlowError::Internal(error.to_string()))?
.annotated_request;
return Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
LlmRequest {
headers: translated.headers,
content: input_content,
},
Some(translated_annotated),
));
}
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
translated, annotated,
))
Arc::new(move |_name: String, request: LlmRequest, annotated| {
let hot_cache = hot_cache.clone();
let agent_id = agent_id.clone();
let provider = provider.clone();
let plugin = plugin.clone();
Box::pin(async move {
let input_content = request.content.clone();
let translated =
translate_request(&request, &agent_id, &provider, plugin.as_ref(), &hot_cache)
.unwrap_or(request);
if annotated.is_some() && translated.content != input_content {
let translated_annotated = build_semantic_request_view(&translated)
.map_err(|error| nemo_relay::error::FlowError::Internal(error.to_string()))?
.annotated_request;
return Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
LlmRequest {
headers: translated.headers,
content: input_content,
},
Some(translated_annotated),
));
}
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
translated, annotated,
))
})
})
}

Expand Down
10 changes: 5 additions & 5 deletions crates/adaptive/src/adaptive_hints_intercept.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,14 +174,14 @@ impl AdaptiveHintsIntercept {
pub fn into_request_fn(self) -> LlmRequestInterceptFn {
let this = Arc::new(self);
Arc::new(
move |_name: &str,
move |_name: String,
mut request: LlmRequest,
mut annotated: Option<AnnotatedLlmRequest>| {
let this = this.clone();
let scope_path = extract_scope_path();
let manual_ls = read_manual_latency_sensitivity();
let scope_depth = scope_path.len();
let call_index = this.call_counter.fetch_add(1, Ordering::Relaxed);

let effective_agent_id = this.effective_agent_id();
let cached_hints =
this.load_hints(&scope_path, &effective_agent_id, call_index, scope_depth);
Expand All @@ -196,9 +196,9 @@ impl AdaptiveHintsIntercept {
inject_agent_hints(&mut request, &mut annotated, &hints);
}

Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
let outcome =
nemo_relay::api::llm::LlmRequestInterceptOutcome::new(request, annotated);
Box::pin(async move { Ok(outcome) })
},
)
}
Expand Down
4 changes: 4 additions & 0 deletions crates/adaptive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
pub(crate) static TEST_GLOBAL_CONTEXT_MUTEX: tokio::sync::Mutex<()> =
tokio::sync::Mutex::const_new(());

#[cfg(test)]
#[path = "../tests/support/mod.rs"]
pub(crate) mod test_support;

pub mod acg;
pub mod acg_component;
pub mod acg_learner;
Expand Down
12 changes: 8 additions & 4 deletions crates/adaptive/tests/integration/runtime_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ async fn test_adaptive_plugin_registers_and_passes_calls_through() {
content: json!({"messages": []}),
},
)
.await
.unwrap();
assert_eq!(request.request.content["messages"], json!([]));

Expand Down Expand Up @@ -739,9 +740,11 @@ impl Plugin for HeaderPlugin {
false,
Arc::new(|_name, mut request, annotated| {
request.headers.insert("x-plugin".into(), json!("set"));
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
Box::pin(async move {
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
})
}),
)?;
ctx.register_tool_request_intercept(
Expand All @@ -752,7 +755,7 @@ impl Plugin for HeaderPlugin {
if let Json::Object(ref mut map) = args {
map.insert("x-tool-plugin".into(), json!(true));
}
Ok(args)
Box::pin(async move { Ok(args) })
}),
)?;
ctx.register_llm_execution_intercept(
Expand Down Expand Up @@ -823,6 +826,7 @@ async fn test_top_level_plugin_registers_request_and_execution_intercepts() {
content: json!({"messages": []}),
},
)
.await
.unwrap();
assert_eq!(request.request.headers.get("x-plugin"), Some(&json!("set")));

Expand Down
12 changes: 12 additions & 0 deletions crates/adaptive/tests/support/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::future::Future;

pub(crate) fn block_on<F: Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build")
.block_on(future)
}
13 changes: 9 additions & 4 deletions crates/adaptive/tests/unit/acg_component_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1087,11 +1087,11 @@ fn acg_component_request_intercept_passes_original_request_and_annotation_when_t
plugin,
);

let outcome = intercept(
"anthropic",
let outcome = crate::test_support::block_on(intercept(
"anthropic".to_string(),
invalid_request.clone(),
Some(annotated.clone()),
)
))
.expect("request intercept should pass through");
let translated = outcome.request;
let returned_annotated = outcome.annotated_request;
Expand Down Expand Up @@ -1335,7 +1335,12 @@ fn acg_component_request_intercept_rewrites_annotation_without_mutating_provider
plugin,
);

let outcome = intercept("anthropic", request, Some(original_annotation.clone())).unwrap();
let outcome = crate::test_support::block_on(intercept(
"anthropic".to_string(),
request,
Some(original_annotation.clone()),
))
.unwrap();

assert_eq!(outcome.request.content, original_content);
let annotation = outcome
Expand Down
19 changes: 10 additions & 9 deletions crates/adaptive/tests/unit/adaptive_hints_intercept_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! Unit tests for adaptive hints intercept in the NeMo Relay adaptive crate.

use super::*;

use std::sync::{Mutex, OnceLock};

use crate::trie::data_models::{LlmCallPrediction, PredictionMetrics};
Expand Down Expand Up @@ -196,14 +197,14 @@ fn test_adaptive_hints_intercept_injects_prediction_hints_and_manual_override()
stream: None,
extra: serde_json::Map::new(),
};
let outcome = req_fn(
"model",
let outcome = crate::test_support::block_on(req_fn(
"model".to_string(),
LlmRequest {
headers: serde_json::Map::new(),
content: serde_json::json!({}),
},
Some(annotated.clone()),
)
))
.unwrap();
let request = outcome.request;
let returned_annotated = outcome.annotated_request;
Expand Down Expand Up @@ -266,14 +267,14 @@ fn test_adaptive_hints_intercept_uses_defaults_and_ignores_poisoned_cache() {
}));
let req_fn =
AdaptiveHintsIntercept::new(hot_cache, "fallback-agent".to_string()).into_request_fn();
let outcome = req_fn(
"model",
let outcome = crate::test_support::block_on(req_fn(
"model".to_string(),
LlmRequest {
headers: serde_json::Map::new(),
content: serde_json::json!({}),
},
None,
)
))
.unwrap();
let request = outcome.request;
let annotated = outcome.annotated_request;
Expand Down Expand Up @@ -305,14 +306,14 @@ fn test_adaptive_hints_intercept_uses_defaults_and_ignores_poisoned_cache() {
});
let poisoned_req_fn =
AdaptiveHintsIntercept::new(poisoned_cache, "fallback-agent".to_string()).into_request_fn();
let poisoned_outcome = poisoned_req_fn(
"model",
let poisoned_outcome = crate::test_support::block_on(poisoned_req_fn(
"model".to_string(),
LlmRequest {
headers: serde_json::Map::new(),
content: serde_json::json!({"existing": true}),
},
None,
)
))
.unwrap();
let poisoned_request = poisoned_outcome.request;
assert!(
Expand Down
1 change: 1 addition & 0 deletions crates/adaptive/tests/unit/plugin_component_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ async fn adaptive_plugin_registers_runtime_and_rolls_back_registration() {
content: json!({}),
},
)
.await
.unwrap();
assert!(request.request.headers.is_empty());

Expand Down
25 changes: 16 additions & 9 deletions crates/adaptive/tests/unit/runtime_features_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,11 @@ fn assert_llm_request_intercept_registered(name: &str) {
i32::MAX,
false,
Arc::new(|_name, request, annotated| {
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
Box::pin(async move {
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
})
}),
),
name,
Expand All @@ -154,9 +156,11 @@ fn assert_llm_request_intercept_absent(name: &str) {
i32::MAX,
false,
Arc::new(|_name, request, annotated| {
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
Box::pin(async move {
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
})
}),
)
.unwrap();
Expand Down Expand Up @@ -565,6 +569,7 @@ async fn adaptive_hints_feature_registers_request_intercept() {
content: json!({}),
},
)
.await
.unwrap();
assert!(request.request.headers.contains_key(AGENT_HINTS_HEADER_KEY));

Expand Down Expand Up @@ -730,9 +735,11 @@ async fn registration_context_registers_all_supported_callback_types() {
5,
false,
Arc::new(|_name, request, annotated| {
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
Box::pin(async move {
Ok(nemo_relay::api::llm::LlmRequestInterceptOutcome::new(
request, annotated,
))
})
}),
)
.unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/adaptive/tests/unit/runtime_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ async fn adaptive_runtime_bind_scope_requires_registration_and_passes_through_wi
};

let translated = llm_request_intercepts("anthropic", request.clone())
.await
.expect("request intercept chain should pass through when no hot-cache state exists");

assert_eq!(translated.request.content, request.content);
Expand Down
26 changes: 15 additions & 11 deletions crates/cli/src/sessions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,10 @@ impl SessionManager {
.map_err(CliError::from)
})
.await?;
// Manual lifecycle events publish on the serial dispatcher. This
// test-only seam returns after the matching end event is observable so
// a subsequent synthetic provider call cannot overtake it.
nemo_relay::api::subscriber::flush_subscribers().map_err(CliError::from)?;
let mut sessions = self.inner.lock().await;
if let Some(session) = sessions.get_mut(&session_id) {
session.record_completed_llm_response(response_for_hints, owner_subagent_id);
Expand Down Expand Up @@ -784,9 +788,9 @@ impl Session {
NormalizedEvent::SubagentStarted(event) => self.start_subagent(event).await,
NormalizedEvent::SubagentEnded(event) => self.end_subagent(event).await,
NormalizedEvent::LlmHint(event) => self.add_llm_hint(event),
NormalizedEvent::LlmStarted(event) => self.start_hook_llm(event),
NormalizedEvent::LlmEnded(event) => self.end_hook_llm(event),
NormalizedEvent::ToolStarted(event) => self.start_tool(event),
NormalizedEvent::LlmStarted(event) => self.start_hook_llm(event).await,
NormalizedEvent::LlmEnded(event) => self.end_hook_llm(event).await,
NormalizedEvent::ToolStarted(event) => self.start_tool(event).await,
NormalizedEvent::ToolEnded(event) => self.end_tool(event).await,
NormalizedEvent::PromptSubmitted(event) => self.start_turn(event).await,
NormalizedEvent::Compaction(event) => self.mark("compaction", event),
Expand Down Expand Up @@ -1142,8 +1146,8 @@ impl Session {
if self.turn_scope.is_none() {
return Ok(Vec::new());
}
self.close_active_llms(reason)?;
self.close_active_tools(reason)?;
self.close_active_llms(reason).await?;
self.close_active_tools(reason).await?;
let closed_subagents = self.close_active_subagents(reason).await?;
let output = self.last_turn_llm_output.take().unwrap_or(output);
self.clear_correlation_state();
Expand Down Expand Up @@ -1182,7 +1186,7 @@ impl Session {
}

// Ends all active hook-observed LLM calls before closing their containing scopes.
fn close_active_llms(&mut self, reason: &str) -> Result<(), CliError> {
async fn close_active_llms(&mut self, reason: &str) -> Result<(), CliError> {
let active_llms: Vec<_> = self.llms.drain().map(|(_, handle)| handle).collect();
for handle in active_llms {
llm_call_end(
Expand All @@ -1198,7 +1202,7 @@ impl Session {

// Ends all active tool calls with a synthetic close result before ending their containing scopes.
// Draining first avoids holding mutable map state while the runtime emits lifecycle events.
fn close_active_tools(&mut self, reason: &str) -> Result<(), CliError> {
async fn close_active_tools(&mut self, reason: &str) -> Result<(), CliError> {
let active_tools: Vec<_> = self
.tools
.drain()
Expand Down Expand Up @@ -1428,7 +1432,7 @@ impl Session {
// ignored so repeated pre hooks do not create parallel handles for one provider call. Aliased
// child-session LLMs carry their subagent owner in metadata and are resolved by
// `hook_llm_owner`.
fn start_hook_llm(&mut self, event: LlmEvent) -> Result<(), CliError> {
async fn start_hook_llm(&mut self, event: LlmEvent) -> Result<(), CliError> {
self.ensure_turn_started(event.metadata.clone())?;
if self.llms.contains_key(&event.api_call_id) {
return Ok(());
Expand All @@ -1454,7 +1458,7 @@ impl Session {
// Ends a hook-observed LLM call, synthesizing a start if only the post hook arrives. The same
// alias metadata recovery used by `start_hook_llm` keeps post-only aliased child LLMs under the
// subagent instead of falling back to the root agent.
fn end_hook_llm(&mut self, event: LlmEvent) -> Result<(), CliError> {
async fn end_hook_llm(&mut self, event: LlmEvent) -> Result<(), CliError> {
self.ensure_turn_started(event.metadata.clone())?;
let (parent, metadata) = self.hook_llm_owner(event.metadata);
let handle = match self.llms.remove(&event.api_call_id) {
Expand Down Expand Up @@ -1511,7 +1515,7 @@ impl Session {
// Starts a tool call under an explicit subagent when available, otherwise under the turn
// scope. Duplicate tool IDs are ignored so repeated pre-tool hooks do not create parallel
// handles for one agent tool invocation.
fn start_tool(&mut self, event: ToolEvent) -> Result<(), CliError> {
async fn start_tool(&mut self, event: ToolEvent) -> Result<(), CliError> {
self.ensure_turn_started(event.metadata.clone())?;
if self.tools.contains_key(&event.tool_call_id) {
return Ok(());
Expand All @@ -1529,7 +1533,7 @@ impl Session {
let active_tool_arguments = arguments.clone();
let active_tool_name = event.tool_name.clone();
let active_tool_owner_subagent_id = owner.subagent_id.clone();
tool_conditional_execution(event.tool_name.as_str(), &arguments)?;
tool_conditional_execution(event.tool_name.as_str(), &arguments).await?;
let metadata = tool_correlation_metadata(
self.event_identity_metadata(event.metadata),
owner.status,
Expand Down
Loading
Loading