Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,242 changes: 1,770 additions & 472 deletions ATTRIBUTIONS-Node.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ or plugin hooks that preserve enough lifecycle fidelity.
| LangChain | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| LangGraph | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| Deep Agents | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| OpenClaw | Yes | Partial | No | Hook-backed telemetry with pre-tool guardrails. Public hooks do not expose managed execution rewrites. |
| OpenClaw | Yes | Yes | Yes | In-process `nemo-relay/*` provider with live lineage. Tool execution intercepts are not available through public hooks. |

The Python `nemo-relay` package ships extras for LangChain, LangGraph, and Deep
Agents:
Expand Down
72 changes: 68 additions & 4 deletions crates/node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,13 +558,19 @@ fn build_atof_config(

static NEXT_STREAM_ID: AtomicU64 = AtomicU64::new(0);

type StreamSender = tokio::sync::mpsc::UnboundedSender<FlowResult<Json>>;
struct StreamEnvelope {
item: FlowResult<Json>,
_permit: Option<tokio::sync::OwnedSemaphorePermit>,
}

type StreamSender = tokio::sync::mpsc::UnboundedSender<StreamEnvelope>;
type RustJsonStream = LlmJsonStream;

struct StreamChannel {
sender: StreamSender,
cancelled: AtomicBool,
closed: tokio::sync::watch::Sender<Option<std::result::Result<(), String>>>,
capacity: Arc<tokio::sync::Semaphore>,
}

static STREAM_CHANNELS: std::sync::LazyLock<StdMutex<HashMap<u64, Arc<StreamChannel>>>> =
Expand All @@ -581,6 +587,7 @@ fn register_stream_channel(
sender: tx,
cancelled: AtomicBool::new(false),
closed,
capacity: Arc::new(tokio::sync::Semaphore::new(LLM_STREAM_BRIDGE_CAPACITY)),
}),
);
closed_rx
Expand Down Expand Up @@ -664,7 +671,7 @@ pub(crate) fn llm_stream_from_rust_stream(rust_stream: RustJsonStream) -> LlmStr
}

struct NodePushStream {
receiver: tokio_stream::wrappers::UnboundedReceiverStream<FlowResult<Json>>,
receiver: tokio_stream::wrappers::UnboundedReceiverStream<StreamEnvelope>,
stream_id: u64,
closed: tokio::sync::watch::Receiver<Option<std::result::Result<(), String>>>,
}
Expand All @@ -673,7 +680,11 @@ impl Stream for NodePushStream {
type Item = FlowResult<Json>;

fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.receiver).poll_next(cx)
match Pin::new(&mut self.receiver).poll_next(cx) {
Poll::Ready(Some(envelope)) => Poll::Ready(Some(envelope.item)),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}

