"""SSE frame parser — converts raw byte chunks into typed events. Handles the ``event:`` / ``data:`` / ``[DONE]`` framing from the server's ``text/event-stream`` responses. """ from __future__ import annotations import json import logging from collections.abc import AsyncIterator from typing import Any from omnigent.server import schemas as _srv_events from ._events import ( NATIVE_TOOL_TYPES, ClientTaskCancel, CompactionInProgress, ElicitationRequest, ErrorEvent, MessageDone, NativeToolCall, OutputFileDone, ReasoningDelta, ReasoningStarted, ReasoningSummaryDelta, ResponseCancelled, ResponseCompleted, ResponseCreated, ResponseFailed, ResponseIncomplete, ResponseInProgress, ResponseQueued, RetryEvent, StreamEvent, TextDelta, ToolCall, ToolResult, ) from ._types import ErrorInfo, Response _log = logging.getLogger("omnigent_client.sse") def _wire_type(cls: type) -> str: """ Extract the wire ``type`` literal from a server event class. Each :mod:`omnigent.server.schemas` event class pins its ``type`` field as ``Literal["..."]``; this helper unwraps that to a plain string so the SDK's ``str == str`` dispatch table stays a ``str`` comparison rather than introducing a class-side isinstance check. :param cls: A subclass of the server's ``_SSEEventBase``, e.g. :class:`omnigent.server.schemas.OutputTextDeltaEvent`. :returns: The wire ``type`` literal, e.g. ``"response.output_text.delta"``. """ return cls.model_fields["type"].annotation.__args__[0] # type: ignore[union-attr] # Wire type literals — pulled from the server's typed source of # truth so a rename there is a one-edit change here as well. _T_RESPONSE_CREATED = _wire_type(_srv_events.CreatedEvent) _T_RESPONSE_QUEUED = _wire_type(_srv_events.QueuedEvent) _T_RESPONSE_IN_PROGRESS = _wire_type(_srv_events.InProgressEvent) _T_RESPONSE_COMPLETED = _wire_type(_srv_events.CompletedEvent) _T_RESPONSE_FAILED = _wire_type(_srv_events.FailedEvent) _T_RESPONSE_INCOMPLETE = _wire_type(_srv_events.IncompleteEvent) _T_RESPONSE_CANCELLED = _wire_type(_srv_events.CancelledEvent) _T_RESPONSE_OUTPUT_TEXT_DELTA = _wire_type(_srv_events.OutputTextDeltaEvent) _T_RESPONSE_REASONING_STARTED = _wire_type(_srv_events.ReasoningStartedEvent) _T_RESPONSE_REASONING_TEXT_DELTA = _wire_type(_srv_events.ReasoningTextDeltaEvent) _T_RESPONSE_REASONING_SUMMARY_TEXT_DELTA = _wire_type(_srv_events.ReasoningSummaryTextDeltaEvent) _T_RESPONSE_OUTPUT_ITEM_DONE = _wire_type(_srv_events.OutputItemDoneEvent) _T_RESPONSE_OUTPUT_FILE_DONE = _wire_type(_srv_events.OutputFileDoneEvent) _T_RESPONSE_RETRY = _wire_type(_srv_events.RetryEvent) _T_RESPONSE_ERROR = _wire_type(_srv_events.ErrorEvent) _T_RESPONSE_COMPACTION_IN_PROGRESS = _wire_type(_srv_events.CompactionInProgressEvent) _T_RESPONSE_CLIENT_TASK_CANCEL = _wire_type(_srv_events.ClientTaskCancelEvent) _T_RESPONSE_ELICITATION_REQUEST = _wire_type(_srv_events.ElicitationRequestEvent) async def parse_sse_stream( byte_stream: AsyncIterator[bytes], ) -> AsyncIterator[StreamEvent]: """Parse an SSE byte stream into typed events. :param byte_stream: Raw bytes from ``httpx.Response.aiter_bytes()``. :yields: Typed :class:`StreamEvent` instances. """ buf = "" current_event: str | None = None async for chunk in byte_stream: buf += chunk.decode("utf-8", errors="replace") while "\n" in buf: line, buf = buf.split("\n", 1) line = line.rstrip("\r") if line.startswith("event: "): current_event = line[7:] elif line.startswith("data: "): data_str = line[6:] if data_str.strip() == "[DONE]": # The server's terminal sentinel is a bare ``data: [DONE]`` # with no preceding ``event:`` line, so detect it regardless # of ``current_event``. return # A ``data:`` line is only a parseable event when an ``event:`` # line preceded it; a lone data line (other than [DONE]) is # ignored. if current_event is not None: try: data = json.loads(data_str) except json.JSONDecodeError: _log.warning("Failed to parse SSE data: %s", data_str[:200]) current_event = None continue event = _parse_event(current_event, data) if event is not None: yield event current_event = None elif line == "": current_event = None def _normalize_event_type(event_type: str) -> str: """Normalize event type to handle server enum rendering. The server builds terminal events as ``f"response.{task.status}"`` where ``task.status`` may be a Python enum (rendering as ``response.TaskStatus.COMPLETED``) instead of the expected ``response.completed``. Normalize by extracting and lowercasing the enum value. """ if ".TaskStatus." in event_type: # "response.TaskStatus.COMPLETED" → "response.completed" parts = event_type.split(".") status = parts[-1].lower() return f"response.{status}" return event_type def _parse_event(event_type: str, data: dict[str, Any]) -> StreamEvent | None: """Convert a raw SSE event type + JSON data into a typed event. :param event_type: Wire name of the SSE ``event:`` field, e.g. ``"response.output_text.delta"``. Compared against the ``_T_RESPONSE_*`` wire-type constants (sourced from :mod:`omnigent.server.schemas`) to dispatch. :param data: Decoded JSON payload from the SSE ``data:`` field. :returns: A typed :class:`StreamEvent` for known event names, or ``None`` when the payload is missing required fields or the event type is unrecognized (forward-compatible skip). """ event_type = _normalize_event_type(event_type) # Response lifecycle if event_type == _T_RESPONSE_CREATED: return ResponseCreated(response=_parse_response(data)) if event_type == _T_RESPONSE_QUEUED: return ResponseQueued(response=_parse_response(data)) if event_type == _T_RESPONSE_IN_PROGRESS: return ResponseInProgress(response=_parse_response(data)) if event_type == _T_RESPONSE_COMPLETED: return ResponseCompleted(response=_parse_response(data)) if event_type == _T_RESPONSE_FAILED: return ResponseFailed(response=_parse_response(data)) if event_type == _T_RESPONSE_INCOMPLETE: resp = _parse_response(data) reason = "" if resp.incomplete_details is not None: reason = resp.incomplete_details.reason return ResponseIncomplete(response=resp, reason=reason) if event_type == _T_RESPONSE_CANCELLED: return ResponseCancelled(response=_parse_response(data)) # Text streaming if event_type == _T_RESPONSE_OUTPUT_TEXT_DELTA: delta = data.get("delta") if isinstance(delta, str): return TextDelta(delta=delta) return None # Reasoning if event_type == _T_RESPONSE_REASONING_STARTED: return ReasoningStarted() if event_type == _T_RESPONSE_REASONING_TEXT_DELTA: delta = data.get("delta") if isinstance(delta, str): return ReasoningDelta(delta=delta) return None if event_type == _T_RESPONSE_REASONING_SUMMARY_TEXT_DELTA: delta = data.get("delta") if isinstance(delta, str): return ReasoningSummaryDelta(delta=delta) return None # Output items if event_type == _T_RESPONSE_OUTPUT_ITEM_DONE: return _parse_output_item(data) # File output if event_type == _T_RESPONSE_OUTPUT_FILE_DONE: return OutputFileDone( file_id=str(data.get("file_id", "")), filename=str(data["filename"]) if data.get("filename") is not None else None, content_type=str(data["content_type"]) if data.get("content_type") is not None else None, ) # Retry if event_type == _T_RESPONSE_RETRY: return RetryEvent( source=str(data.get("source", "")), tool_name=str(data["tool_name"]) if data.get("tool_name") is not None else None, attempt=int(data.get("attempt", 0)), max_attempts=int(data.get("max_attempts", 0)), delay_seconds=float(data.get("delay_seconds", 0.0)), error=_parse_error_info(data.get("error", {})), ) # Error if event_type == _T_RESPONSE_ERROR: return ErrorEvent( source=str(data.get("source", "")), tool_name=str(data["tool_name"]) if data.get("tool_name") is not None else None, error=_parse_error_info(data.get("error", {})), ) # Compaction if event_type == _T_RESPONSE_COMPACTION_IN_PROGRESS: return CompactionInProgress() # Async client-tool cancel notification if event_type == _T_RESPONSE_CLIENT_TASK_CANCEL: task_id = data.get("task_id") if isinstance(task_id, str) and task_id: raw_call_id = data.get("call_id") call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id else None return ClientTaskCancel(task_id=task_id, call_id=call_id) _log.warning("response.client_task.cancel missing task_id: %r", data) return None # MCP-shape elicitation request (POLICIES.md §7 + the # universal API additions in # designs/SERVER_HARNESS_CONTRACT.md). The ``params`` block # mirrors MCP's ``ElicitRequestFormParams`` field-for-field; # we surface it as a typed event so the stream consumer can # route it to the elicitation hook (not into a ToolHandler). if event_type == "response.elicitation_request": elicitation_id = data.get("elicitation_id") if not isinstance(elicitation_id, str) or not elicitation_id: _log.warning( "response.elicitation_request missing elicitation_id: %r", data, ) return None params = data.get("params") if not isinstance(params, dict): _log.warning( "response.elicitation_request missing params dict: %r", data, ) return None requested_schema = params.get("requestedSchema") return ElicitationRequest( elicitation_id=elicitation_id, message=str(params.get("message") or ""), # MCP spec restricts requestedSchema to a JSON-Schema # dict — we accept anything dict-shaped and pass it # through; non-dict inputs (defensive against # malformed producers) become an empty schema. requested_schema=requested_schema if isinstance(requested_schema, dict) else {}, mode=str(params.get("mode") or "form"), phase=str(params.get("phase") or ""), policy_name=str(params.get("policy_name") or ""), content_preview=str(params.get("content_preview") or ""), url=str(params["url"]) if isinstance(params.get("url"), str) else None, ) # Unknown event — skip gracefully for forward-compatibility _log.debug("Skipping unknown SSE event type: %s", event_type) return None def _parse_output_item(data: dict[str, Any]) -> StreamEvent | None: """Parse a ``response.output_item.done`` event into a typed event.""" item = data.get("item") if not isinstance(item, dict): return None item_type = item.get("type", "") if item_type == "function_call": args_str = str(item.get("arguments", "{}")) try: arguments = json.loads(args_str) except json.JSONDecodeError: arguments = {} name = str(item.get("name", "")) call_id = str(item.get("call_id", "")) return ToolCall( name=name, arguments=arguments, call_id=call_id, status=str(item.get("status", "")), agent_name=str(item.get("model", "")), ) if item_type == "function_call_output": raw_arguments = item.get("arguments") arguments: dict[str, object] = {} if isinstance(raw_arguments, dict): arguments = raw_arguments elif isinstance(raw_arguments, str) and raw_arguments: try: decoded_arguments = json.loads(raw_arguments) except json.JSONDecodeError: decoded_arguments = {} if isinstance(decoded_arguments, dict): arguments = decoded_arguments return ToolResult( call_id=str(item.get("call_id", "")), output=str(item.get("output", "")), arguments=arguments, ) if item_type == "message": content = item.get("content", []) return MessageDone( content=content if isinstance(content, list) else [], ) if item_type in NATIVE_TOOL_TYPES: return NativeToolCall( tool_type=item_type, data=item, ) # Compaction items, reasoning items, etc. — skip _log.debug("Skipping output item type: %s", item_type) return None def _parse_response(data: dict[str, Any]) -> Response: """Extract the Response object from an SSE event payload.""" resp_data = data.get("response") if isinstance(resp_data, dict): return Response.from_dict(resp_data) # Some events put fields at the top level return Response.from_dict(data) def _parse_error_info(raw: Any) -> ErrorInfo: """Parse an ErrorInfo from a nested dict.""" if isinstance(raw, dict): return ErrorInfo( code=str(raw.get("code", "")), message=str(raw.get("message", "")), ) return ErrorInfo(code="", message=str(raw))