"""LangGraph-based agent runner for Hikaflow.

Replaces the Claude SDK's opaque agentic loop with a LangGraph StateGraph
where we own every turn transition. This enables:
- Per-mode turn budgets (scan=8, debug=12, fix=15, chat=3)
- Forced synthesis when budget exhausted or turns go stale
- No-tools nudging when the agent narrates without acting
- Full visibility into every node transition for debugging

Usage:
    from autodebug.agent.langgraph_runner import build_graph, build_langgraph_tools

    tools = build_langgraph_tools(deps)
    graph = build_graph(tools)
    # Run via _run_langgraph_repl in cli_v2.py
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import os
import re
import signal as signal_mod
import subprocess
import time
from pathlib import Path
from typing import Any, Annotated, Optional, TypedDict

from langchain_core.messages import (
    AIMessage,
    BaseMessage,
    HumanMessage,
    SystemMessage,
    ToolMessage,
)
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import StructuredTool
from langgraph.errors import GraphRecursionError
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages

logger = logging.getLogger(__name__)

# Max node transitions per LLM turn, derived from graph topology:
#   Simple:      agent -> tools -> check_budget                          = 3
#   With verify: agent -> tools -> verify -> agent -> tools -> cb        = 6
#   With grace:  agent -> tools -> grace -> agent -> tools -> cb         = 6
#   With nudge:  agent -> nudge -> agent -> tools -> cb                  = 5
#   With v.claims: agent -> verify_claims -> agent -> tools -> cb        = 5
#   Worst case:  grace + verify_claims in same session                   = ~10
# Add margin for edge cases (nudge after grace, etc).
MAX_TRANSITIONS_PER_TURN = 12


# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------

class AgentState(TypedDict):
    """Typed state for the LangGraph agent.

    The ``messages`` field uses the ``add_messages`` reducer so that each
    node can return new messages which get *appended* to the list rather
    than replacing it.  All other fields use simple replacement semantics.
    """
    messages: Annotated[list, add_messages]
    mode: str
    turn_count: int
    tool_calls_count: int
    turn_budget: int
    stale_turns: int
    tool_signatures: set[str]
    nudge_attempted: bool
    synthesis_reason: str
    # Paths edited in the last turn (for self-verification nudging)
    last_edit_paths: list[str]
    # Whether the agent has attempted to verify its own edits
    verification_attempted: bool
    # Whether a grace turn has been attempted before hard synthesis
    grace_turn_attempted: bool
    # Whether the verify_claims node has fired (one-shot, unconditional)
    verify_claims_fired: bool
    # Approval mode for the session
    approval_mode: str
    # Project root path for findings/phase awareness
    project_path: str
    # Cumulative estimated input tokens across all LLM calls
    cumulative_input_tokens: int
    # Token budget cap — force synthesis when exceeded
    token_budget: int
    # Set True when scan_tests returns results — forces synthesis on next check
    scan_complete: bool


# Default state factory
def _default_state() -> dict:
    return {
        "messages": [],
        "mode": "chat",
        "turn_count": 0,
        "tool_calls_count": 0,
        "turn_budget": 3,
        "stale_turns": 0,
        "tool_signatures": set(),
        "nudge_attempted": False,
        "synthesis_reason": "",
        "last_edit_paths": [],
        "verification_attempted": False,
        "verify_claims_fired": False,
        "approval_mode": "yolo",
    }


# ---------------------------------------------------------------------------
# Tool wrapping
# ---------------------------------------------------------------------------

class _MockContext:
    """Minimal stand-in for pydantic_ai.RunContext[HikaflowDeps]."""
    __slots__ = ("deps",)

    def __init__(self, deps: Any):
        self.deps = deps


# ---------------------------------------------------------------------------
# Shared constants (ported from Gemini CLI)
# ---------------------------------------------------------------------------

# Edit tool: fuzzy matching thresholds
FUZZY_MATCH_THRESHOLD = 0.1
WHITESPACE_PENALTY_FACTOR = 0.1
FUZZY_MIN_OLD_STRING_LENGTH = 10
FUZZY_COMPLEXITY_LIMIT = 400_000_000
EDIT_DELIMITERS = ["(", ")", ":", "[", "]", "{", "}", ">", "<", "="]

# Read tool
READ_MAX_LINES = 2000
READ_MAX_LINE_LENGTH = 2000
READ_MAX_FILE_SIZE_MB = 20
READ_MAX_OUTPUT_CHARS = 30_000  # Hard cap on read_file output to prevent context bloat
READ_SOFT_LINE_CAP = 500       # Clamp requested limit to this max

# Universal tool output cap — applied to ALL tool results in tool_node.
# Matches Codex's tool_output_token_limit=10,000 (~40K chars).
# Uses Gemini's 20/80 strategy: keep first 20% + last 80% of truncated output.
TOOL_OUTPUT_MAX_CHARS = 40_000

# Token budget — max cumulative estimated input tokens per session.
# Caps worst-case cost: 400K × $3/1M = $1.20 input + output ≈ $1.50-2.00.
TOKEN_BUDGET_DEFAULT = 400_000

# Write tool
WRITE_MAX_CONTENT_SIZE = 10 * 1024 * 1024  # 10MB

# Bash tool
BASH_MAX_OUTPUT_CHARS = 50_000
BASH_DEFAULT_INACTIVITY_TIMEOUT = 120
DANGEROUS_PATTERNS = [
    # rm on root: catches flags in any form (short, long, split, combined, --)
    # Path variants: /, //, /*, /.*, /., /.., /tmp/../, /{bin,etc}
    (r"\brm\s+(?:--?\S+\s+)*/\S*\.\.(?:/|$|\s)", "rm with parent traversal"),
    (r"\brm\s+(?:--?\S+\s+)*/\{", "rm with brace expansion on root"),
    (r"\brm\s+(?:--?\S+\s+)*/\*", "rm glob on root directory"),
    (r"\brm\s+(?:--?\S+\s+)*/\.\.?(?:\s|/|$)", "rm on root dot path"),
    (r"\brm\s+(?:--?\S+\s+)*/+(?:\s|[)\"'`}]|$)", "rm on root directory"),
    # rm on home: any flags before ~ or $HOME/${HOME}
    (r"\brm\s+(?:--?\S+\s+)*~", "rm on home directory"),
    (r"\brm\s+(?:--?\S+\s+)*\$\{?HOME\}?", "rm on home directory"),
    # rm -rf . in current directory
    (r"\brm\s+(-\w*r\w*f\w*|-\w*f\w*r\w*)\s+\.\s*([;&|]|$)", "rm -rf . in current directory"),
    # Fork bomb: classic :(){ and named variants (word(){ ...|word)
    (r":\(\)\s*\{", "fork bomb"),
    (r"\w+\(\)\s*\{[^}]*\|\s*\w+", "recursive fork bomb"),
    # Filesystem operations
    (r"\bmkfs\b", "filesystem format"),
    (r"\bmke2fs\b", "filesystem format"),
    (r"\bmkswap\b", "swap partition format"),
    # Device writes
    (r"\bdd\b.*\bof\s*=\s*/dev/", "write to device"),
    (r">\s*/dev/(?:sd[a-z]|nvme\d|vd[a-z]|xvd[a-z]|hd[a-z])", "write to disk device"),
]

# Sandbox: context-aware check that skips display commands (echo/printf)
# whose arguments contain dangerous strings as text, not as executed commands.
_SANDBOX_DISPLAY_CMDS = frozenset({"echo", "printf", "cat", "grep", "head"})
_SANDBOX_SHELL_EXPANSION_RE = re.compile(r"\$[({]|`|<\(|>\(")
_SANDBOX_PIPE_TO_SHELL_RE = re.compile(r"\|\s*(?:sh|bash|zsh|dash|ksh|fish|at|crontab|batch)(?:\s|$)")


def _sandbox_check(command: str) -> tuple[bool, str]:
    """Check if a command should be hard-blocked by the execution sandbox.

    Last-resort safety net for catastrophic commands (rm -rf /, mkfs, dd).
    Context-aware: skips patterns when dangerous strings appear as arguments
    to pure display commands (echo, printf, cat) that can never execute them.

    Split on sequential operators (&&, ||, ;) only — NOT pipes, because
    pipe-connected commands form a single composite operation and
    ``echo "rm -rf /" | sh`` must be checked as a unit.
    """
    # Split on sequential operators (&&, ||, ;). Also split on newlines
    # UNLESS the command contains a heredoc marker (<<), because heredoc
    # content uses newlines as data, not as command separators (S8).
    if "<<" not in command:
        sub_commands = re.split(r"\s*(?:&&|\|\||;|\n)\s*", command.strip())
    else:
        sub_commands = re.split(r"\s*(?:&&|\|\||;)\s*", command.strip())
    for sub in sub_commands:
        sub = sub.strip()
        if not sub:
            continue
        first_word = sub.split()[0] if sub.split() else ""
        # Pure display command with no expansion and no pipe to shell → skip
        if first_word in _SANDBOX_DISPLAY_CMDS:
            has_expansion = _SANDBOX_SHELL_EXPANSION_RE.search(sub)
            has_pipe_to_shell = _SANDBOX_PIPE_TO_SHELL_RE.search(sub)
            if not has_expansion and not has_pipe_to_shell:
                continue
            # Display command piped to shell → block directly (the display
            # content may not match DANGEROUS_PATTERNS due to escaping like
            # printf "rm -rf /\n" where \n follows the /)
            if has_pipe_to_shell:
                return True, "display command piped to shell interpreter"
        for pattern, desc in DANGEROUS_PATTERNS:
            if re.search(pattern, sub):
                return True, desc
    return False, ""

# Search tool
SEARCH_TIMEOUT_S = 30
SEARCH_DEFAULT_MAX_MATCHES = 30
AUTO_CONTEXT_LINES_SINGLE = 50
AUTO_CONTEXT_LINES_MULTI = 15

# List tool
ONE_DAY_S = 86400


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _resolve_path(path: str, project_path: str) -> tuple:
    """Resolve a path and check for traversal.

    Returns:
        (resolved_path, error_string_or_None)
    """
    resolved = Path(path)
    if not resolved.is_absolute():
        resolved = Path(project_path) / path
    resolved = resolved.resolve()
    project_root = Path(project_path).resolve()
    # Use is_relative_to() for correct path comparison (no prefix collisions)
    if not (resolved == project_root or resolved.is_relative_to(project_root)):
        return resolved, f"Error: Path {path} is outside the project directory."
    return resolved, None


def _detect_line_ending(content: str) -> str:
    """Detect whether content uses CRLF or LF line endings."""
    if "\r\n" in content:
        return "\r\n"
    return "\n"


def _is_binary(data: bytes, sample_size: int = 4096) -> bool:
    """Check if data is binary (null bytes or >30% non-printable chars)."""
    sample = data[:sample_size]
    if b"\x00" in sample:
        return True
    if not sample:
        return False
    non_printable = sum(
        1 for b in sample
        if b < 9 or (14 <= b <= 31)
    )
    return non_printable / len(sample) > 0.3


def _format_truncated_output(content: str, max_chars: int = BASH_MAX_OUTPUT_CHARS) -> str:
    """Head/tail truncation with omission marker."""
    if len(content) <= max_chars:
        return content
    head_chars = int(max_chars * 0.2)
    tail_chars = max_chars - head_chars
    # Guard against head+tail overlapping the content
    if head_chars + tail_chars >= len(content):
        return content
    head = content[:head_chars]
    tail = content[-tail_chars:]
    omitted = len(content) - head_chars - tail_chars
    return (
        f"{head}\n\n"
        f"... [{omitted:,} characters omitted] ...\n\n"
        f"{tail}"
    )


def _apply_indentation(lines: list, target_indent: str) -> list:
    """Re-indent lines relative to a target indentation.

    Uses the first line's indentation as the reference point. All other lines
    are adjusted by the same offset.
    """
    if not lines:
        return []
    ref_match = re.match(r"^([ \t]*)", lines[0])
    ref_indent = ref_match.group(1) if ref_match else ""
    result = []
    for line in lines:
        if line.strip() == "":
            result.append("")
        elif line.startswith(ref_indent):
            result.append(target_indent + line[len(ref_indent):])
        else:
            result.append(target_indent + line.lstrip())
    return result


def _restore_trailing_newline(original: str, modified: str) -> str:
    """Preserve trailing newline behavior from the original file."""
    had_newline = original.endswith("\n")
    has_newline = modified.endswith("\n")
    if had_newline and not has_newline:
        return modified + "\n"
    if not had_newline and has_newline:
        return modified.rstrip("\n")
    return modified


def _levenshtein(s1: str, s2: str) -> int:
    """Standard dynamic programming Levenshtein distance."""
    if len(s1) < len(s2):
        return _levenshtein(s2, s1)
    if len(s2) == 0:
        return len(s1)
    prev_row = list(range(len(s2) + 1))
    for i, c1 in enumerate(s1):
        curr_row = [i + 1]
        for j, c2 in enumerate(s2):
            insertions = prev_row[j + 1] + 1
            deletions = curr_row[j] + 1
            substitutions = prev_row[j] + (c1 != c2)
            curr_row.append(min(insertions, deletions, substitutions))
        prev_row = curr_row
    return prev_row[-1]


def _human_size(size_bytes: int | float) -> str:
    """Format bytes as human-readable size."""
    value = float(size_bytes)
    for unit in ("B", "KB", "MB", "GB"):
        if abs(value) < 1024:
            return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
        value /= 1024
    return f"{value:.1f}TB"


def _relative_time(mtime: float) -> str:
    """Format a timestamp as relative time string."""
    delta = time.time() - mtime
    if delta < 60:
        return "just now"
    if delta < 3600:
        m = int(delta / 60)
        return f"{m}m ago"
    if delta < ONE_DAY_S:
        h = int(delta / 3600)
        return f"{h}h ago"
    if delta < ONE_DAY_S * 2:
        return "yesterday"
    d = int(delta / ONE_DAY_S)
    return f"{d}d ago"


# ---------------------------------------------------------------------------
# Tool: bash — inactivity timeout + streaming
# ---------------------------------------------------------------------------

def _make_bash_tool(project_path: str, display_fn_holder: Optional[list] = None) -> StructuredTool:
    """Create a bash tool with inactivity-based timeout and streaming output.

    display_fn_holder: mutable list containing [display_fn] — set after tool build.
    This allows the bash tool to stream progress to the CLI during execution.
    """

    # Streaming interval: emit progress every N seconds
    _STREAM_INTERVAL_S = 2.0
    _STREAM_TAIL_CHARS = 500

    async def _bash(command: str, timeout: int = BASH_DEFAULT_INACTIVITY_TIMEOUT) -> str:
        """Execute a shell command in the project directory.

        The timeout is inactivity-based: it resets every time the command
        produces output. A long-running command that continuously outputs
        (e.g. pytest) will not be killed.

        Args:
            command: The shell command to run.
            timeout: Seconds of inactivity before killing (default 120).
        """
        if not command.strip():
            return "Error: Command cannot be empty."

        # Check for dangerous commands (context-aware sandbox)
        blocked, desc = _sandbox_check(command)
        if blocked:
            return f"Error: Blocked dangerous command ({desc}). Refusing to execute."

        env = {**os.environ, "PAGER": "cat"}

        try:
            proc = await asyncio.create_subprocess_shell(
                command,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=project_path,
                env=env,
            )
        except Exception as e:
            return f"Error: Failed to start process: {e}"

        output_chunks: list[bytes] = []
        total_bytes = 0
        is_binary = False
        timed_out = False
        loop = asyncio.get_running_loop()
        last_activity = loop.time()
        last_stream_emit = loop.time()

        async def _read_stream(stream):
            nonlocal last_activity, total_bytes, is_binary, last_stream_emit
            while True:
                try:
                    line = await stream.readline()
                except Exception as e:
                    logger.debug("Stream read error: %s", e)
                    break
                if not line:
                    break
                last_activity = loop.time()
                total_bytes += len(line)

                # Binary detection on first chunk
                if not is_binary and total_bytes <= 4096 and b"\x00" in line:
                    is_binary = True

                if not is_binary:
                    output_chunks.append(line)

                # Streaming: emit progress to display every _STREAM_INTERVAL_S
                now = loop.time()
                _dfn = display_fn_holder[0] if display_fn_holder else None
                if _dfn and (now - last_stream_emit) >= _STREAM_INTERVAL_S:
                    last_stream_emit = now
                    tail = b"".join(output_chunks[-20:]).decode("utf-8", errors="replace")
                    if len(tail) > _STREAM_TAIL_CHARS:
                        tail = tail[-_STREAM_TAIL_CHARS:]
                    _safe_display(_dfn, "bash_output_update",
                                  output_tail=tail, total_bytes=total_bytes)

        async def _watchdog():
            nonlocal timed_out
            while True:
                await asyncio.sleep(1)
                if proc.returncode is not None:
                    break
                elapsed = loop.time() - last_activity
                if elapsed >= timeout:
                    timed_out = True
                    try:
                        proc.terminate()
                        await asyncio.sleep(5)
                        if proc.returncode is None:
                            proc.kill()
                    except (ProcessLookupError, OSError):
                        pass
                    break

        try:
            await asyncio.gather(
                _read_stream(proc.stdout),
                _read_stream(proc.stderr),
                _watchdog(),
            )
            await proc.wait()
        except Exception as e:
            return f"Error: {e}"

        # Build output
        if is_binary:
            output = f"[Binary output detected: {_human_size(total_bytes)} received]"
        else:
            raw = b"".join(output_chunks)
            output = raw.decode("utf-8", errors="replace")

        # Truncate if needed
        output = _format_truncated_output(output)

        parts = []
        if output.strip():
            parts.append(output)
        if timed_out:
            parts.append(
                f"\nCommand cancelled: no output for {timeout}s (inactivity timeout)."
            )
        elif proc.returncode and proc.returncode != 0:
            parts.append(f"\nExit code: {proc.returncode}")

        return "\n".join(parts) or "(no output)"

    return StructuredTool.from_function(
        coroutine=_bash,
        name="bash",
        description="Execute a shell command in the project directory. Timeout is inactivity-based.",
    )


# ---------------------------------------------------------------------------
# Tool: read_file — pagination guidance + binary detection
# ---------------------------------------------------------------------------

def _file_structure_summary(
    resolved_path: str, display_path: str, lines: list[str],
    total_lines: int, file_size: int,
) -> str:
    """Build a structural summary for large files.

    Returns a compact overview (~200-500 tokens) showing file size, language,
    imports, and top-level definitions with line numbers. The agent can then
    request specific sections via start_line.

    Returns empty string if parsing fails (falls through to raw content).
    """
    ext = Path(resolved_path).suffix.lower()
    parts = [f"File: {display_path} ({total_lines} lines, {_human_size(file_size)})"]

    # Language detection
    lang_map = {
        ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
        ".tsx": "TypeScript (React)", ".jsx": "JavaScript (React)",
        ".go": "Go", ".rs": "Rust", ".java": "Java", ".rb": "Ruby",
        ".c": "C", ".cpp": "C++", ".cs": "C#", ".swift": "Swift",
    }
    lang = lang_map.get(ext, ext.lstrip(".").upper() if ext else "Unknown")
    parts.append(f"Language: {lang}")

    defs = []

    if ext == ".py":
        # Python: use ast for reliable parsing
        try:
            import ast as _ast
            source = "\n".join(lines)
            tree = _ast.parse(source)
            for node in _ast.iter_child_nodes(tree):
                if isinstance(node, _ast.ClassDef):
                    methods = [n.name for n in _ast.iter_child_nodes(node)
                               if isinstance(n, (_ast.FunctionDef, _ast.AsyncFunctionDef))]
                    method_str = f" ({len(methods)} methods)" if methods else ""
                    defs.append(f"  class {node.name}{method_str} — line {node.lineno}")
                elif isinstance(node, (_ast.FunctionDef, _ast.AsyncFunctionDef)):
                    async_prefix = "async " if isinstance(node, _ast.AsyncFunctionDef) else ""
                    defs.append(f"  {async_prefix}def {node.name}() — line {node.lineno}")
                elif isinstance(node, _ast.Assign):
                    for target in node.targets:
                        if isinstance(target, _ast.Name) and target.id.isupper():
                            defs.append(f"  {target.id} = ... — line {node.lineno}")
        except Exception:
            pass  # Fall through to regex

    if not defs:
        # Generic regex fallback for any language
        import re as _re
        patterns = [
            (_re.compile(r"^\s*(?:export\s+)?(?:async\s+)?(?:function|def|fn|func)\s+(\w+)"), "def"),
            (_re.compile(r"^\s*(?:export\s+)?class\s+(\w+)"), "class"),
            (_re.compile(r"^\s*(?:pub\s+)?(?:struct|enum|trait|interface|type)\s+(\w+)"), "type"),
            (_re.compile(r"^\s*(?:const|let|var|val)\s+([A-Z_][A-Z0-9_]+)\s*="), "const"),
        ]
        for i, line in enumerate(lines):
            for pat, kind in patterns:
                m = pat.match(line)
                if m:
                    defs.append(f"  {kind} {m.group(1)} — line {i + 1}")
                    break
            if len(defs) >= 60:
                defs.append(f"  ... and more (showing first 60)")
                break

    if defs:
        parts.append(f"\nTop-level definitions ({len(defs)}):")
        parts.extend(defs)

    # First few imports (Python)
    if ext == ".py":
        imports = []
        for i, line in enumerate(lines[:50]):
            stripped = line.strip()
            if stripped.startswith(("import ", "from ")):
                imports.append(f"  {stripped[:80]}")
            if len(imports) >= 10:
                break
        if imports:
            parts.append(f"\nImports:")
            parts.extend(imports)

    parts.append(f"\nUse start_line=N to read specific sections (e.g., start_line={defs[0].split('line ')[-1] if defs else 1}).")
    return "\n".join(parts)


def _make_read_file_tool(project_path: str) -> StructuredTool:
    """Create a file reading tool with pagination guidance and binary detection."""

    async def _read_file(
        path: str,
        start_line: int = 1,
        limit: int = READ_SOFT_LINE_CAP,
    ) -> str:
        """Read contents of a file with line numbers.

        Args:
            path: File path (relative to project root or absolute).
            start_line: 1-based line number to start reading from (default 500).
            limit: Max lines to read (default 500, max 500).
        """
        # Clamp limit to prevent context bloat (h15: limit=5000 returned 249K chars)
        limit = min(limit, READ_SOFT_LINE_CAP)
        resolved, err = _resolve_path(path, project_path)
        if err:
            return err

        # File size guard
        try:
            file_size = resolved.stat().st_size
        except FileNotFoundError:
            return f"Error: File not found: {path}"
        except Exception as e:
            return f"Error: {e}"

        if file_size > READ_MAX_FILE_SIZE_MB * 1024 * 1024:
            return f"Error: File size ({_human_size(file_size)}) exceeds {READ_MAX_FILE_SIZE_MB}MB limit."

        # Binary detection
        try:
            with open(resolved, "rb") as f:
                sample = f.read(4096)
            if _is_binary(sample):
                return f"Error: Cannot display binary file: {path}"
        except Exception as e:
            return f"Error reading {path}: {e}"

        try:
            text = resolved.read_text(encoding="utf-8", errors="replace")
        except Exception as e:
            return f"Error reading {path}: {e}"

        lines = text.splitlines()
        total_lines = len(lines)

        # Summary-first: for large files without specific start_line, return
        # structural overview instead of raw content. Agent can then request
        # specific sections. Saves ~7K tokens vs dumping 500 lines.
        if total_lines > READ_SOFT_LINE_CAP and start_line == 1:
            summary = _file_structure_summary(str(resolved), path, lines, total_lines, file_size)
            if summary:
                return summary

        # Convert 1-based start_line to 0-based index
        offset = max(0, start_line - 1)
        end = min(offset + limit, total_lines)
        selected = lines[offset:end]

        # Per-line truncation
        formatted = []
        for i, line in enumerate(selected):
            line_num = offset + i + 1
            if len(line) > READ_MAX_LINE_LENGTH:
                line = line[:READ_MAX_LINE_LENGTH] + "... [truncated]"
            formatted.append(f"{line_num:>5} | {line}")

        content = "\n".join(formatted)

        # Hard cap on output chars to prevent context bloat
        # (h15: 8768 lines = 249K chars consumed ~$1.50 in a single call)
        if len(content) > READ_MAX_OUTPUT_CHARS:
            # Find how many lines fit within the char budget
            truncated_lines = []
            char_count = 0
            for line in formatted:
                char_count += len(line) + 1  # +1 for newline
                if char_count > READ_MAX_OUTPUT_CHARS:
                    break
                truncated_lines.append(line)
            actual_end = offset + len(truncated_lines)
            content = "\n".join(truncated_lines)
            content = (
                f"Showing lines {offset + 1}-{actual_end} of {total_lines} total lines "
                f"(output capped at {READ_MAX_OUTPUT_CHARS // 1000}K chars). "
                f"To read more, use start_line={actual_end + 1}.\n\n{content}"
            )
            return content

        # Pagination guidance when truncated
        is_truncated = (offset > 0 or end < total_lines)
        if is_truncated:
            header = (
                f"Showing lines {offset + 1}-{end} of {total_lines} total lines."
            )
            if end < total_lines:
                header += f" To read more, use start_line={end + 1}."
            content = f"{header}\n\n{content}"

        return content or "(empty file)"

    return StructuredTool.from_function(
        coroutine=_read_file,
        name="read_file",
        description="Read the contents of a file with line numbers. Use start_line for pagination.",
    )


# ---------------------------------------------------------------------------
# Tool: write_file — line ending preservation
# ---------------------------------------------------------------------------

def _make_write_file_tool(project_path: str) -> StructuredTool:
    """Create a file writing tool with line ending preservation."""

    async def _write_file(path: str, content: str) -> str:
        """Write content to a file, creating directories as needed.

        Preserves the original file's line ending style (CRLF/LF).

        Args:
            path: File path (relative to project root or absolute).
            content: The content to write.
        """
        resolved, err = _resolve_path(path, project_path)
        if err:
            return err

        if len(content) > WRITE_MAX_CONTENT_SIZE:
            return (
                f"Error: Content size ({_human_size(len(content))}) exceeds "
                f"{_human_size(WRITE_MAX_CONTENT_SIZE)} limit."
            )

        try:
            # Detect existing line endings (read binary to avoid Python's \r\n translation)
            line_ending = "\n"
            file_exists = resolved.exists()
            if file_exists:
                try:
                    raw = resolved.read_bytes()
                    line_ending = _detect_line_ending(raw.decode("utf-8", errors="replace"))
                except Exception:
                    pass

            # Normalize then apply target line ending
            normalized = content.replace("\r\n", "\n")
            if line_ending == "\r\n":
                final_content = normalized.replace("\n", "\r\n")
            else:
                final_content = normalized

            resolved.parent.mkdir(parents=True, exist_ok=True)
            # Use write_bytes to prevent Python from translating line endings
            resolved.write_bytes(final_content.encode("utf-8"))
            line_count = final_content.count("\n") + 1
            return f"Wrote {len(final_content)} bytes ({line_count} lines) to {path}"
        except Exception as e:
            return f"Error writing {path}: {e}"

    return StructuredTool.from_function(
        coroutine=_write_file,
        name="write_file",
        description="Write content to a file. Preserves existing line endings (CRLF/LF).",
    )


# ---------------------------------------------------------------------------
# Tool: edit_file — 4-strategy cascade (exact → flexible → regex → fuzzy)
# ---------------------------------------------------------------------------

def _make_edit_file_tool(project_path: str) -> StructuredTool:
    """Create a multi-strategy file editing tool ported from Gemini CLI."""

    def _try_exact(content: str, old_str: str, new_str: str):
        """Strategy 1: exact string match."""
        normalized = content.replace("\r\n", "\n")
        search = old_str.replace("\r\n", "\n")
        replace = new_str.replace("\r\n", "\n")
        count = normalized.count(search)
        if count == 0:
            return None
        new_content = normalized.replace(search, replace, 1)
        return _restore_trailing_newline(content, new_content), count, "exact"

    def _try_flexible(content: str, old_str: str, new_str: str):
        """Strategy 2: whitespace-insensitive line matching."""
        source_lines = content.replace("\r\n", "\n").splitlines(keepends=True)
        search_lines = [l.strip() for l in old_str.replace("\r\n", "\n").split("\n")]
        if not search_lines or not search_lines[0]:
            return None

        n = len(search_lines)
        matches = []
        i = 0
        while i <= len(source_lines) - n:
            window = source_lines[i:i + n]
            window_stripped = [l.strip() for l in window]
            if window_stripped == search_lines:
                matches.append(i)
                i += n
            else:
                i += 1

        if not matches:
            return None

        # Apply from bottom to top
        replace_lines = new_str.replace("\r\n", "\n").split("\n")
        for idx in reversed(matches[:1]):  # Only replace first match
            indent_match = re.match(r"^([ \t]*)", source_lines[idx])
            indent = indent_match.group(1) if indent_match else ""
            new_block = _apply_indentation(replace_lines, indent)
            # Preserve trailing newline of last replaced line
            last_had_nl = source_lines[idx + n - 1].endswith("\n")
            replacement = "\n".join(new_block)
            if last_had_nl and not replacement.endswith("\n"):
                replacement += "\n"
            source_lines[idx:idx + n] = [replacement]

        result = "".join(source_lines)
        return _restore_trailing_newline(content, result), len(matches), "flexible"

    def _try_regex(content: str, old_str: str, new_str: str):
        """Strategy 3: token-based regex match.

        Handles multiline old_str by using DOTALL so \\s* matches newlines.
        Captures the full span including intermediate newlines.
        """
        processed = old_str
        for d in EDIT_DELIMITERS:
            processed = processed.replace(d, f" {d} ")
        tokens = [t for t in processed.split() if t]
        if not tokens:
            return None

        escaped = [re.escape(t) for t in tokens]
        # Use \\s+ between tokens (not \\s*) to require at least some whitespace,
        # and DOTALL so \\s matches newlines in multiline old_string.
        pattern_str = r"^([ \t]*)" + r"\s+".join(escaped)
        try:
            regex = re.compile(pattern_str, re.MULTILINE | re.DOTALL)
        except re.error:
            return None

        match = regex.search(content)
        if not match:
            return None

        indent = match.group(1) or ""
        replace_lines = new_str.split("\n")
        new_block = "\n".join(_apply_indentation(replace_lines, indent))
        new_content = content[:match.start()] + new_block + content[match.end():]
        return _restore_trailing_newline(content, new_content), 1, "regex"

    def _try_fuzzy(content: str, old_str: str, new_str: str):
        """Strategy 4: Levenshtein sliding-window fuzzy match."""
        if len(old_str) < FUZZY_MIN_OLD_STRING_LENGTH:
            return None

        source_lines = content.replace("\r\n", "\n").splitlines(keepends=True)
        search_lines = [l.rstrip() for l in old_str.replace("\r\n", "\n").splitlines()]
        n = len(search_lines)

        if not n or len(source_lines) < n:
            return None

        # Complexity guard
        if len(source_lines) * (len(old_str) ** 2) > FUZZY_COMPLEXITY_LIMIT:
            return None

        search_block = "\n".join(search_lines)
        search_stripped = re.sub(r"\s+", "", search_block)
        candidates = []

        for i in range(len(source_lines) - n + 1):
            window_lines = [l.rstrip() for l in source_lines[i:i + n]]
            window_text = "\n".join(window_lines)

            # Length heuristic
            length_diff = abs(len(window_text) - len(search_block))
            if len(search_block) > 0 and length_diff / len(search_block) > FUZZY_MATCH_THRESHOLD / WHITESPACE_PENALTY_FACTOR:
                continue

            # Tiered scoring
            d_raw = _levenshtein(window_text, search_block)
            window_stripped = re.sub(r"\s+", "", window_text)
            d_norm = _levenshtein(window_stripped, search_stripped)
            weighted = d_norm + (d_raw - d_norm) * WHITESPACE_PENALTY_FACTOR
            score = weighted / len(search_block) if len(search_block) > 0 else 1.0

            if score <= FUZZY_MATCH_THRESHOLD:
                candidates.append((i, score))

        if not candidates:
            return None

        # Best non-overlapping
        candidates.sort(key=lambda x: (x[1], x[0]))
        selected = []
        for idx, score in candidates:
            if not any(abs(idx - s[0]) < n for s in selected):
                selected.append((idx, score))

        if not selected:
            return None

        # Apply from bottom to top
        replace_lines_list = new_str.replace("\r\n", "\n").split("\n")
        for idx, _ in sorted(selected, key=lambda x: -x[0]):
            indent_match = re.match(r"^([ \t]*)", source_lines[idx])
            indent = indent_match.group(1) if indent_match else ""
            new_block = _apply_indentation(replace_lines_list, indent)
            last_had_nl = source_lines[idx + n - 1].endswith("\n")
            replacement = "\n".join(new_block)
            if last_had_nl and not replacement.endswith("\n"):
                replacement += "\n"
            source_lines[idx:idx + n] = [replacement]

        result = "".join(source_lines)
        return _restore_trailing_newline(content, result), len(selected), "fuzzy"

    async def _edit_file(path: str, old_string: str, new_string: str) -> str:
        """Replace a string in a file using a 4-strategy cascade.

        Strategies tried in order: exact → flexible (whitespace-insensitive)
        → regex (token-based) → fuzzy (Levenshtein). Returns on first success.

        Args:
            path: File path (relative to project root or absolute).
            old_string: The text to find (exact or approximate).
            new_string: The replacement text.
        """
        resolved, err = _resolve_path(path, project_path)
        if err:
            return err

        # Handle file creation
        if old_string == "":
            if resolved.exists():
                return f"Error: Cannot create {path} — file already exists. Use old_string to edit."
            try:
                resolved.parent.mkdir(parents=True, exist_ok=True)
                resolved.write_text(new_string, encoding="utf-8")
                return f"Created {path} ({len(new_string)} bytes)"
            except Exception as e:
                return f"Error creating {path}: {e}"

        try:
            text = resolved.read_text(encoding="utf-8")
        except FileNotFoundError:
            return f"Error: File not found: {path}"
        except Exception as e:
            return f"Error reading {path}: {e}"

        if old_string == new_string:
            return "Error: old_string and new_string are identical — no change needed."

        # Try strategies in order
        for strategy_fn in (_try_exact, _try_flexible, _try_regex, _try_fuzzy):
            result = strategy_fn(text, old_string, new_string)
            if result:
                new_content, count, strategy = result
                # Preserve line endings: strategies output LF-normalized content;
                # restore CRLF if the original file used it.
                line_ending = _detect_line_ending(text)
                if line_ending == "\r\n":
                    new_content = new_content.replace("\r\n", "\n").replace("\n", "\r\n")
                resolved.write_text(new_content, encoding="utf-8")
                return f"Replaced {count} occurrence(s) in {path} (strategy: {strategy})"

        return f"Error: old_string not found in {path} (tried exact, flexible, regex, and fuzzy matching)"

    return StructuredTool.from_function(
        coroutine=_edit_file,
        name="edit_file",
        description=(
            "Replace text in a file. Args: path, old_string (text to find), new_string (replacement). "
            "Uses a 4-strategy cascade: exact, whitespace-insensitive, regex, fuzzy."
        ),
    )


# ---------------------------------------------------------------------------
# Tool: search_files — 3-strategy fallback + auto-context
# ---------------------------------------------------------------------------

def _make_search_files_tool(project_path: str) -> StructuredTool:
    """Create a search tool with 3-strategy fallback and auto-context."""

    async def _try_ripgrep(
        pattern: str, file_glob: str, max_results: int,
    ) -> list | None:
        """Try searching with ripgrep. Returns list of (file, line_num, line) or None on failure."""
        try:
            args = [
                "rg", "--no-heading", "-n", "-i",
                "--max-count", "5",
                "--glob", file_glob,
                pattern, project_path,
            ]
            proc = await asyncio.create_subprocess_exec(
                *args,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=SEARCH_TIMEOUT_S)
            if proc.returncode not in (0, 1):
                return None
            output = stdout.decode("utf-8", errors="replace")
            return _parse_grep_output(output, project_path, max_results)
        except (FileNotFoundError, asyncio.TimeoutError):
            return None
        except Exception:
            return None

    async def _try_git_grep(
        pattern: str, file_glob: str, max_results: int,
    ) -> list | None:
        """Try searching with git grep. Returns matches or None on failure."""
        try:
            args = ["git", "grep", "--untracked", "-n", "-i", "-E", pattern]
            if file_glob != "**/*":
                args.extend(["--", file_glob])
            proc = await asyncio.create_subprocess_exec(
                *args,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=project_path,
            )
            stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=SEARCH_TIMEOUT_S)
            if proc.returncode not in (0, 1):
                return None
            output = stdout.decode("utf-8", errors="replace")
            return _parse_grep_output(output, project_path, max_results)
        except (FileNotFoundError, asyncio.TimeoutError):
            return None
        except Exception:
            return None

    def _python_search(
        pattern: str, file_glob: str, max_results: int,
    ) -> list:
        """Pure Python fallback search using re and pathlib.glob."""
        import fnmatch
        try:
            regex = re.compile(pattern, re.IGNORECASE)
        except re.error:
            return []

        _skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache"}
        matches = []
        root = Path(project_path)
        for filepath in root.rglob("*"):
            if not filepath.is_file():
                continue
            # Skip common ignored directories
            parts = filepath.relative_to(root).parts
            if any(p in _skip_dirs for p in parts):
                continue
            if file_glob != "**/*":
                if not fnmatch.fnmatch(str(filepath.relative_to(root)), file_glob):
                    continue
            try:
                text = filepath.read_text(encoding="utf-8", errors="replace")
            except Exception:
                continue
            for line_num, line in enumerate(text.splitlines(), 1):
                if regex.search(line):
                    rel = str(filepath.relative_to(root))
                    matches.append((rel, line_num, line.rstrip()))
                    if len(matches) >= max_results:
                        return matches
        return matches

    def _parse_grep_output(output: str, base_path: str, max_results: int) -> list:
        """Parse grep/rg output lines into (file, line_num, line) tuples."""
        matches = []
        root = Path(base_path).resolve()
        for raw_line in output.strip().splitlines():
            m = re.match(r"^(.+?):(\d+):(.*)$", raw_line)
            if not m:
                continue
            file_path, line_num_str, line_content = m.groups()
            # Make path relative
            try:
                fp = Path(file_path)
                if fp.is_absolute():
                    rel = str(fp.relative_to(root))
                else:
                    rel = file_path
            except ValueError:
                rel = file_path
            # Reject path traversal
            if rel.startswith(".."):
                continue
            matches.append((rel, int(line_num_str), line_content.rstrip()))
            if len(matches) >= max_results:
                break
        return matches

    def _enrich_with_context(
        matches: list, context_lines: int,
    ) -> list:
        """Add surrounding context lines for matches (auto-context enrichment)."""
        enriched = []
        files_seen = {}

        for file_path, line_num, line_text in matches:
            if file_path not in files_seen:
                try:
                    full = Path(project_path) / file_path
                    files_seen[file_path] = full.read_text(
                        encoding="utf-8", errors="replace"
                    ).splitlines()
                except Exception:
                    files_seen[file_path] = None

            file_lines = files_seen[file_path]
            if file_lines is None:
                enriched.append((file_path, line_num, line_text, False))
                continue

            start = max(0, line_num - 1 - context_lines)
            end = min(len(file_lines), line_num - 1 + context_lines + 1)
            for i in range(start, end):
                ln = i + 1
                is_context = (ln != line_num)
                enriched.append((file_path, ln, file_lines[i].rstrip(), is_context))

        return enriched

    def _format_grouped_output(matches: list) -> str:
        """Format matches grouped by file."""
        if not matches:
            return "No matches found."

        # Check if enriched (4-tuples) or plain (3-tuples)
        is_enriched = len(matches[0]) == 4

        by_file: dict = {}
        for item in matches:
            if is_enriched:
                fp, ln, text, is_ctx = item
            else:
                fp, ln, text = item
                is_ctx = False
            by_file.setdefault(fp, []).append((ln, text, is_ctx))

        parts = []
        for fp, file_matches in by_file.items():
            parts.append(f"File: {fp}")
            seen_lines = set()
            for ln, text, is_ctx in sorted(file_matches, key=lambda x: x[0]):
                if ln in seen_lines:
                    continue
                seen_lines.add(ln)
                sep = "-" if is_ctx else ":"
                parts.append(f"L{ln}{sep} {text}")
            parts.append("---")

        return "\n".join(parts)

    async def _search_files(
        pattern: str,
        glob: str = "**/*",
        max_results: int = SEARCH_DEFAULT_MAX_MATCHES,
    ) -> str:
        """Search file contents for a regex pattern.

        Uses a 3-strategy fallback: ripgrep → git grep → pure Python.
        When 1-3 matches are found, surrounding context is automatically included.

        Args:
            pattern: Regex pattern to search for.
            glob: File glob to filter (default: all files).
            max_results: Max matches to return (default 100).
        """
        # Validate regex
        try:
            re.compile(pattern)
        except re.error as e:
            return f"Invalid regex pattern: {e}"

        # Strategy 1: ripgrep
        matches = await _try_ripgrep(pattern, glob, max_results)

        # Strategy 2: git grep
        if matches is None:
            matches = await _try_git_grep(pattern, glob, max_results)

        # Strategy 3: pure Python
        if matches is None:
            matches = _python_search(pattern, glob, max_results)

        if not matches:
            return "No matches found."

        # Auto-context enrichment for small result sets
        if 1 <= len(matches) <= 3:
            ctx_lines = AUTO_CONTEXT_LINES_SINGLE if len(matches) == 1 else AUTO_CONTEXT_LINES_MULTI
            enriched = _enrich_with_context(matches, ctx_lines)
            return _format_grouped_output(enriched)

        output = _format_grouped_output(matches)
        if len(output) > 8000:
            output = output[:8000] + f"\n\n... truncated ({len(matches)} total matches). Narrow your search with a more specific pattern or glob."
        return output

    return StructuredTool.from_function(
        coroutine=_search_files,
        name="search_files",
        description=(
            "Search file contents for a regex pattern. Args: pattern (regex), glob (file filter, default **/*), max_results. "
            "Falls back from ripgrep to git grep to pure Python."
        ),
    )


# ---------------------------------------------------------------------------
# Tool: list_directory — recency sort + size info
# ---------------------------------------------------------------------------

def _make_list_directory_tool(project_path: str) -> StructuredTool:
    """Create a directory listing tool with recency sort and size info."""

    async def _list_directory(path: str = ".") -> str:
        """List files and directories with size and modification info.

        Files modified in the last 24 hours appear first (newest first),
        then older files appear alphabetically. Directories always come first.

        Args:
            path: Directory path (relative to project root or absolute).
        """
        resolved, err = _resolve_path(path, project_path)
        if err:
            return err

        try:
            raw_entries = list(resolved.iterdir())
        except FileNotFoundError:
            return f"Error: Directory not found: {path}"
        except PermissionError:
            return f"Error: Permission denied: {path}"
        except Exception as e:
            return f"Error: {e}"

        if not raw_entries:
            return "(empty directory)"

        now = time.time()
        entries_with_stat = []
        for entry in raw_entries:
            try:
                st = entry.stat()
                entries_with_stat.append((entry, st))
            except Exception:
                entries_with_stat.append((entry, None))

        def _sort_key(item):
            entry, st = item
            is_dir = entry.is_dir()
            mtime = st.st_mtime if st else 0
            is_recent = (now - mtime) < ONE_DAY_S

            # (dir_priority, recent_priority, time_or_alpha)
            dir_priority = 0 if is_dir else 1
            if is_recent:
                return (dir_priority, 0, -mtime, "")
            return (dir_priority, 1, 0, entry.name.lower())

        entries_with_stat.sort(key=_sort_key)

        # Show full relative paths so the LLM can use them directly in read_file
        rel_prefix = path.rstrip("/") + "/" if path not in (".", "", "./") else ""

        lines = []
        for entry, st in entries_with_stat[:200]:
            display_name = f"{rel_prefix}{entry.name}"
            if entry.is_dir():
                if st and (now - st.st_mtime) < ONE_DAY_S:
                    lines.append(f"[DIR] {display_name}  ({_relative_time(st.st_mtime)})")
                else:
                    lines.append(f"[DIR] {display_name}")
            else:
                size = _human_size(st.st_size) if st else "?"
                if st and (now - st.st_mtime) < ONE_DAY_S:
                    lines.append(f"{display_name}  ({size}, {_relative_time(st.st_mtime)})")
                else:
                    lines.append(f"{display_name}  ({size})")

        if len(raw_entries) > 200:
            lines.append(f"... ({len(raw_entries) - 200} more entries)")

        return "\n".join(lines)

    return StructuredTool.from_function(
        coroutine=_list_directory,
        name="list_directory",
        description="List files and directories with size and modification info. Recent files first.",
    )


# ---------------------------------------------------------------------------
# Tool: scan_tests — deterministic test health analysis
# ---------------------------------------------------------------------------

def _make_scan_tests_tool(project_path: str) -> StructuredTool:
    """Create a tool that analyzes test directories for dead/redundant/broken tests."""

    async def _scan_tests(path: str = "tests/") -> str:
        """Scan a test directory for dead, redundant, and broken tests.

        Returns a pre-formatted markdown report with findings table.
        No LLM needed — pure deterministic analysis.

        Args:
            path: Directory to scan (relative to project root). Default: tests/
        """
        import ast

        resolved, err = _resolve_path(path, project_path)
        if err:
            return err
        if not resolved.is_dir():
            return f"Error: {path} is not a directory."

        root = Path(project_path).resolve()
        rows = []  # (severity, file_line, issue, fix)

        # 1. Dead files (.bak, .old, .disabled, .skip, .orig)
        dead_exts = {".bak", ".old", ".disabled", ".skip", ".orig"}
        dead_files = []
        for f in resolved.rglob("*"):
            if f.is_file() and f.suffix in dead_exts:
                dead_files.append(str(f.relative_to(root)))
        for df in sorted(dead_files):
            rows.append(("HIGH", df, f"Dead file ({df.rsplit('.', 1)[-1]} backup)", f"`rm {df}`"))

        # 2. Always-skipped tests (no reason given)
        test_files = sorted(resolved.rglob("test_*.py"))
        for tf in test_files:
            try:
                tree = ast.parse(tf.read_text(encoding="utf-8", errors="replace"))
            except SyntaxError:
                continue
            rel = str(tf.relative_to(root))
            for node in ast.walk(tree):
                if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    continue
                if not node.name.startswith("test_"):
                    continue
                for dec in node.decorator_list:
                    skip_name = ""
                    has_reason = False
                    if isinstance(dec, ast.Attribute) and dec.attr == "skip":
                        skip_name = node.name
                    elif isinstance(dec, ast.Call):
                        func = dec.func
                        if isinstance(func, ast.Attribute) and func.attr == "skip":
                            skip_name = node.name
                            has_reason = any(
                                (isinstance(kw, ast.keyword) and kw.arg == "reason")
                                for kw in dec.keywords
                            ) or len(dec.args) > 0
                    if skip_name and not has_reason:
                        rows.append(("MEDIUM", f"{rel}:{node.lineno}", f"`@skip` without reason on `{skip_name}`", "Add `reason=` or remove skip"))

        # 3. Duplicate test function names across files
        test_funcs: dict[str, list[tuple[str, int]]] = {}  # name -> [(file, line)]
        for tf in test_files:
            try:
                tree = ast.parse(tf.read_text(encoding="utf-8", errors="replace"))
            except SyntaxError:
                continue
            rel = str(tf.relative_to(root))
            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
                    test_funcs.setdefault(node.name, []).append((rel, node.lineno))
        # Duplicates = same name in 2+ different files (within-file dupes are a different issue)
        dupes = {}
        for name, locs in test_funcs.items():
            unique_files = sorted(set(f for f, _ in locs))
            if len(unique_files) > 1:
                # Keep first occurrence per file for display
                seen = {}
                for f, ln in locs:
                    if f not in seen:
                        seen[f] = ln
                dupes[name] = [(f, seen[f]) for f in unique_files]
        sorted_dupes = sorted(dupes.items(), key=lambda x: (-len(x[1]), x[0]))
        DUPE_DISPLAY_LIMIT = 5
        for name, locs in sorted_dupes[:DUPE_DISPLAY_LIMIT]:
            # Show 2 files + count, with specific module-based rename
            display_locs = locs[:2]
            files_short = ", ".join(f"{f}:{ln}" for f, ln in display_locs)
            if len(locs) > 2:
                files_short += f" (+{len(locs) - 2} more)"
            # Generate specific rename using module name from filename
            def _module_suffix(filepath: str) -> str:
                base = filepath.rsplit("/", 1)[-1]  # test_foo.py
                return base.replace("test_", "").replace(".py", "")  # foo
            renames = ", ".join(f"`{name}_{_module_suffix(f)}`" for f, _ in locs[:2])
            rows.append(("LOW", files_short, f"Duplicate: `{name}` ({len(locs)} files)", f"Rename to {renames}, ..."))
        extra_dupes = len(sorted_dupes) - DUPE_DISPLAY_LIMIT
        if extra_dupes > 0:
            rows.append(("LOW", "—", f"+{extra_dupes} more ({len(dupes)} total duplicates)", f"`grep -rn 'def test_' {path} \\| sort -t: -k3 \\| uniq -D -f2`"))

        # 4. Collection errors (pytest --collect-only)
        try:
            proc = await asyncio.create_subprocess_exec(
                "python", "-m", "pytest", "--collect-only", "-q", str(resolved),
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=project_path,
            )
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
            output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace")
            errors = [line for line in output.splitlines() if "ERROR" in line or "ImportError" in line or "ModuleNotFoundError" in line]
            for e in errors[:15]:
                rows.append(("HIGH", "—", f"Collection error: {e.strip()[:120]}", "Fix import or remove broken test"))
        except (asyncio.TimeoutError, Exception):
            rows.append(("MEDIUM", "—", "pytest --collect-only timed out", "Check for slow imports or fixtures"))

        if not rows:
            return f"No issues found in {path}. Analyzed {len(test_files)} test files."

        # Build pre-formatted markdown table — model should pass through as-is
        table = "| Severity | File:Line | Issue | Fix |\n"
        table += "|----------|-----------|-------|-----|\n"
        for sev, loc, issue, fix in rows:
            table += f"| {sev} | {loc} | {issue} | {fix} |\n"

        # Cleanup command for dead files
        cleanup = ""
        if dead_files:
            cleanup = f"\n**Quick cleanup:** `rm {' '.join(sorted(dead_files))}`\n"

        summary = (
            f"Analyzed {len(test_files)} test files in `{path}`.\n"
            f"Found: {len(dead_files)} dead files, {len(dupes)} duplicate names, "
            f"{sum(1 for s, *_ in rows if 'skip' in s.lower() or '@skip' in _[1] if len(_) > 1)} skipped tests.\n\n"
        )
        # Simpler summary count
        skip_count = sum(1 for _, __, issue, ___ in rows if "@skip" in issue)
        collect_count = sum(1 for _, __, issue, ___ in rows if "Collection error" in issue)
        summary = (
            f"Analyzed {len(test_files)} test files in `{path}`.\n"
            f"Found: {len(dead_files)} dead files, {skip_count} skipped without reason, "
            f"{len(dupes)} duplicate names, {collect_count} collection errors.\n\n"
        )

        # Next steps CTA
        cta = "\n**Next:** "
        if dead_files and dupes:
            cta += "Run the cleanup command above to remove dead files, or `hikaflow fix tests/` to auto-rename duplicates."
        elif dead_files:
            cta += "Run the cleanup command above to remove dead files."
        elif dupes:
            cta += "Use `hikaflow fix tests/` to auto-rename duplicates."
        else:
            cta += "Review findings above and fix manually."
        cta += "\n"

        return summary + table + cleanup + cta

    return StructuredTool.from_function(
        coroutine=_scan_tests,
        name="scan_tests",
        description=(
            "Scan a test directory for dead files (.bak), always-skipped tests, "
            "duplicate test names, and collection errors. Use for 'find dead/redundant tests' requests. "
            "Args: path (relative to project root, e.g. 'tests/' or 'tests/api/')."
        ),
    )


def build_langgraph_tools(deps: Any) -> list[StructuredTool]:
    """Build all tools for the LangGraph agent.

    Wraps existing Hikaflow tools from the tool registry + adds filesystem tools.

    Args:
        deps: HikaflowDeps instance.

    Returns:
        List of LangChain StructuredTool instances.
    """
    tools: list[StructuredTool] = []
    ctx = _MockContext(deps)
    project_path = getattr(deps, "project_path", os.getcwd())

    # Names reserved for built-in filesystem + debugging tools (added in steps 2-3).
    # Skip any registry tools with these names to prevent duplicates.
    # NOTE: run_tests intentionally NOT listed — compact profile version (tools.py)
    # has path security, env stripping, Literal types, and structured envelopes.
    _BUILTIN_NAMES = frozenset({
        "bash", "read_file", "write_file", "edit_file",
        "search_files", "list_directory", "scan_tests",
        "parse_error", "git_diff",
    })

    # 1. Wrap Hikaflow tools from registry
    try:
        from autodebug.agent.tools import register_hikaflow_tools
        from autodebug.agent.sdk_bridge import (
            _TOOL_REGISTRY,
            _extract_tool_schema,
            populate_registry_from_agent,
        )

        # Bootstrap registry via fake agent
        class _FakeAgent:
            def __init__(self):
                self._function_tools = {}
            def tool(self, func):
                name = func.__name__
                self._function_tools[name] = type("Tool", (), {"function": func})()
                return func

        _fake = _FakeAgent()
        register_hikaflow_tools(_fake)
        populate_registry_from_agent(_fake)

        from pydantic import BaseModel, Field, create_model

        _type_map = {
            "string": str, "integer": int, "number": float,
            "boolean": bool, "array": list, "object": dict,
        }

        def _make_tool_wrapper(ctx, func, name):
            """Factory to avoid closure-over-loop-variable pitfalls."""
            _is_coro = asyncio.iscoroutinefunction(func)

            async def _tool_wrapper(**kwargs):
                try:
                    if _is_coro:
                        result = await func(ctx, **kwargs)
                    else:
                        result = func(ctx, **kwargs)
                    return str(result) if result is not None else "(no output)"
                except Exception as e:
                    logger.warning("Tool %s error: %s", name, e)
                    return f"Error: {e}"
            return _tool_wrapper

        for name, func, description, schema in _TOOL_REGISTRY:
            if name in _BUILTIN_NAMES:
                continue  # skip — built-in version added in steps 2-3
            _tool_wrapper = _make_tool_wrapper(ctx, func, name)

            # Build a Pydantic model from the JSON Schema so LangChain
            # sends the correct parameter descriptions to the LLM
            args_schema = None
            if schema and schema.get("properties"):
                fields = {}
                required = set(schema.get("required", []))
                for prop_name, prop_info in schema["properties"].items():
                    py_type = _type_map.get(prop_info.get("type", "string"), str)
                    prop_desc = prop_info.get("description", "")
                    if prop_name in required:
                        fields[prop_name] = (py_type, Field(description=prop_desc))
                    else:
                        default = prop_info.get("default", None)
                        fields[prop_name] = (
                            Optional[py_type],
                            Field(default=default, description=prop_desc),
                        )
                try:
                    args_schema = create_model(f"{name}_schema", **fields)
                except Exception:
                    args_schema = None

            # Build the tool with proper schema
            tool_kwargs = dict(
                coroutine=_tool_wrapper,
                name=name,
                description=description,
            )
            if args_schema:
                tool_kwargs["args_schema"] = args_schema
            tool = StructuredTool.from_function(**tool_kwargs)
            tools.append(tool)

    except Exception as e:
        logger.warning("Failed to load Hikaflow tools: %s", e)

    hikaflow_count = len(tools)
    if hikaflow_count == 0:
        logger.warning(
            "No Hikaflow tools loaded — agent will only have filesystem tools. "
            "Check that autodebug.agent.tools and autodebug.agent.sdk_bridge are importable."
        )

    # Mutable holder for display_fn — set later by run_graph_streaming
    # so bash tool can stream progress during execution.
    _display_fn_holder: list = [None]

    # 2. Add filesystem tools
    tools.extend([
        _make_bash_tool(project_path, display_fn_holder=_display_fn_holder),
        _make_read_file_tool(project_path),
        _make_write_file_tool(project_path),
        _make_edit_file_tool(project_path),
        _make_search_files_tool(project_path),
        _make_list_directory_tool(project_path),
        _make_scan_tests_tool(project_path),
    ])

    # 3. Add debugging effectiveness tools
    _headless = getattr(deps, "headless", False)
    tools.extend(_make_debugging_tools(project_path, headless=_headless))

    # 4. Add scan/fix tools (formerly handled by deterministic router)
    async def _scan_for_bugs(
        scope: str = "all",
        focus: str = "bugs, security issues, code quality",
        max_files: int = 10,
    ) -> str:
        """Scan the codebase for bugs using deterministic AST analysis + external tools + tree-sitter.

        This is the primary scanning tool. It runs 13 Python AST specialists,
        6 TS/JS tree-sitter checks, Ruff, Bandit, Vulture, and pip-audit,
        then optionally sends top-risk files to LLM review.

        Args:
            scope: "all" for full codebase, or a directory/file path to scan.
            focus: What to look for (default: bugs, security, code quality).
            max_files: Max files for LLM deep review (default 10, max 20).
        """
        max_files = max(1, min(int(max_files), 20))
        try:
            return await run_codebase_scan(project_path, max_files=max_files, focus=focus)
        except Exception as e:
            return f"Error: {type(e).__name__}: {e}"

    tools.append(StructuredTool.from_function(
        coroutine=_scan_for_bugs,
        name="scan_for_bugs",
        description=(
            "Scan the entire codebase for bugs. Runs AST specialists (Python), "
            "tree-sitter checks (JS/TS/TSX), Ruff, Bandit, Vulture, and pip-audit. "
            "Args: scope (default 'all'), focus (what to look for), max_files (for LLM review)."
        ),
    ))

    async def _deep_scan_file(
        path: str,
        focus: str = "bugs, security issues, code quality",
    ) -> str:
        """Deep-scan a single file with AST specialists + LLM review.

        Runs all deterministic checks plus parallel LLM workers for thorough analysis.

        Args:
            path: File path relative to project root.
            focus: What to look for.
        """
        try:
            resolved = _resolve_scan_path(path, project_path)
            return await run_code_review(resolved, project_path, focus=focus)
        except Exception as e:
            return f"Error: {type(e).__name__}: {e}"

    tools.append(StructuredTool.from_function(
        coroutine=_deep_scan_file,
        name="deep_scan_file",
        description=(
            "Deep-scan a SINGLE file: runs deterministic AST checks (Python/TS/JS) first, "
            "then sends to LLM review workers. Use this for one file. "
            "For multiple files, use review_files instead. "
            "Args: path (file to scan), focus (what to look for)."
        ),
    ))

    async def _fix_finding(finding_number: int) -> str:
        """Generate and apply a fix for a specific finding from the last scan.

        Reads the source context, generates a surgical LLM-powered patch,
        and applies it directly.

        Args:
            finding_number: The finding number from the scan results (e.g., 1, 2, 3).
        """
        try:
            return await generate_fix(finding_number, project_path)
        except Exception as e:
            return f"Error: {type(e).__name__}: {e}"

    tools.append(StructuredTool.from_function(
        coroutine=_fix_finding,
        name="fix_finding",
        description=(
            "Auto-fix a specific finding from the last scan. "
            "Args: finding_number (the # from scan results). "
            "Generates a patch using LLM and applies it to the source file."
        ),
    ))

    # Deduplicate: keep last occurrence of each name (built-in wins over registry)
    _all_names = [t.name for t in tools]
    _dupes = [n for n in _all_names if _all_names.count(n) > 1]
    if _dupes:
        logger.warning("Duplicate tool names before dedupe: %s", sorted(set(_dupes)))
    _seen: set[str] = set()
    _deduped: list[StructuredTool] = []
    for t in reversed(tools):
        if t.name not in _seen:
            _seen.add(t.name)
            _deduped.append(t)
    tools = list(reversed(_deduped))

    logger.info("Built %d LangGraph tools (%d hikaflow + filesystem + debugging)", len(tools), hikaflow_count)

    # Stash display_fn holder on deps so run_graph_streaming can set it
    deps._bash_display_fn_holder = _display_fn_holder

    return tools


# ---------------------------------------------------------------------------
# Debugging effectiveness tools (error parser, test runner, git, memory)
# ---------------------------------------------------------------------------

def _make_debugging_tools(project_path: str, headless: bool = False) -> list[StructuredTool]:
    """Build debugging-specific tools: error parser, git_diff."""
    tools = []

    # --- parse_error: structured error trace parsing ---
    async def _parse_error(error_text: str, language: str = "auto") -> str:
        """Parse an error trace into structured JSON with file:line references.

        Supports Python tracebacks, Node.js stack traces, and generic errors.
        Automatically adds source code context around the failure point.

        Args:
            error_text: The error or stack trace text to parse.
            language: Language hint — "python", "node", or "auto" (tries both).
        """
        try:
            from autodebug.agent.error_parser import parse_error
            result = parse_error(error_text, language=language, project_path=project_path)
            # Extract file:line from result to hint the LLM to read the source
            import json as _json
            try:
                parsed = _json.loads(result)
                frames = parsed.get("frames", [])
                if frames:
                    top = frames[-1]  # innermost frame
                    f, ln = top.get("file", ""), top.get("line", "")
                    if f and ln:
                        result += f"\n\nNext step: call read_file(path=\"{f}\", start_line={max(1, int(ln)-10)}) to examine the code around the failure."
            except (ValueError, TypeError, KeyError, AttributeError):
                pass
            return result
        except Exception as e:
            return f"Error parsing trace: {e}"

    tools.append(StructuredTool.from_function(
        coroutine=_parse_error, name="parse_error",
        description="Parse an error trace into structured JSON with file:line references and source context.",
    ))

    # run_tests removed — compact profile version (tools.py) used instead.
    # It has path security checks, env var stripping, Literal types, and
    # structured envelope output.

    # git_log, git_blame, web_fetch removed — agent can use bash for these.
    # Available via HIKAFLOW_TOOL_PROFILE=admin if needed.

    # --- git_diff: show changes ---
    async def _git_diff(ref: str = "", path: str = "", staged: bool = False) -> str:
        """Show git diff for working changes or between refs.

        Args:
            ref: Git ref to diff against (e.g., 'HEAD~1', 'main'). Empty = working tree.
            path: Optional file path to filter diff for.
            staged: If True, show staged changes (--cached).
        """
        cmd = ["git", "diff"]
        if staged:
            cmd.append("--cached")
        if ref:
            cmd.append(ref)
        if path:
            cmd.extend(["--", path])
        try:
            proc = await asyncio.create_subprocess_exec(
                *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
                cwd=project_path,
            )
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
            if proc.returncode != 0:
                return f"Error: {(stderr or b'').decode()}"
            output = (stdout or b"").decode("utf-8", errors="replace")
            return _format_truncated_output(output) if output else "(no changes)"
        except Exception as e:
            return f"Error: {e}"

    tools.append(StructuredTool.from_function(
        coroutine=_git_diff, name="git_diff",
        description="Show git diff for working changes or between refs.",
    ))

    return tools


# ---------------------------------------------------------------------------
# Graph nodes
# ---------------------------------------------------------------------------

def _safe_display(display_fn, event_type: str, **kwargs) -> None:
    """Call display_fn without letting exceptions propagate."""
    if not display_fn:
        return
    try:
        display_fn(event_type, **kwargs)
    except Exception as e:
        logger.debug("display_fn error (ignored): %s", e)


def _tool_signature(name: str, args: dict) -> str:
    """Create a deterministic signature for a tool call."""
    args_json = json.dumps(args, sort_keys=True, default=str)
    args_hash = hashlib.md5(args_json.encode()).hexdigest()[:8]
    return f"{name}:{args_hash}"


LLM_MAX_RETRIES = 3
LLM_RETRY_BASE_DELAY = 1.0  # seconds


async def _invoke_with_retry(model, messages, max_retries=LLM_MAX_RETRIES, display_fn=None):
    """Invoke an LLM with exponential backoff retry on transient errors.

    Passes cache_control to enable Anthropic prompt caching. Cache-read tokens
    don't count against ITPM rate limits and cost only 10% of base price.
    """
    # Detect if model supports cache_control (Anthropic only)
    use_cache = hasattr(model, '_client_params') or 'anthropic' in str(type(model)).lower()
    for attempt in range(max_retries):
        try:
            if use_cache:
                return await model.ainvoke(
                    messages,
                    cache_control={"type": "ephemeral"},
                )
            else:
                return await model.ainvoke(messages)
        except TypeError as e:
            if "cache_control" in str(e):
                use_cache = False
                return await model.ainvoke(messages)
            raise
        except Exception as e:
            err_str = str(e).lower()
            is_transient = any(kw in err_str for kw in (
                "overloaded", "rate_limit", "529", "timeout", "503", "502",
            ))
            if attempt == max_retries - 1 or not is_transient:
                raise
            wait = LLM_RETRY_BASE_DELAY * (2 ** attempt)
            logger.warning("LLM call failed (attempt %d/%d): %s — retrying in %.1fs",
                           attempt + 1, max_retries, e, wait)
            if display_fn:
                _safe_display(display_fn, "retry",
                    attempt=attempt + 1, max_retries=max_retries,
                    error_type=type(e).__name__, wait_s=wait)
            await asyncio.sleep(wait)


def _phase_status_line(state: dict) -> str:
    """Build a turn-aware phase guidance line for the agent.

    Injected before every LLM call so the agent knows where it is in the
    budget and what it should prioritize.  Phases are soft guidance — the
    agent can still call any tool in any phase.
    """
    turn = state.get("turn_count", 0)
    budget = state.get("turn_budget", 15)
    mode = state.get("mode", "chat")

    if mode not in ("scan", "debug"):
        return ""

    # Count draft/verified findings
    project_path = state.get("project_path", "")
    draft_count = 0
    open_count = 0
    if project_path:
        findings_path = os.path.join(project_path, ".hikaflow", "findings.json")
        try:
            if os.path.isfile(findings_path):
                import json as _json
                with open(findings_path) as f:
                    findings = _json.load(f)
                for f_item in findings:
                    if isinstance(f_item, dict):
                        st = f_item.get("status", "open")
                        if st == "draft":
                            draft_count += 1
                        elif st == "open":
                            open_count += 1
        except Exception:
            pass

    # Determine phase based on budget progress
    progress = turn / max(budget, 1)
    if progress < 0.4:
        phase = "INVESTIGATE"
        guidance = "Read, search, scan. Save hypotheses as draft findings."
    elif progress < 0.7:
        phase = "VERIFY"
        guidance = (
            "Stop reading new files. Run bash/run_tests to test each draft finding. "
            "Promote confirmed issues: update_findings(action='update', status='open'). "
            "Retract false alarms: update_findings(action='update', status='wontfix')."
        )
    else:
        phase = "CONCLUDE"
        guidance = (
            "Wrap up. Promote or retract ALL remaining draft findings. "
            "Do not start new investigations. Prepare your final report."
        )

    parts = [f"[Turn {turn + 1}/{budget} | {phase}"]
    if draft_count or open_count:
        parts.append(f" | {draft_count} drafts, {open_count} verified")
    parts.append(f"] {guidance}")
    return "".join(parts)


async def agent_node(state: dict, config: RunnableConfig) -> dict:
    """Call the LLM with tools bound. Increments turn_count.

    Before calling the LLM:
    - Compact history if context is getting large
    - Inject phase-aware status line (scan/debug modes)
    - Filter tools in PLAN mode (read-only only)
    """
    model = config["configurable"]["model"]
    tools = config["configurable"]["tools"]
    display_fn = config["configurable"].get("display_fn")
    _debug = config["configurable"].get("debug_terminal", False)
    mode = state.get("mode", "chat")
    approval_mode = state.get("approval_mode", "yolo")

    # Compact history if needed (emit debug event on compaction)
    pre_compact_len = len(state["messages"])
    messages = compact_history(state["messages"])
    if _debug and len(messages) < pre_compact_len:
        _safe_display(display_fn, "compaction",
            before=pre_compact_len, after=len(messages),
            dropped=pre_compact_len - len(messages))

    # Inject phase-aware status line (scan/debug only)
    phase_line = _phase_status_line(state)
    if phase_line:
        messages = list(messages)
        messages.append(HumanMessage(content=phase_line))

    # Token budget: inject warning when approaching limit
    token_budget = state.get("token_budget", TOKEN_BUDGET_DEFAULT)
    cumulative = state.get("cumulative_input_tokens", 0)
    if token_budget > 0 and cumulative > 0:
        ratio = cumulative / token_budget
        if ratio >= 0.8:
            remaining_pct = int((1 - ratio) * 100)
            messages = list(messages) if not isinstance(messages, list) else messages
            messages.append(HumanMessage(content=(
                f"[Token budget: {remaining_pct}% remaining ({cumulative:,}/{token_budget:,} tokens). "
                "Wrap up your current work and prepare your final report.]"
            )))

    # Plan mode: filter to read-only tools (pre-LLM filtering)
    if approval_mode == "plan":
        try:
            from autodebug.agent.approval import READ_ONLY_TOOLS
            tools = [t for t in tools if t.name in READ_ONLY_TOOLS]
        except ImportError:
            pass

    # [REMOVED] Scan mode tool filtering — LLM now sees all tools in all modes.
    # The LLM decides what to call based on the system prompt and user request.
    # Previously: scan mode restricted to 6 read-only tools, blocking run_tests etc.

    # Bind tools to model
    model_with_tools = model.bind_tools(tools)

    # Estimate input tokens for this call (for budget tracking)
    this_call_tokens = _estimate_tokens(messages)

    response = await _invoke_with_retry(model_with_tools, messages,
                                         display_fn=display_fn if _debug else None)

    # Debug: show agent reasoning (text before tool calls)
    if _debug and hasattr(response, "content") and response.content:
        reasoning = response.content
        if isinstance(reasoning, str) and reasoning.strip():
            _safe_display(display_fn, "agent_reasoning", text=reasoning[:300])
        elif isinstance(reasoning, list):
            for block in reasoning:
                if isinstance(block, dict) and block.get("type") == "text":
                    text = block.get("text", "").strip()
                    if text:
                        _safe_display(display_fn, "agent_reasoning", text=text[:300])
                        break

    new_cumulative = cumulative + this_call_tokens
    turn_num = state["turn_count"] + 1

    # Per-turn cost display — use actual model pricing
    _model_id = getattr(model, "model_name", None) or getattr(model, "model", "")
    from autodebug.llm_config import MODEL_COSTS
    _input_rate = MODEL_COSTS.get(_model_id, (0.001, 0.005))[0]  # per 1K tokens
    _cost_per_token = _input_rate / 1000  # per token
    est_input_cost = new_cumulative * _cost_per_token
    est_turn_cost = this_call_tokens * _cost_per_token
    _safe_display(display_fn, "turn_cost",
                  turn=turn_num,
                  turn_tokens=this_call_tokens,
                  turn_cost_usd=est_turn_cost,
                  cumulative_tokens=new_cumulative,
                  cumulative_cost_usd=est_input_cost,
                  token_budget=token_budget)

    return {
        "messages": [response],
        "turn_count": turn_num,
        "cumulative_input_tokens": new_cumulative,
    }


async def tool_node(state: dict, config: RunnableConfig) -> dict:
    """Execute tool calls from the last AI message.

    Tracks tool signatures for stale detection, applies approval policy,
    tracks file diffs for write/edit operations, and updates counts.
    """
    tools_by_name = config["configurable"]["tools_by_name"]
    display_fn = config["configurable"].get("display_fn")
    approval_fn = config["configurable"].get("approval_fn")
    diff_tracker = config["configurable"].get("diff_tracker")
    _debug = config["configurable"].get("debug_terminal", False)
    # Dedup cache: {tool_name:args_json -> result_text} for read-only tools
    dedup_cache = config["configurable"].get("dedup_cache")

    # Read-only tools safe for dedup caching (same args → same result)
    _CACHEABLE_TOOLS = frozenset({
        "read_file", "list_directory", "search_files", "update_findings",
        "failure_similarity_search", "get_session_context", "scan_tests",
    })

    # Load approval policy engine
    approval_mode = state.get("approval_mode", "yolo")
    try:
        from autodebug.agent.approval import PolicyEngine, ApprovalMode
        policy = PolicyEngine(ApprovalMode(approval_mode))
    except Exception:
        policy = None

    last_message = state["messages"][-1]
    tool_calls = getattr(last_message, "tool_calls", [])

    tool_messages = []
    new_signatures = set(state.get("tool_signatures", set()))
    new_tool_count = state["tool_calls_count"]
    has_novel_call = False
    has_errors = False
    edit_paths = []  # Track paths edited this turn
    _scan_tests_fired = False  # Track if scan_tests returned results this turn

    for i, tc in enumerate(tool_calls):
        # Safely extract fields — handle both dict and object-style tool calls
        if isinstance(tc, dict):
            name = tc.get("name")
            args = tc.get("args", {})
            tc_id = tc.get("id", f"tc_{i}")
        else:
            name = getattr(tc, "name", None)
            args = getattr(tc, "args", {})
            tc_id = getattr(tc, "id", f"tc_{i}")

        if not name:
            tool_messages.append(
                ToolMessage(content="Error: Malformed tool call (missing name)", tool_call_id=tc_id)
            )
            continue

        # Scan mode enforcement: reject tools not in whitelist
        mode = state.get("mode", "chat")
        if mode == "scan" and name not in {
            "read_file", "list_directory", "search_files", "scan_tests",
            "review_files", "get_session_context", "failure_similarity_search",
            "scan_for_bugs", "deep_scan_file", "fix_finding",
        }:
            tool_messages.append(
                ToolMessage(content=f"Error: `{name}` is not available in scan mode. Use scan_for_bugs, deep_scan_file, read_file, search_files, or scan_tests.", tool_call_id=tc_id)
            )
            has_errors = True
            new_tool_count += 1
            continue

        # Display tool call (full args in debug mode)
        if _debug:
            args_str = ", ".join(f"{k}={v}" for k, v in args.items())
        else:
            args_str = ", ".join(f"{k}={str(v)[:200]}" for k, v in args.items())
        _safe_display(display_fn, "tool_call", name=name, args_str=args_str)

        # --- Dedup cache: return cached result for identical read-only tool calls ---
        # Skip caching for update_findings with mutating actions (add/update/remove)
        cache_key = None
        is_cacheable = (name in _CACHEABLE_TOOLS and
                        not (name == "update_findings" and args.get("action") != "list"))
        if dedup_cache is not None and is_cacheable:
            cache_key = f"{name}:{json.dumps(args, sort_keys=True, default=str)}"
            if cache_key in dedup_cache:
                cached = dedup_cache[cache_key]
                result_text = f"(cached) {cached}"
                tool_messages.append(ToolMessage(content=result_text, tool_call_id=tc_id))
                _safe_display(display_fn, "tool_result", name=name, result="(cached)",
                              is_error=False, elapsed_ms=0)
                new_tool_count += 1
                continue

        # --- Approval check ---
        if policy:
            try:
                from autodebug.agent.approval import PolicyDecision
                decision = policy.check(name, args)
                if _debug:
                    _safe_display(display_fn, "approval", tool=name,
                                  decision=decision.name, mode=approval_mode)
                if decision == PolicyDecision.DENY:
                    result_text = f"Denied: Tool '{name}' is not allowed in {approval_mode} mode."
                    tool_messages.append(ToolMessage(content=result_text, tool_call_id=tc_id))
                    _safe_display(display_fn, "tool_result", name=name, result=result_text, is_error=True)
                    continue
                if decision == PolicyDecision.ASK_USER and approval_fn:
                    approved = await approval_fn(name, args, policy.format_approval_request(name, args))
                    if not approved:
                        result_text = f"Denied by user: {name}"
                        tool_messages.append(ToolMessage(content=result_text, tool_call_id=tc_id))
                        _safe_display(display_fn, "tool_result", name=name, result=result_text, is_error=True)
                        continue
                    # Cache the approval for future calls
                    policy.cache.approve(name, args, scope="prefix")
            except ImportError:
                pass
            except Exception as e:
                logger.debug("Approval check error: %s", e)

        # Track signature for stale detection
        sig = _tool_signature(name, args)
        if sig not in new_signatures:
            has_novel_call = True
            new_signatures.add(sig)

        # --- Snapshot before write/edit for diff tracking ---
        file_path_arg = args.get("path") or args.get("file_path") or ""
        is_write_op = name in ("write_file", "edit_file")
        if is_write_op and diff_tracker and file_path_arg:
            try:
                diff_tracker.snapshot(file_path_arg)
            except Exception:
                pass

        # Execute with timing
        tool = tools_by_name.get(name)
        _tool_t0 = time.monotonic()
        if tool:
            try:
                result = await tool.ainvoke(args)
                result_text = str(result) if result is not None else "(no output)"
            except Exception as e:
                result_text = f"Error: {type(e).__name__}: {e}"
                logger.exception("Tool %s execution error", name)
        else:
            result_text = f"Error: Unknown tool '{name}'"
        _tool_elapsed_ms = (time.monotonic() - _tool_t0) * 1000

        # --- Universal tool output cap (Codex: 10K tokens, Gemini: 20/80 split) ---
        if len(result_text) > TOOL_OUTPUT_MAX_CHARS and not result_text.startswith("Error:"):
            original_len = len(result_text)
            # Gemini strategy: first 20% + last 80% (errors/results appear at end)
            head_budget = TOOL_OUTPUT_MAX_CHARS // 5       # 20% = 8K chars
            tail_budget = TOOL_OUTPUT_MAX_CHARS * 4 // 5   # 80% = 32K chars
            head = result_text[:head_budget]
            tail = result_text[-tail_budget:]
            truncated_chars = original_len - head_budget - tail_budget
            result_text = (
                f"{head}\n\n... [{truncated_chars:,} chars truncated — "
                f"showing first {head_budget // 1000}K + last {tail_budget // 1000}K "
                f"of {original_len:,} chars] ...\n\n{tail}"
            )

        # --- Track scan_tests completion (scan mode: force synthesis after) ---
        is_error = result_text.startswith("Error:")
        if name == "scan_tests" and not is_error:
            _scan_tests_fired = True

        # --- Track edited paths and show diff ---
        if is_write_op and not is_error and file_path_arg:
            edit_paths.append(file_path_arg)
            if diff_tracker:
                try:
                    diff = diff_tracker.get_file_diff(file_path_arg)
                    if diff:
                        _safe_display(display_fn, "file_diff", path=file_path_arg, diff=diff)
                except Exception:
                    pass

        if is_error:
            has_errors = True
        _safe_display(display_fn, "tool_result", name=name, result=result_text,
                      is_error=is_error, elapsed_ms=_tool_elapsed_ms)

        # Store in dedup cache for read-only tools (skip errors and write ops)
        if cache_key and dedup_cache is not None and not is_error and not is_write_op:
            if len(dedup_cache) < 200:  # Bounded cache
                dedup_cache[cache_key] = result_text

        # Invalidate findings list cache on mutating findings actions
        if name == "update_findings" and args.get("action") in ("add", "update", "remove"):
            findings_list_keys = [k for k in dedup_cache if k.startswith("update_findings:")]
            for k in findings_list_keys:
                dedup_cache.pop(k, None)

        tool_messages.append(
            ToolMessage(content=result_text, tool_call_id=tc_id)
        )
        new_tool_count += 1

    # Update stale counter — only increment on repeated successful calls,
    # not on errors (which would cause premature synthesis instead of retry).
    # Threshold raised to 3 to avoid premature synthesis during iterative debugging.
    stale_turns = state.get("stale_turns", 0)
    if has_novel_call:
        stale_turns = 0
    elif not has_errors:
        stale_turns += 1

    if _debug and stale_turns > 0:
        mode = state.get("mode", "chat")
        threshold = MODE_STALE_THRESHOLDS.get(mode, 3)
        _safe_display(display_fn, "stale_warning",
            stale_turns=stale_turns, threshold=threshold)

    # If scan_tests fired in scan mode, signal completion
    scan_complete = state.get("scan_complete", False) or (
        _scan_tests_fired and state.get("mode") == "scan"
    )

    return {
        "messages": tool_messages,
        "tool_calls_count": new_tool_count,
        "tool_signatures": new_signatures,
        "stale_turns": stale_turns,
        "last_edit_paths": edit_paths,
        "scan_complete": scan_complete,
    }


async def synthesis_node(state: dict, config: RunnableConfig) -> dict:
    """Synthesize a final report with tools still bound to preserve cache.

    Keeps identical tool definitions as agent_node so Anthropic's hierarchical
    cache prefix (tools → system → messages) gets a full hit at all 3 levels.
    Uses an instruction-only approach ("do not call tools") instead of
    tool_choice=none, because changing tool_choice invalidates messages cache.
    """
    model = config["configurable"]["model"]
    tools = config["configurable"]["tools"]
    display_fn = config["configurable"].get("display_fn")

    # --- Scan passthrough: if scan_tests produced a report, return it directly ---
    # This avoids the LLM rewriting/dropping/garbling the pre-formatted output.
    # We emit the text via display_fn so the CLI renders it (no LLM stream events).
    if state.get("scan_complete", False):
        scan_output = ""
        for msg in reversed(state["messages"]):
            if isinstance(msg, ToolMessage) and msg.content and "Analyzed" in msg.content and "test files" in msg.content:
                scan_output = msg.content
                break
        if scan_output:
            _safe_display(display_fn, "synthesis", reason="scan_tests complete")
            _safe_display(display_fn, "text_delta", text=scan_output)
            return {
                "messages": [AIMessage(content=scan_output)],
                "synthesis_reason": "scan_tests_passthrough",
            }

    # Determine reason from state
    turn_count = state.get("turn_count", 0)
    turn_budget = state.get("turn_budget", 15)
    stale_turns = state.get("stale_turns", 0)
    mode = state.get("mode", "chat")
    stale_threshold = MODE_STALE_THRESHOLDS.get(mode, 3)

    if turn_count >= turn_budget:
        reason = f"turn budget exhausted ({turn_count}/{turn_budget})"
    elif stale_turns >= stale_threshold:
        reason = f"stale turns ({stale_turns} repeated tool calls, threshold {stale_threshold})"
    else:
        reason = state.get("synthesis_reason", "forced")

    # Notify user why synthesis is happening
    _safe_display(display_fn, "synthesis", reason=reason)

    from autodebug.agent.core import MODE_SYNTHESIS_NUDGES
    mode = state.get("mode", "chat")
    nudge = MODE_SYNTHESIS_NUDGES.get(mode, MODE_SYNTHESIS_NUDGES["chat"])

    # Keep tools bound identically to agent_node to preserve full cache prefix.
    # Using instruction-only approach for text-only response (no tool_choice change).
    model_with_tools = model.bind_tools(tools)

    messages = list(state["messages"])

    # Programmatic draft exclusion: read findings, inject ONLY verified ones.
    # Don't rely on the model to exclude drafts — it doesn't (h15 proved this).
    findings_context = ""
    project_path = state.get("project_path", "")
    if project_path and mode in ("scan", "debug"):
        findings_path = os.path.join(project_path, ".hikaflow", "findings.json")
        try:
            if os.path.isfile(findings_path):
                import json as _json
                with open(findings_path) as f:
                    findings = _json.load(f)
                verified = [f for f in findings
                            if isinstance(f, dict) and f.get("status") == "open"]
                drafts = [f for f in findings
                          if isinstance(f, dict) and f.get("status") == "draft"]
                if verified:
                    rows = []
                    for v in verified:
                        rows.append(
                            f"| {v.get('severity', '?')} | {v.get('file', '?')}:{v.get('line', '?')} "
                            f"| {v.get('description', '?')[:120]} |"
                        )
                    findings_context = (
                        f"\n\nVERIFIED FINDINGS ({len(verified)}):\n"
                        "| Severity | File:Line | Issue |\n"
                        "|----------|-----------|-------|\n"
                        + "\n".join(rows)
                    )
                if drafts:
                    draft_ids = ", ".join(f.get("issue_id", "?") for f in drafts)
                    findings_context += (
                        f"\n\nDRAFT findings excluded from report (unverified): {draft_ids}. "
                        "Do NOT include these in your output."
                    )
                if not verified and not drafts:
                    findings_context = "\n\nNo findings were saved during this session."
        except Exception:
            pass

    nudge_instruction = nudge + findings_context + "\n\nRespond with text only. Do not call any tools."
    messages.append(HumanMessage(content=nudge_instruction))

    response = await _invoke_with_retry(model_with_tools, messages)

    # Safety: strip any accidental tool calls, extract text content only
    if hasattr(response, "tool_calls") and response.tool_calls:
        text_content = ""
        if isinstance(response.content, str):
            text_content = response.content
        elif isinstance(response.content, list):
            text_content = "\n".join(
                b.get("text", "") for b in response.content
                if isinstance(b, dict) and b.get("type") == "text"
            )
        response = AIMessage(content=text_content or "(synthesis produced no text)")

    return {
        "messages": [HumanMessage(content=nudge_instruction), response],
        "synthesis_reason": reason,
    }


async def nudge_node(state: dict, config: RunnableConfig) -> dict:
    """Add a nudge message when the agent narrated without calling tools."""
    nudge = HumanMessage(
        content=(
            "You described a plan but didn't call any tools. "
            "Execute now — call the appropriate tool(s) to make progress."
        )
    )
    return {
        "messages": [nudge],
        "nudge_attempted": True,
    }


async def verify_node(state: dict, config: RunnableConfig) -> dict:
    """Nudge the agent to verify its edits by running tests.

    Injected after tool_node when edits were made in fix mode.
    """
    edit_paths = state.get("last_edit_paths", [])
    paths_str = ", ".join(edit_paths[:5])
    nudge = HumanMessage(
        content=(
            f"You just edited: {paths_str}. "
            "Run the relevant tests now to verify your changes work. "
            "Use run_tests or bash to execute the test suite."
        )
    )
    return {
        "messages": [nudge],
        "verification_attempted": True,
        "last_edit_paths": [],  # Clear so we don't re-nudge
    }


async def grace_node(state: dict, config: RunnableConfig) -> dict:
    """Give the agent one final turn to wrap up before hard synthesis.

    Fires when turn budget is reached but before forced synthesis, giving
    the agent a chance to finish a pending test run or commit.
    """
    display_fn = config["configurable"].get("display_fn")
    turn_count = state.get("turn_count", 0)
    turn_budget = state.get("turn_budget", 15)
    _safe_display(display_fn, "grace_turn",
                  reason=f"Turn budget reached ({turn_count}/{turn_budget})")

    nudge = HumanMessage(content=(
        "You have reached the turn budget. You have ONE final turn. "
        "If you have pending edits, run a quick test. "
        "Otherwise, summarize your findings and stop. "
        "Do not start new investigations."
    ))
    return {
        "messages": [nudge],
        "grace_turn_attempted": True,
    }


async def verify_claims_node(state: dict, config: RunnableConfig) -> dict:
    """Nudge the agent to verify its conclusions by running code.

    Fires unconditionally once in scan/debug mode as a mandatory workflow
    checkpoint. The agent being verified must not control whether
    verification occurs (Swiss cheese model / CoVe principle).

    Also checks for draft (unverified) findings and instructs the agent
    to verify or retract them before the final report.
    """
    display_fn = config["configurable"].get("display_fn")
    _safe_display(display_fn, "verify_claims")

    # Count draft findings to include in nudge
    draft_section = ""
    project_path = state.get("project_path", "")
    if project_path:
        findings_path = os.path.join(project_path, ".hikaflow", "findings.json")
        try:
            if os.path.isfile(findings_path):
                import json as _json
                with open(findings_path) as f:
                    findings = _json.load(f)
                drafts = [f for f in findings if isinstance(f, dict) and f.get("status") == "draft"]
                if drafts:
                    draft_ids = ", ".join(f.get("issue_id", "?") for f in drafts)
                    draft_section = (
                        f"\n\nYou have {len(drafts)} DRAFT (unverified) findings: {draft_ids}.\n"
                        "After verifying each with code, call:\n"
                        "- update_findings(action='update', issue_id='X', status='open') for confirmed issues\n"
                        "- update_findings(action='update', issue_id='X', status='wontfix') for false alarms\n"
                        "Only 'open' findings will appear in the final report. Drafts are excluded."
                    )
        except Exception:
            pass  # Don't fail the node over findings read errors

    nudge = HumanMessage(content=(
        "STOP — you must verify your conclusions with code before reporting. "
        "Do NOT report findings until you run at least one verification.\n\n"
        "REQUIRED: Use bash or run_tests to empirically confirm your claims. "
        "For example:\n"
        "  bash(command='python -c \"from module import func; print(func(test_input))\"')\n"
        "  run_tests(path='tests/', quick=True)\n\n"
        "After verification, update any draft findings to 'open' (confirmed) "
        "or 'wontfix' (false alarm)."
        + draft_section
    ))
    return {
        "messages": [nudge],
        "verify_claims_fired": True,
    }


# ---------------------------------------------------------------------------
# Routing edges
# ---------------------------------------------------------------------------

def should_continue(state: dict) -> str:
    """Route after agent_node: tools, nudge, synthesize, or end."""
    last_message = state["messages"][-1]
    tool_calls = getattr(last_message, "tool_calls", [])
    mode = state.get("mode", "chat")

    if tool_calls:
        return "tools"

    # Chat mode: text-only is fine
    if mode == "chat":
        return "end"

    # Action mode with no tools: nudge once
    if not state.get("nudge_attempted", False):
        # Only nudge if there's substantive text (not just whitespace)
        content = getattr(last_message, "content", "")
        if isinstance(content, str) and len(content.strip()) > 20:
            return "nudge"

    # Verify claims: mandatory one-shot checkpoint in scan/debug mode
    if mode in ("scan", "debug") and not state.get("verify_claims_fired", False):
        return "verify_claims"

    return "end"


MODE_STALE_THRESHOLDS = {"scan": 3, "debug": 3, "fix": 3, "chat": 2}


def _extract_scope(user_message: str) -> str:
    """Extract a file/directory path from the user's message."""
    for token in user_message.split():
        token = token.strip(".,;:\"'")
        if "/" in token or token.endswith(".py"):
            return token
    return ""