Expand Down Expand Up @@ -709,12 +720,47 @@ impl LlmStreamInner for NodePushStream {
pub fn push_stream_chunk(stream_id: f64, chunk: Json) -> bool {
let id = stream_id as u64;
if let Some(channel) = STREAM_CHANNELS.lock().unwrap().get(&id) {
!channel.cancelled.load(Ordering::Acquire) && channel.sender.send(Ok(chunk)).is_ok()
!channel.cancelled.load(Ordering::Acquire)
&& channel
.sender
.send(StreamEnvelope {
item: Ok(chunk),
_permit: None,
})
.is_ok()
} else {
false
}
}

/// Push a chunk while applying bounded backpressure to a JavaScript producer.
///
/// Resolves to `false` when the consumer has closed or cancelled the stream.
#[napi]
pub async fn push_stream_chunk_async(stream_id: f64, chunk: Json) -> bool {
let id = stream_id as u64;
let channel = STREAM_CHANNELS.lock().unwrap().get(&id).cloned();
let Some(channel) = channel else {
return false;
};
if channel.cancelled.load(Ordering::Acquire) {
return false;
}
let Ok(permit) = channel.capacity.clone().acquire_owned().await else {
return false;
};
if channel.cancelled.load(Ordering::Acquire) {
return false;
}
channel
.sender
.send(StreamEnvelope {
item: Ok(chunk),
_permit: Some(permit),
})
.is_ok()
}

/// Signal that a stream is complete. Drops the sender so the Rust
/// receiver sees the channel as closed.
#[napi]
Expand All @@ -724,6 +770,24 @@ pub fn end_stream(env: Env, stream_id: f64) -> napi::Result<()> {
callback_factory::expire_callback_context(&env)
}

/// Signal that a JavaScript stream producer failed.
///
/// The error is delivered to the managed Relay stream and retained as the
/// producer cleanup result so both iteration and explicit close preserve the
/// original failure instead of treating a broken provider stream as complete.
#[napi]
pub fn fail_stream(env: Env, stream_id: f64, message: String) -> napi::Result<()> {
let id = stream_id as u64;
if let Some(channel) = STREAM_CHANNELS.lock().unwrap().get(&id) {
let _ = channel.sender.send(StreamEnvelope {
item: Err(FlowError::Internal(message.clone())),
_permit: None,
});
}
finish_stream_channel(id, Err(message));
callback_factory::expire_callback_context(&env)
}

/// # Safety
/// Both `env` and `value` must contain valid N-API handles that point to live
/// JavaScript objects in the same environment. The caller must also ensure the
Expand Down
21 changes: 21 additions & 0 deletions crates/node/tests/typed_tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,27 @@ describe('typedLlmStreamExecute', () => {
assert.equal(collected.length, 2);
});

it('preserves a JavaScript producer error through iteration and close', async () => {
const passthrough = new JsonPassthrough();
async function* failingSource() {
yield { token: 'first' };
throw new Error('upstream provider failed');
}
const stream = await typedLlmStreamExecute(
'stream_producer_error',
makeNative(),
failingSource,
() => {},
() => null,
passthrough,
passthrough,
);

assert.deepEqual(await stream.next(), { token: 'first' });
await assert.rejects(() => stream.next(), /upstream provider failed/);
await assert.rejects(() => stream.close(), /upstream provider failed/);
});

it('close waits for async-generator cleanup and exhausts subsequent reads', async () => {
const passthrough = new JsonPassthrough();
let releaseProducer;
Expand Down
11 changes: 9 additions & 2 deletions crates/node/typed.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ async function typedLlmStreamExecute(name, request, func, collector, finalizer,
const req = wrapper.__nemo_relay_native;
const streamId = wrapper.__nemo_relay_stream_id;
(async () => {
let producerError;
try {
iterator = func(req)[Symbol.asyncIterator]();
resolveIterator(iterator);
Expand All @@ -256,17 +257,23 @@ async function typedLlmStreamExecute(name, request, func, collector, finalizer,
if (done) {
break;
}
if (!lib.pushStreamChunk(streamId, chunkJsonCodec.toJson(typedChunk))) {
if (!(await lib.pushStreamChunkAsync(streamId, chunkJsonCodec.toJson(typedChunk)))) {
await iterator.return?.();
break;
}
}
} catch (error) {
producerError = error instanceof Error ? error.message : String(error);
} finally {
resolveIterator(iterator);
try {
await iterator?.return?.();
} finally {
lib.endStream(streamId);
if (producerError === undefined) {
lib.endStream(streamId);
} else {
lib.failStream(streamId, producerError);
}
}
}
})();
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ openclaw gateway restart

Use the package name `nemo-relay-openclaw` for installation. Use the plugin ID
`nemo-relay` in OpenClaw configuration, inspection, and gateway status commands.
See the [OpenClaw Plugin Guide](/supported-integrations/openclaw-plugin) for
Refer to the [OpenClaw Plugin Guide](/supported-integrations/openclaw-plugin) for
configuration and verification steps.

### Hermes Agent
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/quick-start/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ not yet know which guide owns the working path.
| Plugin-managed runtime setup | You need process-level exporter or plugin behavior from `plugins.toml` | [Plugin Configuration Files](/configure-plugins/plugin-configuration-files) | The selected plugin path activates and writes the expected output or behavior. |
| Managed middleware | You want policy, redaction, routing, or execution wrapping around managed calls | [Add Middleware](/instrument-applications/advanced-guide) | One allowed request succeeds, one rejected request stops before execution, and observed payloads match the policy. |
| Framework integrations | A framework such as LangChain, LangGraph, or Deep Agents owns callbacks or scheduling | [Supported Integrations](/supported-integrations/about) | The integration guide's verify step confirms the expected framework-owned output. |
| OpenClaw plugin path | OpenClaw owns plugin setup and Relay observes the OpenClaw-managed boundary | [OpenClaw](/supported-integrations/openclaw-plugin) | The OpenClaw guide's verify step confirms plugin setup and runtime output. |
| OpenClaw plugin path | OpenClaw owns plugin setup and upstream transport while Relay wraps managed model calls in process | [OpenClaw](/supported-integrations/openclaw-plugin) | The OpenClaw guide's verify step confirms provider routing, live lineage, and runtime output. |
| Manual or CI workflows | You need explicit config files, deterministic commands, or non-interactive automation | [CLI Basic Usage](/nemo-relay-cli/basic-usage) and [Plugin Configuration Files](/configure-plugins/plugin-configuration-files) | The explicit command uses the intended config files without interactive setup. |

## Local Coding-Agent Runs
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/support-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ understands NeMo Relay plugin configurations.
| LangChain | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| LangGraph | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| Deep Agents | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| OpenClaw | Yes | Partial | No | Public hook-backed telemetry with pre-tool guardrails. Public hooks do not expose managed execution rewrites. |
| OpenClaw | Yes | Yes | Yes | In-process managed LLM execution and live lineage. Tool execution intercepts are unsupported. |

Install the maintained Python integrations with the `langchain`, `langgraph`,
and `deepagents` extras. Install the OpenClaw integration as the
Expand Down
8 changes: 4 additions & 4 deletions docs/supported-integrations/about.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ security middleware, and optimization features.
| LangChain | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| LangGraph | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| Deep Agents | Yes | Yes | Yes | Wrapped tool and LLM calling. |
| OpenClaw | Yes | Partial | No | Hook-backed telemetry with pre-tool guardrails. Public hooks do not expose managed execution rewrites. |
| OpenClaw | Yes | Yes | Yes | In-process managed LLM execution and live lineage. Tool execution intercepts are unsupported. |

## Guides

Use these guide links to move from the support matrix into setup and usage
instructions.

- [OpenClaw Plugin Guide](/supported-integrations/openclaw-plugin) covers configuring the OpenClaw
plugin, mapping OpenClaw hooks to NeMo Relay telemetry, and understanding
current LLM replay fidelity boundaries.
- [OpenClaw Plugin Guide](/supported-integrations/openclaw-plugin) covers the
in-process provider, `nemo-relay` model routes, live lineage, fallback
capture, and the tool execution limitation.
- [LangChain Integration Guide](/supported-integrations/langchain) covers installing the LangChain
extra and adding NeMo Relay middleware and callbacks to LangChain agents.
- [LangGraph Integration Guide](/supported-integrations/langgraph) covers installing the LangGraph
Expand Down
Loading
Loading