From 902242006432b859f1e38559737000ca607eb7f4 Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Sun, 31 May 2026 16:46:18 -0700 Subject: [PATCH 1/2] docs: fix Node.js quickstart scope handle and LLM request usage The Node.js quick start and the nemo-relay-node package README passed the informational handle from `withScope` straight into `event`, `toolCallExecute`, and `llmCallExecute`. Those APIs expect either `null` (the current scope) or a real `ScopeHandle`, so the plain handle object failed with "Failed to recover `ScopeHandle` type from napi value" on the first call. The rejected promise then skipped `deregisterSubscriber`, leaving the subscriber's ThreadsafeFunction ref'd and hanging the process. The LLM example also built the request with `new LlmRequest(...)`, but `llmCallExecute` takes a plain `{ headers, content }` JSON object. Update both examples to pass `null` for the active scope and a plain request object, matching the behavior covered by the Node binding tests. Signed-off-by: Zhongxuan Wang --- crates/node/README.md | 3 ++- docs/getting-started/quick-start/nodejs.mdx | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/node/README.md b/crates/node/README.md index 319154b3..e0e9497b 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -77,7 +77,8 @@ async function main() { }); await withScope("demo-agent", ScopeType.Agent, async (handle) => { - event("initialized", handle, { binding: "node" }, null); + // `handle` describes the active scope; pass null to target the current scope. + event("initialized", null, { binding: "node" }, null); }); deregisterSubscriber("printer"); diff --git a/docs/getting-started/quick-start/nodejs.mdx b/docs/getting-started/quick-start/nodejs.mdx index 7807d164..66327574 100644 --- a/docs/getting-started/quick-start/nodejs.mdx +++ b/docs/getting-started/quick-start/nodejs.mdx @@ -44,7 +44,6 @@ const { registerSubscriber, deregisterSubscriber, flushSubscribers, - LlmRequest, withScope, event, toolCallExecute, @@ -57,13 +56,15 @@ async function main() { }); await withScope("demo-agent", ScopeType.Agent, async (handle) => { - event("initialized", handle, { binding: "node" }, null); + // `handle` describes the active scope (handle.uuid, handle.name, handle.scopeType). + // For lifecycle calls inside the scope, pass null to target the current scope. + event("initialized", null, { binding: "node" }, null); const toolResult = await toolCallExecute( "search", { query: "hello" }, (args) => ({ echo: args.query }), - handle, + null, null, null, null, @@ -71,9 +72,9 @@ async function main() { const llmResult = await llmCallExecute( "demo-provider", - new LlmRequest({}, { messages: [{ role: "user", content: "hi" }] }), + { headers: {}, content: { messages: [{ role: "user", content: "hi" }] } }, (request) => ({ ok: true, messages: request.content.messages }), - handle, + null, null, null, null, From 69cbb2144a147e24efffd90a979dc95a1240560a Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Tue, 2 Jun 2026 10:44:36 -0700 Subject: [PATCH 2/2] fix: pass a real ScopeHandle to Node withScope callbacks Node's withScope handed its callback a plain JSON object instead of a real ScopeHandle, so passing it back into event, toolCallExecute, or llmCallExecute threw "Failed to recover ScopeHandle type from napi value" on the first call, and the skipped deregisterSubscriber left the process hung. Materialize a real ScopeHandle on the JS thread inside the threadsafe-function call (via a new Arg0Builder path in PromiseAwareFn) so the callback receives a usable handle, matching the Rust, Python, and WebAssembly bindings. Keep the Node quick start passing the handle and send the LLM request as a plain { headers, content } object, and add a Node test that reuses the callback handle across event, pushScope, toolCallExecute, and llmCallExecute. Signed-off-by: Zhongxuan Wang --- crates/node/README.md | 3 +- crates/node/src/api/mod.rs | 31 +++++++------ crates/node/src/promise_call.rs | 51 ++++++++++++++++----- crates/node/tests/scope_tests.mjs | 42 +++++++++++++++++ docs/getting-started/quick-start/nodejs.mdx | 8 ++-- 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/crates/node/README.md b/crates/node/README.md index e0e9497b..319154b3 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -77,8 +77,7 @@ async function main() { }); await withScope("demo-agent", ScopeType.Agent, async (handle) => { - // `handle` describes the active scope; pass null to target the current scope. - event("initialized", null, { binding: "node" }, null); + event("initialized", handle, { binding: "node" }, null); }); deregisterSubscriber("printer"); diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index ca1602ad..b3930001 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -1310,10 +1310,12 @@ pub fn with_scope( let scope_stack = current_scope_stack_handle(); let scope_uuid = scope_handle.inner.uuid; - let scope_name = scope_handle.inner.name.clone(); - let scope_type_int: u32 = ScopeType::from(scope_handle.inner.scope_type) as u32; - let scope_attrs = scope_handle.inner.attributes.bits(); - let scope_parent_uuid = scope_handle.inner.parent_uuid.map(|u| u.to_string()); + // Hand the callback a real `ScopeHandle` instance, matching the Rust, + // Python, and WebAssembly bindings, so it can be passed back into `event`, + // `toolCallExecute`, and `llmCallExecute`. The instance is materialized on + // the JS thread because a `napi_wrap`'d handle cannot cross the + // threadsafe-function boundary as plain JSON. + let callback_handle = scope_handle.inner.clone(); // Create a promise-aware wrapper so we handle both sync and async callbacks. let pa_fn = std::sync::Arc::new( @@ -1332,15 +1334,18 @@ pub fn with_scope( async move { TASK_SCOPE_STACK .scope(scope_stack, async move { - let handle_json = serde_json::json!({ - "uuid": scope_uuid.to_string(), - "name": scope_name, - "scopeType": scope_type_int, - "attributes": scope_attrs, - "parentUuid": scope_parent_uuid, - }); - - let result = pa_fn.call(handle_json).await; + let build_handle: crate::promise_call::Arg0Builder = + Box::new(move |env: &Env| { + let raw = unsafe { + ::to_napi_value( + env.raw(), + ScopeHandle::from(callback_handle), + )? + }; + Ok(unsafe { JsUnknown::from_raw_unchecked(env.raw(), raw) }) + }); + + let result = pa_fn.call_with_arg0(build_handle).await; // Always pop the scope, even on error. if core_scope_api::pop_scope( core_scope_api::PopScopeParams::builder() diff --git a/crates/node/src/promise_call.rs b/crates/node/src/promise_call.rs index 82a461a4..92c51cc6 100644 --- a/crates/node/src/promise_call.rs +++ b/crates/node/src/promise_call.rs @@ -33,8 +33,24 @@ enum NextFn { Stream(JsonStreamNextFn), } +/// Builds the first JS callback argument on the Node main thread. +/// +/// Some callback arguments, such as `#[napi]` class instances, cannot cross the +/// threadsafe-function boundary as plain JSON. This builder runs inside the +/// threadsafe-function call (on the JS thread), so it can materialize those +/// values directly instead of serializing them. +pub type Arg0Builder = Box napi::Result + Send>; + +/// The first argument passed to the wrapped JS callback. +enum PrimaryArg { + /// A plain JSON value converted on the JS thread. + Json(Json), + /// A value materialized on the JS thread by a builder closure. + Build(Arg0Builder), +} + struct CallArgs { - args: Json, + arg0: PrimaryArg, next: Option, completion: CallCompletion, } @@ -197,13 +213,12 @@ impl PromiseAwareFn { None => undefined_to_unknown(&ctx.env)?, }; let (resolve, reject) = build_completion_unknowns(&ctx.env, ctx.value.completion)?; + let arg0 = match ctx.value.arg0 { + PrimaryArg::Json(value) => json_to_unknown(&ctx.env, value)?, + PrimaryArg::Build(build) => build(&ctx.env)?, + }; - let args = vec![ - json_to_unknown(&ctx.env, ctx.value.args)?, - next, - resolve, - reject, - ]; + let args = vec![arg0, next, resolve, reject]; Ok(args) })?; @@ -217,13 +232,24 @@ impl PromiseAwareFn { /// Call the JS function with the given args and await the result. pub async fn call(&self, args: Json) -> FlowResult { - self.call_inner(args, None).await + self.call_inner(PrimaryArg::Json(args), None).await + } + + /// Call the JS function with a builder-constructed first argument and await + /// the result. + /// + /// The builder runs on the Node main thread, so it can construct values that + /// cannot cross the threadsafe-function boundary as plain JSON, such as a + /// `#[napi]` class instance. + pub async fn call_with_arg0(&self, build_arg0: Arg0Builder) -> FlowResult { + self.call_inner(PrimaryArg::Build(build_arg0), None).await } /// Call the JS function with a middleware-style `next(arg)` callback that /// resolves to a JSON result. pub async fn call_with_json_next(&self, args: Json, next: JsonNextFn) -> FlowResult { - self.call_inner(args, Some(NextFn::Json(next))).await + self.call_inner(PrimaryArg::Json(args), Some(NextFn::Json(next))) + .await } /// Call the JS function with a middleware-style `next(arg)` callback that @@ -233,7 +259,8 @@ impl PromiseAwareFn { args: Json, next: JsonStreamNextFn, ) -> FlowResult { - self.call_inner(args, Some(NextFn::Stream(next))).await + self.call_inner(PrimaryArg::Json(args), Some(NextFn::Stream(next))) + .await } /// Release the underlying threadsafe function so it does not outlive its registration. @@ -243,7 +270,7 @@ impl PromiseAwareFn { } } - async fn call_inner(&self, args: Json, next: Option) -> FlowResult { + async fn call_inner(&self, arg0: PrimaryArg, next: Option) -> FlowResult { let (sender, receiver) = tokio::sync::oneshot::channel(); let tsfn = self .tsfn @@ -254,7 +281,7 @@ impl PromiseAwareFn { .ok_or_else(closed_tsfn_error)?; let status = tsfn.call( Ok(CallArgs { - args, + arg0, next, completion: CallCompletion::new(sender), }), diff --git a/crates/node/tests/scope_tests.mjs b/crates/node/tests/scope_tests.mjs index d571ebd3..743204a3 100644 --- a/crates/node/tests/scope_tests.mjs +++ b/crates/node/tests/scope_tests.mjs @@ -14,6 +14,8 @@ const { popScope, event, withScope, + toolCallExecute, + llmCallExecute, registerSubscriber, deregisterSubscriber, flushSubscribers, @@ -118,6 +120,46 @@ describe('withScope', () => { assert.equal(after.uuid, before.uuid, 'scope should be popped after withScope'); }); + it('callback receives a reusable ScopeHandle', async () => { + let toolResult; + let llmResult; + let childParentUuid; + await withScope('reusable_handle', ScopeType.Agent, async (handle) => { + // The handle is a real ScopeHandle: usable as an event target, + const handleUuid = handle.uuid; + event('inside', handle, { ok: true }, null); + + // as an explicit parent for child scopes, + const child = pushScope('child', ScopeType.Function, handle, null); + childParentUuid = child.parentUuid; + popScope(child); + + // and as the scope target for managed tool/LLM execution. + toolResult = await toolCallExecute( + 'search', + { query: 'hello' }, + (args) => ({ echo: args.query }), + handle, + null, + null, + null, + ); + llmResult = await llmCallExecute( + 'demo-provider', + { headers: {}, content: { messages: [{ role: 'user', content: 'hi' }] } }, + (request) => ({ ok: true, messages: request.content.messages }), + handle, + null, + null, + null, + null, + ); + assert.equal(childParentUuid, handleUuid, 'child scope should record the handle as its parent'); + }); + assert.deepEqual(toolResult, { echo: 'hello' }); + assert.deepEqual(llmResult, { ok: true, messages: [{ role: 'user', content: 'hi' }] }); + }); + it('returns callback result', async () => { const result = await withScope('result_test', ScopeType.Function, () => { return { diff --git a/docs/getting-started/quick-start/nodejs.mdx b/docs/getting-started/quick-start/nodejs.mdx index 66327574..b6e59ccd 100644 --- a/docs/getting-started/quick-start/nodejs.mdx +++ b/docs/getting-started/quick-start/nodejs.mdx @@ -56,15 +56,13 @@ async function main() { }); await withScope("demo-agent", ScopeType.Agent, async (handle) => { - // `handle` describes the active scope (handle.uuid, handle.name, handle.scopeType). - // For lifecycle calls inside the scope, pass null to target the current scope. - event("initialized", null, { binding: "node" }, null); + event("initialized", handle, { binding: "node" }, null); const toolResult = await toolCallExecute( "search", { query: "hello" }, (args) => ({ echo: args.query }), - null, + handle, null, null, null, @@ -74,7 +72,7 @@ async function main() { "demo-provider", { headers: {}, content: { messages: [{ role: "user", content: "hi" }] } }, (request) => ({ ok: true, messages: request.content.messages }), - null, + handle, null, null, null,