from __future__ import annotations


def _load_main_globals() -> None:
    import backend.main as main

    globals().update({name: value for name, value in vars(main).items() if not name.startswith("__")})


def build_agent_runtime_helpers(*, manager, run_id, websocket, run_settings, run_budget, model, fast_model, nvidia_api_key, context_images, _halt_run, _check_runtime):
    _load_main_globals()
    if True:
        # ── Agent lifecycle helpers ────────────────────────────────────────────────
        def _agent_display_name(agent: str) -> str:
            raw = str(agent or "agent").strip().lower()
            mapping = {
                "planner": "Planner",
                "researcher": "Researcher",
                "curator": "Curator",
                "executor": "Executor",
                "validator": "Validator",
                "reportout": "Report Out",
                "all": "Workflow",
            }
            if raw in mapping:
                return mapping[raw]
            if not raw:
                return "Agent"
            return raw[:1].upper() + raw[1:]

        def _agent_status_narration(agent: str, state: str, msg: str = "") -> str:
            label = _agent_display_name(agent)
            detail = _clip_agent_message(msg, 220)
            state_key = str(state or "").strip().lower()
            if state_key == "active":
                return f"{label} is working on: {detail}" if detail else f"{label} is working."
            if state_key == "done":
                return f"{label} reports: {detail}" if detail else f"{label} finished this part."
            if state_key == "skip":
                return f"{label} skips this part: {detail}" if detail else f"{label} skips this part."
            if state_key == "error":
                return f"{label} flags an issue: {detail}" if detail else f"{label} hit an issue."
            if state_key == "idle":
                return f"{label} is idle."
            return f"{label}: {detail}" if detail else label

        def _handoff_narration(from_a: str, to_a: str, summary: str = "", question: str = "") -> str:
            from_label = _agent_display_name(from_a)
            to_label = _agent_display_name(to_a)
            summary_text = _clip_agent_message(summary, 220)
            question_text = _clip_agent_message(question, 220)
            lines = [f"{from_label} asks {to_label} to take the next step."]
            if summary_text:
                lines.append(f"{from_label} shares with {to_label}: {summary_text}")
            if question_text:
                lines.append(f"{from_label} asks {to_label}: {question_text}")
            return "\n".join(lines)

        async def agent_event(agent: str, state: str, step_index: int = -1, msg: str = ""):
            await manager.send_message({
                "type": "agent_status", "agent": agent,
                "state": state, "step_index": step_index, "msg": msg,
                "narration": _agent_status_narration(agent, state, msg),
                "run_id": run_id,
            }, websocket)
    
        async def handoff(from_a: str, to_a: str, step_index: int = -1, summary: str = "", question: str = ""):
            narration = _handoff_narration(from_a, to_a, summary, question)
            await manager.send_message({
                "type": "agent_handoff", "from": from_a, "to": to_a,
                "step_index": step_index, "summary": summary, "question": question,
                "narration": narration,
                "run_id": run_id,
            }, websocket)
    
        def _clip_agent_message(text: str, limit: int = 180) -> str:
            cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
            if len(cleaned) <= limit:
                return cleaned
            return cleaned[: max(0, limit - 1)].rstrip() + "…"
    
        def _visible_agent_message(default_msg: str, detailed_msg: str = "") -> str:
            detailed = _clip_agent_message(detailed_msg, 220)
            fallback = _clip_agent_message(default_msg, 140)
            if detailed and bool(run_settings.get("show_progress_details", True)):
                return detailed
            return fallback
    
        def _step_focus(step_desc: str, limit: int = 72) -> str:
            return _clip_agent_message(step_desc, limit)
    
        def _plan_preview(step_items: list[dict], limit: int = 2) -> str:
            preview = [
                _step_focus(str((item or {}).get("step") or ""), 44)
                for item in list(step_items or [])[:limit]
                if str((item or {}).get("step") or "").strip()
            ]
            return "; ".join(preview)
    
        def _join_reason_labels(labels: list[str]) -> str:
            values = [str(label or "").strip() for label in labels if str(label or "").strip()]
            if not values:
                return ""
            if len(values) == 1:
                return values[0]
            if len(values) == 2:
                return f"{values[0]} and {values[1]}"
            return ", ".join(values[:-1]) + f", and {values[-1]}"
    
        # ── Token tracking ─────────────────────────────────────────────────────────
        def _est_tokens(text: str) -> int:
            return max(1, len(text) // 4)
    
        async def track_tokens(agent: str, prompt: str, response: str):
            n = _est_tokens(prompt) + _est_tokens(response)
            manager.token_stats["total"] = manager.token_stats.get("total", 0) + n
            by_agent = manager.token_stats.setdefault("by_agent", {})
            by_agent[agent] = by_agent.get(agent, 0) + n
            run_budget["tokens"] += n
            await manager.send_message({
                "type": "token_usage", "agent": agent, "tokens": n,
                "total": manager.token_stats["total"],
                "run_total": run_budget["tokens"],
                "by_agent": dict(by_agent),
            }, websocket)
            if run_budget["tokens"] > run_budget["max_total_tokens"]:
                await _halt_run(
                    "BUDGET_TOKENS",
                    f"Run stopped: token budget exceeded ({run_budget['tokens']} / {run_budget['max_total_tokens']}).",
                )
    
        async def call_with_vision(prompt: str, system: str = "", num_ctx: int = 24576, thinking: bool = True):
            if not await _check_runtime("llm_call"):
                return "[Run halted: runtime budget exceeded]"
            if run_budget["halted"]:
                return f"[Run halted: {run_budget['halt_reason']}]"
            active_model = _select_runnable_model(
                model,
                fast_model,
                (run_settings.get("fallback_model") or "").strip(),
                settings=run_settings,
            )
            await manager.send_message({"type": "model_active", "model": active_model}, websocket)
            try:
                loop = asyncio.get_event_loop()
                if active_model.startswith(NVIDIA_PREFIX):
                    nvidia_model_id = active_model[len(NVIDIA_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_nvidia_sync, prompt, system, nvidia_model_id, thinking),
                        _PRIMARY_MODEL_TIMEOUT_SECONDS,
                        "primary model",
                    )
                elif active_model.startswith(OPENAI_PREFIX):
                    openai_model_id = active_model[len(OPENAI_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_openai_sync, prompt, system, openai_model_id, thinking),
                        _PRIMARY_MODEL_TIMEOUT_SECONDS,
                        "primary model",
                    )
                elif active_model.startswith(GEMINI_PREFIX):
                    gemini_model_id = active_model[len(GEMINI_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_gemini_sync, prompt, system, gemini_model_id, thinking),
                        _PRIMARY_MODEL_TIMEOUT_SECONDS,
                        "primary model",
                    )
                elif active_model.startswith(LLAMACPP_PREFIX):
                    llamacpp_id = active_model[len(LLAMACPP_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_llamacpp_sync, prompt, system, llamacpp_id, thinking),
                        _PRIMARY_MODEL_TIMEOUT_SECONDS,
                        "primary model",
                    )
                else:
                    def _call():
                        _ollama_ensure_model(active_model)
                        messages = []
                        if system:
                            messages.append({"role": "system", "content": system})
                        user_msg = {"role": "user", "content": prompt}
                        if context_images:
                            user_msg["images"] = context_images
                        messages.append(user_msg)
                        payload = {
                            "model": active_model, "messages": messages,
                            "stream": False,
                            "options": {"num_ctx": num_ctx, "temperature": 0.7},
                            "keep_alive": "10m",
                        }
                        if thinking:
                            payload["think"] = True
                        try:
                            res = requests.post(
                                f"{OLLAMA_URL}/chat",
                                json=payload,
                                timeout=_PRIMARY_MODEL_TIMEOUT_SECONDS,
                            )
                            res.raise_for_status()
                            return (res.json().get("message") or {}).get("content", "")
                        except Exception as e:
                            _mark_model_temporarily_unavailable(active_model, str(e))
                            return f"Error calling Ollama: {e}"
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, _call),
                        _PRIMARY_MODEL_TIMEOUT_SECONDS,
                        "primary model",
                    )
                # ── Failover: try fallback model on error ──
                if _is_llm_error(result):
                    fb = (run_settings.get("fallback_model") or "").strip()
                    if fb and fb != active_model:
                        await manager.send_message({"type": "info", "content": f"Primary model failed, switching to fallback model…"}, websocket)
                        fb_result = await _call_single_model_with_timeout(
                            _call_single_model(prompt, fb, system=system, num_ctx=num_ctx, thinking=thinking),
                            _FALLBACK_MODEL_TIMEOUT_SECONDS,
                            "fallback model",
                        )
                        if not _is_llm_error(fb_result):
                            result = fb_result
                await track_tokens("main", prompt, result)
                if run_budget["halted"] or not await _check_runtime("llm_post"):
                    return f"[Run halted: {run_budget['halt_reason'] or 'stopped'}]"
                return result
            finally:
                await manager.send_message({"type": "model_idle"}, websocket)
    
        async def call_fast(prompt: str, system: str = "", num_ctx: int = 16384) -> str:
            if not await _check_runtime("fast_llm_call"):
                return "[Run halted: runtime budget exceeded]"
            if run_budget["halted"]:
                return f"[Run halted: {run_budget['halt_reason']}]"
            fm = fast_model.strip()
            if not fm:
                return await call_with_vision(prompt, system, num_ctx=num_ctx, thinking=False)
            active_model = _select_runnable_model(
                fm,
                model,
                (run_settings.get("fallback_model") or "").strip(),
                settings=run_settings,
            )
            await manager.send_message({"type": "model_active", "model": active_model}, websocket)
            try:
                loop = asyncio.get_event_loop()
                if active_model.startswith(NVIDIA_PREFIX):
                    nvidia_id = active_model[len(NVIDIA_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_nvidia_sync, prompt, system, nvidia_id, False),
                        _FAST_MODEL_TIMEOUT_SECONDS,
                        "fast model",
                    )
                elif active_model.startswith(OPENAI_PREFIX):
                    openai_id = active_model[len(OPENAI_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_openai_sync, prompt, system, openai_id, False),
                        _FAST_MODEL_TIMEOUT_SECONDS,
                        "fast model",
                    )
                elif active_model.startswith(GEMINI_PREFIX):
                    gemini_id = active_model[len(GEMINI_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_gemini_sync, prompt, system, gemini_id, False),
                        _FAST_MODEL_TIMEOUT_SECONDS,
                        "fast model",
                    )
                elif active_model.startswith(LLAMACPP_PREFIX):
                    llamacpp_id = active_model[len(LLAMACPP_PREFIX):]
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, call_llamacpp_sync, prompt, system, llamacpp_id, False),
                        _FAST_MODEL_TIMEOUT_SECONDS,
                        "fast model",
                    )
                else:
                    def _fc():
                        _ollama_ensure_model(active_model)
                        messages = []
                        if system:
                            messages.append({"role": "system", "content": system})
                        messages.append({"role": "user", "content": prompt})
                        try:
                            res = requests.post(
                                f"{OLLAMA_URL}/chat",
                                json={"model": active_model, "messages": messages, "stream": False,
                                      "options": {"num_ctx": num_ctx, "temperature": 0.7}, "keep_alive": "10m"},
                                timeout=_FAST_MODEL_TIMEOUT_SECONDS,
                            )
                            res.raise_for_status()
                            return (res.json().get("message") or {}).get("content", "")
                        except Exception as e:
                            _mark_model_temporarily_unavailable(active_model, str(e))
                            return f"Error calling fast model: {e}"
                    result = await _call_single_model_with_timeout(
                        loop.run_in_executor(None, _fc),
                        _FAST_MODEL_TIMEOUT_SECONDS,
                        "fast model",
                    )
                # ── Failover: try fallback model on error ──
                if _is_llm_error(result):
                    fb = (run_settings.get("fallback_model") or "").strip()
                    if fb and fb != active_model:
                        await manager.send_message({"type": "info", "content": f"Fast model failed, switching to fallback model…"}, websocket)
                        fb_result = await _call_single_model_with_timeout(
                            _call_single_model(prompt, fb, system=system, num_ctx=num_ctx, thinking=False),
                            _FAST_MODEL_TIMEOUT_SECONDS,
                            "fallback model",
                        )
                        if not _is_llm_error(fb_result):
                            result = fb_result
                await track_tokens("fast", prompt, result)
                if run_budget["halted"] or not await _check_runtime("fast_post"):
                    return f"[Run halted: {run_budget['halt_reason'] or 'stopped'}]"
                return result
            finally:
                await manager.send_message({"type": "model_idle"}, websocket)
    
        async def _stream_to_ws(prompt: str, system: str, num_ctx: int = 32768, agent: str = "Executor") -> str:
            if not await _check_runtime("stream_call"):
                return "[Run halted: runtime budget exceeded]"
            if run_budget["halted"]:
                return f"[Run halted: {run_budget['halt_reason']}]"
            await manager.send_message({"type": "model_active", "model": model}, websocket)
            try:
                loop = asyncio.get_event_loop()
                queue: asyncio.Queue = asyncio.Queue()
    
                def _run_ollama_stream(ctx_size: int) -> bool:
                    _ollama_ensure_model(model)
                    messages = []
                    if system:
                        messages.append({"role": "system", "content": system})
                    user_msg = {"role": "user", "content": prompt}
                    if context_images:
                        user_msg["images"] = context_images
                    messages.append(user_msg)
                    stream_payload = {
                        "model": model, "messages": messages, "stream": True,
                        "options": {"num_ctx": ctx_size, "temperature": 0.7},
                        "keep_alive": "10m",
                        "think": True,
                    }
                    try:
                        with requests.post(
                            f"{OLLAMA_URL}/chat", json=stream_payload, stream=True, timeout=300
                        ) as resp:
                            if resp.status_code == 500:
                                return False
                            resp.raise_for_status()
                            _ollama_thinking_started = False
                            for raw_line in resp.iter_lines():
                                if not manager.active_runs.get(run_id, False):
                                    break
                                if raw_line:
                                    chunk = json.loads(raw_line)
                                    if chunk.get("error"):
                                        loop.call_soon_threadsafe(
                                            queue.put_nowait,
                                            f"\n[Stream error: {chunk['error']}]"
                                        )
                                        return True
                                    msg = chunk.get("message") or {}
                                    think_token = msg.get("thinking", "")
                                    if think_token:
                                        if not _ollama_thinking_started:
                                            loop.call_soon_threadsafe(queue.put_nowait, "<think>")
                                            _ollama_thinking_started = True
                                        loop.call_soon_threadsafe(queue.put_nowait, think_token)
                                    token = msg.get("content", "")
                                    if token:
                                        if _ollama_thinking_started:
                                            loop.call_soon_threadsafe(queue.put_nowait, "</think>")
                                            _ollama_thinking_started = False
                                        loop.call_soon_threadsafe(queue.put_nowait, token)
                                    if chunk.get("done"):
                                        if _ollama_thinking_started:
                                            loop.call_soon_threadsafe(queue.put_nowait, "</think>")
                                        break
                    except Exception as e:
                        loop.call_soon_threadsafe(queue.put_nowait, f"\n[Stream error: {e}]")
                    return True
    
                def _run():
                    try:
                        if model.startswith(NVIDIA_PREFIX):
                            nvidia_id = model[len(NVIDIA_PREFIX):]
                            if not nvidia_api_key:
                                loop.call_soon_threadsafe(queue.put_nowait, "\n[Stream error: NVIDIA API key not configured in Settings.]")
                                return
                            msgs = []
                            if system:
                                msgs.append({"role": "system", "content": system})
                            msgs.append({"role": "user", "content": prompt})
                            stream_payload_nv = {
                                "model": nvidia_id, "messages": msgs,
                                "max_tokens": 32768, "temperature": 0.7,
                                "stream": True,
                            }
                            if _nvidia_supports_thinking(nvidia_id):
                                stream_payload_nv["chat_template_kwargs"] = {"thinking": True}
                            with requests.post(
                                NVIDIA_URL,
                                headers={"Authorization": f"Bearer {nvidia_api_key}", "Accept": "text/event-stream"},
                                json=stream_payload_nv,
                                stream=True, timeout=300,
                            ) as resp:
                                for raw_line in resp.iter_lines():
                                    if not manager.active_runs.get(run_id, False):
                                        break
                                    line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
                                    if line.startswith("data: "):
                                        payload = line[6:].strip()
                                        if payload == "[DONE]":
                                            break
                                        try:
                                            chunk = json.loads(payload)
                                            token = chunk["choices"][0]["delta"].get("content", "")
                                            if token:
                                                loop.call_soon_threadsafe(queue.put_nowait, token)
                                        except Exception:
                                            pass
                        elif model.startswith(OPENAI_PREFIX):
                            openai_id = model[len(OPENAI_PREFIX):]
                            oai_key = (run_settings.get("openai_api_key", "") or "").strip()
                            if not oai_key:
                                loop.call_soon_threadsafe(queue.put_nowait, "\n[Stream error: OpenAI API key not configured in Settings.]")
                                return
                            msgs = []
                            if system:
                                msgs.append({"role": "system", "content": system})
                            msgs.append({"role": "user", "content": prompt})
                            oai_payload = {"model": openai_id, "messages": msgs, "max_tokens": 16384,
                                           "temperature": 0.7, "stream": True}
                            if _openai_is_reasoning(openai_id):
                                oai_payload["reasoning_effort"] = "high"
                            with requests.post(
                                OPENAI_URL,
                                headers={"Authorization": f"Bearer {oai_key}", "Content-Type": "application/json"},
                                json=oai_payload,
                                stream=True, timeout=300,
                            ) as resp:
                                for raw_line in resp.iter_lines():
                                    if not manager.active_runs.get(run_id, False):
                                        break
                                    line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
                                    if line.startswith("data: "):
                                        payload_str = line[6:].strip()
                                        if payload_str == "[DONE]":
                                            break
                                        try:
                                            chunk = json.loads(payload_str)
                                            token = chunk["choices"][0]["delta"].get("content", "")
                                            if token:
                                                loop.call_soon_threadsafe(queue.put_nowait, token)
                                        except Exception:
                                            pass
                        elif model.startswith(GEMINI_PREFIX):
                            gemini_id = model[len(GEMINI_PREFIX):]
                            gem_key = (run_settings.get("gemini_api_key", "") or "").strip()
                            if not gem_key:
                                loop.call_soon_threadsafe(queue.put_nowait, "\n[Stream error: Gemini API key not configured in Settings.]")
                                return
                            gem_url = f"{GEMINI_URL}/{gemini_id}:streamGenerateContent?alt=sse&key={gem_key}"
                            contents = []
                            if system:
                                contents.append({"role": "user", "parts": [{"text": system}]})
                                contents.append({"role": "model", "parts": [{"text": "Understood."}]})
                            contents.append({"role": "user", "parts": [{"text": prompt}]})
                            gem_gen_config = {"temperature": 0.7, "maxOutputTokens": 16384}
                            if _gemini_supports_thinking(gemini_id):
                                gem_gen_config["thinkingConfig"] = {"thinkingBudget": 8192}
                            with requests.post(
                                gem_url,
                                headers={"Content-Type": "application/json"},
                                json={"contents": contents, "generationConfig": gem_gen_config},
                                stream=True, timeout=300,
                            ) as resp:
                                for raw_line in resp.iter_lines():
                                    if not manager.active_runs.get(run_id, False):
                                        break
                                    line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
                                    if line.startswith("data: "):
                                        payload_str = line[6:].strip()
                                        try:
                                            chunk = json.loads(payload_str)
                                            parts_arr = chunk.get("candidates", [{}])[0].get("content", {}).get("parts", [])
                                            for p in parts_arr:
                                                txt = p.get("text", "")
                                                if txt:
                                                    loop.call_soon_threadsafe(queue.put_nowait, txt)
                                        except Exception:
                                            pass
                        elif model.startswith(LLAMACPP_PREFIX):
                            llamacpp_id = model[len(LLAMACPP_PREFIX):]
                            s_lc = _load_settings()
                            llamacpp_url = (s_lc.get("llamacpp_url") or LLAMACPP_URL).rstrip("/")
                            msgs = []
                            if system:
                                msgs.append({"role": "system", "content": system})
                            msgs.append({"role": "user", "content": prompt})
                            payload_lc: dict = {
                                "messages": msgs, "temperature": 0.7,
                                "max_tokens": 8192, "stream": True,
                            }
                            if llamacpp_id:
                                payload_lc["model"] = llamacpp_id
                            with requests.post(
                                f"{llamacpp_url}/v1/chat/completions",
                                headers={"Content-Type": "application/json"},
                                json=payload_lc, stream=True, timeout=300,
                            ) as resp:
                                for raw_line in resp.iter_lines():
                                    if not manager.active_runs.get(run_id, False):
                                        break
                                    line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
                                    if line.startswith("data: "):
                                        payload_str = line[6:].strip()
                                        if payload_str == "[DONE]":
                                            break
                                        try:
                                            chunk = json.loads(payload_str)
                                            token = chunk["choices"][0]["delta"].get("content", "")
                                            if token:
                                                loop.call_soon_threadsafe(queue.put_nowait, token)
                                        except Exception:
                                            pass
                        else:
                            success = _run_ollama_stream(num_ctx)
                            if not success:
                                fallback_ctx = min(num_ctx // 2, 16384)
                                loop.call_soon_threadsafe(
                                    queue.put_nowait,
                                    ("info", f"Context window {num_ctx} too large, retrying with {fallback_ctx}…")
                                )
                                _run_ollama_stream(fallback_ctx)
                    except Exception as e:
                        loop.call_soon_threadsafe(queue.put_nowait, f"\n[Stream error: {e}]")
                    finally:
                        loop.call_soon_threadsafe(queue.put_nowait, None)
    
                await manager.send_message({"type": "stream_start", "agent": agent}, websocket)
                t = threading.Thread(target=_run, daemon=True)
                t.start()
                parts: list[str] = []
                _show_thinking = run_settings.get("show_thinking", False)
                _in_think_block = False
                while True:
                    try:
                        token = await asyncio.wait_for(queue.get(), timeout=0.5)
                    except asyncio.TimeoutError:
                        if not manager.active_runs.get(run_id, False):
                            break
                        continue
                    if token is None:
                        break
                    if isinstance(token, tuple) and token[0] == "info":
                        await manager.send_message({"type": "info", "content": token[1]}, websocket)
                    else:
                        parts.append(token)
                        # ── Think-tag streaming filter ───────────────────────
                        # Tokens arrive character-by-character or in small chunks.
                        # Track whether we are inside a <think> block.
                        remaining = token
                        while remaining:
                            if _in_think_block:
                                end_idx = remaining.find("</think>")
                                if end_idx == -1:
                                    # Entire token is inside think block
                                    if _show_thinking:
                                        await manager.send_message({"type": "stream_thinking", "content": remaining}, websocket)
                                    remaining = ""
                                else:
                                    # Think block ends in this token
                                    think_part = remaining[:end_idx]
                                    if _show_thinking and think_part:
                                        await manager.send_message({"type": "stream_thinking", "content": think_part}, websocket)
                                    if _show_thinking:
                                        await manager.send_message({"type": "stream_thinking_end"}, websocket)
                                    remaining = remaining[end_idx + len("</think>"):]
                                    _in_think_block = False
                            else:
                                start_idx = remaining.find("<think>")
                                if start_idx == -1:
                                    # Normal content, no think tag
                                    if remaining:
                                        await manager.send_message({"type": "stream_token", "content": remaining}, websocket)
                                    remaining = ""
                                else:
                                    # Think block starts in this token
                                    before = remaining[:start_idx]
                                    if before:
                                        await manager.send_message({"type": "stream_token", "content": before}, websocket)
                                    if _show_thinking:
                                        await manager.send_message({"type": "stream_thinking_start"}, websocket)
                                    remaining = remaining[start_idx + len("<think>"):]
                                    _in_think_block = True
                full_text = "".join(parts)
                await manager.send_message({"type": "stream_end", "full": full_text}, websocket)
                await track_tokens("stream", prompt, full_text)
                # Notify if show_thinking is on but model produced no thinking tokens
                if _show_thinking and "<think>" not in full_text:
                    await manager.send_message({
                        "type": "info",
                        "content": f"Show Thinking is enabled, but the current model ({model}) does not support thinking. "
                                   f"Models with thinking support include: DeepSeek R1, QwQ, Qwen3.",
                    }, websocket)
                if not manager.active_runs.get(run_id, False):
                    run_budget["halted"] = True
                    run_budget["halt_reason"] = run_budget["halt_reason"] or "USER_STOPPED"
                return full_text
            finally:
                await manager.send_message({"type": "model_idle"}, websocket)
    return {
        "agent_event": agent_event,
        "handoff": handoff,
        "clip_agent_message": _clip_agent_message,
        "visible_agent_message": _visible_agent_message,
        "step_focus": _step_focus,
        "plan_preview": _plan_preview,
        "join_reason_labels": _join_reason_labels,
        "est_tokens": _est_tokens,
        "track_tokens": track_tokens,
        "call_with_vision": call_with_vision,
        "call_fast": call_fast,
        "stream_to_ws": _stream_to_ws,
    }