"""Main agent: ties vault identity, LLM reasoning, and conversation memory together."""

from __future__ import annotations

import asyncio
import contextlib
import contextvars
import logging
import re
import time
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TYPE_CHECKING

from agntspace.llm.base import LLMProvider, StreamChunk
from agntspace.llm.prompt import build_system_prompt
from agntspace.memory.conversation import ConversationMemory
from agntspace.vault.reader import VaultReader

if TYPE_CHECKING:
    from agntspace.agent.registry import ToolRegistry
    from agntspace.agent.tools import ToolExecutor
    from agntspace.automation.heartbeat import Heartbeat
    from agntspace.journal.service import JournalService
    from agntspace.memory.engine import MemoryEngine
    from agntspace.projects.service import ProjectService
    from agntspace.skills.loader import SkillLoader
    from agntspace.tasks.service import TaskService
    from agntspace.trust.service import TrustService

log = logging.getLogger(__name__)


# Contextvar for the active chat conversation. Set at the start of every
# ``chat_stream`` call; read by tools that need to post follow-up messages
# asynchronously (e.g. fire-and-forget jobs that should report completion
# back into the same chat thread). Empty string when no chat turn is
# active (cron, heartbeat, channels, direct API) — tools MUST treat that
# as "no conversation to follow up with".
_active_conversation_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "agntspace_active_conversation", default="",
)


def current_conversation_id() -> str:
    """Return the conversation id for the currently-running chat turn,
    or the empty string when no chat is active."""
    return _active_conversation_var.get()


_NO_RESPONSE_PLACEHOLDER = "⚠ The model returned no response. Please try again."


def _is_junk_assistant_text(text: str | None) -> bool:
    """True when an LLM 'final answer' is empty, whitespace, or a bare tool-call JSON blob.

    Small local models and occasionally cloud models can emit ``{}``, ``[]``,
    ``null``, or a literal tool-call JSON (``{"name": ..., "arguments": ...}``)
    as the assistant message. Saving any of those pollutes history and makes the
    conversation look empty in the sidebar.
    """
    if not text:
        return True
    s = text.strip()
    if not s:
        return True
    if s in ("{}", "[]", "null"):
        return True
    # Bare tool-call JSON shape from local models
    if s.startswith("{") and s.endswith("}"):
        import json as _json

        try:
            obj = _json.loads(s)
        except Exception:
            return False
        if isinstance(obj, dict):
            # Empty dict or single-key tool-call wrapper
            if not obj:
                return True
            keys = set(obj.keys())
            if keys <= {"name", "arguments", "input", "parameters", "tool_code", "tool_name"}:
                return True
            if keys == {"type", "function"} or keys == {"type"}:
                return True
            # Shape: {"<tool_name>": {...}} with no prose
            if len(obj) == 1 and isinstance(next(iter(obj.values())), dict):
                return True
            # Shape: {"action": "...", ...} — narrated tool call from local LLMs
            if keys & {"action", "tool", "command", "function"}:
                return True
    return False


def _sanitize_assistant_text(text: str | None) -> str:
    """Replace empty/junk assistant text with a user-visible placeholder."""
    if _is_junk_assistant_text(text):
        return _NO_RESPONSE_PLACEHOLDER
    return text  # type: ignore[return-value]


# ── Narrated tool-call recovery (local-mode guard #6) ──
# Local LLMs sometimes return JSON that *describes* a tool call as their
# text response instead of using the structured tool-calling protocol.
# Examples:
#   {"action": "create_knowledge_base", "kb_name": "ai-frameworks"}
#   {"tool": "search_vault", "query": "AI agents"}
#   {"name": "run_wiki_job", "arguments": {"kb": "general"}}
#
# This function detects those shapes and maps them to (tool_name, params).
# It's called when the LLM returned no structured tool_calls but produced
# a JSON-like text response.

# Maps common narrated action names → registered tool names
_NARRATED_ACTION_MAP: dict[str, str] = {  # arch:deterministic — structural name mapping (LLM output key → tool registry name), same shape as TOOL_CONTRACT_MAP
    # KB actions
    "create_knowledge_base": "create_kb",
    "create_kb": "create_kb",
    "create_knowledgebase": "create_kb",
    # Research actions
    "search": "search_vault",
    "search_vault": "search_vault",
    "web_search": "web_search",
    "scrape": "scrape_url",
    "scrape_url": "scrape_url",
    "research": "research_scrape",
    "research_scrape": "research_scrape",
    # Wiki actions
    "run_wiki_job": "run_wiki_job",
    "run_wiki_pipeline": "run_wiki_job",
    "wiki_pipeline": "run_wiki_job",
    "run_workflow": "run_workflow",
    # Presentation
    "create_presentation": "create_presentation",
    "generate_presentation": "generate_kb_presentation",
    "make_presentation": "generate_kb_presentation",
    "build_presentation": "generate_kb_presentation",
    "generate_kb_presentation": "generate_kb_presentation",
    # Presentation update/refine
    "update_presentation": "update_presentation",
    "modify_presentation": "update_presentation",
    "refine_presentation": "update_presentation",
    # Research + present pipeline
    "research_and_present": "research_and_present",
    "research_and_make_presentation": "research_and_present",
    # Tasks/goals/projects
    "create_task": "create_task",
    "create_goal": "create_goal",
    "create_project": "create_project",
    # Memory
    "recall_memory": "recall_memory",
    "query_knowledge": "query_knowledge",
    # Journal
    "journal_write": "journal_write",
}

# Keys the LLM uses to name the action inside the JSON
_ACTION_KEYS = ("action", "tool", "tool_name", "function", "command", "name")  # arch:deterministic — JSON key detection for structured parsing

# Keys that are action-naming metadata, not tool parameters.
# Deliberately minimal — "description" is NOT here because many tools accept it.
_META_KEYS = _ACTION_KEYS + ("type",)


def _extract_narrated_tool_call(
    text: str, valid_tools: set[str] | None = None,
) -> tuple[str, dict] | None:
    """Try to parse a JSON text response as a narrated tool call.

    Returns ``(tool_name, params)`` if the text looks like a tool action
    description that maps to a registered tool. Returns ``None`` otherwise.
    """
    if not text:
        return None
    s = text.strip()
    if not (s.startswith("{") and s.endswith("}")):
        return None

    import json as _json
    try:
        obj = _json.loads(s)
    except Exception:
        return None

    if not isinstance(obj, dict) or not obj:
        return None

    # Find the action name from known keys
    action_name = ""
    for key in _ACTION_KEYS:
        val = obj.get(key)
        if isinstance(val, str) and val.strip():
            action_name = val.strip().lower().replace("-", "_").replace(" ", "_")
            break

    if not action_name:
        return None

    # Map to registered tool name
    tool_name = _NARRATED_ACTION_MAP.get(action_name, action_name)

    # Validate against registered tools if provided
    if valid_tools and tool_name not in valid_tools:
        return None

    # Extract params — everything except the meta keys
    params = {k: v for k, v in obj.items() if k.lower() not in _META_KEYS}

    # Normalize common param name variants
    if "kb_name" in params and "topic" not in params:
        params["topic"] = params.pop("kb_name")
    if "kb" in params and "topic" not in params:
        params["topic"] = params.pop("kb")

    return (tool_name, params)


