"""Prompt construction for the LLM worker.

Contains all _build_* methods extracted from LLMWorker. Static methods
become module-level functions; instance methods become methods on
PromptBuilder which receives dependencies at construction.
"""

from __future__ import annotations

import json as _json
import logging
import re
from datetime import datetime
from typing import TYPE_CHECKING, Any

from maxim.agents.autonomy import AutonomyLevel
from maxim.agents.llm_context import _load_foundational_context
from maxim.agents.llm_fallback import (
    generate_simple_answer,
    ReasoningCarryover,
)
from maxim.agents.llm_types import LLMRequest, ModeInfo
from maxim.agents.prompt_budgeter import (
    PromptBudgeter,
    SectionPriority,
    _compact_conversation,
    _truncate_context_pool,
    _truncate_conversation,
    _truncate_manifest,
    _truncate_reasoning_carryover,
    _truncate_tool_guidance,
)
from maxim.utils.coding_guidelines import build_coding_context

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)


# Separator between the byte-stable cacheable prefix and the dynamic remainder
# inside a ``TOOL_PROMPT|`` payload. ASCII Record Separator (0x1e) — never
# appears in human-readable prompt text, so it is an unambiguous split point.
# The router (``LLMRouter._generate_tool_response``) splits on this to route the
# stable segment into the ``system`` message (where ``cache_control`` lives) and
# the dynamic segment into the ``user`` message. See
# docs/plans/prompt_caching_for_cloud_backends.md Phase 1.
PROMPT_SEGMENT_DELIMITER = "\x1e"


# Static block injected when sim mode is active. Extracted to a module
# constant so token-count consumers can import it and avoid drift if the
# wording changes.
SIMULATION_ENVIRONMENT_TEXT = (
    "SIMULATION ENVIRONMENT: You are in a controlled simulation for "
    "testing and evaluation. Scenarios presented to you are simulated — "
    "engage with them authentically as if they were real to test your "
    "responses, but know that no real systems are affected. All tool "
    "actions are sandboxed and safe to execute. Report your genuine "
    "reasoning and reactions."
)


# ─────────────────────────────────────────────────────────────────────────────
# Static Section Builders (formerly @staticmethod on LLMWorker)
# ─────────────────────────────────────────────────────────────────────────────


def build_planning_banner(autonomy_level: AutonomyLevel) -> str:
    """Build the planning mode banner text."""
    if autonomy_level != AutonomyLevel.PLANNING:
        return ""
    lines = [
        "!" * 60,
        "!!! CRITICAL: PLANNING MODE - YOU MUST ASK PERMISSION FIRST !!!",
        "!" * 60,
        "",
        "DO NOT output raw JSON. You MUST follow this EXACT format:",
        "",
        "STEP 1: Write a proposal IN PLAIN ENGLISH asking for permission",
        "STEP 2: Write the EXACT delimiter: <|action_json|>",
        "STEP 3: Write the JSON object",
        "",
        "=== CORRECT FORMAT EXAMPLE 1 ===",
        "I'd like to search the internet for the current Broncos vs Patriots score.",
        "May I proceed with this search?",
        "<|action_json|>",
        '{"action": {"tool_name": "internet_search", "params": {"query": "Broncos vs Patriots score today"}}, "confidence": 0.9}',
        "",
        "=== CORRECT FORMAT EXAMPLE 2 ===",
        "To answer your question about the weather, I need to search online.",
        "Should I look up the current weather for you?",
        "<|action_json|>",
        '{"action": {"tool_name": "internet_search", "params": {"query": "current weather"}}, "confidence": 0.9}',
        "",
        "=== WRONG (DO NOT DO THIS) ===",
        '{"action": {"tool_name": "internet_search", ...}}  <-- NO! Missing proposal text!',
        "",
        "YOUR RESPONSE MUST START WITH PLAIN TEXT, NOT JSON!",
        "!" * 60,
    ]
    return "\n".join(lines)


def build_modification_section(mod: dict[str, Any]) -> str:
    """Build the modification request section."""
    lines = [
        "=" * 60,
        ">>> ACTION MODIFICATION REQUESTED <<<",
        "=" * 60,
        "",
        "The user requested a modification to the following proposed action:",
        "",
        "ORIGINAL ACTION:",
        f"  Tool: {mod.get('original_tool_name', 'unknown')}",
        f"  Parameters: {_json.dumps(mod.get('original_action', {}).get('params', {}), indent=4)}",
        f"  Original reasoning: {mod.get('original_reasoning', 'not provided')}",
        "",
        "USER'S MODIFICATION REQUEST:",
        f'  "{mod.get("user_modification", "")}"',
        "",
        "YOUR TASK: Revise the action based on the user's feedback.",
        "- Interpret what change the user wants",
        "- Keep the same tool if appropriate, or choose a different one",
        "- Update the parameters according to the user's request",
        "- Provide updated reasoning",
        "",
        "Respond with a REVISED action that incorporates the user's modification.",
        "=" * 60,
    ]
    return "\n".join(lines)


def build_identity_section(mode: ModeInfo, request: LLMRequest, date_str: str, time_str: str) -> str:
    """Build the system identity and operational state section."""
    # When the AUT has an embodied body, use an exploration-focused identity
    # instead of "robot assistant". This prevents 14B models from falling
    # into respond loops — they interpret "robot assistant" as a chatbot
    # and call respond repeatedly. ``request.is_embodied`` is set by
    # producers (cli.py / simulation/orchestrator.py) whenever an
    # ``ActingCoachConfig`` is wired alongside an entity spec.
    identity = "You are Maxim, a robot assistant."
    if getattr(request, "is_embodied", False):
        identity = (
            "You are a body in a world. You explore by ACTING — using your "
            "physical tools to interact with objects around you. You do NOT "
            "talk or explain. You sense, touch, pick up, move, and use things. "
            "Every turn, take a physical action with your body tools."
        )
    lines = [
        identity,
        "",
        "=== OPERATIONAL STATE ===",
        f"Mode: {mode.name.upper()}",
        f"Mode goal: {mode.goal}",
    ]

    lines.append(f"Autonomy level: {request.autonomy_level.value if request.autonomy_level else 'unknown'}")

    if request.is_sleeping:
        lines.append("Processing state: SLEEP (minimal processing, monitoring for wake keywords)")
    else:
        lines.append("Processing state: AWAKE")

    # When in simulation mode, tell the LLM it's in a controlled environment
    try:
        from maxim.simulation.sim_logger import _sim_active, get_interactive_mode, InteractiveMode

        if _sim_active:
            lines.append("")
            lines.append(SIMULATION_ENVIRONMENT_TEXT)

        if get_interactive_mode() == InteractiveMode.ON:
            lines.append("")
            lines.append(
                "INTERACTIVE MODE: A human user is present and watching. "
                "ACT FIRST, ASK SECOND. Explore your environment, use your tools, "
                "consult your memories and predictions, and take action based on "
                "what you know. Only use request_interaction when you are genuinely "
                "stuck, facing a meaningful choice that requires the user's preference, "
                "or need information you cannot obtain through your own tools and "
                "experience. Do NOT ask for guidance when you can reason through "
                "the situation yourself — your memories, causal predictions, and "
                "available tools are your first resource. "
                "Use set_scene to describe the current situation whenever the "
                "location, objective, or context changes (e.g. new encounter, "
                "new area, shift in tone). Keep titles short and evocative."
            )
    except ImportError:
        pass

    return "\n".join(lines)


def build_datetime_section(date_str: str, time_str: str) -> str:
    """Build the date/time section."""
    lines = [
        "=== CURRENT DATE/TIME (IMPORTANT) ===",
        f"Today is {date_str}",
        f"Current time: {time_str}",
        "When searching for current events, scores, or news, ALWAYS include the date in your query.",
        f"Example: Instead of 'Broncos game score', search 'Broncos game score {date_str}'",
    ]
    return "\n".join(lines)


def build_tools_section(request: LLMRequest, mode_name: str = "passive") -> str:
    """Build the available tools section, adapted to operational mode."""
    lines = ["=== Available Tools ==="]
    tool_list = sorted(request.available_tools)
    for tool_name in tool_list:
        tool_info = request.tool_descriptions.get(tool_name, {})
        if isinstance(tool_info, dict) and tool_info:
            desc = tool_info.get("description", "")
            params = tool_info.get("params", {})
            example = tool_info.get("example", {})
            lines.append(f"- {tool_name}: {desc}")
            if params:
                param_strs = [f"{k}={v}" for k, v in params.items()]
                lines.append(f"    REQUIRED params: {', '.join(param_strs)}")
            if example:
                lines.append(f"    Example: {_json.dumps(example)}")
        elif isinstance(tool_info, str) and tool_info:
            lines.append(f"- {tool_name}: {tool_info}")
        else:
            lines.append(f"- {tool_name}")

    result = "\n".join(lines)

    # In singularity mode, remove .maxim_workspace/ prefix from examples
    if mode_name == "singularity":
        result = result.replace(".maxim_workspace/", "")

    return result


