use std::cell::RefCell;
use std::collections::BTreeMap;
use std::sync::{Arc, LazyLock, Mutex};
use crate::agent_events::{
self, AgentEvent, AgentEventSink, ToolCallErrorCategory, ToolCallStatus, ToolMutationStatus,
};
use crate::mcp::VmMcpClientHandle;
use crate::value::VmValue;
const LOOP_EXIT_ABANDON_REASON: &str =
"The agent loop exited while this tool call was still in flight; it was \
never dispatched to a result. No terminal update was emitted by the \
dispatch path, so the loop resolved it as abandoned at loop exit.";
pub type SessionEndHook = Arc<dyn Fn(&str) + Send + Sync>;
thread_local! {
static CURRENT_HOST_BRIDGE: RefCell<Option<Arc<crate::bridge::HostBridge>>> =
const { RefCell::new(None) };
static CURRENT_LOOP_SINKS: RefCell<Vec<Arc<dyn AgentEventSink>>> =
const { RefCell::new(Vec::new()) };
}
static SESSION_END_HOOKS: LazyLock<Mutex<Vec<SessionEndHook>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
static SESSION_MCP_CLIENTS: LazyLock<Mutex<BTreeMap<String, BTreeMap<String, VmMcpClientHandle>>>> =
LazyLock::new(|| Mutex::new(BTreeMap::new()));
#[derive(Default)]
struct ToolLifecycleStarts {
live: BTreeMap<(String, String), String>,
}
impl ToolLifecycleStarts {
fn observe(&mut self, event: &AgentEvent) -> bool {
match event {
AgentEvent::ToolCall {
session_id,
tool_call_id,
tool_name,
..
} => {
if tool_call_id.trim().is_empty() {
return true;
}
self.live
.insert(
(session_id.clone(), tool_call_id.clone()),
tool_name.clone(),
)
.is_none()
}
AgentEvent::ToolCallUpdate {
session_id,
tool_call_id,
status: ToolCallStatus::Completed | ToolCallStatus::Failed,
..
} => {
self.live
.remove(&(session_id.clone(), tool_call_id.clone()));
true
}
_ => true,
}
}
fn drain_session(&mut self, session_id: &str) -> Vec<(String, String)> {
let live = std::mem::take(&mut self.live);
let mut drained = Vec::new();
for ((active_session_id, tool_call_id), tool_name) in live {
if active_session_id == session_id {
drained.push((tool_call_id, tool_name));
} else {
self.live
.insert((active_session_id, tool_call_id), tool_name);
}
}
drained
}
fn clear_session(&mut self, session_id: &str) {
let _ = self.drain_session(session_id);
}
}
static TOOL_LIFECYCLE_STARTS: LazyLock<Mutex<ToolLifecycleStarts>> =
LazyLock::new(|| Mutex::new(ToolLifecycleStarts::default()));
fn observe_tool_lifecycle(event: &AgentEvent) -> bool {
TOOL_LIFECYCLE_STARTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.observe(event)
}
pub(crate) struct LoopSinkGuard {
pushed: bool,
}
impl LoopSinkGuard {
pub(crate) fn install(sink: Option<Arc<dyn AgentEventSink>>) -> Self {
if let Some(sink) = sink {
CURRENT_LOOP_SINKS.with(|stack| stack.borrow_mut().push(sink));
Self { pushed: true }
} else {
Self { pushed: false }
}
}
}
impl Drop for LoopSinkGuard {
fn drop(&mut self) {
if self.pushed {
CURRENT_LOOP_SINKS.with(|stack| {
let _ = stack.borrow_mut().pop();
});
}
}
}
pub(crate) fn emit_agent_event_sync(event: &AgentEvent) {
if observe_tool_lifecycle(event) {
agent_events::emit_event(event);
let loop_sink = CURRENT_LOOP_SINKS.with(|stack| stack.borrow().last().cloned());
if let Some(sink) = loop_sink {
sink.handle_event(event);
}
}
}
pub async fn scope_agent_event_sink<F, T>(sink: Option<Arc<dyn AgentEventSink>>, future: F) -> T
where
F: std::future::Future<Output = T>,
{
let _guard = LoopSinkGuard::install(sink);
future.await
}
pub(crate) async fn emit_agent_event_with_ctx(
ctx: Option<&crate::vm::AsyncBuiltinCtx>,
event: &AgentEvent,
) {
if observe_tool_lifecycle(event) {
agent_events::emit_event(event);
let loop_sink = CURRENT_LOOP_SINKS.with(|stack| stack.borrow().last().cloned());
if let Some(sink) = loop_sink {
sink.handle_event(event);
}
}
let subscribers = crate::agent_sessions::subscribers_for(event.session_id());
if subscribers.is_empty() {
return;
}
let payload = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
let arg = crate::stdlib::json_to_vm_value(&payload);
for closure in subscribers {
let VmValue::Closure(closure) = closure else {
continue;
};
let Some(ctx) = ctx else {
continue;
};
let mut vm = ctx.child_vm();
let result = vm.call_closure_pub(&closure, &[arg.clone()]).await;
ctx.forward_output(&vm.take_output());
if let Err(err) = result {
crate::events::log_warn(
"agent.subscriber",
&format!(
"session={} event={:?} subscriber error: {}",
event.session_id(),
std::mem::discriminant(event),
err
),
);
}
}
}
pub fn register_session_end_hook(hook: SessionEndHook) {
if let Ok(mut hooks) = SESSION_END_HOOKS.lock() {
hooks.push(hook);
}
}
pub(crate) fn fire_session_end_hooks(session_id: &str, abandon_in_flight: bool) {
let abandoned = {
let mut tracker = TOOL_LIFECYCLE_STARTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if abandon_in_flight {
tracker.drain_session(session_id)
} else {
tracker.clear_session(session_id);
Vec::new()
}
};
for (tool_call_id, tool_name) in abandoned {
emit_agent_event_sync(&AgentEvent::ToolCallUpdate {
session_id: session_id.to_string(),
tool_call_id,
tool_name,
status: ToolCallStatus::Failed,
raw_output: None,
error: Some(LOOP_EXIT_ABANDON_REASON.to_string()),
duration_ms: None,
execution_duration_ms: None,
error_category: Some(ToolCallErrorCategory::AbandonedAtLoopExit),
mutation_status: ToolMutationStatus::Unknown,
changed_paths: None,
executor: None,
parsing: None,
raw_input: None,
raw_input_partial: None,
audit: None,
});
}
if let Ok(hooks) = SESSION_END_HOOKS.lock() {
for hook in hooks.iter() {
hook(session_id);
}
}
}
pub(crate) fn install_current_host_bridge(bridge: Arc<crate::bridge::HostBridge>) {
CURRENT_HOST_BRIDGE.with(|slot| {
*slot.borrow_mut() = Some(bridge);
});
}
pub(crate) fn clear_current_host_bridge() {
CURRENT_HOST_BRIDGE.with(|slot| {
*slot.borrow_mut() = None;
});
}
pub(crate) fn swap_current_host_bridge(
bridge: Option<Arc<crate::bridge::HostBridge>>,
) -> Option<Arc<crate::bridge::HostBridge>> {
CURRENT_HOST_BRIDGE.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), bridge))
}
pub(crate) fn current_host_bridge() -> Option<Arc<crate::bridge::HostBridge>> {
CURRENT_HOST_BRIDGE.with(|slot| slot.borrow().clone())
}
pub fn current_agent_session_id() -> Option<String> {
crate::agent_sessions::current_session_id()
}
pub(crate) fn install_session_mcp_clients(
session_id: &str,
clients: BTreeMap<String, VmMcpClientHandle>,
) {
if let Ok(mut map) = SESSION_MCP_CLIENTS.lock() {
let existing = map.entry(session_id.to_string()).or_default();
for (name, handle) in clients {
existing.insert(name, handle);
}
}
}
pub(crate) fn take_session_mcp_clients(
session_id: &str,
) -> Option<BTreeMap<String, VmMcpClientHandle>> {
SESSION_MCP_CLIENTS
.lock()
.ok()
.and_then(|mut map| map.remove(session_id))
}
pub(crate) fn session_mcp_client(session_id: &str, server_name: &str) -> Option<VmMcpClientHandle> {
SESSION_MCP_CLIENTS.lock().ok().and_then(|map| {
map.get(session_id)
.and_then(|clients| clients.get(server_name))
.cloned()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_events::{ToolCallErrorCategory, ToolCallStatus, ToolMutationStatus};
use serde_json::json;
use std::collections::BTreeSet;
#[derive(Default)]
struct RecordingSink {
events: Mutex<Vec<AgentEvent>>,
}
impl AgentEventSink for RecordingSink {
fn handle_event(&self, event: &AgentEvent) {
self.events
.lock()
.expect("recording sink")
.push(event.clone());
}
}
fn start(session_id: &str, tool_call_id: &str) -> AgentEvent {
AgentEvent::ToolCall {
session_id: session_id.to_string(),
tool_call_id: tool_call_id.to_string(),
tool_name: "verify".to_string(),
kind: None,
status: ToolCallStatus::Pending,
raw_input: json!({}),
parsing: None,
audit: None,
}
}
fn finish(session_id: &str, tool_call_id: &str) -> AgentEvent {
AgentEvent::ToolCallUpdate {
session_id: session_id.to_string(),
tool_call_id: tool_call_id.to_string(),
tool_name: "verify".to_string(),
status: ToolCallStatus::Completed,
raw_output: None,
error: None,
duration_ms: None,
execution_duration_ms: None,
error_category: None,
mutation_status: ToolMutationStatus::Unknown,
changed_paths: None,
executor: None,
parsing: None,
raw_input: None,
raw_input_partial: None,
audit: None,
}
}
#[test]
fn lifecycle_start_is_single_writer_until_terminal_update() {
let mut starts = ToolLifecycleStarts::default();
assert!(starts.observe(&start("session-a", "tool-1")));
assert!(!starts.observe(&start("session-a", "tool-1")));
assert!(starts.observe(&start("session-a", "tool-2")));
assert!(starts.observe(&start("session-b", "tool-1")));
assert!(starts.observe(&finish("session-a", "tool-1")));
assert!(starts.observe(&start("session-a", "tool-1")));
}
#[test]
fn lifecycle_start_session_cleanup_releases_unfinished_ids() {
let mut starts = ToolLifecycleStarts::default();
assert!(starts.observe(&start("session-a", "tool-1")));
starts.clear_session("session-a");
assert!(starts.observe(&start("session-a", "tool-1")));
}
#[tokio::test(flavor = "current_thread")]
async fn observation_sink_receives_one_start_across_stream_and_dispatch() {
const SESSION_ID: &str = "single-start-observation-test";
const TOOL_CALL_ID: &str = "tool-1";
if let Ok(mut starts) = TOOL_LIFECYCLE_STARTS.lock() {
starts.clear_session(SESSION_ID);
}
let sink = Arc::new(RecordingSink::default());
let _guard = LoopSinkGuard::install(Some(sink.clone()));
emit_agent_event_sync(&start(SESSION_ID, TOOL_CALL_ID));
emit_agent_event_with_ctx(None, &start(SESSION_ID, TOOL_CALL_ID)).await;
emit_agent_event_with_ctx(None, &finish(SESSION_ID, TOOL_CALL_ID)).await;
let events = sink.events.lock().expect("recorded events");
assert_eq!(
events
.iter()
.filter(|event| matches!(event, AgentEvent::ToolCall { .. }))
.count(),
1,
"the streaming and dispatch paths share one observable start authority"
);
assert_eq!(events.len(), 2, "one start and one terminal update remain");
}
#[tokio::test(flavor = "current_thread")]
async fn loop_exit_resolves_every_in_flight_call_to_a_terminal_update() {
const SESSION_ID: &str = "loop-exit-abandon-test";
const OTHER_SESSION: &str = "loop-exit-abandon-other";
if let Ok(mut starts) = TOOL_LIFECYCLE_STARTS.lock() {
starts.clear_session(SESSION_ID);
starts.clear_session(OTHER_SESSION);
}
let sink = Arc::new(RecordingSink::default());
let _guard = LoopSinkGuard::install(Some(sink.clone()));
emit_agent_event_sync(&start(SESSION_ID, "call-a"));
emit_agent_event_sync(&start(SESSION_ID, "call-b"));
emit_agent_event_sync(&start(OTHER_SESSION, "call-c"));
fire_session_end_hooks(SESSION_ID, true);
let abandoned: Vec<(String, String, String, Option<String>)> = {
let events = sink.events.lock().expect("recorded events");
events
.iter()
.filter_map(|event| match event {
AgentEvent::ToolCallUpdate {
session_id,
tool_call_id,
tool_name,
status: ToolCallStatus::Failed,
error,
error_category: Some(ToolCallErrorCategory::AbandonedAtLoopExit),
..
} => Some((
session_id.clone(),
tool_call_id.clone(),
tool_name.clone(),
error.clone(),
)),
_ => None,
})
.collect()
};
assert_eq!(
abandoned.len(),
2,
"both in-flight calls for the finalizing session get one terminal update each"
);
for (session_id, _id, tool_name, error) in &abandoned {
assert_eq!(session_id, SESSION_ID);
assert_eq!(
tool_name, "verify",
"the terminal update carries the tool name from the observed start"
);
assert!(
error.as_deref().is_some_and(|reason| !reason.is_empty()),
"the abandoned call carries a human-readable reason"
);
}
let resolved: BTreeSet<&str> = abandoned.iter().map(|(_, id, _, _)| id.as_str()).collect();
assert_eq!(
resolved,
BTreeSet::from(["call-a", "call-b"]),
"a different session's in-flight call is not swept"
);
let before = sink.events.lock().expect("recorded events").len();
fire_session_end_hooks(SESSION_ID, true);
let after = sink.events.lock().expect("recorded events").len();
assert_eq!(before, after, "re-finalizing a drained session is a no-op");
if let Ok(mut starts) = TOOL_LIFECYCLE_STARTS.lock() {
starts.clear_session(OTHER_SESSION);
}
}
#[tokio::test(flavor = "current_thread")]
async fn loop_exit_does_not_resurrect_a_completed_call() {
const SESSION_ID: &str = "loop-exit-completed-test";
if let Ok(mut starts) = TOOL_LIFECYCLE_STARTS.lock() {
starts.clear_session(SESSION_ID);
}
let sink = Arc::new(RecordingSink::default());
let _guard = LoopSinkGuard::install(Some(sink.clone()));
emit_agent_event_sync(&start(SESSION_ID, "call-done"));
emit_agent_event_sync(&finish(SESSION_ID, "call-done"));
fire_session_end_hooks(SESSION_ID, true);
let events = sink.events.lock().expect("recorded events");
assert!(
!events.iter().any(|event| matches!(
event,
AgentEvent::ToolCallUpdate {
error_category: Some(ToolCallErrorCategory::AbandonedAtLoopExit),
..
}
)),
"a call that already reached a terminal update is not swept at loop exit"
);
}
#[tokio::test(flavor = "current_thread")]
async fn suspend_releases_in_flight_calls_without_a_terminal_update() {
const SESSION_ID: &str = "loop-exit-suspend-test";
if let Ok(mut starts) = TOOL_LIFECYCLE_STARTS.lock() {
starts.clear_session(SESSION_ID);
}
let sink = Arc::new(RecordingSink::default());
let _guard = LoopSinkGuard::install(Some(sink.clone()));
emit_agent_event_sync(&start(SESSION_ID, "call-suspended"));
fire_session_end_hooks(SESSION_ID, false);
let events = sink.events.lock().expect("recorded events");
assert!(
!events.iter().any(|event| matches!(
event,
AgentEvent::ToolCallUpdate {
error_category: Some(ToolCallErrorCategory::AbandonedAtLoopExit),
..
}
)),
"a suspended session must not emit an abandoned terminal update"
);
drop(events);
if let Ok(mut starts) = TOOL_LIFECYCLE_STARTS.lock() {
assert!(
starts.observe(&start(SESSION_ID, "call-suspended")),
"suspend released the in-flight entry"
);
starts.clear_session(SESSION_ID);
}
}
}