class Agent:
    """The main AgntSpace agent.

    Loads identity from vault files, maintains conversation context,
    and delegates reasoning to an LLM provider.
    """

    def __init__(
        self,
        llm: LLMProvider,
        memory: ConversationMemory,
        vault: VaultReader,
        journal: JournalService | None = None,
        task_service: TaskService | None = None,
        trust_service: TrustService | None = None,
        tool_executor: ToolExecutor | None = None,
        memory_engine: MemoryEngine | None = None,
        skill_loader: SkillLoader | None = None,
        heartbeat: Heartbeat | None = None,
        registry: ToolRegistry | None = None,
        project_service: ProjectService | None = None,
        local_mode: bool = False,
        cost_tracker: object | None = None,
    ) -> None:
        self._llm = llm
        self._memory = memory
        self._vault = vault
        self._journal = journal
        self._task_service = task_service
        self._trust_service = trust_service
        self._last_tool_contract_action: str = ""  # for correction signal domain lookup
        self._tool_executor = tool_executor
        self._memory_engine = memory_engine
        self._skill_loader = skill_loader
        self._heartbeat = heartbeat
        self._registry = registry
        self._project_service = project_service
        self._local_mode = local_mode
        self._cost_tracker = cost_tracker
        self._model_router: object | None = None  # injected post-hoc from main.py
        self._activity: object | None = None  # injected post-hoc — ActivityLogger
        self._decisions: object | None = None  # injected post-hoc — DecisionLogger
        self._orchestrator: object | None = None  # injected post-hoc — Orchestrator (for invoke_skill)
        self._hooks: object | None = None  # injected post-hoc — HookBus (plugin hook dispatcher)
        self._evolution: object | None = None  # injected post-hoc — EvolutionService (for invoke_skill learning loop)
        # TurnRegistry — tracks the in-flight chat turn per conversation so
        # the /api/conversations/{id}/status route can tell a reconnecting
        # UI "the agent is still working". Injected post-hoc from main.py.
        self._turn_registry: object | None = None
        # Rolling window of memory/knowledge references the agent has seen
        # in the current chat turn. Populated by memory-recall tool
        # execution; read by _execute_tool() when recording the "why" of
        # the NEXT tool call. Cleared at the start of each chat turn.
        self._recent_memory_refs: list[str] = []
        self._system_prompt: str | None = None
        self._identity_mtimes: dict[str, float] = {}
        self._last_identity_check: float = 0
        _IDENTITY_CHECK_COOLDOWN = 5.0  # seconds between file-stat checks
        # Retain strong references to background tasks fired by
        # `_fire_prediction_hook` and any other fire-and-forget spot.
        # Python's event loop keeps only *weak* refs to tasks created
        # via `asyncio.create_task` — under GC pressure those tasks can
        # be cancelled before completion. A set + `add_done_callback`
        # discard keeps the task alive for its natural lifetime.
        # See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task
        self._pending_tasks: set[asyncio.Task] = set()

    async def initialize(self) -> None:
        """Load identity files and build the system prompt.

        Called at startup and automatically re-invoked when identity files change.
        """
        identity = self._vault.read_identity_files()
        self._snapshot_identity_mtimes()

        if "SOUL" not in identity:
            log.warning("SOUL.md not found in vault — agent has no identity")

        # Gather live context
        journal_today = None
        if self._journal:
            with contextlib.suppress(Exception):
                doc = self._journal.get_today()
                journal_today = doc.content

        active_tasks = None
        if self._task_service:
            with contextlib.suppress(Exception):
                active_tasks = [
                    t for t in self._task_service.list_tasks()
                    if t.get("status") not in ("done", "cancelled")
                ]

        active_projects = None
        if self._project_service:
            with contextlib.suppress(Exception):
                active_projects = [
                    p for p in self._project_service.list_projects()
                    if p.get("status") not in ("completed", "archived")
                ]

        trust_summary = None
        if self._trust_service:
            try:
                trust_data = self._trust_service.get_trust_data()
                if trust_data:
                    trust_summary = ", ".join(
                        f"{t['domain']}: {t['level']}" for t in trust_data
                    )
            except Exception:
                pass

        skills_summary = None
        if self._skill_loader:
            with contextlib.suppress(Exception):
                skills_summary = self._skill_loader.get_full_prompt_section()

        # Get capabilities and integrations from registry (auto-discovers everything)
        capabilities_text = None
        connected = None
        if self._registry:
            capabilities_text = self._registry.get_capabilities_text()
            connected = self._registry.get_connected_integrations() or None

        # Activity context for real-time awareness
        activity_context = None
        if self._activity and hasattr(self._activity, "get_relationship_context"):
            ctx = self._activity.get_relationship_context()
            if ctx:
                activity_context = ctx

        # Phase 6E: inject prediction accuracy so the agent knows how
        # well its model of the user is performing. This is the self-
        # awareness signal: "my prediction accuracy on user preferences
        # this month is 67%" gives the agent calibration data.
        prediction_engine = getattr(self, "_prediction_engine", None)
        if prediction_engine is not None:
            try:
                ps = prediction_engine.stats()
                if ps.get("total_resolved", 0) > 0:
                    accuracy_pct = round((ps.get("accuracy") or 0) * 100)
                    pred_line = (
                        f"Prediction loop: {ps['confirmed']} confirmed, "
                        f"{ps['surprising']} surprising, {ps['pending']} pending. "
                        f"Accuracy: {accuracy_pct}%."
                    )
                    if activity_context:
                        activity_context += "\n" + pred_line
                    else:
                        activity_context = pred_line
            except Exception:
                pass

        # Agent insights: inject top observations so the agent leads
        # conversations with what it's been thinking about. This is the
        # key agent-first behavior — the agent doesn't wait to be asked.
        insights_engine = getattr(self, "_insights_engine", None)
        if insights_engine is not None:
            try:
                nudges = insights_engine.get_chat_nudges(max_count=3)
                if nudges:
                    if activity_context:
                        activity_context += "\n\n" + nudges
                    else:
                        activity_context = nudges
            except Exception:
                pass

        self._system_prompt = build_system_prompt(
            soul=identity["SOUL"].content if "SOUL" in identity else "",
            user=identity["USER"].content if "USER" in identity else "",
            contract=identity["CONTRACT"].content if "CONTRACT" in identity else "",
            memory=identity["MEMORY"].content if "MEMORY" in identity else "",
            profile=identity["PROFILE"].content if "PROFILE" in identity else None,
            relationship=identity["RELATIONSHIP"].content if "RELATIONSHIP" in identity else None,
            activity_context=activity_context,
            journal_today=journal_today,
            active_tasks=active_tasks,
            active_projects=active_projects,
            trust_summary=trust_summary,
            skills_summary=skills_summary,
            connected_integrations=connected,
            capabilities_text=capabilities_text,
            local_mode=self._local_mode,
            skills_dir=str(self._skill_loader._skills_dir) if self._skill_loader else None,
            skill_loader=self._skill_loader,
        )
        log.debug(
            "Agent initialized — system prompt: %d chars, identity files: %s",
            len(self._system_prompt),
            ", ".join(identity.keys()),
        )

    # ── Identity file hot-reload ──

    _IDENTITY_PATHS = [  # arch:deterministic — fixed identity file layout; these paths are structural invariants of the vault, not user-tunable strategy
        "system/identity/SOUL.md",
        "system/identity/USER.md",
        "system/identity/PROFILE.md",
        "system/identity/RELATIONSHIP.md",
        "memory/MEMORY.md",
    ]

    def _resolve_identity_path(self, rel: str) -> Path:
        """Resolve an identity file logical path to a physical path."""
        if hasattr(self._vault, "paths") and self._vault.paths:
            return self._vault.paths.resolve(rel)
        return self._vault._vault_dir / rel

    def _snapshot_identity_mtimes(self) -> None:
        """Record current mtimes of identity files."""
        for rel in self._IDENTITY_PATHS:
            path = self._resolve_identity_path(rel)
            try:
                self._identity_mtimes[rel] = path.stat().st_mtime if path.is_file() else 0
            except OSError:
                self._identity_mtimes[rel] = 0

    def _identity_files_changed(self) -> bool:
        """Check if any identity file has been modified since last snapshot."""
        for rel in self._IDENTITY_PATHS:
            path = self._resolve_identity_path(rel)
            try:
                mtime = path.stat().st_mtime if path.is_file() else 0
            except OSError:
                mtime = 0
            if mtime != self._identity_mtimes.get(rel, 0):
                return True
        return False

    async def _ensure_fresh_prompt(self) -> None:
        """Re-build system prompt if identity files changed on disk.

        Checks at most every 5 seconds to avoid stat() on every message.
        """
        now = time.monotonic()
        if (now - self._last_identity_check) < 5.0:
            return
        self._last_identity_check = now
        if self._identity_files_changed():
            log.info("Identity files changed on disk — rebuilding system prompt")
            await self.initialize()

    async def chat(
        self, conversation_id: str, user_message: str,
        provenance: dict | None = None,
    ) -> str:
        """Non-streaming chat with tool use. Provider-independent.

        Uses the LLM abstraction layer — works with Anthropic, OpenAI, Ollama.
        Every event and decision produced in this call shares a single
        correlation_id so you can trace the full causal chain.

        *provenance* is stamped on the user message at the channel boundary
        (``{kind, source_channel, origin_session, source_tool}``).
        """
        import json

        from agntspace.activity.decisions import (
            current_correlation_id,
            install_scope_mutations,
            new_correlation_id,
            reset_authorized_actions,
            reset_scope_mutations,
            set_correlation_id,
        )
        from agntspace.llm.base import Message as LLMMessage

        # Begin a causal chain for this chat turn unless one is already
        # set by the channel/webhook caller (e.g. WhatsApp bridge).
        _corr_token = None
        if not current_correlation_id():
            _corr_token = set_correlation_id(new_correlation_id("chat"))
        # Every chat turn starts with an empty authorized-contract-actions
        # set, regardless of whether we opened a fresh scope or inherited
        # one. Without this, actions from the previous turn would leak
        # into this one and defeat the VaultWriter runtime guard — the
        # whole point of per-scope authorization. The token is not
        # restored at turn end: contextvars don't leak across asyncio
        # tasks, so the next turn (in a different task) starts clean
        # regardless.
        reset_authorized_actions()

        # Fresh memory provenance window for this turn
        self._recent_memory_refs = []

        await self._ensure_fresh_prompt()
        await self._check_budget()

        # Same tool path for cloud and local — contract-filtered so the
        # LLM never wastes rounds on tools the contract blocks.
        if self._registry:
            tool_defs = self._registry.get_contract_filtered_definitions()
        else:
            from agntspace.agent.tools import TOOL_DEFINITIONS
            tool_defs = TOOL_DEFINITIONS

        valid_tools = {t["name"] for t in tool_defs} if tool_defs else set()

        self._check_journal_candidate(user_message)
        await self._memory.add_message(conversation_id, "user", user_message, provenance=provenance)
        asyncio.create_task(self._extract_entities(user_message))
        # Capture the user message for the prediction hook later.
        # The prediction hook runs as a background task AFTER the
        # response is sent so it never blocks the user-visible path.
        _user_message_for_prediction = user_message

        # Fix 7 (chat-mode): persist tracker + helper (mirrors chat_stream).
        # Used by the hardcoded-fallback path that previously returned text
        # without ever calling add_message. Other paths persist via this
        # helper too, so a future LLMError between an intent match and the
        # save is still observable as "no assistant message persisted".
        _assistant_save_tracker = [False]

        async def _save_assistant_msg(text: str) -> None:
            # Fix 7 hardening (2026-04-19): the original implementation
            # awaited add_message directly. When the caller (WebSocket
            # handler / non-stream HTTP route) gets cancelled mid-await
            # (browser tab closed, request timeout, network drop), the
            # CancelledError propagates THROUGH the await and the persist
            # is lost — sentinel finally-block hits the same cancellation
            # so even THAT can't recover. asyncio.shield protects the
            # inner persist task from outer cancellation: if our caller
            # is cancelled, the inner task continues to completion in
            # the background. We flip the tracker BEFORE the await so
            # the finally block doesn't double-write a sentinel on top.
            _assistant_save_tracker[0] = True
            try:
                await asyncio.shield(
                    self._memory.add_message(conversation_id, "assistant", text),
                )
            except asyncio.CancelledError:
                raise
            except Exception:
                log.exception("assistant message persist failed (chat)")

        # Track user activity for silence detection + word count for engagement
        if self._activity and hasattr(self._activity, "record_user_activity"):
            self._activity.record_user_activity("chat")
            word_count = len(user_message.split())
            self._log_activity("chat", "user_message", f"{user_message[:60]}",
                               {"user_words": word_count, "conversation_id": conversation_id},
                               importance="low")
        history = await self._memory.get_context_messages(conversation_id)

        # Inject artifact context so the LLM knows what's "in focus"
        artifact = await self._memory.get_artifact(conversation_id)
        if artifact:
            artifact_ctx = (
                f"[Artifact in focus: {artifact['type']} at {artifact['path']}. "
                f"If the user asks to refine, update, or change something, "
                f"it refers to this artifact. Metadata: {artifact.get('metadata', {})}]"
            )
            history = [LLMMessage(role="system", content=artifact_ctx)] + list(history)

        from agntspace.llm.base import LLMError

        # Local-mode intent fast path — match user message to tool call
        # via regex patterns before calling the LLM (same logic as chat_stream)
        if self._local_mode and tool_defs and self._tool_executor:
            _last_asst = None
            if history:
                for m in reversed(history):
                    if m.role == "assistant":
                        _last_asst = m.content
                        break
            intent = self._match_local_intent(user_message, last_assistant=_last_asst)
            if intent:
                tool_name, tool_params = intent
                # Inject artifact path for refinement tools
                if artifact and not tool_params.get("path"):
                    tool_params["path"] = artifact["path"]
                log.info("Local intent matched (chat): %s(%s)", tool_name, json.dumps(tool_params)[:100])
                result = await self._execute_tool(tool_name, tool_params)
                await self._capture_artifact(conversation_id, tool_name, result)
                await self._record_pulse("tool_use", f"{tool_name} (intent)")
                self._log_activity("tool", "invoked", f"{tool_name} (intent)",
                                   {"tool": tool_name, "input_keys": list(tool_params.keys())})
                result_json = json.dumps(result)
                if len(result_json) > 2000:
                    result_json = result_json[:2000] + "..."
                summary_response = await self._llm.complete(
                    messages=list(history) + [
                        LLMMessage(
                            role="user",
                            content=(
                                f"I asked: {user_message}\n\n"
                                f"The tool `{tool_name}` was executed with result:\n{result_json}\n\n"
                                f"Tell the user what was done. Be brief and positive. If the result contains a 'message' or 'created' or 'scraped' field, report success. Only report an error if there is an 'error' field."
                            ),
                        ),
                    ],
                    system=self._system_prompt,
                )
                final_text = _sanitize_assistant_text(summary_response.content)
                await _save_assistant_msg(final_text)
                return final_text

        # Use tool-enabled completion if we have tools
        if tool_defs and self._tool_executor:
            # Fix 4 + 5: install mutation scope + tools_attempted + per-round
            # error counters around the tool loop body. The recovery handler
            # (Fix 1) reads the accumulated `messages` list — same pattern
            # as chat_stream — so silent exhaustion is impossible.
            _tools_attempted: list[str] = []
            self._tools_attempted_this_turn = _tools_attempted
            _round_error_counts: list[int] = []
            _round_total_counts: list[int] = []
            _recovery_rules = self._load_chat_recovery_rules() or {}
            _cb = {
                **self._RECOVERY_DEFAULTS["circuit_breaker"],
                **(_recovery_rules.get("circuit_breaker") or {}),
            }
            _turn_mutations, _mutations_token = install_scope_mutations()

            try:
                messages = list(history)  # copy
                max_rounds = 5

                for _ in range(max_rounds):
                    response = await self._llm.complete_with_tools(
                        messages=messages,
                        tools=tool_defs,
                        system=self._system_prompt,
                    )

                    # Check if LLM wants to use tools
                    if response.tool_calls:
                        # Validate tool names — local models may hallucinate
                        invalid = [tc for tc in response.tool_calls if tc.name not in valid_tools]
                        if invalid:
                            log.warning("Hallucinated tool calls: %s", ", ".join(tc.name for tc in invalid))
                            break  # fall through to basic completion

                        # Append assistant response with raw content
                        messages.append(LLMMessage(role="assistant", content=response.raw_content or response.content))

                        # Execute each tool call and send results back
                        _any_blocked = False
                        _round_errors = 0
                        _round_total = 0
                        for tc in response.tool_calls:
                            log.info("Tool call: %s(%s)", tc.name, json.dumps(tc.input)[:100])
                            _tools_attempted.append(tc.name)  # Fix 4
                            result = await self._execute_tool(tc.name, tc.input)
                            await self._capture_artifact(conversation_id, tc.name, result)
                            await self._record_pulse("tool_use", f"{tc.name}")
                            self._log_activity("tool", "invoked", f"{tc.name}",
                                               {"tool": tc.name, "input_keys": list(tc.input.keys())})
                            # Use "tool" role with tool_call_id (OpenAI/LiteLLM format)
                            messages.append(LLMMessage(
                                role="tool",
                                content=json.dumps(result),
                                tool_call_id=tc.id,
                            ))
                            if isinstance(result, dict) and result.get("contract_blocked"):
                                _any_blocked = True
                            _round_total += 1
                            if isinstance(result, dict) and "error" in result:
                                _round_errors += 1

                        _round_error_counts.append(_round_errors)
                        _round_total_counts.append(_round_total)

                        # Fix 5: error-fatigue circuit breaker (mirrors chat_stream).
                        _min_rounds = int(_cb.get("min_rounds_before_break", 2))
                        _threshold = float(_cb.get("error_rate_threshold", 0.5))
                        _window = int(_cb.get("window_size_rounds", 2))
                        if len(_round_error_counts) >= _min_rounds and _threshold > 0.0:
                            _win_errors = sum(_round_error_counts[-_window:])
                            _win_total = sum(_round_total_counts[-_window:])
                            if _win_total > 0 and (_win_errors / _win_total) >= _threshold:
                                log.warning(
                                    "chat() circuit breaker fired: error rate %.2f (%d/%d)",
                                    _win_errors / _win_total, _win_errors, _win_total,
                                )
                                _success_total = sum(_round_total_counts) - sum(_round_error_counts)
                                _error_total = sum(_round_error_counts)
                                _recovery_text = ""
                                async for _ch in self._run_chat_recovery(
                                    messages=messages,
                                    system=self._system_prompt,
                                    trigger="circuit_broken",
                                    tools_attempted=list(_tools_attempted),
                                    success_count=_success_total,
                                    error_count=_error_total,
                                ):
                                    _recovery_text += _ch.delta
                                _recovery_text = _sanitize_assistant_text(_recovery_text) or (
                                    "⚠ I attempted multiple tools but they kept failing. Please try again."
                                )
                                await _save_assistant_msg(_recovery_text)
                                return _recovery_text

                        if _any_blocked:
                            _block_msg = (
                                "I can't perform that action — it's blocked by "
                                "your Contract settings. You can change the "
                                "permission in the Contract Editor (Settings → "
                                "Contract) if you'd like to allow it."
                            )
                            await _save_assistant_msg(_block_msg)
                            return _block_msg

                        continue

                    # No tool calls — check if the LLM narrated a tool call as JSON text
                    narrated = _extract_narrated_tool_call(response.content, valid_tools)
                    if narrated:
                        tool_name, tool_params = narrated
                        log.info("Recovered narrated tool call: %s(%s)", tool_name, json.dumps(tool_params)[:100])
                        result = await self._execute_tool(tool_name, tool_params)
                        await self._capture_artifact(conversation_id, tool_name, result)
                        await self._record_pulse("tool_use", f"{tool_name} (recovered)")
                        self._log_activity("tool", "invoked", f"{tool_name} (narrated recovery)",
                                           {"tool": tool_name, "input_keys": list(tool_params.keys())})
                        # Ask the LLM to summarize the result for the user
                        result_json = json.dumps(result)
                        if len(result_json) > 2000:
                            result_json = result_json[:2000] + "..."
                        summary_response = await self._llm.complete(
                            messages=list(history) + [
                                LLMMessage(
                                    role="user",
                                    content=(
                                        f"I asked: {user_message}\n\n"
                                        f"The tool `{tool_name}` was executed with result:\n{result_json}\n\n"
                                        f"Tell the user what was done. Be brief and positive. If the result contains a 'message' or 'created' or 'scraped' field, report success. Only report an error if there is an 'error' field."
                                    ),
                                ),
                            ],
                            system=self._system_prompt,
                        )
                        final_text = _sanitize_assistant_text(summary_response.content)
                        await _save_assistant_msg(final_text)
                        return final_text

                    # No tool calls — final response
                    final_text = _sanitize_assistant_text(response.content)
                    final_text = await self._apply_reply_hook(final_text, conversation_id)
                    await _save_assistant_msg(final_text)
                    await self._extract_entities(final_text, is_user=False)
                    log.info("Chat (tools): %d in, %d out tokens", response.input_tokens, response.output_tokens)
                    await self._record_cost(
                        response.input_tokens, response.output_tokens, conversation_id, "chat",
                        cache_read_tokens=response.cache_read_tokens, cache_write_tokens=response.cache_write_tokens,
                        provider_cost=response.provider_cost,
                    )
                    await self._record_pulse("chat", f"Response: {final_text[:80]}")
                    self._log_activity("chat", "responded", f"{user_message[:60]}",
                                       {"tokens_in": response.input_tokens, "tokens_out": response.output_tokens},
                                       importance="low")
                    self._fire_prediction_hook(_user_message_for_prediction, final_text)
                    return final_text

                # Fix 1: round exhaustion — use unified recovery handler
                # with the FULL accumulated `messages` list. Replaces the
                # previous hardcoded "I tried multiple tool calls but
                # couldn't complete. Please try again." fallback that
                # discarded all context and lied to the user.
                if _tools_attempted:
                    log.info(
                        "chat() tool loop exhausted with %d tools across %d rounds — running recovery",
                        len(_tools_attempted), len(_round_total_counts),
                    )
                    _success_total = sum(_round_total_counts) - sum(_round_error_counts)
                    _error_total = sum(_round_error_counts)
                    _recovery_text = ""
                    async for _ch in self._run_chat_recovery(
                        messages=messages,
                        system=self._system_prompt,
                        trigger="round_exhausted",
                        tools_attempted=list(_tools_attempted),
                        success_count=_success_total,
                        error_count=_error_total,
                    ):
                        _recovery_text += _ch.delta
                    _recovery_text = _sanitize_assistant_text(_recovery_text) or (
                        "⚠ I attempted multiple tool calls but couldn't complete a summary. Please try again."
                    )
                    await _save_assistant_msg(_recovery_text)
                    return _recovery_text

                # No tools were attempted at all — fall through to basic completion
                _empty_loop_msg = (
                    "⚠ The model didn't reach a final answer. Please try again."
                )
                await _save_assistant_msg(_empty_loop_msg)
                return _empty_loop_msg

            except LLMError:
                raise  # surface to caller with clear reason
            except Exception:
                log.exception("Tool-enabled chat failed, falling back to basic")
            finally:
                # Fix 4: zero-effect detection for chat() (mirrors chat_stream).
                try:
                    if _tools_attempted and _turn_mutations.is_zero_effect():
                        self._log_activity(
                            "llm", "chat_zero_effect",
                            (
                                f"chat() called {len(_tools_attempted)} tool(s) "
                                f"but produced no verifiable effect"
                            ),
                            {
                                "conversation_id": conversation_id,
                                "tools_attempted": _tools_attempted,
                                "vault_writes": _turn_mutations.vault_writes,
                                "successful_tool_calls": _turn_mutations.tool_calls,
                                "graph_triples": _turn_mutations.graph_triples,
                            },
                            importance="high",
                        )
                        if self._decisions:
                            try:
                                await self._decisions.record(
                                    actor="main",
                                    action_type="chat_turn",
                                    action_target="zero_effect",
                                    rationale=(
                                        f"chat() attempted {len(_tools_attempted)} tools "
                                        f"but produced no observable effect"
                                    ),
                                    details={
                                        "tools_attempted": _tools_attempted,
                                        "mutations": _turn_mutations.summary(),
                                    },
                                    result_status="zero_effect",
                                    error_type="empty_result",
                                    triggered_by="user_message",
                                )
                            except Exception:
                                log.debug("Zero-effect decision record failed", exc_info=True)
                except Exception:
                    log.debug("Zero-effect detection failed", exc_info=True)
                reset_scope_mutations(_mutations_token)

        # Fallback: basic completion without tools
        try:
            response = await self._llm.complete(
                messages=history,
                system=self._system_prompt,
            )
        except LLMError:
            raise  # surface to caller with clear reason
        clean = _sanitize_assistant_text(response.content)
        clean = await self._apply_reply_hook(clean, conversation_id)
        await _save_assistant_msg(clean)
        await self._record_pulse("chat", f"Response: {clean[:80]}")
        self._fire_prediction_hook(_user_message_for_prediction, clean)
        return clean

    def _fire_prediction_hook(
        self, user_message: str, assistant_response: str
    ) -> None:
        """Fire the prediction loop as a background task after a chat response.

        Phase 6E integration: the ``PredictionEngine.chat_hook_post_response``
        method implements the three configurable modes (every_turn / daily /
        every_nth) per ``memory-prediction/config/prediction.yaml``. We call
        it via ``asyncio.create_task`` so the user never waits for it.

        The context_text is the system prompt (summarizes the agent's current
        understanding of the conversation); the observation_text is the user's
        latest message (what the agent should validate predictions against).

        If the prediction engine isn't wired (``app.state.prediction_engine``
        not set, or no running loop), this is a silent no-op.
        """
        prediction_engine = getattr(self, "_prediction_engine", None)
        if prediction_engine is None:
            return
        try:
            context = f"{self._system_prompt[:2000]}\n\nUser: {user_message}\nAssistant: {assistant_response[:500]}"
            # Retain a strong reference to the task so GC can't cancel
            # it mid-flight. add_done_callback discards on completion so
            # the set doesn't grow unboundedly. See Agent.__init__ for
            # why this matters (asyncio stdlib docs explicitly warn).
            task = asyncio.create_task(
                prediction_engine.chat_hook_post_response(
                    context_text=context,
                    observation_text=user_message,
                )
            )
            self._pending_tasks.add(task)
            task.add_done_callback(self._pending_tasks.discard)
        except Exception:
            log.debug("prediction hook fire failed", exc_info=True)

    def _resolve_model(self, model_tier: str | None) -> str | None:
        """Resolve a model tier to a specific model name via model_router."""
        if model_tier and self._model_router and hasattr(self._model_router, "resolve"):
            return self._model_router.resolve(model_tier)
        return None

    async def process_task(self, task: str, context: str = "", model_tier: str | None = None) -> str:
        """Process an internal system task using the agent's full capabilities.

        Like chat() but does NOT save to conversation memory — intended for
        automation tasks (daily briefing, EOD, reflections, memory compaction)
        that should not appear in the user's chat history.

        The agent's full system prompt (identity, skills, memory) is used,
        and tools are available for the agent to call during processing.

        Args:
            model_tier: Optional tier override ("fast", "capable", "reasoning").
                        Background tasks should use "fast" to save costs.
        """
        import json
        from datetime import datetime as _dt

        from agntspace.llm.base import LLMError
        from agntspace.llm.base import Message as LLMMessage

        # Deterministic date header — process_task callers are background
        # automation (briefing, EOD, reflection, compaction) where the LLM must
        # know the exact date/weekday.  The system prompt has # Current Time in
        # UTC, but fast-tier models often ignore it.  Prepending it to the user
        # message makes it impossible to miss.

        _now = _dt.now()
        date_header = f"TODAY: {_now.strftime('%A, %B %d, %Y')} (local time)\n\n"

        # Build the user message from task + context
        if context:
            user_content = f"{date_header}{context}\n\n---\n\n{task}"
        else:
            user_content = f"{date_header}{task}"

        messages = [LLMMessage(role="user", content=user_content)]

        # Resolve model tier to specific model name (e.g., "fast" → "claude-haiku-4-5")
        resolved_model = self._resolve_model(model_tier)

        # No budget gate here — process_task is called by background automation
        # (daily cycle, reflections, memory compaction) where a hard crash would
        # break the pipeline.  Budget enforcement is on interactive paths only
        # (chat, chat_stream).  Background callers already have retry/queue.

        # Get tool definitions — skip for local mode (small models hallucinate
        # tool JSON instead of generating content; automation tasks have
        # pre-gathered context and don't need tools)
        tool_defs = None
        if self._registry and self._tool_executor and not self._local_mode:
            tool_defs = self._registry.get_contract_filtered_definitions()
        valid_tools = {t["name"] for t in tool_defs} if tool_defs else set()

        if tool_defs and self._tool_executor:
            # Fix 4 + 5 for process_task: mutation scope + tools_attempted +
            # circuit breaker. process_task is the most-critical place to
            # detect zero-effect because background callers (daily cycle,
            # reflection, memory consolidation) generate vault content based
            # on the returned string — silent failure here corrupts memory.
            from agntspace.activity.decisions import (
                install_scope_mutations as _install_pt_mutations,
            )
            from agntspace.activity.decisions import (
                reset_scope_mutations as _reset_pt_mutations,
            )

            _pt_tools_attempted: list[str] = []
            _pt_round_error_counts: list[int] = []
            _pt_round_total_counts: list[int] = []
            _pt_recovery_rules = self._load_chat_recovery_rules() or {}
            _pt_cb = {
                **self._RECOVERY_DEFAULTS["circuit_breaker"],
                **(_pt_recovery_rules.get("circuit_breaker") or {}),
            }
            _pt_mutations, _pt_mutations_token = _install_pt_mutations()

            try:
                max_rounds = 5
                for _ in range(max_rounds):
                    response = await self._llm.complete_with_tools(
                        messages=messages,
                        tools=tool_defs,
                        system=self._system_prompt,
                        model=resolved_model,
                    )

                    if response.tool_calls:
                        invalid = [tc for tc in response.tool_calls if tc.name not in valid_tools]
                        if invalid:
                            log.warning("process_task: hallucinated tool calls: %s", ", ".join(tc.name for tc in invalid))
                            break

                        messages.append(LLMMessage(role="assistant", content=response.raw_content or response.content))

                        _pt_round_errors = 0
                        _pt_round_total = 0
                        for tc in response.tool_calls:
                            log.info("process_task tool: %s(%s)", tc.name, json.dumps(tc.input)[:100])
                            _pt_tools_attempted.append(tc.name)
                            result = await self._execute_tool(tc.name, tc.input)
                            await self._record_pulse("tool_use", f"{tc.name}")
                            messages.append(LLMMessage(
                                role="tool",
                                content=json.dumps(result),
                                tool_call_id=tc.id,
                            ))
                            _pt_round_total += 1
                            if isinstance(result, dict) and "error" in result:
                                _pt_round_errors += 1

                        _pt_round_error_counts.append(_pt_round_errors)
                        _pt_round_total_counts.append(_pt_round_total)

                        # Fix 5: circuit breaker for process_task. Same
                        # threshold semantics as chat — if the loop is
                        # spinning errors, break and ask the LLM to summarise
                        # honestly. The summary is RETURNED to the caller
                        # (briefing service, reflection, etc.) so the caller
                        # can either accept the honest "X tools failed"
                        # report or escalate to the work queue.
                        _min_rounds = int(_pt_cb.get("min_rounds_before_break", 2))
                        _threshold = float(_pt_cb.get("error_rate_threshold", 0.5))
                        _window = int(_pt_cb.get("window_size_rounds", 2))
                        if len(_pt_round_error_counts) >= _min_rounds and _threshold > 0.0:
                            _win_e = sum(_pt_round_error_counts[-_window:])
                            _win_t = sum(_pt_round_total_counts[-_window:])
                            if _win_t > 0 and (_win_e / _win_t) >= _threshold:
                                log.warning(
                                    "process_task circuit breaker fired: %.2f error rate (%d/%d)",
                                    _win_e / _win_t, _win_e, _win_t,
                                )
                                _pt_success = sum(_pt_round_total_counts) - sum(_pt_round_error_counts)
                                _pt_error = sum(_pt_round_error_counts)
                                _rec_text = ""
                                async for _ch in self._run_chat_recovery(
                                    messages=messages,
                                    system=self._system_prompt,
                                    trigger="circuit_broken",
                                    tools_attempted=list(_pt_tools_attempted),
                                    success_count=_pt_success,
                                    error_count=_pt_error,
                                ):
                                    _rec_text += _ch.delta
                                return _rec_text or (
                                    f"⚠ process_task aborted: {_pt_error} tool errors "
                                    f"out of {_pt_error + _pt_success}. Original task: {task[:120]}"
                                )

                        continue

                    # No tool calls — final response
                    final_text = response.content
                    if self._is_junk_response(final_text):
                        log.warning("process_task: LLM returned junk JSON instead of content, falling back to basic")
                        break
                    await self._extract_entities(final_text, is_user=False)
                    log.info("process_task: %d in, %d out tokens", response.input_tokens, response.output_tokens)
                    await self._record_cost(
                        response.input_tokens, response.output_tokens, action="process_task",
                        cache_read_tokens=response.cache_read_tokens, cache_write_tokens=response.cache_write_tokens,
                        provider_cost=response.provider_cost,
                    )
                    await self._record_pulse("automation", f"process_task: {task[:80]}")
                    return final_text

                # Fix 1: exhausted tool rounds — use unified recovery handler
                # with FULL accumulated messages context. Previously
                # process_task fell through to a fresh basic completion that
                # had ZERO knowledge of the tools that just ran.
                if _pt_tools_attempted:
                    log.warning(
                        "process_task: tool loop exhausted with %d tools — running recovery",
                        len(_pt_tools_attempted),
                    )
                    _pt_success = sum(_pt_round_total_counts) - sum(_pt_round_error_counts)
                    _pt_error = sum(_pt_round_error_counts)
                    _rec_text = ""
                    async for _ch in self._run_chat_recovery(
                        messages=messages,
                        system=self._system_prompt,
                        trigger="round_exhausted",
                        tools_attempted=list(_pt_tools_attempted),
                        success_count=_pt_success,
                        error_count=_pt_error,
                    ):
                        _rec_text += _ch.delta
                    if _rec_text.strip():
                        return _rec_text

                # Exhausted with no tool work — fall through to basic completion
                log.warning("process_task: tool loop ended without clean response, falling back to basic")

            except LLMError:
                raise
            except Exception:
                log.exception("process_task tool loop failed, falling back to basic")
            finally:
                # Fix 4: zero-effect detection for process_task. High-importance
                # because background callers (daily cycle, reflection, memory)
                # don't see the activity log unless we surface it loudly.
                try:
                    if _pt_tools_attempted and _pt_mutations.is_zero_effect():
                        self._log_activity(
                            "llm", "process_task_zero_effect",
                            (
                                f"process_task called {len(_pt_tools_attempted)} tool(s) "
                                f"but produced no verifiable effect — task: {task[:80]}"
                            ),
                            {
                                "task_preview": task[:200],
                                "tools_attempted": _pt_tools_attempted,
                                "vault_writes": _pt_mutations.vault_writes,
                                "successful_tool_calls": _pt_mutations.tool_calls,
                                "graph_triples": _pt_mutations.graph_triples,
                            },
                            importance="high",
                        )
                        if self._decisions:
                            try:
                                await self._decisions.record(
                                    actor="main",
                                    action_type="process_task",
                                    action_target="zero_effect",
                                    rationale=(
                                        f"process_task attempted {len(_pt_tools_attempted)} tools "
                                        f"but produced no observable effect"
                                    ),
                                    details={
                                        "task_preview": task[:200],
                                        "tools_attempted": _pt_tools_attempted,
                                        "mutations": _pt_mutations.summary(),
                                    },
                                    result_status="zero_effect",
                                    error_type="empty_result",
                                    triggered_by="process_task",
                                )
                            except Exception:
                                log.debug("process_task zero-effect record failed", exc_info=True)
                except Exception:
                    log.debug("process_task zero-effect detection failed", exc_info=True)
                _reset_pt_mutations(_pt_mutations_token)

        # Fallback: basic completion without tools
        try:
            response = await self._llm.complete(
                messages=messages,
                system=self._system_prompt,
                model=resolved_model,
            )
        except LLMError:
            raise
        await self._extract_entities(response.content, is_user=False)
        await self._record_cost(
            response.input_tokens, response.output_tokens, action="process_task",
            cache_read_tokens=response.cache_read_tokens, cache_write_tokens=response.cache_write_tokens,
            provider_cost=response.provider_cost,
        )
        await self._record_pulse("automation", f"process_task: {task[:80]}")
        return response.content

    async def chat_stream(
        self, conversation_id: str, user_message: str, skip_tools: bool = False,
        system_suffix: str = "", provenance: dict | None = None,
        store_message: str | None = None,
        skip_user_add: bool = False,
    ) -> AsyncIterator[StreamChunk]:
        """Streaming chat with tool use support.

        Tool calls are executed non-streaming in a loop. Once the LLM
        produces a final text response (no more tool calls), that
        response is streamed to the client.

        skip_tools: if True, don't provide tools to the LLM (used in
        answering mode where the agent should respond conversationally).

        system_suffix: optional text appended to the system prompt for
        this call only (e.g. channel answering-mode instructions). Not
        persisted — keeps conversation history clean.

        provenance: stamped on the user message at the channel boundary.

        store_message: if provided, this is what gets saved to conversation
        history instead of user_message. Use this when user_message contains
        ephemeral wrapping (e.g. prompt injection defense envelopes) that
        should reach the LLM but not pollute the stored conversation.

        skip_user_add: when True, do NOT insert a new user-role row, do
        NOT fire correction detection, do NOT re-extract entities. Used
        by the regenerate path: the user message already exists (it was
        just edited or kept after truncation), the agent only needs to
        produce a fresh assistant reply against the existing history.
        """
        import json

        from agntspace.llm.base import LLMError
        from agntspace.llm.base import Message as LLMMessage

        await self._ensure_fresh_prompt()
        await self._check_budget()

        # Bind the active conversation id for the duration of this chat
        # turn. Tools (especially fire-and-forget plugins that need to
        # post a follow-up message when a background job completes) read
        # ``current_conversation_id()`` to find their way back to this
        # thread. contextvars are task-local; each WS message / HTTP
        # request runs in a fresh task so leakage isn't a real concern.
        _active_conversation_var.set(conversation_id)

        # Build the effective system prompt for this call.  The suffix
        # is NOT persisted — it only exists in the LLM call so that
        # channel instructions (answering mode) appear once in the system
        # prompt rather than repeated on every stored user message.
        _effective_system = self._system_prompt
        if system_suffix:
            _effective_system = (self._system_prompt or "") + "\n\n" + system_suffix
        # Store the clean version in conversation history (without prompt
        # guard envelopes), but send the full version to the LLM.
        _stored = store_message or user_message
        if not skip_user_add:
            self._check_journal_candidate(_stored)
            await self._memory.add_message(
                conversation_id, "user", _stored, provenance=provenance,
            )
            # Fire-and-forget: correction detection for trust + entity extraction
            self._check_correction_signal(_stored)
            asyncio.create_task(self._extract_entities(_stored))
        history = await self._memory.get_context_messages(conversation_id)

        # Inject artifact context so the LLM knows what's "in focus"
        _artifact = await self._memory.get_artifact(conversation_id)
        if _artifact:
            _art_ctx = (
                f"[Artifact in focus: {_artifact['type']} at {_artifact['path']}. "
                f"If the user asks to refine, update, or change something, "
                f"it refers to this artifact. Metadata: {_artifact.get('metadata', {})}]"
            )
            history = [LLMMessage(role="system", content=_art_ctx)] + list(history)

        # When store_message differs from user_message (prompt guard envelope),
        # the DB has the clean text.  Swap the last user message in the LLM
        # history with the full sanitized version so the model sees the
        # security wrapping for the *current* turn only.
        if store_message and history:
            for i in range(len(history) - 1, -1, -1):
                if history[i].role == "user":
                    history[i] = LLMMessage(role="user", content=user_message)
                    break

        # Get tool definitions — same path for cloud and local (cached).
        # Use contract-filtered list so the LLM never sees tools the
        # contract blocks outright (avoids wasting rounds on blocked calls).
        tool_defs = None
        if self._registry and self._tool_executor and not skip_tools:
            tool_defs = self._registry.get_contract_filtered_definitions()

        # Build set of valid tool names for hallucination detection
        valid_tools = {t["name"] for t in tool_defs} if tool_defs else set()

        # Fix 7: guarantee exactly one persisted assistant message per chat
        # turn. Every assistant save goes through ``_save_assistant_msg``,
        # which flips the tracker flag. A ``finally`` clause at the end
        # writes a sentinel if the flag is still False — covers stream
        # generators that exit without a ``done=True`` chunk, Uvicorn
        # request timeouts, and network drops mid-fallback. The invariant
        # is enforced at the function boundary, not inside every path.
        _assistant_save_tracker = [False]
        # Incremental-persist state. The same draft row is updated every
        # time _save_assistant_msg is called within this turn (2026-04-19):
        #   first call → INSERT a new row, remember its id
        #   subsequent calls → UPDATE the same row's content
        #   final call (finalize=True) → clear the streaming flag
        # On WebSocket disconnect, whatever was last persisted survives,
        # so the user sees the partial reply on reconnect.
        _draft_message_id: list[str | None] = [None]

        async def _save_assistant_msg(text: str, *, finalize: bool = True) -> None:
            """Persist (or update) the turn's assistant message.

            Fix 7 hardening (2026-04-19) + incremental-persist (2026-04-19):
            asyncio.shield protects the inner persist from outer cancellation
            (WebSocket disconnect, request timeout). Tracker flips BEFORE
            the await so the finally-block sentinel doesn't double-write on
            top. ``finalize=False`` keeps the row marked as streaming so
            the UI can render a cursor.
            """
            _assistant_save_tracker[0] = True
            try:
                result_mid = await asyncio.shield(
                    self._memory.upsert_assistant_message(
                        conversation_id, text,
                        message_id=_draft_message_id[0],
                        streaming=not finalize,
                    ),
                )
                if _draft_message_id[0] is None:
                    _draft_message_id[0] = result_mid
                    # Tell the TurnRegistry about the draft id so the
                    # status endpoint can surface it to a reconnecting UI.
                    if self._turn_registry is not None:
                        try:
                            self._turn_registry.set_phase(
                                conversation_id, "streaming",
                                message_id=result_mid,
                            )
                        except Exception:
                            log.debug("TurnRegistry set_phase failed", exc_info=True)
            except asyncio.CancelledError:
                raise
            except Exception:
                log.exception("assistant message persist failed (chat_stream)")

        # Fix 4: open a mutation scope for this chat turn so we can detect
        # "9 tools fired, 0 succeeded, but the model narrated anyway".
        # Every successful tool execution calls ``note_tool_call()`` inside
        # ``_execute_tool``; the scope counts those automatically. We track
        # tools_attempted locally to distinguish a pure Q&A turn (0 tools
        # attempted, 0 tools succeeded, NOT zero-effect) from a bad turn
        # (N tools attempted, 0 tools succeeded, IS zero-effect).
        from agntspace.activity.decisions import (
            install_scope_mutations as _install_mutations,
        )
        from agntspace.activity.decisions import (
            reset_scope_mutations as _reset_mutations,
        )
        _tools_attempted: list[str] = []
        self._tools_attempted_this_turn = _tools_attempted  # exposed for _execute_tool
        _turn_mutations, _mutations_token = _install_mutations()

        # TurnRegistry: open a live-status record for this chat turn so the
        # status endpoint can answer "is the agent still working?" to a
        # reconnecting UI. Safe no-op when registry is not wired (tests).
        if self._turn_registry is not None:
            try:
                self._turn_registry.start_turn(conversation_id)
            except Exception:
                log.debug("TurnRegistry.start_turn failed", exc_info=True)

        # Incremental-persist seed: write an empty streaming draft row
        # immediately so a reconnecting UI NEVER sees a user message with
        # no reply row. The draft is updated by every subsequent
        # _save_assistant_msg call (same id via _draft_message_id tracker)
        # and finalized at turn end. asyncio.shield protects the seed
        # from cancellation: if the user closes the tab before the LLM
        # even responds, the draft row survives.
        try:
            _seed_mid = await asyncio.shield(self._memory.upsert_assistant_message(
                conversation_id, "", streaming=True,
            ))
            _draft_message_id[0] = _seed_mid
            _assistant_save_tracker[0] = True  # we've committed to a reply row
            if self._turn_registry is not None:
                try:
                    self._turn_registry.set_phase(
                        conversation_id, "thinking", message_id=_seed_mid,
                    )
                except Exception:
                    pass
        except asyncio.CancelledError:
            raise
        except Exception:
            log.debug("draft seed persist failed", exc_info=True)

        try:
            # ── Local-mode fast path: intent matching BEFORE complete_with_tools ──
            # For local models, try regex-based intent matching first (instant,
            # no LLM call).  If we get a match, execute the tool and summarize
            # in ONE LLM call instead of two (complete_with_tools + stream).
            # This avoids queuing behind other Ollama requests for the expensive
            # tool-definition-laden first call that often returns junk JSON anyway.
            if self._local_mode and tool_defs and self._tool_executor:
                # Local models (Ollama) can't do structured tool calling
                # reliably — they return text JSON instead of tool_use blocks.
                # Skip complete_with_tools entirely: intent matching handles
                # tool calls, plain streaming handles conversation.
                # Extract last assistant message for affirmative follow-up matching
                _last_asst = None
                for _h in reversed(history):
                    if _h.role == "assistant":
                        _last_asst = _h.content
                        break
                intent = self._match_local_intent(user_message, last_assistant=_last_asst)
                if intent:
                    tool_name, tool_input = intent
                    # Inject artifact path for refinement tools
                    if _artifact and not tool_input.get("path"):
                        tool_input["path"] = _artifact["path"]
                    log.info("Local fast-path intent: %s(%s)", tool_name, json.dumps(tool_input)[:100])
                    yield StreamChunk(delta="", tool_name=tool_name, tool_status="running")
                    result = await self._execute_tool(tool_name, tool_input)
                    await self._capture_artifact(conversation_id, tool_name, result)
                    await self._record_pulse("tool_use", tool_name)
                    yield StreamChunk(delta="", tool_name=tool_name, tool_status="done")
                    result_json = json.dumps(result, default=str)
                    if len(result_json) > 2000:
                        result_json = result_json[:2000] + "..."
                    # Summarization runs WITHOUT conversation history. Prior
                    # turns (especially earlier hallucinations on the same
                    # channel thread) poison the summary — small local models
                    # pattern-match off their own past confabulations and
                    # reproduce them instead of reading the tool result.
                    # The prompt below carries everything the LLM needs:
                    # the user's question + the actual tool output + a
                    # tight instruction to report ONLY what was returned.
                    summary_msgs = [
                        LLMMessage(
                            role="user",
                            content=(
                                f"User question: {user_message}\n\n"
                                f"The tool `{tool_name}` ran and returned this JSON:\n"
                                f"{result_json}\n\n"
                                "Reply to the user with ONLY the facts in that JSON. "
                                "Do NOT invent any data. Do NOT ask for permission or "
                                "confirmation — the tool already ran. Do NOT mention "
                                "drafting, writing, or any prior context. If the JSON "
                                "shows an empty result, say so plainly. Keep it to "
                                "1-3 sentences."
                            ),
                        ),
                    ]
                    full_response = ""
                    async for chunk in self._llm.stream(messages=summary_msgs, system=_effective_system):
                        full_response += chunk.delta
                        yield chunk
                        if chunk.done:
                            await _save_assistant_msg(_sanitize_assistant_text(full_response))
                    return
                else:
                    # No intent match — skip to plain streaming (no tool loop).
                    # This avoids the 90-100s complete_with_tools call that
                    # always returns junk JSON for local models.
                    log.info("Local mode: no intent match, skipping tool loop for plain streaming")
                    tool_defs = None  # signals "skip tool loop" below
                    # Tell the model not to attempt tool calls in text —
                    # the system prompt mentions tools but we're streaming
                    # without tool support now.
                    _effective_system = (_effective_system or "") + (
                        "\n\nIMPORTANT: You cannot call tools right now. "
                        "Do NOT output JSON, tool_code, or any tool call syntax. "
                        "Respond conversationally in plain natural language only."
                    )

            if tool_defs and self._tool_executor:
                # Tool-enabled path: run tool loop non-streaming, then stream the final answer
                messages = list(history)
                max_rounds = 5
                last_tool_results: dict[str, str] = {}  # tool_name → result json
                # Fix 5: per-round error counts for the circuit breaker.
                # A "round error" is a tool result dict with an "error" key.
                _round_error_counts: list[int] = []
                _round_total_counts: list[int] = []
                _recovery_rules = self._load_chat_recovery_rules() or {}
                _cb = {
                    **self._RECOVERY_DEFAULTS["circuit_breaker"],
                    **(_recovery_rules.get("circuit_breaker") or {}),
                }

                for _round_idx in range(max_rounds):
                    response = await self._llm.complete_with_tools(
                        messages=messages,
                        tools=tool_defs,
                        system=_effective_system,
                    )

                    if response.tool_calls:
                        # Validate tool names — local models may hallucinate
                        invalid = [tc for tc in response.tool_calls if tc.name not in valid_tools]
                        if invalid:
                            log.warning(
                                "Hallucinated tool calls: %s — falling back to conversation",
                                ", ".join(tc.name for tc in invalid),
                            )
                            break  # fall through to streaming

                        # Detect repeated tool call (local models loop instead of summarizing)
                        repeated = any(tc.name in last_tool_results for tc in response.tool_calls)
                        if repeated and self._local_mode:
                            log.info("Local model repeated tool call — summarizing previous result")
                            # Use the last tool result to generate a summary
                            tool_name = response.tool_calls[0].name
                            result_json = last_tool_results[tool_name]
                            if len(result_json) > 2000:
                                result_json = result_json[:2000] + "..."
                            summary_msgs = list(history) + [
                                LLMMessage(
                                    role="user",
                                    content=f"I asked: {user_message}\n\nThe tool `{tool_name}` returned:\n{result_json}\n\nSummarize this result concisely for the user.",
                                ),
                            ]
                            full_response = ""
                            async for chunk in self._llm.stream(messages=summary_msgs, system=_effective_system):
                                full_response += chunk.delta
                                yield chunk
                                if chunk.done:
                                    await _save_assistant_msg(_sanitize_assistant_text(full_response))
                            return

                        messages.append(LLMMessage(
                            role="assistant",
                            content=response.raw_content or response.content,
                        ))

                        _any_blocked = False
                        _round_errors = 0
                        _round_total = 0
                        # Turn-registry phase: tool loop is now executing.
                        if self._turn_registry is not None:
                            try:
                                self._turn_registry.set_phase(conversation_id, "tool")
                            except Exception:
                                pass
                        for tc in response.tool_calls:
                            log.info("Tool call (stream): %s(%s)", tc.name, json.dumps(tc.input)[:100])
                            _tools_attempted.append(tc.name)  # Fix 4: zero-effect tracking
                            yield StreamChunk(delta="", tool_name=tc.name, tool_status="running")
                            # Time the per-tool dispatch so the conversation
                            # trace shows duration alongside the result.
                            import time as _time_local
                            _tool_t0 = _time_local.perf_counter()
                            result = await self._execute_tool(tc.name, tc.input)
                            _tool_dur_ms = int((_time_local.perf_counter() - _tool_t0) * 1000)
                            await self._capture_artifact(conversation_id, tc.name, result)
                            await self._record_pulse("tool_use", f"{tc.name}")
                            result_str = json.dumps(result)
                            last_tool_results[tc.name] = result_str
                            yield StreamChunk(delta="", tool_name=tc.name, tool_status="done")
                            messages.append(LLMMessage(
                                role="tool",
                                content=result_str,
                                tool_call_id=tc.id,
                            ))
                            # Tool trace persistence — every tool call leaves
                            # a role="tool" row in conversation history so the
                            # chat UI can render it as an expandable card even
                            # after the user reloads or the WebSocket dropped.
                            _tool_succeeded = not (
                                isinstance(result, dict) and result.get("error")
                            )
                            _tool_status = "ok" if _tool_succeeded else "failed"
                            try:
                                await asyncio.shield(
                                    self._memory.add_tool_message(
                                        conversation_id, tc.name, tc.input or {}, result or {},
                                        status=_tool_status, duration_ms=_tool_dur_ms,
                                    ),
                                )
                            except asyncio.CancelledError:
                                raise
                            except Exception:
                                log.debug("tool message persist failed", exc_info=True)
                            # TurnRegistry bookkeeping
                            if self._turn_registry is not None:
                                try:
                                    self._turn_registry.note_tool(
                                        conversation_id, tc.name, succeeded=_tool_succeeded,
                                    )
                                except Exception:
                                    pass
                            if isinstance(result, dict) and result.get("contract_blocked"):
                                _any_blocked = True
                            # Fix 5: per-round error tally for circuit breaker.
                            _round_total += 1
                            if isinstance(result, dict) and "error" in result:
                                _round_errors += 1

                        _round_error_counts.append(_round_errors)
                        _round_total_counts.append(_round_total)

                        # Fix 5: error-fatigue circuit breaker. After
                        # min_rounds_before_break rounds, compute the error
                        # rate over the sliding window. If the LLM is stuck
                        # in a spin of failing tools, break out and let
                        # _run_chat_recovery summarise what happened —
                        # don't trust the LLM to narrate honestly after
                        # its fourth consecutive error dict.
                        _min_rounds = int(_cb.get("min_rounds_before_break", 2))
                        _threshold = float(_cb.get("error_rate_threshold", 0.5))
                        _window = int(_cb.get("window_size_rounds", 2))
                        if len(_round_error_counts) >= _min_rounds and _threshold > 0.0:
                            _win_errors = sum(_round_error_counts[-_window:])
                            _win_total = sum(_round_total_counts[-_window:])
                            if _win_total > 0:
                                _rate = _win_errors / _win_total
                                if _rate >= _threshold:
                                    log.warning(
                                        "Circuit breaker fired: window error rate %.2f >= %.2f (%d/%d)",
                                        _rate, _threshold, _win_errors, _win_total,
                                    )
                                    _success_total = sum(_round_total_counts) - sum(_round_error_counts)
                                    _error_total = sum(_round_error_counts)
                                    _recovery_text = ""
                                    async for _ch in self._run_chat_recovery(
                                        messages=messages,
                                        system=_effective_system,
                                        trigger="circuit_broken",
                                        tools_attempted=list(_tools_attempted),
                                        success_count=_success_total,
                                        error_count=_error_total,
                                    ):
                                        _recovery_text += _ch.delta
                                        yield _ch
                                    if _recovery_text.strip():
                                        await _save_assistant_msg(
                                            _sanitize_assistant_text(_recovery_text)
                                        )
                                    return

                        # If any tool was contract-blocked, tell the user
                        # immediately instead of letting the LLM loop and
                        # retry (which shows as infinite "thinking").
                        if _any_blocked:
                            _block_msg = (
                                "I can't perform that action — it's blocked by "
                                "your Contract settings. You can change the "
                                "permission in the Contract Editor (Settings → "
                                "Contract) if you'd like to allow it."
                            )
                            for i in range(0, len(_block_msg), 12):
                                yield StreamChunk(delta=_block_msg[i:i + 12])
                            await _save_assistant_msg(_block_msg)
                            yield StreamChunk(delta="", done=True)
                            return

                        continue

                    # No tool calls — check for narrated tool call (local-mode guard #6)
                    narrated = _extract_narrated_tool_call(response.content, valid_tools)
                    if narrated:
                        _nt_tool, _nt_params = narrated
                        log.info("Recovered narrated tool call (stream): %s(%s)", _nt_tool, json.dumps(_nt_params)[:100])
                        _nt_result = await self._execute_tool(_nt_tool, _nt_params)
                        await self._capture_artifact(conversation_id, _nt_tool, _nt_result)
                        await self._record_pulse("tool_use", f"{_nt_tool} (recovered)")
                        self._log_activity("tool", "invoked", f"{_nt_tool} (narrated recovery)",
                                           {"tool": _nt_tool, "input_keys": list(_nt_params.keys())})
                        _nt_result_json = json.dumps(_nt_result)
                        if len(_nt_result_json) > 2000:
                            _nt_result_json = _nt_result_json[:2000] + "..."
                        _nt_summary_msgs = list(history) + [
                            LLMMessage(
                                role="user",
                                content=(
                                    f"I asked: {user_message}\n\n"
                                    f"The tool `{_nt_tool}` was executed with result:\n{_nt_result_json}\n\n"
                                    f"Tell the user what was done. Be brief and positive. If the result contains a 'message' or 'created' or 'scraped' field, report success. Only report an error if there is an 'error' field."
                                ),
                            ),
                        ]
                        _nt_full = ""
                        async for chunk in self._llm.stream(messages=_nt_summary_msgs, system=_effective_system):
                            _nt_full += chunk.delta
                            yield chunk
                            if chunk.done:
                                await _save_assistant_msg(_sanitize_assistant_text(_nt_full))
                        return

                    # Stream the final text response
                    final_text = response.content
                    # Reply hook runs BEFORE chunking so plugins can rewrite
                    # the text the user actually sees (not just what's persisted).
                    final_text = await self._apply_reply_hook(final_text, conversation_id)
                    # Detect junk output (raw JSON tool calls, empty braces).
                    # Cloud models can also emit tool-call JSON as text, so
                    # check unconditionally — not just in local mode.
                    is_junk = _is_junk_assistant_text(final_text)
                    if is_junk and last_tool_results:
                        log.info("Empty response after tool call — summarizing")
                        tool_name, result_json = next(iter(last_tool_results.items()))
                        if len(result_json) > 2000:
                            result_json = result_json[:2000] + "..."
                        summary_msgs = list(history) + [
                            LLMMessage(
                                role="user",
                                content=f"I asked: {user_message}\n\nThe tool `{tool_name}` returned:\n{result_json}\n\nSummarize this result concisely for the user.",
                            ),
                        ]
                        full_response = ""
                        async for chunk in self._llm.stream(messages=summary_msgs, system=_effective_system):
                            full_response += chunk.delta
                            yield chunk
                            if chunk.done:
                                await _save_assistant_msg(_sanitize_assistant_text(full_response))
                        return

                    # Junk output with no tool results — fall through to plain streaming
                    if is_junk:
                        log.info("Junk output from local model (%s) — falling to plain streaming", repr(final_text.strip()))
                        break

                    # Stream it chunk by chunk for a natural feel
                    chunk_size = 12
                    for i in range(0, len(final_text), chunk_size):
                        piece = final_text[i : i + chunk_size]
                        yield StreamChunk(delta=piece)

                    # Save message and send done BEFORE slow post-processing
                    final_text = _sanitize_assistant_text(final_text)
                    await _save_assistant_msg(final_text)
                    log.info(
                        "Stream response (tools): %d in, %d out tokens",
                        response.input_tokens, response.output_tokens,
                    )
                    yield StreamChunk(delta="", done=True, response=response)

                    # Post-processing — fire-and-forget so the generator
                    # exits immediately and the WS handler can process the
                    # next user message without waiting for entity extraction
                    # or prediction hooks (which involve LLM calls).
                    _resp_copy = response

                    async def _post_process(_ft=final_text, _rc=_resp_copy) -> None:
                        try:
                            await self._extract_entities(_ft, is_user=False)
                            await self._record_cost(
                                _rc.input_tokens, _rc.output_tokens,
                                conversation_id, "chat_stream",
                                cache_read_tokens=_rc.cache_read_tokens,
                                cache_write_tokens=_rc.cache_write_tokens,
                                provider_cost=_rc.provider_cost,
                            )
                            await self._record_pulse("chat", f"Response: {_ft[:80]}")
                            self._fire_prediction_hook(user_message, _ft)
                        except Exception:
                            log.debug("chat_stream post-processing failed", exc_info=True)

                    asyncio.create_task(_post_process())
                    return

                # Fell through — either exhausted rounds or hallucinated tools
                # Fix 1: unified round-exhaustion handler. BOTH local and
                # cloud modes now summarise what actually ran using the
                # FULL `messages` context (tool calls + results across all
                # rounds). Previously cloud mode abandoned accumulated
                # context and re-streamed from raw history, producing a
                # generic preamble that promised work that had already
                # been attempted — the exact "I'll create a KB… Absolutely."
                # failure observed with GPT-5.4 on the Colombia-floods turn.
                #
                # Only trigger unified recovery when we actually exhausted
                # rounds with tool work (last_tool_results is non-empty).
                # Hallucinated-tool breaks and intent-fallback paths in
                # local mode still have their own recovery handlers below.
                if last_tool_results:
                    log.info(
                        "Tool loop fell through with %d tools across %d rounds — running recovery",
                        len(last_tool_results), len(_round_total_counts),
                    )
                    _success_total = sum(_round_total_counts) - sum(_round_error_counts)
                    _error_total = sum(_round_error_counts)
                    _recovery_text = ""
                    async for _ch in self._run_chat_recovery(
                        messages=messages,
                        system=_effective_system,
                        trigger="round_exhausted",
                        tools_attempted=list(_tools_attempted),
                        success_count=_success_total,
                        error_count=_error_total,
                    ):
                        _recovery_text += _ch.delta
                        yield _ch
                    if _recovery_text.strip():
                        await _save_assistant_msg(
                            _sanitize_assistant_text(_recovery_text)
                        )
                    return

                # No tool results accumulated — local mode intent fallback
                if self._local_mode:

                    intent = self._match_local_intent(user_message)
                    if intent:
                        tool_name, tool_input = intent
                        log.info("Intent fallback: %s(%s)", tool_name, json.dumps(tool_input)[:100])
                        yield StreamChunk(delta=f"Running {tool_name}...\n\n")
                        result = await self._execute_local_tool(tool_name, tool_input)
                        await self._record_pulse("tool_use", tool_name)
                        result_json = json.dumps(result, default=str)
                        if len(result_json) > 2000:
                            result_json = result_json[:2000] + "..."
                        summary_msgs = list(history) + [
                            LLMMessage(
                                role="user",
                                content=f"I asked: {user_message}\n\nThe tool `{tool_name}` returned:\n{result_json}\n\nSummarize this result concisely for the user.",
                            ),
                        ]
                        full_response = ""
                        async for chunk in self._llm.stream(messages=summary_msgs, system=_effective_system):
                            full_response += chunk.delta
                            yield chunk
                            if chunk.done:
                                await _save_assistant_msg(_sanitize_assistant_text(full_response))
                        return

                # No intent match either — fall through to plain streaming

            # No tools or fallback — pure streaming.
            # When we land here after junk detection broke us out of the tool
            # loop, the model saw tool descriptions in the system prompt and
            # tried to call them via text JSON.  Append a stern instruction so
            # the plain-stream retry doesn't repeat the same mistake.
            _stream_system = _effective_system
            if self._local_mode and tool_defs:
                _stream_system = (_effective_system or "") + (
                    "\n\nIMPORTANT: Do NOT output JSON tool calls. "
                    "Respond in plain natural language only."
                )
            full_response = ""
            async for chunk in self._llm.stream(
                messages=history,
                system=_stream_system,
            ):
                full_response += chunk.delta
                yield chunk
                if chunk.done:
                    await _save_assistant_msg(_sanitize_assistant_text(full_response))
                    if chunk.response:
                        log.info(
                            "Stream response: %d input tokens, %d output tokens",
                            chunk.response.input_tokens,
                            chunk.response.output_tokens,
                        )
                        await self._record_cost(
                            chunk.response.input_tokens,
                            chunk.response.output_tokens,
                            conversation_id, "chat_stream",
                            cache_read_tokens=chunk.response.cache_read_tokens,
                            cache_write_tokens=chunk.response.cache_write_tokens,
                            provider_cost=chunk.response.provider_cost,
                        )
                    self._fire_prediction_hook(user_message, full_response)

        except LLMError as e:
            error_msg = f"⚠ {e.user_message}"
            await _save_assistant_msg(error_msg)
            yield StreamChunk(delta=error_msg, done=True)
        except Exception as e:
            log.exception("Streaming failed")
            error_msg = f"⚠ Unexpected error: {e!s}"
            with contextlib.suppress(Exception):
                await _save_assistant_msg(error_msg)
            yield StreamChunk(delta=error_msg, done=True)
        finally:
            # Fix 7: invariant — exactly one persisted assistant message per
            # chat turn. If the stream exited without saving (provider closed
            # the stream before done=True, Uvicorn request timeout, network
            # drop mid-fallback, or an early `return` from a path that did
            # NOT reach a save site), write a sentinel so the conversation
            # isn't left in the "user spoke, agent ghosted" state.
            if not _assistant_save_tracker[0]:
                try:
                    # Fix 7 hardening (2026-04-19): the finally block runs
                    # under the same cancellation scope as the rest of the
                    # function. Without asyncio.shield the await raises
                    # CancelledError immediately and the sentinel is lost
                    # — which is the exact failure mode that produced the
                    # 23:19 Colombia chat with one user message and zero
                    # assistant replies. Shield ensures the inner persist
                    # task runs to completion in the background even when
                    # our caller is being torn down.
                    await asyncio.shield(
                        self._memory.add_message(
                            conversation_id, "assistant",
                            "⚠ The model's reply did not complete. Please try again.",
                        ),
                    )
                    self._log_activity(
                        "llm", "empty_turn",
                        "Chat turn ended without a persisted assistant reply (sentinel written)",
                        {"conversation_id": conversation_id},
                        importance="high",
                    )
                except (asyncio.CancelledError, Exception):
                    log.debug("Sentinel assistant message failed", exc_info=True)

            # Fix 4: zero-effect detection. Tools were ATTEMPTED but none
            # succeeded. Emit a high-importance activity event so the user
            # sees it in /activity and SkillSchool can analyze the pattern
            # later. The user-facing warning (whether to prepend text to
            # the reply) is a strategy decision — see the
            # `_meta/chat-recovery` skill which owns that call.
            try:
                if _tools_attempted and _turn_mutations.is_zero_effect():
                    self._log_activity(
                        "llm", "chat_zero_effect",
                        (
                            f"Chat turn called {len(_tools_attempted)} tool(s) but "
                            f"produced no verifiable effect "
                            f"(writes=0, tools=0, triples=0)"
                        ),
                        {
                            "conversation_id": conversation_id,
                            "tools_attempted": _tools_attempted,
                            "vault_writes": _turn_mutations.vault_writes,
                            "successful_tool_calls": _turn_mutations.tool_calls,
                            "graph_triples": _turn_mutations.graph_triples,
                        },
                        importance="high",
                    )
                    # Record a decision row so SkillSchool's analyze_*
                    # loops can see this pattern alongside reply-guardrails
                    # signals and zero-effect invoke_skill runs.
                    if self._decisions:
                        try:
                            await self._decisions.record(
                                actor="main",
                                action_type="chat_turn",
                                action_target="zero_effect",
                                rationale=(
                                    f"Turn attempted {len(_tools_attempted)} tools "
                                    f"but produced no observable effect"
                                ),
                                details={
                                    "tools_attempted": _tools_attempted,
                                    "mutations": _turn_mutations.summary(),
                                },
                                result_status="zero_effect",
                                error_type="empty_result",
                                triggered_by="user_message",
                            )
                        except Exception:
                            log.debug("Zero-effect decision record failed", exc_info=True)
            except Exception:
                log.debug("Zero-effect detection failed", exc_info=True)

            # Close the mutation scope so nested calls don't bleed into it.
            _reset_mutations(_mutations_token)

            # Close the TurnRegistry entry. Any future /status query for
            # this conversation returns None until the next turn begins.
            # Safe no-op when registry not wired OR the turn was never
            # registered (e.g. early exception before start_turn).
            if self._turn_registry is not None:
                try:
                    self._turn_registry.end_turn(conversation_id)
                except Exception:
                    log.debug("TurnRegistry.end_turn failed", exc_info=True)

    def _log_activity(self, category: str, action: str, summary: str,
                      details: dict | None = None, importance: str = "medium") -> None:
        """Log to the centralized activity logger if available."""
        if self._activity and hasattr(self._activity, "log"):
            try:
                self._activity.log(category, action, summary, details, importance)
            except Exception:
                pass

    async def _execute_tool(self, tool_name: str, tool_input: dict) -> dict:
        """Execute a tool call — handles built-in, registry, and MCP tools.

        Records a Decision with the active skill context so the user can
        trace back *why* this tool was called.
        """
        result: dict

        # ── contract pre-check (all modes) ────────────────────
        # Catches blocks BEFORE any execution branch so the tool loop
        # never wastes a round on a tool the contract refuses.  The
        # local-mode path had this; the standard path relied on
        # ToolExecutor.execute() which returns the same dict — but
        # the LLM saw it as an ambiguous error and retried.  Now the
        # result includes a `contract_blocked` flag the chat loop can
        # detect and break out immediately with a user-facing message.
        if self._registry and not tool_name.startswith("mcp_"):
            _tool = self._registry._tools.get(tool_name)
            if _tool and _tool.contract_action and self._registry._contract:
                _check = self._registry._contract.check(_tool.contract_action)
                if not _check.allowed and not _check.requires_confirmation:
                    self._log_activity(
                        "contract", "tool_blocked",
                        f"Contract blocked tool {tool_name}: {_check.reason}",
                        {"tool": tool_name, "action": _tool.contract_action},
                        importance="high",
                    )
                    return {
                        "error": f"Contract blocked: {_check.reason}",
                        "contract_blocked": True,
                    }

        # ── trust pre-check (autonomous actions only) ─────────
        # Trust can ESCALATE (require confirmation) or RELAX (auto-allow).
        # Runs after contract — contract blocks are absolute.
        # User-initiated calls (chat-* / http-* correlation) bypass.
        _trust_result = None
        if self._trust_service and self._registry and not tool_name.startswith("mcp_"):
            _t_tool = self._registry._tools.get(tool_name)
            _t_action = getattr(_t_tool, "contract_action", None) if _t_tool else None
            if _t_action:
                from agntspace.activity.decisions import current_correlation_id as _cur_cid
                _cid = _cur_cid()
                # User-initiated correlation prefixes bypass trust gating:
                #   chat-*      — UI chat turn
                #   http-*      — direct REST call (CLI, external client)
                #   channel-*   — messaging channel where the human typed
                #                 the message (WhatsApp, Telegram, Discord,
                #                 Slack, etc.). Agent-initiated outreach
                #                 uses a separate `outreach-*` prefix and
                #                 IS still treated as autonomous.
                _user_prefixes = ("chat-", "http-", "channel-")
                _is_auto = bool(_cid) and not _cid.startswith(_user_prefixes)
                _proactivity = (
                    self._registry._contract.rules.proactivity
                    if self._registry._contract else "advisor"
                )
                _trust_result = self._trust_service.check_tool_trust(
                    _t_action, _proactivity, is_autonomous=_is_auto,
                )
                if not _trust_result.action_allowed:
                    self._log_activity(
                        "trust", "tool_blocked",
                        f"Trust blocked tool {tool_name}: {_trust_result.reason}",
                        {"tool": tool_name, "action": _t_action,
                         "domain": _trust_result.domain,
                         "effective_level": _trust_result.effective_level},
                        importance="high",
                    )
                    return {
                        "error": f"Trust blocked: {_trust_result.reason}",
                        "trust_blocked": True,
                    }
                if _trust_result.requires_confirmation:
                    # Only escalate if contract didn't already require confirmation
                    _contract_confirms = (
                        "_check" in dir()
                        and getattr(_check, "requires_confirmation", False)  # noqa: F821
                    )
                    if not _contract_confirms:
                        return {
                            "needs_confirmation": True,
                            "action": tool_name,
                            "reason": _trust_result.reason,
                            "trust_level": _trust_result.effective_level,
                        }

        # Track last tool's contract action for correction signal domain lookup
        if self._registry and not tool_name.startswith("mcp_"):
            _lt = self._registry._tools.get(tool_name)
            if _lt and getattr(_lt, "contract_action", None):
                self._last_tool_contract_action = _lt.contract_action

        # ── before_tool_call hook ──────────────────────────────
        # Plugins can veto the call (e.g. budget guard, rate limiter) or
        # rewrite tool_input. Fires AFTER contract gating — plugins can only
        # narrow permissions, never widen them.
        if self._hooks is not None:
            try:
                from agntspace.plugins.hooks import HookContext, HookName
                pre_ctx = HookContext(
                    hook=HookName.BEFORE_TOOL_CALL,
                    tool_name=tool_name,
                    tool_input=tool_input,
                )
                await self._hooks.fire(HookName.BEFORE_TOOL_CALL, pre_ctx)
                if pre_ctx.veto:
                    return {"error": f"Tool vetoed by plugin: {pre_ctx.veto_reason}"}
                tool_input = pre_ctx.tool_input  # handlers may have rewritten args
            except Exception:
                # Hook bus exceptions are non-fatal here — fail_closed semantics
                # are enforced inside HookBus.fire(), which converts handler
                # errors into a veto. Anything that escapes is a bug in the
                # bus itself, not a reason to break the agent loop.
                log.exception("hook bus error in before_tool_call")

        # Unified dispatcher (Fix 6): resolve async_handler first, then
        # sync handler, then ToolExecutor fallback. Cloud and local paths
        # converge on the same resolution logic — previously cloud mode
        # routed ONLY through ToolExecutor.execute(), silently failing on
        # tools registered via registry.register_tool() with no matching
        # _exec_<name> method (e.g. run_wiki_job, create_kb).
        #
        # Fix 2: time the dispatch so the decision log records wall-clock
        # duration alongside the outcome classification.
        import time as _time
        _dispatch_started_at = _time.perf_counter()
        result = await self._dispatch_tool(tool_name, tool_input)
        _dispatch_duration_ms = int((_time.perf_counter() - _dispatch_started_at) * 1000)

        # ── after_tool_call hook ───────────────────────────────
        # Plugins can rewrite tool_result (redaction, transformation, caching).
        # fail_open: handler errors do not abort the call.
        if self._hooks is not None and isinstance(result, dict):
            try:
                from agntspace.plugins.hooks import HookContext, HookName
                post_ctx = HookContext(
                    hook=HookName.AFTER_TOOL_CALL,
                    tool_name=tool_name,
                    tool_input=tool_input,
                    tool_result=result,
                )
                await self._hooks.fire(HookName.AFTER_TOOL_CALL, post_ctx)
                if isinstance(post_ctx.tool_result, dict):
                    result = post_ctx.tool_result
            except Exception:
                log.exception("hook bus error in after_tool_call")

        # Record as a verifiable mutation in the current skill scope (if any).
        # An error dict still counts as a "real attempt" — only tools that
        # were never invoked are absent from the counter. This is what
        # Agent.invoke_skill uses to distinguish real work from narration.
        if isinstance(result, dict) and "error" not in result:
            try:
                from agntspace.activity.decisions import note_tool_call
                note_tool_call()
            except Exception:
                pass

        # Memory provenance window: if this was a memory/knowledge recall
        # tool, stash the returned entity/triple IDs so future tool calls
        # in the same turn can cite them in their decision record.
        _memory_tools = {
            "recall_memory", "deep_memory_recall",
            "query_memory_graph", "query_knowledge",
        }
        if tool_name in _memory_tools and isinstance(result, dict):
            try:
                refs = self._extract_memory_refs(result)
                if refs:
                    self._recent_memory_refs.extend(refs)
                    # Keep the window bounded
                    if len(self._recent_memory_refs) > 50:
                        self._recent_memory_refs = self._recent_memory_refs[-50:]
            except Exception:
                pass

        # Decision log — record WHY this tool was called AND what happened (Fix 2)
        if self._decisions:
            contract_action = ""
            contract_result = "allowed"
            if self._registry:
                tool = self._registry._tools.get(tool_name) if hasattr(self._registry, "_tools") else None
                if tool and getattr(tool, "contract_action", None):
                    contract_action = tool.contract_action
            if isinstance(result, dict) and "Contract blocked" in str(result.get("error", "")):
                contract_result = "blocked"
            if isinstance(result, dict) and result.get("trust_blocked"):
                contract_result = "trust_blocked"
            try:
                skills = self._active_skills() if hasattr(self, "_active_skills") else []
            except Exception:
                skills = []
            _tl = _trust_result.effective_level if _trust_result else ""
            _td = _trust_result.domain if _trust_result else ""

            # Fix 2: classify the outcome so the row answers "did this work?"
            # alongside "was this allowed?". YAML rules from the
            # _meta/tool-outcome-classify skill provide per-tool semantic
            # rules (e.g. run_wiki_job with ingested==0 → zero_effect).
            from agntspace.activity.decisions import classify_tool_result
            _outcome_rules = self._load_tool_outcome_rules()
            _result_status, _error_type = classify_tool_result(
                result,
                duration_ms=_dispatch_duration_ms,
                yaml_rules=_outcome_rules,
                tool_name=tool_name,
            )

            # Rationale reflects the outcome for quick human scan
            if _result_status == "ok":
                _rationale = f"Tool {tool_name} invoked during chat"
            elif _result_status == "zero_effect":
                _rationale = f"Tool {tool_name} completed with no observable effect"
            elif _result_status == "blocked":
                _rationale = f"Tool {tool_name} blocked ({_error_type})"
            elif _result_status == "failed":
                _rationale = f"Tool {tool_name} failed ({_error_type})"
            elif _result_status == "partial":
                _rationale = f"Tool {tool_name} partial success"
            else:
                _rationale = f"Tool {tool_name} invoked during chat"

            # Enrich details with a compact result summary the user can scan
            # in /decisions without expanding the row. Never include full
            # result body — that could contain secrets or tens of KB of text.
            _details = {
                "input_keys": sorted(tool_input.keys()) if isinstance(tool_input, dict) else [],
            }
            if isinstance(result, dict):
                _result_keys = sorted(result.keys())[:12]
                _details["result_keys"] = _result_keys
                if "error" in result and isinstance(result["error"], str):
                    _details["error_preview"] = result["error"][:240]

            try:
                await self._decisions.record(
                    actor="main",
                    action_type="tool",
                    action_target=tool_name,
                    contract_action=contract_action,
                    contract_result=contract_result,
                    triggered_by="chat_tool",
                    context_skills=skills,
                    context_memories=list(self._recent_memory_refs),
                    rationale=_rationale,
                    details=_details,
                    trust_level=_tl,
                    trust_domain=_td,
                    result_status=_result_status,
                    duration_ms=_dispatch_duration_ms,
                    error_type=_error_type,
                )
            except Exception:
                log.debug("Decision log record failed for tool %s", tool_name, exc_info=True)

        return result

    def _load_chat_recovery_rules(self) -> dict:
        """Load YAML from the _meta/chat-recovery skill (Fix 1 + Fix 5 strategy).

        Mtime-gated cache. Returns an empty dict when the skill or file is
        missing — the engine then uses conservative hardcoded defaults
        tagged ``# arch:deterministic`` below.
        """
        import yaml

        if not self._skill_loader:
            return getattr(self, "_recovery_rules_cache", {}) or {}
        skills_dir = self._skill_loader._skills_dir
        config_path = skills_dir / "_meta" / "chat-recovery" / "config" / "recovery.yaml"
        if not config_path.is_file():
            self._recovery_rules_cache = {}
            self._recovery_rules_mtime = 0.0
            return {}
        try:
            mtime = config_path.stat().st_mtime
        except OSError:
            return getattr(self, "_recovery_rules_cache", {}) or {}

        cached_mtime = getattr(self, "_recovery_rules_mtime", None)
        cached = getattr(self, "_recovery_rules_cache", None)
        if cached is not None and cached_mtime == mtime:
            return cached

        rules: dict = {}
        try:
            with open(config_path) as fh:
                loaded = yaml.safe_load(fh) or {}
            if isinstance(loaded, dict):
                rules = loaded
        except Exception:
            log.warning("Failed to load chat-recovery rules", exc_info=True)

        self._recovery_rules_cache = rules
        self._recovery_rules_mtime = mtime
        return rules

    # Conservative defaults — used only when the skill YAML is missing or
    # malformed. Tagged arch:deterministic because the engine must still
    # function with no skill config on first boot or after a user wipe.
    _RECOVERY_DEFAULTS = {  # arch:deterministic — fallback when chat-recovery YAML absent
        "circuit_breaker": {
            "min_rounds_before_break": 2,
            "error_rate_threshold": 0.5,
            "window_size_rounds": 2,
        },
        "exhaustion": {
            "directive": (
                "Summarise what actually happened in this turn. ONLY report "
                "on the tool calls that actually ran. Be explicit about "
                "failures. End with one concrete next step. 3-6 sentences."
            ),
            "max_summary_tokens": 400,
        },
        "fallback": {
            "template": (
                "⚠ I attempted {tools_attempted} tool call(s) this turn but "
                "couldn't complete a summary. {success_count} succeeded; "
                "{error_count} failed. You can check /decisions for the "
                "full causal chain, or retry this request."
            ),
        },
    }

    async def _run_chat_recovery(
        self,
        messages: list,
        system: str,
        trigger: str,
        tools_attempted: list[str],
        success_count: int,
        error_count: int,
    ):
        """Fix 1 + Fix 5 recovery handler.

        Called when either the error-fatigue circuit breaker fires OR the
        tool loop exhausts ``max_rounds`` without settling. Summarises
        what actually happened using the FULL messages context (every
        tool call + result across rounds) — never re-streams from raw
        history, which would produce a generic preamble.

        Yields ``StreamChunk`` deltas for the user. Returns the final
        persisted text so the caller can decide whether to save.

        On LLM failure, writes the deterministic fallback template so
        the user ALWAYS gets an honest, grounded message.
        """
        from agntspace.llm.base import Message as LLMMessage

        rules = self._load_chat_recovery_rules() or {}
        exh = {**self._RECOVERY_DEFAULTS["exhaustion"], **(rules.get("exhaustion") or {})}
        fb = {**self._RECOVERY_DEFAULTS["fallback"], **(rules.get("fallback") or {})}

        # Emit a high-importance activity event BEFORE the recovery call so
        # /activity shows the trigger regardless of whether the recovery
        # succeeds or falls back.
        try:
            self._log_activity(
                "llm",
                f"chat_{trigger}",
                (
                    f"Chat recovery triggered ({trigger}): "
                    f"{len(tools_attempted)} tools attempted, "
                    f"{success_count} succeeded, {error_count} failed"
                ),
                {
                    "trigger": trigger,
                    "tools_attempted": tools_attempted,
                    "success_count": success_count,
                    "error_count": error_count,
                },
                importance="high",
            )
        except Exception:
            pass

        directive = exh.get("directive", "")
        recovery_msgs = list(messages) + [
            LLMMessage(role="user", content=directive),
        ]

        text = ""
        try:
            async for chunk in self._llm.stream(messages=recovery_msgs, system=system):
                text += chunk.delta
                yield chunk
                if chunk.done and not text.strip():
                    text = ""  # LLM returned empty — fall to template
        except Exception as exc:
            log.warning("Chat recovery LLM call failed: %s — using template", exc)
            text = ""

        if not text.strip():
            template = fb.get("template", self._RECOVERY_DEFAULTS["fallback"]["template"])
            text = template.format(
                tools_attempted=len(tools_attempted),
                success_count=success_count,
                error_count=error_count,
            )
            # Stream the fallback in one chunk so the UI sees it.
            yield StreamChunk(delta=text)

        yield StreamChunk(delta="", done=True)
        # Return the final text via the generator's `value` on StopAsyncIteration.
        # Async generators can't return values, so callers read `text` via the
        # last yielded StreamChunk and persist themselves. Here we only yield;
        # the caller's `async for` loop accumulates the text from deltas.

    def _load_tool_outcome_rules(self) -> dict:
        """Load YAML rules from the _meta/tool-outcome-classify skill.

        Mtime-gated cache. Returns an empty dict when the skill or config
        file is missing — the classifier then falls back to its deterministic
        structural rules (dict-with-error → failed, etc.) without any
        per-tool zero-effect detection.
        """
        import yaml

        if not self._skill_loader:
            return getattr(self, "_outcome_rules_cache", {}) or {}
        skills_dir = self._skill_loader._skills_dir
        config_path = skills_dir / "_meta" / "tool-outcome-classify" / "config" / "outcomes.yaml"
        if not config_path.is_file():
            self._outcome_rules_cache = {}
            self._outcome_rules_mtime = 0.0
            return {}
        try:
            mtime = config_path.stat().st_mtime
        except OSError:
            return getattr(self, "_outcome_rules_cache", {}) or {}

        cached_mtime = getattr(self, "_outcome_rules_mtime", None)
        cached = getattr(self, "_outcome_rules_cache", None)
        if cached is not None and cached_mtime == mtime:
            return cached

        rules: dict = {}
        try:
            with open(config_path) as fh:
                loaded = yaml.safe_load(fh) or {}
            if isinstance(loaded, dict):
                rules = loaded
        except Exception:
            log.warning("Failed to load tool-outcome-classify rules", exc_info=True)

        self._outcome_rules_cache = rules
        self._outcome_rules_mtime = mtime
        return rules

    async def _dispatch_tool(self, tool_name: str, tool_input: dict) -> dict:
        """Unified tool dispatcher. Resolves a tool by name and executes it.

        Fix 6: resolution order is the same for local AND cloud mode:
          1. MCP external tool (prefix ``mcp_``) → await MCP client call.
          2. Registry tool with ``async_handler`` → await directly (no thread
             pool — same event loop, contextvars inherited naturally).
          3. Registry tool with sync ``handler`` → run_in_executor with
             ``contextvars.copy_context()`` so correlation_id / goal_id /
             authorized_actions survive the thread boundary (Fix 3).
          4. Fallback to ``ToolExecutor.execute()`` (sync, thread pool, also
             copy_context-wrapped) — covers tools registered exclusively as
             ``_exec_<name>`` methods on the executor itself.

        Exceptions inside handlers are caught and converted to
        ``{"error": str(e)}`` with the traceback escalated via
        ``_log_tool_crash`` (Fix 8). The caller (``_execute_tool``) classifies
        the returned dict and records the outcome in the decision log (Fix 2).
        """
        import asyncio
        import contextvars

        # ── 1. MCP external tools ─────────────────────────────
        # Only handle mcp_* specially when an MCP client is available.
        # Without a client we fall through to the executor chain below —
        # matches pre-refactor semantics (a ghost mcp_ tool name is still
        # routed to ToolExecutor which returns "Unknown tool: ...").
        if (
            tool_name.startswith("mcp_")
            and self._registry is not None
            and getattr(self._registry, "_mcp_client", None) is not None
        ):
            try:
                return await self._registry._mcp_client.call_tool(tool_name, tool_input)
            except Exception as exc:
                self._log_tool_crash(tool_name, tool_input, exc, "mcp")
                return {"error": f"MCP tool failed: {exc}"}

        # ── 2 + 3. Registry-resolved tool ─────────────────────
        registry_tool = None
        if self._registry and hasattr(self._registry, "_tools"):
            registry_tool = self._registry._tools.get(tool_name)

        if registry_tool is not None:
            # 2. Prefer async_handler — direct await, no thread, natural
            #    contextvar inheritance. This is the correct path for
            #    native-async handlers like run_wiki_job / create_kb /
            #    research_scrape; routing them through the executor forces
            #    a sync-wrapper-over-async that has broken contextvar
            #    propagation and per-call event loop creation.
            if registry_tool.async_handler is not None:
                try:
                    return await registry_tool.async_handler(tool_input)
                except Exception as exc:
                    self._log_tool_crash(tool_name, tool_input, exc, "async_handler")
                    return {"error": str(exc)}

            # 3. Sync handler in thread pool with context copy.
            if registry_tool.handler is not None:
                try:
                    loop = asyncio.get_running_loop()
                    ctx = contextvars.copy_context()
                    return await loop.run_in_executor(
                        None, ctx.run, registry_tool.handler, tool_input,
                    )
                except Exception as exc:
                    self._log_tool_crash(tool_name, tool_input, exc, "sync_handler")
                    return {"error": str(exc)}

        # ── 4. ToolExecutor fallback (legacy _exec_<name> methods) ──
        if self._tool_executor is not None:
            try:
                loop = asyncio.get_running_loop()
                ctx = contextvars.copy_context()
                return await loop.run_in_executor(
                    None, ctx.run, self._tool_executor.execute, tool_name, tool_input,
                )
            except Exception as exc:
                self._log_tool_crash(tool_name, tool_input, exc, "tool_executor")
                return {"error": str(exc)}

        return {"error": f"No executor available for {tool_name}"}

    def _log_tool_crash(
        self, tool_name: str, tool_input: dict, exc: BaseException, branch: str,
    ) -> None:
        """Fix 8: route unhandled tool exceptions to ErrorLogger +
        high-importance activity event so /logs and /activity surface them.

        Without this, exceptions become ``{"error": str(e)}`` and the LLM
        reads them as normal tool output. Maintainers have no visibility
        unless they read server stderr.

        Sync signature (called from sync handlers inside ``_dispatch_tool``'s
        except branches); the persistent error_logs write is async so we
        schedule it via ``asyncio.create_task`` when a loop is running and
        fall back to stderr logging otherwise.
        """
        import asyncio as _asyncio
        import traceback as _tb

        log.exception("Tool crash in %s (%s): %s", tool_name, branch, exc)

        # 1. Persistent error_logs row (SQLite, async) — fire-and-forget
        #    so the tool loop isn't blocked on DB write. The ErrorLogger
        #    itself swallows any failure so this task never raises.
        err_logger = getattr(self, "_error_logger", None)
        if err_logger is not None and hasattr(err_logger, "log_exception"):
            try:
                loop = _asyncio.get_running_loop()
                loop.create_task(err_logger.log_exception(
                    exc,
                    source=f"tool:{tool_name}",
                    context={
                        "tool": tool_name,
                        "branch": branch,
                        "input_keys": sorted(tool_input.keys()) if isinstance(tool_input, dict) else [],
                    },
                    status_code=500,
                ))
            except RuntimeError:
                # No running loop — happens in test harnesses running the
                # agent directly without an event loop. Preserve the
                # traceback in the Python logger so it's not completely lost.
                tb_text = "".join(_tb.format_exception(type(exc), exc, exc.__traceback__))[:4000]
                log.error("Tool crash (no loop): %s\n%s", tool_name, tb_text)
            except Exception:
                log.debug("ErrorLogger scheduling failed", exc_info=True)

        # 2. High-importance activity event so the user sees it in /activity.
        self._log_activity(
            "tool", "crashed",
            f"Tool {tool_name} crashed: {type(exc).__name__}: {str(exc)[:160]}",
            {
                "tool": tool_name,
                "branch": branch,
                "error_type": type(exc).__name__,
                "input_keys": sorted(tool_input.keys()) if isinstance(tool_input, dict) else [],
            },
            importance="high",
        )

    # ── Artifact context (feedback loop) ──

    # Tools whose output creates a mutable artifact the user can refine.
    _ARTIFACT_TOOLS: frozenset[str] = frozenset({  # arch:deterministic — tool classification for artifact tracking
        "create_presentation", "generate_kb_presentation", "research_and_present",
        "update_presentation",
        "scrape_url", "research_scrape",
    })

    # How to extract artifact info from each tool's result dict.
    @staticmethod
    def _extract_artifact(tool_name: str, result: dict) -> dict | None:
        """Extract artifact metadata from a tool result, if it produced one."""
        if not isinstance(result, dict) or result.get("error"):
            return None

        path = result.get("path") or result.get("saved_to") or result.get("companion") or ""
        if not path:
            return None
        # Normalize path separators — Windows backslashes break path resolution
        path = path.replace("\\", "/")

        # Infer type from tool name or file extension
        if "presentation" in tool_name or path.endswith(".pptx"):
            atype = "presentation"
        elif "scrape" in tool_name or "research" in tool_name:
            atype = "source"
        else:
            atype = "document"

        meta = {}
        for key in ("slides", "scraped", "kb", "project", "topic", "message"):
            if key in result:
                meta[key] = result[key]

        return {"type": atype, "path": path, "metadata": meta}

    async def _capture_artifact(
        self, conversation_id: str, tool_name: str, result: dict,
    ) -> None:
        """If a tool produced an artifact, set it as the in-focus artifact."""
        if tool_name not in self._ARTIFACT_TOOLS:
            return
        artifact = self._extract_artifact(tool_name, result)
        if artifact and self._memory:
            await self._memory.set_artifact(
                conversation_id,
                artifact["type"],
                artifact["path"],
                artifact.get("metadata"),
            )

    async def _apply_reply_hook(self, text: str, conversation_id: str) -> str:
        """Run the before_agent_reply plugin hook chain on `text`.

        Returns the (possibly mutated) reply text. fail_closed: a handler
        exception or explicit veto replaces the reply with a stub message
        rather than letting tampered/incomplete content through.
        """
        if self._hooks is None or not text:
            return text
        try:
            from agntspace.plugins.hooks import HookContext, HookName
            ctx = HookContext(
                hook=HookName.BEFORE_AGENT_REPLY,
                reply_text=text,
                conversation_id=conversation_id,
            )
            await self._hooks.fire(HookName.BEFORE_AGENT_REPLY, ctx)
            if ctx.veto:
                log.info("reply vetoed by plugin %s: %s", ctx.plugin_name, ctx.veto_reason)
                return "⚠ Reply withheld by plugin policy."
            return ctx.reply_text or text
        except Exception:
            log.exception("hook bus error in before_agent_reply")
            return text

    def _extract_memory_refs(self, result: dict) -> list[str]:
        """Pull entity/triple/memory IDs from a memory-recall tool result.

        Heuristic: looks for common keys across our memory tools — `entities`,
        `triples`, `results`, `hits`, `articles`, `memories` — and extracts
        a best-guess identifier from each item (slug, id, path, or name).
        Returns a deduped list, capped at 20 refs.
        """
        refs: list[str] = []
        if not isinstance(result, dict):
            return refs

        def _take(item: object) -> str | None:
            if isinstance(item, str):
                return item
            if isinstance(item, dict):
                for key in ("slug", "id", "path", "entity", "subject", "name", "title"):
                    val = item.get(key)
                    if isinstance(val, str) and val:
                        return val
            return None

        for key in ("entities", "triples", "results", "hits", "articles", "memories"):
            items = result.get(key)
            if isinstance(items, list):
                for item in items[:20]:
                    ref = _take(item)
                    if ref and ref not in refs:
                        refs.append(ref)
                        if len(refs) >= 20:
                            return refs
        return refs

    def _active_skills(self) -> list[str]:
        """Return the names of skills currently loaded into the system prompt.

        Used by the decision logger to capture which skills were in force when
        an action was taken. Returns empty list if no loader is available.
        """
        if not self._skill_loader:
            return []
        try:
            return [s.name for s in self._skill_loader.get_active_skills()]  # type: ignore[attr-defined]
        except Exception:
            try:
                return sorted(self._skill_loader._skills.keys())  # type: ignore[attr-defined]
            except Exception:
                return []

    async def _execute_local_tool(self, tool_name: str, tool_input: dict) -> dict:
        """Execute a tool in local mode. Tries registry first, then tool executor.

        Uses async_handler when available (we're already in an event loop,
        so sync wrappers that call run_until_complete would deadlock).
        """
        # Registry has dynamically registered tools (run_wiki_job, etc.)
        if self._registry:
            tool = self._registry._tools.get(tool_name)
            if tool:
                # Contract check
                if tool.contract_action and self._registry._contract:
                    check = self._registry._contract.check(tool.contract_action)
                    if not check.allowed:
                        return {"error": f"Contract blocked: {check.reason}"}

                    # Prompt injection defense: if the current correlation
                    # scope contains untrusted content (scraped web, channel
                    # message, MCP result) AND this tool call is destructive
                    # (writes, sends, deletes), escalate to user confirmation
                    # even if the contract would normally allow it. This is
                    # the "if the LLM was tricked, at least the user sees a
                    # confirmation dialog" last-resort defense.
                    # See docs/threat-model.md §9.1 mitigation #4.
                    _DESTRUCTIVE_ACTIONS = frozenset({
                        "write_vault", "send_email", "send_message",
                        "delete_vault_file", "reset_wiki", "modify_wiki",
                        "manage_channels", "create_task", "modify_project",
                    })
                    try:
                        from agntspace.security.prompt_guard import is_scope_untrusted
                        if (
                            is_scope_untrusted()
                            and tool.contract_action in _DESTRUCTIVE_ACTIONS
                            and not check.requires_confirmation
                        ):
                            log.warning(
                                "Untrusted content in scope — escalating %s to confirmation",
                                tool.contract_action,
                            )
                            return {
                                "error": (
                                    f"Action '{tool.contract_action}' requires confirmation "
                                    f"because this conversation contains untrusted external "
                                    f"content (web scraping, channel message, or MCP result). "
                                    f"Please confirm you want to proceed."
                                ),
                                "requires_confirmation": True,
                                "reason": "untrusted_content_escalation",
                            }
                    except Exception:
                        pass  # Non-fatal: defense-in-depth, not gating
                # Prefer async handler (avoids run_until_complete deadlock)
                try:
                    if tool.async_handler:
                        return await tool.async_handler(tool_input)
                    return tool.handler(tool_input)
                except Exception as e:
                    log.exception("Local tool execution failed: %s", tool_name)
                    return {"error": str(e)}

        # Fall back to built-in tool executor
        if self._tool_executor:
            return self._tool_executor.execute(tool_name, tool_input)
        return {"error": f"No executor available for {tool_name}"}

    def _load_local_intents(self) -> list[dict]:
        """Load intent patterns from the local-intents skill config.

        Mtime-gated cache: re-reads the YAML whenever the file on disk has
        changed since the last load. Tuning intents (user edits or shipped
        upgrades via SkillSync) takes effect on the next match attempt
        without a server restart. Mirrors the hot-reload policy of the
        local prompt config to keep skills-driven tuning consistent.
        """
        import yaml

        if not self._skill_loader:
            return getattr(self, "_local_intents_cache", [])
        skills_dir = self._skill_loader._skills_dir
        config_path = skills_dir / "_meta" / "local-intents" / "config" / "intents.yaml"
        if not config_path.is_file():
            self._local_intents_cache = []
            self._local_intents_mtime = 0.0
            return []
        try:
            mtime = config_path.stat().st_mtime
        except OSError:
            return getattr(self, "_local_intents_cache", [])

        cached_mtime = getattr(self, "_local_intents_mtime", None)
        cached = getattr(self, "_local_intents_cache", None)
        if cached is not None and cached_mtime == mtime:
            return cached
        intents: list[dict] = []
        try:
            with open(config_path) as f:
                intents = yaml.safe_load(f) or []
            log.info("Loaded %d local intents from %s", len(intents), config_path)
        except Exception:
            log.warning("Failed to load local intents config", exc_info=True)

        self._local_intents_cache = intents
        self._local_intents_mtime = mtime
        return intents

    _AFFIRMATIVE_RE = re.compile(
        r"^(yes|yeah|yep|yup|sure|ok|okay|go ahead|do it|please|please do|"
        r"absolutely|definitely|of course|go for it|let'?s do it|affirmative)\.?!?$",
        re.IGNORECASE,
    )

    def _match_local_intent(
        self, message: str, last_assistant: str | None = None,
    ) -> tuple[str, dict] | None:
        """Match user message to a tool call using skill-defined intent patterns.

        Returns (tool_name, tool_input) or None if no match.
        Patterns are loaded from skills/_meta/local-intents/config/intents.yaml.

        When the user sends an affirmative follow-up ("yes", "do it", etc.)
        AND the last assistant message offered to do something, we match
        the *assistant's* text against intents. This handles the common
        pattern: agent asks "would you like me to check email?" → user "yes".
        """
        import re as _re

        msg = message.lower().strip()
        intents = self._load_local_intents()

        for intent in intents:
            patterns = intent.get("patterns", [])
            for pattern in patterns:
                if _re.search(pattern, msg, _re.IGNORECASE):
                    tool = intent["tool"]
                    params = dict(intent.get("params", {}))

                    # Extract dynamic parameter if configured
                    extract = intent.get("param_extract")
                    param_key = intent.get("param_key")
                    if extract and param_key:
                        m = _re.search(extract, msg, _re.IGNORECASE)
                        if m:
                            params[param_key] = m.group(1).strip() if m.lastindex else msg

                    # Support multiple extracts (param_extracts: [{pattern, key}])
                    for extra in intent.get("param_extracts", []):
                        ep = extra.get("pattern")
                        ek = extra.get("key")
                        if ep and ek:
                            em = _re.search(ep, msg, _re.IGNORECASE)
                            if em:
                                params[ek] = em.group(1).strip() if em.lastindex else ""

                    return (tool, params)

        # Affirmative follow-up: match the assistant's offer against intents
        if last_assistant and self._AFFIRMATIVE_RE.match(msg):
            for intent in intents:
                for pattern in intent.get("patterns", []):
                    if _re.search(pattern, last_assistant.lower(), _re.IGNORECASE):
                        log.info("Affirmative follow-up matched intent %s from assistant context",
                                 intent["name"])
                        return (intent["tool"], dict(intent.get("params", {})))

        return None

    async def _record_cost(
        self, input_tokens: int, output_tokens: int,
        conversation_id: str | None = None, action: str = "chat",
        model: str | None = None,
        cache_read_tokens: int = 0, cache_write_tokens: int = 0,
        provider_cost: float | None = None,
        goal_id: str | None = None,
    ) -> None:
        """Record token cost if cost tracker is available."""
        if not self._cost_tracker:
            return
        try:
            # Auto-inherit goal_id from contextvar when not explicitly passed
            if not goal_id:
                from agntspace.activity.decisions import current_goal_id
                goal_id = current_goal_id() or None
            actual_model = model or getattr(self._llm, "_model", "unknown")
            await self._cost_tracker.record(
                model=actual_model,
                input_tokens=input_tokens,
                output_tokens=output_tokens,
                conversation_id=conversation_id,
                action=action,
                cache_read_tokens=cache_read_tokens,
                cache_write_tokens=cache_write_tokens,
                provider_cost=provider_cost,
                goal_id=goal_id,
            )
        except Exception:
            pass

    async def _check_budget(self) -> None:
        """Pre-flight budget gate.  Raises BudgetExceededError when over budget."""
        if not self._cost_tracker:
            return
        await self._cost_tracker.check_budget_or_raise()

    @staticmethod
    def _is_junk_response(text: str) -> bool:
        """Detect junk JSON that small models output instead of real content.

        Local models (especially small ones) sometimes emit raw tool call JSON,
        schema definitions, or empty objects instead of following instructions.
        """
        if not text or not text.strip():
            return True
        stripped = text.strip()
        # Raw JSON object (tool call, schema, empty)
        if stripped.startswith("{") and stripped.endswith("}"):
            if any(k in stripped for k in ('"function"', '"type":', '"$schema"', '"name":', '"parameters"', '"tool_code"', '"tool_name"')):
                return True
            if len(stripped) < 20:  # e.g. {} or {"type": "text"}
                return True
        # JSON array of tool calls
        if stripped.startswith("[") and '"function"' in stripped:
            return True
        return False

    async def _record_pulse(self, pulse_type: str, details: str | None = None) -> None:
        """Record agent activity as a pulse on the cardiogram."""
        if self._heartbeat:
            try:
                await self._heartbeat.record_pulse(pulse_type, details)
            except Exception:
                pass

    async def _extract_entities(self, message: str, is_user: bool = True) -> None:
        """Extract entities and triples from messages in real-time.

        User messages: authority=explicit (user stated it)
        Assistant messages: authority=system (agent synthesized it)
        Fast regex runs always. LLM triple extraction for 50+ word messages.
        """
        if not self._memory_engine:
            return
        try:
            links = await self._memory_engine.extract_from_conversation(message, is_user=is_user)
            if links:
                log.debug("Entities extracted (%s): %s",
                          "user" if is_user else "assistant", ", ".join(links))
        except Exception:
            log.debug("Entity extraction failed", exc_info=True)

    def _check_journal_candidate(self, message: str) -> None:
        """Check if a message is thinking-out-loud and flag it for journaling."""
        if not self._journal:
            return
        from agntspace.journal.service import JournalService

        if JournalService.is_thinking_out_loud(message):
            log.info("Thinking-out-loud detected: %s...", message[:60])
            # The agent's system prompt tells it to ask "Save to journal?"
            # Actual saving happens when user confirms via the journal API

    def _check_correction_signal(self, user_text: str) -> None:
        """Scan user message for correction patterns that feed trust.

        When the user says "no", "wrong", "fix this", etc., this is an
        implicit negative trust signal for the domain of the last tool call.
        Patterns are loaded from signals.yaml (Rule #12).
        """
        if not self._trust_service:
            return
        try:
            from agntspace.trust.service import detect_correction, domain_for_action
            result = detect_correction(user_text)
            if not result:
                return
            rating, weight = result
            # Find the domain of the last tool call in this conversation
            domain = "general"
            if hasattr(self, "_last_tool_contract_action") and self._last_tool_contract_action:
                domain = domain_for_action(self._last_tool_contract_action)
            self._trust_service.record_feedback(domain, rating, weight=weight)
            log.debug("Trust correction signal: %s (%.1f) for domain '%s'", rating, weight, domain)
        except Exception:
            log.debug("Correction signal check failed", exc_info=True)

    # ─────────────────────────────────────────────────────────────────
    # Agent-mediated work primitive (Rule #9 / #17)
    # ─────────────────────────────────────────────────────────────────
    #
    # Every non-chat mutating flow (watcher, heartbeat, daily cycle, cron,
    # webhook, work queue handler, etc.) routes through ``invoke_skill``
    # instead of calling a service method directly. This gives each flow:
    #
    #   1. A correlation scope prefixed with the skill name → visible in
    #      /decisions as its own causal chain.
    #   2. Contract gating via the dispatched subagent's tool grants and
    #      the global ContractEnforcer.
    #   3. A mutation counter that verifies real work actually happened —
    #      a hallucinated "I ingested the files!" with no tool calls or
    #      vault writes gets flagged as SkillZeroEffect.
    #   4. A structured outcome the caller can log, re-queue, or surface
    #      in an action plan item for SkillSchool / the tuner.
    #
    # Callers who hit ``SkillZeroEffect`` should enqueue retry through
    # the persistent work queue — never silently fall back. The whole
    # point of this primitive is that silent fallbacks obscure real
    # failure modes of the current LLM and block the learning loop.

    async def invoke_skill(
        self,
        skill_name: str,
        args: dict | None = None,
        *,
        agent_name: str = "librarian",
        caller: str = "system",
        task_override: str | None = None,
    ) -> SkillOutcome:
        """Run a skill through the full agent pipeline and verify effect.

        Args:
            skill_name: The skill SKILL.md name (e.g. ``kb-ingest``). Used
                only for correlation scope + decision logging metadata —
                the actual tools the subagent may call come from the
                subagent's own skill/tier grants.
            args: Arguments describing the work unit, e.g. ``{"slug": "general"}``.
                Serialized into the dispatched task text.
            agent_name: Which specialist subagent runs the skill. Defaults
                to ``librarian`` because Stage 1 migrates the KB ingest
                watcher path. Callers for other skills (memory, reflection,
                research) pass the matching specialist explicitly.
            caller: Short identifier for the upstream caller (``watcher``,
                ``heartbeat``, ``cron``, ``webhook``). Surfaced in the
                decision record's ``triggered_by`` field so the /decisions
                page shows origin.
            task_override: Full task string, used when callers want to
                pass a pre-built prompt. When None, ``args`` is rendered
                into a minimal JSON-ish task body.

        Returns:
            SkillOutcome with the verdict + counter snapshot + the
            subagent's narrative reply. Never returns a silent failure.

        Raises:
            SkillZeroEffect: When the dispatch completed but nothing the
                skill claimed to do actually happened — zero tool calls,
                zero vault writes, zero triples added. The caller MUST
                re-queue (work queue + action plan item) instead of
                trusting the narrative.
            ValueError: When the target agent or skill is unknown.
        """
        from agntspace.activity.decisions import (
            correlation_scope,
            current_correlation_id,
            current_run_id,
            mutation_scope,
            new_correlation_id,
            reset_policy_id,
            reset_run_id,
            set_policy_id,
            set_run_id,
        )

        _args = dict(args or {})
        task = task_override or _build_skill_task(skill_name, _args)

        # Inherit the outer scope if one already exists (e.g. chat turn
        # that called this via a tool), otherwise start a fresh
        # skill-<name> chain so the /decisions timeline shows it as a
        # first-class causal chain.
        _existing_id = current_correlation_id()
        _corr_ctx = correlation_scope(
            _existing_id or new_correlation_id(f"skill-{skill_name}"),
        )

        # Track whether we've persisted a successful outcome already —
        # `_outcome_persisted=True` means the success path has already
        # written to the ledger and we should NOT re-persist in the
        # finally block (that would overwrite with a failure outcome on
        # the SkillZeroEffect re-raise path).
        _outcome_persisted = False

        with _corr_ctx, mutation_scope() as counter:
            # ─── Evolution prolog ────────────────────────────────
            # Build a RunManifest and pick a policy only for the
            # OUTERMOST invoke_skill call in the chain. Nested calls
            # inherit the parent's run_id/policy_id from the contextvars
            # that the outer invoke set — persisting a separate run row
            # for every nested call would double-count both runs AND
            # mutations in the outcome layer.
            import time as _time
            _started_perf = _time.perf_counter()
            _parent_run_id = current_run_id()
            _is_outermost = not _parent_run_id
            _manifest = None
            _policy_choice = None
            _run_id_token = None
            _policy_id_token = None

            if _is_outermost:
                try:
                    from agntspace.evolution.manifest import build_run_manifest
                    from agntspace.evolution.selector import select_policy
                    _policy_choice = await select_policy(
                        skill_name,
                        caller=caller,
                        trigger_type=skill_name,
                        parent_run_id="",
                        ledger=(
                            getattr(self._evolution, "ledger", None)
                            if self._evolution is not None else None
                        ),
                        config=(
                            getattr(self._evolution, "config", None)
                            if self._evolution is not None else None
                        ),
                    )
                    _manifest = build_run_manifest(
                        skill_name=skill_name,
                        agent_name=agent_name,
                        caller=caller,
                        correlation_id=current_correlation_id(),
                        policy_id=_policy_choice.policy_id,
                        model_name=getattr(
                            getattr(self, "_llm", None), "model_name", "",
                        ) or "",
                        model_tier=(
                            # Subagent's declared model_tier (capable | fast | reasoning)
                            self._orchestrator.agents.get(agent_name).model_tier
                            if (
                                self._orchestrator is not None
                                and hasattr(self._orchestrator, "agents")
                                and agent_name in self._orchestrator.agents
                            )
                            else ""
                        ),
                        vault_reader=getattr(self, "_vault", None),
                        graph=getattr(self, "_graph", None),
                        registry=getattr(self, "_registry", None),
                    )
                    _run_id_token = set_run_id(_manifest.run_id)
                    _policy_id_token = set_policy_id(_manifest.policy_id)
                    # Persist the manifest up front so a dispatch crash
                    # still leaves a ledger row behind for the epilog
                    # to attach an outcome to.
                    if self._evolution is not None:
                        try:
                            await self._evolution.persist_run(_manifest)
                            await self._evolution.persist_selector_telemetry(
                                run_id=_manifest.run_id,
                                policy_id=_policy_choice.policy_id,
                                eligible_ids=[_policy_choice.policy_id],
                                top_samples=[{
                                    "reflex_id": _policy_choice.policy_id,
                                    "sampled_score": _policy_choice.sampled_score,
                                    "posterior_mean": 0.0,
                                }],
                                chosen_reason=_policy_choice.rationale,
                            )
                        except Exception:
                            log.debug("evolution persist_run failed", exc_info=True)
                except Exception:
                    # Evolution must never break the agent loop. Fall
                    # through to the pre-evolution behavior on any error.
                    log.debug("evolution prolog failed", exc_info=True)
                    _manifest = None
                    _policy_choice = None

            try:
                # ─── Log the intent ─────────────────────────────
                # Inside the scope so the event inherits the
                # correlation id — the /decisions chain view needs
                # every event in one batch to share one id.
                self._log_activity(
                    "skill", "skill_invoked",
                    f"Invoked skill {skill_name} via {agent_name} (caller={caller})",
                    {"skill": skill_name, "agent": agent_name, "caller": caller, "args": _args},
                    importance="low",
                )
                # Dispatch through the orchestrator so contract gating,
                # tool filtering, and the decision log all fire.
                if self._orchestrator is None:
                    raise ValueError(
                        "Agent.invoke_skill requires an Orchestrator — "
                        "this is a programming bug, main.py should wire it",
                    )
                try:
                    result = await self._orchestrator.dispatch(
                        agent_name=agent_name,
                        task=task,
                        context=(
                            f"You were invoked by {caller} to run the "
                            f"'{skill_name}' skill. Call real tools to do the "
                            f"work. Do not claim success without tool calls."
                        ),
                        skill_scope=skill_name,
                    )
                except Exception as exc:
                    outcome = SkillOutcome(
                        skill=skill_name,
                        agent=agent_name,
                        caller=caller,
                        correlation_id=current_correlation_id(),
                        succeeded=False,
                        mutations=counter.summary(),
                        content="",
                        error=f"{type(exc).__name__}: {exc}",
                        run_id=_manifest.run_id if _manifest else "",
                    )
                    self._log_activity(
                        "skill", "skill_dispatch_failed",
                        f"Skill {skill_name} dispatch raised {type(exc).__name__}",
                        {"skill": skill_name, "agent": agent_name, "error": str(exc)[:500]},
                        importance="high",
                    )
                    if self._decisions is not None:
                        try:
                            await self._decisions.record(
                                actor=agent_name,
                                action_type="skill_invoke",
                                action_target=skill_name,
                                contract_result="failed",
                                triggered_by=caller,
                                rationale=f"Dispatch raised: {type(exc).__name__}",
                                details={"skill": skill_name, "error": str(exc)[:500], **outcome.mutations},
                            )
                        except Exception:
                            log.debug("decision record failed", exc_info=True)
                    raise
                # Dispatch returned — verify the mutation counter moved.
                # A dispatch that said "done!" with zero effect is the
                # exact failure mode that pre-invoke_skill silently
                # tolerated.
                mutations = counter.summary()
                zero_effect = counter.is_zero_effect()
                outcome = SkillOutcome(
                    skill=skill_name,
                    agent=agent_name,
                    caller=caller,
                    correlation_id=current_correlation_id(),
                    succeeded=not zero_effect,
                    mutations=mutations,
                    content=getattr(result, "content", "") or "",
                    error=None if not zero_effect else "skill_zero_effect",
                    run_id=_manifest.run_id if _manifest else "",
                )

                # IMPORTANT: log + decision stay inside the correlation
                # scope so their correlation_id is inherited automatically.
                if outcome.succeeded:
                    self._log_activity(
                        "skill", "skill_completed",
                        f"{skill_name} completed via {agent_name}: {mutations}",
                        {"skill": skill_name, "agent": agent_name, **mutations},
                        importance="medium",
                    )
                else:
                    self._log_activity(
                        "skill", "skill_zero_effect",
                        f"{skill_name} via {agent_name} produced no real mutations — re-queue",
                        {"skill": skill_name, "agent": agent_name, **mutations, "content": outcome.content[:500]},
                        importance="high",
                    )
                if self._decisions is not None:
                    try:
                        await self._decisions.record(
                            actor=agent_name,
                            action_type="skill_invoke",
                            action_target=skill_name,
                            contract_action="delegate_task",
                            contract_result="allowed" if outcome.succeeded else "zero_effect",
                            triggered_by=caller,
                            context_skills=[skill_name],
                            rationale=(
                                f"Skill {skill_name} produced {mutations['vault_writes']} writes, "
                                f"{mutations['tool_calls']} tools, {mutations['graph_triples']} triples"
                            ),
                            details={
                                "skill": skill_name,
                                "agent": agent_name,
                                "caller": caller,
                                "args": _args,
                                "mutations": mutations,
                                "succeeded": outcome.succeeded,
                            },
                        )
                    except Exception:
                        log.debug("decision record failed for skill_invoke", exc_info=True)

                # ─── Evolution epilog (success + zero-effect) ──
                # Persist outcome BEFORE the SkillZeroEffect re-raise
                # so we have a ledger entry even when the caller's
                # except block handles the exception.
                if _is_outermost and _manifest is not None and self._evolution is not None:
                    try:
                        _duration_ms = int((_time.perf_counter() - _started_perf) * 1000)
                        await self._evolution.persist_outcome_for_run(
                            manifest=_manifest,
                            succeeded=outcome.succeeded,
                            zero_effect=zero_effect,
                            blocked=False,
                            error=outcome.error,
                            duration_ms=_duration_ms,
                            correlation_id=current_correlation_id(),
                        )
                        _outcome_persisted = True
                    except Exception:
                        log.debug("evolution persist_outcome (success path) failed", exc_info=True)
            finally:
                # ─── Evolution epilog (fallback for error path) ──
                # Persist an outcome if the success path never ran
                # (dispatch raised). Reset the contextvars either way
                # so a nested scope's parent doesn't inherit our run_id.
                if _is_outermost and _manifest is not None:
                    if self._evolution is not None and not _outcome_persisted:
                        try:
                            _duration_ms = int((_time.perf_counter() - _started_perf) * 1000)
                            await self._evolution.persist_outcome_for_run(
                                manifest=_manifest,
                                succeeded=False,
                                zero_effect=counter.is_zero_effect(),
                                blocked=False,
                                error="dispatch_raised",
                                duration_ms=_duration_ms,
                                correlation_id=current_correlation_id(),
                            )
                        except Exception:
                            log.debug("evolution persist_outcome (error path) failed", exc_info=True)
                    if _run_id_token is not None:
                        reset_run_id(_run_id_token)
                    if _policy_id_token is not None:
                        reset_policy_id(_policy_id_token)

        if not outcome.succeeded:
            raise SkillZeroEffect(
                skill=skill_name,
                agent=agent_name,
                caller=caller,
                mutations=mutations,
                content=outcome.content,
            )
        return outcome