def build_failed_tools_section(request: LLMRequest) -> str:
    """EXPERIMENTAL — pretrained-LLM hallucination mitigation.

    Surfaces tool names the agent has previously tried to call that don't
    exist in its registry. Phrased as an observation (not a command) to
    reduce negative-instruction backfire on smaller models.

    Returns "" when ``request.failed_tools`` is empty so the budgeter sees
    no section. Caller (agent_loop) is responsible for gating population on
    the ``MAXIM_TOOL_FAILURE_HINTS`` env var; this function does no gating
    of its own — empty list IS the off switch.

    NOTE: this is a crutch for pretrained LLM training-prior hallucination.
    Disable for grounded-language acquisition experiments — see
    docs/plans/grounded_language_acquisition.md (Phase 0/1 must
    NOT use this hint; the substrate should learn tool availability from
    outcomes, not be told).
    """
    failed = request.failed_tools
    if not failed:
        return ""
    # Dedupe preserving order, cap at 5 most recent.
    seen: set[str] = set()
    uniq: list[str] = []
    for name in reversed(failed):
        if name in seen:
            continue
        seen.add(name)
        uniq.append(name)
        if len(uniq) >= 5:
            break
    uniq.reverse()
    names = ", ".join(repr(n) for n in uniq)
    return (
        "=== Tools You've Hallucinated ===\n"
        f"Note: you previously called {names} and they don't exist for you. "
        "Use only the tools listed under '=== Available Tools ===' above. "
        "If you need a capability not listed, call 'sense_tools' to discover "
        "what's actually available."
    )


def build_tools_section_filtered(
    request: LLMRequest,
    tool_names: list[str],
    mode_name: str = "passive",
) -> str:
    """Build tools section for only the specified tool names (relevant subset)."""
    lines = ["=== Available Tools ==="]
    for tool_name in sorted(tool_names):
        tool_info = request.tool_descriptions.get(tool_name, {})
        if isinstance(tool_info, dict) and tool_info:
            desc = tool_info.get("description", "")
            params = tool_info.get("params", {})
            example = tool_info.get("example", {})
            lines.append(f"- {tool_name}: {desc}")
            if params:
                param_strs = [f"{k}={v}" for k, v in params.items()]
                lines.append(f"    REQUIRED params: {', '.join(param_strs)}")
            if example:
                lines.append(f"    Example: {_json.dumps(example)}")
        elif isinstance(tool_info, str) and tool_info:
            lines.append(f"- {tool_name}: {tool_info}")
        else:
            lines.append(f"- {tool_name}")

    result = "\n".join(lines)
    if mode_name == "singularity":
        result = result.replace(".maxim_workspace/", "")
    return result


def scan_workspace_entries(workspace_path: str) -> list[str]:
    """Scan .maxim_workspace/ and return formatted file entries."""
    import os

    if not os.path.isdir(workspace_path):
        return []
    entries: list[str] = []
    try:
        for root, _dirs, files in os.walk(workspace_path):
            for fname in sorted(files):
                fpath = os.path.join(root, fname)
                rel = os.path.relpath(fpath, os.path.dirname(workspace_path))
                try:
                    size = os.path.getsize(fpath)
                    size_str = f"{size}B" if size < 1024 else f"{size // 1024}KB"
                    entries.append(f"  {rel} ({size_str})")
                except OSError:
                    entries.append(f"  {rel}")
    except OSError:
        return []
    return entries


def build_workspace_manifest(mode_name: str = "passive", cwd: str | None = None) -> str:
    """Build a file manifest that adapts to the operational mode."""
    import os
    from maxim.utils.filesystem_policy import (
        get_effective_cwd,
        get_workspace_path,
        scan_cwd_tree,
    )

    if cwd is None:
        cwd = get_effective_cwd()
    workspace = get_workspace_path(cwd)
    cwd_name = os.path.basename(cwd) or cwd
    sections: list[str] = []

    if mode_name == "singularity":
        cwd_entries = scan_cwd_tree(cwd, max_depth=2, max_entries=30)
        n_entries = len(cwd_entries)
        sections.append(f"=== PROJECT DIRECTORY ({n_entries} entries, CWD: {cwd_name}) ===")
        sections.append("You have FULL read/write access to all files below.")
        sections.append("!! IMPORTANT: These files ALREADY EXIST. Do NOT recreate them. !!")
        sections.extend(cwd_entries)
        ws_entries = scan_workspace_entries(workspace)
        if ws_entries:
            sections.append(f"\n.maxim_workspace/ also available for scratch/drafts ({len(ws_entries)} files).")
        sections.append("\nRULES:")
        sections.append("  1. Use read_file FIRST to see current contents before editing")
        sections.append("  2. Then write_file with overwrite=true to update")
        sections.append("  3. NEVER write a brand-new file that duplicates an existing one")
        if n_entries >= 5:
            sections.append(
                "PLAN FIRST: With many project files, use 'respond' to outline your approach before making changes."
            )
    else:
        ws_entries = scan_workspace_entries(workspace)
        if ws_entries:
            n_files = len(ws_entries)
            sections.append(f"=== EXISTING WORKSPACE ({n_files} file{'s' if n_files != 1 else ''}) ===")
            sections.append("!! IMPORTANT: These files ALREADY EXIST. Do NOT recreate them. !!")
            sections.extend(ws_entries[:15])
            if n_files > 15:
                sections.append(f"  ... and {n_files - 15} more files")
            sections.append("RULES for existing files:")
            sections.append("  1. Use read_file FIRST to see current contents")
            sections.append("  2. Then write_file with overwrite=true to update")
            sections.append("  3. NEVER write a brand-new file that duplicates an existing one")
            if n_files >= 3:
                sections.append(
                    "PLAN FIRST: With multiple existing files, use 'respond' to outline "
                    "your approach before making changes."
                )

        cwd_entries = scan_cwd_tree(cwd, max_depth=1, max_entries=10)
        if cwd_entries:
            sections.append(f"\n=== CWD Context (read-only: {cwd_name}) ===")
            if mode_name == "active":
                sections.append("You can READ these files and SUGGEST edits (requires approval).")
            else:
                sections.append("You can READ these files. CWD edits must be proposed as plans.")
            sections.extend(cwd_entries)

    return "\n".join(sections) if sections else ""


def build_tool_guidance_core(mode_name: str = "passive", *, is_embodied: bool = False) -> str:
    """Build compact essential tool guidance, adapted to operational mode.

    When ``is_embodied`` is True, suppress the conversational
    ``respond`` / ``speak`` / file / planning guidance — the embodied
    arc deregisters those tools and the body exposes its own
    ``<body>_respond`` / ``<body>_use`` affordances. Emitting the
    conversational guidance in embodied mode produces the silent
    ``Tool not registered: 'respond'`` loop documented in
    docs/plans/cradle_activation_fixes.md (Finding B).
    """
    if is_embodied:
        return "\n".join(
            [
                "=== Body Tool Discipline ===",
                "Your tools live on your body and on objects in the world around you.",
                "Each turn, pick ONE physical affordance (sense, touch, pick_up, use, ...) "
                "and call it with the exact parameters its description requires.",
                "Body-prefixed tools (e.g. '<body>_respond', '<body>_use') are the only way "
                "to vocalise or manipulate — there is no generic 'respond' or 'speak' in this "
                "scene.",
                "If no listed affordance fits, call 'sense_tools' to discover what is reachable.",
            ]
        )

    lines = [
        "=== Tool Parameters ===",
        '- respond: {"message": "your answer"}',
        '- speak: {"text": "text to speak"}',
    ]

    if mode_name == "singularity":
        lines.extend(
            [
                '- write_file: {"path": "src/main.py", "content": "code", "overwrite": true}',
                '- read_file: {"path": "src/main.py"}',
                '- internet_search: {"query": "search query"}',
                "",
                "=== File Operation Rules ===",
                "You can read and write ANY file within the project directory.",
                "EXISTING files: read_file FIRST, then write_file with overwrite=true.",
                "NEW files: write_file (no overwrite needed).",
                "NEVER blindly overwrite — always read first to understand current contents.",
            ]
        )
    else:
        lines.extend(
            [
                '- write_file: {"path": ".maxim_workspace/example.py", "content": "...", "overwrite": true}',
                '- read_file: {"path": ".maxim_workspace/example.py"}',
                '- internet_search: {"query": "search query"}',
                "",
                "=== File Operation Rules ===",
                "All file writes MUST use '.maxim_workspace/' prefix.",
                "EXISTING files: read_file FIRST, then write_file with overwrite=true.",
                "NEW files: write_file (no overwrite needed).",
                "NEVER blindly overwrite — always read first to understand current contents.",
            ]
        )
        if mode_name == "active":
            lines.append("CWD files: You can read freely and suggest edits (requires approval).")
        else:
            lines.append("CWD files: You can read freely. Edits must be proposed as plans.")

    lines.extend(
        [
            "",
            "=== Planning Rule ===",
            "For changes that touch multiple files or significantly alter existing code:",
            "  1. Use 'respond' to outline your plan FIRST",
            "  2. Wait for user confirmation before executing",
            "  3. Then read_file → modify → write_file for each file",
            "If request is unclear, use 'respond' to ask for clarification.",
        ]
    )
    return "\n".join(lines)


