Skip to content

Commit 4959e55

Browse files
ZhiXiao-Linclaude
andcommitted
feat(sdk): add true streaming to Node.js and Python SDKs
Node.js: - Replace broken Vec<AgentEvent> stream() with EventStream struct - Add EventStream.next() → Promise<{ value, done }> - Add Symbol.asyncIterator in index.js for `for await...of` support - Export EventStream from module Python: - Change done: bool → Arc<AtomicBool> in PyEventStream - Add __aiter__ and __anext__ via run_in_executor bridge - Add BlockingRecv callable for thread-pool async integration - Both sync `for event in stream` and async `async for event in stream` work Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 0a7dc72 commit 4959e55

3 files changed

Lines changed: 178 additions & 36 deletions

File tree

sdk/node/index.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,22 @@ if (!nativeBinding) {
310310
throw new Error(`Failed to load native binding`)
311311
}
312312

313-
const { Agent, Session, builtinSkills } = nativeBinding
313+
const { Agent, Session, EventStream, builtinSkills } = nativeBinding
314+
315+
// Add Symbol.asyncIterator to EventStream for `for await...of` support.
316+
// Each iteration calls .next() until done === true.
317+
EventStream.prototype[Symbol.asyncIterator] = async function* () {
318+
while (true) {
319+
const { value, done } = await this.next()
320+
if (done) {
321+
if (value != null) yield value
322+
return
323+
}
324+
yield value
325+
}
326+
}
314327

315328
module.exports.Agent = Agent
316329
module.exports.Session = Session
330+
module.exports.EventStream = EventStream
317331
module.exports.builtinSkills = builtinSkills

sdk/node/src/lib.rs

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ use a3s_code_core::{
3434
use a3s_code_core::{
3535
Agent as RustAgent, AgentSession as RustAgentSession, SessionOptions as RustSessionOptions,
3636
};
37-
use std::sync::Arc;
37+
use std::sync::{
38+
atomic::{AtomicBool, Ordering},
39+
Arc,
40+
};
3841
use tokio::runtime::Runtime;
3942

4043
// ============================================================================
@@ -179,6 +182,65 @@ pub struct ToolResult {
179182
pub exit_code: i32,
180183
}
181184

185+
// ============================================================================
186+
// EventStream
187+
// ============================================================================
188+
189+
/// Result of a single `EventStream.next()` call.
190+
#[napi(object)]
191+
#[derive(Clone)]
192+
pub struct NextResult {
193+
pub value: Option<AgentEvent>,
194+
pub done: bool,
195+
}
196+
197+
/// Streaming event iterator. Use `for await (const event of stream)` or call `.next()` manually.
198+
#[napi]
199+
pub struct EventStream {
200+
rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<RustAgentEvent>>>,
201+
done: Arc<AtomicBool>,
202+
}
203+
204+
#[napi]
205+
impl EventStream {
206+
/// Get the next event from the stream.
207+
///
208+
/// Returns `{ value: AgentEvent | null, done: boolean }`.
209+
/// When `done` is true, the stream is exhausted.
210+
#[napi]
211+
pub async fn next(&self) -> napi::Result<NextResult> {
212+
if self.done.load(Ordering::Relaxed) {
213+
return Ok(NextResult { value: None, done: true });
214+
}
215+
let rx = self.rx.clone();
216+
let done_flag = self.done.clone();
217+
let result = get_runtime()
218+
.spawn(async move {
219+
let mut guard = rx.lock().await;
220+
guard.recv().await
221+
})
222+
.await
223+
.map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))?;
224+
match result {
225+
Some(event) => {
226+
let is_end = matches!(event, RustAgentEvent::End { .. });
227+
let is_error = matches!(event, RustAgentEvent::Error { .. });
228+
let js_event = AgentEvent::from(event);
229+
if is_end || is_error {
230+
done_flag.store(true, Ordering::Relaxed);
231+
Ok(NextResult { value: Some(js_event), done: true })
232+
} else {
233+
Ok(NextResult { value: Some(js_event), done: false })
234+
}
235+
}
236+
None => {
237+
done_flag.store(true, Ordering::Relaxed);
238+
Ok(NextResult { value: None, done: true })
239+
}
240+
}
241+
}
242+
}
243+
182244
// ============================================================================
183245
// SessionOptions
184246
// ============================================================================
@@ -447,7 +509,9 @@ impl Session {
447509
Ok(AgentResult::from(result))
448510
}
449511

