Skip to content

Commit dc1aee6

Browse files
committed
fix(brains): forward tool calls through the publisher hand-off
The default chat_handler in both publisher entry points (zhub up and the multi_brain_publisher example) iterated the brain's stream and yielded only chunk.delta, silently dropping every tool_call_delta and the finish_reason on a tool-call turn (the brain emits the call as chunks whose text delta is empty). The hub keys tool-call auto-resolution off accumulated tool_call deltas plus a finish_reason=="tool_calls" terminator, so a brain served this way could never resolve a capability even though every adapter surfaces the call correctly. Add brains.stream_for_publish() as the single source of truth for the hand-off: it yields the whole ChatChunk whenever it carries a delta, a tool_call_delta, or a finish_reason. The publisher serializers already understand ChatChunk on both the streaming and non-streaming paths.
1 parent d8ecfe2 commit dc1aee6

3 files changed

Lines changed: 43 additions & 11 deletions

File tree

examples/multi_brain_publisher.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import sys
2626

2727
from zhub import publish
28-
from zhub.brains import detect, list_available, REGISTRY
28+
from zhub.brains import detect, list_available, REGISTRY, stream_for_publish
2929

3030

3131
def _resolve_brain(name: str):
@@ -116,15 +116,15 @@ async def chat_handler(messages, options):
116116
if m.get("role") == "system"]
117117
non_system = [m for m in messages if m.get("role") != "system"]
118118
system = "\n\n".join(p for p in system_parts if p) or None
119-
async for chunk in brain.stream(
119+
async for chunk in stream_for_publish(
120+
brain,
120121
non_system,
121122
system=system,
122123
temperature=float(options.get("temperature", 0.7)),
123124
max_tokens=int(options.get("max_tokens", 2048)),
124125
tools=options.get("tools"),
125126
):
126-
if chunk.delta:
127-
yield chunk.delta
127+
yield chunk
128128

129129
pub = publish(
130130
name=args.name,

zhub/brains/__init__.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from __future__ import annotations
1515

16-
from typing import Optional
16+
from typing import Any, AsyncIterator, Optional
1717

1818
from .base import BrainAdapter, ChatChunk
1919
from .ollama import OllamaAdapter
@@ -50,6 +50,39 @@ def detect() -> Optional[BrainAdapter]:
5050
return None
5151

5252

53+
async def stream_for_publish(
54+
brain: BrainAdapter,
55+
messages: list[dict[str, Any]],
56+
*,
57+
system: Optional[str] = None,
58+
temperature: float = 0.7,
59+
max_tokens: int = 2048,
60+
tools: Optional[list[dict[str, Any]]] = None,
61+
) -> AsyncIterator[ChatChunk]:
62+
"""Stream a brain's reply as chunks suitable to yield from a zhub
63+
``chat_handler``.
64+
65+
Yields the *whole* ChatChunk whenever it carries a text delta, a
66+
tool_call_delta, OR a finish_reason — not just ``chunk.delta``. A
67+
tool-call turn produces chunks whose ``delta`` is empty but whose
68+
``tool_call_delta`` / ``finish_reason="tool_calls"`` carry the call;
69+
yielding only ``chunk.delta`` (the obvious-looking shortcut) silently
70+
drops every function call the brain emits, so the hub — which keys
71+
tool-call auto-resolution off accumulated tool_call deltas + a
72+
``finish_reason == "tool_calls"`` terminator — never sees them. The
73+
publisher serializers (`_serialize_stream_chunk` / `_chunk_fields`)
74+
already understand ChatChunk objects, so handing them the chunk
75+
verbatim preserves the full shape on both the streaming and
76+
non-streaming paths.
77+
"""
78+
async for chunk in brain.stream(
79+
messages, system=system, temperature=temperature,
80+
max_tokens=max_tokens, tools=tools,
81+
):
82+
if chunk.delta or chunk.tool_call_delta or chunk.finish_reason:
83+
yield chunk
84+
85+
5386
def list_available() -> list[BrainAdapter]:
5487
import zhub.brains as _self
5588
out: list[BrainAdapter] = []
@@ -62,7 +95,7 @@ def list_available() -> list[BrainAdapter]:
6295

6396
__all__ = [
6497
"BrainAdapter", "ChatChunk", "REGISTRY",
65-
"detect", "list_available",
98+
"detect", "list_available", "stream_for_publish",
6699
"OllamaAdapter", "GroqAdapter", "OpenAIAdapter",
67100
"CerebrasAdapter", "AnthropicAdapter",
68101
"TogetherAdapter", "MistralAdapter", "CohereAdapter",

zhub/cli_up.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from typing import Optional
3030

3131
from . import publish
32-
from .brains import detect, REGISTRY
32+
from .brains import detect, REGISTRY, stream_for_publish
3333

3434

3535
def _brain_choices() -> str:
@@ -182,14 +182,13 @@ async def chat_handler(messages, options):
182182
if m.get("role") == "system"]
183183
non_system = [m for m in messages if m.get("role") != "system"]
184184
system = "\n\n".join(p for p in system_parts if p) or None
185-
async for chunk in brain.stream(
186-
non_system, system=system,
185+
async for chunk in stream_for_publish(
186+
brain, non_system, system=system,
187187
temperature=float(options.get("temperature", 0.7)),
188188
max_tokens=int(options.get("max_tokens", 2048)),
189189
tools=options.get("tools"),
190190
):
191-
if chunk.delta:
192-
yield chunk.delta
191+
yield chunk
193192

194193
pub = publish(
195194
name=args.name,

0 commit comments

Comments
 (0)