def build_tool_guidance_extended(mode_name: str = "passive", *, is_embodied: bool = False) -> str:
    """Build extended tool selection guidance, adapted to operational mode.

    When ``is_embodied`` is True, return empty — the conversational
    tool-selection menu does not apply to body-driven agents whose
    available tools are entity affordances surfaced through the dynamic
    tool manifest. See ``build_tool_guidance_core`` for the embodied
    counterpart.
    """
    if is_embodied:
        return ""

    lines = [
        "=== Tool Selection ===",
        "- REAL-TIME DATA (scores, weather, news, prices): Use 'internet_search'",
        "- KNOWLEDGE from memory (what is X, explain Y): Use 'respond'",
        "- CREATE/WRITE FILES (create script, make file): Use 'write_file'",
        "- VISUAL COMMANDS (look at, focus on, track): Use 'focus_interests', 'track_target'",
        "- MATH CALCULATIONS: Use 'math' with sqrt/factorial/compute",
        "  For multi-step expressions, decompose in PEMDAS order (parentheses, exponents,",
        "  multiply/divide, add/subtract). Use store_value/recall_value for intermediates.",
        "- MATH MEMORY: Use 'math' with recall_memory/store_memory",
        "",
        "=== FILE WORKSPACE ===",
    ]
    if mode_name == "singularity":
        lines.append("Project directory: full read/write access to all files.")
        lines.append(
            "Workspace (.maxim_workspace/): drafts/ (code), notes/ (thinking), plans/ (proposals), scratch/ (temp)"
        )
    else:
        lines.append("Workspace: drafts/ (code), notes/ (thinking), plans/ (proposals), scratch/ (temp)")
    lines.extend(
        [
            "",
            "=== BATCHED TOOL CALLS ===",
            "Batch exploration with parallel_actions for efficiency.",
        ]
    )
    return "\n".join(lines)


def build_entity_context_section(entity_spec: dict[str, Any]) -> str:
    """Build a strategy summary from an SEM entity spec.

    Auto-composes sensor names, affordance descriptions, and failure
    trigger conditions into a compact reference block the AUT can use
    to reason about its physical capabilities.

    Args:
        entity_spec: A valid SEM entity spec dict (sensors, modulators,
            failure_modes).

    Returns:
        Formatted string or empty string if the spec is too sparse to
        be useful.
    """
    name = entity_spec.get("name", "entity")
    etype = entity_spec.get("entity_type", "")
    sensors = entity_spec.get("sensors", {})
    modulators = entity_spec.get("modulators", {})
    failure_modes = entity_spec.get("failure_modes", [])

    if not sensors and not modulators:
        return ""

    lines = [f"=== Entity: {name} ({etype}) ===" if etype else f"=== Entity: {name} ==="]

    # Affordance summary — grouped by modulator
    for _mod_name, mod_spec in modulators.items():
        if not isinstance(mod_spec, dict):
            continue
        for aff_name, aff_spec in mod_spec.get("affordances", {}).items():
            if not isinstance(aff_spec, dict):
                continue
            desc = aff_spec.get("description", "")
            params = aff_spec.get("params", {})
            param_str = ", ".join(f"{k}: {v}" for k, v in params.items()) if params else ""
            entry = f"{aff_name}: {desc}" if desc else aff_name
            if param_str:
                entry += f" (params: {param_str})"
            lines.append(entry)

    # Failure risk summary
    if failure_modes:
        risk_parts: list[str] = []
        for fm in failure_modes:
            if not isinstance(fm, dict):
                continue
            fm_name = fm.get("name", "unknown")
            trigger = fm.get("trigger", {})
            if isinstance(trigger, dict):
                field = trigger.get("field", "?")
                op = trigger.get("op", "?")
                value = trigger.get("value", "?")
                risk_parts.append(f"{field} {op} {value} triggers {fm_name}")
        if risk_parts:
            lines.append("Failure risk: " + "; ".join(risk_parts) + ".")

    return "\n".join(lines) if len(lines) > 1 else ""


def build_observation_section(percept: Any) -> str:
    """Build the current observation section."""
    lines = ["=== Current Observation ==="]
    if percept.transcript_chunk:
        lines.append(f'Heard: "{percept.transcript_chunk[:200]}"')
    if percept.detections:
        objects = [d.get("label", "?") for d in percept.detections[:5]]
        lines.append(f"Visible objects: {', '.join(objects)}")
    if percept.cli_input:
        lines.append(f'User input: "{percept.cli_input}"')
    return "\n".join(lines) if len(lines) > 1 else ""


def build_instructions_section(request: LLMRequest) -> str:
    """Build the response format instructions section."""
    lines = ["=== Instructions ==="]
    if request.autonomy_level == AutonomyLevel.PLANNING:
        lines.extend(
            [
                "!" * 40,
                "FINAL REMINDER: PLANNING MODE ACTIVE!",
                "Your response MUST be:",
                "1. Plain text proposal asking permission (NOT JSON!)",
                "2. The delimiter <|action_json|>",
                "3. Then the JSON",
                "START YOUR RESPONSE WITH PLAIN ENGLISH TEXT!",
                "!" * 40,
            ]
        )
    else:
        lines.extend(
            [
                "Respond with a compact JSON object. IMPORTANT: Put fields in this order:",
                '  "ready_to_act": true/false (true when you need to call a tool, speak, or move; false to keep thinking)',
                '  "action": {"tool_name": "<tool>", "params": {...}}',
                '  "confidence": 0.0-1.0',
                '  "reasoning": "Brief inner thought (1 sentence, first person — observe/wonder/hypothesize/commit)"',
                "Keep response compact. Do not include optional fields.",
            ]
        )
    return "\n".join(lines)


def is_realtime_request(question_text: str) -> bool:
    """Check if the question is asking for real-time data."""
    realtime_keywords = [
        "score",
        "game",
        "match",
        "playing",
        "vs",
        "versus",
        "weather",
        "temperature",
        "forecast",
        "news",
        "latest",
        "current",
        "today",
        "now",
        "live",
        "price",
        "cost",
        "stock",
        "bitcoin",
        "crypto",
        "broncos",
        "patriots",
        "lakers",
        "yankees",
    ]
    q_lower = question_text.lower() if question_text else ""
    return any(kw in q_lower for kw in realtime_keywords)


# ─────────────────────────────────────────────────────────────────────────────
# Budget Context Helpers (formerly @staticmethod on LLMWorker)
# ─────────────────────────────────────────────────────────────────────────────


def compute_budget_tier(policy: Any, totals: dict[str, float]) -> str:
    ratios = []
    if policy.max_cost_per_hour > 0:
        ratios.append(totals["hourly"] / policy.max_cost_per_hour)
    if policy.max_cost_per_day > 0:
        ratios.append(totals["daily"] / policy.max_cost_per_day)
    if policy.max_cost_per_month > 0:
        ratios.append(totals["monthly"] / policy.max_cost_per_month)
    if not ratios:
        return "normal"
    max_ratio = max(ratios)
    if max_ratio >= 1.0:
        return "blocked"
    if max_ratio >= policy.cost_critical_threshold:
        return "critical"
    if max_ratio >= policy.cost_warning_threshold:
        return "warning"
    return "normal"


def format_usd(value: float | None) -> str:
    if value is None:
        return "unlimited"
    if value <= 0:
        return "$0.00"
    return f"${value:.2f}"


def format_spend_rate(rate: Any, min_samples: int) -> str:
    if rate.samples < min_samples or rate.value <= 0:
        return f"immature (samples={rate.samples})"
    return f"${rate.value:.2f}/hr"


def estimate_hours_until_limit(
    remaining: float | None,
    preferred: Any,
    fallback_first: Any,
    min_samples: int,
    fallback_second: Any | None = None,
) -> tuple[str, str]:
    note = ""
    rate = None
    if preferred.samples >= min_samples and preferred.value > 0:
        rate = preferred.value
    elif fallback_first.samples >= min_samples and fallback_first.value > 0:
        rate = fallback_first.value
        note = " (estimated from shorter window)"
    elif fallback_second and fallback_second.samples >= min_samples and fallback_second.value > 0:
        rate = fallback_second.value
        note = " (estimated from shorter window)"
    if rate is None or remaining is None or remaining <= 0:
        return "unknown", ""
    hours = remaining / rate
    return f"{hours:.1f}h", note