450-
/// Send a prompt and get a stream of events.
512+
/// Send a prompt and get a streaming event iterator.
513+
///
514+
/// Returns an `EventStream`. Use `for await (const event of stream)` or call `.next()` manually.
451515
///
452516
/// @param prompt - The prompt to send
453517
/// @param history - Optional conversation history
@@ -456,7 +520,7 @@ impl Session {
456520
&self,
457521
prompt: String,
458522
history: Option<Vec<MessageObject>>,
459-
) -> napi::Result<Vec<AgentEvent>> {
523+
) -> napi::Result<EventStream> {
460524
let rust_history = history
461525
.map(|h| js_messages_to_rust(&h))
462526
.transpose()?;
@@ -469,27 +533,10 @@ impl Session {
469533
.map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))?
470534
.map_err(|e| napi::Error::from_reason(format!("Failed to start stream: {e}")))?;
471535

472-
let rx_arc: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<RustAgentEvent>>> =
473-
Arc::new(tokio::sync::Mutex::new(rx));
474-
475-
let collected = get_runtime()
476-
.spawn(async move {
477-
let mut guard = rx_arc.lock().await;
478-
let mut events: Vec<AgentEvent> = Vec::new();
479-
while let Some(event) = guard.recv().await {
480-
let is_end = matches!(event, RustAgentEvent::End { .. });
481-
let is_error = matches!(event, RustAgentEvent::Error { .. });
482-
events.push(AgentEvent::from(event));
483-
if is_end || is_error {
484-
break;
485-
}
486-
}
487-
events
488-
})
489-
.await
490-
.map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))?;
491-
492-
Ok(collected)
536+
Ok(EventStream {
537+
rx: Arc::new(tokio::sync::Mutex::new(rx)),
538+
done: Arc::new(AtomicBool::new(false)),
539+
})
493540
}
494541

495542
/// Return the session's conversation history.

