use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, LazyLock, Mutex};
use crate::agent_events::{self, AgentEvent, AgentEventSink};
use crate::mcp::VmMcpClientHandle;
use crate::value::VmValue;
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: BTreeSet<(String, String)>,
}
impl ToolLifecycleStarts {
fn observe(&mut self, event: &AgentEvent) -> bool {
match event {
AgentEvent::ToolCall {
session_id,
tool_call_id,
..
} => {
if tool_call_id.trim().is_empty() {
return true;
}
self.live.insert((session_id.clone(), tool_call_id.clone()))
}
AgentEvent::ToolCallUpdate {
session_id,
tool_call_id,
status:
crate::agent_events::ToolCallStatus::Completed
| crate::agent_events::ToolCallStatus::Failed,
..
} => {
self.live
.remove(&(session_id.clone(), tool_call_id.clone()));
true
}
_ => true,
}
}
fn clear_session(&mut self, session_id: &str) {
self.live
.retain(|(active_session_id, _)| active_session_id != 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) {
TOOL_LIFECYCLE_STARTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear_session(session_id);
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::{ToolCallStatus, ToolMutationStatus};
use serde_json::json;
#[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");
}
}