# ─────────────────────────────────────────────────────────────────
# Supporting types for Agent.invoke_skill
# ─────────────────────────────────────────────────────────────────

from dataclasses import dataclass, field  # noqa: E402


@dataclass
class SkillOutcome:
    """Structured result from ``Agent.invoke_skill``.

    Callers inspect ``succeeded`` to decide whether to re-queue. The
    ``mutations`` dict gives concrete numbers that action plan items and
    SkillSchool can feed back into the learning loop.

    ``run_id`` links this outcome to the evolution ledger's
    ``evolution_runs`` row (empty string when the evolution layer isn't
    wired). Callers that care about evolution attribution (e.g. the work
    queue retry handlers) can read ``run_id`` to fetch the full
    OutcomeVector + selector telemetry via the ledger.
    """

    skill: str
    agent: str
    caller: str
    correlation_id: str
    succeeded: bool
    mutations: dict = field(default_factory=dict)
    content: str = ""
    error: str | None = None
    run_id: str = ""


class SkillZeroEffect(Exception):
    """Raised when ``Agent.invoke_skill`` completed but verified zero
    effect — no vault writes, no tool calls, no graph triples.

    Callers (RawWatcher, heartbeat, cron, webhook handlers) must catch
    this and enqueue retry through the persistent work queue. Do NOT
    fall back to calling the service method directly — that defeats
    the whole point of routing through the agent.
    """

    def __init__(
        self,
        *,
        skill: str,
        agent: str,
        caller: str,
        mutations: dict,
        content: str = "",
    ) -> None:
        self.skill = skill
        self.agent = agent
        self.caller = caller
        self.mutations = mutations
        self.content = content
        super().__init__(
            f"SkillZeroEffect: {skill} via {agent} (caller={caller}) "
            f"produced no verifiable mutations — {mutations}. "
            f"Narrative: {content[:200]!r}",
        )


def _build_skill_task(skill_name: str, args: dict) -> str:
    """Render a minimal, unambiguous task message for a skill invocation.

    The subagent receives this as the user message. We keep it short,
    machine-parseable, and explicit about what's expected so small
    models have less room to improvise.
    """
    import json as _json

    try:
        args_json = _json.dumps(args, ensure_ascii=False, sort_keys=True)
    except Exception:
        args_json = str(args)
    return (
        f"Run skill `{skill_name}` with the following arguments:\n\n"
        f"{args_json}\n\n"
        "Use the tools you have been granted to do the actual work. "
        "Do not describe what you would do — DO it. Return a short "
        "factual summary of what was accomplished."
    )