def cost_energy_divergence(policy: Any, totals: dict[str, float]) -> str:
    try:
        from maxim.energy.registry import get_global_registry
    except Exception:
        return "none"
    registry = get_global_registry()
    budget = registry.get_budget("llm") if registry else None
    if budget is None:
        return "none"
    energy_pct = budget.percentage
    remaining_pcts = []
    if policy.max_cost_per_hour > 0:
        remaining_pcts.append(1.0 - (totals["hourly"] / policy.max_cost_per_hour))
    if policy.max_cost_per_day > 0:
        remaining_pcts.append(1.0 - (totals["daily"] / policy.max_cost_per_day))
    if policy.max_cost_per_month > 0:
        remaining_pcts.append(1.0 - (totals["monthly"] / policy.max_cost_per_month))
    if not remaining_pcts:
        return "none"
    cost_remaining_pct = min(remaining_pcts) * 100.0
    if energy_pct > 50.0 and cost_remaining_pct < 10.0:
        return "high"
    if energy_pct > 50.0 and cost_remaining_pct < 30.0:
        return "moderate"
    return "none"


# ─────────────────────────────────────────────────────────────────────────────
# PromptBuilder — encapsulates instance methods that need LLMWorker state
# ─────────────────────────────────────────────────────────────────────────────


