diff --git a/rust-core/Cargo.lock b/rust-core/Cargo.lock index 8f3dadaf4..082228bd7 100644 --- a/rust-core/Cargo.lock +++ b/rust-core/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" @@ -14,6 +20,21 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "itoa" version = "1.0.17" @@ -26,6 +47,7 @@ version = "0.1.0" dependencies = [ "libc", "parking_lot", + "pyo3", "serde", "serde_json", "tokio", @@ -52,6 +74,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "parking_lot" version = "0.12.5" @@ -81,6 +118,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -90,6 +133,69 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.45" @@ -108,6 +214,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" @@ -174,6 +286,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tokio" version = "1.50.0" @@ -201,6 +319,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/rust-core/Cargo.toml b/rust-core/Cargo.toml index 083cfcd63..5dc0a41dd 100644 --- a/rust-core/Cargo.toml +++ b/rust-core/Cargo.toml @@ -7,7 +7,12 @@ edition = "2021" name = "langgraph_rust_core" crate-type = ["cdylib", "rlib"] +[features] +default = [] +python-bindings = ["dep:pyo3"] + [dependencies] +pyo3 = { version = "0.23.5", features = ["extension-module"], optional = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" parking_lot = "0.12" diff --git a/rust-core/src/lib.rs b/rust-core/src/lib.rs index 29b471b11..7bffe5eac 100644 --- a/rust-core/src/lib.rs +++ b/rust-core/src/lib.rs @@ -1,2 +1,4 @@ mod engine; mod lib_c; +#[cfg(feature = "python-bindings")] +mod lib_py; diff --git a/rust-core/src/lib_py.rs b/rust-core/src/lib_py.rs new file mode 100644 index 000000000..2d52f6d07 --- /dev/null +++ b/rust-core/src/lib_py.rs @@ -0,0 +1,215 @@ +#[cfg(feature = "python-bindings")] +use crate::engine::{ + parse_callback_envelope_json, run_graph_json_with_callback, run_loop_block_on, AnyOfCondition, + Engine, NodeOutcome, WaitCondition, +}; +#[cfg(feature = "python-bindings")] +use pyo3::exceptions::PyValueError; +#[cfg(feature = "python-bindings")] +use pyo3::prelude::*; +#[cfg(feature = "python-bindings")] +use pyo3::types::PyAny; +#[cfg(feature = "python-bindings")] +use serde_json::Value; + +#[cfg(feature = "python-bindings")] +#[pyclass] +struct PyRustEngine { + inner: Engine, +} + +#[cfg(feature = "python-bindings")] +#[pymethods] +impl PyRustEngine { + #[new] + fn new() -> Self { + Self { + inner: Engine::new(), + } + } + + fn add_async_channel(&self, name: &str) { + self.inner.add_async_channel(name); + } + + fn publish_json(&self, channel: &str, value_json: &str) -> PyResult<()> { + let value: Value = serde_json::from_str(value_json) + .map_err(|e| PyValueError::new_err(format!("Invalid JSON value: {e}")))?; + self.inner + .publish_json(channel, value) + .map_err(PyValueError::new_err) + } + + fn publish_obj(&self, py: Python<'_>, channel: &str, value: Py) -> PyResult<()> { + let value_json = py_obj_to_json_string(py, &value.bind(py))?; + self.publish_json(channel, &value_json) + } + + fn wait_any_of_json(&self, any_of_json: &str) -> PyResult { + let any_of: AnyOfCondition = serde_json::from_str(any_of_json) + .map_err(|e| PyValueError::new_err(format!("Invalid any_of JSON: {e}")))?; + let event = run_loop_block_on(self.inner.wait_for_any_of_async(&any_of)) + .map_err(PyValueError::new_err)?; + serde_json::to_string(&event) + .map_err(|e| PyValueError::new_err(format!("Serialize event failed: {e}"))) + } + + fn wait_channel(&self, py: Python<'_>, channel: &str, n: usize) -> PyResult> { + let cond = WaitCondition::Channel { + channel: channel.to_string(), + n, + }; + let event = + run_loop_block_on(self.inner.wait_for_async(&cond)).map_err(PyValueError::new_err)?; + let event_json = serde_json::to_string(&event) + .map_err(|e| PyValueError::new_err(format!("Serialize event failed: {e}")))?; + json_string_to_py_obj(py, &event_json) + } + + fn wait_timer(&self, py: Python<'_>, seconds: f64) -> PyResult> { + let cond = WaitCondition::Timer { seconds }; + let event = + run_loop_block_on(self.inner.wait_for_async(&cond)).map_err(PyValueError::new_err)?; + let event_json = serde_json::to_string(&event) + .map_err(|e| PyValueError::new_err(format!("Serialize event failed: {e}")))?; + json_string_to_py_obj(py, &event_json) + } + + fn wait_any_of_obj(&self, py: Python<'_>, any_of_payload: Py) -> PyResult> { + let payload_json = py_obj_to_json_string(py, &any_of_payload.bind(py))?; + let event_json = self.wait_any_of_json(&payload_json)?; + json_string_to_py_obj(py, &event_json) + } + + fn wait_condition_json(&self, cond_json: &str) -> PyResult { + let cond: WaitCondition = serde_json::from_str(cond_json) + .map_err(|e| PyValueError::new_err(format!("Invalid condition JSON: {e}")))?; + let event = + run_loop_block_on(self.inner.wait_for_async(&cond)).map_err(PyValueError::new_err)?; + serde_json::to_string(&event) + .map_err(|e| PyValueError::new_err(format!("Serialize event failed: {e}"))) + } + + #[pyo3(signature = (stream_mode=None))] + fn start_stream(&self, stream_mode: Option<&str>) -> PyResult<()> { + self.inner + .start_stream(stream_mode) + .map_err(PyValueError::new_err) + } + + fn receive_stream_obj(&self, py: Python<'_>) -> PyResult> { + match run_loop_block_on(self.inner.receive_stream_async()) { + Some(value) => { + let event_json = serde_json::to_string(&value) + .map_err(|e| PyValueError::new_err(format!("Serialize event failed: {e}")))?; + json_string_to_py_obj(py, &event_json) + } + None => Ok(py.None()), + } + } + + fn send_custom_stream_event_obj(&self, py: Python<'_>, value: Py) -> PyResult<()> { + let value_json = py_obj_to_json_string(py, &value.bind(py))?; + let parsed: Value = serde_json::from_str(&value_json) + .map_err(|e| PyValueError::new_err(format!("Invalid Python JSON value: {e}")))?; + self.inner.send_custom_stream_event(parsed); + Ok(()) + } + + fn close_stream(&self) { + self.inner.close_stream(); + } + + #[pyo3(signature = (entry_point, finish_point, initial_state, callback, stream_mode=None))] + fn run_graph_py( + &self, + py: Python<'_>, + entry_point: &str, + finish_point: &str, + initial_state: Py, + callback: Py, + stream_mode: Option<&str>, + ) -> PyResult> { + let state_json = py_obj_to_json_string(py, &initial_state.bind(py))?; + let initial_state_value: Value = serde_json::from_str(&state_json) + .map_err(|e| PyValueError::new_err(format!("Invalid initial state: {e}")))?; + + self.inner + .start_stream(stream_mode) + .map_err(PyValueError::new_err)?; + + let callback_arc = std::sync::Arc::new(callback); + let run_result = run_loop_block_on(run_graph_json_with_callback( + entry_point.to_string(), + finish_point.to_string(), + initial_state_value.clone(), + initial_state_value, + self.inner.clone(), + move |node: String, + arg: Value, + state_snapshot: Value| + -> Result, String> { + Python::with_gil(|py| -> Result, String> { + let callback_bound = callback_arc.as_ref().bind(py); + let arg_json = serde_json::to_string(&arg) + .map_err(|e| format!("serialize arg failed: {e}"))?; + let state_json = serde_json::to_string(&state_snapshot) + .map_err(|e| format!("serialize state failed: {e}"))?; + let arg_obj = json_string_to_py_obj(py, &arg_json) + .map_err(|e| format!("decode arg failed: {e}"))?; + let state_obj = json_string_to_py_obj(py, &state_json) + .map_err(|e| format!("decode state failed: {e}"))?; + let payload_obj = callback_bound + .call1((node.as_str(), arg_obj, state_obj)) + .map_err(|e| format!("callback failed for `{node}`: {e}"))?; + let payload_json = py_obj_to_json_string(py, &payload_obj) + .map_err(|e| format!("serialize callback payload failed: {e}"))?; + let payload_value: Value = serde_json::from_str(&payload_json) + .map_err(|e| format!("decode callback payload failed: {e}"))?; + let envelope = serde_json::json!({ + "ok": true, + "payload": { + "update": payload_value + .get("update") + .cloned() + .unwrap_or(Value::Null), + "sends": payload_value + .get("sends") + .cloned() + .unwrap_or(Value::Array(vec![])), + }, + "suspend": payload_value.get("suspend").cloned(), + }); + parse_callback_envelope_json(&envelope.to_string(), &node) + }) + }, + )); + self.inner.close_stream(); + + let out = run_result.map_err(PyValueError::new_err)?; + let out_json = serde_json::to_string(&out) + .map_err(|e| PyValueError::new_err(format!("Serialize state failed: {e}")))?; + json_string_to_py_obj(py, &out_json) + } +} + +#[cfg(feature = "python-bindings")] +fn py_obj_to_json_string(py: Python<'_>, obj: &Bound<'_, PyAny>) -> PyResult { + let json_mod = py.import("json")?; + let dumped = json_mod.call_method1("dumps", (obj,))?; + dumped.extract::() +} + +#[cfg(feature = "python-bindings")] +fn json_string_to_py_obj(py: Python<'_>, value: &str) -> PyResult> { + let json_mod = py.import("json")?; + let loaded = json_mod.call_method1("loads", (value,))?; + Ok(loaded.unbind()) +} + +#[cfg(feature = "python-bindings")] +#[pymodule] +fn langgraph_rust_core(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/saf-python-sdk/Cargo.toml b/saf-python-sdk/Cargo.toml index fec8c74b3..f708e859b 100644 --- a/saf-python-sdk/Cargo.toml +++ b/saf-python-sdk/Cargo.toml @@ -8,6 +8,10 @@ name = "langgraph_rust_core" path = "../rust-core/src/lib.rs" crate-type = ["cdylib", "rlib"] +[features] +default = [] +python-bindings = [] + [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0"