| 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 ( |
| '<div class="vitals-hud"><div class="vitals-hud__error">' |
| f"Could not load vitals: {html.escape(str(exc))}" |
| "</div></div>" |
| ) |
|
|
|
|
| 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(), |
| ) |
|
|