# Per-mode instructions injected after codebase context
MODE_INSTRUCTIONS = {
    "scan": (
        "[Mode: SCAN] You are scanning this codebase for bugs, issues, and code quality problems. "
        "Be systematic: use search_files and list_directory to explore, read_file to investigate. "
        "Track findings as you go. Don't try to fix — just find and report."
    ),
    "debug": (
        "[Mode: DEBUG] You are debugging a specific issue. Follow the evidence chain: "
        "1) Parse any error traces with parse_error, 2) Read the failing code, "
        "3) Search for related patterns, 4) Form and test hypotheses. "
        "Use git_diff to understand recent changes. Be thorough before concluding."
    ),
    "fix": (
        "[Mode: FIX] You are fixing a bug or implementing a change. "
        "1) Understand the problem (read code, parse errors), "
        "2) Make targeted edits, 3) Run tests with run_tests to verify, "
        "4) Check for regressions. Always verify your changes work."
    ),
    "chat": "[Mode: CHAT] Answer the user's question. Use tools only if needed.",
}


def check_budget(state: dict) -> str:
    """Route after tool_node: continue, verify, grace, or trigger synthesis."""
    turn_count = state.get("turn_count", 0)
    turn_budget = state.get("turn_budget", 15)
    stale_turns = state.get("stale_turns", 0)
    mode = state.get("mode", "chat")

    # Scan mode: scan_tests already returned results → force synthesis
    if mode == "scan" and state.get("scan_complete", False):
        return "synthesize"

    # Token budget exceeded → force synthesis (prevents runaway cost)
    token_budget = state.get("token_budget", TOKEN_BUDGET_DEFAULT)
    cumulative = state.get("cumulative_input_tokens", 0)
    if token_budget > 0 and cumulative >= token_budget:
        return "synthesize"

    if turn_count >= turn_budget:
        if not state.get("grace_turn_attempted", False):
            return "grace"
        return "synthesize"

    stale_threshold = MODE_STALE_THRESHOLDS.get(mode, 3)
    if stale_turns >= stale_threshold:
        return "synthesize_stale"

    # Self-verification: if we just edited files in fix mode, nudge to test
    if mode == "fix" and state.get("last_edit_paths") and not state.get("verification_attempted"):
        return "verify"

    return "continue"


