"""
AgentOrchestrator — main application hub.

This module creates the FastAPI app, registers routes and the WebSocket
endpoint, and contains the core run_orchestrator() loop.  All supporting
                _initial_task_state = resolve_task_turn(goal, session)
                if _initial_task_state:
                    session["task_state"] = _initial_task_state
code lives in sibling modules:

    config.py      – shared constants, settings, auth, update, Google/Outlook helpers
    models.py      – LLM providers (Ollama, NVIDIA, OpenAI, Gemini)
    memory.py      – semantic memory, sessions, user profile, agent identity
    tools_impl.py  – dynamic tool loading, built-in tool executors, _execute_tool
    integrations.py– Slack bot, Google OAuth routes, schedules REST, upload, etc.
"""

import asyncio
import collections
import hashlib
import hmac
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unicodedata
import uuid
import zipfile
from contextlib import asynccontextmanager
from typing import Any

# Force UTF-8 output on Windows to prevent UnicodeEncodeError crashing ASGI
if sys.platform == "win32":
    import io

    def _should_wrap_win_stream(stream, original_stream) -> bool:
        return (
            stream is not None
            and original_stream is not None
            and stream is original_stream
            and hasattr(stream, "buffer")
        )

    if _should_wrap_win_stream(sys.stdout, sys.__stdout__):
        sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    if _should_wrap_win_stream(sys.stderr, sys.__stderr__):
        sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
from contextvars import ContextVar
from datetime import datetime, timedelta
from html import unescape
from pathlib import Path

import requests
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse

# ── Imports from backend sub-modules ──────────────────────────────────────────

from backend.config import (
    # Feature flags
    OUTLOOK_SUPPORT, GOOGLE_API_SUPPORT,
    PLAYWRIGHT_SUPPORT,
    # Paths & constants
    OUTPUT_DIR, TOOLS_DIR, MEMORY_DIR, MEMORY_INDEX_FILE,
    SETTINGS_FILE, VERSION_FILE,
    OLLAMA_URL, NVIDIA_URL, OPENAI_URL, GEMINI_URL,
    NVIDIA_PREFIX, OPENAI_PREFIX, GEMINI_PREFIX,
    LLAMACPP_URL, LLAMACPP_PREFIX,
    UPDATE_BASE_URL,
    # Settings
    DEFAULT_SETTINGS, _SECRET_KEYS, _load_settings, _save_settings,
    _migrate_plaintext_secrets,
    # Auth
    API_AUTH_TOKEN, _BearerAuthMiddleware,
    # Tool helpers
    _BUILTIN_TOOL_NAMES, _PROMOTED_CUSTOM_BUILTIN_NAMES, _normalize_tool_name,
    # Model-aware context sizing
    _model_ctx, _is_cloud_model,
    # Version / update
    _get_local_version, _is_remote_newer,
    _default_manifest_url, _candidate_manifest_urls,
    _fetch_update_manifest, _is_dns_resolution_error,
    _apply_update_from_extracted_dir,
    # Google credential helpers
    _google_get_credentials, _google_service,
    # Microsoft
    _microsoft_graph_headers,
    # Schedules
    _load_schedules, _save_schedules,
)

from backend.models import (
    _model_cache,
    _ollama_ensure_model, _ollama_unload_all,
    call_nvidia_sync, call_openai_sync, call_gemini_sync, call_llamacpp_sync, call_ollama,
    _call_text_model, _call_text_model_candidates, _call_single_model, _is_llm_error,
    _nvidia_supports_thinking, _openai_is_reasoning, _gemini_supports_thinking,
    _unique_model_candidates,
    get_ollama_models,
)

from backend.memory import (
    _load_session, _save_session,
    _recall_memory_bundle, _recall_memory_deep,
    _update_user_profile_from_text, _enrich_user_profile_llm,
    _detect_and_store_correction, _run_self_reflection,
    _load_soul_text, _load_user_text,
    _compose_agent_system, _current_date_line,
    _generate_session_followup,
    _compact_chat_history,
    _get_user_preferences, _consolidate_memory_sessions, _episode_recall,
    _reindex_all_sessions, _prune_with_usage,
    invalidate_agent_system_cache,
    humanize_system_log,
    log_system_event, read_system_log, prune_system_log,
)
from backend.wiki_memory import (
    maybe_capture_session_wiki_draft,
    retrieve_wiki_context,
    should_retrieve_wiki_context,
)

from backend.task_understanding import (
    ensure_session_task_state,
    task_state_requires_live_data,
    task_state_display_goal,
    task_state_to_direct_intent,
    split_compound_instructions,
    _extract_explicit_date_range,
    _EMAIL_MARKERS_RE,
    _CALENDAR_MARKERS_RE,
    _SEARCH_BROWSER_RE,
    _SEARCH_DOMAIN_RE,
    _SEARCH_VERB_RE,
    _SEARCH_WEB_RE,
    _SEARCH_WORKSPACE_RE,
)
from backend.intent_frames import (
    appointment_intent_from_frame,
    appointment_intent_to_frame,
    build_intent_frame,
    email_intent_from_frame,
    email_intent_to_frame,
    intent_frame_to_direct_intent,
    intent_frame_from_payload,
)

from backend.direct_accelerators import (
    _build_direct_tool_calls,
    _detect_calendar_days_ahead,
    _detect_email_source_scope,
    _detect_requested_email_count,
    _detect_time_filter,
    _extract_email_search_hint,
    _format_direct_result,
    _heuristic_readonly_lookup_intent,
    _match_folder_profile,
    _merge_direct_intent,
    _normalize_email_search_query,
    detect_extract_source_request,
    resolve_direct_acceleration,
)
import backend.direct_accelerators as _direct_accel_module
from backend.protocol_router import refine_protocol_route, resolve_protocol_route, scope_task_state_to_domain
from backend.task_graph import task_graph_from_direct_calls, validate_task_graph
from backend.execution_planner import plan_calendar_action_execution, plan_direct_execution, plan_email_action_execution

from backend.appointment_intents import (
    AppointmentIntent,
    appointment_intent_from_classifier,
    appointment_intent_from_llm_payload,
    appointment_intent_to_create_payload,
    appointment_intent_to_update_directives,
    detect_calendar_action,
    merge_appointment_intents,
    parse_appointment_intent,
)
from backend.action_intents import ReferenceSpec, ordinal_to_index
from backend.email_intents import (
    EmailIntent,
    detect_email_action,
    email_intent_from_llm_payload,
    email_intent_to_send_payload,
    email_intent_to_update_payload,
    merge_email_intents,
    parse_email_intent,
)
from backend.copilot_briefing import (
    build_followup_brief_report_prompt,
    build_followup_brief_report_system,
    build_morning_brief_report_prompt,
    build_morning_brief_report_system,
)
from backend.copilot_routines import (
    build_copilot_routine_planner_guide,
    resolve_copilot_routine_by_id,
)

try:
    from backend.copilot_briefing import (
        _COPILOT_CROSS_ITEM_ENTRY,
        _COPILOT_EMAIL_ANALYSIS_ENTRY,
        _COPILOT_EVENT_ANALYSIS_ENTRY,
        _COPILOT_THREAD_ANALYSIS_ENTRY,
        build_contacts_context_block,
        build_copilot_cross_item_prompt,
        build_copilot_cross_item_system,
        build_copilot_email_analysis_prompt,
        build_copilot_email_analysis_system,
        build_copilot_event_analysis_prompt,
        build_copilot_event_analysis_system,
        build_copilot_thread_analysis_prompt,
        build_copilot_thread_analysis_system,
        build_priorities_context_block,
        build_user_context_block,
        extract_email_enrichments,
        extract_event_enrichments,
        identify_thread_candidates,
        load_contact_profiles,
        parse_copilot_cross_item_analysis,
        parse_copilot_email_analysis,
        parse_copilot_event_analysis,
        parse_copilot_thread_analysis,
        update_contact_profiles_from_emails,
    )
except Exception:
    _COPILOT_EMAIL_ANALYSIS_ENTRY = "copilot_email_analysis"
    _COPILOT_EVENT_ANALYSIS_ENTRY = "copilot_event_analysis"
    _COPILOT_THREAD_ANALYSIS_ENTRY = "copilot_thread_analysis"
    _COPILOT_CROSS_ITEM_ENTRY = "copilot_cross_item_analysis"

    def build_user_context_block(raw_user: str) -> str:
        text = str(raw_user or "").strip()
        if not text:
            return ""
        return f"User context:\n{text[:2400]}"

    def build_priorities_context_block() -> str:
        return ""

    def load_contact_profiles() -> list[dict]:
        return []

    def build_contacts_context_block(contact_profiles: list[dict]) -> str:
        if not contact_profiles:
            return ""
        lines = []
        for row in contact_profiles[:12]:
            name = str((row or {}).get("name") or "").strip()
            note = str((row or {}).get("note") or "").strip()
            if name:
                lines.append(f"- {name}: {note}" if note else f"- {name}")
        return "Known contacts:\n" + "\n".join(lines) if lines else ""

    def build_copilot_email_analysis_prompt(enrich_emails: list[dict], enrich_events: list[dict]) -> str:
        payload = {
            "emails": enrich_emails[:25],
            "events": enrich_events[:20],
            "task": "Extract urgency, owner, next action, and deadline hints per email.",
        }
        return json.dumps(payload, ensure_ascii=False)

    def build_copilot_email_analysis_system(current_date_line: str, user_context: str = "") -> str:
        return (
            "Return compact JSON only. "
            "Fields per email: email_index, urgency, action_needed, owner, deadline, summary.\n"
            f"{current_date_line}\n{user_context}"
        )

    def parse_copilot_email_analysis(raw: str, expected_count: int) -> list[dict]:
        try:
            data = json.loads(str(raw or "").strip())
        except Exception:
            return []
        if isinstance(data, dict):
            data = data.get("emails") or data.get("items") or []
        if not isinstance(data, list):
            return []
        out: list[dict] = []
        for item in data[: max(0, int(expected_count or 0)) or len(data)]:
            if isinstance(item, dict):
                out.append(item)
        return out

    def update_contact_profiles_from_emails(enrich_emails: list[dict], enrichments: list[dict]) -> None:
        return None

    def build_copilot_event_analysis_prompt(enrich_events: list[dict], enrich_emails: list[dict]) -> str:
        payload = {
            "events": enrich_events[:30],
            "emails": enrich_emails[:20],
            "task": "Assess meeting importance, preparation needs, and risks.",
        }
        return json.dumps(payload, ensure_ascii=False)

    def build_copilot_event_analysis_system(current_date_line: str, user_context: str = "") -> str:
        return (
            "Return compact JSON only. "
            "Fields per event: event_index, prep_needed, risk_level, stakeholders, notes.\n"
            f"{current_date_line}\n{user_context}"
        )

    def parse_copilot_event_analysis(raw: str, expected_count: int) -> list[dict]:
        try:
            data = json.loads(str(raw or "").strip())
        except Exception:
            return []
        if isinstance(data, dict):
            data = data.get("events") or data.get("items") or []
        if not isinstance(data, list):
            return []
        out: list[dict] = []
        for item in data[: max(0, int(expected_count or 0)) or len(data)]:
            if isinstance(item, dict):
                out.append(item)
        return out

    def extract_email_enrichments(memory_rows: list[dict]) -> list[dict]:
        out: list[dict] = []
        for row in memory_rows or []:
            if str((row or {}).get("tool_name") or "") != _COPILOT_EMAIL_ANALYSIS_ENTRY:
                continue
            raw = (row or {}).get("tool_result")
            if not raw:
                continue
            try:
                parsed = json.loads(str(raw))
            except Exception:
                continue
            if isinstance(parsed, list):
                out.extend([x for x in parsed if isinstance(x, dict)])
            elif isinstance(parsed, dict):
                out.append(parsed)
        return out

    def extract_event_enrichments(memory_rows: list[dict]) -> list[dict]:
        out: list[dict] = []
        for row in memory_rows or []:
            if str((row or {}).get("tool_name") or "") != _COPILOT_EVENT_ANALYSIS_ENTRY:
                continue
            raw = (row or {}).get("tool_result")
            if not raw:
                continue
            try:
                parsed = json.loads(str(raw))
            except Exception:
                continue
            if isinstance(parsed, list):
                out.extend([x for x in parsed if isinstance(x, dict)])
            elif isinstance(parsed, dict):
                out.append(parsed)
        return out

    def identify_thread_candidates(enrichments: list[dict], enrich_emails: list[dict], max_threads: int = 5) -> list[dict]:
        seen: set[tuple[str, str]] = set()
        rows: list[dict] = []
        for item in enrichments or []:
            idx = item.get("email_index")
            if not isinstance(idx, int) or idx < 0 or idx >= len(enrich_emails or []):
                continue
            email_row = enrich_emails[idx] if idx < len(enrich_emails) else {}
            thread_id = str(email_row.get("thread_id") or email_row.get("id") or "").strip()
            provider = str(email_row.get("provider") or "").strip().lower()
            if not thread_id or provider not in {"gmail", "outlook"}:
                continue
            key = (provider, thread_id)
            if key in seen:
                continue
            seen.add(key)
            rows.append(
                {
                    "email_index": idx,
                    "thread_id": thread_id,
                    "provider": provider,
                    "subject": str(email_row.get("subject") or ""),
                }
            )
            if len(rows) >= max(1, int(max_threads or 5)):
                break
        return rows

    def build_copilot_thread_analysis_prompt(thread_data: list[dict]) -> str:
        return json.dumps({"threads": thread_data[:8]}, ensure_ascii=False)

    def build_copilot_thread_analysis_system(current_date_line: str, user_context: str = "") -> str:
        return (
            "Return compact JSON only. "
            "Fields per thread: email_index, status, blockers, recommendation.\n"
            f"{current_date_line}\n{user_context}"
        )

    def parse_copilot_thread_analysis(raw: str, allowed_indices: list[int]) -> list[dict]:
        allowed = set(int(i) for i in (allowed_indices or []) if isinstance(i, int))
        try:
            data = json.loads(str(raw or "").strip())
        except Exception:
            return []
        if isinstance(data, dict):
            data = data.get("threads") or data.get("items") or []
        if not isinstance(data, list):
            return []
        out: list[dict] = []
        for item in data:
            if not isinstance(item, dict):
                continue
            idx = item.get("email_index")
            if isinstance(idx, int) and idx in allowed:
                out.append(item)
        return out

    def build_copilot_cross_item_prompt(
        email_enrichments: list[dict],
        enrich_emails: list[dict],
        enrich_events: list[dict],
        *,
        event_enrichments: list[dict] | None = None,
    ) -> str:
        payload = {
            "email_enrichments": (email_enrichments or [])[:30],
            "event_enrichments": (event_enrichments or [])[:30],
            "emails": (enrich_emails or [])[:30],
            "events": (enrich_events or [])[:30],
            "task": "Find cross-item risks, priority clusters, and timeline pressure.",
        }
        return json.dumps(payload, ensure_ascii=False)

    def build_copilot_cross_item_system(current_date_line: str, user_context: str = "") -> str:
        return (
            "Return compact JSON only with keys: clusters, risks, timeline, recommendations.\n"
            f"{current_date_line}\n{user_context}"
        )

    def parse_copilot_cross_item_analysis(raw: str) -> dict:
        try:
            parsed = json.loads(str(raw or "").strip())
            if isinstance(parsed, dict):
                return parsed
        except Exception:
            pass
        return {}
from backend.hypothesis_runtime import (
    append_hypothesis_evidence,
    build_run_kpis,
    hypothesis_decision,
    initialize_hypothesis_runtime,
    summarize_hypothesis_runtime,
)
from backend.learning_feedback import (
    apply_policy_feedback_adjustment,
    update_playbook_stats,
    update_policy_feedback_from_kpis,
)
from backend.learning_pipeline import (
    get_applicable_learning_rules,
)
from backend.action_routes import router as action_router
from backend.identity_routes import router as identity_router
from backend.model_routes import router as model_router
from backend.mcp_routes import router as mcp_router
from backend.knowledge_routes import router as knowledge_router
from backend.system_routes import router as system_router
from backend.tool_routes import router as tool_router
from backend.utility_routes import router as utility_router

from backend.response_composers import (
    _looks_dutch,
    build_tool_result_envelope,
    build_tool_result_details,
    compact_tool_result_for_context,
    compose_direct_results,
    compose_final_report,
    compose_tool_response,
    extract_calendar_lookup_records,
    extract_email_lookup_records,
    _format_sources_footer,
)

from backend.browser_runtime import _set_browser_on_update
from backend.drive_runtime import _is_onedrive_local
from backend.mail_read_runtime import _gmail_get_thread, _outlook_get_thread
from backend.mcp_runtime import _mcp_call, _mcp_discover_all, _mcp_get_cache
from backend.tool_dispatch_runtime import _execute_tool
from backend.tool_general_runtime import _fetch_url, _web_search
from backend.tool_registry_runtime import (
    _dynamic_tools,
    _reload_dynamic_tools,
    record_custom_tool_feedback,
)
from backend.tool_catalog import build_ollama_tool_schemas, build_runtime_tool_catalog
from backend.tool_plan import build_tool_plan_prompt, tool_plan_from_payload, tool_plan_to_task_graph, validate_tool_plan

import backend.integrations as _integ
from backend.integrations import (
    router as integration_router,
    _start_slack_bot, _restart_slack_bot, _start_schedule_ticker,
    _start_telegram_bot, _restart_telegram_bot,
    _wa_auto_start, _wa_is_connected,
)
from backend.llamacpp_routes import router as llamacpp_router

# ── FastAPI application ───────────────────────────────────────────────────────


def _safe_thread(fn, name):
    """Wrap *fn* so uncaught exceptions are logged instead of silently lost."""
    def _wrapper():
        try:
            fn()
        except Exception:
            import traceback
            print(f"[THREAD-ERROR] {name}: {traceback.format_exc()}")
    return _wrapper


@asynccontextmanager
async def _app_lifespan(_: FastAPI):
    """Initialize background services once when the application starts."""
    _migrate_plaintext_secrets()
    _integ._main_loop = asyncio.get_running_loop()
    _reload_dynamic_tools()
    log_system_event("server", "Server started")
    prune_system_log()
    humanize_system_log()
    for fn, name in [
        (_reindex_all_sessions, "reindex"),
        (_consolidate_memory_sessions, "memory-consolidate"),
        (_prune_with_usage, "memory-prune"),
        (_start_slack_bot, "slack"),
        (_start_telegram_bot, "telegram"),
        (_start_schedule_ticker, "scheduler"),
        (_mcp_discover_all, "mcp-discover"),
        (_wa_auto_start, "whatsapp"),
    ]:
        threading.Thread(target=_safe_thread(fn, name), daemon=True, name=name).start()
    yield


app = FastAPI(lifespan=_app_lifespan)
app.add_middleware(_BearerAuthMiddleware)


app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://127.0.0.1:8000", "http://localhost:8000", "http://127.0.0.1:8001", "http://localhost:8001"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Simple in-memory rate limiter ─────────────────────────────────────────────
_RATE_LIMIT_WINDOW = 60.0       # seconds
_RATE_LIMIT_MAX_DEFAULT = 120   # requests / window for normal endpoints
_RATE_LIMIT_MAX_AUTH = 10       # requests / window for auth/token endpoint
_RATE_BUCKETS: dict[str, list[float]] = collections.defaultdict(list)
_RATE_BUCKETS_LOCK = threading.Lock()

_RATE_LIMITED_PATHS = {
    "/api/chat", "/api/copilot", "/api/run", "/api/auth/token",
}


def _rate_limit_check(client_ip: str, path: str) -> bool:
    """Returns True if request is allowed, False if rate-limited."""
    # Localhost is always internal/test traffic — never rate-limit it
    if client_ip in ("127.0.0.1", "::1", "localhost"):
        return True
    max_req = _RATE_LIMIT_MAX_AUTH if path == "/api/auth/token" else _RATE_LIMIT_MAX_DEFAULT
    now = time.time()
    key = f"{client_ip}:{path}"
    cutoff = now - _RATE_LIMIT_WINDOW
    with _RATE_BUCKETS_LOCK:
        bucket = _RATE_BUCKETS[key]
        # Evict expired timestamps
        while bucket and bucket[0] < cutoff:
            bucket.pop(0)
        if len(bucket) >= max_req:
            return False
        bucket.append(now)
    return True


@app.middleware("http")
async def _rate_limit_middleware(request: Request, call_next):
    path = request.url.path
    if path in _RATE_LIMITED_PATHS:
        client_ip = (request.client.host if request.client else "unknown")
        if not _rate_limit_check(client_ip, path):
            return JSONResponse(
                status_code=429,
                content={"detail": "Too many requests. Please slow down."},
            )
    return await call_next(request)


app.include_router(integration_router)
app.include_router(action_router)
app.include_router(identity_router)
app.include_router(llamacpp_router)
app.include_router(knowledge_router)
app.include_router(model_router)
app.include_router(mcp_router)
app.include_router(system_router)
app.include_router(tool_router)
app.include_router(utility_router)


# ── Session / Memory CRUD ────────────────────────────────────────────────────

# ── WebSocket state (extracted to backend.websocket_manager) ─────────────────
from backend.websocket_manager import (           # noqa: E402
    _current_run_id,
    MAX_CONCURRENT_RUNS,
    ConnectionManager,
    manager,
    _browser_update_callback,
    _reset_runtime_state,
)

_set_browser_on_update(_browser_update_callback)

# ── Compiled regex: queries that inherently need live / external data ────────
# NOTE: bare Dutch "weer" is excluded — it's a homonym (weather vs again).
# Only match "weer" in unambiguous weather contexts to avoid false positives
# like "wanneer is X weer" (= "when is X again").
_LIVE_DATA_QUERY_RE = re.compile(
    r"\b(weather|forecast|voorspelling|temperature|temperatuur"
    r"|stock|koers|price|prijs|live|current|actueel|real.time"
    r"|nieuws|news|breaking|headline)\b"
    r"|(?:het|wat\s+voor)\s+weer\b"
    r"|\b(weerbericht|weersverwachting|weersvoorspelling)\b",
    re.IGNORECASE,
)


def _is_multi_goal_request(goal: str) -> bool:
    return len(split_compound_instructions(goal)) > 1


def _should_defer_action_fast_paths_for_multi_goal(goal: str) -> bool:
    text = str(goal or "").strip()
    if not text or not _goal_requests_side_effect(text):
        return False
    return len(split_compound_instructions(text)) > 1


def _build_multi_goal_todo_steps(goal: str, *, use_internet: bool = False, max_steps: int = 5) -> list[dict]:
    parts = split_compound_instructions(goal)
    if len(parts) <= 1:
        return []
    steps: list[dict] = []
    for part in parts[: max(1, max_steps)]:
        item = str(part or "").strip()
        if not item:
            continue
        needs_search = bool(use_internet or _LIVE_DATA_QUERY_RE.search(item))
        steps.append({
            "step": item,
            "needs_search": needs_search,
            "search_hint": item[:100] if needs_search else "",
        })
    return steps


def _build_planner_plan_b_steps(
    goal: str,
    *,
    use_internet: bool = False,
    max_steps: int = 5,
) -> list[dict]:
    multi_steps = _build_multi_goal_todo_steps(goal, use_internet=use_internet, max_steps=max_steps)
    if multi_steps:
        return multi_steps

    item = str(goal or "").strip()
    if not item:
        return []

    needs_search = bool(use_internet or _LIVE_DATA_QUERY_RE.search(item))
    return [{
        "step": item,
        "needs_search": needs_search,
        "search_hint": item[:100] if needs_search else "",
    }]


def _multi_goal_status_label(verdict: str, *, dutch: bool) -> str:
    safe_verdict = str(verdict or "").upper()
    if safe_verdict.startswith("PASS"):
        return "Afgehandeld" if dutch else "Done"
    if safe_verdict.startswith("SKIP"):
        return "Overgeslagen" if dutch else "Skipped"
    if safe_verdict.startswith("ISSUE"):
        return "Probleem" if dutch else "Issue"
    return "Open" if dutch else "Pending"


def _clean_multi_goal_result(text: str, limit: int = 1800) -> str:
    value = re.sub(r"\s+", " ", str(text or "").strip())
    if len(value) <= limit:
        return value
    return value[:limit].rstrip() + "..."


def _todo_progress_state_from_verdict(verdict: str) -> str:
    safe_verdict = str(verdict or "").upper()
    if safe_verdict.startswith("PASS"):
        return "completed"
    if safe_verdict.startswith("SKIP"):
        return "skipped"
    if safe_verdict.startswith("ISSUE"):
        return "issue"
    return "pending"


def _todo_progress_preview(text: str, limit: int = 220) -> str:
    return _clean_multi_goal_result(text, limit=limit)


def _new_todo_progress_item(step: str, step_index: int) -> dict:
    return {
        "step_index": step_index,
        "step": str(step or "").strip(),
        "state": "pending",
        "attempt": 0,
        "verdict": "",
        "evaluation": "",
        "approach": "",
        "result_preview": "",
        "goal_met": False,
        "plan_b_applied": False,
    }


def _ensure_todo_progress_items(session: dict, steps: list[dict]) -> list[dict]:
    raw_items = session.get("todo_progress_items")
    items = raw_items if isinstance(raw_items, list) else []
    normalized: list[dict] = []
    for index, step_obj in enumerate(steps or []):
        if isinstance(step_obj, dict):
            step_desc = str(step_obj.get("step") or "").strip()
        else:
            step_desc = str(step_obj or "").strip()
        existing = items[index] if index < len(items) and isinstance(items[index], dict) else {}
        attempt_value = existing.get("attempt", 0)
        try:
            attempt = max(0, int(attempt_value or 0))
        except Exception:
            attempt = 0
        normalized.append({
            "step_index": index,
            "step": step_desc,
            "state": str(existing.get("state") or "pending").strip() or "pending",
            "attempt": attempt,
            "verdict": str(existing.get("verdict") or "").strip(),
            "evaluation": str(existing.get("evaluation") or "").strip(),
            "approach": str(existing.get("approach") or "").strip(),
            "result_preview": str(existing.get("result_preview") or "").strip(),
            "goal_met": bool(existing.get("goal_met", False)),
            "plan_b_applied": bool(existing.get("plan_b_applied", False)),
        })
    session["todo_progress_items"] = normalized
    return normalized


def _update_todo_progress_item(session: dict, steps: list[dict], step_index: int, **changes) -> dict | None:
    items = _ensure_todo_progress_items(session, steps)
    if step_index < 0 or step_index >= len(items):
        return None
    item = items[step_index]
    for key, value in changes.items():
        if key == "result_preview":
            item[key] = _todo_progress_preview(value)
        elif key == "attempt":
            try:
                item[key] = max(0, int(value or 0))
            except Exception:
                continue
        elif key in {"goal_met", "plan_b_applied"}:
            item[key] = bool(value)
        elif value is None:
            item[key] = ""
        else:
            item[key] = str(value).strip() if isinstance(value, str) else value
    return item


def _build_todo_progress_payload(
    session: dict,
    steps: list[dict],
    *,
    event: str,
    run_id: str = "",
    current_step_index: int | None = None,
) -> dict:
    items = _ensure_todo_progress_items(session, steps)
    payload = {
        "type": "todo_progress",
        "event": event,
        "items": [dict(item) for item in items],
        "total": len(items),
        "goal_status": str(session.get("todo_progress_goal_status") or "").strip(),
        "goal_evaluation": str(session.get("todo_progress_goal_evaluation") or "").strip(),
    }
    if run_id:
        payload["run_id"] = run_id
    if current_step_index is not None:
        payload["current_step_index"] = current_step_index
    return payload


async def _send_todo_progress(
    session: dict,
    steps: list[dict],
    websocket,
    *,
    event: str,
    run_id: str = "",
    current_step_index: int | None = None,
    goal_status: str | None = None,
    goal_evaluation: str | None = None,
) -> None:
    if goal_status is not None:
        session["todo_progress_goal_status"] = str(goal_status or "").strip()
    if goal_evaluation is not None:
        session["todo_progress_goal_evaluation"] = str(goal_evaluation or "").strip()
    await manager.send_message(
        _build_todo_progress_payload(
            session,
            steps,
            event=event,
            run_id=run_id,
            current_step_index=current_step_index,
        ),
        websocket,
    )


def _build_multi_goal_final_report(goal: str, steps: list[dict], memory: list[dict]) -> str:
    if len(steps or []) <= 1:
        return ""
    dutch = _looks_dutch_text(goal)
    result_label = "Resultaat" if dutch else "Result"
    pending_text = "Nog niet afgehandeld." if dutch else "Not completed yet."
    notes_title = "Notities" if dutch else "Notes"
    incomplete_note = (
        "Niet alle to-do's zijn volledig afgerond. Controleer de items met status Probleem, Overgeslagen of Open."
        if dutch else
        "Not all to-do items were fully completed. Check items marked Issue, Skipped, or Pending."
    )

    lines = ["To-do status", ""]
    has_incomplete = False
    for index, step in enumerate(steps):
        memory_item = memory[index] if index < len(memory) else {}
        status = _multi_goal_status_label(memory_item.get("verdict", ""), dutch=dutch)
        if status not in {"Afgehandeld", "Done"}:
            has_incomplete = True
        lines.append(f"{index + 1}. {status}: {str(step.get('step') or '').strip()}")
        result = _clean_multi_goal_result(memory_item.get("result", ""))
        lines.append(f"{result_label}: {result or pending_text}")
        lines.append("")

    if has_incomplete:
        lines.append(notes_title)
        lines.append("")
        lines.append(incomplete_note)

    return "\n".join(lines).strip()


def _step_result_requires_user_input(step_desc: str, step_result: str) -> bool:
    description = str(step_desc or "").strip().lower()
    result = str(step_result or "").strip()
    if not result:
        return False

    if any(marker in description for marker in (
        "ask the user",
        "request the user",
        "vraag de gebruiker",
        "verzoek de gebruiker",
    )):
        return True

    low = result.lower()
    upload_request_markers = (
        "upload the",
        "upload het",
        "upload de",
        "provide a direct link",
        "directe downloadlink",
        "directe link",
        "als bijlage",
        "as an attachment",
    )
    if any(marker in low for marker in upload_request_markers):
        return True

    request_words = (
        "please",
        "can you",
        "could you",
        "upload",
        "kun je",
        "wil je",
        "geef",
        "deel",
        "kies",
        "bevestig",
        "specificeer",
        "stuur",
    )
    if "?" in result and any(word in low for word in request_words):
        return True
    if low.startswith(request_words):
        return True
    return False

# ── Follow-up continuation detection ─────────────────────────────────────────
_FOLLOWUP_FRAGMENT_RE = re.compile(
    r"^(?:en\s|ook\s|and\s|also\s|what\s+about\s|how\s+about\s|"
    r"hoe\s+zit\s+het\s+(?:met|in)\s|maar\s+in\s|but\s+in\s|"
    r"daar\s|there\s|or\s+in\s|of\s+in\s)",
    re.IGNORECASE,
)

# Affirmative / vague-instruction follow-ups that only make sense with
# conversation history (e.g. "ja", "zoek op internet", "doe maar").
_CONTEXTUAL_FOLLOWUP_RE = re.compile(
    r"^("
    # Affirmatives (NL + EN)
    r"ja\b|yes\b|yep\b|jep\b|ok\b|ok[eé]\b|sure\b|absoluut\b|zeker\b"
    r"|graag\b|please\b|go\s+ahead\b|doe\s+(?:maar|het)\b|do\s+it\b"
    # Vague search/action instructions
    r"|zoek\s|search\s|look\s.*\bup\b|check\s|probeer\s|try\s"
    r"|laat\s.*\bzien\b|show\s|find\s"
    r")",
    re.IGNORECASE,
)
_WORKSPACE_CONTEXT_RE = re.compile(
    r"\b(workspace|project|repo(?:sitory)?|codebase|code|bestand|bestanden|file|files|folder|folders|map|document|documenten|doc|docs|script|scripts|readme)\b"
    r"|\b[a-z0-9_./-]+\.(?:py|js|ts|tsx|jsx|json|md|txt|html|css|yml|yaml)\b",
    re.IGNORECASE,
)
_FOLLOWUP_WORKSPACE_BLOCKED_TOOLS = {
    "search_workspace",
    "read_file",
    "list_files",
    "write_file",
}


def _is_short_followup(user_message: str) -> bool:
    """True when the message looks like a short continuation fragment."""
    msg = (user_message or "").strip()
    return bool(msg and len(msg.split()) <= 10 and _FOLLOWUP_FRAGMENT_RE.search(msg))


def _is_contextual_followup(user_message: str) -> bool:
    """True when the message is a short affirmative/instruction that needs context."""
    msg = (user_message or "").strip()
    return bool(msg and len(msg.split()) <= 8 and _CONTEXTUAL_FOLLOWUP_RE.search(msg))


def _latest_substantive_user_message(session: dict | None) -> str:
    history = (session or {}).get("chat_history") or []
    for entry in reversed(history[-12:]):
        if entry.get("role") != "user":
            continue
        prev = str(entry.get("content") or "").strip()
        if not prev:
            continue
        if _is_short_followup(prev) or _is_contextual_followup(prev):
            continue
        return prev
    return str(_get_previous_user_message(session or {}) or "").strip()


def _should_block_workspace_tools_for_followup(session: dict | None, user_message: str) -> bool:
    msg = str(user_message or "").strip()
    if not msg or not (_is_short_followup(msg) or _is_contextual_followup(msg)):
        return False
    prev_user = _latest_substantive_user_message(session)
    combined = "\n".join(part for part in (prev_user, msg) if part)
    return not bool(_WORKSPACE_CONTEXT_RE.search(combined))


def _router_allowed_chat_tools(skills: list[str] | None) -> set[str]:
    allowed = {
        _normalize_tool_name(str(skill or "").strip())
        for skill in list(skills or [])
        if str(skill or "").strip()
    }
    allowed.discard("")
    if "web_search" in allowed:
        allowed.add("fetch_url")
    return allowed


# ── LLM-based intent router ─────────────────────────────────────────────────
# The LLM sees the available skills, user profile, and conversation history
# and decides: (1) what the user's goal is, (2) whether to respond directly
# or orchestrate a multi-step workflow, and (3) which skills to use.

def _build_skills_summary(settings: dict, *, use_outlook: bool = False,
                          gmail_available: bool = False) -> str:
    """Build a condensed one-line-per-skill summary for the intent router."""
    lines: list[str] = []
    # Always-available skills
    lines.append("web_search – Search the web for information")
    lines.append("fetch_url – Get text from a webpage")
    lines.append("run_python – Run Python code")
    lines.append("write_file / read_file / list_files – Manage output files")
    lines.append("search_workspace – Search through project files")
    lines.append("recall_memory – Recall details from earlier conversations")
    lines.append("create_skill – Create a reusable custom skill")
    lines.append("update_skill – Update an existing reusable custom skill")
    lines.append("schedule_goal – Schedule a task to run later")
    lines.append("summarize_personal_items – Summarize retrieved email or calendar items")
    if use_outlook:
        lines.append("outlook_get_inbox – Read user's Outlook email")
        lines.append("outlook_search_emails – Search user's Outlook email")
        lines.append("outlook_send_email – Send email via Outlook")
        lines.append("outlook_read_calendar – Read user's Outlook calendar")
        lines.append("outlook_create_calendar_event – Create Outlook event")
        lines.append("outlook_update_calendar_event – Update Outlook event")
        lines.append("outlook_delete_calendar_event – Delete Outlook event")
    if gmail_available:
        lines.append("gmail_get_inbox – Read user's Gmail")
        lines.append("gmail_search_emails – Search user's Gmail")
        lines.append("gmail_send_email – Send email via Gmail")
        lines.append("google_read_calendar – Read user's Google Calendar")
        lines.append("google_create_calendar_event – Create Google Calendar event")
    if bool(settings.get("whatsapp_enabled", False)):
        lines.append("whatsapp_send_message – Send a WhatsApp message")
    if bool(settings.get("slack_enabled", False)):
        lines.append("slack_post_message – Post a message in Slack")
    if gmail_available:
        lines.append("google_drive_search / google_drive_read – Search/read Google Drive")
    if _is_onedrive_available():
        lines.append("onedrive_search / onedrive_read – Search/read OneDrive")
    # Custom/dynamic tools
    for name, meta in (_dynamic_tools or {}).items():
        desc = str((meta or {}).get("description", "")).strip()[:60]
        if desc:
            lines.append(f"{name} – {desc}")
    return "\n".join(lines)


async def _route_with_llm(
    user_message: str, session: dict, settings: dict,
    *, use_outlook: bool = False, gmail_available: bool = False,
) -> dict:
    """Ask the LLM to determine the user's goal, the approach, and which skills to use.

    Returns a dict with:
      - goal: str — the user's resolved intent as a standalone sentence
      - approach: "respond" | "orchestrate"
      - skills: list[str] — recommended skill/tool names
      - reasoning: str — one-line explanation

    Falls back to ``{"approach": "unknown"}`` when the LLM is unavailable.
    """
    # -- Build conversation transcript --
    history = (session.get("chat_history") or [])[-12:]
    lines: list[str] = []
    _n_hist = len(history)
    for _hi, msg in enumerate(history):
        role = "User" if msg.get("role") == "user" else "Assistant"
        # Give the last 3 messages more room so the router can see recent data
        _max_chars = 800 if _hi >= _n_hist - 3 else 300
        content = str(msg.get("content") or "").strip()[:_max_chars]
        if content:
            lines.append(f"{role}: {content}")
    transcript = "\n".join(lines) or "(new conversation)"

    has_doc = bool((session.get("context") or "").strip())

    # -- Detect session state: does the conversation already have email/calendar data? --
    _has_email_records = bool(_session_email_records(session))
    _has_final_report = bool((session.get("final_report") or "").strip())
    _has_history = _n_hist > 0
    _is_followup = _has_history and (_has_email_records or _has_final_report)

    # Deterministic fast-path for summaries of the user's own email/calendar data.
    # Skip when the conversation already has data loaded — let the LLM decide for follow-ups.
    lowered_message = str(user_message or "").lower()
    explicit_email_action = _email_action_type(user_message)
    if explicit_email_action:
        explicit_provider = _detect_email_action_provider(
            user_message,
            use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
            gmail_available=bool(gmail_available),
        )
        provider_prefix = (
            "gmail" if explicit_provider == "gmail" else
            "outlook" if explicit_provider == "outlook" else
            "gmail" if gmail_available and not use_outlook else
            "outlook"
        )
        skill_suffix = {
            "send": "send_email",
            "update": "update_email",
            "delete": "delete_email",
        }.get(explicit_email_action, "send_email")
        return {
            "goal": str(user_message or "").strip(),
            "approach": "orchestrate",
            "skills": [f"{provider_prefix}_{skill_suffix}"],
            "reasoning": "deterministic route for explicit email action request",
        }

    explicit_calendar_action = _calendar_action_type(user_message)
    if explicit_calendar_action and _is_explicit_calendar_action_request(user_message):
        explicit_provider_scope = _detect_calendar_provider_scope(
            user_message,
            use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
            gmail_available=bool(gmail_available),
        )
        provider_prefix = (
            "google" if explicit_provider_scope == "gmail_only" else
            "outlook" if explicit_provider_scope == "outlook_only" else
            "google" if gmail_available and not use_outlook else
            "outlook"
        )
        skill_suffix = {
            "create": "create_calendar_event",
            "update": "update_calendar_event",
            "delete": "delete_calendar_event",
        }.get(explicit_calendar_action, "read_calendar")
        return {
            "goal": str(user_message or "").strip(),
            "approach": "orchestrate",
            "skills": [f"{provider_prefix}_{skill_suffix}"],
            "reasoning": "deterministic route for explicit calendar action request",
        }

    if _is_explicit_slack_post_request(user_message):
        return {
            "goal": str(user_message or "").strip(),
            "approach": "orchestrate",
            "skills": ["slack_post_message"],
            "reasoning": "deterministic route for explicit Slack post request",
        }

    if not _is_followup and not has_doc and _goal_requests_personal_summary(user_message, "email"):
        if "gmail" in lowered_message and gmail_available:
            email_skill = "gmail_get_inbox" if any(token in lowered_message for token in ("inbox", "postvak", "mailbox")) else "gmail_search_emails"
        elif use_outlook or "outlook" in lowered_message:
            email_skill = "outlook_get_inbox" if any(token in lowered_message for token in ("inbox", "postvak", "mailbox")) else "outlook_search_emails"
        elif gmail_available:
            email_skill = "gmail_get_inbox" if any(token in lowered_message for token in ("inbox", "postvak", "mailbox")) else "gmail_search_emails"
        else:
            email_skill = "outlook_get_inbox" if any(token in lowered_message for token in ("inbox", "postvak", "mailbox")) else "outlook_search_emails"
        return {
            "goal": str(user_message or "").strip(),
            "approach": "orchestrate",
            "skills": [email_skill, "summarize_personal_items"],
            "reasoning": "deterministic route for personal email summary request",
        }
    if not _is_followup and not has_doc and _goal_requests_personal_summary(user_message, "calendar"):
        if any(token in lowered_message for token in ("google calendar", "google-agenda", "gmail agenda", "gmail calendar")) and gmail_available:
            calendar_skill = "google_read_calendar"
        elif use_outlook or any(token in lowered_message for token in ("outlook", "agenda", "afspraak", "afspraken", "calendar")):
            calendar_skill = "outlook_read_calendar"
        elif gmail_available:
            calendar_skill = "google_read_calendar"
        else:
            calendar_skill = "outlook_read_calendar"
        return {
            "goal": str(user_message or "").strip(),
            "approach": "orchestrate",
            "skills": [calendar_skill, "summarize_personal_items"],
            "reasoning": "deterministic route for personal calendar summary request",
        }
    if not _is_followup and not has_doc and _looks_like_public_web_query(user_message):
        return {
            "goal": str(user_message or "").strip(),
            "approach": "respond",
            "skills": ["web_search"],
            "reasoning": "deterministic route for public web-searchable question",
        }

    # -- Build skills list --
    skills_summary = _build_skills_summary(settings, use_outlook=use_outlook,
                                           gmail_available=gmail_available)

    # -- User profile (short) --
    user_profile = (_load_user_text() or "").strip()[:500]
    resolved_followup_hint = _resolve_followup_goal(session, user_message)
    has_followup_hint = bool(resolved_followup_hint and resolved_followup_hint.strip() != str(user_message or "").strip())

    prompt = (
        "You are the intent router for an AI assistant. The user sent a message.\n"
        "Decide: what is the goal, how to handle it, and which skills (if any) to use.\n\n"
        "Return ONLY valid JSON:\n"
        '{"goal":"...","approach":"respond"|"orchestrate","skills":["..."],"reasoning":"..."}\n\n'
        "APPROACHES:\n"
        '  "respond" = the assistant can answer directly, or with a quick web search / simple tool use.\n'
        "    This is the DEFAULT. Use for: general knowledge, facts, public events, weather, news,\n"
        "    opinions, advice, math, translations, document summaries, conversation follow-ups,\n"
        "    and anything answerable from the assistant's knowledge or a web search.\n"
        '  "orchestrate" = the request needs the user\'s PRIVATE data or must perform a side-effect.\n'
        "    Use ONLY when the user explicitly asks to access THEIR OWN personal account data\n"
        "    (their inbox, their calendar, their sent emails) or to perform an action\n"
        "    (send email, create/update/delete event, save/upload file, send message).\n\n"
        "RULES:\n"
        "- If in doubt, choose respond. It has web search and tool access built in.\n"
        "- Questions about public events, festivals, weather, sports, prices → respond\n"
        "- FIRST-TIME requests for the user's OWN private data (inbox, calendar, sent mail) → orchestrate\n"
        "- Write actions (send, create, delete, update, forward, reply) → orchestrate\n"
        "- FOLLOW-UP questions about data ALREADY SHOWN in the conversation → respond.\n"
        "  The 'respond' path has full conversation context and tools — it can answer from what was already fetched.\n"
        "  Words like 'deze', 'die', 'dat', 'this', 'that', 'it', pronouns, ordinals ('email 3'), or\n"
        "  'de email van X' (referencing a visible sender) all signal a follow-up about existing data.\n"
        "- Only use 'orchestrate' when the user explicitly asks for NEW or DIFFERENT data from their accounts,\n"
        "  not when they ask questions about data already in the conversation.\n"
        "- Follow-up questions about shared documents or conversation → respond\n"
        "- For short follow-up fragments, infer the omitted subject from the recent conversation and keep the current topic unless the user explicitly switches topics or asks for private data/actions.\n"
        "- Do NOT turn a short ambiguous follow-up into a workspace/file/code search unless the user explicitly mentions files, code, workspace, repo, folder, document, or asks to search there.\n"
        '- For "goal", write the user\'s actual intent as a clear standalone sentence.\n'
        "  Resolve follow-up references (e.g. 'and tomorrow?' → full question with context).\n\n"
        "EXAMPLES:\n"
        '  "heb ik morgen afspraken?" → orchestrate, skills: ["outlook_read_calendar"]\n'
        '  "stuur een email naar Jan over de meeting" → orchestrate, skills: ["outlook_send_email"]\n'
        '  "laat mijn inbox zien" → orchestrate, skills: ["outlook_get_inbox"]\n'
        '  "wanneer is Sail Amsterdam weer?" → respond, skills: ["web_search"]\n'
        '  "wat wordt het weer morgen?" → respond, skills: ["web_search"]\n'
        '  "vat dit document samen" → respond, skills: []\n'
        '  "maak een afspraak voor morgen om 10" → orchestrate, skills: ["outlook_create_calendar_event"]\n'
        '  "zoek in mijn email naar factuur van Ziggo" → orchestrate, skills: ["outlook_search_emails"]\n'
        '  "vat mijn inbox samen" → orchestrate, skills: ["outlook_get_inbox", "summarize_personal_items"]\n'
        '  "geef een samenvatting van mijn afspraken deze week" → orchestrate, skills: ["outlook_read_calendar", "summarize_personal_items"]\n'
        '  "geef me een werkbriefing voor vandaag op basis van mijn mail en agenda" → orchestrate, skills: []\n'
        '  "maak een follow-up briefing met open mails en komende afspraken" → orchestrate, skills: []\n'
        "\n  FOLLOW-UP EXAMPLES (after email/calendar data was already shown):\n"
        '  "welke tips staan in deze email?" → respond (email data already in context)\n'
        '  "waar gaat email 3 over?" → respond (reference to item already shown)\n'
        '  "vat deze email samen" → respond (follow-up about existing data)\n'
        '  "wat is de locatie van die afspraak?" → respond (calendar data already shown)\n'
        '  "wanneer is die meeting?" → respond (reference to shown data)\n'
        '  "en morgen?" → respond (short follow-up, same topic)\n'
        '  "geef een kort antwoord van twee zinnen over regenbogen" → respond\n'
        '  "en heb je er nog een" (after the rainbow answer) → respond, same topic, no personal-data skills\n'
    )
    if has_doc:
        prompt += "\nNote: a document/file was shared in this conversation.\n"
    # -- Session state context: tell the router what data is already loaded --
    if _is_followup:
        _state_notes: list[str] = []
        if _has_email_records:
            _n_emails = len(_session_email_records(session))
            _state_notes.append(f"This session has {_n_emails} email record(s) loaded from a previous lookup.")
        if _has_final_report:
            _report_preview = (session.get("final_report") or "")[:200].strip()
            _state_notes.append(f"Previous result: {_report_preview}")
        if _state_notes:
            prompt += "\n[SESSION STATE — data already loaded]\n" + "\n".join(_state_notes) + "\n"
            prompt += "If the user's message is about this existing data, use 'respond'.\n"
    prompt += f"\nAVAILABLE SKILLS:\n{skills_summary}\n"
    if user_profile:
        prompt += f"\nUSER PROFILE:\n{user_profile[:500]}\n"
    user_prefs = _get_user_preferences()
    if user_prefs:
        prompt += f"\nUSER PREFERENCES (apply these to all responses):\n{user_prefs}\n"
    if has_followup_hint:
        prompt += f"\n[FOLLOW-UP CONTINUATION HINT]\n{resolved_followup_hint}\n"
    prompt += f"\n[CONVERSATION]\n{transcript}\n\n[USER MESSAGE]\n{user_message}\n"

    fast = (settings.get("fast_model") or "").strip()
    main_model = (settings.get("main_model") or "").strip()
    fallback_model = (settings.get("fallback_model") or "").strip()
    router_models = _unique_model_candidates(fast, main_model, fallback_model)
    if not router_models:
        return {"approach": "unknown"}

    try:
        router_per_model_timeout = 30.0
        router_timeout = max(12.0, router_per_model_timeout * len(router_models) + 4.0)
        raw, _ = await asyncio.wait_for(
            _call_text_model_candidates(
                prompt, router_models,
                system="Intent router. Return ONLY valid JSON, no markdown, no explanation.",
                num_ctx=_model_ctx(8192, settings), thinking=False,
                per_model_timeout=router_per_model_timeout,
            ),
            timeout=router_timeout,
        )
        raw = re.sub(r'<think>[\s\S]*?</think>', '', raw).strip()
        m = re.search(r'\{.*\}', raw, re.DOTALL)
        if m:
            raw = m.group(0)
        parsed = json.loads(raw)
        approach = str(parsed.get("approach", "")).strip().lower()
        if approach in ("respond", "orchestrate"):
            parsed["approach"] = approach
            parsed.setdefault("goal", user_message)
            parsed.setdefault("skills", [])
            parsed.setdefault("reasoning", "")
            return parsed
        # Accept legacy values
        if approach in ("chat", "personal", "workflow"):
            parsed["approach"] = "orchestrate" if approach in ("personal", "workflow") else "respond"
            parsed.setdefault("goal", user_message)
            parsed.setdefault("skills", [])
            return parsed
        return {"approach": "unknown"}
    except Exception:
        return {"approach": "unknown"}


def _get_previous_user_message(session: dict) -> str:
    """Return the last user message from chat_history, or ''."""
    for entry in reversed(session.get("chat_history") or []):
        if entry.get("role") == "user":
            return (entry.get("content") or "").strip()
    return ""


def _build_chat_history_context(session: dict, max_entries: int = 12) -> str:
    """Build a conversation transcript from recent chat_history entries."""
    history = (session.get("chat_history") or [])[-max_entries:]
    if not history:
        return ""
    lines = []
    for entry in history:
        role = "User" if entry.get("role") == "user" else "Assistant"
        content = str(entry.get("content") or "").strip()[:500]
        if content:
            lines.append(f"{role}: {content}")
    return "\n".join(lines)


_PAST_CONVERSATION_REFERENCE_RE = re.compile(
    r"\b(?:weet\s+je\s+nog|vorige\s+keer|eerder(?:e)?\s+(?:chat|gesprek|gesprekken)?|"
    r"herinner(?:\s+je)?|hadden\s+we\s+het\s+over|we\s+bespraken|remember|"
    r"last\s+time|earlier(?:\s+conversation)?|previous\s+(?:chat|conversation))\b",
    re.IGNORECASE,
)


def _should_load_long_term_memory_for_chat(message: str) -> bool:
    return bool(_PAST_CONVERSATION_REFERENCE_RE.search(str(message or "").strip()))


def _load_chat_long_term_memory(message: str, limit: int = 3) -> str:
    if not _should_load_long_term_memory_for_chat(message):
        return ""
    try:
        memory_bundle = _recall_memory_bundle(str(message or "").strip(), limit=limit)
    except Exception:
        return ""
    summary_text = str(memory_bundle.get("summary", "") or "").strip()
    deep_memory = str(memory_bundle.get("deep_memory", "") or "").strip()
    sections: list[str] = []
    if summary_text:
        sections.append(summary_text)
    if deep_memory:
        sections.append(f"Detailed memory from best prior session:\n{deep_memory[:8000]}")
    # Episode-level recall: detect entity references for cross-session search
    _ep_words = re.findall(r"[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*", str(message or ""))
    if len(_ep_words) >= 1:
        try:
            entity_a = _ep_words[0]
            entity_b = _ep_words[1] if len(_ep_words) >= 2 else ""
            episodes = _episode_recall(entity_a, entity_b, limit=3)
            if episodes:
                ep_lines = [f"Sessions mentioning {entity_a}" + (f" & {entity_b}" if entity_b else "") + ":"]
                for ep in episodes:
                    ep_lines.append(f"  [{ep['created_at'][:10]}] {ep['summary'] or ep['goal']}")
                sections.append("\n".join(ep_lines))
        except Exception:
            pass
    return "\n\n".join(sections).strip()


def _build_planner_context_sections(
    recent_chat: str = "",
    past_memory: str = "",
    deep_memory: str = "",
    wiki_memory: str = "",
    user_context: str = "",
    additional_context: str = "",
) -> str:
    sections: list[str] = []
    if recent_chat:
        sections.append(f"[Recent conversation]\n{recent_chat}")
    if past_memory:
        sections.append(f"[Long-term memory — prior sessions]\n{past_memory[:12000]}")
    if deep_memory:
        sections.append(f"[Deep memory recall]\n{deep_memory[:6000]}")
    if wiki_memory:
        sections.append(f"[Reviewed wiki knowledge]\n{wiki_memory[:6000]}")
    combined_context_parts = [
        str(part).strip()
        for part in (user_context, additional_context)
        if str(part).strip()
    ]
    if combined_context_parts:
        combined_context_text = "\n\n".join(combined_context_parts)[:16000]
        sections.append(f"[User-provided context]\n{combined_context_text}")
    return "\n\n".join(sections)


def _build_planner_learning_block(task_state: dict | None = None, *, max_items: int = 6) -> str:
    rules = get_applicable_learning_rules(
        task_state=task_state,
        impact_type="planner_rule",
        status="approved",
        max_items=max_items,
    )
    if not rules:
        return ""

    lines = [
        "Approved planner rules from prior validated runs:",
        "Apply these as planning constraints when they fit the current task.",
    ]
    for row in rules:
        text = str(row.get("rule_text") or "").strip()
        if not text:
            continue
        task_type = str(row.get("task_type") or "generic").strip() or "generic"
        provider_scope = str(row.get("provider_scope") or "default").strip() or "default"
        time_filter = str(row.get("time_filter") or "none").strip() or "none"
        lines.append(
            f"- [{task_type}|{provider_scope}|{time_filter}] {text}"
        )
    if len(lines) <= 2:
        return ""
    return "\n".join(lines)


def _resolve_followup_goal(session: dict, user_message: str) -> str:
    """Resolve any follow-up message into a standalone goal.

    Handles two cases:
    1. Continuation fragments ("en in miami") → reuse the last substantive user question
    2. Contextual follow-ups ("ja", "zoek op internet") → use the
       substantive question from history as the goal

    Scans up to 12 recent history entries to handle chained follow-ups.
    """
    msg = (user_message or "").strip()
    if not msg:
        return msg

    is_fragment = _is_short_followup(msg)
    is_contextual = _is_contextual_followup(msg)
    if not is_fragment and not is_contextual:
        return msg

    history = (session.get("chat_history") or [])[-12:]

    # Find the most recent substantive user
    # question — skip over other short follow-ups to find the real topic.
    for entry in reversed(history):
        if entry.get("role") != "user":
            continue
        prev = (entry.get("content") or "").strip()
        if not prev:
            continue
        # Skip messages that are themselves short follow-ups
        if _is_short_followup(prev) or _is_contextual_followup(prev):
            continue
        # Found a substantive previous message — use it as the goal
        if is_contextual:
            return prev
        else:
            return f"{prev}; {msg}"

    # Absolute fallback — use the last user message as prefix
    prev = _get_previous_user_message(session)
    if prev:
        return prev if is_contextual else f"{prev}; {msg}"
    return msg


_PRIVATE_DATA_DOMAIN_RE = re.compile(
    r"\b(email|emails|e-mail|mail|inbox|postvak|gmail|outlook|agenda|calendar|afspraak|afspraken|meeting|meetings|event|events|workspace|repo|repository|code|bestand|bestanden|file|files|folder|map|document|documenten)\b",
    re.IGNORECASE,
)

_FILESYSTEM_EMAIL_EXCLUDE_RE = re.compile(
    r"\b(email|emails|e-mail|mail|inbox|postvak|gmail|outlook|bijlage|bijlagen|attachment|attachments|agenda|calendar|afspraak|afspraken|meeting|meetings|event|events)\b",
    re.IGNORECASE,
)

_FILESYSTEM_REQUEST_RE = re.compile(
    r"\b(filesystem(?:\s*mcp)?|mcp\s+fileserver|mcp\s+filesystem|file\s*server|downloads?|downloadmap|directory|directories|subfolders?|folders?|mappen?|files?|bestanden?|images?|afbeeldingen?|jpg|jpeg|png|gif|bmp|webp|svg)\b",
    re.IGNORECASE,
)


def _normalize_intent_domain(value: str) -> str:
    domain = str(value or "").strip().lower()
    return domain if domain in {"email", "calendar", "search", "code", "general"} else ""


def _domain_hint_from_router_skills(skills: list[str] | None) -> str:
    names = [str(item or "").strip().lower() for item in (skills or []) if str(item or "").strip()]
    if not names:
        return ""

    joined = " ".join(names)
    if any(token in joined for token in ("calendar", "agenda")):
        return "calendar"
    if any(token in joined for token in ("email", "inbox", "gmail", "outlook")):
        return "email"
    if any(
        token in joined
        for token in (
            "search_workspace",
            "search_web",
            "search_browser",
            "read_file",
            "list_files",
            "browse_dirs",
            "pick_folder",
            "filesystem",
            "fileserver",
        )
    ):
        return "search"
    if any(token in joined for token in ("code", "workspace_edit", "create_tool", "update_tool", "create_skill", "update_skill")):
        return "code"
    return ""


def _is_explicit_filesystem_request(text: str) -> bool:
    message = str(text or "").strip()
    if not message:
        return False
    if not _FILESYSTEM_REQUEST_RE.search(message):
        return False
    if re.search(r"\b(filesystem(?:\s*mcp)?|mcp\s+fileserver|mcp\s+filesystem|downloadmap|downloads?)\b", message, re.IGNORECASE):
        return True
    return not bool(_FILESYSTEM_EMAIL_EXCLUDE_RE.search(message)) and bool(
        re.search(r"\b(directory|directories|subfolders?|folders?|mappen?|files?|bestanden?|images?|afbeeldingen?)\b", message, re.IGNORECASE)
    )


def _default_intent_classifier_payload() -> dict:
    return {
        "domain": "general",
        "subdomain": "chat",
        "goal_type": "general_chat",
        "action": "none",
        "confidence": 0.0,
        "parameters": {},
        "candidate_steps": ["answer_user"],
    }


def _normalize_intent_action(value: str) -> str:
    action = str(value or "").strip().lower()
    return action if action in {"read", "create", "update", "delete", "send", "none"} else "none"


def _infer_intent_subdomain(domain: str, goal: str, parameters: dict | None = None, raw_subdomain: str = "") -> str:
    normalized = str(raw_subdomain or "").strip().lower()
    params = dict(parameters or {})

    if domain == "search":
        if normalized in {"filesystem", "workspace", "web", "browser"}:
            return normalized
        source_scope = str(params.get("source_scope") or "").strip().lower()
        if source_scope in {"filesystem", "workspace", "web", "browser"}:
            return source_scope
        if _is_explicit_filesystem_request(goal):
            return "filesystem"
        if _SEARCH_WORKSPACE_RE.search(goal):
            return "workspace"
        if _SEARCH_BROWSER_RE.search(goal):
            return "browser"
        if _SEARCH_WEB_RE.search(goal) or _SEARCH_DOMAIN_RE.search(goal):
            return "web"
        return "search"

    if domain == "email":
        return normalized if normalized in {"mailbox", "thread", "message"} else "mailbox"

    if domain == "calendar":
        return normalized if normalized in {"schedule", "event", "availability"} else "schedule"

    if domain == "code":
        return normalized if normalized in {"workspace", "repository", "codebase"} else "workspace"

    return normalized or "chat"


def _infer_intent_goal_type(domain: str, subdomain: str, action: str) -> str:
    if domain == "search":
        if subdomain == "filesystem":
            return "inspect_local_files"
        if subdomain == "workspace":
            return "search_workspace"
        if subdomain == "browser":
            return "browse_web"
        if subdomain == "web":
            return "search_web"
        return "search_information"

    if domain == "email":
        return {
            "read": "lookup_email",
            "send": "send_email",
            "delete": "delete_email",
            "update": "update_email",
            "create": "draft_email",
        }.get(action, "lookup_email")

    if domain == "calendar":
        return {
            "read": "lookup_calendar",
            "create": "create_calendar_event",
            "update": "update_calendar_event",
            "delete": "delete_calendar_event",
        }.get(action, "lookup_calendar")

    if domain == "code":
        return "change_code"

    return "general_chat"


def _default_candidate_steps_for_intent(domain: str, subdomain: str, action: str) -> list[str]:
    if domain == "search":
        if subdomain == "filesystem":
            return ["resolve_target_path", "list_files", "filter_results", "summarize_results"]
        if subdomain == "workspace":
            return ["resolve_search_scope", "run_workspace_search", "summarize_results"]
        if subdomain == "browser":
            return ["open_target_page", "inspect_page", "summarize_results"]
        if subdomain == "web":
            return ["formulate_query", "search_web", "summarize_results"]
        return ["resolve_search_scope", "gather_results", "summarize_results"]

    if domain == "email":
        if action == "send":
            return ["resolve_recipient", "draft_message", "send_message"]
        if action == "delete":
            return ["find_target_email", "confirm_target", "delete_message"]
        if action == "update":
            return ["find_target_email", "apply_requested_changes", "save_or_send_message"]
        return ["select_mail_source", "retrieve_messages", "summarize_results"]

    if domain == "calendar":
        if action == "create":
            return ["extract_event_details", "select_calendar_source", "create_event"]
        if action == "update":
            return ["find_target_event", "apply_requested_changes", "save_event"]
        if action == "delete":
            return ["find_target_event", "confirm_target", "delete_event"]
        return ["select_calendar_source", "retrieve_events", "summarize_results"]

    if domain == "code":
        return ["inspect_codebase", "plan_changes", "apply_changes", "verify_result"]

    return ["answer_user"]


def _normalize_candidate_steps(raw_steps: object, *, fallback: list[str]) -> list[str]:
    if not isinstance(raw_steps, list):
        return list(fallback)

    cleaned: list[str] = []
    seen: set[str] = set()
    for raw in raw_steps:
        step = str(raw or "").strip().lower().replace(" ", "_")
        if not step or step in seen:
            continue
        seen.add(step)
        cleaned.append(step)
        if len(cleaned) >= 6:
            break
    return cleaned or list(fallback)


def _normalize_intent_classifier_payload(payload: dict | None, goal: str = "") -> dict:
    source = dict(payload or {})
    base = _default_intent_classifier_payload()
    domain = _normalize_intent_domain(source.get("domain") or base["domain"])
    if not domain:
        domain = base["domain"]
    action = _normalize_intent_action(source.get("action") or base["action"])
    try:
        confidence = float(source.get("confidence") or 0.0)
    except (TypeError, ValueError):
        confidence = 0.0
    confidence = max(0.0, min(1.0, confidence))
    parameters = source.get("parameters") if isinstance(source.get("parameters"), dict) else {}
    subdomain = _infer_intent_subdomain(domain, goal, parameters=parameters, raw_subdomain=source.get("subdomain") or "")
    goal_type = str(source.get("goal_type") or "").strip().lower() or _infer_intent_goal_type(domain, subdomain, action)
    fallback_steps = _default_candidate_steps_for_intent(domain, subdomain, action)
    candidate_steps = _normalize_candidate_steps(source.get("candidate_steps"), fallback=fallback_steps)
    return {
        "domain": domain,
        "subdomain": subdomain,
        "goal_type": goal_type,
        "action": action,
        "confidence": confidence,
        "parameters": parameters,
        "candidate_steps": candidate_steps,
    }


def _domain_hint_breaks_live_followup(task_state: dict | None, domain_hint: str, confidence: float = 0.0) -> bool:
    task_domain = _lookup_domain_for_task_state(task_state)
    hinted_domain = _normalize_intent_domain(domain_hint)
    hinted_confidence = float(confidence or 0.0)
    if not task_domain or not hinted_domain:
        return False
    if hinted_domain == task_domain:
        return False
    if hinted_domain in {"search", "code"}:
        return True
    if hinted_domain in {"email", "calendar"}:
        return True
    return hinted_domain == "general" and hinted_confidence >= 0.85


def _should_clear_stale_chat_task_state(
    session: dict | None,
    user_message: str,
    resolved_goal: str = "",
    *,
    domain_hint: str = "",
    domain_confidence: float = 0.0,
) -> bool:
    source = dict(session or {})
    task = source.get("task_state") if isinstance(source.get("task_state"), dict) else None
    if not task:
        return False

    current = str(user_message or "").strip()
    resolved = str(resolved_goal or "").strip()
    if not current:
        return False

    if _email_action_type(current) or _calendar_action_type(current):
        return False

    combined = "\n".join(part for part in (resolved, current) if part)
    if _domain_hint_breaks_live_followup(task, domain_hint, domain_confidence):
        return True
    if (not _normalize_intent_domain(domain_hint) or float(domain_confidence or 0.0) < 0.35) and _is_explicit_filesystem_request(combined):
        return True
    if _PRIVATE_DATA_DOMAIN_RE.search(combined):
        return False

    if _is_contextual_followup(current) or _is_short_followup(current):
        return True

    return not bool(_PRIVATE_DATA_DOMAIN_RE.search(current))


def _should_force_live_chat_followup(session: dict | None, user_message: str) -> bool:
    source = dict(session or {})
    task = source.get("task_state") if isinstance(source.get("task_state"), dict) else None
    if not task or not task_state_requires_live_data(task):
        return False

    task_type = str(task.get("task_type") or "").strip().lower()
    if task_type in {"email_action", "calendar_action"}:
        return True
    if task_type not in {"email_lookup", "calendar_lookup", "search_lookup"}:
        return False

    followup_relation = str(task.get("followup_relation") or "").strip().lower()
    if followup_relation != "constraint_change":
        return False

    return bool(str(user_message or "").strip())


def _lookup_domain_for_task_state(task_state: dict | None) -> str:
    task_type = str((task_state or {}).get("task_type") or "").strip().lower()
    if task_type == "email_lookup":
        return "email"
    if task_type == "calendar_lookup":
        return "calendar"
    if task_type == "search_lookup":
        return "search"
    return ""


def _should_preserve_live_lookup_followup(
    task_state: dict | None,
    user_message: str,
    *,
    domain_hint: str = "",
    domain_confidence: float = 0.0,
) -> bool:
    if not task_state or not task_state_requires_live_data(task_state):
        return False
    if not _lookup_domain_for_task_state(task_state):
        return False
    if str((task_state or {}).get("followup_relation") or "").strip().lower() != "constraint_change":
        return False
    message = str(user_message or "").strip()
    if not message:
        return False
    if _domain_hint_breaks_live_followup(task_state, domain_hint, domain_confidence):
        return False
    if (not _normalize_intent_domain(domain_hint) or float(domain_confidence or 0.0) < 0.35) and _is_explicit_filesystem_request(message):
        return False
    if _goal_requests_side_effect(message) or _email_action_type(message) or _calendar_action_type(message):
        return False
    return True


# ── Compiled regex for weak chat reply detection ─────────────────────────────
_WEAK_REPLY_RE = re.compile(
    r"(\bik (kan|heb|weet)\b.{0,30}\b(niet|geen)\b"
    r"|\bnot present in (this|the) context\b"
    r"|\bnot (available|present|included)\b"
    r"|\bi (don.?t|cannot|can.?t) (find|access|answer|retrieve|provide|help with)\b"
    r"|\bi (don.?t|do not) have\b.{0,30}\b(data|information|access|details|results)\b"
    r"|\bI have no (answer|data|information)\b"
    r"|\bniet beschikbaar\b"
    r"|\bgeen (data|informatie|toegang|antwoord|resultaat|gegevens)\b"
    r"|\bbeschik ik niet over\b"
    r"|\bbuiten (mijn )?bereik\b"
    r"|\bmissing (from|in)\b"
    r"|\bniet (gevonden|beschikbaar|mogelijk|beantwoord)\b"
    r"|\brequires?.{0,20}\b(tool|agent|workflow|internet|search|lookup)\b"
    r"|\bneed.{0,20}\b(search|look.?up|fetch|retrieve|access)\b"
    r"|\bhelaas\b"
    r"|\bunfortunately\b"
    r"|\bheb je (al )?toegang\b"
    r"|\bdo you have access\b"
    r"|\bniet automatisch (mogelijk|te)\b"
    r"|\bhet vereist\b"
    r"|\bje (moet|kunt) (het )?(zelf|eerst|handmatig)\b"
    r"|\byou (need|have) to .{0,20}(yourself|manually)\b"
    r"|\bkan ik .{0,20}niet (direct|automatisch)\b"
    r"|\bi.m (unable|not able) to\b"
    r"|\bI cannot (currently|directly|automatically)\b"
    r"|\bnot (currently )?supported\b"
    r"|\bbeyond (my|the) (scope|capabilit)\b)",
    re.IGNORECASE,
)

_CHAT_FOLLOWUP_LLM_TIMEOUT_SECONDS = 90
_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS = 18.0
_FALLBACK_MODEL_TIMEOUT_SECONDS = 90.0

_temporarily_unavailable_models: dict[str, float] = {}
_MODEL_UNAVAILABLE_COOLDOWN_SECONDS = 300.0


def _mark_model_temporarily_unavailable(model: str, reason: str = "") -> None:
    _temporarily_unavailable_models[model] = time.time()


def _model_is_temporarily_unavailable(model: str) -> bool:
    ts = _temporarily_unavailable_models.get(model)
    if ts is None:
        return False
    if time.time() - ts >= _MODEL_UNAVAILABLE_COOLDOWN_SECONDS:
        _temporarily_unavailable_models.pop(model, None)
        return False
    return True


async def _call_single_model_with_timeout(call, timeout_seconds: float, timeout_label: str) -> str:
    try:
        return await asyncio.wait_for(call, timeout=timeout_seconds)
    except asyncio.TimeoutError:
        return f"Error calling model: {timeout_label} timed out after {timeout_seconds:.0f}s"


def _model_has_runtime_support(
    model_name: str,
    *,
    settings: dict | None = None,
    installed_ollama: set[str] | None = None,
) -> bool:
    model = str(model_name or "").strip()
    if not model:
        return False
    if settings is None:
        settings = _load_settings()

    if model.startswith(NVIDIA_PREFIX):
        return bool((settings.get("nvidia_api_key") or "").strip())
    if model.startswith(OPENAI_PREFIX):
        return bool((settings.get("openai_api_key") or "").strip())
    if model.startswith(GEMINI_PREFIX):
        return bool((settings.get("gemini_api_key") or "").strip())
    if model.startswith(LLAMACPP_PREFIX):
        return True

    if installed_ollama is None:
        installed_ollama = {
            str(name).strip()
            for name in (get_ollama_models() or [])
            if str(name).strip()
        }

    if model in installed_ollama:
        return True

    if _is_cloud_model(model):
        return bool((settings.get("ollama_cloud_api_key") or "").strip())

    return True


def _select_runnable_model(
    primary_model: str,
    *fallback_models: str,
    settings: dict | None = None,
    installed_ollama: list[str] | set[str] | tuple[str, ...] | None = None,
) -> str:
    ordered = _unique_model_candidates(primary_model, *fallback_models)
    if not ordered:
        return ""
    if settings is None:
        settings = _load_settings()
    installed = None
    if installed_ollama is not None:
        installed = {
            str(name).strip()
            for name in installed_ollama
            if str(name).strip()
        }
    for candidate in ordered:
        if _model_has_runtime_support(candidate, settings=settings, installed_ollama=installed):
            if not _model_is_temporarily_unavailable(candidate):
                return candidate
    # All candidates either unsupported or temporarily unavailable — return first supported one
    for candidate in ordered:
        if _model_has_runtime_support(candidate, settings=settings, installed_ollama=installed):
            return candidate
    return ordered[0]


# ── Side-effect detection (extracted to backend.email_workflow) ──────────────
from backend.email_workflow import _goal_requests_side_effect  # noqa: E402

# ── Parsing helpers (extracted to backend.parsing) ──────────────────────────
from backend.parsing import (                     # noqa: E402
    _extract_tool_call,
    _FILLER_PATTERNS, _FILLER_CLOSING_PATTERNS,
    _strip_filler_phrases,
    _strip_tool_call_artifacts,
    _tool_json_message,
    _normalize_tool_json_reply,
    _extract_balanced_json_fragment,
    _parse_model_json,
    _parse_planner_output,
)


def _tool_plan_capability_summary(catalog: dict[str, object], allowed_tool_names: set[str] | None = None) -> list[dict[str, object]]:
    allowed = set(allowed_tool_names or set())
    summary: list[dict[str, object]] = []
    for tool_name, capability in sorted(catalog.items()):
        if allowed and tool_name not in allowed:
            continue
        summary.append({
            "name": tool_name,
            "description": str(getattr(capability, "description", "") or "").strip(),
            "input_schema": dict(getattr(capability, "input_schema", {}) or {}),
            "output_schema": dict(getattr(capability, "output_schema", {}) or {}),
            "planner_hints": dict(getattr(capability, "planner_hints", {}) or {}),
            "policy": dict(getattr(capability, "policy", {}) or {}),
            "enabled": bool(getattr(capability, "enabled", False)),
            "available": bool(getattr(capability, "available", False)),
            "side_effect": bool(getattr(capability, "side_effect", False)),
            "requires_approval": bool(getattr(capability, "requires_approval", False)),
        })
    return summary


def _resolve_tool_plan_path(source: object, expression: str) -> object | None:
    current = source
    for raw_part in [part.strip() for part in str(expression or "").split(".") if part.strip()]:
        match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)(?:\[(\d+)\])?$", raw_part)
        if not match:
            return None
        key = match.group(1)
        index = match.group(2)
        if not isinstance(current, dict) or key not in current:
            return None
        current = current.get(key)
        if index is not None:
            if not isinstance(current, list):
                return None
            idx = int(index)
            if idx < 0 or idx >= len(current):
                return None
            current = current[idx]
    return current


def _resolve_tool_plan_args(step, plan_bindings: dict[str, dict[str, object]]) -> dict[str, object]:
    if step.args:
        return dict(step.args)
    resolved: dict[str, object] = {}
    for arg_name, raw_value in dict(step.args_from or {}).items():
        if not isinstance(raw_value, str):
            resolved[arg_name] = raw_value
            continue
        reference = raw_value.strip()
        if "." not in reference:
            resolved[arg_name] = raw_value
            continue
        source_step, binding_name = reference.split(".", 1)
        source_step = source_step.strip()
        binding_name = binding_name.strip()
        source_bindings = plan_bindings.get(source_step) or {}
        if binding_name not in source_bindings:
            raise ValueError(f"Missing tool-plan binding '{reference}' for arg '{arg_name}'")
        resolved[arg_name] = source_bindings[binding_name]
    return resolved


def _extract_tool_plan_bindings(step, *, tool_name: str, tool_result: str, tool_details: dict | None = None) -> dict[str, object]:
    details = dict(tool_details or build_tool_result_details(tool_name, tool_result))
    sources: dict[str, object] = dict(details)
    if "records" not in sources:
        records = extract_email_lookup_records(tool_result)
        if records:
            sources["records"] = records
    if "events" not in sources:
        events = extract_calendar_lookup_records(tool_result)
        if events:
            sources["events"] = events
    bindings: dict[str, object] = {}
    for alias, expression in dict(getattr(step, "bind", {}) or {}).items():
        value = _resolve_tool_plan_path(sources, str(expression or "").strip())
        if value is not None:
            bindings[str(alias).strip()] = value
    return bindings


def _followup_confirms_prior_offer(chat_history: list[dict] | None, user_message: str) -> bool:
    message = str(user_message or "").strip().lower()
    if not re.search(r"\b(ja|yes|graag|ok|oke|prima|doe maar|ga maar)\b", message):
        return False

    history = chat_history or []
    assistant_messages = [str(item.get("content") or "") for item in history if str(item.get("role") or "").lower() == "assistant"]
    if not assistant_messages:
        return False

    prior = assistant_messages[-1]
    offer_re = re.compile(
        r"\b(wil je dat ik|zal ik|shall i|would you like me to|laat me ook|let me also|ik kan ook|i can also)\b"
        r"|\b(controleren|checken|zoeken|search|verify|verifi[eë]ren|look broader|breder zoeken)\b",
        re.IGNORECASE,
    )
    return bool(offer_re.search(prior))


def _drop_unsent_chat_reply(session: dict, reply: str) -> None:
    history = list(session.get("chat_history") or [])
    if history and str(history[-1].get("role") or "") == "assistant":
        last_content = str(history[-1].get("content") or "").strip()
        if last_content == str(reply or "").strip():
            history.pop()
            session["chat_history"] = history
            session["updated_at"] = datetime.now().isoformat()
            if session.get("id"):
                _save_session(session)


def _append_session_chat_message(session: dict, role: str, content: str) -> None:
    normalized_role = "user" if str(role or "").strip().lower() == "user" else "assistant"
    normalized_content = str(content or "").strip()
    if not normalized_content:
        return
    history = list(session.get("chat_history") or [])
    if history:
        last_entry = history[-1]
        if (
            str(last_entry.get("role") or "").strip().lower() == normalized_role
            and str(last_entry.get("content") or "").strip() == normalized_content
        ):
            session["chat_history"] = history
            return
    history.append({"role": normalized_role, "content": normalized_content})
    session["chat_history"] = history


def _record_session_exchange(session: dict, user_content: str = "", assistant_content: str = "") -> bool:
    """Record a user+assistant exchange. Returns True if session needs compact suggestion."""
    _append_session_chat_message(session, "user", user_content)
    _append_session_chat_message(session, "assistant", assistant_content)
    _hist_len = len(session.get("chat_history") or [])
    return _hist_len >= 40


_FOLLOWUP_PROVENANCE_RE = re.compile(
    r"\b(waar heb je gekeken|waar keek je|waar heb je gezocht|in welke agenda|welke agenda|"
    r"where did you look|where did you check|where did you search|which calendar|what did you check)\b",
    re.IGNORECASE,
)

_LOCAL_FOLDER_REQUEST_RE = re.compile(
    r"\b(?:welke|welk|toon|laat(?:\s+eens)?\s+zien|wat\s+staat\s+er\s+in|inhoud\s+van|list|show|what|which)\b",
    re.IGNORECASE,
)

_LOCAL_FOLDER_TARGET_RE = re.compile(
    r"\b(?:bestanden|files?|map|folder|directory|inhoud|contents?)\b",
    re.IGNORECASE,
)

_ATTACHED_FILE_SUMMARY_REQUEST_RE = re.compile(
    r"\b(?:wat\s+staat\s+er\s+in|wat\s+is\s+de\s+inhoud\s+van|inhoud(?:\s+van)?|"
    r"samenvat(?:ten|ting)?|vat\s+samen|leg\s+uit|analyseer|"
    r"what(?:'s|\s+is)?\s+(?:in|inside)|contents?\s+of|summary|summari[sz]e|explain|analy[sz]e)\b",
    re.IGNORECASE,
)

_ATTACHED_FILE_REFERENCE_RE = re.compile(
    r"\b(?:dit|deze|this|that|attached|uploaded|shared|bijgevoegde|geuploade|meegestuurde)\s+"
    r"(?:file|document|pdf|json|csv|txt|bestand|bijlage|document)\b",
    re.IGNORECASE,
)

_ATTACHED_FILE_PLURAL_REFERENCE_RE = re.compile(
    r"\b(?:deze|these|attached|uploaded|shared|bijgevoegde|geuploade|meegestuurde)\s+"
    r"(?:files|documents|attachments|bestanden|documenten|bijlagen)\b",
    re.IGNORECASE,
)

_WINDOWS_PATH_RE = re.compile(r"([A-Za-z]:\\[^\r\n\"']+)")

# ── Email workflow helpers (extracted to backend.email_workflow) ─────────────
from backend.email_workflow import (               # noqa: E402
    _EMAIL_LOOKUP_TOOL_NAMES,
    _EMAIL_MATCH_STOPWORDS,
    _EMAIL_OFFER_ALREADY_RE,
    _EMAIL_REFERENCE_RE,
    _SUMMARY_REQUEST_RE,
    _NUMBER_WORDS,
    _normalize_match_text,
    _match_tokens,
    _dedupe_email_records,
    _email_records_from_tool_trace,
    _session_email_records,
    _email_ordinal_index,
    _email_contextual_index,
    _score_email_record_match,
    _select_email_record,
    _looks_dutch_text,
    _compose_email_record_followup,
    _email_followup_should_use_llm,
    _email_attachment_focus_requested,
    _default_selected_email_record,
    _classify_selected_email_followup_decision,
    _build_selected_email_followup_context,
    _maybe_answer_email_followup,
    _selected_or_matched_email_record,
    _build_exact_email_read_call,
    _build_email_followup_read_call,
    _extract_email_body_preview,
    _update_selected_email_record_from_read_result,
    _store_session_email_records,
    _merge_session_email_records,
    _email_followup_offer,
    _append_email_followup_offer,
    _email_action_type,
    _detect_email_action_provider,
    _extract_email_subject,
    _extract_email_body_instruction,
    _is_email_forward_request,
    _email_forward_subject,
    _build_forward_email_body,
    _resolve_forward_send_provider,
    _extract_email_send_request,
    _email_send_action_report,
    _email_requested_count,
    _extract_email_selection_query,
    _email_reference_spec,
    _email_record_matches_temporal_hint,
    _rank_email_records,
    _select_email_records,
    _extract_email_update_directives,
    _email_action_report,
    _email_intent_missing_clarification,
    _email_intent_has_material,
    _email_intent_from_task_state,
    _extract_email_intent_with_fallback,
)

_CALENDAR_MATCH_STOPWORDS = _EMAIL_MATCH_STOPWORDS | {
    "agenda", "agendas", "afspraak", "afspraken", "calendar", "event", "events",
    "meeting", "meetings", "verwijder", "verwijderen", "delete", "remove",
    "wijzig", "wijzigen", "update", "verplaats", "verplaatsen", "move",
    "maak", "aanmaken", "create", "plan", "inplannen", "schedule",
    "gmail", "google", "outlook", "agenda's",
}
_MONTH_NAMES_TO_NUMBER = {
    "januari": 1, "februari": 2, "maart": 3, "april": 4, "mei": 5, "juni": 6,
    "juli": 7, "augustus": 8, "september": 9, "oktober": 10, "november": 11, "december": 12,
    "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6,
    "july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12,
    "jan": 1, "feb": 2, "mrt": 3, "apr": 4, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "okt": 10, "nov": 11, "dec": 12,
    "mar": 3, "may": 5, "oct": 10,
}
_WEEKDAY_NAMES_TO_INDEX = {
    "maandag": 0, "monday": 0,
    "dinsdag": 1, "tuesday": 1,
    "woensdag": 2, "wednesday": 2,
    "donderdag": 3, "thursday": 3,
    "vrijdag": 4, "friday": 4,
    "zaterdag": 5, "saturday": 5,
    "zondag": 6, "sunday": 6,
}
_READONLY_TOOL_NAMES = _EMAIL_LOOKUP_TOOL_NAMES | {
    "google_read_calendar",
    "outlook_read_calendar",
    "web_search",
    "fetch_url",
    "extract_source",
    "read_workspace_file",
    "search_workspace",
    "recall_memory",
}

_UI_TOOL_RESULT_DEFAULT_LIMIT = 1000
_UI_TOOL_RESULT_EXTENDED_LIMIT = 2500
_UI_TOOL_RESULT_EXTENDED_TOOLS = _EMAIL_LOOKUP_TOOL_NAMES | {
    "summarize_personal_items",
}


def _tool_result_ui_snippet(tool_name: str, result: str) -> str:
    normalized = str(tool_name or "").strip().lower()
    limit = _UI_TOOL_RESULT_EXTENDED_LIMIT if normalized in _UI_TOOL_RESULT_EXTENDED_TOOLS else _UI_TOOL_RESULT_DEFAULT_LIMIT
    return str(result or "")[:limit]


def _looks_dutch_text(text: str) -> bool:
    return _looks_dutch(text)


def _goal_requests_personal_summary(goal: str, domain: str) -> bool:
    value = str(goal or "")
    if not _SUMMARY_REQUEST_RE.search(value):
        return False
    lowered = value.lower()
    if domain == "email":
        return bool(_EMAIL_REFERENCE_RE.search(value) or "inbox" in lowered or "mailbox" in lowered)
    if domain == "calendar":
        return bool(_CALENDAR_MARKERS_RE.search(value) or "agenda" in lowered)
    return False


def _is_explicit_email_attachment_summary_request(goal: str) -> bool:
    lowered = str(goal or "").strip().lower()
    if not lowered:
        return False
    has_email_context = bool(re.search(r"\b(outlook|gmail|email|e-mail|mail|inbox|postvak)\b", lowered))
    has_attachment_context = bool(re.search(r"\b(pdf|bijlage|attachment|document)\b", lowered))
    has_summary_intent = bool(re.search(
        r"\b(samenvatting|samenvat|vat\s+samen|summary|summarize|analyseer|analyze|haal\s+de\s+inhoud\s+eruit|extract|lees|read)\b",
        lowered,
    ))
    return has_email_context and has_attachment_context and has_summary_intent


def _is_explicit_slack_post_request(goal: str) -> bool:
    value = str(goal or "")
    lowered = value.lower()
    has_post_verb = bool(re.search(r"\b(post|plaats|stuur|send)\b", lowered))
    has_channel_target = bool(re.search(r"#\w+|\b(kanaal|channel|slack)\b", lowered))
    return has_post_verb and has_channel_target


def _is_explicit_calendar_action_request(goal: str) -> bool:
    value = str(goal or "")
    lowered = value.lower()
    return bool(re.search(
        r"\b(agenda|calendar|afspraak|afspraken|meeting|event|evenement|uitnodiging|uitnodigingen)\b",
        lowered,
    ))


def _looks_like_public_web_query(goal: str) -> bool:
    value = str(goal or "").strip()
    if not value:
        return False
    lowered = value.lower()
    if re.search(r"\b(mijn|my|me|ik|i|ons|onze|our|we)\b", lowered):
        return False
    if _EMAIL_REFERENCE_RE.search(value) or _CALENDAR_MARKERS_RE.search(value):
        return False
    if _LIVE_DATA_QUERY_RE.search(value):
        return True
    if not re.search(r"(^|\s)(wanneer|when|hoe laat|what time|wat zijn|what are)\b", lowered):
        return False
    return bool(re.search(
        r"\b(sail|festival|evenement|event|koningsdag|f1|formula\s*1|champions\s+league|openingstijden|opening hours|race)\b",
        lowered,
    ))


def _extract_windows_path(text: str) -> str:
    raw = str(text or "")
    match = _WINDOWS_PATH_RE.search(raw)
    if not match:
        return ""
    candidate = match.group(1).strip()
    candidate = candidate.rstrip(" \t\r\n.,;:!?)\"]'")
    return candidate


def _format_local_folder_listing(path_text: str, *, dutch: bool) -> str:
    folder = Path(path_text)
    if not folder.exists() or not folder.is_dir():
        return (
            f"Ik kan die map niet vinden: {path_text}"
            if dutch else
            f"I can't find that folder: {path_text}"
        )

    try:
        entries = sorted(folder.iterdir(), key=lambda item: (item.is_file(), item.name.lower()))
    except Exception as exc:
        return (
            f"Ik kan die map niet lezen: {path_text} ({exc})"
            if dutch else
            f"I can't read that folder: {path_text} ({exc})"
        )

    folders = [item.name for item in entries if item.is_dir()]
    files = [item.name for item in entries if item.is_file()]

    if not entries:
        return f"De map {path_text} is leeg." if dutch else f"The folder {path_text} is empty."

    lines: list[str] = []
    if dutch:
        lines.append(f"In {path_text} staan {len(files)} bestanden en {len(folders)} mappen.")
        if files:
            preview = files[:20]
            lines.append("Bestanden: " + ", ".join(preview))
            if len(files) > len(preview):
                lines.append(f"Nog {len(files) - len(preview)} bestanden niet getoond.")
        if folders:
            preview = folders[:12]
            lines.append("Mappen: " + ", ".join(preview))
            if len(folders) > len(preview):
                lines.append(f"Nog {len(folders) - len(preview)} mappen niet getoond.")
    else:
        lines.append(f"{path_text} contains {len(files)} files and {len(folders)} folders.")
        if files:
            preview = files[:20]
            lines.append("Files: " + ", ".join(preview))
            if len(files) > len(preview):
                lines.append(f"{len(files) - len(preview)} more files not shown.")
        if folders:
            preview = folders[:12]
            lines.append("Folders: " + ", ".join(preview))
            if len(folders) > len(preview):
                lines.append(f"{len(folders) - len(preview)} more folders not shown.")
    return "\n".join(lines)


def _maybe_answer_local_folder_request(user_message: str) -> str | None:
    message = str(user_message or "").strip()
    if not message:
        return None
    if not _LOCAL_FOLDER_REQUEST_RE.search(message):
        return None
    if not _LOCAL_FOLDER_TARGET_RE.search(message):
        return None
    path_text = _extract_windows_path(message)
    if not path_text:
        return None
    return _format_local_folder_listing(path_text, dutch=_looks_dutch_text(message))


def _extract_attached_context_blocks(context: str) -> list[dict[str, str]]:
    blocks: list[dict[str, str]] = []
    current_name = ""
    current_lines: list[str] = []

    for raw_line in str(context or "").splitlines():
        header_match = re.match(r"^---\s+(.+?)\s+---\s*$", raw_line.strip())
        if header_match:
            if current_name:
                content = "\n".join(current_lines).strip()
                if content and not current_name.lower().startswith("attached folder:"):
                    blocks.append({"name": current_name, "content": content})
            current_name = str(header_match.group(1) or "").strip()
            current_lines = []
            continue
        if current_name:
            current_lines.append(raw_line)

    if current_name:
        content = "\n".join(current_lines).strip()
        if content and not current_name.lower().startswith("attached folder:"):
            blocks.append({"name": current_name, "content": content})
    return blocks


def _extract_attached_folder_blocks(context: str) -> list[dict[str, str]]:
    blocks: list[dict[str, str]] = []
    current_name = ""
    current_lines: list[str] = []

    for raw_line in str(context or "").splitlines():
        header_match = re.match(r"^---\s+(.+?)\s+---\s*$", raw_line.strip())
        if header_match:
            if current_name and current_name.lower().startswith("attached folder:"):
                content = "\n".join(current_lines).strip()
                if content:
                    blocks.append({"name": current_name, "content": content})
            current_name = str(header_match.group(1) or "").strip()
            current_lines = []
            continue
        if current_name:
            current_lines.append(raw_line)

    if current_name and current_name.lower().startswith("attached folder:"):
        content = "\n".join(current_lines).strip()
        if content:
            blocks.append({"name": current_name, "content": content})
    return blocks


def _parse_attached_folder_pdf_index(content: str) -> list[dict[str, str]]:
    files: list[dict[str, str]] = []
    for raw_line in str(content or "").splitlines():
        match = re.match(
            r"^\s*(?P<name>.+?\.pdf)\s*\([^)]*\)\s*->\s*(?P<path>[A-Za-z]:\\.+?\.pdf)\s*$",
            raw_line.strip(),
            re.IGNORECASE,
        )
        if not match:
            continue
        name = str(match.group("name") or "").strip()
        full_path = str(match.group("path") or "").strip()
        if not name or not full_path:
            continue
        files.append({"name": name, "full_path": full_path})
    return files


def _select_attached_folder_pdf_summary_targets(user_message: str, context: str) -> dict | None:
    message = str(user_message or "").strip()
    if not message:
        return None

    has_summary_intent = bool(_ATTACHED_FILE_SUMMARY_REQUEST_RE.search(message)) or bool(re.search(
        r"\b(?:scan|beschrijf|describe|omschrijving|omschrijven|beschrijving)\b",
        message,
        re.IGNORECASE,
    ))
    if not has_summary_intent:
        return None

    lowered_message = message.lower()
    if "pdf" not in lowered_message:
        return None
    if not any(token in lowered_message for token in ("folder", "map", "attached", "bijgevoegd", "meegestuurd")):
        return None

    indexed_pdfs: list[dict[str, str]] = []
    for block in _extract_attached_folder_blocks(context):
        indexed_pdfs.extend(_parse_attached_folder_pdf_index(block.get("content") or ""))

    if not indexed_pdfs:
        return None

    selected_files = indexed_pdfs[:6]
    return {
        "files": selected_files,
        "total_pdf_count": len(indexed_pdfs),
        "omitted_count": max(0, len(indexed_pdfs) - len(selected_files)),
    }


def _render_attached_folder_pdf_summary_reply(
    user_message: str,
    summaries: list[dict[str, str]],
    *,
    total_pdf_count: int,
    omitted_count: int = 0,
) -> str:
    dutch = _looks_dutch_text(user_message)
    lines = ["Korte inhoud per pdf:" if dutch else "Short summary per PDF:"]
    for item in summaries:
        name = str(item.get("name") or "pdf").strip() or "pdf"
        summary = str(item.get("summary") or "").strip()
        if summary:
            lines.append(f"- {name}: {summary}")
    if omitted_count > 0:
        if dutch:
            lines.append(f"- {omitted_count} extra pdf-bestanden niet meegenomen in deze snelle samenvatting.")
        else:
            lines.append(f"- {omitted_count} additional PDF files were omitted from this quick summary.")
    if len(summaries) < max(1, int(total_pdf_count or len(summaries))):
        if dutch:
            lines.append("Open een specifiek bestand als je de volledige inhoud wilt zien.")
        else:
            lines.append("Open a specific file if you want the full contents.")
    return "\n".join(lines).strip()


def _select_attached_context_summary_payload(user_message: str, context: str) -> dict | None:
    message = str(user_message or "").strip()
    if not message or not _ATTACHED_FILE_SUMMARY_REQUEST_RE.search(message):
        return None

    blocks = _extract_attached_context_blocks(context)
    if not blocks:
        return None

    lowered_message = message.lower()
    selected: list[dict[str, str]] = []
    seen_names: set[str] = set()

    quoted_names = [
        str(part or "").strip().lower()
        for left, right in re.findall(r'"([^\"]+)"|\'([^\']+)\'', message)
        for part in (left or right,)
        if str(part or "").strip()
    ]

    for block in blocks:
        block_name = str(block.get("name") or "").strip()
        if not block_name:
            continue
        block_key = block_name.lower()
        block_stem = Path(block_name).stem.lower().strip()
        if any(name in block_key or block_key in name for name in quoted_names):
            if block_key not in seen_names:
                selected.append(block)
                seen_names.add(block_key)
            continue
        if len(block_key) >= 4 and block_key in lowered_message:
            if block_key not in seen_names:
                selected.append(block)
                seen_names.add(block_key)
            continue
        if len(block_stem) >= 4 and block_stem in lowered_message:
            if block_key not in seen_names:
                selected.append(block)
                seen_names.add(block_key)

    if not selected:
        singular_reference = bool(_ATTACHED_FILE_REFERENCE_RE.search(message))
        plural_reference = bool(_ATTACHED_FILE_PLURAL_REFERENCE_RE.search(message))
        if len(blocks) == 1 and (singular_reference or not plural_reference):
            selected = [blocks[0]]
        elif plural_reference:
            selected = blocks[:3]
        else:
            return None

    names: list[str] = []
    payload_parts: list[str] = []
    for block in selected[:3]:
        block_name = str(block.get("name") or "").strip()
        block_content = str(block.get("content") or "").strip()
        if not block_name or not block_content:
            continue
        names.append(block_name)
        payload_parts.append(f"[Attached file: {block_name}]\n{block_content}")

    if not payload_parts:
        return None
    return {"names": names, "content": "\n\n".join(payload_parts)}


async def _maybe_summarize_extracted_source_for_message(
    user_message: str,
    tool_result: str,
    *,
    settings: dict,
    session: dict | None,
    preferred_model: str = "",
    used_model: str = "",
) -> str:
    if not str(tool_result or "").strip():
        return ""

    model_candidates = [
        candidate
        for candidate in (
            preferred_model,
            used_model,
            str(settings.get("main_model") or ""),
            str(settings.get("fallback_model") or ""),
        )
        if str(candidate or "").strip()
    ]
    if not model_candidates:
        return ""

    envelope = build_tool_result_envelope(
        user_message,
        user_message,
        "extract_source",
        str(tool_result or ""),
        str(tool_result or ""),
    )
    source_content = str(envelope.get("context_result") or "")
    prompt = (
        "Summarize the extracted source below to answer the user's request.\n"
        "Focus on the document's substance, be concise and factual, and mention limitations only when they materially affect the answer.\n"
        "Do not mention extraction markers, console errors, or technical formatting.\n\n"
        f"[USER REQUEST]\n{user_message}\n\n"
        f"[EXTRACTED SOURCE CONTENT]\n{source_content[:18000]}\n"
    )
    try:
        raw_reply, _used_model = await _call_text_model_candidates(
            prompt,
            model_candidates,
            system=_compose_agent_system(),
            num_ctx=16384,
            thinking=False,
            per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
        )
        if _is_llm_error(raw_reply):
            return ""
        reply = re.sub(r'<think>[\s\S]*?</think>', '', str(raw_reply or '')).strip()
        if not reply or "TOOL_CALL" in reply or '"tool"' in reply:
            return ""
        return reply
    except Exception:
        return ""


# ── Calendar workflow helpers (extracted to backend.calendar_workflow) ────────
from backend.calendar_workflow import (            # noqa: E402
    _calendar_records_from_tool_trace,
    _session_calendar_records,
    _store_session_calendar_records,
    _store_session_lookup_records_by_domain,
    _detect_calendar_provider_scope,
    _calendar_action_type,
    _calendar_requested_count,
    _calendar_intent_missing_clarification,
    _extract_calendar_intent_with_fallback,
    _calendar_match_tokens,
    _extract_calendar_selection_query,
    _score_calendar_record_match,
    _calendar_record_matches_temporal_hint,
    _select_calendar_records,
    _parse_rendered_calendar_datetime,
    _resolve_calendar_day_phrase,
    _extract_time_range,
    _calendar_create_payload,
    _calendar_update_directives,
    _calendar_update_payload_from_record,
    _calendar_action_report,
    _apply_calendar_range_guard,
    _init_calendar_constants,
)
_init_calendar_constants(_CALENDAR_MATCH_STOPWORDS, _MONTH_NAMES_TO_NUMBER, _WEEKDAY_NAMES_TO_INDEX)

async def _execute_task_graph(
    graph,
    *,
    websocket: WebSocket,
    run_id: str,
    step_index_base: int = 0,
) -> list[tuple[str, str]]:
    loop = asyncio.get_event_loop()
    completed: dict[str, str] = {}
    task_order = {task.id: index for index, task in enumerate(graph.tasks)}
    task_by_id = {task.id: task for task in graph.tasks}
    pending_ids = [task.id for task in graph.tasks]
    results_by_id: dict[str, tuple[str, str]] = {}

    async def _run_task(task, offset: int) -> tuple[str, str, str]:
        await manager.send_message(
            {"type": "tool_call", "tool": task.tool_name, "args": task.args, "step_index": step_index_base + offset},
            websocket,
        )
        tool_result = await loop.run_in_executor(None, _execute_tool, task.tool_name, task.args)
        await manager.send_message(
            {"type": "tool_result", "tool": task.tool_name, "result": _tool_result_ui_snippet(task.tool_name, tool_result), "step_index": step_index_base + offset},
            websocket,
        )
        return task.id, task.tool_name, tool_result

    while pending_ids:
        if run_id in manager.active_runs and not manager.active_runs.get(run_id, False):
            break
        ready_ids = [
            task_id for task_id in pending_ids
            if all(dep in completed for dep in task_by_id[task_id].depends_on)
        ]
        if not ready_ids:
            unresolved = {
                task_id: [dep for dep in task_by_id[task_id].depends_on if dep not in completed]
                for task_id in pending_ids
            }
            raise RuntimeError(f"Task graph stalled with unresolved dependencies: {unresolved}")

        batch = [task_by_id[task_id] for task_id in ready_ids]
        batch_results = await asyncio.gather(*[
            _run_task(task, task_order[task.id])
            for task in batch
        ])
        for task_id, tool_name, tool_result in batch_results:
            completed[task_id] = tool_result
            results_by_id[task_id] = (tool_name, tool_result)
            pending_ids.remove(task_id)

    return [results_by_id[task.id] for task in graph.tasks if task.id in results_by_id]


def _store_task_graph_with_validation(
    session: dict,
    graph,
    *,
    blocked_tools: set[str],
    disabled_tools: set[str],
    approval_tools: set[str],
    max_tasks: int,
    max_depth: int,
    allow_side_effects: bool,
):
    validation = validate_task_graph(
        graph,
        blocked_tools=blocked_tools,
        disabled_tools=disabled_tools,
        approval_tools=approval_tools,
        max_tasks=max_tasks,
        max_depth=max_depth,
        allow_side_effects=allow_side_effects,
    )
    session["task_graph"] = graph.to_dict()
    session["task_graph_validation"] = validation.to_dict()
    return validation


def _join_human_list(items: list[str], dutch: bool) -> str:
    values = [str(item or "").strip() for item in items if str(item or "").strip()]
    if not values:
        return ""
    if len(values) == 1:
        return values[0]
    if len(values) == 2:
        return f"{values[0]} {'en' if dutch else 'and'} {values[1]}"
    separator = ", "
    tail = f" {'en' if dutch else 'and'} {values[-1]}"
    return separator.join(values[:-1]) + tail


# ── Session context helpers (extracted to backend.session_context) ───────────
from backend.session_context import (             # noqa: E402
    _task_time_phrase,
    _tool_phrase,
    _session_tool_trace,
    _build_compacted_tool_trace,
    _workflow_context_artifacts,
    _append_workflow_context_artifact,
    _infer_tool_trace_from_task_state,
    _stable_json,
)


def _settings_outlook_enabled(settings: dict | None) -> bool:
    source = settings or {}
    if "use_outlook" in source:
        return bool(source.get("use_outlook", False))
    return bool(source.get("outlook_enabled", False))


def _quick_clarification_override(
    goal: str,
    question: str,
    *,
    use_outlook: bool = False,
    gmail_available: bool = False,
) -> str:
    lowered_goal = str(goal or "").strip().lower()
    lowered_question = str(question or "").strip().lower()
    if not lowered_question:
        return ""

    share_like_request = any(
        token in lowered_question
        for token in (
            "upload",
            "provide a link",
            "provide the pdf",
            "provide the complete document",
            "share",
            "delen",
            "hier delen",
            "hier uploaden",
            "uploaden",
        )
    )
    pdf_like_request = any(
        token in lowered_question
        for token in (
            "pdf",
            "bijlage",
            "attachment",
            "bestand",
            "document",
            "email",
            "e-mail",
            "outlook",
        )
    )

    forced_extract_request = detect_extract_source_request(goal)
    if forced_extract_request:
        source = str(forced_extract_request.get("source") or "").strip()
        if source and Path(source).exists():
            return "SIMPLE"

    if use_outlook and OUTLOOK_SUPPORT and "outlook" in lowered_goal:
        if _is_explicit_email_attachment_summary_request(goal) or (_email_attachment_focus_requested(goal) and _goal_requests_personal_summary(goal, "email")):
            return "NEEDS_PLAN"
        if (
            any(
                token in lowered_question
                for token in (
                    "outlook access",
                    "microsoft access",
                    "authorize microsoft",
                    "authorize outlook",
                    "gmail",
                )
            )
            or (share_like_request and pdf_like_request)
        ):
            return "NEEDS_PLAN"

    if gmail_available and "gmail" in lowered_goal:
        if any(
            token in lowered_question
            for token in (
                "gmail access",
                "google access",
                "authorize google",
                "authorize gmail",
            )
        ):
            return "NEEDS_PLAN"

    return ""


def _tool_call_signature(tool_name: str, tool_args: dict | None) -> str:
    normalized_tool = _normalize_tool_name(tool_name)
    payload = {
        "tool": normalized_tool,
        "args": tool_args or {},
    }
    return hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()


def _lookup_signature(goal: str, task_state: dict | None, calls: list[dict]) -> str:
    task = dict(task_state or {})
    payload = {
        "goal": re.sub(r"\s+", " ", str(goal or "").strip()).lower(),
        "task_type": str(task.get("task_type") or ""),
        "provider_scope": str(task.get("provider_scope") or ""),
        "time_filter": str(task.get("time_filter") or ""),
        "time_label": str(task.get("time_label") or ""),
        "since": str(task.get("since") or ""),
        "until": str(task.get("until") or ""),
        "entity": dict(task.get("entity") or {}),
        "calls": [
            {
                "tool": _normalize_tool_name(item.get("tool", "")),
                "args": item.get("args", {}),
            }
            for item in calls
        ],
    }
    return hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()


def _audit_session_continuity_summary(summary: dict | None) -> dict:
    data = dict(summary or {})
    issues: list[str] = []
    if not str(data.get("active_goal") or "").strip():
        issues.append("missing active_goal")
    if not str(data.get("task_type") or "").strip():
        issues.append("missing task_type")
    if not isinstance(data.get("source_tools"), list):
        issues.append("missing source_tools")
    if str(data.get("task_type") or "") in {"email_lookup", "calendar_lookup", "search_lookup"}:
        if not data.get("source_tools"):
            issues.append("lookup summary missing source_tools")
    if str(data.get("task_type") or "") == "email_lookup" and not str(data.get("provider_scope") or "").strip():
        issues.append("email summary missing provider_scope")
    return {"ok": not issues, "issues": issues}


def _build_session_continuity_summary(goal: str, task_state: dict | None, trace: list[dict], final_report: str = "") -> dict:
    task = dict(task_state or {})
    details_list = [
        dict(item.get("tool_details") or build_tool_result_details(str(item.get("tool_name") or ""), str(item.get("tool_result") or "")))
        for item in trace
        if str(item.get("tool_name") or "").strip()
    ]
    provenance = [
        str(detail.get("provenance_label") or "").strip()
        for detail in details_list
        if str(detail.get("provenance_label") or "").strip()
    ]
    counts = [
        f"{detail.get('provider')}: {int(detail.get('count') or 0)}"
        for detail in details_list
        if detail.get("kind") in {"email_lookup", "calendar_lookup"} and int(detail.get("count") or 0) > 0
    ]
    exact_identifiers: list[str] = []
    for detail in details_list:
        if detail.get("kind") == "email_lookup":
            for record in list(detail.get("records") or [])[:5]:
                subject = str(record.get("subject") or "").strip()
                if subject:
                    exact_identifiers.append(subject)
        elif detail.get("kind") == "calendar_lookup":
            for event in list(detail.get("events") or [])[:5]:
                title = str(event.get("title") or "").strip()
                if title:
                    exact_identifiers.append(title)
    for key in ("time_label", "since", "until"):
        value = str(task.get(key) or "").strip()
        if value:
            exact_identifiers.append(value)
    source_tools = [str(item.get("tool_name") or "").strip() for item in trace if str(item.get("tool_name") or "").strip()]
    summary = {
        "active_goal": str(goal or "").strip(),
        "display_goal": task_state_display_goal(task) if task else str(goal or "").strip(),
        "task_type": str(task.get("task_type") or "generic"),
        "provider_scope": str(task.get("provider_scope") or "default"),
        "time_filter": str(task.get("time_filter") or "none"),
        "time_label": str(task.get("time_label") or ""),
        "source_tools": source_tools,
        "source_provenance": list(dict.fromkeys(provenance)),
        "result_counts": list(dict.fromkeys(counts)),
        "exact_identifiers": list(dict.fromkeys(exact_identifiers))[:12],
        "pending_user_asks": [str(goal or "").strip()] if str(goal or "").strip() else [],
        "final_report_preview": str(final_report or "").strip()[:280],
    }
    audit = _audit_session_continuity_summary(summary)
    summary["audit"] = audit
    summary["text"] = "\n".join([
        "## Active goal",
        summary["active_goal"] or "None.",
        "## Constraints",
        f"task_type={summary['task_type']}; provider_scope={summary['provider_scope']}; time_filter={summary['time_filter']}; time_label={summary['time_label'] or '-'}",
        "## Sources",
        ", ".join(summary["source_provenance"] or summary["source_tools"]) or "None.",
        "## Latest results",
        ", ".join(summary["result_counts"]) or (summary["final_report_preview"] or "None."),
        "## Exact identifiers",
        ", ".join(summary["exact_identifiers"]) or "None.",
    ])
    return summary


def _store_session_continuity_summary(session: dict, goal: str, task_state: dict | None, trace: list[dict], final_report: str = "") -> None:
    summary = _build_session_continuity_summary(goal, task_state, trace, final_report)
    session["continuity_summary"] = summary
    session["continuity_summary_text"] = str(summary.get("text") or "")
    session["continuity_summary_audit"] = dict(summary.get("audit") or {})


def _maybe_reuse_no_progress_lookup(session: dict | None, goal: str, task_state: dict | None, calls: list[dict]) -> dict | None:
    source = session or {}
    if str((task_state or {}).get("task_type") or "") == "calendar_lookup":
        return None
    signature = _lookup_signature(goal, task_state, calls)
    if signature != str(source.get("last_lookup_signature") or ""):
        return None
    final_report = str(source.get("final_report") or "").strip()
    if not final_report:
        return None
    audit = dict(source.get("continuity_summary_audit") or {})
    if audit and not bool(audit.get("ok")):
        return None
    return {
        "signature": signature,
        "final_report": final_report,
        "tool_trace": _session_tool_trace(source),
    }


def _continuity_summary_task_state(session: dict | None) -> dict:
    summary = (session or {}).get("continuity_summary") if isinstance((session or {}).get("continuity_summary"), dict) else {}
    task: dict[str, object] = {}
    for key in ("task_type", "provider_scope", "time_filter", "time_label"):
        value = str(summary.get(key) or "").strip()
        if value:
            task[key] = value
    return task


def _continuity_summary_provenance_phrases(session: dict | None, dutch: bool) -> list[str]:
    summary = (session or {}).get("continuity_summary") if isinstance((session or {}).get("continuity_summary"), dict) else {}
    raw_values: list[str] = []
    for key in ("source_provenance", "source_tools"):
        values = summary.get(key)
        if not isinstance(values, list):
            continue
        for value in values:
            text = str(value or "").strip()
            if text:
                raw_values.append(text)

    phrases: list[str] = []
    seen: set[str] = set()
    for value in raw_values:
        phrase = _tool_phrase(value, dutch)
        if not phrase:
            lowered = value.lower()
            if "outlook" in lowered and ("agenda" in lowered or "calendar" in lowered):
                phrase = "in je Outlook-agenda" if dutch else "in your Outlook calendar"
            elif ("google" in lowered or "gmail" in lowered) and ("agenda" in lowered or "calendar" in lowered):
                phrase = "in je Google Agenda" if dutch else "in your Google Calendar"
            elif "outlook" in lowered:
                phrase = "in je Outlook-mailbox" if dutch else "in your Outlook mailbox"
            elif "gmail" in lowered:
                phrase = "in je Gmail" if dutch else "in your Gmail"
            elif "web" in lowered or "website" in lowered:
                phrase = "op het web" if dutch else "on the web"
        if not phrase or phrase in seen:
            continue
        seen.add(phrase)
        phrases.append(phrase)
    return phrases


def _clear_stale_chat_followup_context(session: dict | None) -> bool:
    if not isinstance(session, dict):
        return False

    changed = False
    for key in (
        "task_state",
        "last_lookup_signature",
        "_pending_exact_email_read",
        "last_selected_email",
        "last_email_results",
        "last_calendar_results",
        "last_workflow_goal",
    ):
        if key in session:
            session.pop(key, None)
            changed = True

    if int(session.get("_chat_turns_since_workflow") or 0) < 1:
        session["_chat_turns_since_workflow"] = 1
        changed = True
    return changed


def _maybe_answer_context_followup(session: dict | None, user_message: str) -> str | None:
    message = str(user_message or "").strip()
    if not message or not _FOLLOWUP_PROVENANCE_RE.search(message):
        return None

    source = session or {}
    dutch = _looks_dutch_text(message) or _looks_dutch_text(source.get("final_report") or "")
    trace = _session_tool_trace(source)
    if not trace:
        trace = _infer_tool_trace_from_task_state(source)

    phrases: list[str] = []
    seen: set[str] = set()
    for item in trace:
        phrase = _tool_phrase(item.get("tool_name", ""), dutch)
        if not phrase or phrase in seen:
            continue
        seen.add(phrase)
        phrases.append(phrase)

    if not phrases:
        phrases = _continuity_summary_provenance_phrases(source, dutch)

    if not phrases:
        return None

    task = dict(source.get("task_state") or {})
    if not task:
        task = _continuity_summary_task_state(source)
    task_type = str(task.get("task_type") or "")
    time_phrase = _task_time_phrase(task, dutch)
    final_report = str(source.get("final_report") or "").strip().lower()

    if dutch:
        reply = f"Ik heb gekeken {_join_human_list(phrases, True)}"
        if time_phrase:
            reply += f" {time_phrase}"
        reply += "."
        if task_type == "calendar_lookup" and (
            "geen komende afspraken" in final_report
            or "no upcoming events" in final_report
            or "no events found for the requested period" in final_report
        ):
            reply += " Daar vond ik geen komende afspraken."
        elif task_type == "email_lookup" and ("geen e-mails" in final_report or "found no emails" in final_report):
            reply += " Daar vond ik geen e-mails."
        return reply

    reply = f"I checked {_join_human_list(phrases, False)}"
    if time_phrase:
        reply += f" {time_phrase}"
    reply += "."
    if task_type == "calendar_lookup" and (
        "geen komende afspraken" in final_report
        or "no upcoming events" in final_report
        or "no events found for the requested period" in final_report
    ):
        reply += " There were no upcoming events there."
    elif task_type == "email_lookup" and ("geen e-mails" in final_report or "found no emails" in final_report):
        reply += " There were no matching emails there."
    return reply


def _chat_followup_model_candidates(settings: dict, session: dict, preferred_model: str = "", used_model: str = "") -> list[str]:
    ordered: list[str] = []
    seen: set[str] = set()
    for candidate in (
        used_model,
        preferred_model,
        session.get("model", ""),
        settings.get("main_model", ""),
        settings.get("fallback_model", ""),
        settings.get("fast_model", ""),
    ):
        model = (candidate or "").strip()
        if not model or model in seen:
            continue
        ordered.append(model)
        seen.add(model)
    return ordered


async def _maybe_summarize_full_email_read_result(
    user_message: str,
    tool_name: str,
    tool_result: str,
    *,
    settings: dict,
    session: dict | None,
    preferred_model: str = "",
    used_model: str = "",
) -> str:
    normalized_tool = _normalize_tool_name(tool_name)
    if normalized_tool not in {"outlook_get_email", "gmail_get_email"}:
        return ""
    if not _goal_requests_personal_summary(user_message, "email"):
        return ""

    session_dict = session if isinstance(session, dict) else {}
    model_candidates = _chat_followup_model_candidates(
        settings,
        session_dict,
        preferred_model=preferred_model,
        used_model=used_model,
    )
    if not model_candidates:
        return ""

    details = build_tool_result_details(normalized_tool, str(tool_result or ""))
    email_content = compact_tool_result_for_context(normalized_tool, str(tool_result or ""), details)
    prompt = (
        "Summarize the exact email and any extracted attachment contents below to answer the user's request.\n"
        "If attachment content is present, focus on the attached document's substance instead of repeating email headers.\n"
        "Be concise, factual, and direct. Do not mention extraction markers or technical formatting.\n\n"
        f"[USER REQUEST]\n{user_message}\n\n"
        f"[EMAIL + ATTACHMENT CONTENT]\n{email_content[:18000]}\n"
    )
    try:
        raw_reply, _used_model = await _call_text_model_candidates(
            prompt,
            model_candidates,
            system=_compose_agent_system(),
            num_ctx=16384,
            thinking=False,
            per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
        )
        if _is_llm_error(raw_reply):
            return ""
        reply = re.sub(r'<think>[\s\S]*?</think>', '', str(raw_reply or '')).strip()
        if not reply or "TOOL_CALL" in reply or '"tool"' in reply:
            return ""
        return reply
    except Exception:
        return ""


def _reply_claims_action(reply: str) -> bool:
    value = str(reply or "").strip()
    if not value:
        return False
    action_claim_re = re.compile(
        r"\b(ik gebruik mijn tools|ik ga|laat me|ik zal|i'm using my tools|i will|let me|checking|controleren|verifi[eë]ren|uitwerken)\b",
        re.IGNORECASE,
    )
    result_re = re.compile(
        r"\b(gevonden|found|hier zijn|here are|resultaat|results|ik heb .* gevonden|i found|samengevat|summary)\b",
        re.IGNORECASE,
    )
    return bool(action_claim_re.search(value)) and not bool(result_re.search(value))


# ── Build TOOLS_DESC for LLM prompts ────────────────────────────────────────

def _is_onedrive_available() -> bool:
    """OneDrive is available if local sync path is set OR Microsoft Graph is authorized."""
    local, _ = _is_onedrive_local()
    if local:
        return True
    return _microsoft_graph_headers() is not None

def _build_tools_desc(*, use_outlook: bool = False, gmail_available: bool = False,
                      whatsapp_available: bool = False, google_drive_available: bool = False,
                      onedrive_available: bool = False, browser_available: bool = False) -> str:
    """Build the full TOOLS_DESC string describing available tools for the LLM."""
    settings = _load_settings()
    _outlook_tools_desc = (
        "  outlook_get_inbox     – {\"count\": int (opt, default 250), \"unread_only\": bool (opt), \"folder\": str (opt), \"mailbox\": str (opt),\n"
        "                           \"today_only\": bool (opt), \"since\": str (opt), \"until\": str (opt)}\n"
        "                         List recent emails from Outlook (NO keyword filtering). Use ONLY when the user\n"
        "                         wants to see inbox contents, recent emails, or a folder listing without a topic.\n"
        "                         Default count is 250. Do NOT use this tool when the user asks about a topic,\n"
        "                         subject, or keyword — use outlook_search_emails instead.\n"
        "  outlook_send_email   – {\"to\": str, \"subject\": str, \"body\": str, \"cc\": str (opt)}\n"
        "                         Send an email via local Outlook.\n"
        "  outlook_update_email – {\"message_id\": str, \"to\": str (opt), \"subject\": str (opt), \"body\": str (opt), \"cc\": str (opt)}\n"
        "                         Update an Outlook draft email by Message ID.\n"
        "                         MANDATORY: call outlook_get_inbox or outlook_search_emails FIRST with folder: 'Drafts' to obtain the exact Message ID.\n"
        "  outlook_delete_email – {\"message_id\": str}\n"
        "                         Delete an Outlook email by Message ID.\n"
        "                         MANDATORY: call outlook_get_inbox or outlook_search_emails FIRST to obtain the exact Message ID. NEVER guess a Message ID.\n"
        "  outlook_read_calendar – {\"days_ahead\": int (opt, default 7), \"since\": str (opt, YYYY-MM-DD), \"until\": str (opt, YYYY-MM-DD)}\n"
        "                         Read upcoming calendar events from local Outlook. Supports relative horizon and explicit date ranges. Returns Event IDs for update/delete.\n"
        "  outlook_create_calendar_event – {\"summary\": str, \"start\": str, \"end\": str (opt),\n"
        "                                   \"description\": str (opt), \"location\": str (opt), \"attendees\": str (opt, comma-separated emails)}\n"
        "                         Create a new Outlook calendar event. start/end use local ISO-8601 datetimes\n"
        "                         like '2026-03-15T10:00:00' or date-only strings like '2026-03-15' for all-day events.\n"
        "                         If no end time is given, default to 1 hour after start.\n"
        "  outlook_update_calendar_event – {\"event_id\": str, \"summary\": str (opt), \"start\": str (opt),\n"
        "                                   \"end\": str (opt), \"description\": str (opt), \"location\": str (opt)}\n"
        "                         Update an existing Outlook calendar event.\n"
        "                         MANDATORY: call outlook_read_calendar FIRST to obtain the exact Event ID. NEVER guess an Event ID.\n"
        "                         Only change the fields the user asked to change — keep everything else unchanged.\n"
        "  outlook_delete_calendar_event – {\"event_id\": str}\n"
        "                         Delete an Outlook calendar event by Event ID.\n"
        "                         MANDATORY: call outlook_read_calendar FIRST to obtain the exact Event ID. NEVER guess an Event ID.\n"
        "  outlook_search_emails – {\"query\": str, \"count\": int (opt, default 250), \"mailbox\": str (opt), \"folder\": str (opt),\n"
        "                           \"today_only\": bool (opt), \"since\": str (opt), \"until\": str (opt)}\n"
        "                         Search emails in Outlook by keyword with mailbox/folder/date filtering.\n"
        "                         ALWAYS use this (not outlook_get_inbox) when the user asks about a topic,\n"
        "                         subject, sender, or keyword. Pass the topic keyword(s) as query.\n"
        "  outlook_get_email    \u2013 {\"message_id\": str}\n"
        "                         Fetch the FULL content of a single Outlook email by Message ID.\n"
        "                         Use this when the user wants to read an entire email. MANDATORY: call\n"
        "                         Set include_attachments:true when the user asks what is in a bijlage/attachment or attached file.\n"
        "                         outlook_get_inbox or outlook_search_emails FIRST to obtain the Message ID.\n"
        "  outlook_get_tasks    \u2013 {\"count\": int (opt, default 20), \"include_completed\": bool (opt, default false)}\n"
        "                         Fetch open tasks / to-do items from Outlook (Microsoft To-Do).\n"
        "                         Use this when the user asks about tasks, to-do items, open action items,\n"
        "                         or wants to create follow-up reminders based on open tasks.\n"
        "                         Do NOT use email search tools to look up tasks — use this tool instead.\n"
        "\nOutlook notes:\n"
        "  - For 'today': pass today_only: true to outlook_get_inbox or outlook_search_emails.\n"
        "  - For 'this week': use since: 'YYYY-MM-DD' with Monday's date.\n"
        "  - If outlook tools return no results, use run_python with the win32com template above.\n"
        "  - For draft updates: search Outlook with folder: 'Drafts' first, then pass the returned Message ID to outlook_update_email.\n"
        "  - For deletes: always use the Message ID returned by a prior Outlook mail lookup.\n"
        "  - For calendar updates/deletes: always read the calendar first so you have the real Event ID.\n"
        "\n⚠ EMAIL SAFETY RULES (Outlook):\n"
        "  - NEVER send an email without a valid recipient address.\n"
        "  - If the user says a name (not an address), first search the inbox to find the real email address.\n"
        "  - Do NOT guess or fabricate email addresses.\n"
    ) if use_outlook else ""

    _gmail_tools_desc = (
        "\n  gmail_get_inbox      – {\"count\": int (opt, default 20), \"unread_only\": bool (opt), \"folder\": str (opt),\n"
        "                          \"today_only\": bool (opt), \"since\": str (opt, YYYY-MM-DD), \"until\": str (opt, YYYY-MM-DD)}\n"
        "                         List recent emails from Gmail (NO keyword filtering). Use ONLY for listing\n"
        "                         inbox or folder contents. For topic/keyword searches, use gmail_search_emails.\n"
        "  gmail_send_email     – {\"to\": str, \"subject\": str, \"body\": str, \"cc\": str (opt)}\n"
        "                         Send an email via Gmail.\n"
        "  gmail_update_email   – {\"message_id\": str, \"to\": str (opt), \"subject\": str (opt), \"body\": str (opt), \"cc\": str (opt)}\n"
        "                         Update a Gmail draft email by Message ID.\n"
        "                         MANDATORY: call gmail_get_inbox or gmail_search_emails FIRST with folder: 'Drafts' to obtain the exact Message ID.\n"
        "  gmail_delete_email   – {\"message_id\": str}\n"
        "                         Delete a Gmail email by Message ID.\n"
        "                         MANDATORY: call gmail_get_inbox or gmail_search_emails FIRST to obtain the exact Message ID. NEVER guess a Message ID.\n"
        "  gmail_search_emails  – {\"query\": str, \"count\": int (opt, default 20), \"folder\": str (opt)}\n"
        "                         Search emails in Gmail. Uses Gmail search syntax (from:, subject:, is:unread,\n"
        "                         after:YYYY/MM/DD, before:YYYY/MM/DD, newer_than:2d, etc.).\n"
        "  gmail_get_email      – {\"message_id\": str}\n"
        "                         Fetch the FULL content of a single Gmail email by Message ID.\n"
        "                         Use this when the user wants to read an entire email. MANDATORY: call\n"
        "                         Set include_attachments:true when the user asks what is in a bijlage/attachment or attached file.\n"
        "                         gmail_get_inbox or gmail_search_emails FIRST to obtain the Message ID.\n"
        "  google_read_calendar – {\"days_ahead\": int (opt, default 7), \"since\": str (opt, YYYY-MM-DD), \"until\": str (opt, YYYY-MM-DD)}\n"
        "                         Read upcoming calendar events from Google Calendar. Supports relative horizon and explicit date ranges. Returns Event IDs for update/delete.\n"
        "  google_create_calendar_event – {\"summary\": str, \"start\": str, \"end\": str,\n"
        "                                  \"description\": str (opt), \"location\": str (opt), \"attendees\": str (opt, comma-separated emails)}\n"
        "                         Create a new Google Calendar event. start/end are ISO-8601 LOCAL datetime strings\n"
        "                         (e.g. '2025-03-15T10:00:00') — interpreted as the user's local timezone.\n"
        "                         Use date-only for all-day events ('2025-03-15').\n"
        "                         If no end time given, default to 1 hour after start.\n"
        "  google_update_calendar_event – {\"event_id\": str, \"summary\": str (opt), \"start\": str (opt),\n"
        "                                  \"end\": str (opt), \"description\": str (opt), \"location\": str (opt)}\n"
        "                         Update an existing Google Calendar event. Times are local.\n"
        "                         MANDATORY: call google_read_calendar FIRST to obtain the exact Event ID. NEVER guess an Event ID.\n"
        "                         Only change the fields the user asked to change — keep everything else unchanged.\n"
        "  google_delete_calendar_event – {\"event_id\": str}\n"
        "                         Delete a Google Calendar event by Event ID.\n"
        "                         MANDATORY: call google_read_calendar FIRST to obtain the exact Event ID. NEVER guess an Event ID.\n"
        "\n⚠ CALENDAR SAFETY RULES:\n"
        "  - ALWAYS call google_read_calendar BEFORE calling update or delete. The Event ID from read_calendar is the ONLY valid source.\n"
        "  - For updates: only change the fields the user asked to change. Preserve all other fields.\n"
        "  - Use ISO-8601 LOCAL datetime format: '2026-03-15T10:00:00'. NEVER include a timezone offset.\n"
        "  - All-day events use date-only format: '2026-03-15'.\n"
        "  - For explicit periods, pass since/until as 'YYYY-MM-DD'. Use both for a single calendar day.\n"
        "\nGmail notes:\n"
        "  - For 'today's emails': pass today_only: true to gmail_get_inbox.\n"
        "  - For sent mail: pass folder: 'Sent' to gmail_get_inbox or gmail_search_emails.\n"
        "  - For draft updates: search Gmail with folder: 'Drafts' first, then pass the returned Message ID to gmail_update_email.\n"
        "  - For deletes: always use the Message ID returned by a prior Gmail mail lookup.\n"
        "  - For a date range: use since: 'YYYY-MM-DD' and/or until: 'YYYY-MM-DD' on gmail_get_inbox.\n"
        "  - For complex queries (from:, subject:, has:attachment, etc.), use gmail_search_emails with Gmail search syntax.\n"
        "\n⚠ EMAIL SAFETY RULES (Gmail):\n"
        "  - NEVER send an email without a valid recipient address.\n"
        "  - If the user says a name (not an address), search the inbox first to find the real email address.\n"
        "  - Do NOT guess or fabricate email addresses.\n"
    ) if gmail_available else ""

    _whatsapp_tools_desc = (
        "\n  whatsapp_send_message – {\"recipient\": str, \"text\": str}\n"
        "                         Send a WhatsApp message. 'recipient' is the phone number\n"
        "                         including country code (e.g. '31612345678', no + prefix).\n"
        "\n⚠ WHATSAPP SAFETY RULES:\n"
        "  - NEVER guess phone numbers. Ask the user for the number if not provided.\n"
        "  - Use the phone number WITHOUT a '+' prefix (e.g. '31612345678').\n"
    ) if whatsapp_available else ""

    _slack_tools_desc = (
        "\n  slack_post_message   – {\"channel\": str, \"text\": str, \"thread_ts\": str (opt)}\n"
        "                         Post a message to a Slack channel using the connected Slack bot.\n"
    ) if bool((settings.get("slack_bot_token") or "").strip()) else ""

    _google_drive_tools_desc = (
        "\n  google_drive_list    – {\"folder_id\": str (opt, default 'root'), \"count\": int (opt, default 30)}\n"
        "                         List files in a Google Drive folder.\n"
        "  google_drive_search  – {\"query\": str, \"count\": int (opt, default 20)}\n"
        "                         Search files in Google Drive by name/content.\n"
        "  google_drive_read    – {\"file_id\": str}\n"
        "                         Read/download a file from Google Drive by its ID.\n"
        "  google_drive_upload  – {\"name\": str, \"content\": str, \"folder_id\": str (opt, default 'root'),\n"
        "                          \"mime_type\": str (opt, default 'text/plain')}\n"
        "                         Upload a file to Google Drive.\n"
    ) if google_drive_available else ""

    _onedrive_tools_desc = (
        "\n  onedrive_list        – {\"folder_path\": str (opt), \"count\": int (opt, default 30)}\n"
        "                         List files in a OneDrive folder.\n"
        "  onedrive_search      – {\"query\": str, \"count\": int (opt, default 20)}\n"
        "                         Search files in OneDrive by name.\n"
        "  onedrive_read        – {\"file_id\": str}\n"
        "                         Read/download a file from OneDrive.\n"
        "  onedrive_upload      – {\"name\": str, \"content\": str, \"folder_path\": str (opt)}\n"
        "                         Upload a file to OneDrive.\n"
    ) if onedrive_available else ""

    _browser_tools_desc = (
        "\n  browser_navigate     – {\"url\": str, \"wait_until\": str (opt, default 'domcontentloaded')}\n"
        "                         Navigate the browser to a URL. Returns page title.\n"
        "  browser_click        – {\"selector\": str}\n"
        "                         Click an element matching the CSS selector.\n"
        "  browser_type         – {\"selector\": str, \"text\": str, \"press_enter\": bool (opt)}\n"
        "                         Type text into an input element.\n"
        "  browser_screenshot   – {}\n"
        "                         Take a screenshot of the current page (returned as base64).\n"
        "  browser_get_text     – {\"selector\": str (opt, default 'body'), \"max_chars\": int (opt, default 12000)}\n"
        "                         Extract text from the current page or a specific element.\n"
        "  browser_eval         – {\"js_code\": str}\n"
        "                         Execute JavaScript in the browser and return the result.\n"
    ) if browser_available else ""

    _custom_tools_desc = ""
    if _dynamic_tools:
        lines = []
        for _tname, _t in _dynamic_tools.items():
            _adesc = _t["args_desc"]
            _tdesc = _t["description"]
            lines.append(f"  {_tname:<20} – {_adesc}\n                         {_tdesc}\n")
        _custom_tools_desc = "".join(lines)

    _mcp_tools_desc = ""
    _mcp_cache = _mcp_get_cache()
    _builtin_names = {
        "web_search", "fetch_url", "run_python", "run_shell", "http_request",
        "fetch_webhook",
        "write_file", "read_file", "list_files", "read_workspace_file",
        "search_workspace", "create_skill", "create_tool", "update_skill", "update_tool", "schedule_goal",
        "recall_memory",
        "outlook_get_inbox", "outlook_read_calendar", "outlook_send_email",
        "outlook_update_email", "outlook_delete_email",
        "outlook_create_calendar_event", "outlook_update_calendar_event", "outlook_delete_calendar_event",
        "gmail_get_inbox", "gmail_send_email", "gmail_update_email", "gmail_delete_email",
        "slack_post_message",
        "whatsapp_send_message",
        "browser_navigate", "browser_click", "browser_type", "browser_eval",
        "browser_screenshot", "browser_select", "browser_back",
    }
    if _mcp_cache:
        mcp_lines: list[str] = []
        for _srv_name, _srv_entry in _mcp_cache.items():
            if not _srv_entry.get("ok"):
                continue
            srv_tools = _srv_entry.get("tools", [])
            if not srv_tools:
                continue
            unique_tools = [t for t in srv_tools if t.get("name", "?") not in _builtin_names]
            if not unique_tools:
                continue
            mcp_lines.append(f"\n  Server '{_srv_name}' provides:\n")
            for _mt in unique_tools:
                _mtn = _mt.get("name", "?")
                _mtd = _mt.get("description", "")
                desc_short = (_mtd[:100] + "...") if len(_mtd) > 103 else _mtd
                mcp_lines.append(f"    - {_mtn}: {desc_short}\n")
        if mcp_lines:
            _mcp_tools_desc = (
                "\nMCP skills (external tool servers — call via mcp_call):\n"
                "  mcp_call             – {\"server\": str, \"tool\": str, \"arguments\": obj (opt)}\n"
                "                         Call a tool on a connected MCP server.\n"
                + "".join(mcp_lines)
            )

    # ── Webhook data sources ─────────────────────────────────────────
    _webhook_tools_desc = ""
    _webhooks = settings.get("webhooks") or []
    if _webhooks:
        wh_lines = ["\nConfigured webhooks (data sources the user set up — call via fetch_webhook):\n"]
        for _wh in _webhooks:
            _wname = _wh.get("name", "?")
            _wdesc = _wh.get("description", "")
            wh_lines.append(f"  • {_wname}" + (f" — {_wdesc}" if _wdesc else "") + "\n")
        _webhook_tools_desc = "".join(wh_lines)

    return (
        "\nYou have access to the following tools. To call one, output exactly "
        "(on its own line, nothing else on that line):\n"
        "TOOL_CALL: {\"tool\": \"name\", \"args\": {\"key\": \"value\"}}\n"
        "Wait for the TOOL_RESULT before continuing. Do NOT invent results.\n"
        "CRITICAL: Use ONLY the TOOL_CALL: JSON format above. NEVER use XML, HTML, "
        "<function_calls>, <invoke>, or any other format for tool calls.\n\n"
        "Built-in skills:\n"
        "  web_search           – {\"query\": str}\n"
        "                         Search the web (Brave Search when configured; fallback built-in search).\n"
        "  fetch_url            – {\"url\": str}\n"
        "                         Download and extract text from a webpage.\n"
        "  extract_source       – {\"source\": str, \"max_chars\": int (opt, default 24000)}\n"
        "                         Extract text or transcripts from a URL or local file path. Supports webpages, PDFs, YouTube pages with public captions, podcast feeds with published transcripts, and local transcript sidecars for audio/video.\n"
        "  run_python           – {\"code\": str}\n"
        "                         Execute Python code; returns stdout/stderr.\n"
        "                         win32com.client (pywin32) is available — use it for custom Outlook/COM queries.\n"
        "                         Template for custom Outlook query:\n"
        "                           import pythoncom, win32com.client, datetime\n"
        "                           pythoncom.CoInitialize()\n"
        "                           ns = win32com.client.Dispatch('Outlook.Application').GetNamespace('MAPI')\n"
        "                           inbox = ns.GetDefaultFolder(6)  # 6=Inbox\n"
        "                           items = inbox.Items\n"
        "                           items.Sort('[ReceivedTime]', True)\n"
        "                           # add Restrict() filter here if needed\n"
        "                           for i in range(1, min(items.Count+1, 11)):\n"
        "                               m = items.Item(i); print(m.Subject, m.ReceivedTime)\n"
        "  run_shell            – {\"command\": str, \"timeout\": int (opt, default 30)}\n"
        "                         Run a shell command (git, npm, pip, curl, etc.).\n"
        "  http_request         – {\"method\": str, \"url\": str, \"headers\": obj (opt),\n"
        "                          \"body\": str (opt), \"timeout\": int (opt)}\n"
        "                         Make an HTTP request to any API.\n"
        "  fetch_webhook        – {\"webhook_name\": str, \"params\": obj (opt)}\n"
        "                         Fetch data from a pre-configured webhook by name.\n"
        "                         Webhooks are data endpoints configured by the user in Settings.\n"
        "  write_file           – {\"filename\": str, \"content\": str}\n"
        "                         Save a file to output/.\n"
        "  read_file            – {\"filename\": str}\n"
        "                         Read a file from output/.\n"
        "  list_files           – {\"count\": int (opt, default 250)}\n"
        "                         List files currently in output/ (up to count, default 250).\n"
        "  read_workspace_file  – {\"path\": str}\n"
        "                         Read any file in the project workspace by relative path.\n"
        "  search_workspace     – {\"pattern\": str, \"glob\": str (opt), \"max_results\": int (opt, default 250)}\n"
        "                         Regex search through files in the workspace.\n"
        "  recall_memory        – {\"session_id\": str (opt), \"query\": str (opt)}\n"
        "                         Recall full details of a past session from deep memory.\n"
        "                         Use when the summary shown in past sessions is not enough.\n"
        "                         Pass session_id (from the past sessions list) or a query to search.\n"
        "  create_skill/create_tool – {\"name\": str, \"description\": str, \"args_schema\": str, \"code\": str, \"overwrite\": bool (opt)}\n"
        "                         Write and permanently register a new reusable skill.\n"
        "                         'code' MUST contain: def run(args: dict) -> str\n"
        "                         Saved to tools/{name}.py — available immediately AND in all future sessions.\n"
        "                         Code is syntax-checked and probe-run before saving.\n"
        "                         Pass overwrite:true to update an existing skill (auto-backs up .py.bak).\n"
        "                         Use this when no existing skill is sufficient.\n"
        "  update_skill/update_tool – {\"name\": str, \"code\": str, \"description\": str (opt), \"args_schema\": str (opt), \"approved\": bool (opt)}\n"
        "                         Update an existing reusable skill in tools/{name}.py.\n"
        "                         Keeps existing description/args_schema when omitted.\n"
        "  schedule_goal         – {\"goal\": str, \"delay_minutes\": int (opt, default 5)}\n"
        "                         Schedule a goal to be executed as a future agent run.\n"
        "                         The goal will run automatically after the specified delay.\n"
        "                         Use delay_minutes to space out multiple scheduled goals.\n"
        + _outlook_tools_desc
        + _gmail_tools_desc
        + _slack_tools_desc
        + _whatsapp_tools_desc
        + _google_drive_tools_desc
        + _onedrive_tools_desc
        + _browser_tools_desc
        + ("\nCustom skills (created by agent, reusable across sessions):\n" + _custom_tools_desc if _custom_tools_desc else "")
        + _mcp_tools_desc
        + _webhook_tools_desc
        + "\nRULES:\n"
        "- When a task requires DOING something (not just explaining), USE A SKILL.\n"
        "- Prefer existing skills. Use create_skill/create_tool only when no existing skill fits.\n"
        "- Use update_skill/update_tool to modify an existing custom skill.\n"
        "- If a skill returns TOOL_DISABLED, DO NOT create a copy with create_skill/create_tool. Tell the user to enable it in Settings > Skills.\n"
        "- NEVER create skills for messaging channels (WhatsApp, Telegram, Slack, email). These require built-in integrations.\n"
        "  If a messaging tool is not listed above, tell the user to enable/connect it in Settings.\n"
        "- NEVER invent or hallucinate policies, restrictions, or limitations. Only reference capabilities listed in this tool description.\n"
        "- After create_skill/create_tool or update_skill/update_tool, call the skill immediately in the same run.\n"
        "- Do not describe what code would do — run it.\n"
        "- NEVER output raw code, commands, scripts, or code blocks (```bash, ```curl, ```python, etc.) as your answer.\n"
        "  If you need to run a command, use the run_shell or run_python tool via TOOL_CALL.\n"
        "  If you need to fetch a URL, use the fetch_url or http_request tool via TOOL_CALL.\n"
        "- Your final answer to the user must ALWAYS be in plain, human-readable language.\n"
        "  Summarize results, provide facts, answer the question — never show raw API calls, curl commands, or code.\n"
        "\nSKILL OUTPUT FORMAT:\n"
        "- Skills return JSON: {\"status\": \"ok\"|\"error\", \"data\": <structured result>, \"message\": <human summary>}.\n"
        "- When status is \"ok\", use the \"data\" field for facts/numbers and \"message\" for the user-facing summary.\n"
        "- When status is \"error\", relay the \"message\" to the user as-is.\n"
        "- NEVER dump raw JSON to the user. Always rephrase the result in natural language.\n"
    )


# ── Direct chat respond helper (for action=start routed to chat) ─────────────

async def _chat_respond_direct(
    session: dict, message: str, model: str, context: str,
    settings: dict, run_id: str, websocket,
) -> None:
    """Handle a brand-new 'start' request that the LLM router classified as 'respond'.

    Instead of spinning up the full orchestrator, generate a direct chat reply
    (optionally using tools and web search), same as the chat follow-up path.
    """
    session_id = session["id"]
    try:
        quick_email_reply = _maybe_answer_email_followup(session, message)
        if quick_email_reply:
            _record_session_exchange(session, message, quick_email_reply)
            session["updated_at"] = datetime.now().isoformat()
            _save_session(session)
            await manager.send_message({
                "type": "chat_reply", "content": quick_email_reply,
                "session_id": session_id, "model": "session-email-context",
                "run_id": run_id, "goal": message,
            }, websocket)
            return

        await manager.send_message({
            "type": "status", "content": "Generating answer\u2026", "run_id": run_id,
        }, websocket)

        _chat_outlook = bool(session.get("use_outlook", False) or _settings_outlook_enabled(settings)) and OUTLOOK_SUPPORT
        _chat_google = bool(settings.get("google_enabled", False)) and GOOGLE_API_SUPPORT and (_google_get_credentials() is not None)
        _chat_tools_desc = _build_tools_desc(
            use_outlook=_chat_outlook, gmail_available=_chat_google,
            whatsapp_available=_wa_is_connected(),
            google_drive_available=_chat_google,
            onedrive_available=_is_onedrive_available(),
            browser_available=PLAYWRIGHT_SUPPORT,
        )
        _chat_disabled = {
            _normalize_tool_name(str(t).strip())
            for t, enabled in (settings.get("tool_enabled_map") or {}).items()
            if str(t).strip() and not bool(enabled)
        }
        _chat_blocked = {
            _normalize_tool_name(str(t).strip())
            for t in (settings.get("policy_blocked_tools") or [])
            if str(t).strip()
        }

        # NOTE: _generate_session_followup handles adding user+assistant to chat_history
        # Do NOT append here — it would cause duplicates.

        # Generate the LLM reply
        preferred_model = model or settings.get("main_model", "")
        past_memory_text = ""
        if _should_load_long_term_memory_for_chat(message):
            loop = asyncio.get_event_loop()
            past_memory_text = await loop.run_in_executor(None, _load_chat_long_term_memory, message, 3)
        _tool_used = False
        _email_lookup_hit = False
        _last_tool_result = ""
        reply, used_model, raw_reply = await asyncio.wait_for(
            _generate_session_followup(
                session, message, preferred_model,
                tools_desc=_chat_tools_desc,
                extra_context=context,
                past_memory_text=past_memory_text,
            ),
            timeout=_CHAT_FOLLOWUP_LLM_TIMEOUT_SECONDS,
        )

        # ── Emit thinking events if show_thinking is enabled ──
        if settings.get("show_thinking", False):
            _think_blocks = re.findall(r'<think>([\s\S]*?)</think>', raw_reply or "")
            if _think_blocks:
                await manager.send_message({"type": "stream_thinking_start", "run_id": run_id}, websocket)
                for _tb in _think_blocks:
                    await manager.send_message({"type": "stream_thinking", "content": _tb, "run_id": run_id}, websocket)
                await manager.send_message({"type": "stream_thinking_end", "run_id": run_id}, websocket)
                await asyncio.sleep(0)  # yield so frontend can render before chat_reply
            else:
                print(f"[thinking] show_thinking is on but model '{used_model}' returned no thinking tokens")
                await manager.send_message({
                    "type": "info",
                    "content": f"Show Thinking is enabled, but the current model ({used_model}) does not support thinking. "
                               f"Models with thinking support include: DeepSeek R1, QwQ, Qwen3.",
                }, websocket)

        # ── Tool execution loop (up to 3 turns) ──
        for _ in range(3):
            tc, cleaned, has_marker = _extract_tool_call(reply)
            if not has_marker or not isinstance(tc, dict):
                if has_marker:
                    reply = cleaned
                break

            t_name = _normalize_tool_name(tc.get("tool", ""))
            t_args = tc.get("args", {})
            source_tool_name = t_name
            source_tool_result = ""

            if t_name in _chat_disabled or t_name in _chat_blocked:
                tool_result = f"TOOL_DISABLED: Skill '{t_name}' is not available."
                source_tool_result = str(tool_result)
            else:
                _tool_used = True
                await manager.send_message({
                    "type": "tool_call", "tool": t_name, "args": t_args, "run_id": run_id,
                }, websocket)
                loop = asyncio.get_event_loop()
                tool_result = await loop.run_in_executor(None, _execute_tool, t_name, t_args)
                source_tool_result = str(tool_result or "")
                _append_workflow_context_artifact(
                    session,
                    tool_name=t_name,
                    tool_args=t_args,
                    tool_result=source_tool_result,
                    tool_details=build_tool_result_details(t_name, source_tool_result),
                    stage="chat",
                )
                await manager.send_message({
                    "type": "tool_result", "tool": t_name,
                    "result": _tool_result_ui_snippet(t_name, tool_result), "run_id": run_id,
                }, websocket)

                _chat_domain = ""
                if t_name in _EMAIL_LOOKUP_TOOL_NAMES:
                    _chat_domain = "email"
                elif t_name in {"outlook_read_calendar", "google_read_calendar"}:
                    _chat_domain = "calendar"

                if _chat_domain and _goal_requests_personal_summary(message, _chat_domain):
                    _summary_records: list[dict] = []
                    if _chat_domain == "email":
                        _summary_records = extract_email_lookup_records(source_tool_result)
                    elif _chat_domain == "calendar":
                        _summary_records = extract_calendar_lookup_records(source_tool_result)

                    if _summary_records:
                        _summary_args = {
                            "source_type": _chat_domain,
                            "items": _summary_records,
                            "max_items": 6,
                            "style": "brief",
                        }
                        await manager.send_message({
                            "type": "tool_call", "tool": "summarize_personal_items", "args": _summary_args, "run_id": run_id,
                        }, websocket)
                        _summary_result = await loop.run_in_executor(None, _execute_tool, "summarize_personal_items", _summary_args)
                        _append_workflow_context_artifact(
                            session,
                            tool_name="summarize_personal_items",
                            tool_args=_summary_args,
                            tool_result=str(_summary_result),
                            tool_details=build_tool_result_details("summarize_personal_items", str(_summary_result)),
                            stage="chat",
                        )
                        await manager.send_message({
                            "type": "tool_result", "tool": "summarize_personal_items",
                            "result": _tool_result_ui_snippet("summarize_personal_items", str(_summary_result)), "run_id": run_id,
                        }, websocket)
                        tool_result = str(_summary_result)
                        t_name = "summarize_personal_items"
                        t_args = _summary_args

                if source_tool_name in _EMAIL_LOOKUP_TOOL_NAMES:
                    _email_lookup_hit = True
                    _email_records = extract_email_lookup_records(source_tool_result)
                    if source_tool_name.startswith("gmail"):
                        for _record in _email_records:
                            if isinstance(_record, dict):
                                _record.setdefault("provider", "gmail")
                    elif source_tool_name.startswith("outlook"):
                        for _record in _email_records:
                            if isinstance(_record, dict):
                                _record.setdefault("provider", "outlook")
                    _merge_session_email_records(session, _email_records)

            _last_tool_result = str(tool_result or "")

            followup_prompt = (
                f"Skill '{t_name}' returned:\n{tool_result[:30000]}\n\n"
                "The result is JSON with 'status' (ok/error), 'data' (structured result), and 'message' (summary).\n"
                "Use 'data' for facts and 'message' for a natural-language answer. Never dump raw JSON to the user.\n\n"
                f"Now answer the user's question: {message}\n"
            )
            _reply_candidates = _chat_followup_model_candidates(
                settings, session, preferred_model=preferred_model, used_model=used_model,
            )
            reply, used_model = await asyncio.wait_for(
                _call_text_model_candidates(
                    followup_prompt, _reply_candidates,
                    system=_compose_agent_system() + _chat_tools_desc,
                    num_ctx=_model_ctx(16384), thinking=False,
                    per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
                ),
                timeout=_CHAT_FOLLOWUP_LLM_TIMEOUT_SECONDS,
            )
            reply = reply.strip()
            if _is_llm_error(reply):
                raise RuntimeError(reply)
            reply = re.sub(r'<think>[\s\S]*?</think>', '', reply).strip()
            hist = session.get("chat_history", [])
            if hist and hist[-1].get("role") == "assistant":
                hist[-1]["content"] = reply
            session["chat_history"] = hist
            session["updated_at"] = datetime.now().isoformat()
            _save_session(session)

        # ── Weak-reply web-search fallback ──
        is_weak = bool(_WEAK_REPLY_RE.search(reply))
        if is_weak:
            try:
                await manager.send_message({
                    "type": "status", "content": "Searching the web\u2026", "run_id": run_id,
                }, websocket)
                loop = asyncio.get_event_loop()
                search_results = await loop.run_in_executor(None, _web_search, message, 6)
                if search_results and "No search results found" not in search_results:
                    search_candidates = _chat_followup_model_candidates(
                        settings, session, preferred_model=preferred_model, used_model=used_model,
                    )
                    if search_candidates:
                        search_prompt = (
                            "Answer the user's question using the web search results below.\n"
                            "Give a direct, concise answer in a few sentences MAX. No lengthy reasoning.\n"
                            "Get straight to the point. Do NOT say you cannot help.\n"
                            "If the search results don't contain the answer, say so briefly.\n\n"
                            f"[WEB SEARCH RESULTS]\n{search_results[:4000]}\n\n"
                            f"[USER QUESTION]\n{message}\n"
                        )
                        search_reply, used_model = await asyncio.wait_for(
                            _call_text_model_candidates(
                                search_prompt, search_candidates,
                                system=_compose_agent_system(),
                                num_ctx=8192, thinking=False,
                                per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
                            ),
                            timeout=_CHAT_FOLLOWUP_LLM_TIMEOUT_SECONDS,
                        )
                        search_reply = search_reply.strip()
                        if not _is_llm_error(search_reply):
                            search_reply = re.sub(r'<think>[\s\S]*?</think>', '', search_reply).strip()
                            if search_reply and not _WEAK_REPLY_RE.search(search_reply):
                                reply = search_reply
            except Exception as search_err:
                print(f"[chat-direct] web search fallback failed: {search_err}")

        # Update the existing assistant reply saved by _generate_session_followup
        # (covers tool-loop and web-search-fallback changes to reply)
        reply = _strip_tool_call_artifacts(reply)
        if _tool_used:
            reply = _normalize_tool_json_reply(reply, _last_tool_result)
        if _email_lookup_hit:
            reply = _append_email_followup_offer(reply, session, dutch_hint=_looks_dutch_text(message))
        hist = session.get("chat_history", [])
        if hist and hist[-1].get("role") == "assistant":
            hist[-1]["content"] = reply
            session["chat_history"] = hist
            session["updated_at"] = datetime.now().isoformat()
            _save_session(session)

        await manager.send_message({
            "type": "chat_reply", "content": reply,
            "session_id": session_id, "model": used_model,
            "run_id": run_id, "goal": message,
        }, websocket)

        try:
            _update_user_profile_from_text(message)
        except Exception:
            pass

    except asyncio.TimeoutError:
        await manager.send_message({
            "type": "error", "content": "Chat response timed out.", "run_id": run_id,
        }, websocket)
    except Exception as e:
        await manager.send_message({
            "type": "error", "content": f"Chat response failed: {e}", "run_id": run_id,
        }, websocket)


# ── WebSocket endpoint ───────────────────────────────────────────────────────

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, token: str = Query("")):
    if not hmac.compare_digest(token, API_AUTH_TOKEN):
        await websocket.close(code=4401)
        return
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_json()
            action = data.get("action")

            if action == "start":
                active_count = manager.active_run_count()
                if active_count >= MAX_CONCURRENT_RUNS:
                    await manager.send_message({"type": "error", "content": f"Maximum {MAX_CONCURRENT_RUNS} concurrent runs reached. Stop one first."}, websocket)
                    continue
                run_id = str(uuid.uuid4())[:8]
                manager.active_runs[run_id] = True
                goal = data.get("goal")
                model = data.get("model")
                context = data.get("context", "")
                use_internet = data.get("use_internet", False)
                _run_settings = _load_settings()
                _requested_outlook = bool(data.get("use_outlook", _settings_outlook_enabled(_run_settings)))
                session = {
                    "id": str(uuid.uuid4()),
                    "goal": goal,
                    "model": model,
                    "use_outlook": _requested_outlook,
                    "use_internet": bool(use_internet),
                    "created_at": datetime.now().isoformat(),
                    "completed": False,
                    "steps": [],
                }
                if context:
                    session["context"] = context
                _save_session(session)
                await manager.send_message({"type": "session_id", "session_id": session["id"], "run_id": run_id}, websocket)

                # ── LLM Intent Router (same as chat path) ────────────────
                _start_google = bool(_run_settings.get("google_enabled", False)) and GOOGLE_API_SUPPORT and (_google_get_credentials() is not None)
                _start_route = await _route_with_llm(
                    goal, session, _run_settings,
                    use_outlook=_requested_outlook, gmail_available=_start_google,
                )
                _start_approach = str(_start_route.get("approach", "unknown")).strip().lower()
                _start_skills = _start_route.get("skills") or []
                _start_routed_goal = str(_start_route.get("goal", goal)).strip() or goal
                # Always execute the original user request. Router rephrasing can
                # drop critical constraints (e.g., exact dates like "1 april").
                _start_goal = goal
                _start_reasoning = str(_start_route.get("reasoning", "")).strip()
                print(
                    f"[intent-router:start] approach={_start_approach!r} skills={_start_skills} "
                    f"goal={_start_goal[:80]!r} routed_goal={_start_routed_goal[:80]!r} "
                    f"reason={_start_reasoning[:60]!r}"
                )

                # ── Override: complex multi-step goals → orchestrate ──
                # The chat path only does up to 3 quick tool rounds with no
                # planning.  Goals that require multiple searches, comparison,
                # and synthesis need the full orchestrator loop.
                _has_prior_data = bool((session.get("chat_history") or []) and
                                      (_session_email_records(session) or (session.get("final_report") or "").strip()))
                if _start_approach == "respond" and not _has_prior_data:
                    _gl = (goal or "").lower()
                    _multi_step_signals = 0
                    # Explicit sequential instructions ("zoek …, vergelijk …, geef …")
                    _action_verbs = re.findall(
                        r"\b(?:zoek|search|vind|find|vergelijk|compare|"
                        r"analyseer|analyze|geef|give|maak|create|"
                        r"controleer|check|kijk|look|lees|read)\b", _gl)
                    if len(_action_verbs) >= 3:
                        _multi_step_signals += 2
                    elif len(_action_verbs) >= 2:
                        _multi_step_signals += 1
                    # Comparison + recommendation language
                    if re.search(r"\b(vergelijk|compare|vs\.?|versus|benchmark)\b", _gl):
                        _multi_step_signals += 1
                    if re.search(r"\b(aanbev|recommend|beste|best|welke.*beter|which.*better)\b", _gl):
                        _multi_step_signals += 1
                    # "top N" or enumeration requests
                    if re.search(r"\b(top\s*\d|meest\s+recente?|latest|nieuwste)\b", _gl):
                        _multi_step_signals += 1
                    # Connectors suggesting multi-step ("en dan", "daarna", "vervolgens")
                    if re.search(r"\b(en\s+(?:dan|geef|vergelijk)|daarna|vervolgens|then|afterwards)\b", _gl):
                        _multi_step_signals += 1
                    if _multi_step_signals >= 3:
                        _start_approach = "orchestrate"
                        print(f"[intent-router:start] OVERRIDE respond→orchestrate "
                              f"(multi-step signals={_multi_step_signals}) goal={goal[:80]!r}")

                if _start_approach == "respond":
                    # Route to chat path instead of orchestrator
                    session["chat_history"] = session.get("chat_history") or []
                    session["goal"] = f"Chat: {goal.strip().replace(chr(10), ' ')[:120]}"
                    _save_session(session)
                    manager.active_runs.pop(run_id, None)
                    # Re-emit as chat action so the chat handler processes it
                    await manager.send_message({
                        "type": "route_to_chat",
                        "session_id": session["id"],
                        "run_id": run_id,
                    }, websocket)
                    asyncio.create_task(_chat_respond_direct(
                        session, goal, model, context, _run_settings,
                        run_id, websocket,
                    ))
                else:
                    # Orchestrate: send run_start now; the orchestrator's own
                    # LLM classifiers handle parameter extraction.
                    await manager.send_message({"type": "run_start", "run_id": run_id, "goal": goal}, websocket)
                    _initial_task_state = None
                    if _start_approach == "orchestrate":
                        _skill_str = " ".join(str(s) for s in _start_skills)
                        if "calendar" in _skill_str:
                            _initial_task_state = {
                                "task_type": "calendar_lookup", "operation": "calendar_read",
                                "requires_live_data": True, "can_answer_from_context": False,
                                "followup_relation": "new_task",
                            }
                            _start_range = _extract_explicit_date_range(goal)
                            if _start_range:
                                _initial_task_state.update({
                                    "time_filter": "custom_range",
                                    "since": _start_range.get("since", ""),
                                    "until": _start_range.get("until", ""),
                                    "time_label": _start_range.get("time_label", ""),
                                    "days_ahead": 1,
                                })
                        elif "email" in _skill_str or "inbox" in _skill_str or "gmail" in _skill_str:
                            _initial_task_state = {
                                "task_type": "email_lookup", "operation": "find_received",
                                "requires_live_data": True, "can_answer_from_context": False,
                                "followup_relation": "new_task",
                            }
                    if _initial_task_state:
                        session["task_state"] = _initial_task_state
                    _save_session(session)
                    run_task = asyncio.create_task(run_orchestrator(
                        _start_goal, model, context, use_internet, session, websocket,
                        fast_model=data.get("fast_model", ""),
                        require_approval=bool(data.get("require_approval", False)),
                        speed_mode=bool(data.get("speed_mode", False)),
                        use_outlook=_requested_outlook,
                        run_id=run_id,
                    ))
                    manager.register_run_task(run_id, run_task, websocket)
            elif action == "chat":
                session_id = str(data.get("session_id") or "").strip()
                user_message = str(data.get("message") or "").strip()
                run_id = str(data.get("run_id") or "").strip()
                preferred_model = str(data.get("model") or "").strip()
                chat_context = str(data.get("context") or "").strip()
                exact_email_message_id = str(data.get("email_message_id") or "").strip()
                exact_email_provider = str(data.get("email_provider") or "").strip().lower()
                exact_email_include_attachments = False

                if not user_message:
                    await manager.send_message({
                        "type": "error", "content": "Chat message is empty.", "run_id": run_id,
                    }, websocket)
                    continue

                session = _load_session(session_id) if session_id else None
                if not session:
                    goal_seed = user_message.strip().replace("\n", " ")[:120]
                    session = {
                        "id": str(uuid.uuid4()),
                        "goal": f"Chat: {goal_seed}" if goal_seed else "Chat conversation",
                        "model": preferred_model,
                        "use_outlook": False,
                        "created_at": datetime.now().isoformat(),
                        "completed": False,
                        "steps": [],
                        "source": "chat",
                        "chat_history": [],
                    }
                    if chat_context:
                        session["context"] = chat_context
                    _save_session(session)
                if chat_context:
                    session["context"] = chat_context
                    _save_session(session)
                session_id = session["id"]
                await manager.send_message({"type": "session_id", "session_id": session_id, "run_id": run_id}, websocket)

                # ── /compact slash-command ────────────────────────────────
                if user_message.strip().lower() == "/compact":
                    _compact_settings = _load_settings()
                    _compact_models = _unique_model_candidates(
                        preferred_model,
                        session.get("model", ""),
                        _compact_settings.get("fast_model", ""),
                        _compact_settings.get("main_model", ""),
                        _compact_settings.get("fallback_model", ""),
                    )
                    _compact_result = await _compact_chat_history(session, _compact_models)
                    if _compact_result.get("compacted"):
                        await manager.send_message({
                            "type": "compact_result",
                            "session_id": session_id,
                            "before": _compact_result["before"],
                            "after": _compact_result["after"],
                            "summary_preview": _compact_result.get("summary_preview", ""),
                            "run_id": run_id,
                        }, websocket)
                    else:
                        await manager.send_message({
                            "type": "chat_reply",
                            "content": _compact_result.get("reason", "Could not compact session."),
                            "session_id": session_id,
                            "model": "system",
                            "run_id": run_id,
                            "goal": user_message,
                        }, websocket)
                    continue

                ensure_session_task_state(session, user_message)
                _msg_date_range = _extract_explicit_date_range(user_message)
                _msg_task_state = session.get("task_state") if isinstance(session.get("task_state"), dict) else None
                if _msg_date_range and _msg_task_state and str(_msg_task_state.get("task_type") or "") == "calendar_lookup":
                    _msg_task_state["time_filter"] = "custom_range"
                    _msg_task_state["since"] = str(_msg_date_range.get("since") or "")
                    _msg_task_state["until"] = str(_msg_date_range.get("until") or "")
                    _msg_task_state["time_label"] = str(_msg_date_range.get("time_label") or "")
                    _msg_task_state["days_ahead"] = 1
                _email_action_request = bool(_email_action_type(user_message))
                _email_followup_context = "" if _email_action_request else _build_selected_email_followup_context(session, user_message)
                _quick_email_reply = None if _email_action_request else _maybe_answer_email_followup(session, user_message)
                if _quick_email_reply:
                    _record_session_exchange(session, user_message, _quick_email_reply)
                    session["updated_at"] = datetime.now().isoformat()
                    _save_session(session)
                    await manager.send_message({
                        "type": "chat_reply", "content": _quick_email_reply,
                        "session_id": session_id, "model": "session-email-context",
                        "run_id": run_id, "goal": user_message,
                    }, websocket)
                    continue
                _chat_settings = _load_settings()
                _mentions_outlook = bool(re.search(r"\boutlook\b", user_message, re.IGNORECASE))
                _chat_outlook = bool(session.get("use_outlook", False) or _settings_outlook_enabled(_chat_settings) or _mentions_outlook) and OUTLOOK_SUPPORT
                _chat_google = bool(_chat_settings.get("google_enabled", False)) and GOOGLE_API_SUPPORT and (_google_get_credentials() is not None)
                _semantic_email_followup = {
                    "targets_selected_email": False,
                    "needs_full_email_read": False,
                    "needs_attachment_analysis": False,
                    "confidence": 0.0,
                }

                if not exact_email_message_id and not _email_action_request:
                    _selected_email_read_call = _build_email_followup_read_call(
                        session,
                        user_message,
                        use_outlook=_chat_outlook,
                        gmail_available=_chat_google,
                    )
                    if _selected_email_read_call:
                        _, _selected_email_args, _selected_email_record = _selected_email_read_call
                        exact_email_message_id = str(_selected_email_record.get("message_id") or "").strip()
                        exact_email_provider = str(_selected_email_record.get("provider") or "").strip()
                        exact_email_include_attachments = bool(_selected_email_args.get("include_attachments"))

                if not exact_email_message_id and not _email_action_request:
                    _semantic_candidates = _chat_followup_model_candidates(
                        _chat_settings,
                        session,
                        preferred_model=preferred_model or session.get("model", ""),
                    )
                    _semantic_email_followup = await _classify_selected_email_followup_decision(
                        session,
                        user_message,
                        _semantic_candidates,
                    )
                    if (
                        _semantic_email_followup.get("targets_selected_email")
                        and _semantic_email_followup.get("needs_full_email_read")
                        and float(_semantic_email_followup.get("confidence") or 0.0) >= 0.6
                    ):
                        _selected_email_record = _default_selected_email_record(session)
                        if _selected_email_record:
                            exact_email_message_id = str(_selected_email_record.get("message_id") or "").strip()
                            exact_email_provider = str(_selected_email_record.get("provider") or "").strip()
                            exact_email_include_attachments = bool(_semantic_email_followup.get("needs_attachment_analysis"))

                if exact_email_message_id:
                    session["_pending_exact_email_read"] = {
                        "message_id": exact_email_message_id,
                        "provider": exact_email_provider,
                        "include_attachments": exact_email_include_attachments,
                    }
                    _display_goal = user_message
                    _workflow_goal = user_message
                    _workflow_context = chat_context
                    _hist_text = _build_chat_history_context(session)
                    if _hist_text:
                        _workflow_context = f"Recent conversation:\n{_hist_text}\n\n{_workflow_context}".strip()
                    session["goal"] = user_message
                    session["display_goal"] = _display_goal
                    session["last_workflow_goal"] = _workflow_goal
                    session["last_routed_goal"] = user_message
                    session["_chat_turns_since_workflow"] = 0
                    session["model"] = preferred_model or session.get("model", "")
                    session["use_outlook"] = _chat_outlook
                    session["completed"] = False
                    _save_session(session)
                    manager.active_runs[run_id] = True
                    await manager.send_message({
                        "type": "chat_escalate",
                        "content": "Opening selected email...",
                        "run_id": run_id,
                        "goal": user_message,
                        "display_goal": _display_goal,
                    }, websocket)
                    run_task = asyncio.create_task(run_orchestrator(
                        _workflow_goal,
                        preferred_model or session.get("model", ""),
                        _workflow_context,
                        bool(_chat_settings.get("use_internet", True)),
                        session,
                        websocket,
                        fast_model=_chat_settings.get("fast_model", ""),
                        require_approval=bool(_chat_settings.get("require_approval", False)),
                        speed_mode=bool(_chat_settings.get("speed_mode", False)),
                        use_outlook=_chat_outlook,
                        run_id=run_id,
                    ))
                    manager.register_run_task(run_id, run_task, websocket)
                    continue

                if _should_force_live_chat_followup(session, user_message):
                    _forced_task = session.get("task_state") if isinstance(session.get("task_state"), dict) else None
                    _display_goal = task_state_display_goal(_forced_task) if _forced_task else user_message
                    _workflow_goal = user_message
                    _workflow_context = chat_context
                    _hist_text = _build_chat_history_context(session)
                    if _hist_text:
                        _workflow_context = f"Recent conversation:\n{_hist_text}\n\n{_workflow_context}".strip()
                    session["goal"] = user_message
                    session["display_goal"] = _display_goal
                    session["last_workflow_goal"] = _workflow_goal
                    session["last_routed_goal"] = user_message
                    session["_chat_turns_since_workflow"] = 0
                    session["model"] = preferred_model or session.get("model", "")
                    session["use_outlook"] = _chat_outlook
                    session["completed"] = False
                    _save_session(session)
                    manager.active_runs[run_id] = True
                    await manager.send_message({
                        "type": "chat_escalate",
                        "content": "Starting live data workflow...",
                        "run_id": run_id,
                        "goal": user_message,
                        "display_goal": _display_goal,
                    }, websocket)
                    run_task = asyncio.create_task(run_orchestrator(
                        _workflow_goal,
                        preferred_model or session.get("model", ""),
                        _workflow_context,
                        bool(_chat_settings.get("use_internet", True)),
                        session,
                        websocket,
                        fast_model=_chat_settings.get("fast_model", ""),
                        require_approval=bool(_chat_settings.get("require_approval", False)),
                        speed_mode=bool(_chat_settings.get("speed_mode", False)),
                        use_outlook=_chat_outlook,
                        run_id=run_id,
                    ))
                    manager.register_run_task(run_id, run_task, websocket)
                    continue

                # ── LLM Intent Router ────────────────────────────────────
                # The LLM sees the user message, available skills, user
                # profile, and conversation history — and decides the goal,
                # approach (respond vs orchestrate), and which skills to use.
                _llm_route = await _route_with_llm(
                    user_message, session, _chat_settings,
                    use_outlook=_chat_outlook, gmail_available=_chat_google,
                )
                _approach = str(_llm_route.get("approach", "unknown")).strip().lower()
                _llm_goal = str(_llm_route.get("goal", user_message)).strip() or user_message
                _llm_skills = _llm_route.get("skills") or []
                _llm_reasoning = str(_llm_route.get("reasoning", "")).strip()
                print(f"[intent-router] approach={_approach!r} skills={_llm_skills} goal={_llm_goal[:80]!r} reason={_llm_reasoning[:60]!r}")

                if _approach == "orchestrate":
                    should_run_live_workflow = True
                    # Build a minimal task_state from the LLM's skill
                    # recommendations so the orchestrator knows the domain.
                    # Full parameter extraction (time filters, search_query,
                    # provider scope) is handled by the orchestrator's own
                    # LLM classifiers inside run_orchestrator.
                    _resolved_task = None
                    _skill_str = " ".join(str(s) for s in _llm_skills)
                    if "calendar" in _skill_str:
                        _resolved_task = {
                            "task_type": "calendar_lookup", "operation": "calendar_read",
                            "requires_live_data": True, "can_answer_from_context": False,
                            "followup_relation": "new_task",
                        }
                        _chat_range = _extract_explicit_date_range(user_message)
                        if _chat_range:
                            _resolved_task.update({
                                "time_filter": "custom_range",
                                "since": _chat_range.get("since", ""),
                                "until": _chat_range.get("until", ""),
                                "time_label": _chat_range.get("time_label", ""),
                                "days_ahead": 1,
                            })
                    elif "email" in _skill_str or "inbox" in _skill_str or "gmail" in _skill_str:
                        _resolved_task = {
                            "task_type": "email_lookup", "operation": "find_received",
                            "requires_live_data": True, "can_answer_from_context": False,
                            "followup_relation": "new_task",
                        }
                elif _approach == "respond":
                    should_run_live_workflow = False
                    _resolved_task = None
                    _resolved_followup_goal = _resolve_followup_goal(session, user_message)
                    _router_domain_hint = _domain_hint_from_router_skills(_llm_skills)
                    if _should_clear_stale_chat_task_state(
                        session,
                        user_message,
                        _resolved_followup_goal,
                        domain_hint=_router_domain_hint,
                    ):
                        _clear_stale_chat_followup_context(session)
                else:
                    # LLM router unavailable — inform user, don't guess
                    print(f"[intent-router] LLM unavailable — approach={_approach!r}")
                    await manager.send_message({
                        "type": "chat_reply",
                        "content": "\u26a0\ufe0f LLM unavailable \u2014 please try again.",
                        "session_id": session_id,
                        "model": "system",
                        "run_id": run_id,
                        "goal": user_message,
                    }, websocket)
                    continue
                if should_run_live_workflow:
                    _display_goal = task_state_display_goal(_resolved_task) if _resolved_task else user_message
                    # Execute with the exact user text so downstream date/entity
                    # extraction never depends on router paraphrasing.
                    _workflow_goal = user_message
                    # Enrich context with recent chat history for follow-ups
                    _workflow_context = chat_context
                    _hist_text = _build_chat_history_context(session)
                    if _hist_text:
                        _workflow_context = f"Recent conversation:\n{_hist_text}\n\n{_workflow_context}".strip()
                    if _resolved_task and not isinstance(session.get("task_state"), dict):
                        session["task_state"] = _resolved_task
                    session["goal"] = user_message
                    session["display_goal"] = _display_goal
                    session["last_workflow_goal"] = _workflow_goal
                    session["last_routed_goal"] = _llm_goal
                    session["_chat_turns_since_workflow"] = 0
                    session["model"] = preferred_model or session.get("model", "")
                    session["use_outlook"] = _chat_outlook
                    session["completed"] = False
                    _save_session(session)
                    manager.active_runs[run_id] = True
                    await manager.send_message({
                        "type": "chat_escalate",
                        "content": "Starting live data workflow...",
                        "run_id": run_id,
                        "goal": user_message,
                        "display_goal": _display_goal,
                    }, websocket)
                    run_task = asyncio.create_task(run_orchestrator(
                        _workflow_goal,
                        preferred_model or session.get("model", ""),
                        _workflow_context,
                        bool(_chat_settings.get("use_internet", True)),
                        session,
                        websocket,
                        fast_model=_chat_settings.get("fast_model", ""),
                        require_approval=bool(_chat_settings.get("require_approval", False)),
                        speed_mode=bool(_chat_settings.get("speed_mode", False)),
                        use_outlook=_chat_outlook,
                        run_id=run_id,
                    ))
                    manager.register_run_task(run_id, run_task, websocket)
                    continue

                async def _chat_task(local_session: dict, local_session_id: str, local_message: str, local_model: str, local_run_id: str, router_skills: list[str] | None = None):
                    _current_run_id.set(local_run_id)
                    _prior_chat_history = list(local_session.get("chat_history") or [])
                    _router_allowed_tools = _router_allowed_chat_tools(router_skills)
                    _router_domain_hint = _domain_hint_from_router_skills(router_skills)
                    _resolved_followup_goal = _resolve_followup_goal(local_session, local_message)
                    _cleared_stale_context = False
                    if _should_clear_stale_chat_task_state(
                        local_session,
                        local_message,
                        _resolved_followup_goal,
                        domain_hint=_router_domain_hint,
                    ):
                        _cleared_stale_context = _clear_stale_chat_followup_context(local_session)

                    # Detect corrections: if user's message follows an assistant reply
                    if _prior_chat_history:
                        _last_msg = _prior_chat_history[-1]
                        if _last_msg.get("role") == "assistant":
                            _prev_assistant = str(_last_msg.get("content") or "")
                            _session_goal = str(local_session.get("goal") or "")
                            threading.Thread(
                                target=_detect_and_store_correction,
                                args=(local_message, _prev_assistant, _session_goal),
                                daemon=True,
                            ).start()

                    try:
                        if not _cleared_stale_context:
                            quick_followup = _maybe_answer_context_followup(local_session, local_message)
                            if quick_followup:
                                _record_session_exchange(local_session, local_message, quick_followup)
                                local_session["updated_at"] = datetime.now().isoformat()
                                _save_session(local_session)
                                await manager.send_message({
                                    "type": "chat_reply", "content": quick_followup,
                                    "session_id": local_session_id, "model": "session-context",
                                    "run_id": local_run_id, "goal": local_message,
                                }, websocket)
                                return

                        local_email_action_request = bool(_email_action_type(local_message))
                        if not local_email_action_request and not _cleared_stale_context:
                            local_email_reply = _maybe_answer_email_followup(local_session, local_message)
                            if local_email_reply:
                                _record_session_exchange(local_session, local_message, local_email_reply)
                                local_session["updated_at"] = datetime.now().isoformat()
                                _save_session(local_session)
                                await manager.send_message({
                                    "type": "chat_reply", "content": local_email_reply,
                                    "session_id": local_session_id, "model": "session-email-context",
                                    "run_id": local_run_id, "goal": local_message,
                                }, websocket)
                                return

                        _chat_settings = _load_settings()
                        _chat_outlook = bool(local_session.get("use_outlook", False) or _settings_outlook_enabled(_chat_settings)) and OUTLOOK_SUPPORT
                        _chat_google = bool(_chat_settings.get("google_enabled", False)) and GOOGLE_API_SUPPORT and (_google_get_credentials() is not None)
                        semantic_email_followup = {
                            "targets_selected_email": False,
                            "needs_full_email_read": False,
                            "needs_attachment_analysis": False,
                            "confidence": 0.0,
                        }
                        if not local_email_action_request and not _cleared_stale_context:
                            semantic_email_followup = await _classify_selected_email_followup_decision(
                                local_session,
                                local_message,
                                _chat_followup_model_candidates(
                                    _chat_settings,
                                    local_session,
                                    preferred_model=local_model,
                                ),
                            )

                        local_folder_reply = _maybe_answer_local_folder_request(local_message)
                        if local_folder_reply:
                            _record_session_exchange(local_session, local_message, local_folder_reply)
                            local_session["updated_at"] = datetime.now().isoformat()
                            _save_session(local_session)
                            await manager.send_message({
                                "type": "chat_reply", "content": local_folder_reply,
                                "session_id": local_session_id, "model": "local-folder-context",
                                "run_id": local_run_id, "goal": local_message,
                            }, websocket)
                            return
                        if not local_email_action_request and not _cleared_stale_context:
                            local_email_read_call = _build_email_followup_read_call(
                                local_session,
                                local_message,
                                use_outlook=_chat_outlook,
                                gmail_available=_chat_google,
                            )
                            if (
                                not local_email_read_call
                                and semantic_email_followup.get("targets_selected_email")
                                and semantic_email_followup.get("needs_full_email_read")
                                and float(semantic_email_followup.get("confidence") or 0.0) >= 0.6
                            ):
                                selected_record = _default_selected_email_record(local_session)
                                if selected_record:
                                    provider = str(selected_record.get("provider") or "").strip()
                                    local_email_read_call = _build_exact_email_read_call(
                                        local_session,
                                        selected_record.get("message_id"),
                                        provider,
                                        use_outlook=_chat_outlook,
                                        gmail_available=_chat_google,
                                        user_message=local_message,
                                        force_include_attachments=bool(semantic_email_followup.get("needs_attachment_analysis")),
                                    )
                            if local_email_read_call:
                                tool_name, tool_args, selected_record = local_email_read_call
                                await manager.send_message({
                                    "type": "status", "content": "Opening selected email...", "run_id": local_run_id,
                                }, websocket)
                                await manager.send_message({
                                    "type": "tool_call", "tool": tool_name, "args": tool_args,
                                    "run_id": local_run_id,
                                }, websocket)
                                loop = asyncio.get_event_loop()
                                tool_result = await loop.run_in_executor(None, _execute_tool, tool_name, tool_args)
                                _update_selected_email_record_from_read_result(local_session, selected_record, str(tool_result or ""))
                                summary_reply = await _maybe_summarize_full_email_read_result(
                                    local_message,
                                    tool_name,
                                    str(tool_result or ""),
                                    settings=_chat_settings,
                                    session=local_session,
                                    preferred_model=local_model,
                                )
                                tool_envelope = build_tool_result_envelope(
                                    local_message,
                                    local_message,
                                    tool_name,
                                    str(tool_result or ""),
                                    summary_reply or str(tool_result or ""),
                                )
                                direct_reply = (
                                    str(tool_envelope.get("rendered_result") or "").strip()
                                    if bool(tool_envelope.get("was_composed"))
                                    else str(tool_result or "").strip()
                                )
                                if not direct_reply:
                                    direct_reply = "I opened the selected email." if not _looks_dutch_text(local_message) else "Ik heb de geselecteerde e-mail geopend."
                                if not _EMAIL_OFFER_ALREADY_RE.search(direct_reply):
                                    direct_reply = _append_email_followup_offer(direct_reply, local_session, dutch_hint=_looks_dutch_text(local_message))
                                _record_session_exchange(local_session, local_message, direct_reply)
                                local_session["updated_at"] = datetime.now().isoformat()
                                _save_session(local_session)
                                await manager.send_message({
                                    "type": "chat_reply", "content": direct_reply,
                                    "session_id": local_session_id, "model": tool_name,
                                    "run_id": local_run_id, "goal": local_message,
                                }, websocket)
                                return

                        await manager.send_message({
                            "type": "status", "content": "Generating follow-up answer…", "run_id": local_run_id,
                        }, websocket)

                        # Build tools_desc from current settings so chat can call tools
                        _chat_tools_desc = _build_tools_desc(
                            use_outlook=_chat_outlook, gmail_available=_chat_google,
                            whatsapp_available=_wa_is_connected(),
                            google_drive_available=_chat_google,
                            onedrive_available=_is_onedrive_available(),
                            browser_available=PLAYWRIGHT_SUPPORT,
                        )

                        # Load policy sets for tool gating
                        _chat_disabled = {
                            _normalize_tool_name(str(t).strip())
                            for t, enabled in (_chat_settings.get("tool_enabled_map") or {}).items()
                            if str(t).strip() and not bool(enabled)
                        }
                        _chat_blocked = {
                            _normalize_tool_name(str(t).strip())
                            for t in (_chat_settings.get("policy_blocked_tools") or [])
                            if str(t).strip()
                        }
                        if _should_block_workspace_tools_for_followup(local_session, local_message):
                            _chat_blocked.update(_FOLLOWUP_WORKSPACE_BLOCKED_TOOLS)

                        email_followup_context = _build_selected_email_followup_context(local_session, local_message)
                        if not email_followup_context and semantic_email_followup.get("targets_selected_email"):
                            semantic_hint_message = "this email attachment" if semantic_email_followup.get("needs_attachment_analysis") else "this email"
                            email_followup_context = _build_selected_email_followup_context(local_session, semantic_hint_message)
                        resolved_followup_hint = _resolve_followup_goal(local_session, local_message)
                        followup_resolution_context = ""
                        if resolved_followup_hint and resolved_followup_hint.strip() != str(local_message or "").strip():
                            followup_resolution_context = f"[FOLLOW-UP CONTINUATION HINT]\n{resolved_followup_hint}"
                        combined_extra_context = "\n\n".join(
                            part for part in (followup_resolution_context, email_followup_context) if str(part).strip()
                        )
                        past_memory_text = ""
                        if _should_load_long_term_memory_for_chat(local_message):
                            loop = asyncio.get_event_loop()
                            past_memory_text = await loop.run_in_executor(None, _load_chat_long_term_memory, local_message, 3)

                        reply, used_model, _raw_followup = await asyncio.wait_for(
                            _generate_session_followup(
                                local_session, local_message, local_model,
                                tools_desc=_chat_tools_desc,
                                extra_context=combined_extra_context,
                                past_memory_text=past_memory_text,
                            ),
                            timeout=_CHAT_FOLLOWUP_LLM_TIMEOUT_SECONDS,
                        )

                        # ── Emit thinking events if show_thinking is enabled ──
                        if _chat_settings.get("show_thinking", False):
                            _think_blocks = re.findall(r'<think>([\s\S]*?)</think>', _raw_followup or "")
                            if _think_blocks:
                                await manager.send_message({"type": "stream_thinking_start", "run_id": local_run_id}, websocket)
                                for _tb in _think_blocks:
                                    await manager.send_message({"type": "stream_thinking", "content": _tb, "run_id": local_run_id}, websocket)
                                await manager.send_message({"type": "stream_thinking_end", "run_id": local_run_id}, websocket)
                                await asyncio.sleep(0)

                        # ── Tool execution loop (up to 3 turns) ──
                        _tool_used = False
                        _email_lookup_hit = False
                        for _chat_tool_turn in range(3):
                            tc, cleaned, has_marker = _extract_tool_call(reply)
                            if not has_marker or not isinstance(tc, dict):
                                if has_marker:
                                    reply = cleaned
                                break

                            t_name = _normalize_tool_name(tc.get("tool", ""))
                            t_args = tc.get("args", {})

                            if _router_allowed_tools and t_name not in _router_allowed_tools:
                                tool_result = f"TOOL_DISABLED: Skill '{t_name}' is not allowed for this conversational turn."
                            elif t_name in _chat_disabled or t_name in _chat_blocked:
                                tool_result = f"TOOL_DISABLED: Skill '{t_name}' is not available."
                            else:
                                _tool_used = True
                                await manager.send_message({
                                    "type": "tool_call", "tool": t_name, "args": t_args,
                                    "run_id": local_run_id,
                                }, websocket)
                                loop = asyncio.get_event_loop()
                                tool_result = await loop.run_in_executor(None, _execute_tool, t_name, t_args)
                                await manager.send_message({
                                    "type": "tool_result", "tool": t_name,
                                    "result": _tool_result_ui_snippet(t_name, tool_result), "run_id": local_run_id,
                                }, websocket)

                                if t_name in _EMAIL_LOOKUP_TOOL_NAMES:
                                    _email_lookup_hit = True
                                    _email_records = extract_email_lookup_records(str(tool_result or ""))
                                    if t_name.startswith("gmail"):
                                        for _record in _email_records:
                                            if isinstance(_record, dict):
                                                _record.setdefault("provider", "gmail")
                                    elif t_name.startswith("outlook"):
                                        for _record in _email_records:
                                            if isinstance(_record, dict):
                                                _record.setdefault("provider", "outlook")
                                    _merge_session_email_records(local_session, _email_records)

                            # Feed tool result back to LLM for next turn
                            followup_prompt = (
                                f"Skill '{t_name}' returned:\n{tool_result[:30000]}\n\n"
                                "The result is JSON with 'status' (ok/error), 'data' (structured result), and 'message' (summary).\n"
                                "Use 'data' for facts and 'message' for a natural-language answer. Never dump raw JSON to the user.\n\n"
                                f"Now answer the user's question: {local_message}\n"
                            )
                            _reply_settings = _load_settings()
                            _reply_candidates = _chat_followup_model_candidates(
                                _reply_settings,
                                local_session,
                                preferred_model=local_model,
                                used_model=used_model,
                            )
                            reply, used_model = await asyncio.wait_for(
                                _call_text_model_candidates(
                                    followup_prompt,
                                    _reply_candidates,
                                    system=_compose_agent_system() + _chat_tools_desc,
                                    num_ctx=_model_ctx(16384),
                                    thinking=False,
                                    per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
                                ),
                                timeout=_CHAT_FOLLOWUP_LLM_TIMEOUT_SECONDS,
                            )
                            reply = reply.strip()
                            if _is_llm_error(reply):
                                raise RuntimeError(reply)
                            reply = re.sub(r'<think>[\s\S]*?</think>', '', reply).strip()

                            # Update session history with the tool-augmented answer
                            history = local_session.get("chat_history", [])
                            if history and history[-1].get("role") == "assistant":
                                history[-1]["content"] = reply
                            local_session["chat_history"] = history
                            local_session["updated_at"] = datetime.now().isoformat()
                            _save_session(local_session)

                        is_weak = bool(_WEAK_REPLY_RE.search(reply))
                        if is_weak:
                            try:
                                await manager.send_message({
                                    "type": "status", "content": "Searching the web…", "run_id": local_run_id,
                                }, websocket)
                                loop = asyncio.get_event_loop()
                                _search_query = _resolve_followup_goal(local_session, local_message)
                                search_results = await loop.run_in_executor(None, _web_search, _search_query, 6)
                                if search_results and "No search results found" not in search_results:
                                    settings = _load_settings()
                                    search_candidates = _chat_followup_model_candidates(
                                        settings,
                                        local_session,
                                        preferred_model=local_model,
                                        used_model=used_model,
                                    )
                                    if search_candidates:
                                        _chat_ctx = _build_chat_history_context(local_session, 4)
                                        _ctx_block = f"[CONVERSATION CONTEXT]\n{_chat_ctx}\n\n" if _chat_ctx else ""
                                        search_prompt = (
                                            "Answer the user's question using the web search results and conversation context below.\n"
                                            "Give a direct, concise answer in a few sentences MAX. No lengthy reasoning or analysis.\n"
                                            "Get straight to the point. Do NOT say you cannot help.\n"
                                            "If the search results don't contain the answer, say so briefly.\n\n"
                                            f"{_ctx_block}"
                                            f"[WEB SEARCH RESULTS]\n{search_results[:4000]}\n\n"
                                            f"[USER QUESTION]\n{local_message}\n"
                                        )
                                        search_reply, used_model = await asyncio.wait_for(
                                            _call_text_model_candidates(
                                                search_prompt,
                                                search_candidates,
                                                system=_compose_agent_system(),
                                                num_ctx=8192,
                                                thinking=False,
                                                per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
                                            ),
                                            timeout=_CHAT_FOLLOWUP_LLM_TIMEOUT_SECONDS,
                                        )
                                        search_reply = search_reply.strip()
                                        if _is_llm_error(search_reply):
                                            raise RuntimeError(search_reply)
                                        search_reply = re.sub(r'<think>[\s\S]*?</think>', '', search_reply).strip()
                                        if search_reply and not _WEAK_REPLY_RE.search(search_reply):
                                            reply = search_reply
                                            is_weak = False
                                            local_session["chat_history"] = local_session.get("chat_history", [])[:-1]
                                            local_session["chat_history"].append({"role": "assistant", "content": reply})
                                            local_session["updated_at"] = datetime.now().isoformat()
                                            _save_session(local_session)
                            except Exception as search_err:
                                print(f"[chat] web search fallback failed: {search_err}")

                        # ── Auto-escalate to full workflow when chat can't handle it ──
                        _needs_escalation = is_weak
                        if not _needs_escalation:
                            _offer_re = re.compile(
                                r"wil je dat ik.{0,40}(agent|workflow|flow|uitvoer|start|doe|verander|update)"
                                r"|shall i.{0,30}(start|launch|run|do|change|update).{0,20}(agent|workflow|flow|that|this|it)"
                                r"|ik kan.{0,30}(maar|however|eerst|first)"
                                r"|I can.{0,30}(but|however|first)",
                                re.IGNORECASE,
                            )
                            _needs_escalation = bool(_offer_re.search(reply))

                        if not _needs_escalation and not _tool_used:
                            _needs_escalation = _followup_confirms_prior_offer(_prior_chat_history, local_message)

                        if not _needs_escalation and not _tool_used:
                            _needs_escalation = _reply_claims_action(reply)

                        # If user clearly requested an action but LLM didn't use any tool, escalate
                        if not _needs_escalation:
                            _action_re = re.compile(
                                r"\b(verander|wijzig\w*|update|aanpas\w*|verzet\w*|vervroeg\w*|verlaat\w*|maak|stuur|send|"
                                r"create|delete|verwijder\w*|move|doe.{0,5}(het|dat|dit)|do.{0,3}(it|that)|inplan\w*|plan|inboek\w*|boek|book|zet\w*|toevoeg\w*|voeg\s+toe|plaats\w*)",
                                re.IGNORECASE,
                            )
                            _tool_call_in_reply = '"tool"' in reply or 'TOOL_CALL' in reply
                            if _action_re.search(local_message) and not _tool_call_in_reply and not _tool_used:
                                _needs_escalation = True

                        # Only send chat reply if we're NOT escalating — avoid showing the weak/evasive answer
                        if not _needs_escalation:
                            if _email_lookup_hit:
                                reply = _append_email_followup_offer(reply, local_session, dutch_hint=_looks_dutch_text(local_message))
                            # The user is engaging in chat, not a workflow.
                            # Clear stale workflow markers so future messages
                            # are not incorrectly routed to a live-data workflow.
                            local_session.pop("last_workflow_goal", None)
                            local_session["_chat_turns_since_workflow"] = local_session.get("_chat_turns_since_workflow", 0) + 1
                            _save_session(local_session)
                            await manager.send_message({
                                "type": "chat_reply", "content": reply,
                                "session_id": local_session_id, "model": used_model,
                                "run_id": local_run_id, "goal": local_message,
                            }, websocket)
                            # Auto-suggest compaction when history gets long
                            _hist_len = len(local_session.get("chat_history") or [])
                            if _hist_len >= 20 and not local_session.get("_compact_suggested"):
                                local_session["_compact_suggested"] = True
                                _save_session(local_session)
                                await manager.send_message({
                                    "type": "compact_suggest",
                                    "session_id": local_session_id,
                                    "message_count": _hist_len,
                                    "run_id": local_run_id,
                                }, websocket)

                        if _needs_escalation:
                            _drop_unsent_chat_reply(local_session, reply)
                            _esc_settings = _load_settings()
                            _esc_model = (
                                _esc_settings.get("main_model")
                                or local_model
                                or ""
                            ).strip()
                            if _esc_model:
                                # Reuse existing chat session & run — no new lane / screen clear
                                manager.active_runs[local_run_id] = True
                                _esc_context = local_session.get("context", "")
                                # Include recent chat history as context for the workflow
                                _esc_history = local_session.get("chat_history", [])[-6:]
                                if _esc_history:
                                    _hist_text = "\n".join(
                                        f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')[:500]}"
                                        for m in _esc_history
                                    )
                                    _esc_context = f"Recent conversation:\n{_hist_text}\n\n{_esc_context}".strip()
                                # Enrich existing session with workflow fields
                                local_session["goal"] = local_message
                                local_session["model"] = _esc_model
                                local_session["use_outlook"] = _chat_outlook
                                local_session["completed"] = False
                                local_session["_chat_turns_since_workflow"] = 0
                                if not local_session.get("steps"):
                                    local_session["steps"] = []
                                if _esc_context:
                                    local_session["context"] = _esc_context
                                _save_session(local_session)
                                await manager.send_message({
                                    "type": "chat_escalate",
                                    "content": "Starting full workflow…",
                                    "run_id": local_run_id,
                                    "goal": local_message,
                                }, websocket)
                                run_task = asyncio.create_task(run_orchestrator(
                                    local_message, _esc_model, _esc_context,
                                    bool(_esc_settings.get("use_internet", True)),
                                    local_session, websocket,
                                    fast_model=_esc_settings.get("fast_model", ""),
                                    require_approval=bool(_esc_settings.get("require_approval", False)),
                                    speed_mode=bool(_esc_settings.get("speed_mode", False)),
                                    use_outlook=_chat_outlook,
                                    run_id=local_run_id,
                                ))
                                manager.register_run_task(local_run_id, run_task, websocket)

                        try:
                            print(f"[user-profile] chat enrichment triggered for: {local_message[:60]!r}")
                            _update_user_profile_from_text(local_message)
                            convo_text = f"User: {local_message}\nAssistant: {reply[:1500]}"
                            threading.Thread(target=_enrich_user_profile_llm, args=(convo_text,), daemon=True).start()
                        except Exception as enrich_err:
                            print(f"[user-profile] chat enrichment error: {enrich_err}")
                    except asyncio.TimeoutError:
                        await manager.send_message({
                            "type": "error", "content": "Chat follow-up timed out while waiting for the model.", "run_id": local_run_id,
                        }, websocket)
                    except Exception as e:
                        await manager.send_message({
                            "type": "error", "content": f"Chat follow-up failed: {e}", "run_id": local_run_id,
                        }, websocket)

                asyncio.create_task(_chat_task(session, session_id, user_message, preferred_model, run_id, _llm_skills))
            elif action == "stop":
                target_run = data.get("run_id", "")
                if target_run:
                    was_running = target_run in manager.active_runs and bool(manager.active_runs.get(target_run))
                    manager.active_runs[target_run] = False
                    await manager.send_message({
                        "type": "run_stop_requested", "run_id": target_run,
                        "content": "Kill requested. Waiting for current model call to finish...",
                    }, websocket)
                    if not was_running:
                        await manager.send_message({
                            "type": "run_stopped", "run_id": target_run,
                            "reason_code": "ALREADY_STOPPED",
                            "content": "Workflow was already stopped.",
                        }, websocket)
                else:
                    for rid in list(manager.active_runs):
                        manager.active_runs[rid] = False
                        await manager.send_message({
                            "type": "run_stop_requested", "run_id": rid,
                            "content": "Kill requested. Waiting for current model call to finish...",
                        }, websocket)
                await manager.send_message({"type": "info", "content": "Stopping…"}, websocket)
            elif action == "pause":
                target_run = data.get("run_id", "")
                targets = [target_run] if target_run else [r for r, v in manager.active_runs.items() if v is True]
                for rid in targets:
                    if manager.active_runs.get(rid) is True:
                        manager.active_runs[rid] = 'paused'
                        await manager.send_message({"type": "paused", "run_id": rid, "content": "Paused by user."}, websocket)
            elif action == "resume":
                target_run = data.get("run_id", "")
                targets = [target_run] if target_run else [r for r, v in manager.active_runs.items() if v == 'paused']
                for rid in targets:
                    if manager.active_runs.get(rid) == 'paused':
                        manager.active_runs[rid] = True
                        await manager.send_message({"type": "resumed", "run_id": rid, "content": "Resumed."}, websocket)
            elif action == "clarification_response":
                if manager.pending_clarification:
                    manager.pending_clarification["answer"] = data.get("answer", "")
                    manager.pending_clarification["event"].set()
            elif action == "approval_response":
                target_run = str(data.get("run_id", "") or "").strip()
                pending = None
                if target_run and target_run in manager.pending_approvals:
                    pending = manager.pending_approvals.get(target_run)
                elif len(manager.pending_approvals) == 1:
                    pending = next(iter(manager.pending_approvals.values()))
                if pending:
                    pending["response"] = data.get("response", "reject")
                    pending["note"] = data.get("note", "")
                    pending["event"].set()
    except WebSocketDisconnect:
        manager.disconnect(websocket)


async def _wait_if_paused(run_id: str) -> bool:
    """Wait while a run is paused. Returns True if still running, False if stopped."""
    while manager.active_runs.get(run_id) == 'paused':
        await asyncio.sleep(0.3)
    return bool(manager.active_runs.get(run_id, False))


# ── Direct tool execution: tool-call builder from classified intent ────────────

def _detect_email_folder(text: str, folder_profiles: dict | None = None) -> str:
    value = (text or "").lower()
    _has_sent = bool(re.search(r"\b(sent|sent items|verzonden|verzonden items|verstuurd|gestuurd)\b", value))
    if _has_sent:
        # If the "sent" keyword only appears inside a conditional fallback clause
        # (e.g. "als je niets vindt, probeer ook in verzonden items"), the
        # primary intent is still Inbox.  Detect inbox-primary signals.
        _has_inbox_primary = bool(re.search(
            r"\b(ontvangen|received|gekregen|binnengekregen|inbox|postvak)\b", value
        ))
        _sent_in_conditional = bool(re.search(
            r"\b(?:als|if|anders|otherwise|indien|mocht)\b.{0,60}\b(?:verzonden|sent|verstuurd|gestuurd)\b",
            value,
        ))
        if _has_inbox_primary and _sent_in_conditional:
            pass  # fall through to Inbox default
        else:
            return "Sent"
    if re.search(r"\b(drafts|concepten)\b", value):
        return "Drafts"
    if re.search(r"\b(deleted|deleted items|trash|prullenbak)\b", value):
        return "Deleted"
    if folder_profiles:
        matched = _match_folder_profile(value, folder_profiles)
        if matched:
            return matched
    return "Inbox"
    value = (step_desc or "").lower()
    if provider == "gmail":
        return bool(re.search(r"\b(gmail|google mail)\b", value))
    if provider == "outlook":
        return bool(re.search(r"\b(outlook)\b", value))
    return False


def _step_is_cross_source(step_desc: str) -> bool:
    value = (step_desc or "").lower()
    return bool(re.search(r"\b(merge|combine|compare|vergelijk|both|beide|across accounts|all accounts)\b", value))


def _format_direct_result(goal: str, results: list[tuple[str, str]]) -> str:
    """Format direct tool results, counting emails when the goal asks for a count."""
    gl = goal.lower()
    is_count = bool(re.search(
        r"\b(hoeveel|count|how many|number of|aantal|tel)\b", gl, re.IGNORECASE,
    ))

    parts: list[str] = []
    total_email_count = 0

    for tool_name, result in results:
        txt = result.strip()
        if not txt:
            continue
        is_email_tool = any(k in tool_name for k in ("inbox", "email", "gmail"))
        if is_email_tool:
            if txt.lower().startswith("no email"):
                pass  # 0 contribution
            else:
                count = len(re.findall(r"(?m)^(?:Mailbox:|From:)\s", txt))
                total_email_count += count
                if not is_count:
                    parts.append(txt)
        else:
            parts.append(txt)

    if is_count:
        # Detect language from goal for response
        if re.search(r"[a-z]*(?:hoeveel|aantal|berichten|ontvangen)", gl):
            return f"{total_email_count} emails gevonden."
        return f"{total_email_count} emails found."

    if not parts:
        return "No results found."
    return "\n\n---\n\n".join(parts)


# Expose shared helpers from backend.direct_accelerators at module level.
_heuristic_readonly_lookup_intent = _direct_accel_module._heuristic_readonly_lookup_intent
_merge_direct_intent = _direct_accel_module._merge_direct_intent
_build_direct_tool_calls = _direct_accel_module._build_direct_tool_calls
_detect_email_source_scope = _direct_accel_module._detect_email_source_scope
_detect_requested_email_count = _direct_accel_module._detect_requested_email_count
_detect_time_filter = _direct_accel_module._detect_time_filter
_detect_calendar_days_ahead = _direct_accel_module._detect_calendar_days_ahead
_extract_email_search_hint = _direct_accel_module._extract_email_search_hint
_step_mentions_provider = _direct_accel_module._step_mentions_provider


# ══════════════════════════════════════════════════════════════════════════════
# Orchestrator pure helpers — stateless, no closure dependencies
# ══════════════════════════════════════════════════════════════════════════════


def _as_int(value, default: int) -> int:
    try:
        return int(value)
    except Exception:
        return int(default)


def sanitize_context_for_prompt(ctx: str) -> str:
    return re.sub(r"\[base64:[^;]+;[A-Za-z0-9+/=]+\]", "[image attached]", ctx)


def extract_images(ctx: str) -> list[str]:
    return re.findall(r"\[base64:[^;]+;([A-Za-z0-9+/=]+)\]", ctx)


def _est_tokens(text: str) -> int:
    return max(1, len(text) // 4)


def _can_skip_validator_fast(result: str, had_side_effect: bool) -> bool:
    if had_side_effect:
        return True
    text = (result or "").strip()
    if len(text) < 120:
        return False
    low = text.lower()
    bad_markers = (
        "error", "traceback", "issue:", "tool_disabled",
        "policy_denied", "hitl_required", "run halted",
        "timed out", "cannot",
    )
    return not any(m in low for m in bad_markers)


def _result_looks_failed(text: str) -> bool:
    cleaned = str(text or "").strip()
    if not cleaned:
        return True
    lowered = cleaned.lower()
    if lowered.startswith(("error", "tool_disabled", "policy_denied", "hitl_required", "[run halted")):
        return True
    bad_markers = (
        "traceback",
        "failed to parse plan",
        "no completed steps were available",
        "could not complete",
        "unable to complete",
    )
    return any(marker in lowered for marker in bad_markers)


def _should_attempt_executor_plan_b(
    step_result: str,
    verdict: str,
    step_meta: dict,
    *,
    had_side_effect: bool,
    cl_is_action: bool = False,
    known_copilot_kind: str = "",
    run_budget_halted: bool = False,
) -> bool:
    if had_side_effect or cl_is_action or known_copilot_kind:
        return False
    if run_budget_halted:
        return False

    result_text = str(step_result or "").strip()
    tool_text = str((step_meta or {}).get("tool_result") or "").strip()
    combined = "\n".join(part for part in (result_text, tool_text) if part).lower()
    if any(marker in combined for marker in ("tool_disabled", "policy_denied", "hitl_required")):
        return False

    if str(verdict or "").strip().upper().startswith("ISSUE"):
        return True
    if _result_looks_failed(result_text):
        return True
    if not tool_text and len(re.sub(r"\s+", "", result_text)) < 20:
        return True
    return False


def _replan_step_signature(step_list: list[dict]) -> str:
    """Collapse a step list to a compact string for stagnation detection."""
    return "|".join(sorted(str(s.get("step", ""))[:60] for s in step_list))


def _should_skip_report(mem: list[dict]) -> bool:
    """Decide whether the report agent can be skipped.

    Skip when the executor output is already a clean, user-ready answer
    and a separate synthesis pass would just repeat it with minor formatting.
    """
    if not mem:
        return True
    # Never skip when any step has an ISSUE verdict — needs a proper final report.
    if any(str(item.get("verdict") or "").upper().startswith("ISSUE") for item in mem):
        return False
    # Single-step runs: the executor result is already the answer.
    if len(mem) == 1:
        return True
    # All results are very short — nothing to synthesise.
    combined_len = sum(len((m.get("result") or "").strip()) for m in mem)
    if combined_len < 300:
        return True
    # Every step was a side-effect action (send email, write file, etc.)
    # where the result is just a confirmation message.
    _action_markers = (
        "uploaded", "sent", "saved", "created", "deleted", "scheduled",
        "posted", "moved", "wrote", "updated",
    )
    if all(
        any(marker in (m.get("result") or "").lower()[:200] for marker in _action_markers)
        for m in mem if (m.get("result") or "").strip()
    ):
        return True
    return False


def _human_hypothesis_event_text(
    event: dict,
    tool_name: str,
    *,
    prev_conf: float,
    new_conf: float,
    decision_mode: str,
    remediation_mode: str,
    step_index: int,
) -> str:
    outcome = str(event.get("outcome") or "")
    switched_to = str(event.get("switched_to") or "")
    if outcome == "support":
        signal_text = "result supports current hypothesis"
    elif outcome == "weak":
        signal_text = "evidence is limited / weak"
    elif outcome == "error":
        signal_text = "error signal lowers confidence"
    else:
        signal_text = "neutral signal"
    decision_text = decision_mode or "unknown"
    if switched_to:
        decision_text += f"; switch to {switched_to}"
    adjustment_text = remediation_mode or "no extra adjustment"
    return (
        "Situation: hypothesis evidence after tool step.\n"
        f"Signal: step={step_index}, tool={tool_name or 'unknown'}, {signal_text}, confidence {prev_conf:.2f} -> {new_conf:.2f}.\n"
        f"Decision: mode={decision_text}.\n"
        f"Adjustment: {adjustment_text}."
    )


def _human_run_kpi_text(run_kpis: dict) -> str:
    totals = dict(run_kpis.get("totals") or {})
    budget = dict(run_kpis.get("budget_snapshot") or {})
    completed = bool(run_kpis.get("completed"))
    halted = bool(run_kpis.get("halted"))
    steps = int(totals.get("steps") or 0)
    calls = int(totals.get("tool_calls") or 0)
    issues = int(totals.get("issues") or 0)
    tokens = int(totals.get("tokens") or 0)
    first_pass = bool(run_kpis.get("first_pass_success"))
    corr_rate = float(run_kpis.get("correction_rate") or 0.0)
    eff = float(run_kpis.get("tool_call_efficiency") or 0.0)
    runtime_ms = int(run_kpis.get("time_to_solution_ms") or 0)
    max_steps = int(budget.get("max_steps") or 0)
    max_calls = int(budget.get("max_tool_calls") or 0)
    if completed and not halted:
        return (
            "Situation: run completed successfully.\n"
            f"Signal: steps={steps}, tool_calls={calls}, runtime_ms={runtime_ms}, first_pass={first_pass}, correction_rate={corr_rate:.2f}, tool_efficiency={eff:.2f}.\n"
            "Decision: result is sufficient to finish.\n"
            f"Adjustment: used budget max_steps={max_steps}, max_tool_calls={max_calls}, tokens={tokens}."
        )
    if halted:
        reason = str(run_kpis.get("halt_reason") or "unknown reason")
        return (
            "Situation: run stopped early.\n"
            f"Signal: halt_reason={reason}, steps={steps}, tool_calls={calls}, issues={issues}, tokens={tokens}.\n"
            "Decision: stop due to policy/budget boundary.\n"
            f"Adjustment: steer next runs using correction_rate={corr_rate:.2f}, tool_efficiency={eff:.2f}."
        )
    return (
        "Situation: KPI midpoint snapshot recorded.\n"
        f"Signal: steps={steps}, tool_calls={calls}, issues={issues}, tokens={tokens}.\n"
        "Decision: run still in progress.\n"
        f"Adjustment: keep monitoring correction_rate={corr_rate:.2f}, tool_efficiency={eff:.2f}."
    )


def _human_learning_text(playbook_row: dict, policy_row: dict) -> str:
    status = str(playbook_row.get("status") or "updated")
    task_type = str(policy_row.get("task_type") or "generic")
    attempts = int(playbook_row.get("attempts") or 0)
    success_rate = float(playbook_row.get("success_rate") or 0.0)
    first_pass_rate = float(playbook_row.get("first_pass_rate") or 0.0)
    d_steps = int(policy_row.get("delta_steps") or 0)
    d_calls = int(policy_row.get("delta_tool_calls") or 0)
    d_runtime = int(policy_row.get("delta_runtime_seconds") or 0)
    d_tokens = int(policy_row.get("delta_tokens") or 0)
    return (
        "Situation: learning feedback stored.\n"
        f"Signal: task_type={task_type}, attempts={attempts}, success_rate={success_rate:.2f}, first_pass_rate={first_pass_rate:.2f}.\n"
        f"Decision: playbook_status={status}.\n"
        f"Adjustment: delta_steps={d_steps}, delta_tool_calls={d_calls}, delta_runtime_seconds={d_runtime}, delta_tokens={d_tokens}."
    )


def _planner_capability_digest(catalog: dict) -> str:
    """Return a compact string listing all enabled+available tools for the planner prompt."""
    lines = []
    for name in sorted(catalog):
        cap = catalog[name]
        if not (getattr(cap, "enabled", True) and getattr(cap, "available", True)):
            continue
        desc = str(getattr(cap, "description", "") or "").strip()
        if len(desc) > 80:
            desc = desc[:77] + "…"
        side = " [side-effect]" if getattr(cap, "side_effect", False) else ""
        lines.append(f"{name}{side}: {desc}" if desc else f"{name}{side}")
    return "\n".join(lines)


def _extract_native_tool_calls(response: dict) -> list[dict]:
    """Extract tool calls from an Ollama /api/chat response.

    Returns list of {"name": str, "arguments": dict} dicts,
    or empty list if no tool calls.
    """
    msg = response.get("message") or {}
    raw_calls = msg.get("tool_calls") or []
    result = []
    for tc in raw_calls:
        fn = tc.get("function") or {}
        name = fn.get("name", "")
        arguments = fn.get("arguments") or {}
        if name:
            result.append({"name": name, "arguments": arguments})
    return result


async def _execute_email_record_action_plan(
    *,
    goal: str,
    action: str,
    intent,
    route,
    session: dict,
    run_settings: dict,
    dynamic_tools: dict,
    mcp_cache: dict[str, dict] | None,
    use_outlook: bool,
    gmail_available: bool,
    explicit_provider: str,
    folder: str,
    requested_count: int,
    selection_query: str,
    update_directives: dict[str, Any],
    dutch: bool,
    blocked_tools: set[str],
    disabled_tools: set[str],
    approval_tools: set[str],
    run_budget: dict,
    websocket,
    run_id: str,
    store_task_graph_with_validation,
    execute_task_graph,
    execute_validated_graphs,
    build_compacted_tool_trace,
    finalize_action_clarification,
    finalize_action_result,
) -> bool:
    read_results: list[tuple[str, str]] = []
    action_results: list[tuple[str, str]] = []
    email_action_plan = plan_email_action_execution(
        goal,
        route=route,
        action=action,
        intent=intent,
        session_email_records=_session_email_records(session),
        use_outlook=use_outlook,
        gmail_available=gmail_available,
        settings=run_settings,
        dynamic_tools=dynamic_tools,
        mcp_cache=mcp_cache,
        explicit_provider=explicit_provider,
        folder=folder,
        requested_count=requested_count,
        selection_query=selection_query,
        update_directives=update_directives,
        select_email_records=_select_email_records,
        dutch=dutch,
    )
    if email_action_plan.clarification:
        clarification = email_action_plan.clarification
        if action in {"update", "delete"} and intent.missing_fields:
            clarification = _email_intent_missing_clarification(intent, dutch=dutch)
        return await finalize_action_clarification(clarification)

    if email_action_plan.read_graphs:
        provider_records: list[dict] = []
        for read_graph in email_action_plan.read_graphs:
            store_task_graph_with_validation(
                session,
                read_graph,
                blocked_tools=blocked_tools,
                disabled_tools=disabled_tools,
                approval_tools=approval_tools,
                max_tasks=run_budget["max_tool_calls"],
                max_depth=run_budget["max_steps"],
                allow_side_effects=False,
            )
            graph_results = await execute_task_graph(read_graph, websocket=websocket, run_id=run_id, step_index_base=0)
            read_results.extend(graph_results)
            read_result = graph_results[-1][1] if graph_results else ""
            extracted = extract_email_lookup_records(read_result)
            provider_hint = "gmail" if any(task.tool_name.startswith("gmail_") for task in read_graph.tasks) else "outlook"
            for record in extracted:
                record.setdefault("provider", provider_hint)
            provider_records.extend(extracted)

        email_action_plan = plan_email_action_execution(
            goal,
            route=route,
            action=action,
            intent=intent,
            session_email_records=_session_email_records(session),
            use_outlook=use_outlook,
            gmail_available=gmail_available,
            settings=run_settings,
            dynamic_tools=dynamic_tools,
            mcp_cache=mcp_cache,
            explicit_provider=explicit_provider,
            folder=folder,
            requested_count=requested_count,
            selection_query=selection_query,
            update_directives=update_directives,
            existing_records=provider_records,
            select_email_records=_select_email_records,
            dutch=dutch,
        )
        if email_action_plan.clarification:
            return await finalize_action_clarification(
                email_action_plan.clarification,
                trace=build_compacted_tool_trace(read_results),
                record_store="email",
            )

    action_results.extend(await execute_validated_graphs(email_action_plan.write_graphs, allow_side_effects=True, step_index_base=1))
    if not action_results:
        return False

    final_report = _email_action_report(action, action_results, dutch=dutch)
    full_trace = build_compacted_tool_trace(read_results + action_results)
    return await finalize_action_result(final_report, full_trace, record_store="email")


async def _execute_calendar_action_plan(
    *,
    goal: str,
    action: str,
    intent,
    providers: list[str],
    route,
    session: dict,
    run_settings: dict,
    dynamic_tools: dict,
    mcp_cache: dict[str, dict] | None,
    use_outlook: bool,
    gmail_available: bool,
    requested_count: int,
    days_ahead: int,
    selection_query: str,
    directives: dict[str, Any],
    dutch: bool,
    blocked_tools: set[str],
    disabled_tools: set[str],
    approval_tools: set[str],
    run_budget: dict,
    websocket,
    run_id: str,
    store_task_graph_with_validation,
    execute_task_graph,
    execute_validated_graphs,
    build_compacted_tool_trace,
    finalize_action_clarification,
    finalize_action_result,
) -> bool:
    read_results: list[tuple[str, str]] = []
    action_results: list[tuple[str, str]] = []

    if action == "create":
        calendar_action_plan = plan_calendar_action_execution(
            goal,
            route=route,
            action="create",
            intent=intent,
            providers=providers,
            settings=run_settings,
            dynamic_tools=dynamic_tools,
            mcp_cache=mcp_cache,
            use_outlook=use_outlook,
            gmail_available=gmail_available,
            dutch=dutch,
        )
        if not calendar_action_plan.write_graphs:
            clarification = _calendar_intent_missing_clarification(intent, dutch=dutch)
            return await finalize_action_clarification(clarification)
        action_results.extend(await execute_validated_graphs(calendar_action_plan.write_graphs, allow_side_effects=True, step_index_base=0))
    else:
        stored_calendar_records = _session_calendar_records(session)
        use_stored_calendar_records = bool(stored_calendar_records) and (
            getattr(intent, "reference_ordinal", None) is not None
            or bool(str(getattr(intent, "reference_temporal_hint", "") or "").strip())
            or not selection_query
        )
        calendar_action_plan = plan_calendar_action_execution(
            goal,
            route=route,
            action=action,
            intent=intent,
            providers=providers,
            settings=run_settings,
            dynamic_tools=dynamic_tools,
            mcp_cache=mcp_cache,
            use_outlook=use_outlook,
            gmail_available=gmail_available,
            requested_count=requested_count,
            days_ahead=days_ahead,
            selection_query=selection_query,
            directives=directives,
            existing_records=stored_calendar_records if use_stored_calendar_records else None,
            select_calendar_records=_select_calendar_records,
            calendar_update_payload_from_record=_calendar_update_payload_from_record,
            dutch=dutch,
        )
        if use_stored_calendar_records and calendar_action_plan.clarification and not calendar_action_plan.write_graphs:
            calendar_action_plan = plan_calendar_action_execution(
                goal,
                route=route,
                action=action,
                intent=intent,
                providers=providers,
                settings=run_settings,
                dynamic_tools=dynamic_tools,
                mcp_cache=mcp_cache,
                use_outlook=use_outlook,
                gmail_available=gmail_available,
                requested_count=requested_count,
                days_ahead=days_ahead,
                selection_query=selection_query,
                directives=directives,
                select_calendar_records=_select_calendar_records,
                calendar_update_payload_from_record=_calendar_update_payload_from_record,
                dutch=dutch,
            )
        if calendar_action_plan.clarification or (action in {"update", "delete"} and intent.missing_fields):
            clarification = _calendar_intent_missing_clarification(intent, dutch=dutch)
            if not intent.missing_fields and calendar_action_plan.clarification:
                clarification = calendar_action_plan.clarification
            return await finalize_action_clarification(clarification)

        if calendar_action_plan.read_graphs:
            provider_records: list[dict] = []
            for read_graph in calendar_action_plan.read_graphs:
                store_task_graph_with_validation(
                    session,
                    read_graph,
                    blocked_tools=blocked_tools,
                    disabled_tools=disabled_tools,
                    approval_tools=approval_tools,
                    max_tasks=run_budget["max_tool_calls"],
                    max_depth=run_budget["max_steps"],
                    allow_side_effects=False,
                )
                graph_results = await execute_task_graph(read_graph, websocket=websocket, run_id=run_id, step_index_base=0)
                read_results.extend(graph_results)
                read_result = graph_results[-1][1] if graph_results else ""
                extracted = extract_calendar_lookup_records(read_result)
                provider_scope_hint = "gmail_only" if any(task.tool_name.startswith("google_") for task in read_graph.tasks) else "outlook_only"
                for record in extracted:
                    record["provider_scope"] = provider_scope_hint
                provider_records.extend(extracted)

            calendar_action_plan = plan_calendar_action_execution(
                goal,
                route=route,
                action=action,
                intent=intent,
                providers=providers,
                settings=run_settings,
                dynamic_tools=dynamic_tools,
                mcp_cache=mcp_cache,
                use_outlook=use_outlook,
                gmail_available=gmail_available,
                requested_count=requested_count,
                days_ahead=days_ahead,
                selection_query=selection_query,
                directives=directives,
                existing_records=provider_records,
                select_calendar_records=_select_calendar_records,
                calendar_update_payload_from_record=_calendar_update_payload_from_record,
                dutch=dutch,
            )
            if calendar_action_plan.clarification:
                return await finalize_action_clarification(
                    calendar_action_plan.clarification,
                    trace=build_compacted_tool_trace(read_results),
                    record_store="calendar",
                )

        action_results.extend(await execute_validated_graphs(calendar_action_plan.write_graphs, allow_side_effects=True, step_index_base=1))

    if not action_results:
        return False

    final_report = _calendar_action_report(action, action_results, dutch=dutch)
    full_trace = build_compacted_tool_trace(read_results + action_results)
    return await finalize_action_result(final_report, full_trace, record_store="calendar")


# ══════════════════════════════════════════════════════════════════════════════
# run_orchestrator — the core agent execution loop
# ══════════════════════════════════════════════════════════════════════════════

async def run_orchestrator(goal: str, model: str, context: str, use_internet: bool, session: dict, websocket: WebSocket, fast_model: str = "", require_approval: bool = False, speed_mode: bool = False, use_outlook: bool = False, run_id: str = ""):
    _current_run_id.set(run_id)
    if use_outlook:
        session["use_outlook"] = True
    ensure_session_task_state(session, goal)
    agent_system = _compose_agent_system()
    # Skill iteration directive: instruct the planner to fix and retry failing skills
    _skill_iteration_directive = "If a newly created/updated skill fails or produces wrong output, use update_skill to fix the code and retry before moving on."
    agent_system = agent_system + "\n\n" + _skill_iteration_directive
    run_settings = _load_settings()
    model = str(model or run_settings.get("main_model") or "").strip()
    fast_model = str(fast_model or run_settings.get("fast_model") or "").strip()
    if model:
        session["model"] = model
    nvidia_api_key = (run_settings.get("nvidia_api_key", "") or "").strip()
    use_google = bool(run_settings.get("google_enabled", False))
    _google_authorized = _google_get_credentials() is not None
    _gmail_available = use_google and GOOGLE_API_SUPPORT and _google_authorized
    if use_internet:
        await manager.send_message({"type": "info", "content": "Internet access: ENABLED"}, websocket)
    if speed_mode:
        await manager.send_message({"type": "info", "content": "Speed mode: ENABLED (faster planning/validation)"}, websocket)

    known_copilot_steps: list[dict] | None = None
    known_copilot_kind = ""

    _policy_budget_mode = str(run_settings.get("policy_budget_mode", "budget") or "budget").strip().lower()
    _dynamic_budget_mode = _policy_budget_mode == "dynamic"
    _UNLIMITED_BUDGET_INT = 2_000_000_000
    _task_state_for_feedback = session.get("task_state") if isinstance(session.get("task_state"), dict) else None
    _policy_adjusted = apply_policy_feedback_adjustment(run_settings, task_state=_task_state_for_feedback)
    _planner_learning_block = _build_planner_learning_block(_task_state_for_feedback)

    run_budget = {
        "max_steps": _UNLIMITED_BUDGET_INT if _dynamic_budget_mode else max(1, _as_int(_policy_adjusted.get("policy_max_steps_per_run", run_settings.get("policy_max_steps_per_run", 12)), 12)),
        "max_tool_calls": _UNLIMITED_BUDGET_INT if _dynamic_budget_mode else max(1, _as_int(_policy_adjusted.get("policy_max_tool_calls_per_run", run_settings.get("policy_max_tool_calls_per_run", 30)), 30)),
        "max_validation_retries": max(0, _as_int(run_settings.get("policy_max_validation_retries_per_step", 1), 1)),
        "max_total_tokens": _UNLIMITED_BUDGET_INT if _dynamic_budget_mode else max(1, _as_int(_policy_adjusted.get("policy_max_total_tokens_per_run", run_settings.get("policy_max_total_tokens_per_run", 120000)), 120000)),
        "max_runtime_seconds": _UNLIMITED_BUDGET_INT if _dynamic_budget_mode else max(10, _as_int(_policy_adjusted.get("policy_max_runtime_seconds", run_settings.get("policy_max_runtime_seconds", 900)), 900)),
        "max_replan_rounds": max(1, _as_int(run_settings.get("policy_max_replan_rounds", 3), 3)),
        "steps_executed": 0,
        "tool_calls": 0,
        "tokens": 0,
        "started_at": time.monotonic(),
        "halted": False,
        "halt_reason": "",
        "dynamic_unlimited": _dynamic_budget_mode,
    }
    _hypothesis_runtime = initialize_hypothesis_runtime(
        run_id=run_id,
        goal=goal,
        task_state=session.get("task_state") if isinstance(session.get("task_state"), dict) else None,
        policy_mode=_policy_budget_mode,
        max_items=3,
    )
    session["hypothesis_runtime"] = _hypothesis_runtime
    session["hypothesis_summary"] = summarize_hypothesis_runtime(_hypothesis_runtime)
    log_system_event(
        "hypothesis",
        "Hypothesis runtime initialized",
        {
            "run_id": run_id,
            "active_hypothesis": str(_hypothesis_runtime.get("active_hypothesis_id") or ""),
            "policy_mode": _policy_budget_mode,
            "goal_preview": str(_hypothesis_runtime.get("goal_preview") or ""),
            "uitleg": (
                "Situation: new run started with initial working hypothesis.\n"
                "Signal: goal='{}', policy_mode='{}'.\n"
                "Decision: keep hypothesis tracking active during the run.\n"
                "Adjustment: update confidence and route per step based on tool outcomes."
            ).format(
                str(_hypothesis_runtime.get("goal_preview") or "unknown goal")[:120],
                _policy_budget_mode,
            ),
        },
    )

    def _record_hypothesis_evidence(step_index: int, tool_name: str, tool_result: str, tool_details: dict | None = None) -> None:
        if not str(tool_name or "").strip():
            return
        _before_summary = summarize_hypothesis_runtime(_hypothesis_runtime)
        _before_conf = float(_before_summary.get("active_confidence") or 0.0)
        _before_active = str(_before_summary.get("active_hypothesis_id") or "")
        event = append_hypothesis_evidence(
            _hypothesis_runtime,
            tool_name=tool_name,
            tool_result=tool_result,
            step_index=step_index,
            tool_details=tool_details,
        )
        session["hypothesis_runtime"] = _hypothesis_runtime
        session["hypothesis_summary"] = summarize_hypothesis_runtime(_hypothesis_runtime)
        _after_summary = dict(session.get("hypothesis_summary") or {})
        _after_conf = float(_after_summary.get("active_confidence") or 0.0)
        _after_active = str(_after_summary.get("active_hypothesis_id") or "")
        session["hypothesis_decision"] = hypothesis_decision(_hypothesis_runtime)
        _dec_mode = str((session.get("hypothesis_decision") or {}).get("mode") or "")
        remediation = ""
        if _dec_mode == "switch_or_widen":
            _tools_left = max(0, int(run_budget.get("max_tool_calls", 0)) - int(run_budget.get("tool_calls", 0)))
            _steps_left = max(0, int(run_budget.get("max_steps", 0)) - int(run_budget.get("steps_executed", 0)))
            remediation = "widen_search"
            if _tools_left <= 1 and _steps_left <= 0:
                remediation = "escalate_response_with_known_evidence"
            session["auto_remediation"] = {
                "mode": remediation,
                "tools_left": _tools_left,
                "steps_left": _steps_left,
                "source": "hypothesis_decision",
            }
        _goal_preview = str((_hypothesis_runtime.get("goal_preview") or ""))
        log_system_event(
            "hypothesis",
            "Evidence event",
            {
                "run_id": run_id,
                "event_id": str(event.get("event_id") or ""),
                "tool": str(tool_name or ""),
                "outcome": str(event.get("outcome") or ""),
                "active_hypothesis": str(_hypothesis_runtime.get("active_hypothesis_id") or ""),
                "goal_preview": _goal_preview,
                "step_index": str(step_index),
                "active_before": _before_active,
                "active_after": _after_active,
                "confidence_before": f"{_before_conf:.4f}",
                "confidence_after": f"{_after_conf:.4f}",
                "decision_mode": _dec_mode,
                "remediation_mode": remediation,
                "uitleg": _human_hypothesis_event_text(
                    event,
                    str(tool_name or ""),
                    prev_conf=_before_conf,
                    new_conf=_after_conf,
                    decision_mode=_dec_mode,
                    remediation_mode=remediation,
                    step_index=step_index,
                ),
            },
        )
        if bool(event.get("switched")):
            log_system_event(
                "hypothesis",
                "Active hypothesis switched",
                {
                    "run_id": run_id,
                    "switched_to": str(event.get("switched_to") or ""),
                    "mode": str((session.get("hypothesis_decision") or {}).get("mode") or "switch_or_widen"),
                    "active_before": _before_active,
                    "active_after": _after_active,
                    "confidence_before": f"{_before_conf:.4f}",
                    "confidence_after": f"{_after_conf:.4f}",
                    "uitleg": (
                        "Situation: active hypothesis switched.\n"
                        f"Signal: {_before_active} ({_before_conf:.2f}) -> {_after_active} ({_after_conf:.2f}).\n"
                        f"Decision: mode={str((session.get('hypothesis_decision') or {}).get('mode') or 'switch_or_widen')}.\n"
                        f"Adjustment: {remediation or 'no explicit adjustment'}."
                    ),
                },
            )

    async def _apply_auto_remediation(step_index: int) -> bool:
        """Apply hypothesis remediation decisions to runtime behavior.

        Returns True when execution should stop early and report with known evidence.
        """
        remediation = dict(session.get("auto_remediation") or {})
        if str(remediation.get("source") or "") != "hypothesis_decision":
            return False
        mode = str(remediation.get("mode") or "").strip().lower()
        if not mode:
            return False
        if str(remediation.get("applied_for_step") or "") == str(step_index):
            return False

        remediation["applied_for_step"] = str(step_index)
        remediation["applied_at"] = datetime.now().isoformat()
        stop_early = False

        if mode == "widen_search":
            if not bool(run_budget.get("dynamic_unlimited")):
                old_max_steps = int(run_budget.get("max_steps") or 0)
                old_max_calls = int(run_budget.get("max_tool_calls") or 0)
                run_budget["max_steps"] = min(30, old_max_steps + 1)
                run_budget["max_tool_calls"] = min(80, old_max_calls + 2)

            if use_internet and isinstance(steps, list) and (step_index + 1) < len(steps):
                next_step = steps[step_index + 1]
                if isinstance(next_step, dict):
                    next_step["needs_search"] = True
                    hint = str(next_step.get("search_hint") or "").strip()
                    widen_hint = "widen search scope: include alternate provider/source, broader time range, and synonym terms"
                    if widen_hint not in hint.lower():
                        next_step["search_hint"] = f"{hint} | {widen_hint}" if hint else widen_hint

            await manager.send_message(
                {
                    "type": "info",
                    "content": (
                        "Auto-remediation applied: widening search strategy and granting a small temporary budget extension."
                    ),
                },
                websocket,
            )

        elif mode == "escalate_response_with_known_evidence":
            stop_early = True
            await manager.send_message(
                {
                    "type": "info",
                    "content": (
                        "Auto-remediation applied: switching to evidence-first response mode due to low confidence and low remaining budget."
                    ),
                },
                websocket,
            )

        session["auto_remediation"] = remediation
        return stop_early

    def _set_run_kpis_snapshot(*, completed: bool) -> None:
        run_kpis = build_run_kpis(
            memory,
            run_budget,
            completed=completed,
            halted=bool(run_budget.get("halted")),
            halt_reason=str(run_budget.get("halt_reason") or ""),
        )
        session["run_kpis"] = run_kpis
        session["hypothesis_runtime"] = _hypothesis_runtime
        session["hypothesis_summary"] = summarize_hypothesis_runtime(_hypothesis_runtime)
        log_system_event(
            "run_kpi",
            "Run KPI snapshot",
            {
                "run_id": run_id,
                "completed": str(run_kpis.get("completed")),
                "halted": str(run_kpis.get("halted")),
                "steps": str((run_kpis.get("totals") or {}).get("steps", 0)),
                "tool_calls": str((run_kpis.get("totals") or {}).get("tool_calls", 0)),
                "first_pass_success": str(run_kpis.get("first_pass_success")),
                "correction_rate": str(run_kpis.get("correction_rate")),
                "tool_call_efficiency": str(run_kpis.get("tool_call_efficiency")),
                "time_to_solution_ms": str(run_kpis.get("time_to_solution_ms")),
                "halt_reason": str(run_kpis.get("halt_reason") or ""),
                "uitleg": _human_run_kpi_text(run_kpis),
            },
        )

    def _persist_learning_feedback() -> None:
        run_kpis = dict(session.get("run_kpis") or {})
        if not run_kpis:
            return
        hypothesis_summary = dict(session.get("hypothesis_summary") or {})
        playbook_row = update_playbook_stats(
            goal=goal,
            task_state=session.get("task_state") if isinstance(session.get("task_state"), dict) else None,
            run_kpis=run_kpis,
            hypothesis_summary=hypothesis_summary,
        )
        policy_row = update_policy_feedback_from_kpis(
            session.get("task_state") if isinstance(session.get("task_state"), dict) else None,
            run_kpis,
        )
        session["learning_feedback"] = {
            "playbook": playbook_row,
            "policy": policy_row,
        }
        log_system_event(
            "learning",
            "Playbook and policy feedback updated",
            {
                "run_id": run_id,
                "playbook_key": str(playbook_row.get("key") or ""),
                "playbook_status": str(playbook_row.get("status") or ""),
                "task_type": str(policy_row.get("task_type") or ""),
                "attempts": str(playbook_row.get("attempts") or 0),
                "success_rate": str(playbook_row.get("success_rate") or 0.0),
                "first_pass_rate": str(playbook_row.get("first_pass_rate") or 0.0),
                "delta_steps": str(policy_row.get("delta_steps") or 0),
                "delta_tool_calls": str(policy_row.get("delta_tool_calls") or 0),
                "delta_runtime_seconds": str(policy_row.get("delta_runtime_seconds") or 0),
                "delta_tokens": str(policy_row.get("delta_tokens") or 0),
                "uitleg": _human_learning_text(playbook_row, policy_row),
            },
        )

    if _dynamic_budget_mode:
        await manager.send_message({
            "type": "info",
            "content": "Run Policy mode: dynamic (unlimited run/calls/steps/tokens/runtime).",
        }, websocket)
    elif isinstance(_policy_adjusted.get("profile"), dict) and _policy_adjusted.get("profile"):
        await manager.send_message({
            "type": "info",
            "content": "Policy feedback active: safe run-budget tuning applied based on previous runs.",
        }, websocket)
    blocked_tools = {
        _normalize_tool_name(str(t).strip())
        for t in (run_settings.get("policy_blocked_tools") or [])
        if str(t).strip()
    }
    approval_tools = {
        _normalize_tool_name(str(t).strip())
        for t in (run_settings.get("policy_require_approval_tools") or [])
        if str(t).strip()
    }
    tool_enabled_map = (run_settings.get("tool_enabled_map") or {})
    disabled_tools = {
        _normalize_tool_name(str(t).strip())
        for t, enabled in tool_enabled_map.items()
        if str(t).strip() and not bool(enabled)
    }
    auto_approved_tools: set[str] = set()
    steps_auto_approved = False

    async def _halt_run(reason_code: str, message: str):
        if run_budget["halted"]:
            return
        run_budget["halted"] = True
        run_budget["halt_reason"] = reason_code
        manager.active_runs[run_id] = False
        await manager.send_message({
            "type": "run_stopped", "reason_code": reason_code, "content": message,
        }, websocket)
        await manager.send_message({"type": "error", "content": message}, websocket)

    async def _check_runtime(where: str = "") -> bool:
        if run_budget["halted"]:
            return False
        # Detect user kill (active_runs set to False by stop handler)
        if not manager.active_runs.get(run_id, False):
            await _halt_run("USER_STOPPED", "Workflow stopped by user.")
            return False
        elapsed = int(time.monotonic() - run_budget["started_at"])
        limit = run_budget["max_runtime_seconds"]
        if elapsed > limit:
            suffix = f" ({where})" if where else ""
            await _halt_run("BUDGET_RUNTIME", f"Run stopped: runtime budget exceeded after {elapsed}s (limit {limit}s){suffix}.")
            return False
        return True

    async def _request_tool_approval(step_index: int, tool_name: str, tool_args: dict) -> tuple[bool, bool]:
        """Returns (approved, always). When always=True the caller should skip future prompts for this tool."""
        if tool_name in auto_approved_tools:
            return True, True
        ap_event = asyncio.Event()
        manager.pending_approvals[run_id] = {"event": ap_event, "response": None, "note": ""}
        await manager.send_message({
            "type": "approval_needed", "step_index": step_index,
            "step": f"Tool approval needed: {tool_name}",
            "tool": tool_name, "args": tool_args,
        }, websocket)
        try:
            await asyncio.wait_for(ap_event.wait(), timeout=180)
        except asyncio.TimeoutError:
            manager.pending_approvals.pop(run_id, None)
            return False, False
        ap = dict(manager.pending_approvals.pop(run_id, {}) or {})
        resp = ap.get("response", "reject")
        if resp == "reject":
            return False, False
        always = resp == "approve_always"
        if always:
            auto_approved_tools.add(tool_name)
        return True, always

    # ── Load past-session memory ───────────────────────────────────────────────
    loop = asyncio.get_event_loop()
    memory_bundle = await loop.run_in_executor(None, _recall_memory_bundle, goal)
    past_memory = str(memory_bundle.get("summary", "") or "")
    deep_memory = str(memory_bundle.get("deep_memory", "") or "")
    memory_matches = list(memory_bundle.get("matches") or [])
    wiki_bundle = {"used": False, "matches": [], "summary": ""}
    if should_retrieve_wiki_context(goal, task_state=session.get("task_state"), user_context=context):
        wiki_bundle = await loop.run_in_executor(None, retrieve_wiki_context, goal)
    wiki_memory = str(wiki_bundle.get("summary", "") or "")
    if past_memory:
        await manager.send_message({
            "type": "memory_recall",
            "count": len(memory_matches),
            "content": past_memory,
            "matches": memory_matches,
            "citations": memory_bundle.get("citations", []),
        }, websocket)
    else:
        await manager.send_message({"type": "memory_recall", "count": 0, "content": "", "matches": [], "citations": []}, websocket)
    if deep_memory:
        await manager.send_message({
            "type": "info",
            "content": "Loaded detailed memory from a closely related past session.",
        }, websocket)
    if wiki_memory:
        await manager.send_message({
            "type": "info",
            "content": f"Loaded durable user context from {len(list(wiki_bundle.get('matches') or []))} page(s).",
        }, websocket)

    safe_context = sanitize_context_for_prompt(context)
    learned_facts = _update_user_profile_from_text(f"{goal}\n\n{safe_context}")
    if learned_facts:
        await manager.send_message({
            "type": "info",
            "content": f"User profile updated: {', '.join(learned_facts[:3])}{'…' if len(learned_facts) > 3 else ''}",
        }, websocket)
    # ── Suppress stale memory/context for live-data tasks (calendar, email, search) ──
    _task_state = (session or {}).get("task_state") or {}
    # Only suppress context when the *current* goal also appears to be a live-data
    # request.  If the user asks a general follow-up (e.g. "what is X?") after an
    # email session the previous task_state has requires_live_data=True, but we
    # must NOT wipe the conversation context — the LLM needs it to answer.
    _goal_is_live_data = bool(
        _EMAIL_MARKERS_RE.search(goal)
        or _CALENDAR_MARKERS_RE.search(goal)
        or _SEARCH_VERB_RE.search(goal)
    )
    if _task_state.get("requires_live_data") and _goal_is_live_data:
        past_memory = ""
        deep_memory = ""
        # Strip old conversation history that may contain stale API results;
        # keeps the LLM from answering with outdated data instead of calling the tool.
        safe_context = ""
    context_prompt = f"\n\nAdditional Context provided by user:\n{safe_context}\n" if safe_context else ""
    _planner_additional_context = ""
    _citation_instruction = (
        "\nWhen your answer draws on information from a past session listed above, "
        "cite it inline as [\U0001f4ce DATE: brief goal] where DATE and goal match the session reference.\n"
    ) if past_memory or deep_memory else ""
    memory_preamble = f"\n\n{past_memory}\n{_citation_instruction}" if past_memory else ""
    deep_memory_preamble = f"\n\n[Deep memory recall]\n{deep_memory[:6000]}\n{_citation_instruction}" if deep_memory else ""
    wiki_memory_preamble = f"\n\n{wiki_memory[:6000]}\n" if wiki_memory else ""
    context_prompt = deep_memory_preamble + memory_preamble + wiki_memory_preamble + context_prompt
    context_images = extract_images(context)

    # ── Agent lifecycle helpers ────────────────────────────────────────────────
    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,
            "run_id": run_id,
        }, websocket)

    async def handoff(from_a: str, to_a: str, step_index: int = -1, summary: str = ""):
        await manager.send_message({
            "type": "agent_handoff", "from": from_a, "to": to_a,
            "step_index": step_index, "summary": summary,
            "run_id": run_id,
        }, websocket)

    # ── Token tracking ─────────────────────────────────────────────────────────
    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 loop.run_in_executor(None, call_nvidia_sync, prompt, system, nvidia_model_id, thinking)
            elif active_model.startswith(OPENAI_PREFIX):
                openai_model_id = active_model[len(OPENAI_PREFIX):]
                result = await loop.run_in_executor(None, call_openai_sync, prompt, system, openai_model_id, thinking)
            elif active_model.startswith(GEMINI_PREFIX):
                gemini_model_id = active_model[len(GEMINI_PREFIX):]
                result = await loop.run_in_executor(None, call_gemini_sync, prompt, system, gemini_model_id, thinking)
            elif active_model.startswith(LLAMACPP_PREFIX):
                llamacpp_id = active_model[len(LLAMACPP_PREFIX):]
                result = await loop.run_in_executor(None, call_llamacpp_sync, prompt, system, llamacpp_id, thinking)
            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)
                        res.raise_for_status()
                        return (res.json().get("message") or {}).get("content", "")
                    except Exception as e:
                        return f"Error calling Ollama: {e}"
                result = await loop.run_in_executor(None, _call)
            # ── 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 loop.run_in_executor(None, call_nvidia_sync, prompt, system, nvidia_id, False)
            elif active_model.startswith(OPENAI_PREFIX):
                openai_id = active_model[len(OPENAI_PREFIX):]
                result = await loop.run_in_executor(None, call_openai_sync, prompt, system, openai_id, False)
            elif active_model.startswith(GEMINI_PREFIX):
                gemini_id = active_model[len(GEMINI_PREFIX):]
                result = await loop.run_in_executor(None, call_gemini_sync, prompt, system, gemini_id, False)
            elif active_model.startswith(LLAMACPP_PREFIX):
                llamacpp_id = active_model[len(LLAMACPP_PREFIX):]
                result = await loop.run_in_executor(None, call_llamacpp_sync, prompt, system, llamacpp_id, False)
            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"},
                        )
                        res.raise_for_status()
                        return (res.json().get("message") or {}).get("content", "")
                    except Exception as e:
                        return f"Error calling fast model: {e}"
                result = await loop.run_in_executor(None, _fc)
            # ── 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),
                        _FALLBACK_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, "run_id": run_id}, 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, "run_id": run_id}, websocket)
                                if _show_thinking:
                                    await manager.send_message({"type": "stream_thinking_end", "run_id": run_id}, 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", "run_id": run_id}, 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)

    # ── Direct tool fast-path: 1 fast LLM call to classify intent ────────────
    # Instead of hard-coded regex patterns, use one cheap LLM call to check if
    # the request is a simple read-only email/calendar lookup.  If the LLM
    # confirms a match, execute the tool directly — saving 4-6 LLM calls
    # (planner, researcher, curator, executor, validator, report-out).
    _execution_mode = "full"
    _local_folder_reply = _maybe_answer_local_folder_request(goal)
    if _local_folder_reply:
        await agent_event("planner", "done", msg="Local folder lookup – direct answer")
        await manager.send_message({"type": "plan", "content": ["Direct folder listing"]}, websocket)
        await agent_event("reportout", "done", msg="Local folder listing delivered")
        session["last_tool_trace"] = []
        session["steps"] = [{"step": goal, "result": _local_folder_reply, "verdict": "DONE"}]
        session["final_report"] = _local_folder_reply
        session["completed"] = True
        _record_session_exchange(session, goal, _local_folder_reply)
        _save_session(session)
        await manager.send_message({"type": "final_report", "content": _local_folder_reply, "goal": goal, "session_id": session.get("id"), "run_id": run_id}, websocket)
        manager.active_runs.pop(run_id, None)
        return

    _attached_folder_pdf_targets = _select_attached_folder_pdf_summary_targets(goal, safe_context)
    if _attached_folder_pdf_targets and not known_copilot_kind and not _goal_requests_side_effect(goal):
        _extract_tool_name = "extract_source"
        _extract_tool_norm = _normalize_tool_name(_extract_tool_name)
        if _extract_tool_norm not in blocked_tools and _extract_tool_norm not in disabled_tools and _extract_tool_norm not in approval_tools:
            _attached_pdf_files = list(_attached_folder_pdf_targets.get("files") or [])
            if run_budget["tool_calls"] >= run_budget["max_tool_calls"]:
                await _halt_run(
                    "BUDGET_TOOL_CALLS",
                    f"Run stopped: tool-call budget exceeded ({run_budget['tool_calls']} / {run_budget['max_tool_calls']}).",
                )
                return
            _remaining_tool_budget = max(0, run_budget["max_tool_calls"] - run_budget["tool_calls"])
            _attached_pdf_files = _attached_pdf_files[:_remaining_tool_budget]
            _tool_trace: list[dict[str, object]] = []
            _summaries: list[dict[str, str]] = []
            if _attached_pdf_files:
                _execution_mode = "simple"
                await manager.send_message({"type": "plan", "content": ["Extract and summarize attached folder PDFs"]}, websocket)
                await agent_event("planner", "done", msg="Attached folder PDF summary – direct answer")
                _loop = asyncio.get_event_loop()
                for step_index, target in enumerate(_attached_pdf_files):
                    file_name = str(target.get("name") or "attached pdf").strip() or "attached pdf"
                    source_path = str(target.get("full_path") or "").strip()
                    if not source_path:
                        continue
                    tool_args = {"source": source_path, "max_chars": 24000}
                    run_budget["tool_calls"] += 1
                    await manager.send_message(
                        {"type": "tool_call", "tool": _extract_tool_name, "args": tool_args, "step_index": step_index},
                        websocket,
                    )
                    _tool_result = await _loop.run_in_executor(None, _execute_tool, _extract_tool_name, tool_args)
                    _tool_envelope = build_tool_result_envelope(
                        f"{goal}\n\nFocus on {file_name}.",
                        f"{goal}\n\nFocus on {file_name}.",
                        _extract_tool_name,
                        str(_tool_result or ""),
                        str(_tool_result or ""),
                    )
                    _tool_details = dict(_tool_envelope.get("details") or {})
                    _tool_result_for_context = str(_tool_envelope.get("context_result") or "")
                    await manager.send_message(
                        {"type": "tool_result", "tool": _extract_tool_name, "result": _tool_result_ui_snippet(_extract_tool_name, _tool_result_for_context), "step_index": step_index},
                        websocket,
                    )
                    _append_workflow_context_artifact(
                        session,
                        tool_name=_extract_tool_name,
                        tool_args=tool_args,
                        tool_result=str(_tool_result or ""),
                        tool_details=_tool_details,
                        stage="direct",
                    )
                    _summary_reply = await _maybe_summarize_extracted_source_for_message(
                        f"{goal}\n\nFocus on {file_name}.",
                        str(_tool_result or ""),
                        settings=run_settings,
                        session=session,
                        preferred_model=model,
                        used_model=fast_model,
                    )
                    _fallback_summary = _tool_result_ui_snippet(_extract_tool_name, _tool_result_for_context).strip()
                    _summary_text = str(_summary_reply or _fallback_summary or "").strip()
                    if _summary_text:
                        _summaries.append({"name": file_name, "summary": _summary_text})
                    _tool_trace.append({
                        "tool_name": _extract_tool_name,
                        "tool_result": str(_tool_result or ""),
                        "tool_details": _tool_details,
                    })
                if _summaries:
                    _omitted_count = max(
                        int(_attached_folder_pdf_targets.get("omitted_count") or 0),
                        max(0, int(_attached_folder_pdf_targets.get("total_pdf_count") or 0) - len(_summaries)),
                    )
                    _attached_folder_reply = _render_attached_folder_pdf_summary_reply(
                        goal,
                        _summaries,
                        total_pdf_count=int(_attached_folder_pdf_targets.get("total_pdf_count") or len(_summaries)),
                        omitted_count=_omitted_count,
                    )
                    await agent_event("reportout", "done", msg="Attached folder PDF summary delivered")
                    session["last_tool_trace"] = _build_compacted_tool_trace(_tool_trace)
                    session["steps"] = [{"step": goal, "result": _attached_folder_reply, "verdict": "DONE"}]
                    session["final_report"] = _attached_folder_reply
                    session["completed"] = True
                    _record_session_exchange(session, goal, _attached_folder_reply)
                    _save_session(session)
                    await manager.send_message({"type": "final_report", "content": _attached_folder_reply, "goal": goal, "session_id": session.get("id"), "run_id": run_id}, websocket)
                    manager.active_runs.pop(run_id, None)
                    return

    _attached_context_target = _select_attached_context_summary_payload(goal, safe_context)
    if _attached_context_target and not known_copilot_kind and not _goal_requests_side_effect(goal):
        _execution_mode = "simple"
        _attached_names = [str(name or "").strip() for name in (_attached_context_target.get("names") or []) if str(name or "").strip()]
        _attached_payload = str(_attached_context_target.get("content") or "").strip()
        _attached_reply = ""
        _reply_candidates = _chat_followup_model_candidates(
            run_settings,
            session,
            preferred_model=model,
            used_model=fast_model,
        )
        if _reply_candidates and _attached_payload:
            _summary_prompt = (
                "Answer the user's request using only the attached file content below.\n"
                "Treat the attached content as the authoritative source for this answer.\n"
                "Be concise and factual. If the attached content is incomplete, say so.\n"
                "Do not mention workspace search, tool calls, or missing matches.\n\n"
                f"[USER REQUEST]\n{goal}\n\n"
                f"[ATTACHED FILE CONTENT]\n{_attached_payload[:18000]}\n"
            )
            try:
                _raw_reply, _ = await _call_text_model_candidates(
                    _summary_prompt,
                    _reply_candidates,
                    system=(
                        f"{_current_date_line()} "
                        "You summarize attached file content only. "
                        "Return a direct answer with no markdown boilerplate."
                    ),
                    num_ctx=16384,
                    thinking=False,
                    per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
                )
                _clean_reply = re.sub(r'<think>[\s\S]*?</think>', '', str(_raw_reply or '')).strip()
                if _clean_reply and "TOOL_CALL" not in _clean_reply and '"tool"' not in _clean_reply:
                    _attached_reply = _clean_reply
            except Exception:
                _attached_reply = ""
        if not _attached_reply:
            _excerpt = _attached_payload[:1600].strip()
            if len(_attached_payload) > 1600:
                _excerpt = _excerpt.rstrip() + "…"
            if len(_attached_names) == 1:
                _attached_reply = (
                    f"Ik heb de inhoud van {_attached_names[0]} uit de meegestuurde context gehaald.\n\n{_excerpt}"
                    if _looks_dutch_text(goal)
                    else f"I used the attached content from {_attached_names[0]}.\n\n{_excerpt}"
                )
            else:
                _attached_reply = (
                    f"Ik heb de meegestuurde bestanden samengevat op basis van hun inhoud.\n\n{_excerpt}"
                    if _looks_dutch_text(goal)
                    else f"I summarized the attached files from their provided content.\n\n{_excerpt}"
                )

        await agent_event("planner", "done", msg="Attached file summary – direct answer")
        await manager.send_message({"type": "plan", "content": ["Summarize attached file context"]}, websocket)
        await agent_event("reportout", "done", msg="Attached file summary delivered")
        session["last_tool_trace"] = []
        session["steps"] = [{"step": goal, "result": _attached_reply, "verdict": "DONE"}]
        session["final_report"] = _attached_reply
        session["completed"] = True
        _record_session_exchange(session, goal, _attached_reply)
        _save_session(session)
        await manager.send_message({"type": "final_report", "content": _attached_reply, "goal": goal, "session_id": session.get("id"), "run_id": run_id}, websocket)
        manager.active_runs.pop(run_id, None)
        return

    # ── LLM Intent Classifier ────────────────────────────────────────────
    # One cheap LLM call replaces the chain of regex gates that used to
    # decide domain, action, and execution mode.  The result drives all
    # downstream routing: email/calendar fast-paths, direct acceleration,
    # and the complexity gate.
    _avail_integrations: list[str] = []
    if use_outlook and OUTLOOK_SUPPORT:
        _avail_integrations.append("Outlook (email + calendar)")
    if _gmail_available:
        _avail_integrations.append("Gmail / Google Calendar")
    if use_internet:
        _avail_integrations.append("Web search")
    _intent_cl: dict = _default_intent_classifier_payload()
    if not known_copilot_kind:
        _cl_prompt = (
            f"User request: {goal}\n"
            f"Available integrations: {', '.join(_avail_integrations) or 'none'}\n"
            f"Current date/time: {datetime.now().strftime('%Y-%m-%d %H:%M %A')}\n\n"
            "Classify this request. Respond with ONLY a JSON object:\n"
            "{\n"
            '  "domain": "email" | "calendar" | "search" | "code" | "general",\n'
            '  "subdomain": "filesystem" | "workspace" | "web" | "browser" | "mailbox" | "schedule" | "workspace" | "chat",\n'
            '  "goal_type": "short_snake_case_goal",\n'
            '  "action": "read" | "create" | "update" | "delete" | "send" | "none",\n'
            '  "confidence": 0.0-1.0,\n'
            '  "candidate_steps": ["short_step_id", "short_step_id"],\n'
            '  "parameters": {\n'
            '    "search_query": "optional search terms",\n'
            '    "target_summary": "which item to act on (event title, email subject, ordinal like nr 1)",\n'
            '    "new_summary": "new title/subject if renaming",\n'
            '    "new_start": "ISO datetime if time change requested",\n'
            '    "new_end": "ISO datetime if end time given or inferred",\n'
            '    "location": "new location if given",\n'
            '    "recipient": "email recipient if sending",\n'
            '    "subject": "email subject if sending",\n'
            '    "provider_scope": "default | outlook_only | gmail_only"\n'
            "  }\n"
            "}\n\n"
            "Rules:\n"
            "- domain='email' for inbox/search/send/reply/forward/delete mail.\n"
            "- domain='calendar' for agenda/appointments/events/meetings (read, create, update, delete).\n"
            "- domain='search' for web search, workspace search, file search.\n"
            "- domain='code' for code-related tasks (fix, build, implement, refactor).\n"
            "- domain='general' for everything else (chat, knowledge questions, weather, etc.).\n"
            "- Prefer subdomain='filesystem' for local folders/files/downloads/images, 'workspace' for repo/code/project search, 'web' for search-engine lookup, and 'browser' for explicit page navigation/inspection.\n"
            "- goal_type should describe the user's actual outcome, e.g. inspect_local_files, search_workspace, lookup_email, create_calendar_event.\n"
            "- candidate_steps should be 1-4 short planner step ids that describe the likely execution path.\n"
            "- action='read' for lookups/searches. action='none' for general chat.\n"
            "- action='update' for move/reschedule/change/edit requests.\n"
            "- action='send' for sending emails/messages.\n"
            "- Translate any relative dates to ISO using the current date.\n"
            "- Only include parameters that are explicitly mentioned or clearly implied.\n"
            "- Omit empty/null parameter fields.\n"
        )
        try:
            _cl_raw = await call_fast(_cl_prompt, system="You are a strict intent classifier. Output ONLY valid JSON.", num_ctx=2048)
            if _cl_raw and not _is_llm_error(_cl_raw):
                _cl_parsed = _parse_model_json(_cl_raw, expect="object")
                if isinstance(_cl_parsed, dict) and _cl_parsed.get("domain"):
                    _intent_cl = _normalize_intent_classifier_payload(_cl_parsed, goal)
        except Exception:
            pass  # On failure, _intent_cl stays at defaults — regex fallback below

    # Fallback: when LLM classifier failed or returned low-confidence defaults,
    # use regex heuristics as hint providers so routing still works.
    if _intent_cl["confidence"] < 0.1 and not known_copilot_kind:
        if _goal_requests_side_effect(goal):
            if _CALENDAR_MARKERS_RE.search(goal):
                _intent_cl["domain"] = "calendar"
                _intent_cl["action"] = _calendar_action_type(goal) or "update"
            elif _email_action_type(goal):
                _intent_cl["domain"] = "email"
                _intent_cl["action"] = _email_action_type(goal)
            else:
                _intent_cl["action"] = "update"
            _intent_cl["confidence"] = 0.3
        _intent_cl = _normalize_intent_classifier_payload(_intent_cl, goal)

    _stored_followup_task = session.get("task_state") if isinstance(session.get("task_state"), dict) else None
    if _should_preserve_live_lookup_followup(
        _stored_followup_task,
        goal,
        domain_hint=_intent_cl.get("domain", ""),
        domain_confidence=float(_intent_cl.get("confidence") or 0.0),
    ):
        _preserved_domain = _lookup_domain_for_task_state(_stored_followup_task)
        if _preserved_domain:
            _intent_cl["domain"] = _preserved_domain
            _intent_cl["action"] = "read"
            _intent_cl["confidence"] = max(float(_intent_cl.get("confidence") or 0.0), 0.95)
            _intent_cl = _normalize_intent_classifier_payload(_intent_cl, goal)

    _cl_is_action = _intent_cl["action"] not in ("read", "none")
    _cl_is_calendar = _intent_cl["domain"] == "calendar"
    _cl_is_email = _intent_cl["domain"] == "email"
    _defer_action_fast_paths_for_multi_goal = _should_defer_action_fast_paths_for_multi_goal(goal)

    _forced_extract_request = detect_extract_source_request(goal)
    if _forced_extract_request and not _cl_is_action:
        _execution_mode = "simple"
        await agent_event("planner", "done", msg="Source summary – single-step execution")

    _route_task_state = session.get("task_state") if isinstance(session.get("task_state"), dict) else None
    _route_email_action_request = bool(_cl_is_email and _cl_is_action)
    _route_calendar_action_request = bool(_cl_is_calendar and _cl_is_action)
    # Only pass the stored task_state to the protocol router when the LLM
    # classifier agrees the current goal is in the same domain.  When the user
    # switches to a general question (domain='general'), the stale email/calendar
    # task_state must not silently hijack the route to email_read/calendar_read.
    _cl_domain = _intent_cl.get("domain", "general")
    _route_task_state_for_protocol = scope_task_state_to_domain(_route_task_state, _cl_domain)
    _protocol_route = resolve_protocol_route(
        goal,
        task_state=_route_task_state_for_protocol,
        email_action_request=_route_email_action_request,
        calendar_action_request=_route_calendar_action_request,
        forced_extract=bool(_forced_extract_request),
    )
    session["intent_classifier"] = dict(_intent_cl)
    session["protocol_route"] = _protocol_route.to_dict()
    memory: list[dict] = []

    async def _execute_validated_graphs(
        graphs: list,
        *,
        allow_side_effects: bool,
        step_index_base: int,
    ) -> list[tuple[str, str]]:
        results: list[tuple[str, str]] = []
        next_step_index = step_index_base
        for graph in graphs:
            _store_task_graph_with_validation(
                session,
                graph,
                blocked_tools=blocked_tools,
                disabled_tools=disabled_tools,
                approval_tools=approval_tools,
                max_tasks=run_budget["max_tool_calls"],
                max_depth=run_budget["max_steps"],
                allow_side_effects=allow_side_effects,
            )
            graph_results = await _execute_task_graph(graph, websocket=websocket, run_id=run_id, step_index_base=next_step_index)
            results.extend(graph_results)
            next_step_index += len(graph.tasks)
        return results

    async def _finalize_action_clarification(
        content: str,
        *,
        trace: list[dict] | None = None,
        record_store: str = "",
    ) -> bool:
        compacted_trace = list(trace or [])
        if compacted_trace:
            session["last_tool_trace"] = compacted_trace
            for _item in compacted_trace:
                if isinstance(_item, dict):
                    _record_hypothesis_evidence(
                        -1,
                        str(_item.get("tool_name") or _item.get("tool") or ""),
                        str(_item.get("tool_result") or _item.get("result") or ""),
                        dict(_item.get("tool_details") or {}),
                    )
                    _append_workflow_context_artifact(
                        session,
                        tool_name=str(_item.get("tool_name") or _item.get("tool") or ""),
                        tool_args={},
                        tool_result=str(_item.get("tool_result") or _item.get("result") or ""),
                        tool_details=dict(_item.get("tool_details") or {}),
                        stage="finalize",
                    )
            if record_store == "email":
                _store_session_email_records(session, compacted_trace)
            elif record_store == "calendar":
                _store_session_calendar_records(session, compacted_trace)
        session["steps"] = [{"step": goal, "result": content, "verdict": "DONE"}]
        session["final_report"] = content
        session["completed"] = True
        _set_run_kpis_snapshot(completed=True)
        _persist_learning_feedback()
        _record_session_exchange(session, goal, content)
        _save_session(session)
        await manager.send_message({"type": "final_report", "goal": goal, "content": content}, websocket)
        manager.active_runs.pop(run_id, None)
        return True

    async def _finalize_action_result(
        final_report: str,
        trace: list[dict],
        *,
        success: bool = True,
        record_store: str = "",
    ) -> bool:
        session["last_tool_trace"] = trace
        for _item in trace:
            if isinstance(_item, dict):
                _record_hypothesis_evidence(
                    -1,
                    str(_item.get("tool_name") or _item.get("tool") or ""),
                    str(_item.get("tool_result") or _item.get("result") or ""),
                    dict(_item.get("tool_details") or {}),
                )
                _append_workflow_context_artifact(
                    session,
                    tool_name=str(_item.get("tool_name") or _item.get("tool") or ""),
                    tool_args={},
                    tool_result=str(_item.get("tool_result") or _item.get("result") or ""),
                    tool_details=dict(_item.get("tool_details") or {}),
                    stage="finalize",
                )
        if record_store == "email":
            _store_session_email_records(session, trace)
        elif record_store == "calendar":
            _store_session_calendar_records(session, trace)
        step_meta = trace[-1] if trace else {"tool_name": "", "tool_result": "", "tool_details": {}}
        session["steps"] = [{
            "step": goal,
            "result": final_report,
            "verdict": "DONE",
            "tool_name": step_meta.get("tool_name", ""),
            "tool_result": step_meta.get("tool_result", ""),
            "tool_details": dict(step_meta.get("tool_details") or {}),
            "deterministic_result": True,
        }]
        session["final_report"] = final_report
        session["completed"] = True
        _set_run_kpis_snapshot(completed=success)
        _persist_learning_feedback()
        _record_session_exchange(session, goal, final_report)
        _store_session_continuity_summary(session, goal, session.get("task_state"), trace, final_report)
        _save_session(session)
        try:
            maybe_capture_session_wiki_draft(dict(session))
        except Exception as exc:
            print(f"[wiki] draft capture failed: {exc}")
        if success:
            await manager.send_message({"type": "success", "content": "Done!"}, websocket)
        await manager.send_message({"type": "final_report", "goal": goal, "content": final_report}, websocket)
        manager.active_runs.pop(run_id, None)
        return True

    async def _maybe_execute_email_read_followup_fast_path() -> bool:
        if _goal_requests_side_effect(goal):
            return False

        read_call = _build_email_followup_read_call(
            session,
            goal,
            use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
            gmail_available=bool(_gmail_available),
        )
        if not read_call:
            semantic_candidates = [candidate for candidate in (model, fast_model) if str(candidate or "").strip()]
            semantic_email_followup = await _classify_selected_email_followup_decision(
                session,
                goal,
                semantic_candidates,
            )
            if (
                semantic_email_followup.get("targets_selected_email")
                and semantic_email_followup.get("needs_full_email_read")
                and float(semantic_email_followup.get("confidence") or 0.0) >= 0.6
            ):
                selected_record = _default_selected_email_record(session)
                if selected_record:
                    read_call = _build_exact_email_read_call(
                        session,
                        selected_record.get("message_id"),
                        selected_record.get("provider"),
                        use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
                        gmail_available=bool(_gmail_available),
                        user_message=goal,
                        force_include_attachments=bool(semantic_email_followup.get("needs_attachment_analysis")),
                    )
        if not read_call:
            return False

        tool_name, tool_args, selected_record = read_call
        step_index = 0
        await manager.send_message(
            {"type": "tool_call", "tool": tool_name, "args": tool_args, "step_index": step_index},
            websocket,
        )
        loop = asyncio.get_event_loop()
        tool_result = await loop.run_in_executor(None, _execute_tool, tool_name, tool_args)
        await manager.send_message(
            {"type": "tool_result", "tool": tool_name, "result": _tool_result_ui_snippet(tool_name, str(tool_result)), "step_index": step_index},
            websocket,
        )

        _update_selected_email_record_from_read_result(session, selected_record, str(tool_result or ""))
        summary_reply = await _maybe_summarize_full_email_read_result(
            goal,
            tool_name,
            str(tool_result or ""),
            settings=run_settings,
            session=session,
            preferred_model=model,
            used_model=fast_model,
        )
        tool_envelope = build_tool_result_envelope(
            goal,
            goal,
            tool_name,
            str(tool_result or ""),
            summary_reply or str(tool_result or ""),
        )
        final_report = (
            str(tool_envelope.get("rendered_result") or "").strip()
            if bool(tool_envelope.get("was_composed"))
            else str(tool_result or "").strip()
        )
        if not final_report:
            final_report = "I opened the selected email." if not _looks_dutch_text(goal) else "Ik heb de geselecteerde e-mail geopend."
        if not _EMAIL_OFFER_ALREADY_RE.search(final_report):
            final_report = _append_email_followup_offer(final_report, session, dutch_hint=_looks_dutch_text(goal))

        trace = _build_compacted_tool_trace([{
            "tool_name": tool_name,
            "tool_result": str(tool_result or ""),
            "tool_details": {
                "kind": "email_read",
                "provider": str(selected_record.get("provider") or "").strip(),
                "message_id": str(selected_record.get("message_id") or "").strip(),
            },
        }])
        success = not str(tool_result or "").lower().startswith((
            "outlook get-email error:",
            "gmail get-email error:",
            "email not found",
            "no message id provided",
        ))
        return await _finalize_action_result(final_report, trace, success=success, record_store="")

    async def _maybe_summarize_extracted_source_result(
        user_message: str,
        tool_result: str,
        *,
        settings: dict,
        session: dict | None,
        preferred_model: str = "",
        used_model: str = "",
    ) -> str:
        if not str(tool_result or "").strip():
            return ""

        session_dict = session if isinstance(session, dict) else {}
        model_candidates = _chat_followup_model_candidates(
            settings,
            session_dict,
            preferred_model=preferred_model,
            used_model=used_model,
        )
        if not model_candidates:
            return ""

        envelope = build_tool_result_envelope(
            user_message,
            user_message,
            "extract_source",
            str(tool_result or ""),
            str(tool_result or ""),
        )
        source_content = str(envelope.get("context_result") or "")
        prompt = (
            "Summarize the extracted source below to answer the user's request.\n"
            "Focus on the document's substance, be concise and factual, and mention limitations only when they materially affect the answer.\n"
            "Do not mention extraction markers, console errors, or technical formatting.\n\n"
            f"[USER REQUEST]\n{user_message}\n\n"
            f"[EXTRACTED SOURCE CONTENT]\n{source_content[:18000]}\n"
        )
        try:
            raw_reply, _used_model = await _call_text_model_candidates(
                prompt,
                model_candidates,
                system=_compose_agent_system(),
                num_ctx=16384,
                thinking=False,
                per_model_timeout=_CHAT_FOLLOWUP_PER_MODEL_TIMEOUT_SECONDS,
            )
            if _is_llm_error(raw_reply):
                return ""
            reply = re.sub(r'<think>[\s\S]*?</think>', '', str(raw_reply or '')).strip()
            if not reply or "TOOL_CALL" in reply or '"tool"' in reply:
                return ""
            return reply
        except Exception:
            return ""

    async def _maybe_execute_extract_source_fast_path() -> bool:
        if not _forced_extract_request:
            return False
        if _goal_requests_side_effect(goal):
            return False
        if known_copilot_kind:
            return False

        tool_name = "extract_source"
        tool_args = {
            "source": str(_forced_extract_request.get("source") or "").strip(),
            "max_chars": int(_forced_extract_request.get("max_chars", 24000) or 24000),
        }
        if not tool_args["source"]:
            return False

        tool_norm = _normalize_tool_name(tool_name)
        if tool_norm in blocked_tools or tool_norm in disabled_tools or tool_norm in approval_tools:
            return False
        if run_budget["tool_calls"] >= run_budget["max_tool_calls"]:
            await _halt_run(
                "BUDGET_TOOL_CALLS",
                f"Run stopped: tool-call budget exceeded ({run_budget['tool_calls']} / {run_budget['max_tool_calls']}).",
            )
            return True

        step_index = 0
        run_budget["tool_calls"] += 1
        await manager.send_message(
            {"type": "tool_call", "tool": tool_name, "args": tool_args, "step_index": step_index},
            websocket,
        )
        loop = asyncio.get_event_loop()
        tool_result = await loop.run_in_executor(None, _execute_tool, tool_name, tool_args)
        tool_envelope = build_tool_result_envelope(
            goal,
            goal,
            tool_name,
            str(tool_result or ""),
            str(tool_result or ""),
        )
        tool_details = dict(tool_envelope.get("details") or {})
        tool_result_for_context = str(tool_envelope.get("context_result") or "")
        await manager.send_message(
            {"type": "tool_result", "tool": tool_name, "result": _tool_result_ui_snippet(tool_name, tool_result_for_context), "step_index": step_index},
            websocket,
        )
        _append_workflow_context_artifact(
            session,
            tool_name=tool_name,
            tool_args=tool_args,
            tool_result=str(tool_result or ""),
            tool_details=tool_details,
            stage="direct",
        )

        summary_reply = await _maybe_summarize_extracted_source_result(
            goal,
            str(tool_result or ""),
            settings=run_settings,
            session=session,
            preferred_model=model,
            used_model=fast_model,
        )
        fallback_summary = _tool_result_ui_snippet(tool_name, tool_result_for_context).strip()
        composed_envelope = build_tool_result_envelope(
            goal,
            goal,
            tool_name,
            str(tool_result or ""),
            summary_reply or fallback_summary,
            tool_details,
        )
        final_report = str(composed_envelope.get("rendered_result") or "").strip()
        if not final_report:
            final_report = (
                "I extracted and summarized the requested source."
                if not _looks_dutch_text(goal)
                else "Ik heb de gevraagde bron geëxtraheerd en samengevat."
            )

        trace = _build_compacted_tool_trace([{
            "tool_name": tool_name,
            "tool_result": str(tool_result or ""),
            "tool_details": tool_details,
        }])
        result_lower = str(tool_result or "").strip().lower()
        success = not result_lower.startswith((
            "extract_source error:",
            "could not extract content",
            "source not found",
            "file not found",
            "could not read source",
        ))
        return await _finalize_action_result(final_report, trace, success=success, record_store="")

    async def _maybe_execute_email_attachment_summary_lookup_fast_path() -> bool:
        _outlook_enabled_for_run = bool((use_outlook or _settings_outlook_enabled(run_settings)) and OUTLOOK_SUPPORT)
        if _goal_requests_side_effect(goal):
            return False
        if known_copilot_kind:
            return False
        _explicit_attachment_summary_request = _is_explicit_email_attachment_summary_request(goal)
        if not _explicit_attachment_summary_request and not _email_attachment_focus_requested(goal):
            return False
        if not _explicit_attachment_summary_request and not _goal_requests_personal_summary(goal, "email"):
            return False
        if not (_outlook_enabled_for_run or _gmail_available):
            return False

        ensure_session_task_state(session, goal)
        task_state = session.get("task_state") if isinstance(session.get("task_state"), dict) else {}
        if str(task_state.get("task_type") or "") != "email_lookup":
            lowered_goal = str(goal or "").strip().lower()
            if not any(token in lowered_goal for token in ("email", "e-mail", "mail", "outlook", "gmail", "inbox", "postvak")):
                return False
            provider_scope = "default"
            if "outlook" in lowered_goal and _outlook_enabled_for_run:
                provider_scope = "outlook_only"
            elif "gmail" in lowered_goal and _gmail_available:
                provider_scope = "gmail_only"
            elif _outlook_enabled_for_run and not _gmail_available:
                provider_scope = "outlook_only"
            elif _gmail_available and not _outlook_enabled_for_run:
                provider_scope = "gmail_only"
            task_state = {
                "task_type": "email_lookup",
                "provider_scope": provider_scope,
                "folder_scope": "Inbox",
                "target_entity": {
                    "kind": "subject",
                    "text": _extract_email_subject(goal),
                },
                "target_reference": {},
            }

        target_entity = dict(task_state.get("target_entity") or {})
        target_reference = dict(task_state.get("target_reference") or {})
        raw_query = str(
            _extract_email_subject(goal)
            or target_entity.get("text")
            or target_reference.get("query")
            or ""
        ).strip()
        raw_query = re.split(
            r"\b(?:en|and)\b\s+(?:geef|give|vat|summarize|samenvat|analyseer|analyze|haal|extract|lees|read)\b",
            raw_query,
            maxsplit=1,
            flags=re.IGNORECASE,
        )[0].strip(" \t\r\n.,;:!?\"'")
        raw_query = re.split(
            r"(?:[.!?]\s*|\s+)(?:lees|read|open|toon|show|haal|extract|analyseer|analyze|vat|summarize|samenvat|geef|give)\b",
            raw_query,
            maxsplit=1,
            flags=re.IGNORECASE,
        )[0].strip(" \t\r\n.,;:!?\"'")
        query = _normalize_email_search_query(raw_query)
        if not query:
            return False

        provider_scope = str(task_state.get("provider_scope") or "default").strip().lower() or "default"
        folder_scope = str(task_state.get("folder_scope") or "Inbox").strip() or "Inbox"
        reference_kind = str(target_reference.get("kind") or "").strip()
        reference_ordinal = target_reference.get("ordinal")
        reference_temporal_hint = str(target_reference.get("temporal_hint") or "").strip()
        entity_kind = str(target_entity.get("kind") or "").strip().lower()

        search_calls: list[tuple[str, dict]] = []
        if provider_scope in {"outlook_only", "default", "cross_provider"} and _outlook_enabled_for_run:
            search_calls.append((
                "outlook_search_emails",
                {"query": query, "count": 25, "folder": folder_scope},
            ))
        if provider_scope in {"gmail_only", "default", "cross_provider"} and _gmail_available:
            gmail_query = f"subject:{query}" if entity_kind == "subject" else query
            search_calls.append((
                "gmail_search_emails",
                {"query": gmail_query, "count": 25, "folder": folder_scope},
            ))
        if not search_calls:
            return False

        loop = asyncio.get_event_loop()
        trace_items: list[dict] = []
        matched_record: dict | None = None
        all_records: list[dict] = []

        def _pick_match(records: list[dict]) -> dict | None:
            if not records:
                return None
            if entity_kind == "subject":
                normalized_query = _normalize_match_text(query)
                for record in records:
                    if _normalize_match_text(str(record.get("subject") or "")) == normalized_query:
                        return record
            matches = list(_select_email_records(
                records,
                goal,
                query,
                1,
                reference_kind=reference_kind,
                reference_ordinal=reference_ordinal,
                reference_temporal_hint=reference_temporal_hint,
            ))
            return matches[0] if matches else None

        for step_index, (tool_name, tool_args) in enumerate(search_calls):
            await manager.send_message(
                {"type": "tool_call", "tool": tool_name, "args": tool_args, "step_index": step_index},
                websocket,
            )
            tool_result = await loop.run_in_executor(None, _execute_tool, tool_name, tool_args)
            await manager.send_message(
                {"type": "tool_result", "tool": tool_name, "result": _tool_result_ui_snippet(tool_name, str(tool_result)), "step_index": step_index},
                websocket,
            )

            tool_details = build_tool_result_details(tool_name, str(tool_result or ""))
            trace_items.append({
                "tool_name": tool_name,
                "tool_result": str(tool_result or ""),
                "tool_details": tool_details,
            })

            records = extract_email_lookup_records(str(tool_result or ""))
            if not records:
                continue
            provider_hint = "gmail" if tool_name.startswith("gmail_") else "outlook"
            for record in records:
                record.setdefault("provider", provider_hint)
            all_records.extend(records)
            _merge_session_email_records(session, records)
            matched_record = _pick_match(records) or matched_record or _pick_match(all_records)
            if matched_record and str(matched_record.get("message_id") or "").strip():
                break

        if not matched_record:
            matched_record = _pick_match(all_records)
        if not matched_record:
            return False

        read_call = _build_exact_email_read_call(
            session,
            matched_record.get("message_id"),
            matched_record.get("provider"),
            use_outlook=_outlook_enabled_for_run,
            gmail_available=bool(_gmail_available),
            user_message=goal,
            force_include_attachments=True,
        )
        if not read_call:
            return False

        read_tool_name, read_tool_args, selected_record = read_call
        read_step_index = len(trace_items)
        await manager.send_message(
            {"type": "tool_call", "tool": read_tool_name, "args": read_tool_args, "step_index": read_step_index},
            websocket,
        )
        read_result = await loop.run_in_executor(None, _execute_tool, read_tool_name, read_tool_args)
        await manager.send_message(
            {"type": "tool_result", "tool": read_tool_name, "result": _tool_result_ui_snippet(read_tool_name, str(read_result)), "step_index": read_step_index},
            websocket,
        )

        _update_selected_email_record_from_read_result(session, selected_record, str(read_result or ""))
        read_details = build_tool_result_details(read_tool_name, str(read_result or ""))
        trace_items.append({
            "tool_name": read_tool_name,
            "tool_result": str(read_result or ""),
            "tool_details": read_details,
        })

        summary_reply = await _maybe_summarize_full_email_read_result(
            goal,
            read_tool_name,
            str(read_result or ""),
            settings=run_settings,
            session=session,
            preferred_model=model,
            used_model=fast_model,
        )
        read_envelope = build_tool_result_envelope(
            goal,
            goal,
            read_tool_name,
            str(read_result or ""),
            summary_reply or str(read_result or ""),
            read_details,
        )
        final_report = (
            str(read_envelope.get("rendered_result") or "").strip()
            if bool(read_envelope.get("was_composed"))
            else str(summary_reply or read_result or "").strip()
        )
        if not final_report:
            final_report = (
                "I opened the selected email and summarized the attachment."
                if not _looks_dutch_text(goal)
                else "Ik heb de geselecteerde e-mail geopend en de bijlage samengevat."
            )
        if not _EMAIL_OFFER_ALREADY_RE.search(final_report):
            final_report = _append_email_followup_offer(final_report, session, dutch_hint=_looks_dutch_text(goal))

        compacted_trace = _build_compacted_tool_trace(trace_items)
        success = not str(read_result or "").lower().startswith((
            "outlook get-email error:",
            "gmail get-email error:",
            "email not found",
            "no message id provided",
        ))
        return await _finalize_action_result(final_report, compacted_trace, success=success, record_store="email")

    async def _maybe_execute_email_action_fast_path() -> bool:
        if not (_cl_is_email and _cl_is_action):
            return False
        if known_copilot_kind:
            return False
        stored_action = ""
        if isinstance(session.get("task_state"), dict) and str(session["task_state"].get("task_type") or "") == "email_action":
            stored_action = str(session["task_state"].get("operation") or session["task_state"].get("action") or "").strip().lower()
        detected_action = detect_email_action(goal)
        action = str(_intent_cl.get("action") or "").strip().lower() or detected_action or stored_action
        if action not in {"send", "delete", "update"}:
            return False
        # Safety gate: LLM classification alone is insufficient for destructive
        # email actions.  Require explicit email keywords in the user text OR a
        # confirmed email task_state from the prior turn.
        if not detected_action and not stored_action:
            return False
        if not (use_outlook and OUTLOOK_SUPPORT) and not _gmail_available:
            return False

        dutch = _looks_dutch_text(goal)
        loop = asyncio.get_event_loop()
        intent = await _extract_email_intent_with_fallback(
            goal,
            model_candidates=[
                fast_model,
                model,
                str(run_settings.get("main_model") or ""),
                str(run_settings.get("fallback_model") or ""),
            ],
            prior_task_state=session.get("task_state") if isinstance(session.get("task_state"), dict) else None,
        )
        if intent.action in {"send", "update", "delete"}:
            action = intent.action

        if action == "send":
            explicit_provider = _detect_email_action_provider(
                goal,
                use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
                gmail_available=bool(_gmail_available),
            )
            forward_request = _is_email_forward_request(goal)
            send_plan = plan_email_action_execution(
                goal,
                route=_protocol_route,
                action="send",
                intent=intent,
                session_email_records=_session_email_records(session),
                use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
                gmail_available=bool(_gmail_available),
                settings=run_settings,
                dynamic_tools=_dynamic_tools,
                mcp_cache=_mcp_get_cache(),
                explicit_provider=explicit_provider,
                is_forward_request=forward_request,
                select_email_records=_select_email_records,
                resolve_forward_send_provider=_resolve_forward_send_provider,
                email_forward_subject=_email_forward_subject,
                build_forward_email_body=_build_forward_email_body,
                dutch=dutch,
            )
            if not send_plan.write_graphs:
                clarification = send_plan.clarification
                if not clarification and intent.missing_fields:
                    clarification = _email_intent_missing_clarification(intent, dutch=dutch)
                if not clarification:
                    return False
                return await _finalize_action_clarification(clarification)

            write_graph = send_plan.write_graphs[0]
            _send_task = write_graph.tasks[-1]
            _send_tool_name = _normalize_tool_name(_send_task.tool_name)
            _send_tool_args = dict(_send_task.args)
            _send_requires_approval = require_approval or _send_tool_name in approval_tools
            if _send_requires_approval and _send_tool_name not in auto_approved_tools:
                _approved, _always = await _request_tool_approval(0, _send_tool_name, _send_tool_args)
                if not _always and _approved:
                    # one-off approval — do not cache
                    pass
                elif _always:
                    auto_approved_tools.add(_send_tool_name)
                if not _approved:
                    _hitl_msg = (
                        "E-mail niet verzonden — niet goedgekeurd door gebruiker."
                        if dutch else
                        "Email not sent — not approved by user."
                    )
                    return await _finalize_action_result(_hitl_msg, [], success=False, record_store="")
            action_results = await _execute_validated_graphs([write_graph], allow_side_effects=True, step_index_base=0)
            if not action_results:
                return False
            tool_name, tool_result = action_results[-1]
            tool_args = dict(write_graph.tasks[-1].args)
            provider = str(send_plan.metadata.get("provider") or "")
            send_success = str(tool_result or "").strip().startswith(("Email sent", "Email sent successfully"))
            tool_details = dict(send_plan.tool_details[-1] if send_plan.tool_details else {})
            tool_details["status"] = "sent" if send_success else "failed"
            trace = _build_compacted_tool_trace([{
                "tool_name": tool_name,
                "tool_result": tool_result,
                "tool_details": tool_details,
            }])
            final_report = _email_send_action_report(provider, tool_args, tool_result, dutch=dutch)
            return await _finalize_action_result(final_report, trace, success=send_success, record_store="email")

        explicit_provider = _detect_email_action_provider(
            goal,
            use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
            gmail_available=bool(_gmail_available),
        )
        folder = _detect_email_folder(goal, {**(run_settings.get("outlook_folder_profiles") or {}), **(run_settings.get("google_folder_profiles") or {})})
        if action == "update" and folder == "Inbox":
            folder = "Drafts"
        requested_count = _email_requested_count(goal, default=1)
        selection_query = str(intent.selection_query or _extract_email_selection_query(goal, action)).strip()
        update_directives = email_intent_to_update_payload(intent) if action == "update" else {}
        if intent.provider and intent.provider in {"gmail", "outlook"}:
            explicit_provider = intent.provider
        return await _execute_email_record_action_plan(
            goal=goal,
            action=action,
            intent=intent,
            route=_protocol_route,
            session=session,
            run_settings=run_settings,
            dynamic_tools=_dynamic_tools,
            mcp_cache=_mcp_get_cache(),
            use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
            gmail_available=bool(_gmail_available),
            explicit_provider=explicit_provider,
            folder=folder,
            requested_count=requested_count,
            selection_query=selection_query,
            update_directives=update_directives,
            dutch=dutch,
            blocked_tools=blocked_tools,
            disabled_tools=disabled_tools,
            approval_tools=approval_tools,
            run_budget=run_budget,
            websocket=websocket,
            run_id=run_id,
            store_task_graph_with_validation=_store_task_graph_with_validation,
            execute_task_graph=_execute_task_graph,
            execute_validated_graphs=_execute_validated_graphs,
            build_compacted_tool_trace=_build_compacted_tool_trace,
            finalize_action_clarification=_finalize_action_clarification,
            finalize_action_result=_finalize_action_result,
        )

    if await _maybe_execute_extract_source_fast_path():
        return
    if await _maybe_execute_email_attachment_summary_lookup_fast_path():
        return
    if await _maybe_execute_email_read_followup_fast_path():
        return
    if not _defer_action_fast_paths_for_multi_goal and await _maybe_execute_email_action_fast_path():
        return

    async def _maybe_execute_calendar_action_fast_path() -> bool:
        if not (_cl_is_calendar and _cl_is_action):
            return False
        if not (use_outlook and OUTLOOK_SUPPORT) and not _gmail_available:
            return False

        action = _intent_cl.get("action", "none")
        # Map classifier verbs to the action verbs expected downstream
        _cal_action_map = {"update": "update", "create": "create", "delete": "delete", "send": "create"}
        action = _cal_action_map.get(action, "")
        if action not in {"create", "update", "delete"}:
            return False

        dutch = _looks_dutch_text(goal)
        provider_scope = _detect_calendar_provider_scope(goal, use_outlook=bool(use_outlook and OUTLOOK_SUPPORT), gmail_available=bool(_gmail_available))
        providers: list[str] = []
        if provider_scope == "outlook_only":
            providers = ["outlook"] if use_outlook and OUTLOOK_SUPPORT else []
        elif provider_scope == "gmail_only":
            providers = ["gmail"] if _gmail_available else []
        elif provider_scope == "cross_provider":
            if use_outlook and OUTLOOK_SUPPORT:
                providers.append("outlook")
            if _gmail_available:
                providers.append("gmail")
        else:
            if use_outlook and OUTLOOK_SUPPORT:
                providers = ["outlook"]
            elif _gmail_available:
                providers = ["gmail"]
        if not providers:
            return False

        reference = datetime.now()
        read_results: list[tuple[str, str]] = []
        action_results: list[tuple[str, str]] = []
        intent = await _extract_calendar_intent_with_fallback(
            goal,
            reference,
            model_candidates=[
                fast_model,
                model,
                str(run_settings.get("main_model") or ""),
                str(run_settings.get("fallback_model") or ""),
            ],
            classifier_params=_intent_cl.get("parameters"),
            classifier_action=action,
        )
        current_task_state = session.get("task_state") if isinstance(session.get("task_state"), dict) else None
        if str((current_task_state or {}).get("task_type") or "") == "calendar_action":
            current_frame = build_intent_frame(current_task_state)
            if current_frame is not None and current_frame.domain == "calendar":
                intent = merge_appointment_intents(intent, appointment_intent_from_frame(current_frame))
        if intent.action in {"create", "update", "delete"}:
            action = intent.action
        days_ahead = max(14, _detect_calendar_days_ahead(goal))
        requested_count = _calendar_requested_count(goal, default=1)
        selection_query = str(intent.selection_query or _extract_calendar_selection_query(goal, action)).strip()
        directives = appointment_intent_to_update_directives(intent) if action == "update" else {"selection_query": selection_query}
        return await _execute_calendar_action_plan(
            goal=goal,
            action=action,
            intent=intent,
            providers=providers,
            route=_protocol_route,
            session=session,
            run_settings=run_settings,
            dynamic_tools=_dynamic_tools,
            mcp_cache=_mcp_get_cache(),
            use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
            gmail_available=bool(_gmail_available),
            requested_count=requested_count,
            days_ahead=days_ahead,
            selection_query=selection_query,
            directives=directives,
            dutch=dutch,
            blocked_tools=blocked_tools,
            disabled_tools=disabled_tools,
            approval_tools=approval_tools,
            run_budget=run_budget,
            websocket=websocket,
            run_id=run_id,
            store_task_graph_with_validation=_store_task_graph_with_validation,
            execute_task_graph=_execute_task_graph,
            execute_validated_graphs=_execute_validated_graphs,
            build_compacted_tool_trace=_build_compacted_tool_trace,
            finalize_action_clarification=_finalize_action_clarification,
            finalize_action_result=_finalize_action_result,
        )

    if not _defer_action_fast_paths_for_multi_goal and await _maybe_execute_calendar_action_fast_path():
        return

    _outlook_on_early = use_outlook and OUTLOOK_SUPPORT
    # Multi-source guard: if the goal references BOTH email AND calendar,
    # skip direct acceleration — it requires cross-referencing multiple data
    # sources and the orchestrator handles that properly.
    _goal_mentions_email = bool(re.search(
        r"\b(email|emails|e-mail|mail|inbox|postvak|berichten|ontvangen|verzonden)\b",
        goal, re.IGNORECASE,
    ))
    _goal_mentions_calendar = bool(re.search(
        r"\b(agenda|calendar|afspraak|afspraken|events?|meetings?|vergadering)\b",
        goal, re.IGNORECASE,
    ))
    _multi_source_goal = _goal_mentions_email and _goal_mentions_calendar
    if _execution_mode != "simple" and (_outlook_on_early or _gmail_available) and not _cl_is_action and not _multi_source_goal and _protocol_route.protocol != "code_change":
        _avail_sources = []
        if _outlook_on_early:
            _avail_sources.append("Outlook")
        if _gmail_available:
            _avail_sources.append("Gmail/Google Calendar")
        # Parse the classifier output
        _stored_task_state = scope_task_state_to_domain(
            session.get("task_state") if isinstance(session.get("task_state"), dict) else None,
            _protocol_route.domain,
        )
        _direct_plan = plan_direct_execution(
            goal,
            route=_protocol_route,
            stored_task_state=_stored_task_state,
            is_action=False,
            use_outlook=_outlook_on_early,
            gmail_available=_gmail_available,
            settings=run_settings,
            dynamic_tools=_dynamic_tools,
            mcp_cache=_mcp_get_cache(),
        )
        _direct_goal = _direct_plan.goal
        _direct_display_goal = _direct_plan.display_goal
        _direct_intent = _direct_plan.intent
        _direct_calls = _direct_plan.calls
        _classify_prompt = (
            f"User request: {goal}\n\n"
            f"Today's date: {datetime.now().strftime('%Y-%m-%d')} ({datetime.now().strftime('%A')})\n"
            f"Available sources: {', '.join(_avail_sources)}\n\n"
            "Is this a simple READ-ONLY lookup request that can be handled with a direct tool call? "
            "Write operations (send, reply, forward, create, delete, move, update, change, verplaats, wijzig, draft, compose) are NOT read-only.\n\n"
            "If YES, respond with ONLY this IntentFrame JSON object (no markdown, no explanation):\n"
            "{\n"
            '  "domain": "email|calendar|search",\n'
            '  "operation": "find_received|find_sent|calendar_read|search",\n'
            '  "provider_scope": "default|outlook_only|gmail_only|cross_provider",\n'
            '  "source_scope": "default|workspace|web|google_drive|onedrive",\n'
            '  "folder_scope": "Inbox|Sent|Drafts|Deleted",\n'
            '  "result_shape": "list|count",\n'
            '  "search_query": "",\n'
            '  "target_reference": {"query": "", "kind": "", "ordinal": null, "temporal_hint": ""},\n'
            '  "time_filter": "none|today|yesterday|this_week|last_week|this_month|last_month|custom_range",\n'
            '  "since": "YYYY-MM-DD or empty",\n'
            '  "until": "YYYY-MM-DD or empty",\n'
            '  "time_label": "",\n'
            '  "days_ahead": 7\n'
            "}\n\n"
            "Rules:\n"
            "- Keep it read-only only.\n"
            "- Preserve exact temporal constraints like '2 dagen geleden' using time_filter='custom_range' with since/until.\n"
            "- Use domain='email' for inbox/list/search mail requests.\n"
            "- Use domain='calendar' for agenda/event lookups.\n"
            "- Use domain='search' for workspace/web/cloud search.\n"
            "- search_query: extract ONLY the core topic keywords the user wants to find. "
            "Strip all prepositions (van, naar, from, to, over, about), auxiliary verbs (heb, heb ik, is er, are there), "
            "pronouns (ik, mijn, my, me), and filler words. Keep ONLY the essential noun/name.\n"
            "  Examples:\n"
            '  - "heb ik een reserveringsbevestiging" → search_query="reserveringsbevestiging"\n'
            '  - "emails over het project" → search_query="project"\n'
            '  - "mail van Jan" → search_query="Jan"\n'
            '  - "heb ik emails van github gekregen?" → search_query="github"\n'
            '  - "is er iets over de factuur" → search_query="factuur"\n'
            '  - "berichten van de belastingdienst" → search_query="belastingdienst"\n'
            "  - If the user asks for ALL mail (e.g. \"welke emails heb ik vandaag\"), leave search_query empty.\n\n"
            'If NOT a simple read-only request, respond with ONLY: {"domain": "none"}\n'
        )
        _classify_raw = await call_fast(
            _classify_prompt,
            system="You are a strict intent classifier. Output ONLY valid JSON, nothing else.",
            num_ctx=2048,
        )
        if _classify_raw and not _is_llm_error(_classify_raw):
            try:
                _parsed = _parse_model_json(_classify_raw, expect="object")
                if isinstance(_parsed, dict):
                    if str(_parsed.get("domain") or "").strip().lower() != "none":
                        _classified_frame = intent_frame_from_payload(_parsed)
                        if _classified_frame is not None:
                            _parsed = intent_frame_to_direct_intent(_classified_frame) or _parsed
                    _direct_plan = plan_direct_execution(
                        goal,
                        route=_protocol_route,
                        stored_task_state=_stored_task_state,
                        classified_intent=_parsed,
                        is_action=False,
                        use_outlook=_outlook_on_early,
                        gmail_available=_gmail_available,
                        settings=run_settings,
                        dynamic_tools=_dynamic_tools,
                        mcp_cache=_mcp_get_cache(),
                    )
                    _direct_goal = _direct_plan.goal
                    _direct_display_goal = _direct_plan.display_goal
                    _direct_intent = _direct_plan.intent
                    _direct_calls = _direct_plan.calls
            except (json.JSONDecodeError, ValueError):
                pass  # Classification failed — keep heuristic fallback if available

        _direct_task_graph = None
        _direct_task_graph_validation = None
        _graph_route = _direct_plan.graph_route
        _direct_task_graph = _direct_plan.task_graph
        if _direct_task_graph is not None:
            _direct_task_graph_validation = _store_task_graph_with_validation(
                session,
                _direct_task_graph,
                blocked_tools=blocked_tools,
                disabled_tools=disabled_tools,
                approval_tools=approval_tools,
                max_tasks=run_budget["max_tool_calls"],
                max_depth=run_budget["max_steps"],
                allow_side_effects=False,
            )

        if _direct_intent is not None and known_copilot_steps is None:
            _direct_calls = _apply_calendar_range_guard(_direct_calls, goal)
            # Respect policy: blocked / disabled / approval-required → fall through
            _direct_blocked = False
            if _direct_task_graph_validation and not _direct_task_graph_validation.valid:
                _direct_blocked = True
            for _direct_call in _direct_calls:
                _dt_name = _direct_call["tool"]
                _dt_norm = _normalize_tool_name(_dt_name)
                if _dt_norm in blocked_tools or _dt_norm in disabled_tools or _dt_norm in approval_tools:
                    _direct_blocked = True
                    break
            if _direct_calls and not _direct_blocked:
                _reused_lookup = _maybe_reuse_no_progress_lookup(session, _direct_goal, _stored_task_state, _direct_calls)
                if _reused_lookup:
                    final_report = str(_reused_lookup.get("final_report") or "").strip()
                    await manager.send_message(
                        {"type": "status", "content": "No-progress guard — reusing previous lookup result."},
                        websocket,
                    )
                    _record_session_exchange(session, _direct_goal, final_report)
                    session["completed"] = True
                    session["final_report"] = final_report
                    if _stored_task_state:
                        session["task_state"] = _stored_task_state
                        session["goal"] = _direct_goal
                        session["display_goal"] = _direct_display_goal or _direct_goal
                    _store_session_continuity_summary(
                        session,
                        _direct_goal,
                        session.get("task_state") or _stored_task_state,
                        list(_reused_lookup.get("tool_trace") or []),
                        final_report,
                    )
                    _save_session(session)
                    await manager.send_message({"type": "success", "content": "Done!"}, websocket)
                    await manager.send_message(
                        {"type": "final_report", "goal": _direct_goal, "content": final_report}, websocket,
                    )
                    manager.active_runs.pop(run_id, None)
                    return

                await manager.send_message(
                    {"type": "status", "content": "Direct execution — fast path."}, websocket,
                )
                await manager.send_message({"type": "plan", "content": [_direct_goal]}, websocket)

                _direct_results: list[tuple[str, str]] = []
                if _direct_task_graph is not None:
                    _direct_results = await _execute_task_graph(_direct_task_graph, websocket=websocket, run_id=run_id, step_index_base=0)

                if _direct_results:
                    final_report = _format_direct_result(_direct_goal, _direct_results)
                    _domain = str(getattr(_graph_route, "domain", "") or "").strip().lower()
                    if _domain not in {"email", "calendar"}:
                        _tools_seen = {str(_tool_name or "").strip() for _tool_name, _ in _direct_results}
                        if _tools_seen & _EMAIL_LOOKUP_TOOL_NAMES:
                            _domain = "email"
                        elif _tools_seen & {"outlook_read_calendar", "google_read_calendar"}:
                            _domain = "calendar"
                    if _domain in {"email", "calendar"} and _goal_requests_personal_summary(goal, _domain):
                        _summary_norm = _normalize_tool_name("summarize_personal_items")
                        _summary_allowed = (
                            _summary_norm not in blocked_tools
                            and _summary_norm not in disabled_tools
                            and _summary_norm not in approval_tools
                            and run_budget["tool_calls"] < run_budget["max_tool_calls"]
                        )
                        if _summary_allowed:
                            _summary_records: list[dict] = []
                            for _tool_name, _tool_result in _direct_results:
                                if _domain == "email" and _tool_name in _EMAIL_LOOKUP_TOOL_NAMES:
                                    _summary_records.extend(extract_email_lookup_records(_tool_result))
                                if _domain == "calendar" and _tool_name in {"outlook_read_calendar", "google_read_calendar"}:
                                    _summary_records.extend(extract_calendar_lookup_records(_tool_result))

                            if _summary_records:
                                _summary_args = {
                                    "source_type": _domain,
                                    "items": _summary_records,
                                    "max_items": 6,
                                    "style": "brief",
                                }
                                run_budget["tool_calls"] += 1
                                await manager.send_message(
                                    {"type": "tool_call", "tool": "summarize_personal_items", "args": _summary_args, "step_index": len(_direct_results)},
                                    websocket,
                                )
                                _loop = asyncio.get_event_loop()
                                _summary_result = await _loop.run_in_executor(None, _execute_tool, "summarize_personal_items", _summary_args)
                                _summary_details = build_tool_result_details("summarize_personal_items", _summary_result)
                                _summary_compact = compact_tool_result_for_context("summarize_personal_items", _summary_result, _summary_details)
                                await manager.send_message(
                                    {"type": "tool_result", "tool": "summarize_personal_items", "result": _tool_result_ui_snippet("summarize_personal_items", _summary_compact), "step_index": len(_direct_results)},
                                    websocket,
                                )
                                _direct_results.append(("summarize_personal_items", _summary_result))
                                _parsed_summary = None
                                try:
                                    _parsed_summary = json.loads(_summary_result)
                                except Exception:
                                    _parsed_summary = None
                                if isinstance(_parsed_summary, dict):
                                    _summary_msg = str(_parsed_summary.get("message") or "").strip()
                                    if _summary_msg:
                                        final_report = _summary_msg

                    final_report = compose_direct_results(_direct_goal, _direct_results, final_report)
                    session["last_tool_trace"] = _build_compacted_tool_trace(_direct_results)
                    for _item in session["last_tool_trace"]:
                        if isinstance(_item, dict):
                            _append_workflow_context_artifact(
                                session,
                                tool_name=str(_item.get("tool_name") or _item.get("tool") or ""),
                                tool_args={},
                                tool_result=str(_item.get("tool_result") or _item.get("result") or ""),
                                tool_details=dict(_item.get("tool_details") or {}),
                                stage="direct",
                            )
                    _store_session_lookup_records_by_domain(session, _graph_route.domain, session["last_tool_trace"])
                    final_report = _append_email_followup_offer(final_report, session, dutch_hint=_looks_dutch_text(_direct_goal))
                    _direct_step_meta = session["last_tool_trace"][-1] if session["last_tool_trace"] else {"tool_name": "", "tool_result": "", "tool_details": {}}
                    session["steps"] = [{
                        "step": _direct_goal,
                        "result": final_report,
                        "verdict": "DONE",
                        "tool_name": _direct_step_meta.get("tool_name", ""),
                        "tool_result": _direct_step_meta.get("tool_result", ""),
                        "tool_details": dict(_direct_step_meta.get("tool_details") or {}),
                        "deterministic_result": True,
                    }]
                    session["final_report"] = final_report
                    session["completed"] = True
                    _record_session_exchange(session, _direct_goal, final_report)
                    if _stored_task_state:
                        session["task_state"] = _stored_task_state
                        session["goal"] = _direct_goal
                        session["display_goal"] = _direct_display_goal or _direct_goal
                    session["last_lookup_signature"] = _lookup_signature(_direct_goal, session.get("task_state") or _stored_task_state, _direct_calls)
                    _store_session_continuity_summary(
                        session,
                        _direct_goal,
                        session.get("task_state") or _stored_task_state,
                        session["last_tool_trace"],
                        final_report,
                    )
                    _save_session(session)
                    await manager.send_message({"type": "success", "content": "Done!"}, websocket)
                    await manager.send_message(
                        {"type": "final_report", "goal": _direct_goal, "content": final_report}, websocket,
                    )
                    manager.active_runs.pop(run_id, None)
                    return

    # ── Internet: fetch any URLs already in context ─────────────────────────────
    if use_internet and safe_context:
        urls = re.findall(r'https?://[^\s"<>]+', safe_context)
        if urls:
            await manager.send_message({"type": "info", "content": f"Fetching {len(urls)} URL(s) from context..."}, websocket)
            loop = asyncio.get_event_loop()
            for url in urls[:3]:
                fetched = await loop.run_in_executor(None, _fetch_url, url)
                _web_page_block = f"[Web page: {url}]\n{fetched}"
                context_prompt += f"\n\n{_web_page_block}"
                _planner_additional_context += f"\n\n{_web_page_block}"

    # ── 0. Quick-answer gate ────────────────────────────────────────────────────
    _clarification_context = ""
    _execution_mode = "full"
    _wa_connected = _wa_is_connected()
    _onedrive_on = _is_onedrive_available()
    _skip_quick_gate = False
    if _forced_extract_request:
        _forced_source = str(_forced_extract_request.get("source") or "").strip()
        if _forced_source and Path(_forced_source).exists():
            _skip_quick_gate = True
    if not _skip_quick_gate and _is_explicit_email_attachment_summary_request(goal):
        _goal_lowered = str(goal or "").strip().lower()
        _outlook_enabled_for_run = bool((use_outlook or _settings_outlook_enabled(run_settings)) and OUTLOOK_SUPPORT)
        if (
            ("outlook" in _goal_lowered and _outlook_enabled_for_run)
            or ("gmail" in _goal_lowered and _gmail_available)
            or (_outlook_enabled_for_run and not _gmail_available)
            or (_gmail_available and not _outlook_enabled_for_run)
        ):
            _skip_quick_gate = True
    if not _skip_quick_gate and _protocol_route.protocol == "code_change":
        _skip_quick_gate = True
        await manager.send_message({
            "type": "info",
            "content": "Code-change request detected — skipping the quick complexity gate and going straight to planning.",
        }, websocket)
    if known_copilot_steps is None and not _skip_quick_gate:
        await agent_event("planner", "active", msg="Checking query complexity…")
        await manager.send_message({"type": "status", "content": "Checking how to handle this…"}, websocket)
        _available_tools = []
        if use_internet:  _available_tools.append("web search / internet")
        if use_outlook and OUTLOOK_SUPPORT: _available_tools.append("Outlook email & calendar")
        if _gmail_available: _available_tools.append("Gmail email & Google Calendar")
        if _wa_connected: _available_tools.append("WhatsApp messaging")
        if _gmail_available: _available_tools.append("Google Drive")
        if _onedrive_on: _available_tools.append("OneDrive")
        if PLAYWRIGHT_SUPPORT: _available_tools.append("Browser automation")
        _authorized_block = (
            f"\n\nAUTHORIZED TOOLS & CONTEXT (already granted — never ask about these):\n"
            f"- Available tools: {', '.join(_available_tools) if _available_tools else 'none'}\n"
            f"- Internet access: {'YES' if use_internet else 'NO'}\n"
            f"- Outlook email/calendar access: {'YES' if (use_outlook and OUTLOOK_SUPPORT) else 'NO'}\n"
            f"- Gmail / Google Calendar access: {'YES' if _gmail_available else 'NO'}\n"
            f"- WhatsApp messaging: {'YES' if _wa_connected else 'NO'}\n"
            f"- Google Drive: {'YES' if _gmail_available else 'NO'}\n"
            f"- OneDrive: {'YES' if _onedrive_on else 'NO'}\n"
            f"- Browser automation (Playwright): {'YES' if PLAYWRIGHT_SUPPORT else 'NO'}\n"
            f"- The user has already authorized whatever tools are listed above. Do NOT ask for permission to use them.\n"
            f"- Today's date/time is already known to you. Do NOT ask about it.\n"
            f"- The user's language, preferences and profile are in your system prompt. Do NOT ask about them."
        )

        for _round in range(3):
            if not await _check_runtime("complexity_gate"):
                manager.active_runs.pop(run_id, None)
                return
            if run_budget["halted"]:
                manager.active_runs.pop(run_id, None)
                return
            _quick_prompt = (
                f"Goal: {goal}{context_prompt}{_clarification_context}"
                f"{_authorized_block}\n\n"
                "Classify how to handle this goal:\n"
                "1. DIRECT  — You can answer fully from general/factual knowledge RIGHT NOW, no tools needed.\n"
                "   NEVER use DIRECT if the goal requires live/dynamic data: emails, calendar events, inbox,\n"
                "   current news, web pages, files, or any information only a tool can retrieve.\n"
                "   Reply: DIRECT: <complete answer>\n"
                "2. SIMPLE  — Needs AT MOST ONE tool call (e.g. one web search, one email fetch, one API call)\n"
                "   OR is a straightforward lookup/summary/translation/calculation with optional single search.\n"
                "   Reply: SIMPLE\n"
                "3. NEEDS_PLAN — Genuinely complex: requires multiple distinct steps, several tool calls,\n"
                "   coordination between different data sources, or iterative reasoning across many dimensions.\n"
                "   Reply: NEEDS_PLAN\n"
                "4. CLARIFY — ONLY if the goal is genuinely ambiguous in a way that CANNOT be resolved from context\n"
                "   (e.g. refers to something specific you cannot identify at all).\n"
                "   Reply: CLARIFY: <one concise question>\n"
                "   NEVER ask about: tools, permissions, current date/time, things you can infer.\n"
                "   When in doubt between SIMPLE and NEEDS_PLAN, choose NEEDS_PLAN.\n"
                "   When in doubt between NEEDS_PLAN and CLARIFY, choose NEEDS_PLAN."
            )
            _quick_raw = await call_fast(
                _quick_prompt,
                system=agent_system + "\nReply with exactly one of: 'DIRECT: ...', 'SIMPLE', 'NEEDS_PLAN', or 'CLARIFY: ...'. No extra text. No thinking tags.",
                num_ctx=8192,
            )
            _quick_raw = (_quick_raw or "").strip()

            if re.match(r"CLARIFY\s*:", _quick_raw, re.IGNORECASE):
                _clarify_question = re.sub(r"^CLARIFY\s*:\s*", "", _quick_raw, flags=re.IGNORECASE).strip()
                _clarify_override = _quick_clarification_override(
                    goal,
                    _clarify_question,
                    use_outlook=bool((use_outlook or _settings_outlook_enabled(run_settings)) and OUTLOOK_SUPPORT),
                    gmail_available=bool(_gmail_available),
                )
                if _clarify_override:
                    _quick_raw = _clarify_override

            _live_data_keywords = re.compile(
                r"\b(email|emails|mail|inbox|postvak|outlook|gmail|calendar|agenda|afspraak|"
                r"afspraken|nieuws|news|zoek|search|bestand|file|vandaag|today|gisteren|"
                r"yesterday|ontvangen|received|stuur|send|verstuur|whatsapp|bericht|"
                r"berichten|message|messages|drive|onedrive|browser|website|pagina|navigeer|"
                r"weather|weer|forecast|voorspelling|temperature|temperatuur|stock|koers|"
                r"price|prijs|live|current|actueel|real.time)\b",
                re.IGNORECASE,
            )
            _tools_active = (
                use_internet
                or (use_outlook and OUTLOOK_SUPPORT)
                or _gmail_available
                or _wa_connected
                or _onedrive_on
                or PLAYWRIGHT_SUPPORT
            )
            _action_goal = _cl_is_action
            if (
                re.match(r"DIRECT\s*:", _quick_raw, re.IGNORECASE)
                and _tools_active
                and _live_data_keywords.search(goal)
            ):
                _quick_raw = "SIMPLE"

            if _protocol_route.protocol == "code_change" and re.match(r"(?:DIRECT\s*:|SIMPLE\b)", _quick_raw, re.IGNORECASE):
                _quick_raw = "NEEDS_PLAN"

            if _action_goal and re.match(r"(?:DIRECT\s*:|SIMPLE\b)", _quick_raw, re.IGNORECASE):
                _quick_raw = "NEEDS_PLAN"

            _browser_navigation_goal = bool(
                PLAYWRIGHT_SUPPORT and _direct_accel_module._is_explicit_browser_navigation_request(goal)
            )
            if _browser_navigation_goal and re.match(r"(?:DIRECT\s*:|SIMPLE\b)", _quick_raw, re.IGNORECASE):
                _quick_raw = "NEEDS_PLAN"

            if re.match(r"DIRECT\s*:", _quick_raw, re.IGNORECASE):
                _direct_answer = re.sub(r"^DIRECT\s*:\s*", "", _quick_raw, flags=re.IGNORECASE).strip()
                await agent_event("planner", "done", msg="Direct answer – no planning needed")
                await manager.send_message({"type": "plan", "content": ["Direct answer"]}, websocket)
                await agent_event("reportout", "done", msg="Direct answer delivered")
                session["last_tool_trace"] = []
                session["steps"] = [{"step": goal, "result": _direct_answer, "verdict": "DONE"}]
                session["final_report"] = _direct_answer
                session["completed"] = True
                _record_session_exchange(session, goal, _direct_answer)
                _save_session(session)
                await manager.send_message({"type": "success", "content": "Done!"}, websocket)
                await manager.send_message({
                    "type": "final_report", "goal": goal, "content": _direct_answer,
                }, websocket)
                manager.active_runs.pop(run_id, None)
                return
            elif re.match(r"SIMPLE\b", _quick_raw, re.IGNORECASE):
                _execution_mode = "simple"
                await agent_event("planner", "done", msg="Simple query – single-step execution")
                break
            elif re.match(r"CLARIFY\s*:", _quick_raw, re.IGNORECASE) and _round < 2:
                _question = re.sub(r"^CLARIFY\s*:\s*", "", _quick_raw, flags=re.IGNORECASE).strip()
                await manager.send_message({"type": "clarifying_question", "question": _question}, websocket)
                session["last_tool_trace"] = []
                session["steps"] = [{"step": goal, "result": _question, "verdict": "CLARIFY"}]
                session["final_report"] = _question
                session["completed"] = False
                _set_run_kpis_snapshot(completed=False)
                _persist_learning_feedback()
                _record_session_exchange(session, goal, _question)
                _save_session(session)
                await manager.send_message({"type": "success", "content": "Awaiting your input."}, websocket)
                await manager.send_message({
                    "type": "final_report", "goal": goal, "content": _question,
                }, websocket)
                manager.active_runs.pop(run_id, None)
                return
            else:
                break

    if _clarification_context:
        context_prompt += _clarification_context
        _planner_additional_context += _clarification_context
    await agent_event("planner", "idle", msg="")
    if run_budget["halted"]:
        manager.active_runs.pop(run_id, None)
        return

    # ── Resolve weekday names to concrete dates so the LLM doesn't miscalculate ──
    _goal_lower = goal.lower()
    for _wdn, _wdi in _WEEKDAY_NAMES_TO_INDEX.items():
        if re.search(rf"\b{re.escape(_wdn)}\b", _goal_lower):
            _delta = (_wdi - datetime.now().weekday()) % 7
            if _delta == 0:
                _delta = 7
            _resolved = (datetime.now() + timedelta(days=_delta)).strftime("%Y-%m-%d")
            _weekday_note = f"Note: '{_wdn}' refers to {_resolved}."
            context_prompt += f"\n\n{_weekday_note}"
            _planner_additional_context += f"\n\n{_weekday_note}"
            break

    # ── 1. Planning ────────────────────────────────────────────────────────────────
    internet_note = (
        " For any step that would benefit from current/external information, set "
        "\"needs_search\": true and provide a concise \"search_hint\" query."
        if use_internet else ""
    )
    _outlook_on = use_outlook and OUTLOOK_SUPPORT
    _outlook_scope, _gmail_scope, _source_explicit = _detect_email_source_scope(
        goal,
        use_outlook=_outlook_on,
        gmail_available=_gmail_available,
    )
    _email_providers = []
    if _outlook_scope:
        _email_providers.append("Outlook")
    if _gmail_scope:
        _email_providers.append("Gmail")
    if _email_providers:
        _prov_str = " and ".join(_email_providers)
        email_note = (
            f" {_prov_str} email/calendar access is available in this run. "
            "Do NOT ask the user to grant access, authenticate, or paste emails. "
            f"If the goal mentions email, plan actionable steps that directly use {_prov_str} tools."
        )
        if _outlook_scope and _gmail_scope and not _source_explicit:
            email_note += (
                " When the user asks for 'email' generically, use BOTH Outlook and Gmail tools "
                "to cover all mailboxes. Combine and present the results together."
            )
        elif _source_explicit:
            email_note += (
                " The user explicitly named a provider. Stay on that provider only unless the user explicitly asks "
                "to compare, combine, or search across accounts."
            )
    else:
        email_note = " Email tools are disabled for this run. If email data is required, include a step that asks user input."

    email_goal = bool(re.search(r"\b(email|emails|mail|mailbox|outlook|gmail|google mail|inbox|postvak|e-mail|mails|sent|verzonden)\b", goal, re.IGNORECASE))
    email_count_goal = bool(re.search(r"\b(hoeveel|aantal|count|how many|number of)\b", goal, re.IGNORECASE))
    today_goal = bool(re.search(r"\b(vandaag|today)\b", goal, re.IGNORECASE))
    email_folder = _detect_email_folder(goal, {**(run_settings.get("outlook_folder_profiles") or {}), **(run_settings.get("google_folder_profiles") or {})})
    max_plan_steps = 3 if speed_mode else 5
    multi_goal_todo_steps = _build_multi_goal_todo_steps(goal, use_internet=use_internet, max_steps=max_plan_steps)
    multi_goal_mode = len(multi_goal_todo_steps) > 1

    if multi_goal_mode and known_copilot_steps is None:
        steps = multi_goal_todo_steps
        await manager.send_message({"type": "status", "content": "Multi-goal detected - executing as a to-do list…"}, websocket)
        await manager.send_message({"type": "plan", "content": [step["step"] for step in steps]}, websocket)
        await agent_event("planner", "done", msg=f"{len(steps)} to-do items planned")
    elif _execution_mode == "simple":
        _simple_step = _forced_extract_request["display_goal"] if _forced_extract_request else goal
        _needs_search = use_internet or bool(_LIVE_DATA_QUERY_RE.search(goal))
        steps: list[dict] = [{"step": _simple_step, "needs_search": _needs_search, "search_hint": goal[:100] if _needs_search else ""}]
        await manager.send_message({"type": "status", "content": "Simple query – executing directly…"}, websocket)
        await manager.send_message({"type": "plan", "content": [_simple_step]}, websocket)
        await agent_event("planner", "done", msg="1 step (simple mode)")
    elif known_copilot_steps is not None:
        steps = known_copilot_steps
        await manager.send_message({"type": "status", "content": "Known Co-Pilot routine detected - executing predefined steps..."}, websocket)
        await agent_event("planner", "skip", msg=f"Known Co-Pilot routine ({len(steps)} predefined steps)")
        await manager.send_message({"type": "plan", "content": [step["step"] for step in steps]}, websocket)
    else:
        await manager.send_message({"type": "status", "content": "Planning steps..."}, websocket)
        if not manager.active_runs.get(run_id, False):
            return
        _plan_chat_hist = _build_chat_history_context(session, max_entries=6)
        _plan_context_sections = _build_planner_context_sections(
            recent_chat=_plan_chat_hist,
            past_memory=past_memory,
            deep_memory=deep_memory,
            wiki_memory=wiki_memory,
            user_context=safe_context,
            additional_context=_planner_additional_context,
        )
        _plan_context_block = f"\n\n{_plan_context_sections}" if _plan_context_sections else ""
        _plan_learning_block = f"\n\n{_planner_learning_block}" if _planner_learning_block else ""
        _routine_planner_guide = build_copilot_routine_planner_guide().strip()
        _routine_planner_block = (
            f"Available predefined Co-Pilot routines:\n{_routine_planner_guide}\n\n"
            if _routine_planner_guide else ""
        )
        plan_prompt = (
            f"Goal: {goal}{_plan_context_block}{_plan_learning_block}\n\n"
            "First determine the user's true intent from the goal and any conversation "
            "context above, then plan accordingly.\n"
            f"Break this goal into the MINIMUM number of logical, actionable steps needed.{internet_note}{email_note}\n"
            f"Simple/factual goals need 1-2 steps. Complex goals need at most {max_plan_steps} steps.\n"
            f"Use AT MOST {max_plan_steps} steps total — merge steps where possible.\n"
            f"{_routine_planner_block}"
            "If this clearly matches a predefined Co-Pilot routine, you MAY instead return ONLY this JSON object:\n"
            "  {\"routine_id\": \"morning_brief|follow_up\", \"steps\": []}\n"
            "Use routine_id only when that routine is the best exact fit, including paraphrased user requests.\n"
            "Otherwise return ONLY a valid JSON array where every element is an object with keys:\n"
            "  \"step\"         (string) — what to do\n"
            "  \"needs_search\" (bool)   — whether web search is needed\n"
            "  \"search_hint\"  (string) — short search query if needs_search is true, else \"\"\n"
            "No markdown, no prose. Just the JSON object or JSON array."
        )
        await agent_event("planner", "active", msg="Decomposing goal into steps…")
        plan_call = call_fast if speed_mode else call_with_vision
        plan_raw = await plan_call(
            plan_prompt,
            system=agent_system + "\n\nFor this task, output ONLY a valid JSON object or JSON array. No markdown, no prose.",
            num_ctx=_model_ctx(16384),
        )

        try:
            parsed_plan = _parse_planner_output(plan_raw)
            _planner_routine_id = str(parsed_plan.get("routine_id") or "").strip().lower()
            steps = [
                {
                    "step": item["step"],
                    "needs_search": bool(item.get("needs_search", False)) and use_internet,
                    "search_hint": item.get("search_hint", ""),
                }
                for item in list(parsed_plan.get("steps") or [])[:max_plan_steps]
            ]

            if _planner_routine_id and known_copilot_steps is None:
                _planner_routine = resolve_copilot_routine_by_id(
                    _planner_routine_id,
                    outlook_enabled=_outlook_on,
                    gmail_enabled=_gmail_available,
                    outlook_folder_profiles=run_settings.get("outlook_folder_profiles") or None,
                    google_folder_profiles=run_settings.get("google_folder_profiles") or None,
                )
                if _planner_routine is not None:
                    known_copilot_kind = str(_planner_routine.get("id") or "").strip().lower()
                    known_copilot_steps = list(_planner_routine.get("steps") or [])
                    steps = known_copilot_steps
                    await manager.send_message(
                        {"type": "status", "content": "Planner selected Co-Pilot routine - executing predefined steps..."},
                        websocket,
                    )
                    await agent_event("planner", "skip", msg=f"Planner selected Co-Pilot routine ({len(steps)} predefined steps)")

            if not steps:
                raise ValueError("Planner did not return any executable steps")

            if (_outlook_on or _gmail_available) and email_goal:
                access_re = re.compile(r"request access|grant access|provide (the )?list of emails|cannot access|set up account access", re.IGNORECASE)
                ui_re = re.compile(r"open outlook|open gmail|navigate|click|apply (a )?filter|show only emails|inbox view|sent folder|open (the )?(inbox|sent)", re.IGNORECASE)
                date_re = re.compile(r"(determine|retrieve|get|obtain|check|find out|look up)\s+(the\s+)?(current\s+)?(today.?s?\s+)?(date|time|dag|datum)", re.IGNORECASE)
                _retrieve_label = " and ".join(_email_providers)
                _folder_label = email_folder
                rewritten = []
                for s in steps:
                    if date_re.search(s["step"]):
                        pass
                    elif access_re.search(s["step"]) or ui_re.search(s["step"]):
                        rewritten.append({"step": f"Retrieve emails from {_retrieve_label} {_folder_label} for the requested period.", "needs_search": False, "search_hint": ""})
                    else:
                        rewritten.append(s)
                if _source_explicit:
                    filtered_steps = []
                    for step_obj in rewritten:
                        step_text = step_obj["step"]
                        if _step_is_cross_source(step_text):
                            continue
                        if _gmail_scope and not _outlook_scope and _step_mentions_provider(step_text, "outlook"):
                            continue
                        if _outlook_scope and not _gmail_scope and _step_mentions_provider(step_text, "gmail"):
                            continue
                        filtered_steps.append(step_obj)
                    rewritten = filtered_steps or rewritten
                if not rewritten:
                    rewritten = [{"step": f"Retrieve emails from {_retrieve_label} {_folder_label} for today.", "needs_search": False, "search_hint": ""}]
                steps = rewritten
                if email_count_goal:
                    period = "today" if today_goal else "the requested period"
                    steps = [
                        {"step": f"Fetch emails from {_retrieve_label} {_folder_label} for {period}.", "needs_search": False, "search_hint": ""},
                        {"step": f"Count the fetched emails for {period} and report the exact total.", "needs_search": False, "search_hint": ""},
                    ]
        except Exception:
            steps = _build_planner_plan_b_steps(goal, use_internet=use_internet, max_steps=max_plan_steps)
            if not steps:
                await manager.send_message({"type": "error", "content": f"Failed to parse plan. Raw output: {plan_raw}"}, websocket)
                manager.active_runs.pop(run_id, None)
                return
            session["planner_fallback"] = {
                "reason": "parse_error",
                "raw_output": str(plan_raw or "")[:4000],
                "steps": [dict(step) for step in steps],
            }
            await agent_event("planner", "error", msg="Planner output invalid - using Plan B")
            await manager.send_message(
                {
                    "type": "info",
                    "content": "Planner output was invalid - continuing with Plan B.",
                },
                websocket,
            )

        await agent_event("planner", "done", msg=f"{len(steps)} steps planned")
        _first_search = any(s["needs_search"] for s in steps)
        await handoff("planner", "researcher" if _first_search else "curator",
                      summary=f"{len(steps)} steps: " + "; ".join(s["step"][:35] for s in steps[:3]) + ("…" if len(steps) > 3 else ""))
        await manager.send_message({"type": "plan", "content": [s["step"] for s in steps]}, websocket)

    if len(steps) > run_budget["max_steps"]:
        await manager.send_message({
            "type": "info",
            "content": f"Plan truncated to budgeted max of {run_budget['max_steps']} steps.",
        }, websocket)
        steps = steps[:run_budget["max_steps"]]

    async def _push_todo_progress(
        event: str,
        *,
        step_index: int | None = None,
        goal_status: str | None = None,
        goal_evaluation: str | None = None,
        step_fields: dict | None = None,
    ) -> None:
        if step_index is not None and step_fields:
            _update_todo_progress_item(session, steps, step_index, **step_fields)
        await _send_todo_progress(
            session,
            steps,
            websocket,
            event=event,
            run_id=run_id,
            current_step_index=step_index,
            goal_status=goal_status,
            goal_evaluation=goal_evaluation,
        )

    await _push_todo_progress(
        "plan_created",
        goal_status="running",
        goal_evaluation="Execution plan ready.",
    )

    # ── 2. Execution pipeline ───────────────────────────────────────────────────

    async def researcher_agent(step_desc: str, search_hint: str, step_index: int) -> str:
        if run_budget["halted"] or (not await _check_runtime("researcher")):
            return ""
        query = search_hint.strip() or step_desc[:120]
        await agent_event("researcher", "active", step_index, f"Searching: {query[:60]}")
        await manager.send_message({"type": "info", "content": f"Researcher: searching '{query}'"}, websocket)
        loop = asyncio.get_event_loop()
        raw_results = await loop.run_in_executor(None, _web_search, query, 6)
        _NO_RESULTS_MARKERS = ("No search results found", "Brave Search error:", "Brave Search auth error:")
        _search_failed = not raw_results or any(raw_results.startswith(m) for m in _NO_RESULTS_MARKERS)
        if _search_failed:
            await manager.send_message({
                "type": "info",
                "content": f"Researcher: web search returned no usable results for '{query}'."
            }, websocket)
            await agent_event("researcher", "done", step_index, "No results found")
            return ""
        await manager.send_message({
            "type": "info",
            "content": f"Researcher: {len(raw_results)} chars of raw search results. Synthesising…"
        }, websocket)
        urls_found = re.findall(r"URL:\s+(https?://[^\s]+)", raw_results)
        fetched_pages = []
        if urls_found[:2]:
            page_results = await asyncio.gather(
                *[loop.run_in_executor(None, _fetch_url, url, 3000) for url in urls_found[:2]]
            )
            for url, page in zip(urls_found[:2], page_results):
                if not page.startswith("Could not fetch"):
                    fetched_pages.append(f"[From {url}]\n{page}")
        full_raw = raw_results
        if fetched_pages:
            full_raw += "\n\n" + "\n\n".join(fetched_pages)
        synth_prompt = (
            f"Goal: {goal}\n"
            f"Step that needs this research: {step_desc}\n\n"
            f"Raw search results and page extracts:\n{full_raw[:20000]}\n\n"
            "Write a concise research brief (max 400 words) that directly supports "
            "executing the step above. Include key facts, figures, and URLs. "
            "Do NOT include irrelevant information. "
            "IMPORTANT: Only state facts that are explicitly present in the search results above. "
            "If the results do not contain the answer, say so — NEVER fabricate or guess."
        )
        brief = await call_with_vision(
            synth_prompt,
            system=(
                f"{_current_date_line()} "
                "You are a research analyst. Given raw web search results, extract and "
                "synthesise only the information that is directly relevant to the given step. "
                "Be factual, concise, and cite sources (URL) where possible. "
                "NEVER invent facts. If the search results do not contain the needed "
                "information, explicitly state that no relevant data was found."
            )
        )
        if _is_llm_error(brief):
            await manager.send_message({
                "type": "info",
                "content": "Research synthesis was unavailable due to a model error — continuing with raw results.",
            }, websocket)
            await agent_event("researcher", "done", step_index, "Synthesis skipped (LLM error)")
            return ""
        await agent_event("researcher", "done", step_index, f"Brief ready ({len(brief)} chars)")
        await manager.send_message({
            "type": "research_ready", "step_index": step_index, "brief": brief.strip(),
        }, websocket)
        return brief.strip()

    async def curator_agent(step_desc: str, step_index: int, completed: list[dict], research_brief: str) -> str:
        if run_budget["halted"] or (not await _check_runtime("curator")):
            return ""
        await agent_event("curator", "active", step_index, "Filtering relevant context…")
        sections = []
        # Include recent chat history so the curator can surface conversation context
        # (skip for live-data tasks to avoid stale API results leaking in)
        if not _task_state.get("requires_live_data"):
            _chat_hist_text = _build_chat_history_context(session, max_entries=8)
            if _chat_hist_text:
                sections.append(f"[Recent conversation]\n{_chat_hist_text}")
        if deep_memory:
            sections.append(f"[Deep memory recall]\n{deep_memory[:6000]}")
        if past_memory:
            sections.append(f"[Past session memory]\n{past_memory[:12000]}")
        if safe_context:
            sections.append(f"[User-provided context]\n{safe_context[:8000]}")
        if completed:
            recent = completed[-4:]
            prev = "\n\n".join(
                f"Step {completed.index(m)+1}: {m['step']}\nResult: {(m['result'] or '')[:1500].replace(chr(10),' ')}{'…' if len(m['result'] or '')>1500 else ''}"
                for m in recent
            )
            sections.append(f"[Previous results]\n{prev}")
        if not sections:
            await agent_event("curator", "skip", step_index, "Nothing to curate yet")
            return ""
        if not past_memory and not safe_context and not completed and not sections:
            await agent_event("curator", "skip", step_index, "Nothing to curate yet")
            return ""
        available = "\n\n".join(sections)
        plan_overview = "\n".join(f"{j+1}. {s['step']}" for j, s in enumerate(steps))
        curation_prompt = (
            f"Goal: {goal}\n\nPlan:\n{plan_overview}\n\n"
            f"Current step ({step_index+1}/{len(steps)}): \"{step_desc}\"\n\n"
            f"Available information:\n{available}\n\n"
            "Extract ONLY what is directly useful for this step (max 250 words). "
            "If nothing is relevant, reply with exactly: NONE"
        )
        result = await call_fast(
            curation_prompt,
            system=(
                "You are a context curator. Filter available information and return only "
                "snippets directly relevant to the current step. Be brief and specific."
            )
        )
        r = result.strip()
        curated_out = "" if r.upper() == "NONE" else r
        await agent_event("curator", "done", step_index,
                          "No relevant context" if not curated_out else f"{len(curated_out)} chars curated")
        return curated_out

    # ── Tool descriptions ──────────────────────────────────────────────────
    TOOLS_DESC = _build_tools_desc(
        use_outlook=_outlook_on, gmail_available=_gmail_available,
        whatsapp_available=_wa_is_connected(),
        google_drive_available=_gmail_available,
        onedrive_available=_is_onedrive_available(),
        browser_available=PLAYWRIGHT_SUPPORT,
    )
    # Native Ollama tool-calling schemas (only used for Ollama models)
    _use_native_tools = not (model.startswith(NVIDIA_PREFIX)
                             or model.startswith(OPENAI_PREFIX)
                             or model.startswith(GEMINI_PREFIX)
                             or model.startswith(LLAMACPP_PREFIX))
    _ollama_tools: list[dict] = []
    if _use_native_tools:
        _slack_available = bool((run_settings.get("slack_bot_token") or "").strip())
        _ollama_tools = build_ollama_tool_schemas(
            use_outlook=_outlook_on, gmail_available=_gmail_available,
            whatsapp_available=_wa_is_connected(),
            google_drive_available=_gmail_available,
            onedrive_available=_is_onedrive_available(),
            browser_available=PLAYWRIGHT_SUPPORT,
            slack_available=_slack_available,
            disabled_tools=disabled_tools,
            blocked_tools=blocked_tools,
            webhooks=run_settings.get("webhooks") or [],
        )

    async def _call_ollama_with_tools(
        messages: list[dict],
        tools: list[dict],
        num_ctx: int = 32768,
    ) -> dict:
        """Call Ollama /api/chat with native tool schemas.

        Returns the full response dict with message.content and/or
        message.tool_calls.
        """
        loop = asyncio.get_event_loop()
        def _do():
            _ollama_ensure_model(model)
            payload: dict = {
                "model": model, "messages": messages,
                "stream": False,
                "options": {"num_ctx": num_ctx, "temperature": 0.7},
                "keep_alive": "10m",
                "think": True,
            }
            if tools:
                payload["tools"] = tools
            try:
                res = requests.post(f"{OLLAMA_URL}/chat", json=payload, timeout=180)
                res.raise_for_status()
                return res.json()
            except Exception as e:
                return {"error": str(e)}
        return await loop.run_in_executor(None, _do)

    # Sub-agent C — Executor
    async def executor_agent(
        step_desc: str,
        curated_context: str,
        research_brief: str,
        feedback: str = "",
        step_index: int = -1,
        needs_search: bool = False,
        completed_steps: list[dict] | None = None,
    ) -> tuple[str, bool, dict]:
        if run_budget["halted"] or (not await _check_runtime("executor")):
            return f"[Run halted: {run_budget['halt_reason'] or 'runtime budget exceeded'}]", False, {
                "tool_name": "",
                "tool_result": "",
                "deterministic_result": False,
            }

        parts = [f"Goal: {goal}\n"]
        # Inject recent conversation so the executor understands conversational context
        _exec_chat_hist = _build_chat_history_context(session, max_entries=8)
        if _exec_chat_hist:
            parts.append(f"Recent conversation:\n{_exec_chat_hist}\n")
        # Cross-step memory: give the executor direct access to prior step
        # results so it can use IDs, dates, and other precise data from
        # earlier steps without relying solely on the curator's summary.
        if completed_steps:
            _csm_lines: list[str] = []
            for _ci, _cs in enumerate(completed_steps[-4:], start=max(1, len(completed_steps) - 3)):
                _cs_tool = str(_cs.get("tool_name") or "").strip()
                _cs_result = str(_cs.get("tool_result") or _cs.get("result") or "").strip()
                if _cs_result:
                    _csm_lines.append(
                        f"Step {_ci}: {_cs.get('step', '')[:80]}\n"
                        f"Tool: {_cs_tool or 'none'}\n"
                        f"Result:\n{_cs_result[:2000]}"
                    )
            if _csm_lines:
                parts.append(f"Prior step results (use IDs and details from these):\n{'---'.join(_csm_lines)}\n")
        if curated_context:
            parts.append(f"Relevant prior context:\n{curated_context}\n")
        if research_brief:
            parts.append(f"Research brief:\n{research_brief}\n")
        elif needs_search:
            parts.append(
                "Note: Web search was attempted but returned no usable results. "
                "Use tools (e.g. web_search) to find the information yourself, or "
                "state clearly that the information could not be verified.\n"
            )
        if feedback:
            parts.append(
                f"⚠ Your previous attempt was rejected by quality control.\n"
                f"Feedback: {feedback}\n"
                f"Please correct the issues and provide an improved answer.\n"
            )
        parts.append(
            f"Task: {step_desc}\n\n"
            "Execute this task. Use tools if needed, then give your final answer.\n"
            "IMPORTANT: First determine the user's true intent from the conversation "
            "and all available context. Base your answer on provided data (search results, "
            "email/calendar/file content, tool output) — NOT on your training knowledge "
            "alone for factual claims about current events, dates, or real-world state. "
            "If no reliable data source confirms a fact, say so explicitly."
        )

        _SIDE_EFFECT_TOOLS = {
            "outlook_send_email", "outlook_update_email", "outlook_delete_email",
            "gmail_send_email", "gmail_update_email", "gmail_delete_email",
            "outlook_create_calendar_event", "outlook_update_calendar_event",
            "outlook_delete_calendar_event",
            "google_create_calendar_event", "google_update_calendar_event",
            "google_delete_calendar_event",
            "google_drive_upload", "onedrive_upload",
            "whatsapp_send_message",
            "slack_post_message",
            "write_file", "run_shell", "run_python",
            "schedule_goal",
        }
        tool_log:           list[str] = []
        last_clean_response: str      = ""
        latest_tool_name: str         = ""
        latest_tool_args: dict        = {}
        latest_tool_result: str       = ""
        latest_tool_details: dict     = {}
        used_side_effect_tool: bool   = False
        composed_from_tool: bool      = False
        tool_call_cache: dict[str, tuple[str, dict, str]] = {}
        current_prompt = "\n".join(parts)
        current_system = agent_system + TOOLS_DESC

        _extract_request = None
        if _forced_extract_request and _execution_mode == "simple" and step_index in {-1, 0}:
            _extract_request = dict(_forced_extract_request)

        if _extract_request and not feedback:
            _extract_tool = "extract_source"
            _extract_args = {
                "source": _extract_request["source"],
                "max_chars": int(_extract_request.get("max_chars", 24000) or 24000),
            }
            _extract_norm = _normalize_tool_name(_extract_tool)
            if _extract_norm not in blocked_tools and _extract_norm not in disabled_tools and _extract_norm not in approval_tools:
                if run_budget["tool_calls"] < run_budget["max_tool_calls"]:
                    run_budget["tool_calls"] += 1
                    await manager.send_message({
                        "type": "tool_call", "tool": _extract_tool, "args": _extract_args, "step_index": step_index,
                    }, websocket)
                    loop = asyncio.get_event_loop()
                    tool_result = await loop.run_in_executor(None, _execute_tool, _extract_tool, _extract_args)
                    tool_details = build_tool_result_details(_extract_tool, tool_result)
                    tool_result_for_context = compact_tool_result_for_context(_extract_tool, tool_result, tool_details)
                    latest_tool_name = _extract_tool
                    latest_tool_args = _extract_args
                    latest_tool_result = tool_result
                    latest_tool_details = tool_details
                    _append_workflow_context_artifact(
                        session,
                        tool_name=_extract_tool,
                        tool_args=_extract_args,
                        tool_result=tool_result,
                        tool_details=tool_details,
                        stage="executor",
                    )
                    await manager.send_message({
                        "type": "tool_result", "tool": _extract_tool,
                        "result": _tool_result_ui_snippet(_extract_tool, tool_result_for_context), "step_index": step_index,
                    }, websocket)
                    tool_log.append(f"Tool: {_extract_tool}\nArgs: {json.dumps(_extract_args)}\nResult:\n{tool_result_for_context}")
                    current_prompt = (
                        f"Goal: {goal}\n"
                        f"Task: {step_desc}\n\n"
                        f"The extract_source skill returned:\n{tool_result_for_context}\n\n"
                        "Write a concise answer in the same language as the user. "
                        "Summarize the source clearly, highlight the main points, and mention important limitations "
                        "such as missing transcript or OCR when they appear in the extracted content. "
                        "Do not call extract_source again unless the first result explicitly says the source could not be read because the source string is invalid."
                    )
                else:
                    await _halt_run(
                        "BUDGET_TOOL_CALLS",
                        f"Run stopped: tool-call budget exceeded ({run_budget['tool_calls']} / {run_budget['max_tool_calls']}).",
                    )

        async def _try_execute_tool_plan() -> bool:
            nonlocal last_clean_response, latest_tool_name, latest_tool_args, latest_tool_result
            nonlocal latest_tool_details, used_side_effect_tool, tool_log, _native_done

            try:
                runtime_catalog = build_runtime_tool_catalog(
                    run_settings,
                    dynamic_tools=_dynamic_tools,
                    mcp_cache=_mcp_get_cache(),
                    use_outlook=_outlook_on,
                    gmail_available=_gmail_available,
                )
                allowed_tool_names = {
                    _normalize_tool_name(str((schema.get("function") or {}).get("name") or "").strip())
                    for schema in _ollama_tools
                    if isinstance(schema, dict)
                }
                capability_summary = _tool_plan_capability_summary(runtime_catalog, allowed_tool_names)
                if not capability_summary:
                    return False

                plan_prompt = build_tool_plan_prompt(current_prompt, capability_summary)
                plan_messages: list[dict] = []
                if agent_system:
                    plan_messages.append({"role": "system", "content": agent_system + "\nReturn valid JSON only."})
                plan_messages.append({"role": "user", "content": plan_prompt})

                await manager.send_message({"type": "model_active", "model": model}, websocket)
                plan_response = await _call_ollama_with_tools(plan_messages, [], num_ctx=_model_ctx(32768))
                await manager.send_message({"type": "model_idle"}, websocket)
                if plan_response.get("error"):
                    return False

                plan_content = str((plan_response.get("message") or {}).get("content") or "").strip()
                if not plan_content:
                    return False

                parsed_plan = _parse_model_json(plan_content, expect="object")
                plan = tool_plan_from_payload(parsed_plan)
                if plan is None:
                    return False

                plan_validation = validate_tool_plan(
                    plan,
                    catalog=runtime_catalog,
                    blocked_tools=blocked_tools,
                    disabled_tools=disabled_tools,
                    approval_tools=approval_tools,
                    allow_side_effects=True,
                    max_steps=max(1, min(8, run_budget["max_tool_calls"] - run_budget["tool_calls"])),
                )
                session["tool_plan"] = plan.to_dict()
                session["tool_plan_validation"] = plan_validation.to_dict()
                try:
                    plan_graph = tool_plan_to_task_graph(plan)
                    _store_task_graph_with_validation(
                        session,
                        plan_graph,
                        blocked_tools=blocked_tools,
                        disabled_tools=disabled_tools,
                        approval_tools=approval_tools,
                        max_tasks=max(1, len(plan.steps) + 2),
                        max_depth=8,
                        allow_side_effects=True,
                    )
                except Exception:
                    pass

                if not plan_validation.valid:
                    return False

                if plan.requires_clarification:
                    last_clean_response = plan.clarification_question or (
                        "I need one clarification before I can continue."
                        if not _looks_dutch(goal, step_desc)
                        else "Ik heb eerst een verduidelijking nodig voordat ik verder kan."
                    )
                    _native_done = True
                    return True

                plan_bindings: dict[str, dict[str, object]] = {}
                session["tool_plan_bindings"] = {}

                for plan_step in plan.steps:
                    t_name = _normalize_tool_name(plan_step.tool)
                    try:
                        t_args = _resolve_tool_plan_args(plan_step, plan_bindings)
                    except ValueError as exc:
                        latest_tool_name = t_name
                        latest_tool_result = f"Error: {exc}"
                        tool_log.append(f"Tool: {t_name}\nArgs: {{}}\nResult:\n{latest_tool_result}")
                        if plan_step.on_failure == "ask_user":
                            last_clean_response = str(exc)
                        _native_done = True
                        return True

                    if t_name in disabled_tools:
                        tool_result = f"TOOL_DISABLED: Skill '{t_name}' is disabled in Settings > Skills."
                    elif t_name in blocked_tools:
                        tool_result = f"POLICY_DENIED: Skill '{t_name}' is blocked by policy."
                    else:
                        if t_name in approval_tools and not require_approval:
                            approved, _ = await _request_tool_approval(step_index, t_name, t_args)
                            if not approved:
                                tool_result = f"HITL_REQUIRED: Skill '{t_name}' was not approved by user."
                            else:
                                tool_result = ""
                        else:
                            tool_result = ""

                    latest_tool_name = t_name
                    latest_tool_args = dict(t_args)

                    if tool_result:
                        latest_tool_result = tool_result
                        latest_tool_details = build_tool_result_details(t_name, tool_result)
                        await manager.send_message({"type": "tool_result", "tool": t_name, "result": tool_result, "step_index": step_index}, websocket)
                        tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result}")
                        if plan_step.on_failure in {"abort", "ask_user"}:
                            if plan_step.on_failure == "ask_user":
                                last_clean_response = tool_result
                            _native_done = True
                            return True
                        continue

                    if run_budget["tool_calls"] >= run_budget["max_tool_calls"]:
                        await _halt_run("BUDGET_TOOL_CALLS", f"Run stopped: tool-call budget exceeded ({run_budget['tool_calls']} / {run_budget['max_tool_calls']}).")
                        _native_done = True
                        return True
                    run_budget["tool_calls"] += 1

                    await manager.send_message({"type": "tool_call", "tool": t_name, "args": t_args, "step_index": step_index}, websocket)
                    await track_tokens("tool", str(t_args), "")

                    cache_key = _tool_call_signature(t_name, t_args)
                    if t_name in _READONLY_TOOL_NAMES and cache_key in tool_call_cache:
                        tool_result, tool_details, tool_result_for_context = tool_call_cache[cache_key]
                    else:
                        loop = asyncio.get_event_loop()
                        tool_result = await loop.run_in_executor(None, _execute_tool, t_name, t_args)
                        tool_details = build_tool_result_details(t_name, tool_result)
                        tool_result_for_context = compact_tool_result_for_context(t_name, tool_result, tool_details)
                        if t_name in _READONLY_TOOL_NAMES:
                            tool_call_cache[cache_key] = (tool_result, tool_details, tool_result_for_context)

                    latest_tool_result = tool_result
                    latest_tool_details = tool_details
                    _append_workflow_context_artifact(
                        session,
                        tool_name=t_name,
                        tool_args=t_args,
                        tool_result=tool_result,
                        tool_details=tool_details,
                        stage="executor",
                    )
                    if t_name in _SIDE_EFFECT_TOOLS and not tool_result.startswith(("Error", "POLICY_DENIED", "HITL_REQUIRED")):
                        used_side_effect_tool = True
                    await manager.send_message({"type": "tool_result", "tool": t_name, "result": _tool_result_ui_snippet(t_name, tool_result_for_context), "step_index": step_index}, websocket)
                    tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result_for_context}")

                    step_bindings = _extract_tool_plan_bindings(plan_step, tool_name=t_name, tool_result=tool_result, tool_details=tool_details)
                    plan_bindings[plan_step.step_id] = step_bindings
                    session["tool_plan_bindings"][plan_step.step_id] = dict(step_bindings)

                    if tool_result.startswith(("Error", "POLICY_DENIED", "HITL_REQUIRED", "TOOL_DISABLED")) and plan_step.on_failure in {"abort", "ask_user"}:
                        if plan_step.on_failure == "ask_user":
                            last_clean_response = tool_result
                        _native_done = True
                        return True

                _native_done = True
                return True
            except Exception:
                return False

        # ── Native Ollama tool-calling loop ──────────────────────────────────
        _native_done = False
        if _use_native_tools and _ollama_tools and not (_extract_request and not feedback):
            _tool_plan_handled = await _try_execute_tool_plan()
            if not _tool_plan_handled:
                _native_messages: list[dict] = []
                # Use agent_system WITHOUT TOOLS_DESC — native tool schemas are
                # passed via the 'tools' parameter; mixing both confuses the model.
                if agent_system:
                    _native_messages.append({"role": "system", "content": agent_system})
                _native_messages.append({"role": "user", "content": current_prompt})

                for _nturn in range(5):
                    if run_budget["halted"] or (not await _check_runtime("executor_native_turn")):
                        break
                    await manager.send_message({"type": "model_active", "model": model}, websocket)
                    _resp = await _call_ollama_with_tools(_native_messages, _ollama_tools, num_ctx=_model_ctx(32768))
                    await manager.send_message({"type": "model_idle"}, websocket)

                    if _resp.get("error"):
                        break  # fall back to text-based loop

                    _native_tc = _extract_native_tool_calls(_resp)
                    _msg_content = (_resp.get("message") or {}).get("content", "")

                    if not _native_tc:
                        # Side-effect step answered with prose — nudge model to call the tool
                        if (
                            _msg_content.strip()
                            and not tool_log
                            and _nturn == 0
                            and (_goal_requests_side_effect(step_desc) or _cl_is_action)
                            and _reply_claims_action(_msg_content)
                        ):
                            _native_messages.append({"role": "assistant", "content": _msg_content})
                            _native_messages.append({"role": "user", "content": (
                                "You described the action instead of performing it. "
                                "You MUST call the appropriate tool now. Do NOT narrate — invoke the function."
                            )})
                            continue
                        # Model answered without tool calls — stream the answer to the UI
                        if _msg_content.strip():
                            await manager.send_message({"type": "stream_start", "agent": "Executor"}, websocket)
                            # Strip think tags from one-shot native response
                            _nc_clean = re.sub(r'<think>[\s\S]*?</think>', '', _msg_content).strip()
                            _nc_out = _nc_clean or _msg_content
                            await manager.send_message({"type": "stream_token", "content": _nc_out}, websocket)
                            await manager.send_message({"type": "stream_end", "full": _nc_out}, websocket)
                            last_clean_response = _nc_out
                            _native_done = True
                        break

                    # Process each tool call from the native response
                    for _tc_item in _native_tc:
                        t_name = _normalize_tool_name(_tc_item["name"])
                        t_args = _tc_item.get("arguments") or {}

                        # Policy gates (same as text-based loop)
                        if t_name in disabled_tools:
                            tool_result = f"TOOL_DISABLED: Skill '{t_name}' is disabled in Settings > Skills."
                            latest_tool_name = t_name
                            latest_tool_result = tool_result
                            await manager.send_message({"type": "tool_result", "tool": t_name, "result": tool_result, "step_index": step_index}, websocket)
                            tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result}")
                            _native_messages.append({"role": "assistant", "tool_calls": [{"type": "function", "function": {"name": _tc_item["name"], "arguments": t_args}}]})
                            _native_messages.append({"role": "tool", "content": tool_result})
                            continue
                        if t_name in blocked_tools:
                            tool_result = f"POLICY_DENIED: Skill '{t_name}' is blocked by policy."
                            latest_tool_name = t_name
                            latest_tool_result = tool_result
                            await manager.send_message({"type": "tool_result", "tool": t_name, "result": tool_result, "step_index": step_index}, websocket)
                            tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result}")
                            _native_messages.append({"role": "assistant", "tool_calls": [{"type": "function", "function": {"name": _tc_item["name"], "arguments": t_args}}]})
                            _native_messages.append({"role": "tool", "content": tool_result})
                            continue
                        if t_name in approval_tools and not require_approval:
                            approved, _ = await _request_tool_approval(step_index, t_name, t_args)
                            if not approved:
                                tool_result = f"HITL_REQUIRED: Skill '{t_name}' was not approved by user."
                                latest_tool_name = t_name
                                latest_tool_result = tool_result
                                await manager.send_message({"type": "tool_result", "tool": t_name, "result": tool_result, "step_index": step_index}, websocket)
                                tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result}")
                                _native_messages.append({"role": "assistant", "tool_calls": [{"type": "function", "function": {"name": _tc_item["name"], "arguments": t_args}}]})
                                _native_messages.append({"role": "tool", "content": tool_result})
                                continue

                        if run_budget["tool_calls"] >= run_budget["max_tool_calls"]:
                            await _halt_run("BUDGET_TOOL_CALLS", f"Run stopped: tool-call budget exceeded ({run_budget['tool_calls']} / {run_budget['max_tool_calls']}).")
                            break
                        run_budget["tool_calls"] += 1

                        await manager.send_message({"type": "tool_call", "tool": t_name, "args": t_args, "step_index": step_index}, websocket)
                        await track_tokens("tool", str(t_args), "")

                        cache_key = _tool_call_signature(t_name, t_args)
                        if t_name in _READONLY_TOOL_NAMES and cache_key in tool_call_cache:
                            tool_result, tool_details, tool_result_for_context = tool_call_cache[cache_key]
                        else:
                            loop = asyncio.get_event_loop()
                            tool_result = await loop.run_in_executor(None, _execute_tool, t_name, t_args)
                            tool_details = build_tool_result_details(t_name, tool_result)
                            tool_result_for_context = compact_tool_result_for_context(t_name, tool_result, tool_details)
                            if t_name in _READONLY_TOOL_NAMES:
                                tool_call_cache[cache_key] = (tool_result, tool_details, tool_result_for_context)

                        latest_tool_name = t_name
                        latest_tool_args = dict(t_args)
                        latest_tool_result = tool_result
                        latest_tool_details = tool_details
                        _append_workflow_context_artifact(
                            session,
                            tool_name=t_name,
                            tool_args=t_args,
                            tool_result=tool_result,
                            tool_details=tool_details,
                            stage="executor",
                        )
                        if t_name in _SIDE_EFFECT_TOOLS and not tool_result.startswith(("Error", "POLICY_DENIED", "HITL_REQUIRED")):
                            used_side_effect_tool = True
                        await manager.send_message({"type": "tool_result", "tool": t_name, "result": _tool_result_ui_snippet(t_name, tool_result_for_context), "step_index": step_index}, websocket)
                        tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result_for_context}")

                        # Append assistant tool_call + tool result to messages
                        _native_messages.append({"role": "assistant", "tool_calls": [{"type": "function", "function": {"name": _tc_item["name"], "arguments": t_args}}]})
                        _native_messages.append({"role": "tool", "content": tool_result_for_context})

                    if run_budget["halted"]:
                        break
                # End native loop

        # ── Text-based fallback loop (non-Ollama, or native didn't complete) ──
        if not _native_done:
            for _turn in range(5):
                if run_budget["halted"] or (not await _check_runtime("executor_turn")):
                    break
                response = await _stream_to_ws(current_prompt, current_system)
                tc, cleaned_response, has_tool_marker = _extract_tool_call(response)
                if has_tool_marker:
                    await manager.send_message({"type": "stream_clear"}, websocket)
                if not has_tool_marker:
                    if (_goal_requests_side_effect(step_desc) or _cl_is_action) and not tool_log and _reply_claims_action(cleaned_response or response):
                        current_prompt = (
                            f"Task: {step_desc}\n\n"
                            "Your previous reply only described an intended action without actually doing it. "
                            "For action requests, you MUST either:\n"
                            "1. emit a TOOL_CALL that performs or gathers what is needed for the action, or\n"
                            "2. ask one short clarifying question if essential information is genuinely missing.\n\n"
                            "Do NOT answer with phrases like 'I will', 'I am going to', or 'first I will check' unless you also make the tool call."
                        )
                        continue
                    last_clean_response = response
                    break
                if not isinstance(tc, dict):
                    last_clean_response = cleaned_response.strip()
                    break

                t_name = _normalize_tool_name(tc.get("tool", ""))
                t_args = tc.get("args", {})

                if t_name in disabled_tools:
                    tool_result = (
                        f"TOOL_DISABLED: Skill '{t_name}' is disabled in Settings > Skills. "
                        "Do not create a copy skill. Advise the user to enable this skill."
                    )
                    latest_tool_name = t_name
                    latest_tool_result = tool_result
                    await manager.send_message({
                        "type": "tool_result", "tool": t_name,
                        "result": tool_result, "step_index": step_index,
                    }, websocket)
                    tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result}")
                    current_prompt = (
                        f"Skill '{t_name}' returned:\n{tool_result}\n\n"
                        "Continue without disabled skills. "
                        "Tell the user to enable the skill in Settings > Skills when needed. "
                        "Never use create_skill/create_tool as a workaround for a disabled skill."
                    )
                    continue

                if t_name in blocked_tools:
                    tool_result = f"POLICY_DENIED: Skill '{t_name}' is blocked by policy for this run."
                    latest_tool_name = t_name
                    latest_tool_result = tool_result
                    await manager.send_message({
                        "type": "tool_result", "tool": t_name,
                        "result": tool_result, "step_index": step_index,
                    }, websocket)
                    tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result}")
                    current_prompt = (
                        f"Skill '{t_name}' returned:\n{tool_result}\n\n"
                        f"Continue task without this skill or request an alternative skill call."
                    )
                    continue

                if t_name in approval_tools and not require_approval:
                    approved, _always = await _request_tool_approval(step_index, t_name, t_args)
                    if not approved:
                        tool_result = f"HITL_REQUIRED: Skill '{t_name}' was not approved by user."
                        latest_tool_name = t_name
                        latest_tool_result = tool_result
                        await manager.send_message({
                            "type": "tool_result", "tool": t_name,
                            "result": tool_result, "step_index": step_index,
                        }, websocket)
                        tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result}")
                        current_prompt = (
                            f"Skill '{t_name}' returned:\n{tool_result}\n\n"
                            f"Continue task without this skill or request an alternative skill call."
                        )
                        continue

                if run_budget["tool_calls"] >= run_budget["max_tool_calls"]:
                    await _halt_run(
                        "BUDGET_TOOL_CALLS",
                        f"Run stopped: tool-call budget exceeded ({run_budget['tool_calls']} / {run_budget['max_tool_calls']}).",
                    )
                    break
                run_budget["tool_calls"] += 1

                await manager.send_message({
                    "type": "tool_call", "tool": t_name, "args": t_args, "step_index": step_index,
                }, websocket)
                await track_tokens("tool", str(t_args), "")
                cache_key = _tool_call_signature(t_name, t_args)
                if t_name in _READONLY_TOOL_NAMES and cache_key in tool_call_cache:
                    tool_result, tool_details, tool_result_for_context = tool_call_cache[cache_key]
                    await manager.send_message(
                        {"type": "status", "content": f"No-progress guard — reusing {t_name} result."},
                        websocket,
                    )
                else:
                    loop = asyncio.get_event_loop()
                    tool_result = await loop.run_in_executor(None, _execute_tool, t_name, t_args)
                    tool_details = build_tool_result_details(t_name, tool_result)
                    tool_result_for_context = compact_tool_result_for_context(t_name, tool_result, tool_details)
                    if t_name in _READONLY_TOOL_NAMES:
                        tool_call_cache[cache_key] = (tool_result, tool_details, tool_result_for_context)
                latest_tool_name = t_name
                latest_tool_args = dict(t_args)
                latest_tool_result = tool_result
                latest_tool_details = tool_details
                _append_workflow_context_artifact(
                    session,
                    tool_name=t_name,
                    tool_args=t_args,
                    tool_result=tool_result,
                    tool_details=tool_details,
                    stage="executor",
                )
                if t_name in _SIDE_EFFECT_TOOLS and not tool_result.startswith(("Error", "POLICY_DENIED", "HITL_REQUIRED")):
                    used_side_effect_tool = True
                await manager.send_message({
                    "type": "tool_result", "tool": t_name,
                    "result": _tool_result_ui_snippet(t_name, tool_result_for_context), "step_index": step_index,
                }, websocket)
                tool_log.append(f"Tool: {t_name}\nArgs: {json.dumps(t_args)}\nResult:\n{tool_result_for_context}")
                current_prompt = (
                    f"Skill '{t_name}' returned:\n{tool_result_for_context}\n\n"
                    f"Now write your complete answer to the task: {step_desc}\n"
                    f"If you need more information use another TOOL_CALL, otherwise write the full answer directly.\n"
                    f"IMPORTANT: Write your answer in plain, human-readable language. "
                    f"NEVER output code blocks, raw commands, curl, or scripts as an answer."
                )
                current_system = agent_system + TOOLS_DESC
            else:
                last_clean_response = ""

        if not last_clean_response:
            tools_summary = "\n\n---\n\n".join(tool_log) if tool_log else "(no tools were called)"
            synth_prompt = (
                f"Goal: {goal}\n"
                f"Task: {step_desc}\n\n"
                f"You gathered the following information using tools:\n\n{tools_summary}\n\n"
                "Using ONLY the information above, write a complete, direct answer to the task. "
                "Do NOT call any more tools. Do NOT output code blocks, commands, or scripts. "
                "Write the full answer in plain, human-readable language now."
            )
            last_clean_response = await _stream_to_ws(synth_prompt, agent_system)

        if latest_tool_result and len(re.sub(r"\s+", "", last_clean_response or "")) < 20:
            if latest_tool_name in {"outlook_get_inbox", "outlook_search_emails"}:
                if latest_tool_result.strip().lower().startswith("no emails"):
                    last_clean_response = "I found 0 emails for the requested period."
                else:
                    email_count = len(re.findall(r"(?m)^Mailbox:\s", latest_tool_result))
                    if email_count > 0:
                        last_clean_response = f"I found {email_count} emails for the requested period."
                    else:
                        last_clean_response = latest_tool_result[:3000]
            else:
                last_clean_response = latest_tool_result[:3000]

        if latest_tool_name in {
            "outlook_send_email",
            "outlook_update_email",
            "outlook_delete_email",
            "gmail_send_email",
            "gmail_update_email",
            "gmail_delete_email",
            "outlook_create_calendar_event",
            "outlook_update_calendar_event",
            "outlook_delete_calendar_event",
            "google_create_calendar_event",
            "google_update_calendar_event",
            "google_delete_calendar_event",
        } and latest_tool_result.startswith("Calendar event"):
            last_clean_response = latest_tool_result[:3000]

        _latest_domain = ""
        if latest_tool_name in _EMAIL_LOOKUP_TOOL_NAMES:
            _latest_domain = "email"
        elif latest_tool_name in {"outlook_read_calendar", "google_read_calendar"}:
            _latest_domain = "calendar"

        if latest_tool_name != "summarize_personal_items" and latest_tool_result and _latest_domain and _goal_requests_personal_summary(goal, _latest_domain):
            _summary_norm = _normalize_tool_name("summarize_personal_items")
            _summary_allowed = (
                _summary_norm not in blocked_tools
                and _summary_norm not in disabled_tools
                and _summary_norm not in approval_tools
                and run_budget["tool_calls"] < run_budget["max_tool_calls"]
            )
            _summary_records: list[dict] = []
            if _latest_domain == "email":
                _summary_records = extract_email_lookup_records(latest_tool_result)
            elif _latest_domain == "calendar":
                _summary_records = extract_calendar_lookup_records(latest_tool_result)

            if _summary_allowed and _summary_records:
                _summary_args = {
                    "source_type": _latest_domain,
                    "items": _summary_records,
                    "max_items": 6,
                    "style": "brief",
                }
                run_budget["tool_calls"] += 1
                await manager.send_message({
                    "type": "tool_call", "tool": "summarize_personal_items", "args": _summary_args, "step_index": step_index,
                }, websocket)
                _loop = asyncio.get_event_loop()
                _summary_result = await _loop.run_in_executor(None, _execute_tool, "summarize_personal_items", _summary_args)
                _summary_details = build_tool_result_details("summarize_personal_items", _summary_result)
                _summary_compact = compact_tool_result_for_context("summarize_personal_items", _summary_result, _summary_details)
                await manager.send_message({
                    "type": "tool_result", "tool": "summarize_personal_items", "result": _tool_result_ui_snippet("summarize_personal_items", _summary_compact), "step_index": step_index,
                }, websocket)
                latest_tool_name = "summarize_personal_items"
                latest_tool_args = _summary_args
                latest_tool_result = _summary_result
                latest_tool_details = _summary_details
                _append_workflow_context_artifact(
                    session,
                    tool_name="summarize_personal_items",
                    tool_args=_summary_args,
                    tool_result=_summary_result,
                    tool_details=_summary_details,
                    stage="executor",
                )

                try:
                    _parsed_summary = json.loads(_summary_result)
                except Exception:
                    _parsed_summary = None
                if isinstance(_parsed_summary, dict):
                    _summary_msg = str(_parsed_summary.get("message") or "").strip()
                    if _summary_msg:
                        last_clean_response = _summary_msg

        if latest_tool_name and latest_tool_result:
            tool_envelope = build_tool_result_envelope(
                goal,
                step_desc,
                latest_tool_name,
                latest_tool_result,
                last_clean_response,
                latest_tool_details,
            )
            if str(tool_envelope.get("rendered_result") or "").strip():
                last_clean_response = str(tool_envelope.get("rendered_result") or "").strip()
            composed_from_tool = bool(tool_envelope.get("was_composed"))

        if last_clean_response:
            _code_blocks = re.findall(r"```[\s\S]*?```", last_clean_response)
            _code_chars = sum(len(b) for b in _code_blocks)
            _total_chars = len(last_clean_response.strip())
            _has_command_pattern = bool(re.search(
                r"(?m)^(?:curl |wget |bash |sh |pip |npm |docker |git clone|powershell|Invoke-)",
                last_clean_response
            ))
            if _total_chars > 0 and (
                (_code_chars / _total_chars > 0.5)
                or (_has_command_pattern and _code_chars / _total_chars > 0.3)
            ):
                _rewrite_prompt = (
                    f"Goal: {goal}\n"
                    f"Task: {step_desc}\n\n"
                    f"The previous answer contained raw code or commands instead of a proper response:\n"
                    f"{last_clean_response[:5000]}\n\n"
                    "Rewrite this as a clean, human-readable answer. "
                    "Extract the FACTS and DATA from the code/output and present them clearly. "
                    "NEVER include code blocks, commands, curl, bash, or scripts. "
                    "Write ONLY the substantive answer in plain language."
                )
                last_clean_response = await call_fast(
                    _rewrite_prompt, system=agent_system, num_ctx=_model_ctx(16384),
                )

            last_clean_response = _strip_tool_call_artifacts(last_clean_response)
            last_clean_response = _strip_filler_phrases(last_clean_response)
            if latest_tool_result:
                last_clean_response = _normalize_tool_json_reply(last_clean_response, latest_tool_result)

        return last_clean_response, used_side_effect_tool, {
            "tool_name": latest_tool_name,
            "tool_args": latest_tool_args,
            "tool_result": latest_tool_result[:3000],
            "tool_details": latest_tool_details,
            "deterministic_result": composed_from_tool,
        }

    # Sub-agent D — Validator
    async def validator_agent(step_desc: str, result: str) -> str:
        if run_budget["halted"] or (not await _check_runtime("validator")):
            return "ISSUE: Run halted by budget policy"
        validator_max_chars = 2400 if speed_mode else 9000

        def _build_validator_result_view(text: str, limit: int) -> tuple[str, bool]:
            if len(text) <= limit:
                return text, False
            head = max(500, int(limit * 0.7))
            tail = max(300, limit - head)
            marker = "\n\n[... middle omitted for validator context ...]\n\n"
            preview = text[:head] + marker + text[-tail:]
            return preview, True

        result_view, was_truncated = _build_validator_result_view(result, validator_max_chars)
        truncation_note = (
            "NOTE: The result excerpt below is intentionally truncated in the middle. "
            "Do NOT report ISSUE only because the excerpt boundary cuts a sentence. "
            "Report ISSUE only for real quality/completeness problems.\n\n"
            if was_truncated else ""
        )
        prompt = (
            f"Step: {step_desc}\n\n"
            f"{truncation_note}"
            f"Result produced:\n{result_view}\n\n"
            "Does this result fully and correctly address the step? "
            "Reply with one line: PASS if yes, or ISSUE: <brief reason> if not."
        )
        verdict = await call_fast(
            prompt,
            system="You are a quality control agent. Be strict but fair. Reply with exactly PASS or ISSUE: <reason>.",
            num_ctx=12288,
        )
        return verdict.strip()

    async def _try_executor_plan_b(step_desc: str, step_index: int) -> tuple[str, bool, dict] | None:
        if not manager.active_runs.get(run_id, False):
            return None

        stored_task_state = scope_task_state_to_domain(
            session.get("task_state") if isinstance(session.get("task_state"), dict) else None,
            _protocol_route.domain,
        )
        classified_hint = None
        raw_classifier = session.get("intent_classifier")
        if len(steps) == 1 and step_index in {-1, 0} and isinstance(raw_classifier, dict):
            classified_hint = dict(raw_classifier)

        direct_plan = plan_direct_execution(
            step_desc,
            route=_protocol_route,
            stored_task_state=stored_task_state,
            classified_intent=classified_hint,
            is_action=False,
            use_outlook=bool(use_outlook and OUTLOOK_SUPPORT),
            gmail_available=bool(_gmail_available),
            settings=run_settings,
            dynamic_tools=_dynamic_tools,
            mcp_cache=_mcp_get_cache(),
        )

        direct_graph = direct_plan.task_graph
        if direct_graph is None and direct_plan.calls:
            direct_graph = task_graph_from_direct_calls(
                direct_plan.goal or step_desc,
                protocol=str(getattr(direct_plan.graph_route, "protocol", "read") or "read"),
                domain=str(getattr(direct_plan.graph_route, "domain", "general") or "general"),
                calls=[(call["tool"], dict(call.get("args") or {})) for call in direct_plan.calls],
                display_goal=direct_plan.display_goal or step_desc,
                metadata=dict(direct_plan.metadata or {}),
                side_effect=False,
            )
        if direct_graph is None:
            return None

        validation = _store_task_graph_with_validation(
            session,
            direct_graph,
            blocked_tools=blocked_tools,
            disabled_tools=disabled_tools,
            approval_tools=approval_tools,
            max_tasks=run_budget["max_tool_calls"],
            max_depth=max(1, min(run_budget["max_steps"], len(direct_graph.tasks) or 1)),
            allow_side_effects=False,
        )
        if not validation.valid:
            return None

        direct_results = await _execute_task_graph(
            direct_graph,
            websocket=websocket,
            run_id=run_id,
            step_index_base=step_index,
        )
        if not direct_results:
            return None

        rendered_results: list[str] = []
        latest_meta = {
            "tool_name": "",
            "tool_args": {},
            "tool_result": "",
            "tool_details": {},
            "deterministic_result": True,
            "plan_b_applied": True,
        }
        for task, (tool_name, tool_result) in zip(direct_graph.tasks, direct_results):
            tool_args = dict(getattr(task, "args", {}) or {})
            tool_envelope = build_tool_result_envelope(
                goal,
                step_desc,
                tool_name,
                tool_result,
                tool_result,
            )
            tool_details = dict(tool_envelope.get("details") or {})
            _append_workflow_context_artifact(
                session,
                tool_name=tool_name,
                tool_args=tool_args,
                tool_result=tool_result,
                tool_details=tool_details,
                stage="executor_plan_b",
            )
            _record_hypothesis_evidence(step_index, tool_name, tool_result, tool_details)
            rendered_results.append(str(tool_envelope.get("rendered_result") or "").strip())
            latest_meta = {
                "tool_name": tool_name,
                "tool_args": tool_args,
                "tool_result": str(tool_result or "")[:3000],
                "tool_details": tool_details,
                "deterministic_result": True,
                "plan_b_applied": True,
            }

        step_result = compose_direct_results(
            step_desc,
            direct_results,
            "\n\n---\n\n".join(text for text in rendered_results if text),
        ).strip()
        if not step_result:
            step_result = str(latest_meta.get("tool_result") or "").strip()
        if _result_looks_failed(step_result):
            return None

        return step_result, False, latest_meta

    async def _should_stop_after_step(step_desc: str, step_result: str, had_side_effect: bool, step_index: int) -> bool:
        if had_side_effect:
            return False
        if step_index >= len(steps) - 1:
            return False
        if multi_goal_mode:
            return False
        if not (step_result or "").strip():
            return False
        if known_copilot_kind:
            return False
        if _cl_is_action:
            return False

        completed_summary = "\n\n".join(
            f"Step {j+1}: {m['step']}\nResult: {(m['result'] or '')[:1000]}"
            for j, m in enumerate(memory)
        )
        remaining = "\n".join(f"- {s['step']}" for s in steps[step_index + 1:]) or "- none"
        stop_check = await call_fast(
            (
                f"Original user goal: {goal}\n\n"
                f"Completed results so far:\n{completed_summary}\n\n"
                f"Remaining planned steps:\n{remaining}\n\n"
                "Should the workflow STOP NOW because the user already has the final answer?\n"
                "Rules:\n"
                "- STOP only if the completed results already fully and completely answer the user's request with no gaps.\n"
                "- If the user explicitly named one provider/source/folder, results from that exact provider/source/folder are sufficient.\n"
                "- CONTINUE if any required part of the answer is missing, incomplete, or could be improved by remaining steps.\n"
                "- When in doubt, CONTINUE — it is better to do slightly more work than to leave the user with an incomplete answer.\n\n"
                "Reply with exactly one line: STOP or CONTINUE: <brief reason>."
            ),
            system="You are a strict workflow stop evaluator. Reply with exactly STOP or CONTINUE: <brief reason>.",
            num_ctx=4096,
        )
        return (stop_check or "").strip().upper().startswith("STOP")

    # Sub-agent E — Report Out
    async def _emit_thinking_events(raw: str) -> str:
        """Extract <think> blocks, send as WS events, return cleaned text."""
        if not run_settings.get("show_thinking", False):
            return re.sub(r'<think>[\s\S]*?</think>', '', raw).strip()
        blocks = re.findall(r'<think>([\s\S]*?)</think>', raw or "")
        if blocks:
            await manager.send_message({"type": "stream_thinking_start", "run_id": run_id}, websocket)
            for b in blocks:
                await manager.send_message({"type": "stream_thinking", "content": b, "run_id": run_id}, websocket)
            await manager.send_message({"type": "stream_thinking_end", "run_id": run_id}, websocket)
            await asyncio.sleep(0)
        return re.sub(r'<think>[\s\S]*?</think>', '', raw).strip()

    async def report_out_agent() -> str:
        if run_budget["halted"]:
            return f"Run stopped by policy: {run_budget['halt_reason']}."
        if not memory:
            return "No completed steps were available to build a final report."
        if multi_goal_mode:
            todo_report = _build_multi_goal_final_report(goal, steps, memory)
            if todo_report:
                return todo_report
        _want_thinking = run_settings.get("show_thinking", False)
        _report_routine_kind = known_copilot_kind
        if _report_routine_kind in {"morning_brief", "follow_up"}:
            _report_snippet_limit = 1200 if speed_mode else 1800
            raw_results = []
            for m in memory:
                result_text = (m.get("result", "") or "").strip()
                if result_text:
                    raw_results.append(result_text[:_report_snippet_limit])
            combined_results = "\n---\n".join(raw_results)
            if _report_routine_kind == "morning_brief":
                prompt = build_morning_brief_report_prompt(goal, combined_results)
                report_system = build_morning_brief_report_system(_current_date_line())
            else:
                prompt = build_followup_brief_report_prompt(goal, combined_results)
                report_system = build_followup_brief_report_system(_current_date_line())
            if speed_mode:
                report = await call_fast(prompt, system=report_system, num_ctx=_model_ctx(8192))
            else:
                report = await call_with_vision(prompt, system=report_system, num_ctx=_model_ctx(12288), thinking=_want_thinking)
            report = await _emit_thinking_events(report or "")
            # Background: enrich user.md from briefing data (#11)
            try:
                from backend.copilot_briefing import (
                    _collect_morning_brief_inputs,
                    enrich_user_profile_from_briefing,
                    extract_email_enrichments,
                )
                _b_emails, _b_events, _, _, _ = _collect_morning_brief_inputs(memory)
                _b_enrichments = extract_email_enrichments(memory)
                _b_suggestions = enrich_user_profile_from_briefing(_b_emails, _b_events, _b_enrichments)
                if _b_suggestions:
                    _user_path = Path("user.md")
                    if _user_path.exists():
                        _existing = _user_path.read_text(encoding="utf-8")
                        _new_lines = [s for s in _b_suggestions if s.strip().lstrip("- ").lower() not in _existing.lower()]
                        if _new_lines:
                            _user_path.write_text(
                                _existing.rstrip() + "\n" + "\n".join(_new_lines) + "\n",
                                encoding="utf-8",
                            )
            except Exception:
                pass
            return (report or "").strip()
        _report_snippet_limit = 2500 if speed_mode else 4000
        raw_results = []
        for m in memory:
            result_text = (m.get("result", "") or "").strip()
            if result_text:
                raw_results.append(result_text[:_report_snippet_limit])
        combined_results = "\n---\n".join(raw_results)
        prompt = (
            f"User's request: {goal}\n\n"
            f"Raw data collected:\n{combined_results}\n\n"
            "─── YOUR TASK ───\n"
            "Write the FINAL ANSWER the user will read.  This is the ONLY thing they see — "
            "they have NOT seen any intermediate steps or tool output.\n\n"
            "FORMAT (use exactly these headers):\n\n"
            "Answer\n"
            "Write the complete, direct answer here.  This is the ENTIRE deliverable.\n"
            "- Present the actual content: data, facts, numbers, email contents, names, dates, URLs, code, etc.\n"
            "- Structure with bullet points, numbered lists, or tables where it helps readability.\n"
            "- Be thorough — include every relevant detail from the raw data.  Do NOT summarise away specifics.\n"
            "- If the user asked for a list, give the full list.  If they asked for email content, show it.\n\n"
            "Notes\n"
            "(ONLY if there were real problems, incomplete data, or important caveats.  "
            "Omit this section entirely when everything is fine.)\n\n"
            "ABSOLUTE RULES:\n"
            "1. ZERO process narration.  Never say 'I searched…', 'I found…', 'The agent performed…', "
            "'Based on the research…', 'After analysing…'.  Just present the answer.\n"
            "2. BAD: 'I searched your inbox and found 5 emails.'  "
            "GOOD: '5 emails today:\n1. From john@ — Meeting tomorrow — …'\n"
            "3. BAD: 'A web search was performed for Python tutorials.'  "
            "GOOD: 'Top Python tutorials:\n1. …'\n"
            "4. Write as if YOU are the knowledgeable answer, not a report about an investigation.\n"
            "5. Write in the same language as the user's request.\n"
            "6. Do not invent information not present in the raw data.\n"
            "7. If the raw data references past sessions, include inline citations as "
            "[\U0001f4ce DATE: goal] for traceability."
        )
        report_system = (
            f"{_current_date_line()} "
            "You deliver clear, direct answers.  Your output is the final thing the user reads — "
            "give them the actual content they asked for, not a story about how it was found.  "
            "Never describe steps, process, or methodology.  Just the answer."
        )
        if speed_mode:
            report = await call_fast(prompt, system=report_system, num_ctx=_model_ctx(12288))
        else:
            report = await call_with_vision(prompt, system=report_system, num_ctx=_model_ctx(16384), thinking=_want_thinking)
        report = await _emit_thinking_events(report or "")
        return report

    # ── Execute each step through the pipeline ──────────────────────────────────
    research_cache: dict[int, str] = {}
    search_steps = [(idx, s) for idx, s in enumerate(steps) if s["needs_search"]]
    if search_steps:
        await manager.send_message({
            "type": "info",
            "content": f"⚡ Parallel research: pre-fetching {len(search_steps)} search(es) simultaneously…"
        }, websocket)
        await agent_event("researcher", "active", msg=f"Running {len(search_steps)} search(es) in parallel")

        async def _prefetch_research(idx: int, step_obj: dict) -> tuple[int, str]:
            brief = await researcher_agent(step_obj["step"], step_obj["search_hint"], idx)
            return idx, brief

        prefetch_results = await asyncio.gather(
            *[_prefetch_research(idx, s) for idx, s in search_steps],
            return_exceptions=True,
        )
        for item in prefetch_results:
            if isinstance(item, tuple):
                research_cache[item[0]] = item[1]
        await agent_event("researcher", "done",
                          msg=f"{len(research_cache)}/{len(search_steps)} briefs ready")
        await manager.send_message({
            "type": "info",
            "content": f"✓ Research complete: {len(research_cache)} brief(s) cached. Starting execution loop."
        }, websocket)

    # Phase B: sequential execution loop
    memory = []
    awaiting_user_input = False
    for i, step_obj in enumerate(steps):
        if run_budget["steps_executed"] >= run_budget["max_steps"]:
            await _halt_run(
                "BUDGET_STEPS",
                f"Run stopped: step budget exceeded ({run_budget['steps_executed']} / {run_budget['max_steps']}).",
            )
            break
        run_budget["steps_executed"] += 1

        if not await _wait_if_paused(run_id):
            await manager.send_message({"type": "info", "content": "Orchestration stopped by user."}, websocket)
            break
        if run_budget["halted"] or (not await _check_runtime("step_loop")):
            break

        step_desc   = step_obj["step"]
        needs_search = step_obj["needs_search"]
        search_hint  = step_obj["search_hint"]
        direct_calls = list(step_obj.get("direct_calls") or [])

        await manager.send_message({
            "type": "step_start", "step_index": i,
            "content": f"Step {i+1}/{len(steps)}: {step_desc}"
        }, websocket)
        await agent_event("all", "idle", i)
        _current_attempt = (_ensure_todo_progress_items(session, steps)[i] or {}).get("attempt") or 0
        await _push_todo_progress(
            "step_started",
            step_index=i,
            step_fields={
                "state": "active",
                "attempt": max(1, int(_current_attempt)),
                "evaluation": "",
                "approach": "Primary approach in progress.",
                "result_preview": "",
                "goal_met": False,
            },
        )

        if known_copilot_kind and direct_calls:
            await manager.send_message({"type": "status", "content": f"Step {i+1}: Executing…"}, websocket)
            await agent_event("executor", "active", i, f"{step_desc[:60]}")
            graph = task_graph_from_direct_calls(
                step_desc,
                protocol="read",
                domain=known_copilot_kind,
                calls=direct_calls,
                display_goal=step_desc,
                metadata={"goal": goal, "copilot": known_copilot_kind},
                side_effect=False,
                dependency_mode="independent",
            )
            validation = _store_task_graph_with_validation(
                session,
                graph,
                blocked_tools=blocked_tools,
                disabled_tools=disabled_tools,
                approval_tools=approval_tools,
                max_tasks=run_budget["max_tool_calls"],
                max_depth=max(1, len(direct_calls)),
                allow_side_effects=False,
            )
            if not validation.valid:
                step_result = "Unable to run Morning Brief fetch tasks: " + "; ".join(validation.errors or ["task graph invalid"])
                step_entries = [{
                    "step": step_desc,
                    "result": step_result,
                    "verdict": "ISSUE",
                    "tool_name": "",
                    "tool_result": "",
                    "tool_details": {},
                    "deterministic_result": False,
                }]
                verdict = "ISSUE"
                await agent_event("executor", "error", i, step_result[:80])
                await agent_event("validator", "done", i, "ISSUE")
            else:
                direct_results = await _execute_task_graph(
                    graph,
                    websocket=websocket,
                    run_id=run_id,
                    step_index_base=i,
                )
                step_entries = []
                rendered_results: list[str] = []
                for tool_name, tool_result in direct_results:
                    tool_envelope = build_tool_result_envelope(
                        goal,
                        step_desc,
                        tool_name,
                        tool_result,
                        tool_result,
                    )
                    tool_details = dict(tool_envelope.get("details") or {})
                    rendered_result = str(tool_envelope.get("rendered_result") or "").strip()
                    _record_hypothesis_evidence(i, tool_name, tool_result, tool_details)
                    rendered_results.append(rendered_result)
                    step_entries.append({
                        "step": step_desc,
                        "result": rendered_result,
                        "verdict": "PASS",
                        "tool_name": tool_name,
                        "tool_result": tool_result[:3000],
                        "tool_details": tool_details,
                        "deterministic_result": True,
                    })
                step_result = compose_direct_results(
                    step_desc,
                    direct_results,
                    "\n\n---\n\n".join(text for text in rendered_results if text),
                ).strip()
                verdict = "PASS"
                await agent_event("executor", "done", i, f"{len(step_result)} chars produced")
                await agent_event("validator", "done", i, "PASS (Morning Brief direct task graph)")
            memory.extend(step_entries)
            session["steps"].extend(step_entries)
            _save_session(session)
            await _push_todo_progress(
                "step_completed",
                step_index=i,
                step_fields={
                    "state": _todo_progress_state_from_verdict(verdict),
                    "verdict": verdict,
                    "evaluation": verdict if not str(verdict or "").upper().startswith("PASS") else "Step goal achieved.",
                    "approach": "Primary approach completed.",
                    "result_preview": step_result,
                    "goal_met": str(verdict or "").upper().startswith("PASS"),
                },
            )
            await manager.send_message({
                "type": "step_result", "step": step_desc, "result": step_result,
                "verdict": verdict, "memory": memory, "streamed": True,
            }, websocket)
            continue

        if require_approval and not steps_auto_approved:
            ap_event = asyncio.Event()
            manager.pending_approvals[run_id] = {"event": ap_event, "response": None, "note": ""}
            await manager.send_message({
                "type": "approval_needed", "step_index": i, "step": step_desc,
            }, websocket)
            await ap_event.wait()
            ap = dict(manager.pending_approvals.pop(run_id, {}) or {})
            resp = ap.get("response", "reject")
            if resp == "reject":
                note = ap.get("note") or "rejected by user"
                await manager.send_message({"type": "info", "content": f"Step {i+1} skipped: {note}"}, websocket)
                memory.append({"step": step_desc, "result": f"[Skipped: {note}]", "verdict": "SKIP"})
                session["steps"].append({"step": step_desc, "result": f"[Skipped: {note}]", "verdict": "SKIP"})
                await _push_todo_progress(
                    "step_completed",
                    step_index=i,
                    step_fields={
                        "state": "skipped",
                        "attempt": 1,
                        "verdict": "SKIP",
                        "evaluation": note,
                        "approach": "Skipped by user approval flow.",
                        "result_preview": f"[Skipped: {note}]",
                        "goal_met": False,
                    },
                )
                _save_session(session)
                continue
            if resp == "approve_always":
                steps_auto_approved = True
                await manager.send_message({"type": "info", "content": "Auto-approving all remaining steps in this workflow."}, websocket)
            if ap.get("note"):
                await manager.send_message({"type": "info", "content": f"Note from user: {ap['note']}"}, websocket)

        research_brief = ""
        if needs_search:
            research_brief = research_cache.get(i, "")
            if research_brief:
                await manager.send_message({"type": "info", "content": f"Step {i+1}: Using pre-fetched research brief."}, websocket)
                await agent_event("researcher", "done", i, "Using cached brief")
            else:
                await manager.send_message({"type": "status", "content": f"Step {i+1}: Researching… (cache miss)"}, websocket)
                research_brief = await researcher_agent(step_desc, search_hint, i)
            await handoff("researcher", "curator", i,
                          f"{research_brief[:80].replace(chr(10),' ')}…" if len(research_brief) > 80 else research_brief[:80])
        else:
            await agent_event("researcher", "skip", i, "No web search needed")

        # Skip curator LLM call for simple single-step queries — pass any
        # user context directly to the executor without an LLM summarisation pass.
        if known_copilot_kind:
            curated = ""
            await manager.send_message({
                "type": "context_curated", "step_index": i, "briefing": curated,
            }, websocket)
            await agent_event("curator", "skip", i, f"{known_copilot_kind} fast-path")
        elif _execution_mode == "simple" and not memory:
            curated = safe_context[:4000] if safe_context else ""
            if past_memory and not curated:
                curated = past_memory[:2000]
            await manager.send_message({
                "type": "context_curated", "step_index": i, "briefing": curated,
            }, websocket)
            await agent_event("curator", "skip" if not curated else "done", i,
                              "Direct context pass (no LLM curation)" if curated else "Skipped (simple mode)")
        else:
            await manager.send_message({"type": "status", "content": f"Step {i+1}: Curating context…"}, websocket)
            curated = await curator_agent(step_desc, i, memory, research_brief)
            await manager.send_message({
                "type": "context_curated", "step_index": i, "briefing": curated,
            }, websocket)
        await handoff("curator", "executor", i,
                      f"Curated context ready ({len(curated)} chars)" if curated else "No prior context needed")

        await manager.send_message({"type": "status", "content": f"Step {i+1}: Executing…"}, websocket)
        await agent_event("executor", "active", i, f"{step_desc[:60]}")
        step_result, had_side_effect, step_meta = await executor_agent(step_desc, curated, research_brief, step_index=i, needs_search=needs_search, completed_steps=memory)
        await agent_event("executor", "done", i, f"{len(step_result)} chars produced")
        await manager.send_message({
            "type": "step_draft", "step_index": i, "step": step_desc,
            "result": step_result, "attempt": 1,
        }, websocket)
        await handoff("executor", "validator", i, f"Result ready ({len(step_result)} chars)")

        if known_copilot_kind:
            verdict = "PASS"
            await agent_event("validator", "done", i, f"PASS ({known_copilot_kind} fast-path)")
        elif _execution_mode == "simple":
            verdict = "PASS"
            await agent_event("validator", "done", i, "PASS (simple mode)")
        elif _can_skip_validator_fast(step_result, had_side_effect):
            verdict = "PASS"
            await agent_event("validator", "done", i, "PASS (fast-path, skip validator call)")
        else:
            await manager.send_message({"type": "status", "content": f"Step {i+1}: Validating…"}, websocket)
            await agent_event("validator", "active", i, "Checking result quality…")
            verdict = await validator_agent(step_desc, step_result)
        max_validation_retries = min((0 if speed_mode else 1), run_budget["max_validation_retries"])
        for retry_num in range(max_validation_retries):
            if not verdict.upper().startswith("ISSUE"):
                break
            await agent_event("validator", "error", i, verdict[:80])
            await manager.send_message({
                "type": "retry", "step_index": i, "attempt": retry_num + 1, "reason": verdict,
            }, websocket)
            await _push_todo_progress(
                "step_retrying",
                step_index=i,
                step_fields={
                    "state": "retrying",
                    "attempt": retry_num + 2,
                    "evaluation": verdict,
                    "approach": "Retrying with revised instructions.",
                    "goal_met": False,
                },
            )
            await manager.send_message({"type": "status", "content": f"Step {i+1}: Retrying (attempt {retry_num + 2})…"}, websocket)
            await handoff("validator", "executor", i, f"Retry {retry_num+1}: {verdict[:60]}")
            await agent_event("executor", "active", i, f"Retry attempt {retry_num + 2}")
            step_result, _, step_meta = await executor_agent(step_desc, curated, research_brief, feedback=verdict, step_index=i, needs_search=needs_search, completed_steps=memory)
            await agent_event("executor", "done", i)
            await manager.send_message({
                "type": "step_draft", "step_index": i, "step": step_desc,
                "result": step_result, "attempt": retry_num + 2,
            }, websocket)
            await handoff("executor", "validator", i, f"Revised result ({len(step_result)} chars)")
            await manager.send_message({"type": "status", "content": f"Step {i+1}: Re-validating…"}, websocket)
            await agent_event("validator", "active", i, f"Re-checking after retry {retry_num+1}…")
            verdict = await validator_agent(step_desc, step_result)

        if _should_attempt_executor_plan_b(step_result, verdict, step_meta, had_side_effect=had_side_effect, cl_is_action=_cl_is_action, known_copilot_kind=known_copilot_kind, run_budget_halted=run_budget["halted"]):
            _plan_b_reason = verdict if str(verdict or "").strip() else "Executor result was insufficient."
            await manager.send_message({
                "type": "retry", "step_index": i, "attempt": max_validation_retries + 1, "reason": _plan_b_reason,
            }, websocket)
            await _push_todo_progress(
                "step_plan_b",
                step_index=i,
                step_fields={
                    "state": "plan_b",
                    "attempt": max_validation_retries + 2,
                    "evaluation": _plan_b_reason,
                    "approach": "Trying an alternative approach (Plan B).",
                    "goal_met": False,
                    "plan_b_applied": True,
                },
            )
            await manager.send_message({
                "type": "status", "content": f"Step {i+1}: Starting Plan B…"}, websocket)
            await handoff("validator", "executor", i, f"Plan B: {_plan_b_reason[:60]}")
            await agent_event("executor", "active", i, "Plan B fallback")
            _plan_b_result = await _try_executor_plan_b(step_desc, i)
            if _plan_b_result is not None:
                step_result, had_side_effect, step_meta = _plan_b_result
                await agent_event("executor", "done", i, f"{len(step_result)} chars produced (Plan B)")
                await manager.send_message({
                    "type": "step_draft", "step_index": i, "step": step_desc,
                    "result": step_result, "attempt": max_validation_retries + 2,
                }, websocket)
                await handoff("executor", "validator", i, f"Plan B result ({len(step_result)} chars)")
                if _can_skip_validator_fast(step_result, had_side_effect):
                    verdict = "PASS"
                else:
                    await manager.send_message({"type": "status", "content": f"Step {i+1}: Validating Plan B…"}, websocket)
                    await agent_event("validator", "active", i, "Checking Plan B result…")
                    verdict = await validator_agent(step_desc, step_result)
        await agent_event("validator", "done", i, verdict)

        _tool_used = str(step_meta.get("tool_name") or "").strip()
        if _tool_used:
            try:
                _was_quarantined = record_custom_tool_feedback(_tool_used, verdict, reason=str(step_result)[:260])
                if _was_quarantined:
                    await manager.send_message({
                        "type": "warning",
                        "content": f"Skill '{_tool_used}' has been automatically disabled after repeated failures. You can re-enable it in Settings > Skills.",
                    }, websocket)
            except Exception:
                pass

        memory.append({"step": step_desc, "result": step_result, "verdict": verdict, **step_meta})
        session["steps"].append({"step": step_desc, "result": step_result, "verdict": verdict, **step_meta})
        _record_hypothesis_evidence(
            i,
            str(step_meta.get("tool_name") or ""),
            str(step_meta.get("tool_result") or ""),
            dict(step_meta.get("tool_details") or {}),
        )
        _stop_after_remediation = await _apply_auto_remediation(i)
        _save_session(session)

        _step_passed = str(verdict or "").upper().startswith("PASS")
        await _push_todo_progress(
            "step_completed",
            step_index=i,
            step_fields={
                "state": _todo_progress_state_from_verdict(verdict),
                "verdict": verdict,
                "evaluation": verdict if not _step_passed else "Step goal achieved.",
                "approach": "Alternative approach succeeded." if step_meta.get("plan_b_applied") else "Primary approach completed.",
                "result_preview": step_result,
                "goal_met": _step_passed,
                "plan_b_applied": bool(step_meta.get("plan_b_applied")),
            },
        )

        await manager.send_message({
            "type": "step_result", "step": step_desc, "result": step_result,
            "verdict": verdict, "memory": memory, "streamed": True,
        }, websocket)

        if _step_result_requires_user_input(step_desc, step_result):
            awaiting_user_input = True
            await _push_todo_progress(
                "goal_evaluation",
                goal_status="awaiting_user",
                goal_evaluation="Waiting for user input before continuing.",
            )
            await manager.send_message({
                "type": "info",
                "content": "Stopping here: waiting for user input before continuing.",
            }, websocket)
            steps = steps[:i + 1]
            break

        if await _should_stop_after_step(step_desc, step_result, had_side_effect, i):
            await manager.send_message({
                "type": "info",
                "content": "Stopping early: the requested answer is already complete.",
            }, websocket)
            steps = steps[:i + 1]
            break

        if _stop_after_remediation and not multi_goal_mode:
            await manager.send_message({
                "type": "info",
                "content": "Stopping early: auto-remediation selected a final evidence-first response.",
            }, websocket)
            steps = steps[:i + 1]
            break

    # ── 3. Re-planning ──────────────────────────────────────────────────────────
    _max_replan_rounds = run_budget["max_replan_rounds"]
    _prior_replan_step_sigs: list[str] = []
    _prior_gaps: list[str] = []

    for _replan_round in range(_max_replan_rounds):
        if awaiting_user_input:
            break
        if _execution_mode == "simple" and memory:
            # SIMPLE mode vangnet: check if the single step actually achieved the goal.
            # If result is empty/error/clearly incomplete, upgrade to NEEDS_PLAN and re-plan.
            _simple_result = (memory[0].get("result") or "").strip() if memory else ""
            _simple_looks_failed = (
                not _simple_result
                or _simple_result.startswith(("Error", "TOOL_DISABLED", "POLICY_DENIED", "HITL_REQUIRED"))
                or len(_simple_result) < 20
            )
            if not _simple_looks_failed:
                break
            # Upgrade: allow re-planning to close the gap
            _execution_mode = "needs_plan"
            await manager.send_message(
                {"type": "info", "content": "Simple execution returned insufficient results — re-evaluating with full planning…"},
                websocket,
            )
            # Fall through to the normal re-planning logic below
        if known_copilot_kind:
            break
        # Skip re-planning when every step was an action/side-effect (e.g. calendar, email)
        _action_markers_re = (
            "uploaded", "sent", "saved", "created", "deleted", "scheduled",
            "posted", "moved", "wrote", "updated", "calendar", "event",
        )
        if memory and all(
            any(m in (mem_item.get("result") or "").lower()[:300] for m in _action_markers_re)
            for mem_item in memory if (mem_item.get("result") or "").strip()
        ):
            break
        if not manager.active_runs.get(run_id, False):
            break
        if run_budget["halted"] or (not await _check_runtime("replan")):
            break
        summary = "\n\n".join(
            f"Step {j+1}: {m['step']}\nResult: {(m['result'] or '')[:300].replace(chr(10), ' ')}"
            for j, m in enumerate(memory)
        )
        await agent_event("all", "idle")
        await agent_event("planner", "active", msg="Re-evaluating goal completion…")
        await manager.send_message({"type": "status", "content": "Checking if goal is fully achieved…"}, websocket)
        completion_check = await call_with_vision(
            f"Original goal: {goal}\n\nCompleted steps and results:\n{summary}\n\n"
            "Has the original goal been fully and satisfactorily achieved?\n"
            "If the steps show that actions were successfully performed (e.g. event created, email sent, "
            "file written), consider the goal COMPLETE — base your judgment on CONCRETE EVIDENCE from "
            "the step results, not on assumptions.\n"
            "Reply with exactly one of:\n"
            "  COMPLETE\n"
            "  INCOMPLETE: <brief explanation of exactly what is still missing>",
            system=(
                "You are a goal completion evaluator. Be practical, concise, and evidence-based. "
                "When actions have been executed and confirmed by their output, the goal is COMPLETE. "
                "Only say INCOMPLETE if a clearly required deliverable is genuinely missing. "
                "Reply ONLY with COMPLETE or INCOMPLETE: <reason>."
            ),
        )
        if completion_check.strip().upper().startswith("COMPLETE"):
            await _push_todo_progress(
                "goal_evaluation",
                goal_status="complete",
                goal_evaluation="Current results satisfy the original goal.",
            )
            break

        gap = completion_check.strip()
        _prior_gaps.append(gap)
        await _push_todo_progress(
            "goal_evaluation",
            goal_status="incomplete",
            goal_evaluation=gap,
        )
        await manager.send_message({"type": "info", "content": f"Re-planning — gap found: {gap}"}, websocket)

        # Build prior-attempts context for subsequent rounds
        _prior_attempts_block = ""
        if len(_prior_gaps) > 1:
            _prior_attempts_block = (
                "Prior replan rounds identified these gaps:\n"
                + "\n".join(f"- {g}" for g in _prior_gaps[:-1])
                + "\nIf previous approaches failed, try a DIFFERENT strategy or tool.\n\n"
            )

        if _planner_learning_block:
            replan_prompt = (
                f"Original goal: {goal}\n\nAlready completed:\n{summary}\n\n"
                f"Gap identified: {gap}\n\n"
                f"{_prior_attempts_block}"
                f"{_planner_learning_block}\n\n"
                f"List ONLY the additional steps needed to close this gap.{internet_note}\n"
                "Return ONLY a valid JSON array of step objects with keys: \"step\", \"needs_search\", \"search_hint\"."
            )
        else:
            replan_prompt = (
                f"Original goal: {goal}\n\nAlready completed:\n{summary}\n\n"
                f"Gap identified: {gap}\n\n"
                f"{_prior_attempts_block}"
                f"List ONLY the additional steps needed to close this gap.{internet_note}\n"
                "Return ONLY a valid JSON array of step objects with keys: \"step\", \"needs_search\", \"search_hint\"."
            )
        replan_raw = await call_with_vision(
            replan_prompt,
            system=agent_system + "\n\nOutput ONLY a valid JSON array of objects. No prose.",
        )
        try:
            extra_items = _parse_model_json(replan_raw, expect="array")
            if not isinstance(extra_items, list) or not extra_items:
                break
            new_steps: list[dict] = []
            for item in extra_items:
                if isinstance(item, str):
                    new_steps.append({"step": item, "needs_search": False, "search_hint": ""})
                elif isinstance(item, dict) and "step" in item:
                    new_steps.append({
                        "step": item["step"],
                        "needs_search": bool(item.get("needs_search", False)) and use_internet,
                        "search_hint": item.get("search_hint", ""),
                    })
            if not new_steps:
                break

            # Stagnation detection — stop if planner is repeating previously attempted steps
            _new_sig = _replan_step_signature(new_steps)
            if _new_sig in _prior_replan_step_sigs:
                await manager.send_message({
                    "type": "info",
                    "content": "planner is repeating previously attempted steps — stopping re-plan to avoid infinite loop.",
                }, websocket)
                break
            _prior_replan_step_sigs.append(_new_sig)

        except Exception:
            break

        offset = len(steps)
        steps.extend(new_steps)
        await manager.send_message({
            "type": "plan_extended", "new_steps": [s["step"] for s in new_steps], "total": len(steps),
        }, websocket)
        await _push_todo_progress(
            "plan_extended",
            goal_status="running",
            goal_evaluation=gap,
        )

        for _extra_i, extra_step_obj in enumerate(new_steps):
            if run_budget["steps_executed"] >= run_budget["max_steps"]:
                await _halt_run(
                    "BUDGET_STEPS",
                    f"Run stopped: step budget exceeded ({run_budget['steps_executed']} / {run_budget['max_steps']}).",
                )
                break
            run_budget["steps_executed"] += 1

            if not await _wait_if_paused(run_id):
                break
            if run_budget["halted"] or (not await _check_runtime("replan_step_loop")):
                break
            i = offset + _extra_i
            step_desc    = extra_step_obj["step"]
            needs_search = extra_step_obj["needs_search"]
            search_hint  = extra_step_obj["search_hint"]

            await manager.send_message({
                "type": "step_start", "step_index": i,
                "content": f"Step {i+1}/{len(steps)}: {step_desc}",
            }, websocket)
            await agent_event("all", "idle", i)
            _current_attempt = (_ensure_todo_progress_items(session, steps)[i] or {}).get("attempt") or 0
            await _push_todo_progress(
                "step_started",
                step_index=i,
                step_fields={
                    "state": "active",
                    "attempt": max(1, int(_current_attempt)),
                    "evaluation": "",
                    "approach": "Primary approach in progress.",
                    "result_preview": "",
                    "goal_met": False,
                },
            )

            if require_approval and not steps_auto_approved:
                ap_event = asyncio.Event()
                manager.pending_approvals[run_id] = {"event": ap_event, "response": None, "note": ""}
                await manager.send_message({
                    "type": "approval_needed", "step_index": i, "step": step_desc,
                }, websocket)
                await ap_event.wait()
                ap = dict(manager.pending_approvals.pop(run_id, {}) or {})
                resp = ap.get("response", "reject")
                if resp == "reject":
                    note = ap.get("note") or "rejected by user"
                    await manager.send_message({"type": "info", "content": f"Step {i+1} skipped: {note}"}, websocket)
                    memory.append({"step": step_desc, "result": f"[Skipped: {note}]", "verdict": "SKIP"})
                    session["steps"].append({"step": step_desc, "result": f"[Skipped: {note}]", "verdict": "SKIP"})
                    await _push_todo_progress(
                        "step_completed",
                        step_index=i,
                        step_fields={
                            "state": "skipped",
                            "attempt": 1,
                            "verdict": "SKIP",
                            "evaluation": note,
                            "approach": "Skipped by user approval flow.",
                            "result_preview": f"[Skipped: {note}]",
                            "goal_met": False,
                        },
                    )
                    _save_session(session)
                    continue
                if resp == "approve_always":
                    steps_auto_approved = True
                    await manager.send_message({"type": "info", "content": "Auto-approving all remaining steps in this workflow."}, websocket)
                if ap.get("note"):
                    await manager.send_message({"type": "info", "content": f"Note from user: {ap['note']}"}, websocket)

            research_brief = ""
            if needs_search:
                await manager.send_message({"type": "status", "content": f"Step {i+1}: Researching…"}, websocket)
                research_brief = await researcher_agent(step_desc, search_hint, i)
                await handoff("researcher", "curator", i,
                              f"{research_brief[:80].replace(chr(10),' ')}…" if len(research_brief) > 80 else research_brief[:80])
            else:
                await agent_event("researcher", "skip", i, "No web search needed")

            await manager.send_message({"type": "status", "content": f"Step {i+1}: Curating context…"}, websocket)
            curated = await curator_agent(step_desc, i, memory, research_brief)
            await manager.send_message({
                "type": "context_curated", "step_index": i, "briefing": curated,
            }, websocket)
            await handoff("curator", "executor", i,
                          f"Curated context ready ({len(curated)} chars)" if curated else "No prior context needed")

            await manager.send_message({"type": "status", "content": f"Step {i+1}: Executing…"}, websocket)
            await agent_event("executor", "active", i, f"{step_desc[:60]}")
            step_result, had_side_effect, step_meta = await executor_agent(step_desc, curated, research_brief, step_index=i, needs_search=needs_search)
            await agent_event("executor", "done", i, f"{len(step_result)} chars produced")
            await manager.send_message({
                "type": "step_draft", "step_index": i, "step": step_desc,
                "result": step_result, "attempt": 1,
            }, websocket)
            await handoff("executor", "validator", i, f"Result ready ({len(step_result)} chars)")

            if _can_skip_validator_fast(step_result, had_side_effect):
                verdict = "PASS"
                await agent_event("validator", "done", i, "PASS (fast-path, skip validator call)")
            else:
                await manager.send_message({"type": "status", "content": f"Step {i+1}: Validating…"}, websocket)
                await agent_event("validator", "active", i, "Checking result quality…")
                verdict = await validator_agent(step_desc, step_result)
            max_validation_retries = min((0 if speed_mode else 1), run_budget["max_validation_retries"])
            for retry_num in range(max_validation_retries):
                if not verdict.upper().startswith("ISSUE"):
                    break
                await agent_event("validator", "error", i, verdict[:80])
                await manager.send_message({
                    "type": "retry", "step_index": i,
                    "attempt": retry_num + 1, "reason": verdict,
                }, websocket)
                await _push_todo_progress(
                    "step_retrying",
                    step_index=i,
                    step_fields={
                        "state": "retrying",
                        "attempt": retry_num + 2,
                        "evaluation": verdict,
                        "approach": "Retrying with revised instructions.",
                        "goal_met": False,
                    },
                )
                await manager.send_message({"type": "status", "content": f"Step {i+1}: Retrying (attempt {retry_num + 2})…"}, websocket)
                await handoff("validator", "executor", i, f"Retry {retry_num+1}: {verdict[:60]}")
                await agent_event("executor", "active", i, f"Retry attempt {retry_num + 2}")
                step_result, _, step_meta = await executor_agent(step_desc, curated, research_brief, feedback=verdict, step_index=i, needs_search=needs_search, completed_steps=memory)
                await agent_event("executor", "done", i)
                await manager.send_message({
                    "type": "step_draft", "step_index": i, "step": step_desc,
                    "result": step_result, "attempt": retry_num + 2,
                }, websocket)
                await handoff("executor", "validator", i, f"Revised result ({len(step_result)} chars)")
                await manager.send_message({"type": "status", "content": f"Step {i+1}: Re-validating…"}, websocket)
                await agent_event("validator", "active", i, f"Re-checking after retry {retry_num+1}…")
                verdict = await validator_agent(step_desc, step_result)

            if _should_attempt_executor_plan_b(step_result, verdict, step_meta, had_side_effect=had_side_effect, cl_is_action=_cl_is_action, known_copilot_kind=known_copilot_kind, run_budget_halted=run_budget["halted"]):
                _plan_b_reason = verdict if str(verdict or "").strip() else "Executor result was insufficient."
                await manager.send_message({
                    "type": "retry", "step_index": i,
                    "attempt": max_validation_retries + 1, "reason": _plan_b_reason,
                }, websocket)
                await _push_todo_progress(
                    "step_plan_b",
                    step_index=i,
                    step_fields={
                        "state": "plan_b",
                        "attempt": max_validation_retries + 2,
                        "evaluation": _plan_b_reason,
                        "approach": "Trying an alternative approach (Plan B).",
                        "goal_met": False,
                        "plan_b_applied": True,
                    },
                )
                await manager.send_message({"type": "status", "content": f"Step {i+1}: Starting Plan B…"}, websocket)
                await handoff("validator", "executor", i, f"Plan B: {_plan_b_reason[:60]}")
                await agent_event("executor", "active", i, "Plan B fallback")
                _plan_b_result = await _try_executor_plan_b(step_desc, i)
                if _plan_b_result is not None:
                    step_result, had_side_effect, step_meta = _plan_b_result
                    await agent_event("executor", "done", i, f"{len(step_result)} chars produced (Plan B)")
                    await manager.send_message({
                        "type": "step_draft", "step_index": i, "step": step_desc,
                        "result": step_result, "attempt": max_validation_retries + 2,
                    }, websocket)
                    await handoff("executor", "validator", i, f"Plan B result ({len(step_result)} chars)")
                    if _can_skip_validator_fast(step_result, had_side_effect):
                        verdict = "PASS"
                    else:
                        await manager.send_message({"type": "status", "content": f"Step {i+1}: Validating Plan B…"}, websocket)
                        await agent_event("validator", "active", i, "Checking Plan B result…")
                        verdict = await validator_agent(step_desc, step_result)
            await agent_event("validator", "done", i, verdict)

            _tool_used = str(step_meta.get("tool_name") or "").strip()
            if _tool_used:
                try:
                    _was_quarantined = record_custom_tool_feedback(_tool_used, verdict, reason=str(step_result)[:260])
                    if _was_quarantined:
                        await manager.send_message({
                            "type": "warning",
                            "content": f"Skill '{_tool_used}' has been automatically disabled after repeated failures. You can re-enable it in Settings > Skills.",
                        }, websocket)
                except Exception:
                    pass

            memory.append({"step": step_desc, "result": step_result, "verdict": verdict, **step_meta})
            session["steps"].append({"step": step_desc, "result": step_result, "verdict": verdict, **step_meta})
            _record_hypothesis_evidence(
                i,
                str(step_meta.get("tool_name") or ""),
                str(step_meta.get("tool_result") or ""),
                dict(step_meta.get("tool_details") or {}),
            )
            _stop_after_remediation = await _apply_auto_remediation(i)
            _save_session(session)

            _step_passed = str(verdict or "").upper().startswith("PASS")
            await _push_todo_progress(
                "step_completed",
                step_index=i,
                step_fields={
                    "state": _todo_progress_state_from_verdict(verdict),
                    "verdict": verdict,
                    "evaluation": verdict if not _step_passed else "Step goal achieved.",
                    "approach": "Alternative approach succeeded." if step_meta.get("plan_b_applied") else "Primary approach completed.",
                    "result_preview": step_result,
                    "goal_met": _step_passed,
                    "plan_b_applied": bool(step_meta.get("plan_b_applied")),
                },
            )

            await manager.send_message({
                "type": "step_result", "step": step_desc, "result": step_result,
                "verdict": verdict, "memory": memory, "streamed": True,
            }, websocket)

            if _step_result_requires_user_input(step_desc, step_result):
                awaiting_user_input = True
                await _push_todo_progress(
                    "goal_evaluation",
                    goal_status="awaiting_user",
                    goal_evaluation="Waiting for user input before continuing.",
                )
                await manager.send_message({
                    "type": "info",
                    "content": "Stopping here: waiting for user input before continuing.",
                }, websocket)
                steps = steps[:i + 1]
                break

            if _stop_after_remediation:
                await manager.send_message({
                    "type": "info",
                    "content": "Stopping early: auto-remediation selected a final evidence-first response.",
                }, websocket)
                steps = steps[:i + 1]
                break

    # ── Final report ────────────────────────────────────────────────────────────
    _final_goal_status = str(session.get("todo_progress_goal_status") or "").strip().lower()
    _final_goal_evaluation = str(session.get("todo_progress_goal_evaluation") or "").strip()
    if not _final_goal_status:
        if awaiting_user_input:
            _final_goal_status = "awaiting_user"
            _final_goal_evaluation = _final_goal_evaluation or "Waiting for user input before continuing."
        elif memory and all(str(item.get("verdict") or "").upper().startswith("PASS") for item in memory):
            _final_goal_status = "complete"
            _final_goal_evaluation = _final_goal_evaluation or "All planned step goals were achieved."
        elif memory:
            _final_goal_status = "partial"
            _final_goal_evaluation = _final_goal_evaluation or "Workflow finished with open issues or skipped items."
        else:
            _final_goal_status = "running"
    # Safety: if any step has ISSUE verdict, downgrade "complete" to "partial"
    if _final_goal_status == "complete" and memory and any(
        str(item.get("verdict") or "").upper().startswith("ISSUE") for item in memory
    ):
        _final_goal_status = "partial"
        _final_goal_evaluation = _final_goal_evaluation or "Workflow finished with open issues."
    await _push_todo_progress(
        "goal_final",
        goal_status=_final_goal_status,
        goal_evaluation=_final_goal_evaluation,
    )
    if _final_goal_status == "partial":
        await manager.send_message({
            "type": "info",
            "content": "Workflow completed with open issues — some steps could not be finished successfully.",
        }, websocket)

    if manager.active_runs.get(run_id, False):
        if known_copilot_kind:
            # ── Layer 3: Build personal context ────────────────────────
            _user_ctx = ""
            try:
                _raw_user = _load_user_text()
                if _raw_user:
                    _user_ctx_parts = [build_user_context_block(_raw_user)]
                    _prio_block = build_priorities_context_block()
                    if _prio_block:
                        _user_ctx_parts.append(_prio_block)
                    _cp = load_contact_profiles()
                    if _cp:
                        _contacts_block = build_contacts_context_block(_cp)
                        if _contacts_block:
                            _user_ctx_parts.append(_contacts_block)
                    _user_ctx = "\n".join(p for p in _user_ctx_parts if p)
            except Exception:
                pass

            # ── Layer 1: Deep Content Analysis ─────────────────────────
            # Run LLM analysis over the fetched email bodies + calendar
            # events before composing the deterministic report.
            try:
                from backend.copilot_briefing import _collect_morning_brief_inputs
                _enrich_emails, _enrich_events, _, _, _ = _collect_morning_brief_inputs(memory)
                if _enrich_emails:
                    await agent_event("reportout", "active", msg="Analysing email content…")
                    _analysis_prompt = build_copilot_email_analysis_prompt(_enrich_emails, _enrich_events)
                    _analysis_system = build_copilot_email_analysis_system(_current_date_line(), user_context=_user_ctx)
                    if speed_mode:
                        _analysis_raw = await call_fast(_analysis_prompt, system=_analysis_system, num_ctx=12288)
                    else:
                        _analysis_raw = await call_with_vision(_analysis_prompt, system=_analysis_system, num_ctx=16384, thinking=False)
                    _analysis_parsed = parse_copilot_email_analysis(_analysis_raw or "", len(_enrich_emails))
                    memory.append({
                        "tool_name": _COPILOT_EMAIL_ANALYSIS_ENTRY,
                        "tool_result": json.dumps(_analysis_parsed, ensure_ascii=False),
                        "result": "",
                    })
                    # Layer 3: update contact profiles from enriched data
                    try:
                        _l1_for_contacts = extract_email_enrichments(memory)
                        update_contact_profiles_from_emails(_enrich_emails, _l1_for_contacts)
                    except Exception:
                        pass
            except Exception as _enrich_err:
                await agent_event("reportout", "active", msg=f"Content analysis skipped: {_enrich_err}")

            # ── Layer 1b: Deep Event Analysis ──────────────────────────
            # Run LLM analysis over calendar events for preparation
            # needs, importance, and stakeholder assessment.
            try:
                if _enrich_events:
                    await agent_event("reportout", "active", msg="Analysing calendar events…")
                    _evt_prompt = build_copilot_event_analysis_prompt(_enrich_events, _enrich_emails)
                    _evt_system = build_copilot_event_analysis_system(_current_date_line(), user_context=_user_ctx)
                    if speed_mode:
                        _evt_raw = await call_fast(_evt_prompt, system=_evt_system, num_ctx=8192)
                    else:
                        _evt_raw = await call_with_vision(_evt_prompt, system=_evt_system, num_ctx=12288, thinking=False)
                    _evt_parsed = parse_copilot_event_analysis(_evt_raw or "", len(_enrich_events))
                    memory.append({
                        "tool_name": _COPILOT_EVENT_ANALYSIS_ENTRY,
                        "tool_result": json.dumps(_evt_parsed, ensure_ascii=False),
                        "result": "",
                    })
            except Exception as _evt_err:
                await agent_event("reportout", "active", msg=f"Event analysis skipped: {_evt_err}")

            # ── Layer 1c: Thread Context for Priority Emails ───────────
            # For high-priority emails, fetch the full conversation thread
            # and run LLM analysis to extract context, status, and suggestions.
            try:
                _l1c_enrichments = extract_email_enrichments(memory)
                if _l1c_enrichments and _enrich_emails:
                    _thread_candidates = identify_thread_candidates(_l1c_enrichments, _enrich_emails, max_threads=5)
                    if _thread_candidates:
                        await agent_event("reportout", "active", msg="Fetching email threads…")
                        _thread_data: list[dict] = []
                        for _tc in _thread_candidates:
                            try:
                                _tid = _tc.get("thread_id") or ""
                                _tprov = _tc.get("provider") or ""
                                if not _tid:
                                    continue
                                if _tprov == "gmail":
                                    _traw = _gmail_get_thread(_tid, body_length=400, max_messages=8)
                                elif _tprov == "outlook":
                                    _traw = _outlook_get_thread(_tid, body_length=400, max_messages=8)
                                else:
                                    continue
                                if _traw and not _traw.startswith("Error"):
                                    _thread_data.append({
                                        "email_index": _tc["email_index"],
                                        "subject": _tc.get("subject", ""),
                                        "thread_messages": _traw,
                                    })
                            except Exception:
                                pass
                        if _thread_data:
                            await agent_event("reportout", "active", msg="Analysing email threads…")
                            _tc_prompt = build_copilot_thread_analysis_prompt(_thread_data)
                            _tc_system = build_copilot_thread_analysis_system(_current_date_line(), user_context=_user_ctx)
                            _tc_indices = [d["email_index"] for d in _thread_data]
                            if speed_mode:
                                _tc_raw = await call_fast(_tc_prompt, system=_tc_system, num_ctx=8192)
                            else:
                                _tc_raw = await call_with_vision(_tc_prompt, system=_tc_system, num_ctx=12288, thinking=False)
                            _tc_parsed = parse_copilot_thread_analysis(_tc_raw or "", _tc_indices)
                            if _tc_parsed:
                                memory.append({
                                    "tool_name": _COPILOT_THREAD_ANALYSIS_ENTRY,
                                    "tool_result": json.dumps(_tc_parsed, ensure_ascii=False),
                                    "result": "",
                                })
            except Exception as _tc_err:
                await agent_event("reportout", "active", msg=f"Thread analysis skipped: {_tc_err}")

            # ── Layer 2: Cross-Item Intelligence ───────────────────────
            # Find topic clusters, time-pressure patterns, and a day
            # narrative across all emails + events.
            try:
                _l1_enrichments = extract_email_enrichments(memory)
                _l1b_event_enrichments = extract_event_enrichments(memory)
                if (_l1_enrichments or _l1b_event_enrichments) and (_enrich_emails or _enrich_events):
                    await agent_event("reportout", "active", msg="Finding cross-item patterns…")
                    _cross_prompt = build_copilot_cross_item_prompt(
                        _l1_enrichments, _enrich_emails, _enrich_events,
                        event_enrichments=_l1b_event_enrichments,
                    )
                    _cross_system = build_copilot_cross_item_system(_current_date_line(), user_context=_user_ctx)
                    if speed_mode:
                        _cross_raw = await call_fast(_cross_prompt, system=_cross_system, num_ctx=12288)
                    else:
                        _cross_raw = await call_with_vision(_cross_prompt, system=_cross_system, num_ctx=16384, thinking=False)
                    _cross_parsed = parse_copilot_cross_item_analysis(_cross_raw or "")
                    if _cross_parsed:
                        memory.append({
                            "tool_name": _COPILOT_CROSS_ITEM_ENTRY,
                            "tool_result": json.dumps(_cross_parsed, ensure_ascii=False),
                            "result": "",
                        })
            except Exception as _cross_err:
                await agent_event("reportout", "active", msg=f"Cross-item analysis skipped: {_cross_err}")

            final_report = compose_final_report(goal, memory, "")
            if final_report:
                await agent_event("reportout", "done", msg=f"{known_copilot_kind} composed from structured signals")
            else:
                await agent_event("reportout", "active", msg="Preparing final report…")
                await handoff("validator", "reportout", summary="All steps complete; drafting final report")
                final_report = await report_out_agent()
                await agent_event("reportout", "done", msg="Final report ready")
        elif _should_skip_report(memory):
            # Use the executor output directly as the final report.
            if len(memory) == 1:
                final_report = (memory[0].get("result") or "").strip()
            else:
                final_report = "\n\n---\n\n".join(
                    (m.get("result") or "").strip() for m in memory if (m.get("result") or "").strip()
                )
            await agent_event("reportout", "done", msg="Report skipped — executor output is final")
        else:
            await agent_event("reportout", "active", msg="Preparing final report…")
            await handoff("validator", "reportout", summary="All steps complete; drafting final report")
            final_report = await report_out_agent()
            await agent_event("reportout", "done", msg="Final report ready")
        final_report = compose_final_report(goal, memory, final_report)
        final_report += _format_sources_footer(memory, dutch=_looks_dutch_text(goal))
        session["last_tool_trace"] = _build_compacted_tool_trace(memory)
        _store_session_email_records(session, [
            {
                "tool_name": str(item.get("tool_name") or ""),
                "tool_result": str(item.get("tool_result") or ""),
            }
            for item in memory
            if str(item.get("tool_name") or "").strip()
        ])
        final_report = _append_email_followup_offer(final_report, session, dutch_hint=_looks_dutch_text(goal))
        session.pop("last_lookup_signature", None)
        _store_session_continuity_summary(
            session,
            goal,
            session.get("task_state"),
            session["last_tool_trace"],
            final_report,
        )
        session["final_report"] = final_report
        _run_succeeded = not awaiting_user_input and _final_goal_status not in ("partial", "incomplete")
        session["completed"] = _run_succeeded
        _set_run_kpis_snapshot(completed=_run_succeeded)
        _persist_learning_feedback()
        # Seed chat_history so follow-up chat messages have conversation context
        _record_session_exchange(session, goal, final_report)
        # Collect output files created/modified during this run
        output_files = []
        try:
            run_start_ts = datetime.fromisoformat(session["created_at"]).timestamp()
            for f in OUTPUT_DIR.iterdir():
                if f.is_file() and f.stat().st_mtime >= run_start_ts:
                    output_files.append({"name": f.name, "size": f.stat().st_size})
        except Exception:
            pass
        if output_files:
            session["output_files"] = output_files
        _save_session(session)
        await manager.send_message({
            "type": "success",
            "content": "Awaiting your input." if awaiting_user_input else ("Done — some steps had issues." if _final_goal_status == "partial" else "Goal achieved!"),
        }, websocket)
        await manager.send_message({
            "type": "final_report", "goal": goal, "content": final_report,
            "output_files": output_files,
        }, websocket)
    elif run_budget["halted"]:
        session["completed"] = False
        session["final_report"] = f"Run stopped by policy/budget: {run_budget['halt_reason']}"
        _set_run_kpis_snapshot(completed=False)
        _persist_learning_feedback()
        _save_session(session)
    else:
        session["completed"] = False
        session["final_report"] = "Run stopped by user."
        _set_run_kpis_snapshot(completed=False)
        _persist_learning_feedback()
        _save_session(session)
        await manager.send_message({
            "type": "run_stopped", "reason_code": "USER_STOPPED",
            "content": "Workflow stopped by user.",
        }, websocket)
    manager.active_runs.pop(run_id, None)

    try:
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, _ollama_unload_all)
    except Exception:
        pass

    try:
        convo_chunks = [f"Goal: {goal}"]
        if safe_context:
            convo_chunks.append(f"Context: {safe_context}")
        for st in (session.get("steps") or []):
            step_txt = (st.get("step") or "").strip()
            res_txt = (st.get("result") or "").strip()[:600]
            if step_txt:
                convo_chunks.append(f"Step: {step_txt}")
            if res_txt:
                convo_chunks.append(f"Result: {res_txt}")
        fr = (session.get("final_report") or "").strip()[:1500]
        if fr:
            convo_chunks.append(f"Final report: {fr}")
        convo_text = "\n".join(convo_chunks)
        threading.Thread(target=_enrich_user_profile_llm, args=(convo_text,), daemon=True).start()
    except Exception:
        pass

    # Post-run self-reflection (async, non-blocking)
    if session.get("completed"):
        try:
            threading.Thread(target=_run_self_reflection, args=(dict(session),), daemon=True).start()
        except Exception:
            pass


# Serve frontend static files (must be mounted last to avoid overriding API routes)
app.mount("/", StaticFiles(directory="frontend", html=True), name="static")
