Skip to content

Commit de8fc3d

Browse files
Zawwarsami16claude
andcommitted
phase 4.2b: true chunked tool_call streaming through SSE
three layers updated: ChatChunk (zhub/brains/base.py) gains optional tool_call_delta field. Either delta (text) or tool_call_delta (openai-shape) per chunk, never both. brain adapters (groq, openai, cerebras) now parse delta.tool_calls arrays from upstream SSE and emit one ChatChunk per tool_call delta in arrival order. text + finish_reason still emit normally. anthropic + ollama unchanged for now (anthropic uses a different shape, ollama doesn't natively support function calling). publisher SDK (_handle_chat in zhub/client.py) now accepts dicts and ChatChunk-shaped objects from chat_handler async-generators in addition to plain strings. _serialize_stream_chunk helper picks the right WS chat-chunk envelope shape; _chunk_to_text extracts just the text fragment for non-streaming accumulation. backward compatible: yielding strings still works as today. hub streaming branch in chat_completions: forwards tool_call_delta as openai-shape SSE chunks (delta.tool_calls). new X-Zhub-Stream-Tools mode `auto`: when finish_reason: tool_calls arrives, hub auto-resolves accumulated tool_calls in parallel, appends role:tool messages, opens a follow-up streaming proxy_chat, continues the SSE stream with the new chunks. bounded at 4 hops. pre-resolve mode (Phase 4.2) untouched. modern OpenAI-streaming clients (Cursor agentic mode, Continue, native openai-py with stream=True) now work fully through zhub including streamed tool calls. tests: - default mode passes tool_call deltas through (no resolve) - auto mode resolves + continues stream with follow-up text - pre-resolve mode (Phase 4.2) sanity check - bumped cli_up test timeout for slow CI hosts 144/144 pytest now. spec at docs/superpowers/specs/2026-05-10-zhub-phase-4.2b-tool-call-streaming-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0f9397a commit de8fc3d

10 files changed

Lines changed: 542 additions & 68 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ CI runs the Python suite on 3.10 / 3.11 / 3.12 plus the JS module test on every
344344

345345
| | What |
346346
|---|---|
347-
| **4.2b** | True chunked tool_call delta streaming through SSE (today: pre-resolve mode) |
347+
| **~~4.2b~~** | True chunked tool_call delta streaming through SSE (default mode passes deltas through; `auto` mode also resolves+continues) |
348348
| **7.1** | Per-exposure access policies (whitelist of AI names / publisher keys) |
349349
| **More brains** | Cohere, Mistral, Together, Bedrock, Vertex, vLLM-direct |
350350
| **MCP resources + prompts** | Surface zhub-served files & prompts to MCP hosts, beyond just tools |

tests/test_cli_up.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,13 @@ async def stream(self, messages, *, system=None, temperature=0.7,
8484

8585
stdout_buf: list[str] = []
8686
api_key = None
87-
deadline = time.time() + 12.0
87+
deadline = time.time() + 20.0
8888
try:
8989
while time.time() < deadline:
90-
line = await asyncio.wait_for(proc.stdout.readline(), timeout=4.0)
90+
try:
91+
line = await asyncio.wait_for(proc.stdout.readline(), timeout=8.0)
92+
except asyncio.TimeoutError:
93+
continue
9194
if not line:
9295
break
9396
text = line.decode().rstrip()

tests/test_tool_call_streaming.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Phase 4.2b — true chunked tool_call delta streaming through SSE.
2+
3+
Three modes:
4+
default — pass tool_call deltas through verbatim, no resolve
5+
X-Zhub-Stream-Tools: pre-resolve — Phase 4.2 buffer-then-resolve (unchanged)
6+
X-Zhub-Stream-Tools: auto — pass through + auto-resolve when
7+
finish_reason: tool_calls arrives,
8+
continue stream with the follow-up
9+
"""
10+
11+
import asyncio
12+
import json
13+
import socket
14+
import threading
15+
import time
16+
17+
import pytest
18+
19+
try:
20+
import fastapi # noqa
21+
import uvicorn # noqa
22+
import httpx # noqa
23+
DEPS_AVAILABLE = True
24+
except ImportError:
25+
DEPS_AVAILABLE = False
26+
27+
if DEPS_AVAILABLE:
28+
from zhub.server import create_app
29+
from zhub import publish, connect
30+
31+
32+
def _free_port() -> int:
33+
with socket.socket() as s:
34+
s.bind(("", 0))
35+
return s.getsockname()[1]
36+
37+
38+
@pytest.fixture(scope="module")
39+
def hub():
40+
if not DEPS_AVAILABLE:
41+
pytest.skip("fastapi/uvicorn/httpx not installed")
42+
port = _free_port()
43+
app = create_app()
44+
45+
def run():
46+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
47+
log_level="warning")
48+
asyncio.run(uvicorn.Server(config).serve())
49+
50+
threading.Thread(target=run, daemon=True).start()
51+
for _ in range(30):
52+
try:
53+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
54+
break
55+
except OSError:
56+
time.sleep(0.1)
57+
yield port
58+
59+
60+
def _parse_sse(body: str) -> list[dict]:
61+
out = []
62+
for line in body.splitlines():
63+
line = line.rstrip("\r")
64+
if not line.startswith("data:"):
65+
continue
66+
payload = line[5:].strip()
67+
if payload == "[DONE]":
68+
continue
69+
try:
70+
out.append(json.loads(payload))
71+
except json.JSONDecodeError:
72+
continue
73+
return out
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_default_mode_passes_tool_call_deltas_through(hub):
78+
"""Publisher (async-gen) yields a tool_call delta dict, then a finish.
79+
Default streaming SSE should contain the tool_call delta + finish_reason
80+
without the hub running auto-resolve."""
81+
82+
async def chat_handler(messages, options):
83+
# Yield a tool_call delta, then a finish marker
84+
yield {
85+
"tool_call_delta": {
86+
"index": 0,
87+
"id": "call_x",
88+
"type": "function",
89+
"function": {"name": "do_thing", "arguments": "{\"a\":1}"},
90+
},
91+
}
92+
yield {"done": True, "finish_reason": "tool_calls"}
93+
94+
pub = publish(
95+
name="stream-tc-bot",
96+
description="phase 4.2b default test",
97+
chat_handler=chat_handler,
98+
hub_url=f"ws://127.0.0.1:{hub}",
99+
)
100+
for _ in range(50):
101+
if pub.api_key:
102+
break
103+
await asyncio.sleep(0.1)
104+
105+
async with httpx.AsyncClient(timeout=5.0) as c:
106+
resp = await c.post(
107+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
108+
json={"messages": [{"role": "user", "content": "go"}],
109+
"stream": True},
110+
headers={"Authorization": f"Bearer {pub.api_key}"},
111+
)
112+
assert resp.status_code == 200
113+
chunks = _parse_sse(resp.text)
114+
assert chunks, f"empty SSE: {resp.text!r}"
115+
116+
# at least one chunk must carry tool_calls in its delta
117+
tcs = [c for c in chunks
118+
if c.get("choices", [{}])[0].get("delta", {}).get("tool_calls")]
119+
assert tcs, f"no tool_call delta SSE chunk found: {chunks!r}"
120+
tcd = tcs[0]["choices"][0]["delta"]["tool_calls"][0]
121+
assert tcd["function"]["name"] == "do_thing"
122+
123+
# final chunk must carry finish_reason: tool_calls
124+
finish = next(
125+
(c["choices"][0].get("finish_reason") for c in reversed(chunks)
126+
if c["choices"][0].get("finish_reason")),
127+
None,
128+
)
129+
assert finish == "tool_calls"
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_auto_mode_resolves_and_continues_stream(hub):
134+
"""Publisher emits tool_call delta + finish:tool_calls on first call,
135+
then plain text on the follow-up. Auto mode should auto-invoke the
136+
connected capability, append role:tool, re-ask publisher, and stream
137+
the follow-up text. Final SSE should carry tool_call deltas THEN the
138+
follow-up text deltas."""
139+
invoke_n = {"n": 0}
140+
call_n = {"n": 0}
141+
142+
async def chat_handler(messages, options):
143+
call_n["n"] += 1
144+
if call_n["n"] == 1:
145+
yield {
146+
"tool_call_delta": {
147+
"index": 0,
148+
"id": "call_a",
149+
"type": "function",
150+
"function": {"name": "auto_thing",
151+
"arguments": json.dumps({"x": 1})},
152+
},
153+
}
154+
yield {"done": True, "finish_reason": "tool_calls"}
155+
else:
156+
# follow-up after tool resolution: stream some text
157+
for piece in ("ok ", "done"):
158+
yield piece
159+
160+
def thing_handler(args):
161+
invoke_n["n"] += 1
162+
return {"got": args, "result": "fired"}
163+
164+
pub = publish(
165+
name="auto-tc-bot",
166+
description="phase 4.2b auto mode",
167+
chat_handler=chat_handler,
168+
hub_url=f"ws://127.0.0.1:{hub}",
169+
)
170+
for _ in range(50):
171+
if pub.api_key:
172+
break
173+
await asyncio.sleep(0.1)
174+
175+
conn = connect(
176+
ai_name=pub.name, api_key=pub.api_key,
177+
hub_url=f"ws://127.0.0.1:{hub}",
178+
capabilities={"auto_thing": ({"type": "object"}, thing_handler)},
179+
)
180+
await asyncio.sleep(0.6)
181+
182+
async with httpx.AsyncClient(timeout=10.0) as c:
183+
resp = await c.post(
184+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
185+
json={"messages": [{"role": "user", "content": "go"}],
186+
"stream": True},
187+
headers={
188+
"Authorization": f"Bearer {pub.api_key}",
189+
"X-Zhub-Stream-Tools": "auto",
190+
},
191+
)
192+
assert resp.status_code == 200
193+
chunks = _parse_sse(resp.text)
194+
assert chunks
195+
196+
# Tool_call deltas must be in the early stream
197+
tc_chunks = [c for c in chunks
198+
if c.get("choices", [{}])[0].get("delta", {}).get("tool_calls")]
199+
assert tc_chunks, f"no tool_call delta seen: {chunks!r}"
200+
201+
# Follow-up text must arrive after resolution
202+
text = "".join(
203+
c["choices"][0].get("delta", {}).get("content", "") or ""
204+
for c in chunks
205+
)
206+
assert "ok" in text and "done" in text, f"missing follow-up text: {text!r}"
207+
208+
# Capability fired exactly once
209+
assert invoke_n["n"] == 1
210+
# Publisher called twice (initial + follow-up)
211+
assert call_n["n"] == 2
212+
213+
# Final finish_reason should be "stop" (the follow-up's)
214+
finish = next(
215+
(c["choices"][0].get("finish_reason") for c in reversed(chunks)
216+
if c["choices"][0].get("finish_reason")),
217+
None,
218+
)
219+
assert finish == "stop"
220+
221+
222+
@pytest.mark.asyncio
223+
async def test_pre_resolve_mode_still_works(hub):
224+
"""Phase 4.2 pre-resolve path is untouched."""
225+
call_n = {"n": 0}
226+
227+
def chat_handler(messages, options):
228+
call_n["n"] += 1
229+
if call_n["n"] == 1:
230+
return {
231+
"text": "",
232+
"tool_calls": [{
233+
"id": "p1",
234+
"type": "function",
235+
"function": {"name": "pre_thing", "arguments": "{}"},
236+
}],
237+
"finish_reason": "tool_calls",
238+
}
239+
return "preresolved final"
240+
241+
pub = publish(
242+
name="pre-tc-bot",
243+
description="phase 4.2 pre-resolve sanity",
244+
chat_handler=chat_handler,
245+
hub_url=f"ws://127.0.0.1:{hub}",
246+
)
247+
for _ in range(50):
248+
if pub.api_key:
249+
break
250+
await asyncio.sleep(0.1)
251+
252+
conn = connect(
253+
ai_name=pub.name, api_key=pub.api_key,
254+
hub_url=f"ws://127.0.0.1:{hub}",
255+
capabilities={"pre_thing": ({"type": "object"}, lambda a: {"ok": True})},
256+
)
257+
await asyncio.sleep(0.6)
258+
259+
async with httpx.AsyncClient(timeout=8.0) as c:
260+
resp = await c.post(
261+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
262+
json={"messages": [{"role": "user", "content": "go"}],
263+
"stream": True},
264+
headers={
265+
"Authorization": f"Bearer {pub.api_key}",
266+
"X-Zhub-Stream-Tools": "pre-resolve",
267+
},
268+
)
269+
assert resp.status_code == 200
270+
text = "".join(
271+
c["choices"][0].get("delta", {}).get("content", "") or ""
272+
for c in _parse_sse(resp.text)
273+
)
274+
assert "preresolved final" in text

zhub/brains/base.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,22 @@ class ChatChunk:
1919
"""One incremental piece of a streaming chat response.
2020
2121
delta: incremental text emitted in this chunk (may be empty when the
22-
chunk only signals end-of-stream).
22+
chunk only signals end-of-stream or carries a tool_call_delta).
2323
done: True on the final chunk.
24-
finish_reason: standard reason string ("stop", "length", etc.) on the
25-
final chunk; None elsewhere.
24+
finish_reason: standard reason string ("stop", "tool_calls", "length",
25+
etc.) on the final chunk; None elsewhere.
26+
tool_call_delta: when set, this chunk represents an incremental
27+
OpenAI-shape tool_call delta (Phase 4.2b). Either delta or
28+
tool_call_delta will be non-empty, not both.
29+
Shape: {index, id?, type?, function: {name?, arguments?}}.
30+
Argument fragments concatenate across multiple chunks.
2631
raw: the underlying API's chunk dict, kept for debugging.
2732
"""
2833

2934
delta: str = ""
3035
done: bool = False
3136
finish_reason: Optional[str] = None
37+
tool_call_delta: Optional[dict] = None
3238
raw: Optional[dict] = None
3339

3440

zhub/brains/cerebras.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,18 @@ async def stream(
102102
if not choices:
103103
continue
104104
choice = choices[0]
105-
delta = (choice.get("delta") or {}).get("content") or ""
105+
delta_obj = choice.get("delta") or {}
106+
content = delta_obj.get("content") or ""
106107
finish = choice.get("finish_reason")
107-
yield ChatChunk(
108-
delta=delta,
109-
done=bool(finish),
110-
finish_reason=finish,
111-
raw=data,
112-
)
108+
tool_call_deltas = delta_obj.get("tool_calls") or []
109+
for tcd in tool_call_deltas:
110+
yield ChatChunk(
111+
delta="", done=False,
112+
tool_call_delta=tcd, raw=data,
113+
)
114+
if content:
115+
yield ChatChunk(delta=content, done=False, raw=data)
113116
if finish:
117+
yield ChatChunk(delta="", done=True,
118+
finish_reason=finish, raw=data)
114119
return

zhub/brains/groq.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,31 @@ async def stream(
100100
if not choices:
101101
continue
102102
choice = choices[0]
103-
delta = (choice.get("delta") or {}).get("content") or ""
103+
delta_obj = choice.get("delta") or {}
104+
content = delta_obj.get("content") or ""
104105
finish = choice.get("finish_reason")
105-
yield ChatChunk(
106-
delta=delta,
107-
done=bool(finish),
108-
finish_reason=finish,
109-
raw=data,
110-
)
106+
tool_call_deltas = delta_obj.get("tool_calls") or []
107+
# OpenAI-shape: each delta.tool_calls element is one
108+
# tool-call's incremental update. Surface each as its own
109+
# ChatChunk so downstream sees them in arrival order.
110+
for tcd in tool_call_deltas:
111+
yield ChatChunk(
112+
delta="",
113+
done=False,
114+
tool_call_delta=tcd,
115+
raw=data,
116+
)
117+
if content:
118+
yield ChatChunk(
119+
delta=content,
120+
done=False,
121+
raw=data,
122+
)
111123
if finish:
124+
yield ChatChunk(
125+
delta="",
126+
done=True,
127+
finish_reason=finish,
128+
raw=data,
129+
)
112130
return

0 commit comments

Comments
 (0)