# ---------------------------------------------------------------------------
# Graph builder
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Context window management
# ---------------------------------------------------------------------------

# Rough char-to-token ratio for estimation (1 token ~ 4 chars for English)
CHARS_PER_TOKEN_ESTIMATE = 4
# Max estimated tokens for conversation history before compaction
CONTEXT_MAX_TOKENS = 100_000  # Claude has 200K context; compact at ~50%
# Tool output mask threshold (chars) — outputs longer than this get truncated
# in older messages (not the most recent turn)
TOOL_OUTPUT_MASK_CHARS = 500

# Patterns to extract insights from AI messages before dropping them
INSIGHT_PATTERNS = [
    re.compile(r"root cause[:\s]+(.+?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
    re.compile(r"the (?:bug|issue|problem) (?:is|was)[:\s]+(.+?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
    re.compile(r"found that[:\s]+(.+?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
    re.compile(r"this fails because[:\s]+(.+?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
    re.compile(r"(?:fix|solution|resolution)[:\s]+(.+?)(?:\.|$)", re.IGNORECASE | re.MULTILINE),
]


def _estimate_tokens(msgs: list) -> int:
    """Estimate token count for a list of messages."""
    total = 0
    for m in msgs:
        content = getattr(m, "content", "")
        if isinstance(content, str):
            total += len(content) // CHARS_PER_TOKEN_ESTIMATE
        elif isinstance(content, list):
            for block in content:
                if isinstance(block, str):
                    total += len(block) // CHARS_PER_TOKEN_ESTIMATE
                elif isinstance(block, dict):
                    total += len(str(block.get("text", ""))) // CHARS_PER_TOKEN_ESTIMATE
    return total


def _extract_insights(messages: list) -> list[str]:
    """Extract debugging insights from AI messages being dropped.

    Scans for patterns like 'root cause:', 'found that:', etc.
    Returns deduplicated list of insight strings.
    """
    insights = []
    seen = set()
    for msg in messages:
        if not isinstance(msg, AIMessage):
            continue
        content = getattr(msg, "content", "")
        if not isinstance(content, str):
            continue
        for pattern in INSIGHT_PATTERNS:
            for match in pattern.finditer(content):
                insight = match.group(1).strip()
                if insight and len(insight) > 15 and insight not in seen:
                    seen.add(insight)
                    insights.append(insight)
    return insights[:10]  # Cap at 10 insights


def _summarize_tool_output(content: str) -> str:
    """Intelligently summarize a tool output for compaction.

    Preserves test results and key information instead of blind truncation.
    """
    if not content or len(content) <= TOOL_OUTPUT_MASK_CHARS:
        return content

    # Test output: extract pass/fail summary
    test_summary = re.search(
        r'(\d+)\s+passed.*?(?:(\d+)\s+failed)?.*?(?:(\d+)\s+(?:error|skipped))?.*?in\s+[\d.]+s',
        content, re.IGNORECASE,
    )
    if test_summary:
        summary = f"Test results: {test_summary.group(0)}"
        # Also grab failure names if present
        failures = re.findall(r'FAILED\s+(\S+)', content)
        if failures:
            summary += f"\nFailed: {', '.join(failures[:5])}"
        return summary

    # Search results: keep file paths and matched lines
    if "matches" in content.lower() or re.search(r'^\d+:', content, re.MULTILINE):
        lines = content.splitlines()
        kept = [l for l in lines if re.match(r'^[^\s].*:\d+:', l)][:20]
        if kept:
            return f"Search results ({len(kept)} matches shown):\n" + "\n".join(kept)

    # Default: head/tail truncation
    head = content[:300]
    tail = content[-200:]
    return f"{head}\n... [{len(content) - 500} chars masked] ...\n{tail}"


# Aggressive per-turn tool output aging thresholds (chars).
# Tool outputs shrink as they age to reduce carry-forward context cost.
# Each token carried forward is re-billed as input on every subsequent turn.
_AGE_TIER_1_CHARS = 2_000   # >2 msgs from tail: summarize to this
_AGE_TIER_2_CHARS = 200     # >6 msgs from tail: one-line signature


def _one_line_signature(msg: ToolMessage) -> str:
    """Create a one-line signature for a very old tool output."""
    content = msg.content if isinstance(msg.content, str) else str(msg.content)
    # Extract tool name hint from first line
    first_line = content.split("\n", 1)[0][:120]
    total = len(content)
    return f"[Prior tool output: {first_line}... ({total:,} chars, aged)]"


def age_tool_outputs(messages: list) -> list:
    """Aggressively age old tool outputs to reduce carry-forward cost.

    Every token in context is billed as input on every subsequent LLM call.
    A 7.5K token read on turn 1 costs 7500 * 12 * $3/1M = $0.27 by session end.
    Aging old outputs prevents this compounding.

    Strategy (inspired by Gemini CLI's 20/80 and Codex's 10K cap):
    - Last 4 messages: keep intact (current turn context)
    - 5-8 messages from tail: summarize to ~2K chars via _summarize_tool_output
    - 9+ messages from tail: collapse to one-line signature (~200 chars)

    Runs before every LLM call, not just at threshold. This is the single
    highest-impact cost optimization after read_file capping.
    """
    if len(messages) <= 4:
        return messages

    result = list(messages)
    tail_start = len(result) - 4  # Keep last 4 intact

    for i in range(len(result)):
        msg = result[i]
        if not isinstance(msg, ToolMessage):
            continue
        content = msg.content if isinstance(msg.content, str) else str(msg.content)

        distance_from_tail = len(result) - i

        if distance_from_tail > 8 and len(content) > _AGE_TIER_2_CHARS:
            # Very old: collapse to one-line signature
            result[i] = ToolMessage(
                content=_one_line_signature(msg),
                tool_call_id=msg.tool_call_id,
            )
        elif distance_from_tail > 4 and len(content) > _AGE_TIER_1_CHARS:
            # Moderately old: smart summarize
            result[i] = ToolMessage(
                content=_summarize_tool_output(content),
                tool_call_id=msg.tool_call_id,
            )

    return result


def compact_history(
    messages: list,
    max_tokens: int = CONTEXT_MAX_TOKENS,
) -> list:
    """Semantic context compaction for conversation history.

    Three-phase strategy that preserves debugging insights:
    1. Age tool outputs aggressively (always runs)
    2. Summarize remaining old tool outputs if still over budget
    3. Extract insights from AI messages being dropped
    4. Drop oldest messages but inject insight summary

    Args:
        messages: List of BaseMessage instances.
        max_tokens: Estimated token budget.

    Returns:
        Compacted list of messages.
    """
    if not messages:
        return messages

    # Phase 0: Always age old tool outputs (runs every turn, not just at threshold)
    messages = age_tool_outputs(messages)

    est = _estimate_tokens(messages)
    if est <= max_tokens:
        return messages

    # Phase 1: Intelligently summarize old tool outputs (keep last 10 intact)
    result = list(messages)
    safe_tail = max(10, len(result) // 3)

    for i in range(len(result) - safe_tail):
        msg = result[i]
        if isinstance(msg, ToolMessage):
            content = msg.content
            if isinstance(content, str) and len(content) > TOOL_OUTPUT_MASK_CHARS:
                summarized = _summarize_tool_output(content)
                result[i] = ToolMessage(content=summarized, tool_call_id=msg.tool_call_id)
            elif isinstance(content, list):
                total_len = sum(
                    len(b.get("text", "")) if isinstance(b, dict) else len(str(b))
                    for b in content
                )
                if total_len > TOOL_OUTPUT_MASK_CHARS:
                    summary = f"[Tool output masked: {total_len} chars across {len(content)} blocks]"
                    result[i] = ToolMessage(content=summary, tool_call_id=msg.tool_call_id)

    est = _estimate_tokens(result)
    if est <= max_tokens:
        return result

    # Phase 2: Extract insights from messages we're about to drop
    keep_tail = max(int(len(result) * 0.6), 4)  # Keep 60% (up from 50%)
    drop_count = len(result) - keep_tail
    has_system = isinstance(result[0], SystemMessage) if result else False
    start_idx = 1 if has_system else 0
    messages_to_drop = result[start_idx:start_idx + drop_count]
    insights = _extract_insights(messages_to_drop)

    # Phase 3: Rebuild with insight injection
    kept_messages = result[-keep_tail:]

    # Phase 3b: Fix orphaned ToolMessages.
    # A ToolMessage is orphaned if its tool_call_id doesn't match any
    # AIMessage.tool_calls in the kept set. The Anthropic API rejects
    # conversations with orphaned ToolMessages.
    valid_tc_ids: set[str] = set()
    for msg in kept_messages:
        if isinstance(msg, AIMessage):
            for tc in getattr(msg, "tool_calls", []):
                tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
                if tc_id:
                    valid_tc_ids.add(tc_id)
    kept_messages = [
        msg for msg in kept_messages
        if not isinstance(msg, ToolMessage) or msg.tool_call_id in valid_tc_ids
    ]

    # Build the compacted list
    compacted = []
    if has_system:
        compacted.append(result[0])

    # Inject session context block with extracted insights
    if insights:
        insight_text = "Earlier in this session, you determined:\n" + "\n".join(
            f"- {ins}" for ins in insights
        )
        compacted.append(HumanMessage(
            content=f"[Session context — {drop_count} messages compacted]\n{insight_text}"
        ))

    compacted.extend(kept_messages)
    return compacted


def build_graph(tools: list[StructuredTool]) -> StateGraph:
    """Build the LangGraph agent graph.

    Graph topology:
        START → agent → should_continue → {
            tools → check_budget → {
                agent | verify → agent | grace → agent | synthesis → END
            }
            nudge → agent
            verify_claims → agent
            END
        }

    Returns:
        A compiled StateGraph.
    """
    graph = StateGraph(AgentState)

    # Add nodes
    graph.add_node("agent", agent_node)
    graph.add_node("tools", tool_node)
    graph.add_node("synthesis", synthesis_node)
    graph.add_node("nudge", nudge_node)
    graph.add_node("verify", verify_node)
    graph.add_node("grace", grace_node)
    graph.add_node("verify_claims", verify_claims_node)

    # Entry point
    graph.set_entry_point("agent")

    # After agent: route to tools, nudge, or end
    graph.add_conditional_edges(
        "agent",
        should_continue,
        {
            "tools": "tools",
            "nudge": "nudge",
            "verify_claims": "verify_claims",
            "end": END,
        },
    )

    # After tools: check budget (includes verify, grace, and synthesis paths)
    graph.add_conditional_edges(
        "tools",
        check_budget,
        {
            "continue": "agent",
            "verify": "verify",
            "grace": "grace",
            "synthesize": "synthesis",
            "synthesize_stale": "synthesis",
        },
    )

    # After verify: back to agent (to run tests)
    graph.add_edge("verify", "agent")

    # After grace: back to agent (one final turn)
    graph.add_edge("grace", "agent")

    # After nudge: back to agent
    graph.add_edge("nudge", "agent")

    # After verify_claims: back to agent (to run code)
    graph.add_edge("verify_claims", "agent")

    # After synthesis: end
    graph.add_edge("synthesis", END)

    return graph.compile()


# ---------------------------------------------------------------------------
# Streaming runner
# ---------------------------------------------------------------------------

async def run_codebase_scan(
    project_path: str,
    max_files: int = 10,
    focus: str = "bugs, security issues, code quality",
) -> str:
    """3-layer codebase scan: deterministic AST → LLM verification → LLM discovery.

    Layer 1: Deterministic AST specialists on ALL files (free, instant)
    Layer 2: LLM verification of ambiguous findings (one call, ~$0.005)
    Layer 3: LLM discovery on top-risk files (parallel, ~$0.05-0.10)

    `scan for bugs` = Layer 1+2+3 (top 10). `scan all for bugs` = top 20.
    """
    import asyncio
    from autodebug.agent.specialists import run_specialists
    from autodebug.agent.specialists.llm_review import ASTFlags

    # ── Step 1: Discover source files ──
    _SOURCE_DIRS = ["autodebug", "api", "worker", "src"]
    _EXCLUDE = {"__pycache__", ".git", "node_modules", ".venv", "venv", "build",
                "dist", ".tox", ".eggs", ".research", "tests", "test",
                "selfimprovementloop", "repro_zoo", "migrations", "specialists"}
    _MAX_FILE_LINES = 4000

    root = Path(project_path).resolve()
    all_files: list[Path] = []
    for sdir in _SOURCE_DIRS:
        sdir_path = root / sdir
        if not sdir_path.exists():
            continue
        for f in sdir_path.rglob("*.py"):
            if f.is_file() and f.stat().st_size > 200:
                rel_parts = f.relative_to(root).parts
                if not (_EXCLUDE & set(rel_parts)):
                    all_files.append(f)

    if not all_files:
        return "## Codebase Scan\n\nNo Python source files found."

    # ── Layer 1: Deterministic AST scan on ALL files (free, fast) ──
    file_scores: list[tuple[int, int, str]] = []  # (risk_score, lines, path)

    # First pass: score all files (sync, fast)
    eligible_for_ast: list[str] = []
    for f in all_files:
        try:
            source = f.read_text(errors="replace")
        except Exception:
            continue
        lines_count = source.count("\n") + 1
        if lines_count < 20:
            continue
        rel_path = str(f.relative_to(root))

        flags = ASTFlags.from_source(source)
        score = 0
        if flags.has_subprocess: score += 3
        if flags.has_auth_patterns: score += 3
        if flags.has_async: score += 1
        if flags.has_file_ops: score += 1
        if flags.has_except_handlers: score += 1
        if flags.has_globals: score += 1
        if lines_count > 200: score += 2
        if lines_count > 500: score += 2
        file_scores.append((score, lines_count, rel_path))
        if lines_count <= _MAX_FILE_LINES:
            eligible_for_ast.append(rel_path)

    file_scores.sort(reverse=True)
    n_total_files = len(file_scores)

    # ── Run Layer 1 (AST), 1b (external tools), 1c (tree-sitter) concurrently ──
    _SKIP_SPECIALISTS = {"dead_code"}  # too noisy/slow for bulk scan

    async def _run_ast_layer() -> list[tuple[int, str, str, str, str]]:
        """Layer 1: AST specialists on all eligible Python files."""
        findings: list[tuple[int, str, str, str, str]] = []

        async def _ast_scan(rel_path: str) -> list[tuple[int, str, str, str, str]]:
            try:
                result, _ = await run_specialists(rel_path, project_path, focus)
                return [
                    (f.line, f.severity, f.message, rel_path, f.confidence)
                    for f in result if f.specialist not in _SKIP_SPECIALISTS
                ]
            except Exception:
                return []

        for i in range(0, len(eligible_for_ast), 20):
            batch = eligible_for_ast[i:i + 20]
            batch_results = await asyncio.gather(*[_ast_scan(p) for p in batch])
            for file_findings in batch_results:
                findings.extend(file_findings)
        return findings

    async def _run_external_layer() -> tuple[list[tuple[int, str, str, str, str]], dict[str, float]]:
        """Layer 1b: External tools (Ruff, Bandit, Vulture, pip-audit)."""
        findings: list[tuple[int, str, str, str, str]] = []
        timing: dict[str, float] = {}
        try:
            from autodebug.agent.specialists.external_tools import (
                run_ruff, run_bandit, run_vulture, run_pip_audit,
            )
            existing_dirs = [d for d in _SOURCE_DIRS if (root / d).exists()]
            if existing_dirs:
                ext_tasks: list[tuple[str, str, Any]] = []
                for d in existing_dirs:
                    ext_tasks.append(("ruff", d, run_ruff(d, project_path)))
                    ext_tasks.append(("bandit", d, run_bandit(d, project_path)))
                    ext_tasks.append(("vulture", d, run_vulture(d, project_path)))
                ext_tasks.append(("pip-audit", ".", run_pip_audit(project_path)))

                results = await asyncio.gather(
                    *[t[2] for t in ext_tasks], return_exceptions=True
                )
                for (tool_name, _, _), result in zip(ext_tasks, results):
                    if isinstance(result, Exception):
                        continue
                    found, elapsed = result
                    for f in found:
                        findings.append((
                            f.line, f.severity, f.message,
                            f.file_path or "?", f.confidence,
                        ))
                    timing[tool_name] = timing.get(tool_name, 0) + elapsed
        except Exception:
            timing["_error"] = 0.0
        return findings, timing

    async def _run_ts_layer() -> tuple[list[tuple[int, str, str, str, str]], float]:
        """Layer 1c: Tree-sitter TS/JS/TSX specialists."""
        findings: list[tuple[int, str, str, str, str]] = []
        elapsed = 0.0
        try:
            from autodebug.agent.specialists.ts_specialists import run_ts_specialists
            ts_start = time.perf_counter()
            ts_extensions = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"}
            ts_files: list[Path] = []
            for sdir in _SOURCE_DIRS + ["web"]:
                sdir_path = root / sdir
                if sdir_path.exists():
                    for fpath in sdir_path.rglob("*"):
                        if (fpath.suffix.lower() in ts_extensions
                                and "node_modules" not in fpath.parts
                                and ".next" not in fpath.parts):
                            ts_files.append(fpath)
            for fpath in ts_files:
                try:
                    source = fpath.read_text(encoding="utf-8", errors="replace")
                    rel = str(fpath.relative_to(root))
                    result = run_ts_specialists(source, rel)
                    for f in result:
                        findings.append((f.line, f.severity, f.message, rel, f.confidence))
                except Exception:
                    continue
            elapsed = time.perf_counter() - ts_start
        except ImportError:
            pass
        return findings, elapsed

    # Run all three layers concurrently — they are fully independent
    ast_findings_result, ext_result, ts_result = await asyncio.gather(
        _run_ast_layer(), _run_external_layer(), _run_ts_layer()
    )
    ast_findings: list[tuple[int, str, str, str, str]] = ast_findings_result
    ext_findings, ext_timing = ext_result
    ast_findings.extend(ext_findings)
    ts_findings, ts_timing = ts_result
    ast_findings.extend(ts_findings)

    # ── Layer 3: LLM discovery on top-risk files (parallel) ──
    from autodebug.agent.specialists.llm_review import run_llm_review

    llm_top = file_scores[:max_files]
    llm_findings: list[tuple[int, str, str, str]] = []  # (line, sev, msg, path)
    llm_errors: list[str] = []

    async def _llm_scan(rel_path: str) -> list[tuple[int, str, str, str]]:
        try:
            source = (root / rel_path).read_text(errors="replace")
        except Exception:
            return []
        findings, timing = await run_llm_review(source, rel_path)
        errs = timing.get("_errors")
        if errs and isinstance(errs, list):
            llm_errors.extend(errs)
        return [(f.line, f.severity, f.message, rel_path) for f in findings]

    llm_eligible = [(s, l, p) for s, l, p in llm_top if l <= _MAX_FILE_LINES]
    if llm_eligible:
        llm_results = await asyncio.gather(*[_llm_scan(p) for _, _, p in llm_eligible])
        for file_findings in llm_results:
            # Per-file cap for LLM findings
            if len(file_findings) > 4:
                sev_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
                file_findings.sort(key=lambda x: sev_order.get(x[1], 3))
                file_findings = file_findings[:4]
            llm_findings.extend(file_findings)

    # ── Merge all findings ──
    all_found: list[tuple[int, str, str, str]] = []

    # AST findings (DEFINITE go straight in, LIKELY are included too since they're deterministic)
    for line, sev, msg, path, conf in ast_findings:
        all_found.append((line, sev, msg, path))

    # LLM findings
    all_found.extend(llm_findings)

    if not all_found:
        return (
            f"## Codebase Scan — No issues found\n\n"
            f"Scanned {n_total_files} files with 13 AST specialists + "
            f"{len(llm_eligible)} files with LLM specialists."
        )

    # ── Cross-file dedup ──
    sev_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
    all_found.sort(key=lambda x: (sev_order.get(x[1], 3), x[3], x[0]))

    def _core(msg: str) -> str:
        s = msg[:80].lower()
        s = re.sub(r'`[^`]*`', '``', s)
        s = re.sub(r'"[^"]*"', '""', s)
        s = re.sub(r'\b(the\s+)\w+(\s+endpoint|\s+function|\s+method)', r'\1_\2', s)
        s = re.sub(r'(in\s+)\w+(\s*,|\s+mode|\s+the)', r'\1_\2', s)
        return s[:50].strip()

    deduped: list[tuple[int, str, str, str]] = []
    seen_exact: set[tuple[str, int]] = set()
    seen_cores: dict[str, int] = {}
    proximity: list[tuple[str, int, str]] = []
    for line, sev, msg, path in all_found:
        exact_key = (path, line)
        if exact_key in seen_exact:
            continue
        seen_exact.add(exact_key)
        core = _core(msg)
        adjacent = any(pf == path and abs(pl - line) <= 5 for pf, pl, _ in proximity)
        if adjacent:
            continue
        near = any(pf == path and abs(pl - line) <= 20 and pc == core
                    for pf, pl, pc in proximity)
        if near:
            continue
        count = seen_cores.get(core, 0)
        if count >= 2:
            continue
        seen_cores[core] = count + 1
        proximity.append((path, line, core))
        deduped.append((line, sev, msg, path))

    MAX_FINDINGS = 25
    if len(deduped) > MAX_FINDINGS:
        deduped = deduped[:MAX_FINDINGS]

    # ── Format output ──
    total = len(deduped)
    sev_counts: dict[str, int] = {}
    for _, sev, _, _ in deduped:
        sev_counts[sev] = sev_counts.get(sev, 0) + 1
    sev_str = ", ".join(f"{v} {k}" for k, v in sev_counts.items() if v)

    n_ast = len(ast_findings)
    n_llm = len(llm_findings)
    n_ext = len([k for k in ext_timing if not k.startswith("_")])
    ext_tools_used = ", ".join(k for k in ext_timing if not k.startswith("_"))

    lines_out = [f"## Codebase Scan — {total} finding{'s' if total != 1 else ''} ({sev_str})\n"]
    lines_out.append("| # | File | Line | Severity | Issue |")
    lines_out.append("|---|------|------|----------|-------|")
    for i, (line, sev, msg, path) in enumerate(deduped, 1):
        short = path.split("/")[-1] if path else "?"
        if len(msg) > 90:
            cut = msg[:90].rfind(" ")
            msg = msg[:cut] + "..." if cut > 20 else msg[:90] + "..."
        lines_out.append(f"| {i} | `{short}` | {line} | {sev} | {msg} |")

    ts_info = f" + tree-sitter TS/JS ({ts_timing:.1f}s)" if ts_timing > 0 else ""
    lines_out.append(
        f"\nScanned {n_total_files} files: 13 AST specialists ({n_ast} findings) + "
        f"{n_ext} external tools ({ext_tools_used}){ts_info} + "
        f"{len(llm_eligible)} top-risk files with LLM ({n_llm} findings)."
    )
    if llm_errors:
        unique_errors = sorted(set(llm_errors))
        lines_out.append(f"**Note:** {len(unique_errors)} LLM specialist(s) failed: {', '.join(unique_errors)}. LLM results may be incomplete.")

    lines_out.append("**Next:** `fix #N` to auto-fix a finding, or `scan <file>.py` to dive deeper.")

    # Cache findings for `fix #N`
    _store_scan_findings(deduped)

    return "\n".join(lines_out)


async def run_code_review(
    file_path: str,
    project_path: str,
    focus: str = "bugs, security issues, code quality",
) -> str:
    """Hybrid code review: deterministic AST checks + parallel LLM specialists.

    Pipeline (all parallel):
    1. Deterministic specialists (AST/grep) — dead code, missing await, etc.
    2. LLM specialists (7 focused prompts) — security, logic, design, etc.

    Results are merged, deduplicated by line+message, and formatted.
    Wall time ≈ slowest LLM call (~5-10s). Cost ~$0.02 via Gemini Flash.
    """
    import asyncio
    from autodebug.agent.specialists import run_specialists
    from autodebug.agent.specialists.llm_review import run_llm_review

    # Read the file first — shared by both pipelines
    full_path = os.path.join(project_path, file_path)
    try:
        source = open(full_path, encoding="utf-8", errors="replace").read()
    except FileNotFoundError:
        _EXCLUDE = {".git", "build", "dist", "__pycache__", "node_modules", ".venv", "venv"}
        name = Path(file_path).name
        root = Path(project_path).resolve()
        candidates = [
            str(p.relative_to(root))
            for p in root.rglob(name)
            if p.is_file() and not (_EXCLUDE & set(p.relative_to(root).parts))
        ]
        if candidates:
            hint = "\n".join(f"- `{c}`" for c in candidates[:5])
            return f"## Code Review — `{file_path}`\n\nFile not found. Did you mean:\n{hint}"
        return f"## Code Review — `{file_path}`\n\nError: file not found: {full_path}"
    except Exception as e:
        return f"## Code Review — `{file_path}`\n\nError: {e}"

    # Run deterministic + LLM + external tools in parallel
    from autodebug.agent.specialists.external_tools import run_external_tools
    ast_task = run_specialists(file_path, project_path, focus)
    llm_task = run_llm_review(source, file_path)
    ext_task = run_external_tools(file_path, project_path, single_file=True)
    (ast_findings, ast_timing), (llm_findings, llm_timing), (ext_findings, ext_timing) = (
        await asyncio.gather(ast_task, llm_task, ext_task)
    )

    # Merge all findings into (line, severity, message) tuples
    total_lines = source.count("\n") + 1
    all_findings: list[tuple[int, str, str]] = []
    seen_exact: set[tuple[int, str]] = set()    # (line, msg_prefix)
    seen_fuzzy: dict[str, int] = {}              # msg_core → count (cap repeats)

    def _msg_core(msg: str) -> str:
        """Extract core message for fuzzy dedup — normalize away names that vary per-instance."""
        s = msg[:80].lower()
        # Strip backtick-quoted names: `foo_bar` → ``
        s = re.sub(r'`[^`]*`', '``', s)
        # Strip double-quoted strings: "sub" → ""
        s = re.sub(r'"[^"]*"', '""', s)
        # Strip endpoint/function names: "the create_session endpoint" → "the _ endpoint"
        s = re.sub(r'\b(the\s+)\w+(\s+endpoint|\s+function|\s+method)', r'\1_\2', s)
        # Strip specific identifiers after common prefixes
        s = re.sub(r'(in\s+)\w+(\s*,|\s+mode|\s+the)', r'\1_\2', s)
        return s[:50].strip()

    # Track (line, core) pairs for proximity dedup
    added_findings: list[tuple[int, str]] = []  # (line, core) of accepted findings

    def _is_near_duplicate(line: int, core: str) -> bool:
        """Check if a finding with similar message exists within 10 lines."""
        for prev_line, prev_core in added_findings:
            if abs(prev_line - line) <= 10 and prev_core == core:
                return True
        return False

    def _add(line: int, sev: str, msg: str):
        # Drop findings with invalid line numbers
        if line < 1 or line > total_lines:
            return
        # Exact dedup: same line + same message start
        exact_key = (line, msg[:60].lower())
        if exact_key in seen_exact:
            return
        seen_exact.add(exact_key)
        # Fuzzy dedup: cap identical-pattern findings at 2
        core = _msg_core(msg)
        count = seen_fuzzy.get(core, 0)
        if count >= 2:
            return
        # Proximity dedup: same core message within 10 lines
        if _is_near_duplicate(line, core):
            return
        seen_fuzzy[core] = count + 1
        added_findings.append((line, core))
        all_findings.append((line, sev, msg))

    for f in ast_findings:
        _add(f.line, f.severity, f.message)
    for f in ext_findings:
        _add(f.line, f.severity, f.message)
    for f in llm_findings:
        _add(f.line, f.severity, f.message)

    # Sort: HIGH first, then by line number, cap at 15
    sev_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
    all_findings.sort(key=lambda x: (sev_order.get(x[1], 3), x[0]))
    MAX_FINDINGS = 15
    if len(all_findings) > MAX_FINDINGS:
        all_findings = all_findings[:MAX_FINDINGS]

    # Build timing summary
    all_timing = {**ast_timing, **llm_timing, **ext_timing}
    n_ast = len([k for k in ast_timing if not k.startswith("_")])
    n_llm = len([k for k in llm_timing if not k.startswith("_")])
    n_ext = len([k for k in ext_timing if not k.startswith("_")])
    wall_time = max((v for v in all_timing.values() if isinstance(v, (int, float))), default=0)
    source_label = f"{n_ast} AST + {n_ext} external + {n_llm} LLM specialists"

    # Format output
    if not all_findings:
        llm_errors = llm_timing.get("_errors")
        note = ""
        if llm_errors and isinstance(llm_errors, list):
            note = f"\n**Note:** {len(llm_errors)} LLM specialist(s) failed: {', '.join(llm_errors)}. Results may be incomplete."
        return (
            f"## Code Review — `{file_path}`\n\n"
            f"No issues found.\n\n"
            f"Analyzed with {source_label} ({wall_time:.1f}s).{note}"
        )

    total = len(all_findings)
    sev_counts: dict[str, int] = {}
    for _, sev, _ in all_findings:
        sev_counts[sev] = sev_counts.get(sev, 0) + 1
    sev_str = ", ".join(f"{v} {k}" for k, v in sev_counts.items() if v)

    lines = [f"## Code Review — {total} finding{'s' if total != 1 else ''} ({sev_str})\n"]
    lines.append("| # | Line | Severity | Issue |")
    lines.append("|---|------|----------|-------|")
    for i, (line, sev, msg) in enumerate(all_findings, 1):
        if len(msg) > 100:
            cut = msg[:100].rfind(" ")
            msg = msg[:cut] + "..." if cut > 20 else msg[:100] + "..."
        lines.append(f"| {i} | {line} | {sev} | {msg} |")
    lines.append(f"\nAnalyzed `{file_path}` with {source_label} ({wall_time:.1f}s).")

    # Surface any LLM specialist errors
    llm_errors = llm_timing.get("_errors")
    if llm_errors and isinstance(llm_errors, list):
        lines.append(f"**Note:** {len(llm_errors)} LLM specialist(s) failed: {', '.join(llm_errors)}. Results may be incomplete.")

    lines.append("**Next:** `fix #N` to auto-fix a finding.")

    # Cache findings for `fix #N` (add file_path to each tuple)
    _store_scan_findings([(line, sev, msg, file_path) for line, sev, msg in all_findings])

    return "\n".join(lines)


def _quick_code_review(file_path: str, project_path: str) -> str:
    """Deterministic AST-based code review. No LLM needed.

    Checks for common bug patterns that static analysis catches reliably.
    Returns pre-formatted markdown report.
    """
    import ast
    full_path = os.path.join(project_path, file_path)
    try:
        source = open(full_path).read()
    except Exception as e:
        return f"Error reading `{file_path}`: {e}"

    try:
        tree = ast.parse(source)
    except SyntaxError as e:
        return f"## Code Review — `{file_path}`\n\n| # | Line | Severity | Issue |\n|---|------|----------|-------|\n| 1 | {e.lineno} | HIGH | Syntax error: {e.msg} |"

    lines = source.splitlines()
    findings: list[tuple[int, str, str]] = []  # (line, severity, message)

    # Collect all defined function names and their call sites
    defined_funcs: dict[str, int] = {}  # name -> line
    called_funcs: set[str] = set()

    for node in ast.walk(tree):
        # Track function definitions and calls
        if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
            defined_funcs[node.name] = node.lineno
        if isinstance(node, ast.Call):
            if isinstance(node.func, ast.Name):
                called_funcs.add(node.func.id)
            elif isinstance(node.func, ast.Attribute):
                called_funcs.add(node.func.attr)

        # Check 1: bare except / except Exception: pass
        if isinstance(node, ast.ExceptHandler):
            if node.body and len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
                if node.type is None:
                    findings.append((node.lineno, "HIGH", "Bare `except: pass` swallows all exceptions silently"))
                elif isinstance(node.type, ast.Name) and node.type.id == "Exception":
                    findings.append((node.lineno, "MEDIUM", "`except Exception: pass` swallows errors silently"))

        # Check 2: startswith() without word boundary in security-sensitive context
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
            if node.func.attr == "startswith" and node.args:
                # Check if this is inside an approval/auth function
                line_text = lines[node.lineno - 1] if node.lineno <= len(lines) else ""
                # Look for approval/command/prefix context
                for parent in ast.walk(tree):
                    if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        if any(kw in parent.name.lower() for kw in ("approv", "auth", "check", "match", "allow")):
                            if parent.lineno <= node.lineno <= parent.end_lineno:
                                # Check if there's already a word-boundary helper being used
                                src_slice = source[parent.col_offset:] if hasattr(parent, 'col_offset') else ""
                                if "word_boundary" not in source[parent.col_offset:parent.end_lineno * 80 if hasattr(parent, 'end_lineno') else len(source)]:
                                    findings.append((node.lineno, "HIGH",
                                        f"`startswith()` without word boundary in `{parent.name}()` — "
                                        f"e.g. approving 'git' also approves 'github'"))
                                break

        # Check 3: json.dumps() without default= in try/except or return context
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
            if node.func.attr == "dumps" and isinstance(node.func.value, ast.Name) and node.func.value.id == "json":
                has_default = any(kw.arg == "default" for kw in node.keywords)
                if not has_default:
                    # Only flag if args might contain non-serializable data
                    for arg in node.args:
                        if isinstance(arg, ast.Name) and arg.id in ("args", "kwargs", "data", "obj", "result"):
                            findings.append((node.lineno, "LOW",
                                f"`json.dumps({arg.id})` without `default=str` — crashes on non-serializable objects"))
                            break

        # Check 4: mutable default arguments (not in dataclass/pydantic)
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            for default in node.args.defaults + node.args.kw_defaults:
                if default is not None and isinstance(default, (ast.List, ast.Dict, ast.Set)):
                    # Skip if it's in a class that's likely a dataclass/pydantic model
                    findings.append((node.lineno, "MEDIUM",
                        f"Mutable default argument in `{node.name}()` — shared across calls"))

        # Check 5: unreachable code after return/raise
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            for i, stmt in enumerate(node.body[:-1]):
                if isinstance(stmt, (ast.Return, ast.Raise)):
                    next_stmt = node.body[i + 1]
                    if not isinstance(next_stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                        findings.append((next_stmt.lineno, "LOW", "Unreachable code after return/raise"))

        # Check 6: open() without context manager
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "open":
            # Check if it's inside a 'with' statement
            in_with = False
            for parent in ast.walk(tree):
                if isinstance(parent, ast.With):
                    for item in parent.items:
                        if isinstance(item.context_expr, ast.Call):
                            if isinstance(item.context_expr.func, ast.Name) and item.context_expr.func.id == "open":
                                if item.context_expr.lineno == node.lineno:
                                    in_with = True
            if not in_with:
                findings.append((node.lineno, "MEDIUM", "`open()` without `with` block — file descriptor may leak on exception"))

        # Check 7: == None / != None instead of is None / is not None
        if isinstance(node, ast.Compare):
            for op, comparator in zip(node.ops, node.comparators):
                if isinstance(comparator, ast.Constant) and comparator.value is None:
                    if isinstance(op, ast.Eq):
                        findings.append((node.lineno, "LOW", "`== None` should be `is None` (PEP 8, identity check)"))
                    elif isinstance(op, ast.NotEq):
                        findings.append((node.lineno, "LOW", "`!= None` should be `is not None` (PEP 8, identity check)"))

        # Check 8: eval() / exec() calls
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
            if node.func.id == "eval":
                findings.append((node.lineno, "HIGH", "`eval()` — arbitrary code execution risk"))
            elif node.func.id == "exec":
                findings.append((node.lineno, "HIGH", "`exec()` — arbitrary code execution risk"))

        # Check 9: missing await on coroutine call (async def called without await)
        if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
            func_name = None
            if isinstance(node.value.func, ast.Name):
                func_name = node.value.func.id
            elif isinstance(node.value.func, ast.Attribute):
                func_name = node.value.func.attr
            if func_name and func_name in {n for n, _ in defined_funcs.items()}:
                # Check if the called function is async
                for f_node in ast.walk(tree):
                    if isinstance(f_node, ast.AsyncFunctionDef) and f_node.name == func_name:
                        findings.append((node.lineno, "HIGH",
                            f"Missing `await` on async function `{func_name}()` — coroutine never executes"))
                        break

        # Check 10: subprocess with shell=True
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
            if node.func.attr in ("run", "Popen", "call", "check_output", "check_call"):
                if isinstance(node.func.value, ast.Name) and node.func.value.id == "subprocess":
                    for kw in node.keywords:
                        if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
                            findings.append((node.lineno, "MEDIUM",
                                f"`subprocess.{node.func.attr}(shell=True)` — shell injection risk if args contain user input"))

        # Check 11: hardcoded secrets (API keys, passwords, tokens)
        if isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Name):
                    name_lower = target.id.lower()
                    if any(kw in name_lower for kw in ("password", "secret", "api_key", "apikey", "token", "private_key")):
                        if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) and len(node.value.value) > 4:
                            findings.append((node.lineno, "HIGH",
                                f"Hardcoded secret in `{target.id}` — use environment variable instead"))

        # Check 12: broad except that returns None (callers get unexpected None)
        if isinstance(node, ast.ExceptHandler):
            if node.body and len(node.body) == 1 and isinstance(node.body[0], ast.Return):
                ret = node.body[0]
                if ret.value is None or (isinstance(ret.value, ast.Constant) and ret.value.value is None):
                    if node.type is None or (isinstance(node.type, ast.Name) and node.type.id == "Exception"):
                        findings.append((node.lineno, "MEDIUM",
                            "`except Exception: return None` — callers may not expect None, leading to AttributeError"))

        # Check 13: time.sleep() in async function (blocks event loop)
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
            if node.func.attr == "sleep" and isinstance(node.func.value, ast.Name) and node.func.value.id == "time":
                # Check if inside async function
                for parent in ast.walk(tree):
                    if isinstance(parent, ast.AsyncFunctionDef):
                        if hasattr(parent, 'end_lineno') and parent.lineno <= node.lineno <= parent.end_lineno:
                            findings.append((node.lineno, "HIGH",
                                "`time.sleep()` inside async function — blocks event loop, use `await asyncio.sleep()`"))
                            break

    # Check for defined but never-called private functions
    _skip_kw = ("test", "fixture", "hook", "callback", "handler", "setup", "teardown")
    _candidates = {
        name: lineno for name, lineno in defined_funcs.items()
        if name.startswith("_") and not name.startswith("__")
        and name not in called_funcs
        and not any(kw in name for kw in _skip_kw)
    }
    if _candidates:
        # Batch cross-file check: grep source dirs only (fast)
        _externally_used: set[str] = set()
        try:
            import subprocess
            # Only search source code dirs, not tests/node_modules/etc.
            _src_dirs = []
            for d in ("autodebug", "api", "worker", "src"):
                _d = os.path.join(project_path, d)
                if os.path.isdir(_d):
                    _src_dirs.append(_d)
            if not _src_dirs:
                _src_dirs = [project_path]
            _pattern = "|".join(f"{n}\\(" for n in _candidates)
            grep_result = subprocess.run(
                ["grep", "-rlE", _pattern, *_src_dirs,
                 "--include=*.py"],
                capture_output=True, text=True, timeout=5,
            )
            _abs_file = os.path.join(project_path, file_path)
            _other_files = {
                f for f in grep_result.stdout.strip().splitlines()
                if os.path.abspath(f) != os.path.abspath(_abs_file)
            }
            if _other_files:
                for name in _candidates:
                    for f in _other_files:
                        try:
                            if f"{name}(" in open(f).read():
                                _externally_used.add(name)
                                break
                        except Exception:
                            pass
        except Exception:
            pass
        for name, lineno in _candidates.items():
            if name not in _externally_used:
                findings.append((lineno, "LOW", f"`{name}()` defined but never called in this file"))

    # Deduplicate
    seen: set[tuple[int, str]] = set()
    unique: list[tuple[int, str, str]] = []
    for line, sev, msg in findings:
        key = (line, msg[:60])
        if key not in seen:
            seen.add(key)
            unique.append((line, sev, msg))
    unique.sort(key=lambda x: ({"HIGH": 0, "MEDIUM": 1, "LOW": 2}.get(x[1], 3), x[0]))

    # Format output
    total = len(unique)
    if not unique:
        return f"## Code Review — `{file_path}`\n\nNo issues found.\n\nAnalyzed {len(lines)} lines with AST-based checks (common bug patterns)."

    sev_counts = {}
    for _, sev, _ in unique:
        sev_counts[sev] = sev_counts.get(sev, 0) + 1
    sev_str = ", ".join(f"{v} {k}" for k, v in sev_counts.items() if v)

    result = [f"## Code Review — {total} finding{'s' if total != 1 else ''} ({sev_str})\n"]
    result.append("| # | Line | Severity | Issue |")
    result.append("|---|------|----------|-------|")
    for i, (line, sev, msg) in enumerate(unique, 1):
        if len(msg) > 80:
            cut = msg[:80].rfind(" ")
            msg = msg[:cut] + "..." if cut > 20 else msg[:80] + "..."
        result.append(f"| {i} | {line} | {sev} | {msg} |")
    result.append(f"\nAnalyzed `{file_path}` ({len(lines)} lines) with AST-based checks.")
    result.append("**Next:** Review findings above. To fix: `fix <file>:<line>`")
    return "\n".join(result)


def _format_review_output(raw: str) -> str:
    """Convert review_files JSON envelope into terminal-friendly text."""
    try:
        data = json.loads(raw)
    except (json.JSONDecodeError, TypeError):
        return raw  # not JSON, return as-is

    if not isinstance(data, dict) or "status" not in data:
        return raw

    findings = data.get("data", {}).get("findings", [])
    files = data.get("data", {}).get("files", [])
    severity_counts = data.get("data", {}).get("severity_counts", {})
    error_notes = data.get("data", {}).get("error_notes", [])
    worker_timeouts = data.get("data", {}).get("worker_timeouts", [])
    pending = data.get("data", {}).get("pending_reviews", [])

    lines: list[str] = []

    if findings:
        # Header
        total = len(findings)
        sev_parts = [f"{v} {k}" for k, v in severity_counts.items() if v]
        lines.append(f"## Code Review — {total} finding{'s' if total != 1 else ''} ({', '.join(sev_parts)})\n")

        # Table
        lines.append("| # | File | Line | Severity | Issue |")
        lines.append("|---|------|------|----------|-------|")
        for i, f in enumerate(findings, 1):
            msg = f.get('message', '')
            if len(msg) > 80:
                cut = msg[:80].rfind(" ")
                msg = msg[:cut] + "..." if cut > 20 else msg[:80] + "..."
            lines.append(
                f"| {i} | `{f.get('file', '?')}` | {f.get('line', '?')} | "
                f"{f.get('severity', '?')} | {msg} |"
            )

        # CTA
        lines.append(f"\n**Next:** Review findings above. To fix: `fix <file>:<line>`")
    else:
        # No findings
        file_list = ", ".join(f"`{f.get('path', '?')}`" for f in files) if files else "unknown"
        lines.append(f"## Code Review — No issues found\n")
        lines.append(f"Analyzed: {file_list}")

        # Show raw worker output if available for transparency
        raw_output = data.get("data", {}).get("raw_output", "")
        if raw_output and "No issues found" not in raw_output:
            lines.append(f"\n{raw_output}")

    if worker_timeouts:
        lines.append(f"\n**Warnings:** {len(worker_timeouts)} worker(s) timed out")

    if error_notes:
        lines.append(f"\n**Errors:**")
        for e in error_notes[:3]:
            lines.append(f"- {e}")

    if pending:
        paths = [p.get("path", "?") for p in pending]
        lines.append(f"\n**Deferred ({len(pending)} files):** {', '.join(paths)}")

    return "\n".join(lines)


def _resolve_scan_path(scan_path: str, project_path: str) -> str:
    """Resolve a possibly short filename to its full relative path in the project.

    e.g. "core.py" → "autodebug/agent/core.py" if that's the unique match.
    If already a valid path or ambiguous, return as-is.
    """
    full = Path(project_path) / scan_path
    if full.exists():
        return scan_path
    # Search project tree for matching filename (exclude build/dist/venv artifacts)
    _EXCLUDE_DIRS = {".git", "build", "dist", "__pycache__", "node_modules", ".venv", "venv", ".tox", ".eggs"}
    filename = Path(scan_path).name
    root = Path(project_path).resolve()
    matches = [
        str(p.relative_to(root))
        for p in root.rglob(filename)
        if p.is_file() and not (_EXCLUDE_DIRS & set(p.relative_to(root).parts))
    ]
    if len(matches) == 1:
        return matches[0]
    # If multiple matches, prefer one ending with the user's input
    suffix_matches = [m for m in matches if m.endswith(scan_path)]
    if len(suffix_matches) == 1:
        return suffix_matches[0]
    # Prefer main source dirs over support dirs (api/db/core.py vs autodebug/agent/core.py)
    _PREFER_DIRS = ("autodebug/", "api/", "worker/", "src/")
    for pdir in _PREFER_DIRS:
        in_dir = [m for m in matches if m.startswith(pdir)]
        if len(in_dir) == 1:
            return in_dir[0]
    # If still ambiguous, pick the shallowest path (fewest slashes)
    if matches:
        matches.sort(key=lambda m: m.count("/"))
        return matches[0]
    return scan_path


# ---------------------------------------------------------------------------
# Last scan findings cache (for `fix #N` command)
# ---------------------------------------------------------------------------

# Each entry: (line, severity, message, file_path)
_last_scan_findings: list[tuple[int, str, str, str]] = []


def _store_scan_findings(findings: list[tuple[int, str, str, str]]):
    """Cache the most recent scan's findings for `fix #N`."""
    global _last_scan_findings
    _last_scan_findings = list(findings)


async def generate_fix(
    finding_index: int,
    project_path: str,
) -> str:
    """Generate a fix for finding #N from the last scan.

    Reads the source file, extracts context around the finding line,
    sends a surgical LLM prompt, and applies the patch.
    """
    if not _last_scan_findings:
        return "No scan findings cached. Run `scan for bugs` or `scan <file>.py` first."

    if finding_index < 1 or finding_index > len(_last_scan_findings):
        return f"Invalid finding number. Valid range: 1-{len(_last_scan_findings)}."

    line_no, severity, message, file_path = _last_scan_findings[finding_index - 1]

    if not file_path or file_path == "?":
        return f"Finding #{finding_index} has no file path — cannot generate fix."

    # Read the source file
    full_path = os.path.join(project_path, file_path)
    try:
        source = open(full_path, encoding="utf-8", errors="replace").read()
    except FileNotFoundError:
        return f"File not found: `{file_path}`"

    source_lines = source.splitlines()
    total_lines = len(source_lines)

    # Extract context: ±15 lines around the finding
    ctx_start = max(0, line_no - 16)
    ctx_end = min(total_lines, line_no + 15)
    context_lines = []
    for i in range(ctx_start, ctx_end):
        marker = " >>>" if i == line_no - 1 else "    "
        context_lines.append(f"{marker} {i+1:4d} | {source_lines[i]}")
    context = "\n".join(context_lines)

    # Build the fix prompt
    prompt = f"""You are a senior software engineer. Fix the bug described below.

FILE: {file_path}
LINE: {line_no}
ISSUE: {message}
SEVERITY: {severity}

SOURCE CONTEXT (>>> marks the problem line):
```python
{context}
```

INSTRUCTIONS:
- Output ONLY the fix as a unified diff (--- a/file, +++ b/file, @@ line numbers @@)
- Keep changes minimal — fix the bug, don't refactor surrounding code
- If the fix requires adding an import, include it in the diff
- Do NOT add comments explaining the fix — the diff should be self-explanatory
- If the finding is a false positive that shouldn't be fixed, output: SKIP: <reason>

Output the diff now:"""

    # Call LLM
    api_key = os.getenv("OPENROUTER_API_KEY", "")
    if not api_key:
        try:
            from autodebug.config import CREDENTIALS_PATH
            import json as _json
            creds = _json.loads(Path(CREDENTIALS_PATH).read_text())
            api_key = creds.get("openrouter_api_key", "")
        except Exception:
            pass
    if not api_key:
        return "No OpenRouter API key found. Set OPENROUTER_API_KEY or add to credentials.json."

    import httpx
    from autodebug.agent.specialists.llm_review import REVIEW_MODEL

    try:
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.post(
                "https://openrouter.ai/api/v1/chat/completions",
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "model": REVIEW_MODEL,
                    "messages": [{"role": "user", "content": prompt}],
                    "max_tokens": 2000,
                    "temperature": 0.0,
                },
            )
            data = resp.json()
    except Exception as e:
        return f"LLM call failed: {e}"

    if "error" in data:
        return f"LLM error: {data['error']}"

    try:
        content = data["choices"][0]["message"]["content"]
    except (KeyError, IndexError):
        return "Failed to parse LLM response."

    # Check for SKIP
    if content.strip().startswith("SKIP:"):
        return f"**Skipped:** {content.strip()}"

    def _similar(a: str, b: str) -> bool:
        """Check if two lines are similar enough to be old/new versions of same line."""
        a_words = set(a.strip().split())
        b_words = set(b.strip().split())
        if not a_words or not b_words:
            return False
        overlap = len(a_words & b_words)
        return overlap / max(len(a_words), len(b_words)) > 0.5

    # Extract the diff from LLM response
    diff_text = content.strip()
    # Strip markdown code fences if present
    if "```" in diff_text:
        parts = diff_text.split("```")
        for part in parts:
            stripped = part.strip()
            if stripped.startswith(("diff", "---", "@@", "-", "+")):
                diff_text = stripped
                # Remove language tag (e.g., "diff\n...")
                if diff_text.startswith("diff"):
                    diff_text = diff_text.split("\n", 1)[-1] if "\n" in diff_text else diff_text
                break

    # Try to apply via direct line replacement (more reliable than patch)
    applied = False
    error_msg = ""

    # Parse - and + lines from the diff (skip headers, strip line-number noise)
    old_lines: list[str] = []
    new_lines: list[str] = []

    def _strip_line_prefix(line: str) -> str:
        """Strip LLM noise: '>>> 200 | code' → 'code', '     200 | code' → 'code'."""
        return re.sub(r'^>{0,3}\s*\d+\s*\|\s?', '', line)

    for dl in diff_text.splitlines():
        stripped = dl.rstrip("\n")
        # Skip diff headers and @@ hunk markers
        if stripped.startswith(("---", "+++", "diff ")):
            continue
        if re.match(r'^[+-]?@@', stripped):
            continue
        # Context lines (space-prefixed, no +/-) — skip
        if stripped.startswith(" "):
            continue
        # Pure line-number lines (no +/-) — skip
        if re.match(r'^\s*>{0,3}\s*\d+\s*\|', stripped):
            continue
        # Actual diff lines — strip the +/- then strip line-number noise
        if stripped.startswith("-"):
            content = _strip_line_prefix(stripped[1:])
            old_lines.append(content)
        elif stripped.startswith("+"):
            content = _strip_line_prefix(stripped[1:])
            new_lines.append(content)

    if old_lines and new_lines:
        # If we got too many old lines (LLM noise), try to narrow down to
        # matching pairs — find the minimal set that actually differs
        if len(old_lines) > len(new_lines) * 3:
            # Likely LLM noise (double-diff) — find matching old/new pairs
            narrowed_old = []
            narrowed_new = []
            used_new = set()
            for nl in new_lines:
                for ol in old_lines:
                    if ol.strip() != nl.strip() and _similar(ol, nl) and ol not in narrowed_old:
                        narrowed_old.append(ol)
                        narrowed_new.append(nl)
                        break  # one old per new
            if narrowed_old and narrowed_new:
                old_lines = narrowed_old
                new_lines = narrowed_new

        try:
            old_text = "\n".join(ol.rstrip() for ol in old_lines)
            new_text = "\n".join(nl.rstrip() for nl in new_lines)

            # Try exact match first
            if old_text.strip() in source:
                new_source = source.replace(old_text.strip(), new_text.strip(), 1)
                if new_source != source:
                    with open(full_path, "w", encoding="utf-8") as f:
                        f.write(new_source)
                    applied = True

            # Try single-line match (most common case)
            if not applied and len(old_lines) == 1 and len(new_lines) == 1:
                old_stripped = old_lines[0].strip()
                if old_stripped and old_stripped in source:
                    # Find the right occurrence (closest to the finding line)
                    best_idx = -1
                    best_dist = 999999
                    for i, src_line in enumerate(source_lines):
                        if old_stripped in src_line:
                            dist = abs(i + 1 - line_no)
                            if dist < best_dist:
                                best_dist = dist
                                best_idx = i
                    if best_idx >= 0:
                        indent = source_lines[best_idx][:len(source_lines[best_idx]) - len(source_lines[best_idx].lstrip())]
                        source_lines[best_idx] = indent + new_lines[0].strip()
                        new_source = "\n".join(source_lines)
                        if not source.endswith("\n"):
                            pass
                        else:
                            new_source += "\n"
                        with open(full_path, "w", encoding="utf-8") as f:
                            f.write(new_source)
                        applied = True

            if not applied:
                error_msg = "Could not locate the code to replace"
        except Exception as e:
            error_msg = str(e)

    # Format output
    lines_out = [f"## Fix #{finding_index}: `{file_path}` line {line_no}\n"]
    lines_out.append(f"**Issue:** {message}\n")

    if applied:
        lines_out.append("**Status:** Applied successfully\n")
        lines_out.append(f"```diff\n{diff_text}\n```")
    else:
        lines_out.append("**Suggested fix:**\n")
        lines_out.append(f"```diff\n{diff_text}\n```")
        if error_msg:
            lines_out.append(f"\n*Auto-apply failed: {error_msg}*")
            lines_out.append("Apply manually or copy the diff above.")

    return "\n".join(lines_out)


async def run_graph_streaming(
    graph,
    *,
    messages: list[BaseMessage],
    mode: str,
    turn_budget: int,
    model: Any,
    tools: list[StructuredTool],
    display_fn: Optional[Any] = None,
    approval_fn: Optional[Any] = None,
    approval_mode: str = "yolo",
    project_path: str = "",
    debug_terminal: bool = False,
) -> dict:
    """Run the graph with streaming support.

    Args:
        graph: Compiled LangGraph.
        messages: Initial messages (system + user).
        mode: Agent mode (scan/debug/fix/chat).
        turn_budget: Max LLM calls before forced synthesis.
        model: ChatAnthropic model instance.
        tools: List of tools.
        display_fn: Callback for display (tool_call, tool_result, text events).
        approval_fn: Async callback for approval prompts (name, args, msg) -> bool.
        approval_mode: Approval mode for the session (default/auto-edit/yolo/plan).
        project_path: Project root for diff tracking.

    Returns:
        Final state dict with messages, turn_count, etc.
    """
    tools_by_name = {t.name: t for t in tools}

    # Deterministic interception for "fix #N" — too simple for LLM reasoning,
    # and weak models ignore the fix_finding tool.  Intercept, run, inject result.
    if messages and mode == "fix":
        _last_human = next(
            (m.content if isinstance(m.content, str) else str(m.content)
             for m in reversed(messages) if isinstance(m, HumanMessage)),
            "",
        )
        _fix_match = re.match(r"^\s*(?:fix|fix_finding)\s*#?\s*(\d+)\s*$", _last_human, re.IGNORECASE)
        if _fix_match:
            finding_num = int(_fix_match.group(1))
            _fix_project = project_path or str(Path.cwd())
            if display_fn:
                display_fn("tool_call", name="fix_finding", args_str=f"finding_number={finding_num}")
            fix_result = await generate_fix(finding_num, _fix_project)
            if display_fn:
                display_fn("tool_result", name="fix_finding", result=fix_result[:200])
                display_fn("text", text=fix_result)
            # Return a synthetic final state with the fix result
            return {
                "messages": messages + [AIMessage(content=fix_result)],
                "mode": mode,
                "turn_count": 1,
                "tool_calls_count": 1,
                "synthesis_reason": "fix_intercept",
            }

    # Initialize diff tracker for this session
    diff_tracker = None
    if project_path:
        try:
            from autodebug.agent.diff_tracker import TurnDiffTracker
            diff_tracker = TurnDiffTracker(project_path)
        except ImportError:
            pass

    # Inject per-mode instructions into messages if not already present
    if messages and mode in MODE_INSTRUCTIONS:
        # Extract target scope from user message to inject as persistent hint
        user_msg = next((m.content for m in reversed(messages) if isinstance(m, HumanMessage)), "")
        target_scope = _extract_scope(user_msg)
        scope_suffix = ""
        if target_scope:
            scope_suffix = (
                f"\n\nTARGET SCOPE: {target_scope} — all file operations should target "
                f"paths under this directory. When calling read_file or list_directory, "
                f"use paths like '{target_scope}/filename'."
            )
        mode_msg = HumanMessage(content=MODE_INSTRUCTIONS[mode] + scope_suffix)
        insert_idx = 1 if isinstance(messages[0], SystemMessage) else 0
        messages = list(messages)
        messages.insert(insert_idx, mode_msg)

    initial_state = {
        "messages": messages,
        "mode": mode,
        "turn_count": 0,
        "tool_calls_count": 0,
        "turn_budget": turn_budget,
        "stale_turns": 0,
        "tool_signatures": set(),
        "nudge_attempted": False,
        "synthesis_reason": "",
        "last_edit_paths": [],
        "verification_attempted": False,
        "grace_turn_attempted": False,
        "approval_mode": approval_mode,
        "project_path": project_path,
        "cumulative_input_tokens": 0,
        "token_budget": TOKEN_BUDGET_DEFAULT,
        "scan_complete": False,
    }

    # Dedup cache: mutable dict shared across all tool_node invocations in this session.
    # Read-only tools with identical args return cached results (h15: update_findings list
    # called twice back-to-back wasted 8K chars + tokens).
    dedup_cache: dict[str, str] = {}

    config = {
        # Derived recursion_limit: turn_budget * max node transitions per turn.
        # This ensures LangGraph's safety net never races our own check_budget.
        "recursion_limit": turn_budget * MAX_TRANSITIONS_PER_TURN,
        "configurable": {
            "model": model,
            "tools": tools,
            "tools_by_name": tools_by_name,
            "display_fn": display_fn,
            "approval_fn": approval_fn,
            "diff_tracker": diff_tracker,
            "debug_terminal": debug_terminal,
            "dedup_cache": dedup_cache,
        },
    }

    # Use astream_events for token-level streaming
    final_state = initial_state.copy()
    # Accumulate token usage from on_chat_model_end events
    token_usage = {
        "input_tokens": 0,
        "output_tokens": 0,
        "total_tokens": 0,
        "cache_read_tokens": 0,
        "cache_creation_tokens": 0,
    }
    # Per-turn tracking for debug mode
    _llm_call_start: float = 0.0
    turn_metrics_list: list[dict] = []
    _peak_ctx_pct: float = 0.0
    # Phase 2: External counters — source of truth for operational metrics.
    # These are always in scope and don't depend on on_chain_end extraction.
    _turn_count: int = 0
    _tool_calls_count: int = 0
    # Node transition trace for diagnostics
    _recent_node_names: list[str] = []

    try:
        async for event in graph.astream_events(initial_state, config, version="v2"):
            kind = event.get("event", "")

            # Track LLM call start time for wall-clock measurement
            if kind == "on_chat_model_start":
                _llm_call_start = time.monotonic()

            # Token-level streaming from the LLM
            if kind == "on_chat_model_stream":
                chunk = event.get("data", {}).get("chunk")
                if chunk and hasattr(chunk, "content") and chunk.content:
                    content = chunk.content
                    if isinstance(content, str) and content:
                        _safe_display(display_fn, "text_delta", text=content)
                    elif isinstance(content, list):
                        # Handle list-type content blocks from Anthropic
                        for block in content:
                            if isinstance(block, dict) and block.get("type") == "text":
                                text = block.get("text", "")
                                if text:
                                    _safe_display(display_fn, "text_delta", text=text)
                            elif isinstance(block, str) and block:
                                _safe_display(display_fn, "text_delta", text=block)

            # Capture token usage from LLM call completion
            if kind == "on_chat_model_end":
                _turn_count += 1
                llm_wall_s = time.monotonic() - _llm_call_start if _llm_call_start else 0
                output = event.get("data", {}).get("output")
                turn_input = 0
                turn_output = 0
                cache_read = 0
                cache_create = 0
                if output:
                    usage = getattr(output, "usage_metadata", None)
                    if usage and isinstance(usage, dict):
                        turn_input = usage.get("input_tokens", 0) or 0
                        turn_output = usage.get("output_tokens", 0) or 0
                        token_usage["input_tokens"] += turn_input
                        token_usage["output_tokens"] += turn_output
                        token_usage["total_tokens"] += usage.get("total_tokens", 0) or 0
                    elif usage:
                        turn_input = getattr(usage, "input_tokens", 0) or 0
                        turn_output = getattr(usage, "output_tokens", 0) or 0
                        token_usage["input_tokens"] += turn_input
                        token_usage["output_tokens"] += turn_output
                        token_usage["total_tokens"] += getattr(usage, "total_tokens", 0) or 0

                    # Cache metrics: try langchain input_token_details first,
                    # fall back to raw Anthropic response_metadata.usage
                    if usage and isinstance(usage, dict):
                        details = usage.get("input_token_details") or {}
                        cache_read = details.get("cache_read", 0) or 0
                        cache_create = details.get("cache_creation", 0) or 0
                    if not cache_read and not cache_create:
                        resp_meta = getattr(output, "response_metadata", None) or {}
                        raw_usage = resp_meta.get("usage", {})
                        cache_read = raw_usage.get("cache_read_input_tokens", 0) or 0
                        cache_create = raw_usage.get("cache_creation_input_tokens", 0) or 0
                    token_usage["cache_read_tokens"] += cache_read
                    token_usage["cache_creation_tokens"] += cache_create

                    # Emit per-turn metrics in debug mode
                    if debug_terminal:
                        ctx_pct = min(100.0, token_usage["total_tokens"] / 200_000 * 100)
                        _peak_ctx_pct = max(_peak_ctx_pct, ctx_pct)
                        turn_num = len(turn_metrics_list) + 1
                        tm = {
                            "turn": turn_num,
                            "input_tokens": turn_input,
                            "output_tokens": turn_output,
                            "cache_read": cache_read,
                            "cache_create": cache_create,
                            "wall_time_s": llm_wall_s,
                            "ctx_pct": ctx_pct,
                        }
                        turn_metrics_list.append(tm)
                        _safe_display(display_fn, "turn_metrics",
                            turn=turn_num, budget=turn_budget,
                            input_tokens=turn_input, output_tokens=turn_output,
                            cache_read=cache_read, cache_create=cache_create,
                            ctx_pct=ctx_pct, wall_time_s=llm_wall_s,
                        )

                        # Rate limit headers from Anthropic response
                        resp_meta = getattr(output, "response_metadata", None) or {}
                        headers = resp_meta.get("headers", {})
                        itpm_remaining = headers.get("anthropic-ratelimit-input-tokens-remaining")
                        itpm_limit = headers.get("anthropic-ratelimit-input-tokens-limit")
                        if itpm_remaining is not None:
                            _safe_display(display_fn, "rate_limit_headers",
                                remaining=int(itpm_remaining),
                                limit=int(itpm_limit) if itpm_limit else 30000,
                            )

            # Track tool execution for external counter
            if kind == "on_tool_end":
                _tool_calls_count += 1

            # Track graph state transitions (always, for diagnostics)
            if kind == "on_chain_start":
                node_name = event.get("name", "")
                if node_name in ("agent", "tools", "synthesis", "nudge", "verify", "grace", "verify_claims"):
                    _recent_node_names.append(node_name)
                    if debug_terminal:
                        _safe_display(display_fn, "graph_transition", node=node_name)

            # Capture final state from graph end
            if kind == "on_chain_end" and event.get("name") == "LangGraph":
                output = event.get("data", {}).get("output", {})
                if isinstance(output, dict):
                    final_state.update(output)

    except GraphRecursionError:
        # Defense-in-depth: should never fire if recursion_limit is derived
        # correctly. If it does, the diagnostic tells us what went wrong.
        logger.error(
            "GraphRecursionError — recursion_limit derivation may be wrong. "
            "turn_count=%d, turn_budget=%d, tool_calls=%d, "
            "mode=%s, last_nodes=%s",
            _turn_count, turn_budget, _tool_calls_count,
            mode, _recent_node_names[-10:],
        )
        _safe_display(
            display_fn, "error",
            error=f"Agent hit graph recursion limit at turn {_turn_count}/{turn_budget}. "
                  f"This is a bug — please report it.",
        )
        final_state["synthesis_reason"] = (
            f"recursion_limit (turn {_turn_count}/{turn_budget})"
        )
        final_state["error_occurred"] = True
    except Exception as e:
        logger.error("Graph execution error: %s", e)
        err_str = str(e)
        # Detect model-not-found (API 404) and give actionable guidance
        if "404" in err_str and "model" in err_str.lower():
            friendly = (
                "Model not found. Common models: claude-sonnet-4-5-20250929, "
                "claude-haiku-4-5-20251001, claude-opus-4-5-20250514\n"
                "Use --model to specify a valid model."
            )
            _safe_display(display_fn, "error", error=friendly)
        else:
            _safe_display(display_fn, "error", error=err_str)
        final_state["synthesis_reason"] = f"error: {e}"
        final_state["error_occurred"] = True

    # Reconcile external counters into final_state — these are always correct
    # regardless of whether on_chain_end fired.
    final_state["turn_count"] = _turn_count
    final_state["tool_calls_count"] = _tool_calls_count

    # Attach token usage and debug metrics to final state
    final_state["token_usage"] = token_usage
    if debug_terminal:
        final_state["turn_metrics"] = turn_metrics_list
        final_state["peak_ctx_pct"] = _peak_ctx_pct

    return final_state
