juncture 0.1.0

Typed state machine framework for LLM agents - Rust implementation of LangGraph
Documentation
//! Tool runtime context for stateful tool execution

use std::sync::Arc;

use juncture_core::{config::RunnableConfig, state::State, store::Store, stream::ToolsEvent};

/// Runtime context for stateful tool execution
///
/// Provides tools with access to execution context including
/// the current state, tool call metadata, configuration, and
/// optional cross-thread persistent store.
///
/// # Type Parameters
///
/// * `S` - The state type (must implement [`State`])
///
/// # Example
///
/// ```ignore
/// use juncture::tools::{StatefulTool, ToolRuntime};
/// use juncture_core::{State, RunnableConfig};
/// use async_trait::async_trait;
///
/// struct MyStatefulTool;
///
/// #[async_trait]
/// impl<S: State + 'static> StatefulTool<S> for MyStatefulTool {
///     async fn invoke_with_runtime(
///         &self,
///         input: serde_json::Value,
///         runtime: &ToolRuntime<S>,
///     ) -> Result<String, ToolError> {
///         // Access state
///         let state = &runtime.state;
///         let config = runtime.config();
///
///         // Access store if available
///         if let Some(store) = runtime.store() {
///             let item = store.get("namespace", "key").await?;
///         }
///
///         Ok("Result".to_string())
///     }
/// }
/// ```
pub struct ToolRuntime<S: State> {
    /// Current state snapshot
    pub state: S,

    /// Tool call ID being executed
    pub tool_call_id: String,

    /// Execution configuration
    pub config: RunnableConfig,

    /// Optional cross-thread persistent store for long-term memory
    pub store: Option<Arc<dyn Store>>,

    /// Optional sender for tool lifecycle streaming events.
    ///
    /// When set, [`emit_output_delta`](Self::emit_output_delta) sends
    /// [`ToolsEvent::ToolOutputDelta`] events through this channel.
    tools_event_tx: Option<tokio::sync::mpsc::UnboundedSender<ToolsEvent>>,
}

impl<S: State> ToolRuntime<S> {
    /// Create a new tool runtime with all fields
    #[must_use]
    pub fn new(
        state: S,
        tool_call_id: String,
        config: RunnableConfig,
        store: Option<Arc<dyn Store>>,
    ) -> Self {
        Self {
            state,
            tool_call_id,
            config,
            store,
            tools_event_tx: None,
        }
    }

    /// Create a new tool runtime without a store
    #[must_use]
    pub fn new_without_store(state: S, tool_call_id: String, config: RunnableConfig) -> Self {
        Self {
            state,
            tool_call_id,
            config,
            store: None,
            tools_event_tx: None,
        }
    }

    /// Attach a tool event sender for streaming output deltas.
    #[must_use]
    pub fn with_tools_event_tx(
        mut self,
        tx: tokio::sync::mpsc::UnboundedSender<ToolsEvent>,
    ) -> Self {
        self.tools_event_tx = Some(tx);
        self
    }

    /// Get a reference to the state
    #[must_use]
    pub const fn state(&self) -> &S {
        &self.state
    }

    /// Get the tool call ID
    #[must_use]
    pub fn tool_call_id(&self) -> &str {
        &self.tool_call_id
    }

    /// Get the configuration
    #[must_use]
    pub const fn config(&self) -> &RunnableConfig {
        &self.config
    }

    /// Get the optional store
    #[must_use]
    pub const fn store(&self) -> Option<&Arc<dyn Store>> {
        self.store.as_ref()
    }

    /// Emit an incremental output delta during tool execution.
    ///
    /// Sends a [`ToolsEvent::ToolOutputDelta`] through the tools event channel
    /// when one is configured. Falls back to debug logging when no channel
    /// is available (e.g., in test contexts).
    pub fn emit_output_delta(&self, delta: &str) {
        if let Some(ref tx) = self.tools_event_tx {
            let event = ToolsEvent::ToolOutputDelta {
                tool_call_id: self.tool_call_id.clone(),
                delta: delta.to_string(),
            };
            let _ = tx.send(event);
        }
        tracing::debug!(
            tool_call_id = %self.tool_call_id,
            delta_len = delta.len(),
            "tool output delta emitted"
        );
    }
}

