"""The agent loop: model → tool calls → model → ... → answer. Written out by hand rather than taken from a framework, because the whole value of this screen is watching the loop happen: which tool, with which arguments, what came back, what the model made of it. A framework that owns the loop also owns what you are allowed to see of it. Every stage yields an event; main.py forwards them to the browser as SSE. Nothing here writes to the transport, so the loop stays testable on its own. """ from __future__ import annotations import json import re from collections.abc import AsyncIterator from pathlib import Path from typing import Any from .config import AppConfig from .llm import LLMError, chat_completion, extract_message, extract_reasoning from .mcp_tools import mcp_session, result_to_text, to_openai_tools from .wikipedia_tool import WIKIPEDIA_TOOL_NAME, WIKIPEDIA_TOOL_SCHEMA, wikipedia_lookup PROMPT_PATH = Path(__file__).resolve().parent.parent / "PROMPT.md" # Fallback only. The real rules live in PROMPT.md, shipped alongside this app # and copied unmodified from the vfrplan project — it is what stops the model # inventing figures it does not have. If the file goes missing the agent still # runs, but the safety-critical parts of its behaviour are gone, so the # fallback says so out loud rather than pretending nothing happened. FALLBACK_PROMPT = """You are a VFR flight planning assistant for Europe. Use the provided tools for every aeronautical fact. Never invent aerodrome data, weather, NOTAMs or performance figures. Report every assumption and warning the tools return. Ask the pilot for missing inputs instead of guessing them. Fuel, weather minima and go/no-go remain the pilot's decisions. (Note: PROMPT.md was not found, so these are reduced fallback instructions.)""" def system_prompt() -> str: try: text = PROMPT_PATH.read_text(encoding="utf-8").strip() return text or FALLBACK_PROMPT except OSError: return FALLBACK_PROMPT def _event(kind: str, **data: Any) -> dict[str, Any]: return {"type": kind, **data} # Matches an ISO date, optionally followed by a time and/or a trailing 'Z' / # numeric UTC offset, e.g. "2026-08-18", "2026-08-18T11:00:00" or # "2026-08-14T09:00:00+00:00". _DATETIME_RE = re.compile( r"^(?P\d{4}-\d{2}-\d{2})" r"(?:[T ](?P