mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 19:52:23 +02:00
137 lines
6.3 KiB
Python
137 lines
6.3 KiB
Python
"""Handle one streaming StreamEvent from the SDK: the incremental content_block_start /
|
|
delta / stop / message_stop path that drives live text, thinking, and tool streaming to the UI.
|
|
Lifted out of the agent loop; mutates the passed TurnState / ThinkingState by reference and
|
|
writes the manager's live-partial mirror, exactly as it did inline."""
|
|
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Dict
|
|
from uuid import uuid4
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.core.models import AgentSession
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState
|
|
from backend.apps.agents.manager.streaming.PartialReply import PartialReply
|
|
|
|
try:
|
|
from claude_agent_sdk.types import StreamEvent
|
|
except ImportError: # the SDK is optional at runtime (mock mode); keep this module importable
|
|
StreamEvent = object # type: ignore
|
|
|
|
|
|
@typechecked
|
|
async def handle_stream_event(
|
|
message: StreamEvent,
|
|
session: AgentSession,
|
|
session_id: str,
|
|
turn: TurnState,
|
|
thinking: ThinkingState,
|
|
live_partial: Dict[str, PartialReply],
|
|
) -> None:
|
|
event = message.event
|
|
event_type = event.get("type")
|
|
|
|
if event_type == "content_block_start":
|
|
# Stamp the first stream event of the session so the session list can show "first response at HH:MM" on reload. Only the first turn sets this; later turns leave it untouched.
|
|
if session.first_response_at is None:
|
|
session.first_response_at = datetime.now()
|
|
|
|
block = event.get("content_block", {})
|
|
index = event.get("index")
|
|
block_type = block.get("type")
|
|
|
|
if block_type == "text":
|
|
if turn.stream_text_msg_id is None:
|
|
turn.stream_text_msg_id = uuid4().hex
|
|
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
|
"session_id": session_id,
|
|
"message_id": turn.stream_text_msg_id,
|
|
"role": "assistant",
|
|
})
|
|
turn.stream_block_index_map[index] = turn.stream_text_msg_id
|
|
|
|
elif block_type == "thinking":
|
|
# Reasoning trace from thinking-capable models (GPT-5.3 Codex, Gemini 3 Pro/Flash, Claude with extended thinking). Rendered as a collapsible "thinking" message in the UI via the existing stream infrastructure, the frontend already handles role="thinking" for the DynamicIsland/agent card rendering.
|
|
thinking_msg_id = uuid4().hex
|
|
turn.stream_block_index_map[index] = thinking_msg_id
|
|
# Server-stamp start so we can accumulate per-turn elapsed_ms across multiple thinking blocks (think → tool → think → answer turns sum correctly).
|
|
thinking.block_starts[index] = time.time()
|
|
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
|
"session_id": session_id,
|
|
"message_id": thinking_msg_id,
|
|
"role": "thinking",
|
|
})
|
|
|
|
elif block_type == "tool_use":
|
|
tool_msg_id = uuid4().hex
|
|
turn.stream_tool_msg_ids_ordered.append(tool_msg_id)
|
|
turn.stream_block_index_map[index] = tool_msg_id
|
|
# Stream-level tool count for the thinking pill. OpenAI/Gemini-through-9Router AssistantMessage envelopes are sometimes incomplete, so this stream count guarantees "N tools used" renders cross-provider; the AssistantMessage path dedupes against it.
|
|
turn.tool_count += 1
|
|
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
|
"session_id": session_id,
|
|
"message_id": tool_msg_id,
|
|
"role": "tool_call",
|
|
"tool_name": block.get("name", ""),
|
|
})
|
|
|
|
elif event_type == "content_block_delta":
|
|
index = event.get("index")
|
|
delta = event.get("delta", {})
|
|
delta_type = delta.get("type")
|
|
msg_id = turn.stream_block_index_map.get(index)
|
|
|
|
if msg_id and delta_type == "text_delta":
|
|
text_chunk = delta.get("text", "")
|
|
turn.assistant_text_chars += len(text_chunk)
|
|
turn.stream_text_accum += text_chunk
|
|
live_partial[session_id] = PartialReply(
|
|
msg_id=turn.stream_text_msg_id,
|
|
text=turn.stream_text_accum,
|
|
branch_id=session.active_branch_id,
|
|
)
|
|
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
|
"session_id": session_id,
|
|
"message_id": msg_id,
|
|
"delta": text_chunk,
|
|
})
|
|
elif msg_id and delta_type == "thinking_delta":
|
|
# Thinking content streams as thinking_delta with a "thinking" field (not "text")
|
|
think_chunk = delta.get("thinking", "")
|
|
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
|
"session_id": session_id,
|
|
"message_id": msg_id,
|
|
"delta": think_chunk,
|
|
})
|
|
elif msg_id and delta_type == "input_json_delta":
|
|
json_chunk = delta.get("partial_json", "")
|
|
turn.tool_input_chars += len(json_chunk)
|
|
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
|
"session_id": session_id,
|
|
"message_id": msg_id,
|
|
"delta": json_chunk,
|
|
})
|
|
|
|
elif event_type == "content_block_stop":
|
|
index = event.get("index")
|
|
msg_id = turn.stream_block_index_map.get(index)
|
|
# If this was a thinking block, accumulate elapsed_ms server-side. We don't include per-block elapsed/tokens on the WS event, the pill stays in "Thinking…" until the AssistantMessage lands carrying the per-turn aggregate values.
|
|
if index in thinking.block_starts:
|
|
thinking.total_ms += int(
|
|
(time.time() - thinking.block_starts.pop(index)) * 1000
|
|
)
|
|
if msg_id and msg_id != turn.stream_text_msg_id:
|
|
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
|
"session_id": session_id,
|
|
"message_id": msg_id,
|
|
})
|
|
|
|
elif event_type == "message_stop":
|
|
if turn.stream_text_msg_id:
|
|
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
|
"session_id": session_id,
|
|
"message_id": turn.stream_text_msg_id,
|
|
})
|