impl<S: State> Clone for ToolRuntime<S> {
    fn clone(&self) -> Self {
        Self {
            state: self.state.clone(),
            tool_call_id: self.tool_call_id.clone(),
            config: self.config.clone(),
            store: self.store.clone(),
            tools_event_tx: self.tools_event_tx.clone(),
        }
    }
}

impl<S: State> std::fmt::Debug for ToolRuntime<S> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ToolRuntime")
            .field("tool_call_id", &self.tool_call_id)
            .field("config", &self.config)
            .field(
                "store",
                &self.store.as_ref().map_or("None", |_| "Some(...)"),
            )
            .field(
                "tools_event_tx",
                &self.tools_event_tx.as_ref().map_or("None", |_| "Some(...)"),
            )
            .field("state", &std::any::type_name::<S>())
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use juncture_core::state::FieldsChanged;

    // Dummy State for testing
    #[derive(Clone, Debug, Default)]
    struct TestState;

    impl juncture_core::State for TestState {
        type Update = TestStateUpdate;
        type FieldVersions = juncture_core::state::FieldVersions;

        fn apply(&mut self, _update: Self::Update) -> FieldsChanged {
            FieldsChanged(0)
        }

        fn reset_ephemeral(&mut self) {}
    }

    #[derive(Clone, Debug, Default)]
    struct TestStateUpdate;

    #[test]
    fn test_tool_runtime_new() {
        let state = TestState;
        let config = RunnableConfig::default();
        let runtime = ToolRuntime::new_without_store(state, "call_123".to_string(), config);

        assert_eq!(runtime.tool_call_id, "call_123");
    }

    #[test]
    fn test_tool_runtime_accessors() {
        let state = TestState;
        let config = RunnableConfig::default();
        let runtime = ToolRuntime::new_without_store(state, "call_123".to_string(), config);

        assert_eq!(runtime.tool_call_id(), "call_123");
    }

    #[test]
    fn test_tool_runtime_clone() {
        let state = TestState;
        let config = RunnableConfig::default();
        let runtime = ToolRuntime::new_without_store(state, "call_123".to_string(), config);

        let cloned = runtime.clone();
        assert_eq!(cloned.tool_call_id, runtime.tool_call_id);
    }

    #[test]
    fn test_tool_runtime_debug() {
        let state = TestState;
        let config = RunnableConfig::default();
        let runtime = ToolRuntime::new_without_store(state, "call_123".to_string(), config);

        let debug_str = format!("{runtime:?}");
        assert!(debug_str.contains("ToolRuntime"));
        assert!(debug_str.contains("call_123"));
    }

    #[test]
    fn test_emit_output_delta_does_not_panic() {
        let state = TestState;
        let config = RunnableConfig::default();
        let runtime = ToolRuntime::new_without_store(state, "call_456".to_string(), config);

        runtime.emit_output_delta("partial result chunk");
        runtime.emit_output_delta("");
        runtime.emit_output_delta("another delta");
    }

    #[test]
    fn test_emit_output_delta_with_tx_sends_event() {
        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        let state = TestState;
        let config = RunnableConfig::default();
        let runtime = ToolRuntime::new_without_store(state, "call_delta".to_string(), config)
            .with_tools_event_tx(tx);

        runtime.emit_output_delta("delta_chunk");

        let event = rx.try_recv().expect("should receive ToolOutputDelta");
        match event {
            juncture_core::stream::ToolsEvent::ToolOutputDelta {
                tool_call_id,
                delta,
            } => {
                assert_eq!(tool_call_id, "call_delta");
                assert_eq!(delta, "delta_chunk");
            }
            other => panic!("expected ToolOutputDelta, got {other:?}"),
        }
    }
}

// Rust guideline compliant 2026-05-22