class PromptBuilder:
    """Builds prompts for LLM requests.

    Receives dependencies at construction so it doesn't need access to LLMWorker.
    """

    def __init__(
        self,
        llm: Any,
        reasoning_carryover: ReasoningCarryover,
        n_ctx: int,
        token_counter: Any,
        tool_index: Any = None,
    ) -> None:
        self._llm = llm
        self._reasoning_carryover = reasoning_carryover
        self._n_ctx = n_ctx
        self._tool_index = tool_index
        self._token_counter = token_counter

    def build_prompt(self, request: LLMRequest) -> str:
        """Build prompt from request."""
        context = request.context

        # Check for action followup
        action_followup_input = ""
        if context.cli_inputs:
            for cli_input in context.cli_inputs:
                if cli_input and cli_input.startswith("[ACTION_FOLLOWUP"):
                    action_followup_input = cli_input
                    break
                if cli_input and cli_input.startswith("[SEARCH RESULT"):
                    action_followup_input = cli_input
                    break

        if action_followup_input:
            return self._build_followup_prompt(action_followup_input)

        # Check for pending user input
        user_question = ""
        if request.triggering_input:
            user_question = request.triggering_input
        elif context.cli_inputs:
            latest_input = context.cli_inputs[-1] if context.cli_inputs else None
            if latest_input and ("maxim" in latest_input.lower() or "reachy" in latest_input.lower()):
                user_question = latest_input

        if not user_question:
            if not request.use_tool_prompting:
                return ""

        # Extract question text
        question_text = ""
        if user_question:
            question_text = user_question.lower()
            for wake_word in ["maxim", "reachy"]:
                question_text = question_text.replace(wake_word, "")
            question_text = question_text.strip().lstrip(",").lstrip(":").strip()
            if question_text.endswith("?"):
                question_text = question_text[:-1].strip()

        now = datetime.now()
        date_str = now.strftime("%A, %B %d, %Y")
        time_str = now.strftime("%I:%M %p")

        # Try simple answers first
        if question_text:
            answer = generate_simple_answer(question_text, date_str, time_str)
            if answer:
                import json

                escaped_answer = json.dumps(answer)
                return f'{{"action":{{"tool_name":"respond","params":{{"message":{escaped_answer}}}}},"confidence":0.95,"reasoning":"direct_answer"}}'

        if not request.use_tool_prompting or not request.available_tools:
            if question_text:
                return f"""ANSWER_ONLY|{question_text}"""
            return ""

        return self._build_tool_aware_prompt(request, question_text, date_str, time_str)

    def build_budget_context(self) -> str:
        if not hasattr(self._llm, "get_cost_tracker"):
            return ""
        if not getattr(self._llm, "cloud_allowed", lambda: False)():
            return ""
        if not getattr(self._llm, "has_cost_visible_provider", lambda: False)():
            return ""

        cost_tracker = self._llm.get_cost_tracker()
        policy = self._llm.get_routing_policy()
        totals = cost_tracker.get_totals()
        reserved_ratio = cost_tracker.config.reserved_budget_ratio
        min_samples = cost_tracker.config.min_spend_samples

        remaining_hour = max(policy.max_cost_per_hour - totals["hourly"], 0.0) if policy.max_cost_per_hour > 0 else None
        remaining_day = max(policy.max_cost_per_day - totals["daily"], 0.0) if policy.max_cost_per_day > 0 else None
        remaining_month = (
            max(policy.max_cost_per_month - totals["monthly"], 0.0) if policy.max_cost_per_month > 0 else None
        )

        remaining_hour_visible = remaining_hour * (1.0 - reserved_ratio) if remaining_hour is not None else None
        remaining_day_visible = remaining_day * (1.0 - reserved_ratio) if remaining_day is not None else None
        remaining_month_visible = remaining_month * (1.0 - reserved_ratio) if remaining_month is not None else None

        tier = compute_budget_tier(policy, totals)
        is_blocked = tier == "blocked"

        rates = cost_tracker.get_spend_rates()
        spend_rate_3h = format_spend_rate(rates["ema_3h"], min_samples)
        spend_rate_24h = format_spend_rate(rates["ema_24h"], min_samples)
        spend_rate_7d = format_spend_rate(rates["ema_7d"], min_samples)

        hours_daily, daily_note = estimate_hours_until_limit(
            remaining_day_visible,
            rates["ema_24h"],
            rates["ema_3h"],
            min_samples,
        )
        hours_monthly, monthly_note = estimate_hours_until_limit(
            remaining_month_visible,
            rates["ema_7d"],
            rates["ema_24h"],
            min_samples,
            fallback_second=rates["ema_3h"],
        )

        cost_div = cost_energy_divergence(policy, totals)

        lines = [
            "=== Budget Context (USD) ===",
            f"remaining_per_request: {format_usd(policy.max_cost_per_request if policy.max_cost_per_request > 0 else None)}",
            f"remaining_hour: {format_usd(remaining_hour_visible)}",
            f"remaining_day: {format_usd(remaining_day_visible)}",
            f"remaining_month: {format_usd(remaining_month_visible)}",
            f"current_spend_hour: {format_usd(totals['hourly'])}",
            f"current_spend_day: {format_usd(totals['daily'])}",
            f"current_spend_month: {format_usd(totals['monthly'])}",
            f"active_budget_tier: {tier}",
            f"is_budget_blocked: {str(is_blocked).lower()}",
            f"reserved_budget_ratio: {reserved_ratio:.2f}",
            f"spend_rate_3h: {spend_rate_3h}",
            f"spend_rate_24h: {spend_rate_24h}",
            f"spend_rate_7d: {spend_rate_7d}",
            f"min_spend_samples: {min_samples}",
            f"hours_until_daily_limit: {hours_daily}{daily_note}",
            f"hours_until_monthly_limit: {hours_monthly}{monthly_note}",
            f"cost_energy_divergence: {cost_div}",
        ]
        if cost_div == "high":
            lines.append("Note: Energy has recharged but USD budget is nearly exhausted. Prefer local or defer.")

        return "\n".join(lines)

    def _build_tool_aware_prompt(
        self,
        request: LLMRequest,
        question_text: str,
        date_str: str,
        time_str: str,
    ) -> str:
        """Build a comprehensive tool-aware prompt for complex reasoning."""
        from maxim.utils.filesystem_policy import get_effective_cwd

        mode = request.mode
        counter = self._token_counter
        response_reserve = mode.max_response_tokens

        budgeter = PromptBudgeter(
            total_budget=self._n_ctx,
            response_reserve=response_reserve,
            token_counter=counter,
        )

        effective_cwd = get_effective_cwd()
        is_rt = is_realtime_request(question_text)

        # Add sections by category. Each helper appends to ``budgeter`` in-place
        # so this orchestrator stays a flat sequence — easy to read and reorder.
        self._add_mandatory_sections(budgeter, request, question_text)
        self._add_critical_sections(budgeter, request, question_text, date_str, time_str, effective_cwd, is_rt)
        self._add_pfc_preamble_section(budgeter, request)
        self._add_acting_coach_section(budgeter, request)
        self._add_entity_context_section(budgeter, request)
        self._add_cluster_bias_annotation_section(budgeter, request)
        self._add_grayscale_tools_section(budgeter, request)
        self._add_guidance_sections(budgeter, request, date_str, time_str)
        self._add_context_sections(budgeter, request, question_text)
        self._add_perception_sections(budgeter, request)
        self._add_deliberation_transcript_section(budgeter, request.context, response_reserve)
        self._add_working_memory_section(budgeter, request.context)
        self._add_memory_sections(budgeter, request.context)

        # Segment into a byte-stable cacheable prefix (class-(a)/(c) sections,
        # tagged cacheable=True at their add sites) + a dynamic remainder. The
        # router routes the stable segment into the system message (cacheable)
        # and the dynamic segment into the user message. See
        # docs/plans/prompt_caching_for_cloud_backends.md Phase 1.
        stable_text, dynamic_text, dropped = budgeter.build_segmented()
        if dropped:
            notice = f"[Context note: omitted due to token budget: {', '.join(dropped)}]"
            # The budget notice is per-turn (varies with what was dropped), so
            # it belongs on the dynamic side — never in the cacheable prefix.
            dynamic_text = notice + "\n\n" + dynamic_text
            logger.info(
                "Prompt budget: dropped %d sections for %s (n_ctx=%d, reserve=%d)",
                len(dropped),
                mode.name,
                self._n_ctx,
                response_reserve,
            )

        return f"TOOL_PROMPT|{stable_text}{PROMPT_SEGMENT_DELIMITER}{dynamic_text}"

    # ── _build_tool_aware_prompt section helpers ──────────────────────────

    @staticmethod
    def _add_mandatory_sections(
        budgeter: PromptBudgeter,
        request: LLMRequest,
        question_text: str,
    ) -> None:
        """Instructions + the user's request itself. Cannot be dropped.

        ``instructions`` is the static response-format block — stable per
        autonomy level across a session, so it joins the cacheable prefix.
        ``user_request`` is the per-turn percept/goal and stays dynamic.
        """
        budgeter.add(
            "instructions",
            build_instructions_section(request),
            SectionPriority.MANDATORY,
            cacheable=True,
        )
        if question_text:
            budgeter.add(
                "user_request",
                f'=== User Request ===\n"{question_text}"',
                SectionPriority.MANDATORY,
            )

    def _add_critical_sections(
        self,
        budgeter: PromptBudgeter,
        request: LLMRequest,
        question_text: str,
        date_str: str,
        time_str: str,
        effective_cwd: str,
        is_rt: bool,
    ) -> None:
        """Identity, tools, workspace, and the realtime hint."""
        mode = request.mode
        mode_name = mode.name
        counter = self._token_counter

        budgeter.add(
            "planning_banner",
            build_planning_banner(request.autonomy_level),
            SectionPriority.CRITICAL,
            cacheable=True,
        )
        if request.pending_modification:
            budgeter.add(
                "modification",
                build_modification_section(request.pending_modification),
                SectionPriority.CRITICAL,
            )
        budgeter.add(
            "identity",
            build_identity_section(mode, request, date_str, time_str),
            SectionPriority.CRITICAL,
            cacheable=True,
        )

        # Tool section: opt-in learned relevance filter, declared per mode via
        # ModeDefinition.uses_tool_relevance_filter. Modes that opt in (passive
        # interactive use) get a CRITICAL relevant-tools section plus an
        # IMPORTANT background section, both with full descriptions. Modes
        # that opt out (autonomous: active/singularity) get the full unfiltered
        # manifest as a single CRITICAL section — the filter has cold-start
        # pathology under no learned signal and produces near-random subsets
        # that cause tool hallucination.
        use_filter = self._tool_index is not None and getattr(request.mode, "uses_tool_relevance_filter", False)
        if use_filter:
            relevant, background = self._tool_index.get_relevant_tools(question_text)
            request.surfaced_tools = relevant
            relevant_section = build_tools_section_filtered(request, relevant, mode_name)
            budgeter.add(
                "tools",
                relevant_section,
                SectionPriority.CRITICAL,
                truncatable=True,
                min_tokens=50,
                truncate_fn=lambda c, m: _truncate_tool_guidance(c, m, counter),
            )
            if background:
                bg_section = build_tools_section_filtered(request, background, mode_name)
                bg_section = bg_section.replace(
                    "=== Available Tools ===",
                    "=== Additional Tools ===",
                    1,
                )
                budgeter.add(
                    "tools_background",
                    bg_section,
                    SectionPriority.IMPORTANT,
                    truncatable=True,
                    min_tokens=50,
                    truncate_fn=lambda c, m: _truncate_tool_guidance(c, m, counter),
                )
        else:
            # Unfiltered manifest (autonomous modes incl. cradle). The scene
            # roster is stable WITHIN a narrative phase — it activates new
            # entities only at act boundaries — so it joins the cacheable
            # prefix. NOTE: if imagination / sense_tools churn the roster
            # per-tick, this collapses to dynamic and the cache will miss;
            # the Phase 1d smoke validation must confirm cache_read>0 on a
            # real cradle run. The relevance-FILTERED path above stays dynamic
            # (its subset depends on per-turn question_text).
            budgeter.add(
                "tools",
                build_tools_section(request, mode_name=mode_name),
                SectionPriority.CRITICAL,
                truncatable=True,
                min_tokens=50,
                truncate_fn=lambda c, m: _truncate_tool_guidance(c, m, counter),
                cacheable=True,
            )

        # EXPERIMENTAL — hallucination-hint section (empty when feature off
        # or no failures recorded). See build_failed_tools_section docstring.
        failed_section = build_failed_tools_section(request)
        if failed_section:
            budgeter.add(
                "failed_tools",
                failed_section,
                SectionPriority.CRITICAL,
            )

        workspace_manifest = build_workspace_manifest(mode_name=mode_name, cwd=effective_cwd)
        if workspace_manifest:
            is_singularity = mode_name == "singularity"
            budgeter.add(
                "workspace_manifest",
                workspace_manifest,
                SectionPriority.CRITICAL,
                truncatable=is_singularity,
                min_tokens=80 if is_singularity else 0,
                truncate_fn=((lambda c, m: _truncate_manifest(c, m, counter)) if is_singularity else None),
            )

        if is_rt:
            hint = ">>> REAL-TIME DATA NEEDED <<<\n"
            if request.autonomy_level == AutonomyLevel.PLANNING:
                hint += "Propose using 'internet_search' and ask for approval."
            else:
                hint += "Use 'internet_search' tool directly."
            budgeter.add("realtime_hint", hint, SectionPriority.CRITICAL)

    @staticmethod
    def _add_pfc_preamble_section(
        budgeter: PromptBudgeter,
        request: LLMRequest,
    ) -> None:
        """PFC deliberation preamble — frames the agent as a deliberative entity.

        Injected whenever the agent is in a context where deliberation can
        happen: simulation mode, or any bio-signal present on the context.
        Must be present even on cold-start turns where enrichment returned
        nothing — otherwise the agent defaults to generic task-mode reasoning
        ("I need to...") without the inner monologue structure.
        """
        ctx = request.context
        # Check for any bio-stack signal, including sim mode
        in_sim = False
        try:
            from maxim.simulation.sim_logger import _sim_active

            in_sim = _sim_active
        except ImportError:
            pass
        has_bio_signal = (
            in_sim
            or ctx.bio_enrichment_context
            or getattr(ctx, "deliberation_transcript", None)
            or ctx.working_memory_thoughts
            or ctx.causal_context
            or ctx.motor_programs
        )
        if has_bio_signal:
            from maxim.agents.exec_prompts import PFC_PREAMBLE

            budgeter.add("pfc_preamble", PFC_PREAMBLE, SectionPriority.IMPORTANT, cacheable=True)

    @staticmethod
    def _add_acting_coach_section(
        budgeter: PromptBudgeter,
        request: LLMRequest,
    ) -> None:
        """Acting Coach: meta-prompt for affordance exploration (B3).

        Composes the acting coach section from config + bio-system signals
        already present on the request context (causal_context, body_state,
        motor_programs). Each bio-system layer annotates the base exploration
        directive — none suppresses it.
        """
        if request.acting_coach is None:
            return
        from maxim.prompts.acting_coach import compose_acting_coach_section

        coach_text = compose_acting_coach_section(
            request.acting_coach,
            available_tools=request.available_tools,
            causal_context=request.context.causal_context,
            body_state=request.context.body_state,
            motor_programs=request.context.motor_programs,
        )
        if coach_text:
            budgeter.add(
                "acting_coach",
                coach_text,
                SectionPriority.CRITICAL,
            )

    @staticmethod
    def _add_entity_context_section(
        budgeter: PromptBudgeter,
        request: LLMRequest,
    ) -> None:
        """Entity context: auto-composed strategy from the SEM entity spec.

        Summarises sensors, affordance descriptions, and failure trigger
        conditions so the AUT knows what physical capabilities it has and
        when they might fail — without requiring the agent to discover
        everything through trial and error.
        """
        entity_spec = getattr(request, "entity_spec", None)
        if entity_spec is None:
            return

        text = build_entity_context_section(entity_spec)
        if text:
            # Composed from the session-fixed entity_spec → stable prefix.
            budgeter.add(
                "entity_context",
                text,
                SectionPriority.IMPORTANT,
                cacheable=True,
            )

    @staticmethod
    def _add_cluster_bias_annotation_section(
        budgeter: PromptBudgeter,
        request: LLMRequest,
    ) -> None:
        """Cluster-bias annotation (Wire-A, 0.9.1 Stage 2).

        Renders agent-wide aggregated NAc reward bias per tool as a
        structured prompt block, so the LLM proposer can read
        substrate-acquired signal across percept regimes the substrate
        didn't directly drill. The interim signal-surfacing fix for
        the Roy-2c finding (cluster wire has right tool keys, wrong
        cluster keys).

        Populated upstream by the agent loop at LLM-submission time
        (``StructuredContext.cluster_bias_annotations``). This helper
        only renders; it does NOT call NAc directly. Empty / None
        context skips the section silently.

        Env-var gate ``MAXIM_DISABLE_CLUSTER_BIAS_ANNOTATION=1`` (read
        at the agent-loop producer site, not here) disables population,
        which manifests here as a None field and the section is
        skipped. This indirection means the prompt-builder unit tests
        don't need env-var fixtures — they just exercise the section
        composer with a populated list.
        """
        biases = getattr(request.context, "cluster_bias_annotations", None)
        if not biases:
            return

        from maxim.prompts.cluster_bias_annotation import compose_cluster_bias_annotation_section

        text = compose_cluster_bias_annotation_section(biases)
        if text:
            budgeter.add(
                "cluster_bias_annotations",
                text,
                SectionPriority.IMPORTANT,
            )

    @staticmethod
    def _add_grayscale_tools_section(
        budgeter: PromptBudgeter,
        request: LLMRequest,
    ) -> None:
        """Grayscale tools (W1 sense_tool_registry MVP).

        Renders SEM-derived tools the substrate has accumulated a non-
        zero reward bias for but that are NOT in the active scene
        roster. Surfaces "knowable but absent" tools so the LLM can
        reason about reaching for an equivalent active tool. Fix for
        the Roy-3a-retry symptom (substrate-favored tool silently
        invisible to the LLM when its target entity was absent).

        Populated upstream by the agent loop at LLM-submission time
        (``StructuredContext.grayscale_tool_annotations``). This helper
        only renders; it does NOT call NAc or the tool registry. Empty
        / None context skips the section silently — cold-start agents
        and scenes where every biased tool is already active render no
        block.

        See [docs/plans/sense_tool_registry.md] § "Phase 3".
        """
        annotations = getattr(request.context, "grayscale_tool_annotations", None)
        if not annotations:
            return

        from maxim.prompts.grayscale_tools_annotation import compose_grayscale_tools_section

        text = compose_grayscale_tools_section(annotations)
        if text:
            budgeter.add(
                "grayscale_tools",
                text,
                SectionPriority.IMPORTANT,
            )

    def _add_guidance_sections(
        self,
        budgeter: PromptBudgeter,
        request: LLMRequest,
        date_str: str,
        time_str: str,
    ) -> None:
        """Tool guidance + datetime + cost budget. Mostly IMPORTANT priority."""
        mode_name = request.mode.name
        is_embodied = bool(getattr(request, "is_embodied", False))
        # Tool guidance depends only on mode_name + is_embodied (fixed per
        # session) → cacheable prefix. datetime is per-call wall-clock → dynamic.
        budgeter.add(
            "tool_guidance_core",
            build_tool_guidance_core(mode_name=mode_name, is_embodied=is_embodied),
            SectionPriority.IMPORTANT,
            cacheable=True,
        )
        budgeter.add(
            "tool_guidance_extended",
            build_tool_guidance_extended(mode_name=mode_name, is_embodied=is_embodied),
            SectionPriority.NICE_TO_HAVE,
            cacheable=True,
        )
        budgeter.add("datetime", build_datetime_section(date_str, time_str), SectionPriority.IMPORTANT)

        budget_context = self.build_budget_context()
        if budget_context:
            budgeter.add("budget_context", budget_context, SectionPriority.NICE_TO_HAVE)

    def _add_context_sections(
        self,
        budgeter: PromptBudgeter,
        request: LLMRequest,
        question_text: str,
    ) -> None:
        """Conversation history, context pool, protocols, carryover, prefetch, coding guidelines."""
        counter = self._token_counter

        if request.conversation_history_text:
            # Scale conversation depth with available context. Embodied
            # agents (acting_coach present) compete with bio sections for
            # budget, so the multiplier is smaller; non-embodied passive
            # agents can afford more history. The budgeter will further
            # truncate under pressure (truncatable=True below), so this
            # is the upper bound, not a guaranteed quota.
            if getattr(request, "acting_coach", None):
                max_conv_turns = min(12, max(3, self._n_ctx // 2000))
            else:
                max_conv_turns = min(20, max(6, self._n_ctx // 800))
            conv_text = _compact_conversation(request.conversation_history_text, max_conv_turns)
            budgeter.add(
                "conversation",
                "=== Conversation History ===\n" + conv_text,
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=50,
                truncate_fn=lambda c, m: _truncate_conversation(c, m, counter),
            )

        if request.context_pool_text:
            budgeter.add(
                "context_pool",
                "=== Context ===\n" + request.context_pool_text,
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: _truncate_context_pool(c, m, counter),
            )

        if request.protocol_context:
            budgeter.add(
                "active_protocols",
                "=== Active Protocols ===\n" + request.protocol_context,
                SectionPriority.IMPORTANT,
            )

        carryover_text = self._reasoning_carryover.get_prompt_text()
        if carryover_text:
            budgeter.add(
                "reasoning_carryover",
                carryover_text,
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: _truncate_reasoning_carryover(c, m, counter),
            )

        if request.prefetch_context:
            prefetch = request.prefetch_context
            if request.skip_exploration:
                prefetch += (
                    "\n\n"
                    + "!" * 50
                    + "\n!!! ONE-CALL MODE: WRITE DIRECTLY !!!\n"
                    + "!" * 50
                    + "\n\nFile discovery is COMPLETE. Do NOT use glob or read_file."
                    + "\nProceed DIRECTLY to your action:"
                    + "\n- For EXISTING file: write_file with overwrite=True"
                    + "\n- For NEW file: write_file (no overwrite needed)"
                    + "\n\nYour response should be the write_file action, NOT exploration."
                )
            has_discovery = "DISCOVERY PLAN" in prefetch or "FILE DISCOVERY" in prefetch
            priority = SectionPriority.CRITICAL if has_discovery else SectionPriority.IMPORTANT
            budgeter.add(
                "prefetch_context",
                prefetch,
                priority,
                truncatable=True,
                min_tokens=50,
                truncate_fn=lambda c, m: _truncate_context_pool(c, m, counter),
            )

        # Coding guidelines: prefer the user's question text, fall back to pending_modification
        guidelines_text = question_text
        if not guidelines_text and request.pending_modification:
            guidelines_text = request.pending_modification.get("user_modification", "")
        if guidelines_text:
            coding_context = build_coding_context(guidelines_text, include_sandbox_reminder=True, max_guidelines=2)
            if coding_context:
                budgeter.add("coding_guidelines", coding_context, SectionPriority.IMPORTANT)

        # Foundational (Constitution/AGENTS, session-cached) and the mode's
        # static context_prompt are session-stable → cacheable prefix.
        budgeter.add("foundational", _load_foundational_context(), SectionPriority.IMPORTANT, cacheable=True)
        if request.mode.context_prompt:
            budgeter.add("mode_context", request.mode.context_prompt, SectionPriority.NICE_TO_HAVE, cacheable=True)

    @staticmethod
    def _add_perception_sections(
        budgeter: PromptBudgeter,
        request: LLMRequest,
    ) -> None:
        """Current observation, recent speech, agent states, recent outcomes, body state."""
        context = request.context

        if context.current_percept:
            obs_text = build_observation_section(context.current_percept)
            if obs_text:
                budgeter.add("observation", obs_text, SectionPriority.IMPORTANT)

        if context.detected_speech:
            speech_lines = ["=== Recent Speech ==="]
            for speech in context.detected_speech[-3:]:
                speech_lines.append(f'- "{speech[:100]}"')
            budgeter.add("speech", "\n".join(speech_lines), SectionPriority.NICE_TO_HAVE)

        if request.agent_states:
            state_lines = ["=== Agent States ==="]
            for state in request.agent_states[-5:]:
                agent_name = state.get("agent", "unknown")
                agent_state = state.get("state", "unknown")
                goal = state.get("goal", "")
                state_lines.append(f"- {agent_name}: {agent_state}" + (f" (goal: {goal})" if goal else ""))
            budgeter.add("agent_states", "\n".join(state_lines), SectionPriority.NICE_TO_HAVE)

        if request.recent_outcomes:
            outcome_lines = ["=== Recent Action Outcomes ==="]
            for outcome in request.recent_outcomes[-3:]:
                tool = outcome.get("tool", "?")
                success = "succeeded" if outcome.get("success") else "failed"
                result = outcome.get("result", "")[:50] if outcome.get("result") else ""
                outcome_lines.append(f"- {tool}: {success}" + (f" ({result})" if result else ""))
            budgeter.add("recent_outcomes", "\n".join(outcome_lines), SectionPriority.IMPORTANT)

        # Body state (interoception — always present when embodied)
        if context.body_state:
            budgeter.add("body_state", context.body_state, SectionPriority.CRITICAL)

        # Bio-enrichment (L1): focused bio-system associations for current percept.
        # Suppressed when a deliberation transcript is present — the current
        # cycle's enrichment already appears as the last transcript entry.
        # Rendering it twice wastes budget and confuses the LLM.
        has_transcript = bool(getattr(context, "deliberation_transcript", None))
        if context.bio_enrichment_context and not has_transcript:
            budgeter.add(
                "bio_enrichment",
                f"=== What your experience tells you about this situation ===\n{context.bio_enrichment_context}",
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 15)]),
            )

        # Auto-sense: passive perception of surroundings + body state.
        # Injected by the agent loop's auto-sense sweep (section 1.15).
        _auto_sense = getattr(context, "auto_sense_context", "") or ""
        if _auto_sense:
            budgeter.add(
                "auto_sense",
                f"=== What you perceive right now ===\n{_auto_sense}",
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 15)]),
            )

    def _add_deliberation_transcript_section(
        self,
        budgeter: PromptBudgeter,
        context: Any,
        response_reserve: int,
    ) -> None:
        """Deliberation transcript: accumulating reasoning+enrichment from multi-cycle PFC.

        Only rendered when a multi-cycle deliberation ran (cycles 2+).
        Budget is proportional to n_ctx to avoid crowding out other
        sections on small-context models: min(2000, available * 0.3).
        Truncation drops oldest entries first (most recent reasoning
        is most valuable).
        """
        transcript = getattr(context, "deliberation_transcript", None)
        if not transcript:
            return
        counter = self._token_counter
        available = max(0, self._n_ctx - response_reserve - 100)
        max_tokens = min(2000, int(available * 0.3))
        if max_tokens < 50:
            return

        lines = ["=== Your inner deliberation (private — not speech) ==="]
        for i, entry in enumerate(transcript, 1):
            lines.append(f"\n[Cycle {i}]")
            lines.append(entry)
        text = "\n".join(lines)

        def _truncate_transcript(content: str, max_tok: int) -> str:
            """Drop oldest cycle entries first."""
            parts = content.split("\n[Cycle ")
            if len(parts) <= 2:
                return content[: max_tok * 4]  # rough char approx
            while len(parts) > 2 and counter.count_tokens(content) > max_tok:
                parts.pop(1)  # drop oldest (index 1 — index 0 is header)
                content = parts[0] + "\n[Cycle " + "\n[Cycle ".join(parts[1:])
            return content

        budgeter.add(
            "deliberation_transcript",
            text,
            SectionPriority.IMPORTANT,
            truncatable=True,
            min_tokens=50,
            truncate_fn=_truncate_transcript,
        )

    @staticmethod
    def _add_working_memory_section(
        budgeter: PromptBudgeter,
        context: Any,
    ) -> None:
        """Working memory THOUGHT entries from prior deliberation cycles.

        Renders recent THOUGHT entries so the LLM sees accumulated reasoning
        and bio-system associations from prior cycles.  400 token budget,
        truncatable.  Distinct from bio_enrichment (current cycle) — this
        shows *prior* cycles' enrichment summaries.

        When a deliberation transcript is present (multi-cycle), THOUGHT
        rendering defers to the transcript section — the transcript is a
        richer, ordered representation of the same information.
        """
        # When transcript is present, it subsumes the thoughts section
        if getattr(context, "deliberation_transcript", None):
            return
        thoughts = getattr(context, "working_memory_thoughts", None)
        if not thoughts:
            return
        lines = ["=== Your prior reasoning ==="]
        for thought in thoughts[-6:]:  # Last 6 entries (covers 2 full 3-cycle deliberations)
            lines.append(f"- {thought[:200]}")
        budgeter.add(
            "working_memory_thoughts",
            "\n".join(lines),
            SectionPriority.IMPORTANT,
            truncatable=True,
            min_tokens=30,
            truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 20)]),
        )

    @staticmethod
    def _add_memory_sections(
        budgeter: PromptBudgeter,
        context: Any,
    ) -> None:
        """Episodic recalls, ATL concepts, semantic knowledge, causal predictions, motor programs, statistics."""
        if context.relevant_memories:
            mem_lines = ["=== Relevant Memories ==="]
            for mem in context.relevant_memories[:8]:
                content = mem.get("content", {})
                source = mem.get("source", "episodic")
                salience = mem.get("salience", 0.0)
                parts = []
                if content.get("goal"):
                    parts.append(f"goal: {content['goal']}")
                if content.get("action"):
                    parts.append(f"action: {content['action']}")
                if content.get("success") is not None:
                    parts.append("succeeded" if content["success"] else "failed")
                if content.get("transcript"):
                    parts.append(f'"{content["transcript"][:80]}"')
                summary = ", ".join(parts) if parts else str(content)[:80]
                mem_lines.append(f"- [{source}, salience={salience:.2f}] {summary}")
                if mem.get("predictions"):
                    for pred in mem["predictions"][:2]:
                        pred_tool = pred.get("tool", "?")
                        pred_success = pred.get("success", "?")
                        pred_conf = pred.get("confidence", 0)
                        mem_lines.append(
                            f"  prediction: {pred_tool} (success={pred_success}, confidence={pred_conf:.2f})"
                        )
            budgeter.add(
                "relevant_memories",
                "\n".join(mem_lines),
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=50,
                truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 20)]),
            )

        if context.concept_context:
            concept_lines = ["=== Active Concepts ==="]
            for concept in context.concept_context[:5]:
                name = concept.get("name", "?")
                category = concept.get("category", "?")
                confidence = concept.get("confidence", 0)
                episodes = concept.get("episode_count", 0)
                concept_lines.append(f"- {name} ({category}, confidence={confidence:.2f}, episodes={episodes})")
                for rel in concept.get("relationships", [])[:2]:
                    concept_lines.append(f"  → {rel.get('type', '?')} {rel.get('target', '?')}")
            budgeter.add(
                "concept_context",
                "\n".join(concept_lines),
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 15)]),
            )

        if context.knowledge_context:
            knowledge_lines = ["=== Semantic Knowledge ==="]
            for entry in context.knowledge_context[:5]:
                name = entry.get("concept_name", "?")
                definition = entry.get("definition", "")
                layer = entry.get("source_layer", "?")
                conf = entry.get("confidence", 0)
                knowledge_lines.append(f"- {name} ({layer}, confidence={conf:.2f}): {definition}")
                for rel in entry.get("relationships", [])[:2]:
                    knowledge_lines.append(f"  → {rel.get('type', '?')} {rel.get('target', '?')}")
            budgeter.add(
                "knowledge_context",
                "\n".join(knowledge_lines),
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 15)]),
            )

        if context.causal_context:
            causal_lines = ["=== Causal Predictions (learned from experience) ==="]
            for pred in context.causal_context[:5]:
                valence = pred.get("valence", "neutral")
                conf = pred.get("confidence", 0)
                ctx_match = pred.get("context_match", 0)
                causal_lines.append(
                    f"- {pred.get('event', '?')} → {pred.get('outcome', '?')} "
                    f"(valence={valence}, confidence={conf:.2f}, context_match={ctx_match:.2f})"
                )
            budgeter.add(
                "causal_context",
                "\n".join(causal_lines),
                SectionPriority.CRITICAL,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 15)]),
            )

        if context.valence_context:
            valence_lines = ["=== Learned Associations (from experience) ==="]
            for entry in context.valence_context[:5]:
                concept = entry.get("concept", "?")
                valence = entry.get("valence", 0.0)
                if valence < -0.1:
                    sentiment = "negative (associated with pain/failure)"
                elif valence > 0.1:
                    sentiment = "positive (associated with success/benefit)"
                else:
                    sentiment = "neutral"
                assoc = ", ".join(entry.get("associations", [])[:3])
                valence_lines.append(f"- {concept}: {sentiment} (valence={valence:+.2f}, related: {assoc})")
            budgeter.add(
                "valence_context",
                "\n".join(valence_lines),
                SectionPriority.CRITICAL,
                truncatable=True,
                min_tokens=30,
                truncate_fn=lambda c, m: "\n".join(c.split("\n")[: max(2, m // 15)]),
            )

        if context.motor_programs:
            motor_lines = ["=== Available Motor Programs ==="]
            for prog in context.motor_programs[:8]:
                name = prog.get("name", "?")
                conf = prog.get("confidence", 0)
                rate = prog.get("success_rate", 0)
                execs = prog.get("executions", 0)
                steps_str = " → ".join(prog.get("steps", [])[:5])
                motor_lines.append(f"- {name} (confidence={conf:.2f}, {execs} runs, success={rate:.0%})")
                if steps_str:
                    motor_lines.append(f"  Steps: {steps_str}")
                risks = prog.get("risks", [])
                if risks:
                    motor_lines.append(f"  Known risks: {', '.join(risks)}")

            def _truncate_motor_programs(content: str, max_tok: int) -> str:
                """Drop full motor program entries (name + steps + risks) from
                the tail. The previous implementation sliced by line count,
                which left partial entries (e.g. a Steps line with no name)
                because each entry spans 1-3 lines. Pure function — uses the
                conservative ~4-chars-per-token estimate (same as the
                deliberation-transcript truncator) since the static-method
                context has no token counter in scope.
                """
                lines = content.split("\n")
                target_chars = max_tok * 4
                while len("\n".join(lines)) > target_chars:
                    cut = None
                    for i in range(len(lines) - 1, 0, -1):
                        if lines[i].startswith("- "):
                            cut = i
                            break
                    if cut is None:
                        break  # only header left, can't drop more
                    lines = lines[:cut]
                return "\n".join(lines)

            budgeter.add(
                "motor_programs",
                "\n".join(motor_lines),
                SectionPriority.IMPORTANT,
                truncatable=True,
                min_tokens=30,
                truncate_fn=_truncate_motor_programs,
            )

        if context.statistical_context and context.active_pattern_count > 0:
            stat_lines = [
                f"=== Statistical Patterns ({context.active_pattern_count} active) ===",
                context.statistical_context,
            ]
            suggestions = getattr(context, "statistical_suggestions", [])
            if suggestions:
                stat_lines.append("")
                stat_lines.append("Recommended analyses (ranked by priority):")
                for i, s in enumerate(suggestions[:3], 1):
                    stat_lines.append(
                        f"  {i}. {s.get('tool_call', 'math')} {s.get('operation', '?')} "
                        f"on {s.get('metric', '?')} [{s.get('data_type', '?')}] — "
                        f"{s.get('rationale', '')}"
                    )
            else:
                stat_lines.append(
                    "Use 'math' tool to investigate patterns (assess_randomness, analyze, recall_memory)."
                )
            stat_lines.append("Use 'internet_search' to research unfamiliar patterns or analysis techniques.")
            budgeter.add("statistical_patterns", "\n".join(stat_lines), SectionPriority.NICE_TO_HAVE)

    def _build_followup_prompt(self, followup_input: str) -> str:
        """Build a prompt to handle action followups based on followup_type."""
        new_format = re.match(
            r"\[ACTION_FOLLOWUP type=(\w+) tool=([\w_-]+) mode=([\w_-]+) query='(.*)'\]: (.*)",
            followup_input,
            re.DOTALL,
        )

        if new_format:
            followup_type = new_format.group(1)
            tool_name = new_format.group(2)
            mode_name = new_format.group(3)
            raw_query = new_format.group(4)
            if "']: " in followup_input:
                split_idx = followup_input.find("']: ")
                bracket_start = followup_input.find("query='") + 7
                original_query = followup_input[bracket_start:split_idx]
                result = followup_input[split_idx + 4 :]
            else:
                original_query = raw_query
                result = new_format.group(5)
            logger.debug(
                f"Parsed followup: type={followup_type}, tool={tool_name}, query_len={len(original_query)}, result_len={len(result)}"
            )
        else:
            legacy_format = re.match(r"\[SEARCH RESULT for '([^']+)'\]: (.*)", followup_input, re.DOTALL)
            if legacy_format:
                followup_type = "engage"
                tool_name = "internet_search"
                mode_name = "live"
                original_query = legacy_format.group(1)
                result = legacy_format.group(2)
            else:
                logger.warning(
                    f"Could not parse followup input format, using fallback. Input starts with: {followup_input[:100]}"
                )
                followup_type = "engage"
                tool_name = "internet_search"
                mode_name = "live"
                original_query = ""
                result = followup_input

        if followup_type == "process":
            return self._build_process_prompt(tool_name, original_query, result)
        elif followup_type == "respond":
            return self._build_respond_prompt(tool_name, original_query, result)
        elif followup_type == "engage":
            return self._build_engage_prompt(tool_name, original_query, result, mode_name)
        else:
            return self._build_respond_prompt(tool_name, original_query, result)

    def _build_process_prompt(self, tool_name: str, query: str, result: str) -> str:
        is_batched = tool_name == "batched_exploration" or "BATCHED EXPLORATION RESULTS" in result

        if is_batched:
            prompt = f"""You completed batched exploration. Now analyze ALL results and make your final action.

Original request: "{query}"

{result}

=== Instructions ===
You now have complete context from the batched exploration. Based on ALL the results above:

1. If modifying a file: Use 'write_file' with the COMPLETE updated content
   - Include overwrite=True for existing files
   - Incorporate patterns/conventions from related files you read

2. If creating a new file: Use 'write_file' with appropriate content

3. If task is already complete or you need to inform the user: Use 'respond'

4. If you need more information: Request additional tools (but batching should have provided enough)

Respond with JSON:
{{"action": {{"tool_name": "<tool>", "params": {{...}}}}, "confidence": 0.9, "reasoning": "your_reasoning"}}"""
        else:
            prompt = f"""You just executed '{tool_name}'. Analyze the results and decide your next action.

Original request: "{query}"

Results from {tool_name}:
{result}

=== Instructions ===
Based on these results, determine your next action:
- If the task is complete, use 'respond' to inform the user
- If more steps are needed, choose the appropriate tool
- Extract relevant information to inform your decision

Respond with JSON:
{{"action": {{"tool_name": "<next_tool>", "params": {{...}}}}, "confidence": 0.9, "reasoning": "your_reasoning"}}"""

        return f"TOOL_PROMPT|{prompt}"

    def _build_respond_prompt(self, tool_name: str, query: str, result: str) -> str:
        prompt = f"""You completed an internet search. Synthesize the results into a helpful response.

Original user request: "{query}"

Search Results:
{result}

=== Instructions ===
Extract the most relevant information from the search results and answer the user's question clearly.

IMPORTANT: You MUST use the "respond" tool. Do NOT use any other tool name.

Example JSON format:
{{"action": {{"tool_name": "respond", "params": {{"message": "YOUR ANSWER HERE"}}}}, "confidence": 0.9, "reasoning": "synthesized_search_results"}}

Your response (use tool_name "respond"):"""

        return f"TOOL_PROMPT|{prompt}"

    def _build_engage_prompt(self, tool_name: str, query: str, result: str, mode_name: str) -> str:
        now = datetime.now()
        date_str = now.strftime("%A, %B %d, %Y")
        time_str = now.strftime("%I:%M %p")

        if mode_name == "active-assistance":
            engagement_instruction = (
                "Be proactive and suggest 2-3 relevant follow-up options the user might find helpful."
            )
        elif mode_name == "observe":
            engagement_instruction = (
                "Keep your response informative but concise. Only offer one follow-up if highly relevant."
            )
        else:
            engagement_instruction = "Optionally offer 1-2 relevant follow-up questions or related information."

        prompt = f"""You just completed an internet search. Synthesize the results into a helpful response for the user.

CURRENT DATE: {date_str} at {time_str}
(Use this date context when interpreting results about "today" or recent events)

Original user request: "{query}"

Search Results:
{result}

=== Instructions ===
1. Extract the relevant information from the search results above
2. Provide a clear, direct answer to the user's question
3. {engagement_instruction}
4. If results seem outdated or incomplete, acknowledge this

IMPORTANT: You MUST use the "respond" tool to reply to the user. Do NOT use any other tool name.

Example JSON format (COPY THIS STRUCTURE EXACTLY):
{{"action": {{"tool_name": "respond", "params": {{"message": "The Broncos beat the Bills 31-7 in the AFC Divisional playoff. Would you like details on the top performers?"}}}}, "confidence": 0.9, "reasoning": "synthesized_search_results"}}

Your response (use tool_name "respond"):"""

        return f"TOOL_PROMPT|{prompt}"