sdk/python/src/lib.rs

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@ use a3s_code_core::{
3131
use a3s_code_core::{
3232
Agent as RustAgent, AgentSession as RustAgentSession, SessionOptions as RustSessionOptions,
3333
};
34-
use pyo3::exceptions::{PyRuntimeError, PyStopIteration, PyTypeError, PyValueError};
34+
use pyo3::exceptions::{PyRuntimeError, PyStopAsyncIteration, PyStopIteration, PyTypeError, PyValueError};
3535
use pyo3::prelude::*;
3636
use pyo3::types::{PyDict, PyList};
37-
use std::sync::Arc;
37+
use std::sync::{
38+
atomic::{AtomicBool, Ordering},
39+
Arc,
40+
};
3841
use tokio::runtime::Runtime;
3942
use tokio::sync::Mutex;
4043

@@ -261,32 +264,79 @@ impl PyToolResult {
261264
}
262265

263266
// ============================================================================
264-
// EventStream (Python Iterator)
267+
// EventStream (Python Iterator + Async Iterator)
265268
// ============================================================================
266269

267-
/// Iterator that yields AgentEvents from a streaming execution.
270+
/// One-shot callable used by `run_in_executor` for async iteration.
271+
///
272+
/// Each `__anext__` call creates a new instance; `__call__` blocks on the
273+
/// next channel receive and raises `StopAsyncIteration` when done.
274+
#[pyclass]
275+
struct BlockingRecv {
276+
rx: Arc<Mutex<tokio::sync::mpsc::Receiver<RustAgentEvent>>>,
277+
done: Arc<AtomicBool>,
278+
}
279+
280+
#[pymethods]
281+
impl BlockingRecv {
282+
fn __call__(&self, py: Python<'_>) -> PyResult<PyAgentEvent> {
283+
let rx = self.rx.clone();
284+
let done_flag = self.done.clone();
285+
let result = py.allow_threads(|| {
286+
get_runtime().block_on(async {
287+
let mut guard = rx.lock().await;
288+
guard.recv().await
289+
})
290+
});
291+
match result {
292+
Some(event) => {
293+
let is_end = matches!(event, RustAgentEvent::End { .. });
294+
let is_error = matches!(event, RustAgentEvent::Error { .. });
295+
let py_event = PyAgentEvent::from(event);
296+
if is_end || is_error {
297+
done_flag.store(true, Ordering::Relaxed);
298+
}
299+
Ok(py_event)
300+
}
301+
None => {
302+
done_flag.store(true, Ordering::Relaxed);
303+
Err(PyStopAsyncIteration::new_err("stream exhausted"))
304+
}
305+
}
306+
}
307+
}
308+
309+
/// Iterator / async-iterator that yields AgentEvents from a streaming execution.
310+
///
311+
/// Sync usage: `for event in session.stream(prompt):`
312+
/// Async usage: `async for event in session.stream(prompt):`
268313
#[pyclass(name = "EventStream")]
269314
struct PyEventStream {
270315
rx: Arc<Mutex<tokio::sync::mpsc::Receiver<RustAgentEvent>>>,
271-
done: bool,
316+
done: Arc<AtomicBool>,
272317
}
273318

274319
#[pymethods]
275320
impl PyEventStream {
321+
// ------------------------------------------------------------------
322+
// Sync iterator protocol
323+
// ------------------------------------------------------------------
324+
276325
fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
277326
slf
278327
}
279328

280329
fn __next__(&mut self, py: Python<'_>) -> PyResult<Option<PyAgentEvent>> {
281-
if self.done {
330+
if self.done.load(Ordering::Relaxed) {
282331
return Err(PyStopIteration::new_err("stream exhausted"));
283332
}
284333

285334
let rx = self.rx.clone();
335+
let done_flag = self.done.clone();
286336
let result = py.allow_threads(|| {
287337
get_runtime().block_on(async {
288-
let mut rx = rx.lock().await;
289-
rx.recv().await
338+
let mut guard = rx.lock().await;
339+
guard.recv().await
290340
})
291341
});
292342

@@ -296,16 +346,47 @@ impl PyEventStream {
296346
let is_error = matches!(event, RustAgentEvent::Error { .. });
297347
let py_event = PyAgentEvent::from(event);
298348
if is_end || is_error {
299-
self.done = true;
349+
done_flag.store(true, Ordering::Relaxed);
300350
}
301351
Ok(Some(py_event))
302352
}
303353
None => {
304-
self.done = true;
354+
done_flag.store(true, Ordering::Relaxed);
305355
Err(PyStopIteration::new_err("stream exhausted"))
306356
}
307357
}
308358
}
359+
360+
// ------------------------------------------------------------------
361+
// Async iterator protocol
362+
// ------------------------------------------------------------------
363+
364+
fn __aiter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
365+
slf
366+
}
367+
368+
/// Returns an `asyncio.Future` that resolves to the next `AgentEvent`.
369+
///
370+
/// Uses `run_in_executor` to bridge the blocking channel recv into an
371+
/// asyncio-compatible awaitable without requiring `pyo3-async`.
372+
fn __anext__<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
373+
if self.done.load(Ordering::Relaxed) {
374+
return Err(PyStopAsyncIteration::new_err("stream exhausted"));
375+
}
376+
377+
let callable = Bound::new(
378+
py,
379+
BlockingRecv {
380+
rx: self.rx.clone(),
381+
done: self.done.clone(),
382+
},
383+
)?;
384+
385+
let asyncio = py.import("asyncio")?;
386+
let loop_ = asyncio.call_method0("get_running_loop")?;
387+
let future = loop_.call_method1("run_in_executor", (py.None(), callable))?;
388+
Ok(future)
389+
}
309390
}
310391

311392
// ============================================================================
@@ -491,7 +572,7 @@ impl PySession {
491572

492573
Ok(PyEventStream {
493574
rx: Arc::new(Mutex::new(rx)),
494-
done: false,
575+
done: Arc::new(AtomicBool::new(false)),
495576
})
496577
}
497578

0 commit comments

Comments
 (0)