import html import json import logging import os import shutil import tempfile import time from datetime import datetime, timezone import gradio as gr import spaces from dotenv import load_dotenv from langchain.agents import create_agent from langchain_core.messages import AIMessage, ToolMessage from langchain_openai.chat_models.base import OpenAIContextOverflowError from nemotron_llm import NemotronChatOpenAI from nemotron_llm import message_content_to_string from nemotron_llm import strip_thinking_for_display from context_management import ( MAX_TOKENS, build_context_middleware, context_settings, ) from diagnostic_planning import build_todo_middleware from llama_server import N_CTX, SERVER_CONFIG, start_llama_server from elm_server import ELM_CONFIG, ensure_elm_emulator from obd_connection import init_obd_session, obd_session_lock from obd_connection import VITALS_POLL_INTERVAL from obd_vitals import refresh_vitals_hud, start_vitals_poller from vitals_store import DB_PATH, PURGE_INTERVAL_SECONDS, RETENTION_HOURS from fault_simulation import ( apply_fault, clear_faults, describe_fault, fault_investigation_prompt, get_active_fault, ) from tools import DIAGNOSTIC_TOOLS from ui.layout import build_ui from ui.theme import build_cockpit_theme, load_cockpit_css from trace_store import ( append_trace, build_turn_trace, trace_file_exists, trace_file_path, ) load_dotenv() os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1" logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger("car-diagnostic-agent") GPU_DURATION = int(os.environ.get("SPACES_GPU_DURATION", "180")) DEFAULT_THINKING_ENABLED = os.environ.get("THINKING_ENABLED", "false").lower() in ( "1", "true", "yes", ) SYSTEM_PROMPT = ( "You are a helpful car diagnostic assistant for a Toyota Auris Hybrid (2012). " "Help users understand symptoms, possible causes, and suggested next steps. " "Be clear and practical.\n\n" "OBD vitals and DTCs are recorded automatically every few seconds. DTCs may " "not appear immediately when a problem starts—the ECU can set them after " "conditions persist. During an active diagnosis, keep checking live and recent " "data across multiple turns (wait a few seconds between checks when watching " "for new codes) rather than relying on a single reading.\n\n" "You MUST call tools for vehicle data (never invent readings or codes). " "Tool names and parameters are available via the tool schemas.\n\n" "**Before investigating any symptoms**, call `obd_vitals_reference` to learn " "stored labels, command names, and how to use live vs historical tools. " "Then call `diagnosis_methodology` for DTC and workflow rules.\n\n" "Always call obd_vitals_reference and diagnosis_methodology before reading vitals or DTCs. " "If live vitals fail or before the first live read, call check_obd_vitals_link. " "Stored DTCs may remain after a fault clears — " "confirm any code with live vitals and get_stored_vital_history trends before concluding. " "When you suspect a specific failure, call get_diagnosis_guide with the matching fault_id.\n\n" "When investigating symptoms, use get_stored_vital_history to trend one sensor over time, " "get_recent_vitals and read_dtc_codes across the conversation to catch developing faults. " "Compare snapshots over time for trends (fuel trims, temps, RPM). " "Actually invoke tools, then summarize results for the user.\n\n" "For investigations that need many tool calls in one turn, use `write_todos` " "to track steps (reference, vitals, DTCs, trends, guides) before you start." ) CONTEXT_OVERFLOW_REPLY = ( "The conversation context is full (model limit ~{n_ctx} tokens). " "Please start a new chat, or ask for fewer vitals at a time " "(e.g. one PID or a smaller recent count)." ) _agent = None _llm: NemotronChatOpenAI | None = None def _truncate(text: str, limit: int = 1200) -> str: text = text.strip() if len(text) <= limit: return text return text[:limit] + "…" def _pretty_payload(raw: str | dict | list) -> str: if isinstance(raw, (dict, list)): return json.dumps(raw, indent=2, ensure_ascii=False) text = str(raw).strip() try: return json.dumps(json.loads(text), indent=2, ensure_ascii=False) except (json.JSONDecodeError, TypeError): return text def _message_key(msg) -> int: return id(msg) def _append_tool_trace(lines: list[str], msg, seen: set[int]) -> None: key = _message_key(msg) if key in seen: return seen.add(key) if isinstance(msg, AIMessage): for tc in msg.tool_calls or []: name = tc.get("name", "?") args = tc.get("args", {}) lines.append(f"**Call `{name}`**") lines.append(f"```json\n{_pretty_payload(args)}\n```") logger.info("Tool call: %s args=%s", name, args) elif isinstance(msg, ToolMessage): tool_name = getattr(msg, "name", None) or msg.tool_call_id or "tool" lines.append(f"**Result `{tool_name}`**") lines.append(f"```json\n{_truncate(_pretty_payload(msg.content), 4000)}\n```") logger.info("Tool result %s: %s", tool_name, msg.content) def _format_trace(lines: list[str], *, in_progress: bool = False) -> str: if not lines: status = "*Waiting for tool activity…*" if not in_progress else "*Running…*" return f"### Tool calls & results\n\n{status}" body = "\n\n".join(lines) suffix = "\n\n---\n*In progress…*" if in_progress else "" return f"### Tool calls & results\n\n{body}{suffix}" def _normalize_chat_history(history: list | None) -> list[dict[str, str]]: """Gradio 6 messages format: list of {role, content} dicts with string content.""" normalized: list[dict[str, str]] = [] for entry in history or []: if isinstance(entry, dict): role = entry.get("role") content = entry.get("content") if role and content is not None: normalized.append( {"role": str(role), "content": message_content_to_string(content)} ) continue if isinstance(entry, (list, tuple)) and len(entry) == 2: user_msg, assistant_msg = entry if user_msg: normalized.append( {"role": "user", "content": message_content_to_string(user_msg)} ) if assistant_msg: normalized.append( { "role": "assistant", "content": message_content_to_string(assistant_msg), } ) return normalized def _final_reply(result_messages: list, input_count: int) -> str: final = strip_thinking_for_display( message_content_to_string(result_messages[-1].content) ) return final or "(no response)" def _model_label() -> str: return f"{SERVER_CONFIG.hf_model_repo_id}:{SERVER_CONFIG.model}" def _trace_params(enable_thinking: bool) -> dict: return { "temperature": 0.6, "max_tokens": MAX_TOKENS, "n_ctx": N_CTX, "thinking_enabled": enable_thinking, } def _persist_turn_trace( *, user_message: str, prior_turns: int, result_messages: list, start_index: int, assistant_message: str, enable_thinking: bool, latency_ms: int, status: str, error: str | None = None, turn_started: datetime, ) -> None: try: append_trace( build_turn_trace( user_message=user_message, prior_turns=prior_turns, result_messages=result_messages, start_index=start_index, assistant_message=assistant_message, model=_model_label(), params=_trace_params(enable_thinking), latency_ms=latency_ms, status=status, error=error, ts=turn_started, ) ) except Exception: logger.exception("Failed to persist agent trace") def _download_traces_file() -> str | None: path = trace_file_path() if not trace_file_exists(): return None dest = os.path.join(tempfile.gettempdir(), "agent_traces.jsonl") shutil.copy2(path, dest) return dest def refresh_vitals_panel() -> str: """Poll ECU now, store snapshot, and update the vitals HUD.""" try: return refresh_vitals_hud() except Exception as exc: logger.exception("Vitals panel refresh failed") return ( '
' f"Could not load vitals: {html.escape(str(exc))}" "
" ) def on_fault_select(fault_id: str | None) -> str: return describe_fault(fault_id) def on_apply_fault( fault_id: str | None, history: list | None, enable_thinking: bool, ): """Apply fault, refresh vitals, and ask the agent to investigate (visible in chat).""" try: if not fault_id or fault_id == "none": status = clear_faults() vitals = refresh_vitals_panel() yield status, vitals, history, gr.update() return status = apply_fault(fault_id) vitals = refresh_vitals_panel() prompt = fault_investigation_prompt() yield status, vitals, history, gr.update() for chat_history, trace in chat(prompt, history, enable_thinking): yield status, vitals, chat_history, trace except Exception as exc: logger.exception("Apply fault failed") yield ( f"*Could not apply fault: {exc}*", refresh_vitals_panel(), history, gr.update(), ) def on_clear_fault() -> tuple[str, str, None]: try: status = clear_faults() return status, refresh_vitals_panel(), gr.update(value="none") except Exception as exc: logger.exception("Clear fault failed") return f"*Could not clear faults: {exc}*", refresh_vitals_panel(), gr.update() def active_fault_status() -> str: fault = get_active_fault() if fault is None: return "**Active fault:** none (healthy `car` scenario)" return ( f"**Active fault:** {fault.name} (`{fault.id}`) \n" f"**Typical DTC:** `{fault.typical_dtc}`" ) @spaces.GPU(duration=GPU_DURATION) def load_agent() -> None: """Start llama.cpp server (model load) and build the agent inside GPU context.""" global _agent, _llm if _agent is not None: return print("Loading model via llama.cpp server...") logger.info( "Starting agent (model=%s, max_tokens=%d, thinking=%s, tools=%s)", SERVER_CONFIG.model_alias, MAX_TOKENS, DEFAULT_THINKING_ENABLED, [t.name for t in DIAGNOSTIC_TOOLS], ) start_llama_server(SERVER_CONFIG) ctx = context_settings() logger.info( "Context budget: N_CTX=%d, MAX_TOKENS=%d, input_budget=%d, message_budget=%d, " "summary_trigger=%d, summary_keep_tokens=%d, tool_clear_trigger=%d", ctx.n_ctx, ctx.max_tokens, ctx.input_budget, ctx.message_budget, ctx.summary_trigger_tokens, ctx.summary_keep_tokens, ctx.tool_clear_trigger_tokens, ) _llm = NemotronChatOpenAI( base_url=SERVER_CONFIG.base_url, api_key=os.environ.get("OPENAI_API_KEY", "local"), model=SERVER_CONFIG.model_alias, temperature=0.6, top_p=0.95, max_tokens=MAX_TOKENS, enable_thinking=DEFAULT_THINKING_ENABLED, profile={"max_input_tokens": N_CTX}, ) summary_llm = NemotronChatOpenAI( base_url=SERVER_CONFIG.base_url, api_key=os.environ.get("OPENAI_API_KEY", "local"), model=SERVER_CONFIG.model_alias, temperature=0.3, max_tokens=512, enable_thinking=False, profile={"max_input_tokens": N_CTX}, ) _agent = create_agent( model=_llm, tools=DIAGNOSTIC_TOOLS, system_prompt=SYSTEM_PROMPT, middleware=[build_todo_middleware(), *build_context_middleware(summary_llm)], ) print("Agent ready.") logger.info("Agent ready.") @spaces.GPU(duration=GPU_DURATION) def chat(message: str, history: list | None, enable_thinking: bool): if _agent is None: load_agent() if _llm is not None: _llm.enable_thinking = enable_thinking history = _normalize_chat_history(history) agent_messages = [ {"role": entry["role"], "content": entry["content"]} for entry in history ] agent_messages.append({"role": "user", "content": message}) trace_lines: list[str] = [] seen: set[int] = set() pending = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": "…"}, ] yield pending, _format_trace(trace_lines, in_progress=True) logger.info("User message: %s (thinking=%s)", message, enable_thinking) result_messages = list(agent_messages) turn_started = datetime.now(timezone.utc) turn_start_mono = time.monotonic() prior_turns = sum(1 for entry in history if entry.get("role") == "user") start_index = len(agent_messages) assistant_reply = "" try: for step in _agent.stream( {"messages": agent_messages}, stream_mode="updates", ): for _node, update in step.items(): if not isinstance(update, dict): continue new_msgs = update.get("messages", []) result_messages.extend(new_msgs) for msg in new_msgs: _append_tool_trace(trace_lines, msg, seen) yield pending, _format_trace(trace_lines, in_progress=True) except OpenAIContextOverflowError as exc: logger.warning("Agent context overflow during stream") trace_lines.append("*Context limit reached*") assistant_reply = CONTEXT_OVERFLOW_REPLY.format(n_ctx=N_CTX) latency_ms = int((time.monotonic() - turn_start_mono) * 1000) _persist_turn_trace( user_message=message, prior_turns=prior_turns, result_messages=result_messages, start_index=start_index, assistant_message=assistant_reply, enable_thinking=enable_thinking, latency_ms=latency_ms, status="context_overflow", error=str(exc), turn_started=turn_started, ) history = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": assistant_reply}, ] yield history, _format_trace(trace_lines, in_progress=False) return except Exception: logger.exception("Agent stream failed; falling back to invoke") trace_lines.append("*Non-streaming fallback*") yield pending, _format_trace(trace_lines, in_progress=True) try: final_state = _agent.invoke({"messages": agent_messages}) except OpenAIContextOverflowError as exc: logger.warning("Agent context overflow during invoke") trace_lines.append("*Context limit reached*") assistant_reply = CONTEXT_OVERFLOW_REPLY.format(n_ctx=N_CTX) latency_ms = int((time.monotonic() - turn_start_mono) * 1000) _persist_turn_trace( user_message=message, prior_turns=prior_turns, result_messages=result_messages, start_index=start_index, assistant_message=assistant_reply, enable_thinking=enable_thinking, latency_ms=latency_ms, status="context_overflow", error=str(exc), turn_started=turn_started, ) history = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": assistant_reply}, ] yield history, _format_trace(trace_lines, in_progress=False) return except Exception as exc: logger.exception("Agent invoke failed") trace_lines.append(f"*Error:* `{exc}`") assistant_reply = "Sorry, the diagnostic agent failed. Please try again." latency_ms = int((time.monotonic() - turn_start_mono) * 1000) _persist_turn_trace( user_message=message, prior_turns=prior_turns, result_messages=result_messages, start_index=start_index, assistant_message=assistant_reply, enable_thinking=enable_thinking, latency_ms=latency_ms, status="error", error=str(exc), turn_started=turn_started, ) history = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": assistant_reply}, ] yield history, _format_trace(trace_lines, in_progress=False) return result_messages = final_state["messages"] for msg in result_messages[len(agent_messages) :]: _append_tool_trace(trace_lines, msg, seen) reply = _final_reply(result_messages, len(agent_messages)) assistant_reply = reply latency_ms = int((time.monotonic() - turn_start_mono) * 1000) _persist_turn_trace( user_message=message, prior_turns=prior_turns, result_messages=result_messages, start_index=start_index, assistant_message=assistant_reply, enable_thinking=enable_thinking, latency_ms=latency_ms, status="ok", turn_started=turn_started, ) history = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": reply}, ] if not trace_lines: trace_lines.append("*No tools were called.*") logger.info("Reply length: %d chars", len(reply)) yield history, _format_trace(trace_lines, in_progress=False) def _submit(message, history, enable_thinking): if not message or not str(message).strip(): yield history, gr.update() return yield from chat(message.strip(), history, enable_thinking) try: ensure_elm_emulator() with obd_session_lock: init_obd_session() except Exception: logger.exception("ELM/OBD singleton failed to start at boot (poller will retry)") demo = build_ui( default_thinking_enabled=DEFAULT_THINKING_ENABLED, active_fault_status_fn=active_fault_status, on_fault_select_fn=on_fault_select, on_apply_fault_fn=on_apply_fault, on_clear_fault_fn=on_clear_fault, chat_submit_fn=_submit, download_traces_fn=_download_traces_file, ) load_agent() start_vitals_poller() logger.info( "ELM327-emulator (scenario=%s) at %s; vitals poll every ~%ss; db=%s; " "purge every %ss (retention %.1fh)", ELM_CONFIG.scenario, ELM_CONFIG.connect_uri, VITALS_POLL_INTERVAL, DB_PATH, PURGE_INTERVAL_SECONDS, RETENTION_HOURS, ) if __name__ == "__main__": allowed = ["/data", tempfile.gettempdir()] data_dir = os.environ.get("VITALS_DATA_DIR", "/data") if os.path.isdir(data_dir): allowed.append(data_dir) demo.launch( allowed_paths=allowed, theme=build_cockpit_theme(), css=load_cockpit_css(), )