"""TUI→web forwarder for the qwen-native harness. The ``omnigent qwen`` wrapper launches the real ``qwen`` TUI in a runner-owned tmux pane with ``--json-file`` pointed at the bridge dir, and :mod:`omnigent.qwen_native_bridge` appends web-UI messages to its ``--input-file``. That covers the web→TUI direction, but the *embedded terminal* is then the only surface that reflects the agent's work — the Omnigent conversation view stays empty because nothing mirrors the transcript back into the session. This module is that missing mirror — the qwen analog of :mod:`omnigent.goose_native_forwarder`. Where goose has to scrape a SQLite store, qwen emits a structured **stream-json event stream** (verified Anthropic-shaped against ``qwen`` v0.18.1): we tail the ``--json-file`` NDJSON by byte offset and POST each new ``user`` / ``assistant`` message as an ``external_conversation_item`` event (which also seeds the session title). Event shapes consumed (others are ignored defensively): - ``{"type":"user","message":{"role":"user","content":[{"type":"text","text":...}]}}`` - ``{"type":"assistant","message":{"role":"assistant","content":[{"type":"text"| "thinking"|"tool_use",...}]}}`` — only ``text`` blocks are mirrored. - ``{"type":"control_request","request":{"subtype":"can_use_tool",...}, "request_id":...}`` and the matching ``control_response`` — the permission control plane. NOT handled here: the tool-approval mirror (:mod:`omnigent.qwen_native_permissions`) tails the same stream and surfaces these as web elicitation cards. This forwarder ignores them (they carry no transcript prose to mirror). Status (``running``/``idle``) is intentionally NOT posted here: the runner's PTY-activity watcher owns those edges for qwen-native (see :mod:`omnigent.runner.app`), exactly as for goose-/cursor-native. This module also hosts the **compaction mirror** (:func:`supervise_qwen_compaction_mirror`). qwen compaction (its *compression*) is invisible on the ``--json-file`` stream (``session_start``'s ``supported_events`` omits it — verified live, ``qwen`` v0.18.2), but qwen writes a ``{"type":"system","subtype":"chat_compression", "systemPayload":{"info":{originalTokenCount,newTokenCount,compressionStatus}}}`` record to its on-disk chat recording the instant compression finishes. The mirror tails that recording and POSTs ``external_compaction_status`` (``completed`` on success, ``failed`` otherwise) — the completion half of the web ``/compact`` → qwen ``/compress`` flow whose ``in_progress`` edge the runner raises on injection. It fires for both explicit ``/compress`` and auto-compaction. """ from __future__ import annotations import asyncio import contextlib import json import logging import os import re import time from collections.abc import Container, Iterable from dataclasses import dataclass from pathlib import Path import httpx from omnigent.qwen_native_bridge import events_file_path _logger = logging.getLogger(__name__) #: Seconds between event-file polls. qwen flushes events per streaming step, so a #: sub-second cadence keeps the mirrored chat tracking the terminal step by step. _DEFAULT_POLL_INTERVAL_S = 0.4 _POST_TIMEOUT_S = 30.0 # Supervisor backoff (mirrors goose_native_forwarder.supervise_goose_forwarder). _SUPERVISOR_INITIAL_BACKOFF_S = 1.0 _SUPERVISOR_MAX_BACKOFF_S = 30.0 _SUPERVISOR_HEALTHY_UPTIME_S = 60.0 _STATE_FILE = "qwen_forwarder.json" # Dedup window: the number of most-recently-posted event uuids persisted so a # truncation/relaunch re-read (offset rewinds to 0) doesn't re-post them. The # window must keep the *most recent* uuids, so ``seen`` is an insertion-ordered # mapping (a ``dict`` used as an ordered set), not a ``set`` — ``list(set)`` is # hash-ordered, which would make the ``[-_DEDUP_WINDOW:]`` cap keep an arbitrary # subset and re-post recent history on a long-session relaunch. _DEDUP_WINDOW = 512 def _new_seen(uuids: Iterable[str] | None = None) -> dict[str, None]: """Build the insertion-ordered dedup set (``dict`` used as an ordered set).""" return dict.fromkeys(uuids or []) # The executor injects ``[Attached: ]`` markers for web-UI attachments # before submitting; strip them from the mirrored bubble (the path is an internal # bridge detail). _ATTACHMENT_MARKER_RE = re.compile(r"\[Attached:[^\]]*\]") @dataclass class _ForwardState: """Durable forwarder cursor, persisted to ``bridge_dir/qwen_forwarder.json``. :param offset: Byte offset into the ``--json-file`` already consumed. The event file is append-only within a TUI lifetime; a relaunched terminal truncates it (see :func:`~omnigent.qwen_native_bridge.prepare_bridge_files`), which we detect as ``size < offset`` and reset to 0. :param seen_uuids: Recently posted event uuids, for idempotent dedup across a truncation/restart. Bounded to the most recent entries. """ offset: int = 0 seen_uuids: list[str] | None = None def _read_state(bridge_dir: Path) -> _ForwardState: """Load the persisted forward cursor, or a cold default.""" try: raw = (bridge_dir / _STATE_FILE).read_text(encoding="utf-8") data = json.loads(raw) except (OSError, ValueError): return _ForwardState(offset=0, seen_uuids=[]) offset = data.get("offset") seen = data.get("seen_uuids") return _ForwardState( offset=offset if isinstance(offset, int) and offset >= 0 else 0, seen_uuids=[u for u in seen if isinstance(u, str)] if isinstance(seen, list) else [], ) def _write_state(bridge_dir: Path, state: _ForwardState) -> bool: """Atomically persist the forward cursor (tmp write + rename).""" try: bridge_dir.mkdir(parents=True, exist_ok=True) tmp = bridge_dir / (_STATE_FILE + ".tmp") # Cap the dedup window so the state file can't grow unbounded. The list # is insertion-ordered (see _new_seen), so this keeps the most recent # _DEDUP_WINDOW uuids — the ones a relaunch re-read is most likely to hit. seen = (state.seen_uuids or [])[-_DEDUP_WINDOW:] tmp.write_text( json.dumps({"offset": state.offset, "seen_uuids": seen}), encoding="utf-8", ) os.replace(tmp, bridge_dir / _STATE_FILE) return True except OSError: _logger.warning("qwen forwarder could not persist state to %s", bridge_dir, exc_info=True) return False def clear_qwen_bridge_state(bridge_dir: Path) -> None: """Remove the persisted forward cursor so a re-created terminal starts clean.""" with contextlib.suppress(OSError): (bridge_dir / _STATE_FILE).unlink() @dataclass class _MirrorItem: """One conversation item ready to POST, plus the event uuid that produced it.""" uuid: str item_type: str item_data: dict[str, object] response_id: str def _text_from_content(content: object) -> str: """Join the ``text`` blocks of a stream-json message ``content`` array. ``thinking`` and ``tool_use`` blocks are skipped — only user-facing prose is mirrored into the chat bubble. Tolerant of a bare string or odd shapes so a schema tweak degrades to "best available text" rather than dropping the row. """ if isinstance(content, str): return content.strip() if not isinstance(content, list): return "" parts: list[str] = [] for block in content: if not isinstance(block, dict): continue if block.get("type") != "text": continue text = block.get("text") if isinstance(text, str): parts.append(text) return "".join(parts).strip() def _event_to_item(event: dict[str, object], agent_name: str) -> _MirrorItem | None: """Convert one qwen stream-json event to a mirror item, or ``None`` to skip it.""" etype = event.get("type") if etype not in ("user", "assistant"): # control_request / control_response (the permission control plane) carry # no transcript prose; the tool-approval mirror # (omnigent.qwen_native_permissions) owns them off the same stream. return None uuid = event.get("uuid") if not isinstance(uuid, str) or not uuid: return None message = event.get("message") if not isinstance(message, dict): return None text = _ATTACHMENT_MARKER_RE.sub("", _text_from_content(message.get("content"))).strip() if not text: return None # tool-only / thinking-only turn with no prose response_id = f"qwen:{uuid}" if etype == "user": return _MirrorItem( uuid=uuid, item_type="message", item_data={"role": "user", "content": [{"type": "input_text", "text": text}]}, response_id=response_id, ) return _MirrorItem( uuid=uuid, item_type="message", item_data={ "role": "assistant", "agent": agent_name, "content": [{"type": "output_text", "text": text}], }, response_id=response_id, ) def _read_new_events( events_file: Path, offset: int, seen: Container[str], agent_name: str ) -> tuple[list[_MirrorItem], int]: """Read NDJSON lines past *offset*, returning new mirror items + the new offset. Detects a truncated/recreated event file (``size < offset``) and rewinds to 0. Only fully terminated lines (ending in ``\\n``) are consumed; a trailing partial line is left for the next poll by not advancing past it. """ try: size = events_file.stat().st_size except OSError: return [], offset if size < offset: offset = 0 # file truncated by a relaunched terminal if size == offset: return [], offset try: with open(events_file, "rb") as fh: fh.seek(offset) data = fh.read(size - offset) except OSError: return [], offset # Only consume up to the last newline; keep any trailing partial line. last_nl = data.rfind(b"\n") if last_nl == -1: return [], offset # no complete line yet consumed = data[: last_nl + 1] new_offset = offset + len(consumed) items: list[_MirrorItem] = [] for raw in consumed.split(b"\n"): raw = raw.strip() if not raw: continue try: event = json.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError): continue # tolerate a malformed line rather than stalling the tail if not isinstance(event, dict): continue item = _event_to_item(event, agent_name) if item is not None and item.uuid not in seen: items.append(item) return items, new_offset async def _post_conversation_item( client: httpx.AsyncClient, *, session_id: str, item: _MirrorItem ) -> None: """POST one mirrored item as an ``external_conversation_item`` event.""" resp = await client.post( f"/v1/sessions/{session_id}/events", json={ "type": "external_conversation_item", "data": { "item_type": item.item_type, "item_data": item.item_data, "response_id": item.response_id, }, }, ) resp.raise_for_status() async def forward_qwen_events_to_session( *, base_url: str, headers: dict[str, str], session_id: str, bridge_dir: Path, agent_name: str, events_file: Path | None = None, poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S, auth: httpx.Auth | None = None, ) -> None: """Tail qwen's ``--json-file`` and mirror new messages into the AP session. Polls the event file past a persisted byte offset, posting each new user/assistant message as an ``external_conversation_item``. The offset + dedup set are persisted to ``bridge_dir`` so a supervisor restart resumes without re-posting. :param base_url: Omnigent server base URL. :param headers: Static HTTP headers (auth normally via ``auth``). :param session_id: Omnigent session/conversation id. :param bridge_dir: The qwen-native bridge dir (holds the persisted cursor). :param agent_name: Agent label stamped on mirrored assistant items. :param events_file: qwen ``--json-file`` path; defaults to the bridge dir's. :param poll_interval_s: Seconds between event-file polls. :param auth: Optional refresh-capable httpx Auth for remote deployments. :returns: Never normally returns; cancel the task to stop it. """ target = events_file or events_file_path(bridge_dir) persisted = _read_state(bridge_dir) offset = persisted.offset seen = _new_seen(persisted.seen_uuids) timeout = httpx.Timeout(_POST_TIMEOUT_S) async with httpx.AsyncClient( base_url=base_url, headers=headers, auth=auth, timeout=timeout ) as client: while True: try: items, new_offset = await asyncio.to_thread( _read_new_events, target, offset, seen, agent_name ) for item in items: await _post_conversation_item(client, session_id=session_id, item=item) seen[item.uuid] = None if new_offset != offset or items: offset = new_offset _write_state( bridge_dir, _ForwardState(offset=offset, seen_uuids=list(seen)), ) except asyncio.CancelledError: raise except Exception: _logger.exception( "qwen forwarder poll failed; session=%s bridge_dir=%s", session_id, bridge_dir, ) await asyncio.sleep(poll_interval_s) def _supervisor_monotonic() -> float: """Indirection so tests can stub the supervisor's clock.""" return time.monotonic() async def _supervisor_sleep(seconds: float) -> None: """Indirection so tests can stub the supervisor's backoff sleep.""" await asyncio.sleep(seconds) async def supervise_qwen_forwarder( *, base_url: str, headers: dict[str, str], session_id: str, bridge_dir: Path, agent_name: str, events_file: Path | None = None, poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S, auth: httpx.Auth | None = None, ) -> None: """Run :func:`forward_qwen_events_to_session` under a restart supervisor. Mirrors :func:`omnigent.goose_native_forwarder.supervise_goose_forwarder`: bounded exponential backoff, :class:`asyncio.CancelledError` propagates for clean teardown, and the persisted offset means restarts resume exactly where they left off. :returns: Never normally returns; cancel the task to stop it. """ backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S while True: run_started_at = _supervisor_monotonic() crash_exc: Exception | None = None try: await forward_qwen_events_to_session( base_url=base_url, headers=headers, session_id=session_id, bridge_dir=bridge_dir, agent_name=agent_name, events_file=events_file, poll_interval_s=poll_interval_s, auth=auth, ) _logger.warning( "qwen forwarder returned unexpectedly; restarting; session=%s bridge_dir=%s", session_id, bridge_dir, ) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 — supervisor restarts on any Exception crash_exc = exc if _supervisor_monotonic() - run_started_at >= _SUPERVISOR_HEALTHY_UPTIME_S: backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S if crash_exc is not None: _logger.error( "qwen forwarder crashed; restarting in %.1fs; session=%s bridge_dir=%s", backoff_s, session_id, bridge_dir, exc_info=crash_exc, ) await _supervisor_sleep(backoff_s) backoff_s = min(backoff_s * 2.0, _SUPERVISOR_MAX_BACKOFF_S) # --- Compaction mirror (chat-recording tail → external_compaction_status) ------ #: qwen's CompressionStatus enum (verified, qwen v0.18.2): 1 = COMPRESSED (success), #: 2/3 = COMPRESSION_FAILED_*. 1 → completed; anything else → failed. _COMPRESSION_STATUS_OK = 1 def _compaction_status_from_record(record: dict[str, object]) -> str | None: """Map a chat-recording line to a compaction status, or ``None`` to skip it. Returns ``"completed"`` for a successful ``chat_compression`` record, ``"failed"`` for a failed one, and ``None`` for any other line. """ if record.get("type") != "system" or record.get("subtype") != "chat_compression": return None payload = record.get("systemPayload") info = payload.get("info") if isinstance(payload, dict) else None status = info.get("compressionStatus") if isinstance(info, dict) else None return "completed" if status == _COMPRESSION_STATUS_OK else "failed" def _read_new_compaction_statuses(recording: Path, offset: int) -> tuple[list[str], int]: """Read NDJSON lines past *offset*, returning new compaction statuses + offset. Same tail discipline as :func:`_read_new_events` (truncation rewind, only newline-terminated lines consumed), but scoped to ``chat_compression`` records. """ try: size = recording.stat().st_size except OSError: return [], offset # recording not created yet — retry next poll if size < offset: offset = 0 # truncated/recreated if size == offset: return [], offset try: with open(recording, "rb") as fh: fh.seek(offset) data = fh.read(size - offset) except OSError: return [], offset last_nl = data.rfind(b"\n") if last_nl == -1: return [], offset consumed = data[: last_nl + 1] new_offset = offset + len(consumed) statuses: list[str] = [] for raw in consumed.split(b"\n"): raw = raw.strip() if not raw: continue try: record = json.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError): continue if not isinstance(record, dict): continue status = _compaction_status_from_record(record) if status is not None: statuses.append(status) return statuses, new_offset async def _post_external_compaction_status( client: httpx.AsyncClient, *, session_id: str, status: str ) -> None: """POST one ``external_compaction_status`` event; the server republishes the SSE.""" resp = await client.post( f"/v1/sessions/{session_id}/events", json={"type": "external_compaction_status", "data": {"status": status}}, ) resp.raise_for_status() async def supervise_qwen_compaction_mirror( *, base_url: str, headers: dict[str, str], session_id: str, recording_path: Path, auth: httpx.Auth | None = None, poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S, ) -> None: """Tail qwen's chat recording and mirror compaction completions to the session. Seeds the read offset at the recording's current end of file so only compactions that happen *after* launch are posted — a resumed session's recording already holds prior ``chat_compression`` records, and re-posting them would flash stale "Conversation compacted" dividers. Self-healing: any error is logged and the loop continues (a transient blip never abandons the mirror); cancellation propagates for clean teardown. Best-effort, like the approval mirror — the offset is in-memory, so a forwarder restart may miss a compaction that lands during the gap, which only drops one divider. :param recording_path: qwen's chat recording for this session (see :func:`omnigent.qwen_native_bridge.qwen_session_recording_path`). """ try: offset = recording_path.stat().st_size except OSError: offset = 0 # not created yet; first poll reads from the start timeout = httpx.Timeout(_POST_TIMEOUT_S) async with httpx.AsyncClient( base_url=base_url, headers=headers, auth=auth, timeout=timeout ) as client: while True: try: statuses, offset = await asyncio.to_thread( _read_new_compaction_statuses, recording_path, offset ) for status in statuses: await _post_external_compaction_status( client, session_id=session_id, status=status ) except asyncio.CancelledError: raise except Exception: _logger.exception( "qwen compaction mirror poll failed; session=%s recording=%s", session_id, recording_path, ) await asyncio.sleep(poll_interval_s)