616 lines
26 KiB
Python
616 lines
26 KiB
Python
"""BlockStream — event dispatch state machine that emits semantic blocks.
|
|
|
|
Consumes the raw event stream from ``session.send()`` and produces
|
|
a stream of typed blocks with context. Each block carries a
|
|
``BlockContext`` identifying which agent produced it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from typing import TYPE_CHECKING
|
|
|
|
from ._blocks import (
|
|
AnyBlock,
|
|
BlockContext,
|
|
CompactionBlock,
|
|
ErrorBlock,
|
|
FileBlock,
|
|
NativeToolBlock,
|
|
ReasoningBlock,
|
|
ReasoningChunk,
|
|
ReasoningStartBlock,
|
|
ResponseEndBlock,
|
|
ResponseStartBlock,
|
|
RetryBlock,
|
|
TextChunk,
|
|
TextDone,
|
|
ToolExecution,
|
|
ToolGroup,
|
|
ToolResultBlock,
|
|
)
|
|
from ._events import (
|
|
CompactionInProgress,
|
|
ErrorEvent,
|
|
MessageDone,
|
|
NativeToolCall,
|
|
OutputFileDone,
|
|
ReasoningDelta,
|
|
ReasoningStarted,
|
|
ReasoningSummaryDelta,
|
|
ResponseCancelled,
|
|
ResponseCompleted,
|
|
ResponseCreated,
|
|
ResponseFailed,
|
|
ResponseIncomplete,
|
|
ResponseInProgress,
|
|
ResponseQueued,
|
|
RetryEvent,
|
|
TextDelta,
|
|
ToolCall,
|
|
ToolResult,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from ._session import Session
|
|
|
|
|
|
def format_tool_args_brief(name: str, arguments: dict[str, object]) -> str:
|
|
"""
|
|
Format tool arguments for inline display next to the ``⏵ <name>``
|
|
line.
|
|
|
|
Public so frontends that re-render historical tool calls (e.g. the
|
|
REPL's conversation-resume preview) can produce the same
|
|
``args_summary`` string the live stream produced when the call
|
|
first ran. Without a single source of truth, "this is what you
|
|
saw originally" diverges from "this is what the renderer
|
|
chooses now" the moment either side changes.
|
|
|
|
:param name: Tool name, e.g. ``"Read"``.
|
|
:param arguments: Parsed arguments dict, e.g.
|
|
``{"file_path": "/x/y.py"}``.
|
|
:returns: One-line summary, e.g. ``"y.py"`` for ``Read`` or the
|
|
tool's primary user-meaningful field. Falls back to a JSON
|
|
encoding of the full dict (truncated to 80 chars) for
|
|
unrecognized tools.
|
|
"""
|
|
if not arguments:
|
|
return ""
|
|
_KEYS = {
|
|
"Read": "file_path",
|
|
"Write": "file_path",
|
|
"Edit": "file_path",
|
|
"Bash": "command",
|
|
"Glob": "pattern",
|
|
"Grep": "pattern",
|
|
"web_search": "query",
|
|
}
|
|
key = _KEYS.get(name)
|
|
if key and key in arguments:
|
|
s = str(arguments[key])
|
|
if key == "file_path" and "/" in s:
|
|
s = s.rsplit("/", 1)[-1]
|
|
return s[:80] + "…" if len(s) > 80 else s
|
|
try:
|
|
s = json.dumps(arguments, ensure_ascii=False)
|
|
except (TypeError, ValueError):
|
|
s = str(arguments)
|
|
return s[:80] + "…" if len(s) > 80 else s
|
|
|
|
|
|
def _format_native_label(tool_type: str, data: dict[str, object]) -> str:
|
|
"""Format a native tool label."""
|
|
if tool_type == "web_search_call":
|
|
action = data.get("action")
|
|
if isinstance(action, dict):
|
|
at = action.get("type", "")
|
|
if at == "search":
|
|
return f"web search: {str(action.get('query', ''))[:80]}"
|
|
if at == "open_page":
|
|
return f"web open: {str(action.get('url', ''))[:80]}"
|
|
return "web search"
|
|
if tool_type == "mcp_call":
|
|
n = data.get("name", "")
|
|
return f"mcp: {n}" if n else "mcp call"
|
|
return tool_type.replace("_", " ")
|
|
|
|
|
|
def _output_text_from_message_content(content: list[dict[str, object]]) -> str:
|
|
"""
|
|
Extract completed assistant text from a message item.
|
|
|
|
:param content: Message content blocks from ``MessageDone``.
|
|
:returns: Concatenated ``output_text`` content.
|
|
"""
|
|
parts: list[str] = []
|
|
for block in content:
|
|
if block.get("type") != "output_text":
|
|
continue
|
|
text = block.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
return "".join(parts)
|
|
|
|
|
|
class BlockStream:
|
|
"""Consumes a session event stream and emits semantic stream blocks.
|
|
|
|
:param text_flush_threshold: Min chars to buffer before flushing
|
|
on a word boundary. Default 30.
|
|
"""
|
|
|
|
def __init__(self, text_flush_threshold: int = 30) -> None:
|
|
self._flush_threshold = text_flush_threshold
|
|
|
|
async def stream(
|
|
self,
|
|
session: Session,
|
|
input: str | list[dict[str, object]],
|
|
*,
|
|
files: list[str] | None = None,
|
|
) -> AsyncIterator[AnyBlock]:
|
|
"""Stream semantic blocks for one turn.
|
|
|
|
:param session: The :class:`Session` whose event stream to fold.
|
|
The session's ``send()`` method is invoked internally.
|
|
:param input: User text or a list of content-block dicts,
|
|
e.g. ``"hello"`` or ``[{"type": "input_text", "text": "hi"}]``.
|
|
:param files: Optional list of file paths to upload and attach
|
|
to the turn, e.g. ``["./data.csv"]``.
|
|
:returns: Async iterator of :class:`AnyBlock` values in order.
|
|
"""
|
|
in_reasoning = False
|
|
reasoning_text = ""
|
|
summary_text = ""
|
|
# Per-section accumulator for reasoning chunks awaiting a
|
|
# newline / threshold flush. Mirrors ``accumulated`` for text.
|
|
reasoning_accumulated = ""
|
|
# Set when this reasoning section streamed any
|
|
# ``ReasoningChunk``. The final ``ReasoningBlock`` is then
|
|
# suppressed so renderers don't show the same text twice
|
|
# (once live, once as a summary panel).
|
|
reasoning_chunks_emitted = False
|
|
in_text = False
|
|
accumulated = ""
|
|
full_text = ""
|
|
pending_tools: dict[str, ToolExecution] = {}
|
|
# Long-lived metadata for tool calls rendered in this stream.
|
|
# Unlike ``pending_tools``, this survives text deltas so a
|
|
# delayed ``ToolResult`` can still render with the original
|
|
# tool name/arguments instead of being dropped.
|
|
tool_executions_by_call_id: dict[str, ToolExecution] = {}
|
|
# Dedup set: every ``call_id`` we've yielded a
|
|
# ``ToolGroup`` for so far. Survives ``pending_tools``
|
|
# clears (which fire on each ``TextDelta`` to flush
|
|
# ``ToolResultBlock``s for completed tools), so the
|
|
# post-stream action_required event for an MCP tool
|
|
# whose inline observed event already rendered doesn't
|
|
# silently re-render once the intervening text deltas
|
|
# cleared ``pending_tools``. Call_ids are SDK-assigned
|
|
# ``toolu_*`` ids — globally unique within this turn,
|
|
# so the set never grows pathologically.
|
|
seen_call_ids: set[str] = set()
|
|
# Dedup set: every ``call_id`` we've yielded a
|
|
# ``ToolResultBlock`` for. Same survives-clear semantics
|
|
# as ``seen_call_ids`` for the call-line side, but on the
|
|
# result side. Necessary because every action_required
|
|
# tool's result fires TWICE on the SSE stream: once
|
|
# inline from ``_dispatch_action_required`` (the moment
|
|
# the dispatch returns) and once from the
|
|
# ``response.completed`` flush in
|
|
# ``_translate_omnigent_event``. Without this, the result
|
|
# panel renders twice.
|
|
seen_result_call_ids: set[str] = set()
|
|
agent: str | None = None
|
|
turn = 0
|
|
started = False
|
|
|
|
def _ctx() -> BlockContext:
|
|
depth = agent.count(".") if agent else 0
|
|
return BlockContext(agent=agent, depth=depth, turn=turn)
|
|
|
|
async for event in session.send(input, files=files):
|
|
# ── Response lifecycle ───────────────────
|
|
if isinstance(event, ResponseCreated):
|
|
# Tool calls were already yielded immediately. Emit
|
|
# result-only groups for tools that got output between
|
|
# ResponseCompleted and this ResponseCreated.
|
|
for ex in list(pending_tools.values()):
|
|
if ex.output is not None:
|
|
yield ToolResultBlock(
|
|
name=ex.name,
|
|
call_id=ex.call_id,
|
|
agent_name=ex.agent_name,
|
|
output=ex.output,
|
|
arguments=ex.arguments,
|
|
args_summary=ex.args_summary,
|
|
ctx=_ctx(),
|
|
)
|
|
pending_tools.clear()
|
|
tool_executions_by_call_id.clear()
|
|
agent = event.response.model
|
|
if not started:
|
|
started = True
|
|
yield ResponseStartBlock(
|
|
model=agent,
|
|
response_id=event.response.id,
|
|
ctx=_ctx(),
|
|
)
|
|
else:
|
|
turn += 1
|
|
|
|
elif isinstance(event, ResponseQueued | ResponseInProgress):
|
|
pass
|
|
|
|
# ── Reasoning ────────────────────────────
|
|
elif isinstance(event, ReasoningStarted):
|
|
# Already reasoning = a new summarized-thinking block in the
|
|
# same section. Flush this block's tail + a separator so
|
|
# consecutive thought items don't run together.
|
|
if in_reasoning:
|
|
yield ReasoningChunk(text=reasoning_accumulated + "\n\n", ctx=_ctx())
|
|
reasoning_accumulated = ""
|
|
reasoning_chunks_emitted = True
|
|
continue
|
|
# Entering reasoning closes open text (symmetric with
|
|
# TextDelta closing reasoning) — else interleaved
|
|
# think→speak→think orphans + concatenates text.
|
|
if in_text:
|
|
if accumulated:
|
|
yield TextChunk(text=accumulated, ctx=_ctx())
|
|
accumulated = ""
|
|
yield TextDone(
|
|
full_text=full_text,
|
|
has_code_blocks="```" in full_text,
|
|
ctx=_ctx(),
|
|
)
|
|
in_text = False
|
|
full_text = ""
|
|
in_reasoning = True
|
|
reasoning_text = ""
|
|
summary_text = ""
|
|
reasoning_accumulated = ""
|
|
reasoning_chunks_emitted = False
|
|
yield ReasoningStartBlock(ctx=_ctx())
|
|
|
|
elif isinstance(event, ReasoningDelta | ReasoningSummaryDelta):
|
|
# An out-of-order delta (no preceding ReasoningStarted)
|
|
# marks an implicit start — Codex's bridged events
|
|
# arrive this way. Emit the start block once so the
|
|
# formatter has its "thinking…" anchor.
|
|
if not in_reasoning:
|
|
# Close any open text first — same boundary as ReasoningStarted.
|
|
if in_text:
|
|
if accumulated:
|
|
yield TextChunk(text=accumulated, ctx=_ctx())
|
|
accumulated = ""
|
|
yield TextDone(
|
|
full_text=full_text,
|
|
has_code_blocks="```" in full_text,
|
|
ctx=_ctx(),
|
|
)
|
|
in_text = False
|
|
full_text = ""
|
|
in_reasoning = True
|
|
reasoning_text = ""
|
|
summary_text = ""
|
|
reasoning_accumulated = ""
|
|
reasoning_chunks_emitted = False
|
|
yield ReasoningStartBlock(ctx=_ctx())
|
|
if isinstance(event, ReasoningDelta):
|
|
reasoning_text += event.delta
|
|
else:
|
|
summary_text += event.delta
|
|
# Stream the delta as a ReasoningChunk so the TUI can
|
|
# render mid-flight. Mirrors the TextDelta line/
|
|
# threshold flush so chunks land on natural breaks.
|
|
reasoning_accumulated += event.delta
|
|
while "\n" in reasoning_accumulated:
|
|
line, reasoning_accumulated = reasoning_accumulated.split("\n", 1)
|
|
yield ReasoningChunk(text=line + "\n", ctx=_ctx())
|
|
reasoning_chunks_emitted = True
|
|
if len(reasoning_accumulated) >= self._flush_threshold:
|
|
last_space = reasoning_accumulated.rfind(" ")
|
|
if last_space > 0:
|
|
yield ReasoningChunk(
|
|
text=reasoning_accumulated[: last_space + 1],
|
|
ctx=_ctx(),
|
|
)
|
|
reasoning_accumulated = reasoning_accumulated[last_space + 1 :]
|
|
reasoning_chunks_emitted = True
|
|
|
|
# ── Text ─────────────────────────────────
|
|
elif isinstance(event, TextDelta):
|
|
if in_reasoning:
|
|
in_reasoning = False
|
|
if reasoning_accumulated:
|
|
yield ReasoningChunk(
|
|
text=reasoning_accumulated,
|
|
ctx=_ctx(),
|
|
)
|
|
reasoning_chunks_emitted = True
|
|
reasoning_accumulated = ""
|
|
if not reasoning_chunks_emitted:
|
|
yield ReasoningBlock(
|
|
reasoning_text=reasoning_text,
|
|
summary_text=summary_text,
|
|
ctx=_ctx(),
|
|
)
|
|
# Emit results for tools that completed.
|
|
for ex in list(pending_tools.values()):
|
|
if ex.output is not None:
|
|
yield ToolResultBlock(
|
|
name=ex.name,
|
|
call_id=ex.call_id,
|
|
agent_name=ex.agent_name,
|
|
output=ex.output,
|
|
arguments=ex.arguments,
|
|
args_summary=ex.args_summary,
|
|
ctx=_ctx(),
|
|
)
|
|
pending_tools.clear()
|
|
|
|
in_text = True
|
|
accumulated += event.delta
|
|
full_text += event.delta
|
|
|
|
while "\n" in accumulated:
|
|
line, accumulated = accumulated.split("\n", 1)
|
|
yield TextChunk(text=line + "\n", ctx=_ctx())
|
|
|
|
if len(accumulated) >= self._flush_threshold:
|
|
last_space = accumulated.rfind(" ")
|
|
if last_space > 0:
|
|
yield TextChunk(text=accumulated[: last_space + 1], ctx=_ctx())
|
|
accumulated = accumulated[last_space + 1 :]
|
|
|
|
# ── Tool calls ───────────────────────────
|
|
elif isinstance(event, ToolCall):
|
|
# Dedupe by call_id. Under the claude-sdk harness's
|
|
# MCP path, a tool call surfaces as TWO ToolCall
|
|
# events with correlated call_ids: an inline
|
|
# observed event (status="completed") emitted as
|
|
# the inner SDK parses the tool_use block, and a
|
|
# post-stream action_required event emitted when
|
|
# the SDK invokes the MCP-server handler. The
|
|
# adapter (omnigent/runtime/harnesses/
|
|
# _executor_adapter.py) threads the SDK's
|
|
# tool_use_id through both so they share a
|
|
# call_id; this block keeps the first occurrence
|
|
# (the inline render) and drops the second so the
|
|
# REPL doesn't render ``⏵ tool_name`` twice. See
|
|
# designs/RUN_OMNIGENT_REPL_PARITY.md.
|
|
#
|
|
# Non-MCP paths emit exactly one ToolCall per
|
|
# call_id, so the second-arrival branch never
|
|
# fires for them.
|
|
if event.call_id in seen_call_ids:
|
|
# Already rendered the ⏵ line for this call_id
|
|
# (e.g. via the inline observed event from the
|
|
# harness's content_block_stop, or via the
|
|
# ``ToolCallInProgress`` event the Omnigent server
|
|
# emits at action_required arrival). Re-register
|
|
# in pending_tools so the eventual ``ToolResult``
|
|
# can pair by call_id — the prior pending entry
|
|
# may have been cleared by the
|
|
# ``pending_tools.clear()`` that fires on every
|
|
# ``TextDelta`` between observed and
|
|
# action_required (or between action_required
|
|
# and the post-PATCH function_call_output).
|
|
execution = tool_executions_by_call_id.get(event.call_id)
|
|
if execution is None:
|
|
execution = ToolExecution(
|
|
name=event.name,
|
|
arguments=event.arguments,
|
|
args_summary=format_tool_args_brief(event.name, event.arguments),
|
|
call_id=event.call_id,
|
|
agent_name=event.agent_name,
|
|
executed_by="server",
|
|
)
|
|
tool_executions_by_call_id[event.call_id] = execution
|
|
pending_tools[event.call_id] = execution
|
|
continue
|
|
seen_call_ids.add(event.call_id)
|
|
|
|
if in_reasoning:
|
|
in_reasoning = False
|
|
if reasoning_accumulated:
|
|
yield ReasoningChunk(
|
|
text=reasoning_accumulated,
|
|
ctx=_ctx(),
|
|
)
|
|
reasoning_chunks_emitted = True
|
|
reasoning_accumulated = ""
|
|
if not reasoning_chunks_emitted:
|
|
yield ReasoningBlock(
|
|
reasoning_text=reasoning_text,
|
|
summary_text=summary_text,
|
|
ctx=_ctx(),
|
|
)
|
|
if in_text:
|
|
if accumulated:
|
|
yield TextChunk(text=accumulated, ctx=_ctx())
|
|
accumulated = ""
|
|
yield TextDone(
|
|
full_text=full_text,
|
|
has_code_blocks="```" in full_text,
|
|
ctx=_ctx(),
|
|
)
|
|
in_text = False
|
|
full_text = ""
|
|
|
|
execution = ToolExecution(
|
|
name=event.name,
|
|
arguments=event.arguments,
|
|
args_summary=format_tool_args_brief(event.name, event.arguments),
|
|
call_id=event.call_id,
|
|
agent_name=event.agent_name,
|
|
executed_by="server",
|
|
)
|
|
pending_tools[event.call_id] = execution
|
|
tool_executions_by_call_id[event.call_id] = execution
|
|
# Yield immediately so the user sees the tool call
|
|
# before execution. output=None means the formatter
|
|
# shows the call line but no result panel.
|
|
yield ToolGroup(executions=[execution], ctx=_ctx())
|
|
|
|
elif isinstance(event, ToolResult):
|
|
if event.call_id in seen_result_call_ids:
|
|
# Already rendered the result panel. The late
|
|
# ``response.completed`` flush emission is
|
|
# redundant; drop it so a subsequent
|
|
# ``ResponseCreated`` doesn't yield it again
|
|
# via the pending_tools sweep.
|
|
continue
|
|
ex = pending_tools.get(event.call_id) or tool_executions_by_call_id.get(
|
|
event.call_id
|
|
)
|
|
if ex is None:
|
|
# Result arrived after a TextDelta cleared
|
|
# ``pending_tools``. If the matching call line
|
|
# never rendered, we have no name/agent for the
|
|
# panel — drop the event rather than render
|
|
# with placeholders.
|
|
continue
|
|
ex.output = event.output
|
|
ex.executed_by = "client"
|
|
if event.arguments:
|
|
ex.arguments = event.arguments
|
|
# Yield the result panel IMMEDIATELY so multi-
|
|
# tool turns don't bunch result rendering at
|
|
# end-of-turn. Each dispatch's
|
|
# ``_live_publish`` of the function_call_output
|
|
# arrives here as the tool finishes.
|
|
seen_result_call_ids.add(event.call_id)
|
|
yield ToolResultBlock(
|
|
name=ex.name,
|
|
call_id=ex.call_id,
|
|
agent_name=ex.agent_name,
|
|
output=event.output,
|
|
arguments=ex.arguments,
|
|
args_summary=ex.args_summary,
|
|
ctx=_ctx(),
|
|
)
|
|
# Drop from ``pending_tools`` so the next
|
|
# ``TextDelta`` / ``ResponseCreated`` sweep
|
|
# doesn't re-yield this result.
|
|
pending_tools.pop(event.call_id, None)
|
|
|
|
# ── Native tools ─────────────────────────
|
|
elif isinstance(event, NativeToolCall):
|
|
yield NativeToolBlock(
|
|
tool_type=event.tool_type,
|
|
label=_format_native_label(event.tool_type, event.data),
|
|
data=event.data,
|
|
ctx=_ctx(),
|
|
)
|
|
|
|
# ── Message done ─────────────────────────
|
|
elif isinstance(event, MessageDone):
|
|
if in_reasoning:
|
|
in_reasoning = False
|
|
if reasoning_accumulated:
|
|
yield ReasoningChunk(
|
|
text=reasoning_accumulated,
|
|
ctx=_ctx(),
|
|
)
|
|
reasoning_chunks_emitted = True
|
|
reasoning_accumulated = ""
|
|
if not reasoning_chunks_emitted:
|
|
yield ReasoningBlock(
|
|
reasoning_text=reasoning_text,
|
|
summary_text=summary_text,
|
|
ctx=_ctx(),
|
|
)
|
|
if in_text:
|
|
if accumulated:
|
|
yield TextChunk(text=accumulated, ctx=_ctx())
|
|
accumulated = ""
|
|
yield TextDone(
|
|
full_text=full_text,
|
|
has_code_blocks="```" in full_text,
|
|
ctx=_ctx(),
|
|
)
|
|
in_text = False
|
|
full_text = ""
|
|
else:
|
|
text = _output_text_from_message_content(event.content)
|
|
if text:
|
|
# No prior deltas: synthesize a TextChunk so
|
|
# TextChunk-only consumers (Session._stream_chunks)
|
|
# still see the message.
|
|
yield TextChunk(text=text, ctx=_ctx())
|
|
yield TextDone(
|
|
full_text=text,
|
|
has_code_blocks="```" in text,
|
|
ctx=_ctx(),
|
|
)
|
|
|
|
# ── Status events ────────────────────────
|
|
elif isinstance(event, CompactionInProgress):
|
|
yield CompactionBlock(ctx=_ctx())
|
|
|
|
elif isinstance(event, RetryEvent):
|
|
yield RetryBlock(
|
|
source=event.source,
|
|
attempt=event.attempt,
|
|
max_attempts=event.max_attempts,
|
|
delay_seconds=event.delay_seconds,
|
|
ctx=_ctx(),
|
|
)
|
|
|
|
elif isinstance(event, ErrorEvent):
|
|
# Pass ``code`` through too — renderers need it as a
|
|
# fallback label when ``message`` is empty (otherwise
|
|
# the error panel shows just ``[llm]`` with no hint
|
|
# as to what went wrong).
|
|
yield ErrorBlock(
|
|
message=event.error.message,
|
|
source=event.source,
|
|
code=event.error.code,
|
|
ctx=_ctx(),
|
|
)
|
|
|
|
elif isinstance(event, OutputFileDone):
|
|
yield FileBlock(file_id=event.file_id, filename=event.filename, ctx=_ctx())
|
|
|
|
# ── Terminal events ──────────────────────
|
|
elif isinstance(
|
|
event,
|
|
ResponseCompleted | ResponseFailed | ResponseIncomplete | ResponseCancelled,
|
|
):
|
|
if in_reasoning:
|
|
in_reasoning = False
|
|
if reasoning_accumulated:
|
|
yield ReasoningChunk(
|
|
text=reasoning_accumulated,
|
|
ctx=_ctx(),
|
|
)
|
|
reasoning_chunks_emitted = True
|
|
reasoning_accumulated = ""
|
|
if not reasoning_chunks_emitted:
|
|
yield ReasoningBlock(
|
|
reasoning_text=reasoning_text,
|
|
summary_text=summary_text,
|
|
ctx=_ctx(),
|
|
)
|
|
if in_text:
|
|
if accumulated:
|
|
yield TextChunk(text=accumulated, ctx=_ctx())
|
|
accumulated = ""
|
|
yield TextDone(
|
|
full_text=full_text,
|
|
has_code_blocks="```" in full_text,
|
|
ctx=_ctx(),
|
|
)
|
|
in_text = False
|
|
full_text = ""
|
|
|
|
yield ResponseEndBlock(
|
|
status=event.response.status,
|
|
response=event.response,
|
|
ctx=_ctx(),
|
|
)
|