Settings: equalize Textbox/Dropdown height, flatten Slider wrapper, default Temperature to 1.0
49586ec | import json | |
| import os | |
| import re | |
| import time | |
| from dataclasses import dataclass, field | |
| from datetime import date | |
| from typing import Any, Dict, List, Optional, Set, Tuple, Union | |
| import gradio as gr | |
| import requests | |
| from bs4 import BeautifulSoup | |
| from duckduckgo_search import DDGS | |
| from huggingface_hub import InferenceClient | |
| # --- Model configuration --------------------------------------------------- | |
| # Our own DeepResearch model. When QUEST_BASE_URL is configured in Space | |
| # Secrets, the app will route requests to that dedicated HF Inference Endpoint | |
| # instead of the shared HF Inference API. | |
| QUEST_MODEL_ID = "osunlp/Quest-4B" | |
| QUEST_BASE_URL = os.getenv("QUEST_BASE_URL", "").strip() | |
| # Endpoints built from the TGI image expose a single-model OpenAI route; the | |
| # model name passed to chat_completion is usually "tgi". vLLM endpoints usually | |
| # want the original repo id. QUEST_ENDPOINT_MODEL overrides this if needed. | |
| QUEST_ENDPOINT_MODEL = os.getenv("QUEST_ENDPOINT_MODEL", "tgi").strip() or "tgi" | |
| # This Space runs exclusively on Quest-4B served via the private HF Inference | |
| # Endpoint pointed to by QUEST_BASE_URL. No public fallback list — the model | |
| # field in the UI is display-only. | |
| DEFAULT_MODEL = QUEST_MODEL_ID | |
| # Internal defaults. Search budget is no longer user-tunable. | |
| DEFAULT_MAX_SEARCH_RESULTS = 10 | |
| PAPER_URL = os.getenv("PAPER_URL", "#") | |
| CODE_URL = os.getenv("CODE_URL", "#") | |
| DATASET_URL = os.getenv("DATASET_URL", "#") | |
| MODEL_URL = os.getenv("MODEL_URL", "#") | |
| # --- System prompt --------------------------------------------------------- | |
| # Full QUEST SYSTEM_PROMPT (mirrors inference/prompt.py in the research repo) | |
| # so that Quest-4B sees the exact tool schema it was trained with. Other | |
| # models still follow this schema just fine in practice. | |
| QUEST_SYSTEM_PROMPT = """You are a deep research assistant. Your core function is to conduct thorough, multi-source investigations into any topic. You must handle both broad, open-domain inquiries and queries within specialized academic fields. For every request, synthesize information from credible, diverse sources to deliver a comprehensive, accurate, and objective response. When you have gathered sufficient information and are ready to provide the definitive response, you must enclose the entire final answer within <answer></answer> tags. | |
| # Tools | |
| You may call one or more functions to assist with the user query. | |
| You are provided with function signatures within <tools></tools> XML tags: | |
| <tools> | |
| {"type": "function", "function": {"name": "search", "description": "Perform Google web searches then returns a string of the top search results. Accepts multiple queries.", "parameters": {"type": "object", "properties": {"query": {"type": "array", "items": {"type": "string", "description": "The search query."}, "minItems": 1, "description": "The list of search queries."}}, "required": ["query"]}}} | |
| {"type": "function", "function": {"name": "visit", "description": "Visit webpage(s) and return the summary of the content.", "parameters": {"type": "object", "properties": {"url": {"type": "array", "items": {"type": "string"}, "description": "The URL(s) of the webpage(s) to visit. Can be a single URL or an array of URLs."}, "goal": {"type": "string", "description": "The specific information goal for visiting webpage(s)."}}, "required": ["url", "goal"]}}} | |
| </tools> | |
| # Using prev_state (Research State Summary) | |
| If you see a "RESEARCH STATE SUMMARY (prev_state)" section in the user message, it contains a compressed summary of previous research progress. Use it to avoid repeating searches/visits that have already been executed, use verified information directly in your answer, and follow up on uncertain claims only when needed. | |
| For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags: | |
| <tool_call> | |
| {"name": <function-name>, "arguments": <args-json-object>} | |
| </tool_call> | |
| Current date: """ | |
| def build_system_prompt() -> str: | |
| return QUEST_SYSTEM_PROMPT + date.today().isoformat() | |
| TOOL_RESPONSE_TEMPLATE = """<tool_response> | |
| {payload} | |
| </tool_response>""" | |
| SEARCH_CACHE: Dict[str, Dict[str, Any]] = {} | |
| VISIT_CACHE: Dict[str, Dict[str, Any]] = {} | |
| # Quest paper palette. The Gradio shell is themed to match the OSU-NLP Quest | |
| # microsite: soft off-white page, paper-white cards, terracotta accent, mint | |
| # secondary, Manrope for UI type and Source Serif 4 for display headings. | |
| APP_THEME = gr.themes.Base( | |
| primary_hue=gr.themes.colors.orange, | |
| secondary_hue=gr.themes.colors.teal, | |
| neutral_hue=gr.themes.colors.slate, | |
| font=[ | |
| gr.themes.GoogleFont("Manrope"), | |
| "ui-sans-serif", | |
| "system-ui", | |
| "sans-serif", | |
| ], | |
| font_mono=[ | |
| gr.themes.GoogleFont("JetBrains Mono"), | |
| "ui-monospace", | |
| "monospace", | |
| ], | |
| ).set( | |
| body_background_fill="#F2F4F8", | |
| body_text_color="#0D1117", | |
| body_text_color_subdued="#64748B", | |
| color_accent="#BE5B2B", | |
| color_accent_soft="rgba(190,91,43,0.09)", | |
| background_fill_primary="#FFFFFF", | |
| background_fill_secondary="#EEF1F7", | |
| border_color_primary="rgba(10,15,40,0.08)", | |
| border_color_accent="#BE5B2B", | |
| block_background_fill="#FFFFFF", | |
| block_border_width="1px", | |
| block_border_color="rgba(10,15,40,0.08)", | |
| block_shadow="0 1px 2px rgba(10,15,40,0.05), 0 2px 10px rgba(10,15,40,0.06)", | |
| block_radius="16px", | |
| block_label_background_fill="transparent", | |
| block_label_border_width="0px", | |
| block_label_text_color="#64748B", | |
| block_label_text_weight="700", | |
| block_title_text_color="#0D1117", | |
| block_title_text_weight="700", | |
| block_title_border_width="0px", | |
| panel_background_fill="transparent", | |
| panel_border_width="0px", | |
| panel_border_color="transparent", | |
| input_background_fill="#FFFFFF", | |
| input_background_fill_focus="#FFFFFF", | |
| input_border_color="rgba(10,15,40,0.12)", | |
| input_border_color_focus="#BE5B2B", | |
| input_border_width="1px", | |
| input_radius="12px", | |
| input_shadow="none", | |
| input_shadow_focus="0 0 0 3px rgba(190,91,43,0.15)", | |
| code_background_fill="#EEF1F7", | |
| slider_color="#BE5B2B", | |
| button_primary_background_fill="#0D1117", | |
| button_primary_background_fill_hover="#1F2A37", | |
| button_primary_text_color="#FFFFFF", | |
| button_primary_border_color="transparent", | |
| button_primary_shadow="0 1px 2px rgba(10,15,40,0.08), 0 6px 18px rgba(10,15,40,0.12)", | |
| button_secondary_background_fill="#FFFFFF", | |
| button_secondary_background_fill_hover="rgba(190,91,43,0.09)", | |
| button_secondary_text_color="#BE5B2B", | |
| button_secondary_border_color="rgba(10,15,40,0.16)", | |
| button_cancel_background_fill="#FFFFFF", | |
| button_cancel_background_fill_hover="#FEE2E2", | |
| button_cancel_text_color="#DC2626", | |
| button_cancel_border_color="#FCA5A5", | |
| table_border_color="rgba(10,15,40,0.08)", | |
| table_even_background_fill="#FAFBFD", | |
| table_odd_background_fill="#FFFFFF", | |
| ) | |
| CUSTOM_CSS = """ | |
| /* === Quest paper palette applied to the Gradio shell ==================== */ | |
| /* Brings the OSU-NLP Quest microsite aesthetic into the live Space: soft | |
| off-white background, paper-white cards with subtle 1px borders and | |
| low-opacity shadows, terracotta accent, Source Serif 4 for display | |
| headings, Manrope for everything else. */ | |
| :root { | |
| --q-bg: #F2F4F8; | |
| --q-paper: #FFFFFF; | |
| --q-surface-alt: #EEF1F7; | |
| --q-line: rgba(10, 15, 40, 0.08); | |
| --q-line-strong: rgba(10, 15, 40, 0.16); | |
| --q-text: #0D1117; | |
| --q-muted: #64748B; | |
| --q-accent: #BE5B2B; | |
| --q-accent-soft: rgba(190, 91, 43, 0.09); | |
| --q-accent-line: rgba(190, 91, 43, 0.55); | |
| --q-mint: #0B9E8A; | |
| --q-mint-deep: #0A8070; | |
| --q-cover-bg: #0D1117; | |
| --q-shadow: 0 1px 3px rgba(10,15,40,0.04), 0 8px 32px rgba(10,15,40,0.08); | |
| --q-shadow-card: 0 1px 2px rgba(10,15,40,0.05), 0 2px 10px rgba(10,15,40,0.06); | |
| --q-radius-xl: 20px; | |
| --q-radius-lg: 16px; | |
| --q-radius-md: 12px; | |
| } | |
| html, body, gradio-app, [class*="gradio-container"] { | |
| background: var(--q-bg) !important; | |
| } | |
| /* Full-height shell ------------------------------------------------------- */ | |
| html, body { width: 100% !important; min-height: 100vh !important; margin: 0 !important; } | |
| gradio-app { | |
| display: block !important; | |
| width: 100% !important; | |
| min-height: 100vh !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| } | |
| gradio-app > .gradio-container, | |
| gradio-app > div { | |
| display: block !important; | |
| width: 100% !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| } | |
| [class*="gradio-container"] { | |
| max-width: 1400px !important; | |
| width: 100% !important; | |
| min-width: 320px !important; | |
| margin-left: auto !important; | |
| margin-right: auto !important; | |
| padding: 24px 28px 64px !important; | |
| color: var(--q-text); | |
| box-sizing: border-box !important; | |
| font-family: "Manrope", ui-sans-serif, system-ui, sans-serif; | |
| } | |
| [class*="gradio-container"] *::selection { background: rgba(190,91,43,0.18); } | |
| /* Prevent inner wrappers from collapsing when streaming content first arrives. */ | |
| [class*="gradio-container"] .layout-gap { width: 100% !important; } | |
| [class*="gradio-container"] .layout-gap > .gr-column, | |
| [class*="gradio-container"] .layout-gap > div { min-width: 0 !important; } | |
| [class*="gradio-container"] .gradio-markdown, | |
| [class*="gradio-container"] [data-testid="markdown"] { min-height: 220px !important; } | |
| [class*="gradio-container"] .codemirror-wrapper, | |
| [class*="gradio-container"] .cm-editor { min-height: 220px !important; } | |
| /* Long code / markdown cannot push the layout sideways. */ | |
| [class*="gradio-container"] .gradio-code, | |
| [class*="gradio-container"] .gradio-markdown, | |
| [class*="gradio-container"] .prose, | |
| [class*="gradio-container"] .markdown, | |
| [class*="gradio-container"] [data-testid="markdown"], | |
| [class*="gradio-container"] .tabs, | |
| [class*="gradio-container"] .tabitem, | |
| [class*="gradio-container"] .tab-content { | |
| max-width: 100% !important; | |
| width: 100% !important; | |
| min-width: 0 !important; | |
| word-wrap: break-word !important; | |
| overflow-wrap: anywhere !important; | |
| } | |
| [class*="gradio-container"] .codemirror-wrapper { | |
| max-width: 100% !important; | |
| border-radius: 14px !important; | |
| overflow: hidden !important; | |
| } | |
| [class*="gradio-container"] .cm-editor { max-width: 100% !important; overflow: hidden !important; } | |
| [class*="gradio-container"] .cm-scroller { max-width: 100% !important; overflow-x: auto !important; } | |
| [class*="gradio-container"] .cm-content, | |
| [class*="gradio-container"] .cm-line { | |
| max-width: 100% !important; | |
| white-space: pre-wrap !important; | |
| word-break: break-word !important; | |
| } | |
| [class*="gradio-container"] .prose pre, | |
| [class*="gradio-container"] .markdown pre { | |
| max-width: 100% !important; | |
| overflow-x: auto !important; | |
| white-space: pre-wrap !important; | |
| } | |
| /* === Quest-style header ================================================= */ | |
| .quest-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 18px; | |
| padding: 18px 22px; | |
| margin: 8px 0 24px; | |
| border: 1px solid var(--q-line); | |
| border-radius: var(--q-radius-lg); | |
| background: var(--q-paper); | |
| box-shadow: var(--q-shadow-card); | |
| } | |
| .quest-header-mark { | |
| display: grid; | |
| place-items: center; | |
| width: 48px; | |
| height: 48px; | |
| flex-shrink: 0; | |
| border-radius: 12px; | |
| background: var(--q-text); | |
| color: #FFFFFF; | |
| font-family: "Source Serif 4", "Source Serif Pro", ui-serif, Georgia, serif; | |
| font-weight: 700; | |
| font-size: 1.55rem; | |
| } | |
| .quest-header-text { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| min-width: 0; | |
| } | |
| .quest-header-title { | |
| margin: 0; | |
| font-family: "Source Serif 4", "Source Serif Pro", ui-serif, Georgia, serif; | |
| font-weight: 600; | |
| font-size: clamp(1.25rem, 2vw, 1.75rem); | |
| line-height: 1.2; | |
| letter-spacing: -0.01em; | |
| color: var(--q-text); | |
| } | |
| .quest-header-byline { | |
| color: var(--q-muted); | |
| font-size: 0.9rem; | |
| font-weight: 500; | |
| text-decoration: underline; | |
| text-decoration-color: rgba(100,116,139,0.45); | |
| text-underline-offset: 3px; | |
| text-decoration-thickness: 1px; | |
| width: fit-content; | |
| transition: color 140ms ease, text-decoration-color 140ms ease; | |
| } | |
| .quest-header-byline:hover { | |
| color: var(--q-accent); | |
| text-decoration-color: var(--q-accent); | |
| } | |
| /* === Cards (section-card) =============================================== */ | |
| .section-card { | |
| background: var(--q-paper) !important; | |
| border: 1px solid var(--q-line) !important; | |
| border-radius: var(--q-radius-xl) !important; | |
| box-shadow: var(--q-shadow-card) !important; | |
| padding: 22px !important; | |
| } | |
| .no-frame { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| padding: 0 !important; | |
| } | |
| /* Section kicker + hero heading follow the paper treatment. */ | |
| .section-heading { | |
| font-size: 0.7rem; | |
| font-weight: 800; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: var(--q-accent); | |
| margin: 0 0 14px 0; | |
| } | |
| .hero-heading { | |
| font-family: "Source Serif 4", "Source Serif Pro", ui-serif, Georgia, serif !important; | |
| font-weight: 600 !important; | |
| font-size: 1.6rem !important; | |
| letter-spacing: -0.01em !important; | |
| text-transform: none !important; | |
| color: var(--q-text) !important; | |
| } | |
| .quest-name { | |
| font-family: "Source Serif 4", "Source Serif Pro", ui-serif, Georgia, serif !important; | |
| font-style: italic !important; | |
| font-weight: 700 !important; | |
| color: inherit !important; | |
| letter-spacing: 0.005em; | |
| margin: 4px 0 14px 0 !important; | |
| } | |
| .hero-subtitle { | |
| color: var(--q-muted); | |
| font-size: 0.95rem; | |
| line-height: 1.6; | |
| margin: -6px 0 16px 0; | |
| } | |
| /* Layout gap: mirror the paper's column rhythm. */ | |
| .layout-gap { gap: 24px !important; align-items: flex-start; } | |
| .right-stack > * { margin-bottom: 14px; } | |
| .action-row { gap: 10px !important; margin-top: 14px; } | |
| .action-row button { min-width: 0; flex: 1; } | |
| /* === Icon grid (Paper / Code / Dataset / Model) ========================= */ | |
| .icon-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 10px; | |
| width: 100%; | |
| } | |
| .icon-link { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| padding: 11px 14px; | |
| border-radius: 999px; | |
| text-decoration: none !important; | |
| color: var(--q-text) !important; | |
| background: var(--q-paper); | |
| font-weight: 600; | |
| font-size: 0.88rem; | |
| white-space: nowrap; | |
| border: 1px solid var(--q-line-strong); | |
| transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease; | |
| } | |
| .icon-link:hover { | |
| background: var(--q-accent-soft); | |
| border-color: var(--q-accent-line); | |
| color: var(--q-accent) !important; | |
| transform: translateY(-1px); | |
| } | |
| /* === Buttons ============================================================ */ | |
| [class*="gradio-container"] button.primary, | |
| [class*="gradio-container"] .gr-button-primary { | |
| background: var(--q-text) !important; | |
| color: #ffffff !important; | |
| border: 1px solid var(--q-text) !important; | |
| box-shadow: 0 1px 2px rgba(10,15,40,0.08), 0 6px 18px rgba(10,15,40,0.12) !important; | |
| font-weight: 700 !important; | |
| letter-spacing: 0.01em !important; | |
| } | |
| [class*="gradio-container"] button.primary:hover, | |
| [class*="gradio-container"] .gr-button-primary:hover { | |
| background: #1F2A37 !important; | |
| border-color: #1F2A37 !important; | |
| } | |
| [class*="gradio-container"] button.secondary, | |
| [class*="gradio-container"] .gr-button-secondary { | |
| background: var(--q-paper) !important; | |
| color: var(--q-text) !important; | |
| border: 1px solid var(--q-line-strong) !important; | |
| box-shadow: none !important; | |
| font-weight: 600 !important; | |
| } | |
| [class*="gradio-container"] button.secondary:hover, | |
| [class*="gradio-container"] .gr-button-secondary:hover { | |
| background: var(--q-accent-soft) !important; | |
| border-color: var(--q-accent-line) !important; | |
| color: var(--q-accent) !important; | |
| } | |
| [class*="gradio-container"] button.stop, | |
| [class*="gradio-container"] .gr-button-stop { | |
| background: var(--q-paper) !important; | |
| color: #DC2626 !important; | |
| border: 1px solid #FCA5A5 !important; | |
| box-shadow: none !important; | |
| font-weight: 600 !important; | |
| } | |
| [class*="gradio-container"] button.stop:hover, | |
| [class*="gradio-container"] .gr-button-stop:hover { | |
| background: #FEE2E2 !important; | |
| color: #B91C1C !important; | |
| } | |
| /* Flatten every grey block Gradio drops inside our cards. */ | |
| [class*="gradio-container"] .gr-group, | |
| [class*="gradio-container"] fieldset, | |
| [class*="gradio-container"] .gr-box, | |
| [class*="gradio-container"] .gr-panel, | |
| [class*="gradio-container"] .form, | |
| [class*="gradio-container"] .gr-form, | |
| [class*="gradio-container"] .container { | |
| background: transparent !important; | |
| } | |
| .section-card { | |
| --block-shadow: none; | |
| --block-shadow-dark: none; | |
| --block-background-fill: transparent; | |
| --block-border-color: transparent; | |
| --block-border-width: 0px; | |
| --panel-background-fill: transparent; | |
| --panel-border-width: 0px; | |
| --background-fill-secondary: transparent; | |
| --border-color-primary: transparent; | |
| overflow: visible !important; | |
| } | |
| .section-card > div, | |
| .section-card > div > div, | |
| .section-card > div > div > div { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| overflow: visible !important; | |
| } | |
| .section-card .block, | |
| .section-card .form, | |
| .section-card .gr-form, | |
| .section-card .gr-block, | |
| .section-card .gr-panel, | |
| .section-card .gr-group, | |
| .section-card .gradio-dropdown, | |
| .section-card .gradio-slider, | |
| .section-card .gradio-textbox, | |
| .section-card .gradio-markdown, | |
| .section-card .gradio-code { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| overflow: visible !important; | |
| } | |
| .section-card .form, | |
| .section-card .gr-form { | |
| display: flex !important; | |
| flex-direction: column !important; | |
| gap: 14px !important; | |
| } | |
| [class*="gradio-container"] .section-card .row, | |
| [class*="gradio-container"] .section-card [class*="row"] { | |
| display: flex !important; | |
| flex-direction: row !important; | |
| flex-wrap: wrap !important; | |
| gap: 10px !important; | |
| } | |
| .action-row { | |
| display: flex !important; | |
| flex-direction: row !important; | |
| gap: 10px !important; | |
| margin-top: 14px; | |
| } | |
| .action-row > * { flex: 1 1 0; min-width: 0; } | |
| .section-card > * + * { margin-top: 14px; } | |
| /* === Inputs ============================================================= */ | |
| [class*="gradio-container"] textarea, | |
| [class*="gradio-container"] input:not([type="checkbox"]):not([type="radio"]):not([type="range"]) { | |
| background: var(--q-paper) !important; | |
| border: 1px solid var(--q-line-strong) !important; | |
| box-shadow: none !important; | |
| border-radius: var(--q-radius-md) !important; | |
| color: var(--q-text) !important; | |
| font-family: "Manrope", ui-sans-serif, system-ui, sans-serif !important; | |
| } | |
| /* Make the Model Textbox match the Memory Strategy Dropdown's height (46px outer = 44px content + 2*1px border). */ | |
| .section-card [data-testid="textbox"] textarea, | |
| .section-card [data-testid="textbox"] input { | |
| min-height: 44px !important; | |
| padding: 11px 14px !important; | |
| line-height: 1.4 !important; | |
| box-sizing: border-box !important; | |
| } | |
| [class*="gradio-container"] textarea::placeholder, | |
| [class*="gradio-container"] input::placeholder { color: #94A3B8 !important; } | |
| [class*="gradio-container"] textarea:focus, | |
| [class*="gradio-container"] input:focus { | |
| border-color: var(--q-accent) !important; | |
| box-shadow: 0 0 0 3px rgba(190,91,43,0.15) !important; | |
| outline: none !important; | |
| } | |
| /* === Dropdown =========================================================== */ | |
| [class*="gradio-container"] [data-testid="dropdown"], | |
| [class*="gradio-container"] .gradio-dropdown { | |
| background: var(--q-paper) !important; | |
| border: 1px solid var(--q-line-strong) !important; | |
| border-radius: var(--q-radius-md) !important; | |
| box-shadow: none !important; | |
| padding: 0 !important; | |
| min-height: 46px !important; | |
| width: 100% !important; | |
| box-sizing: border-box !important; | |
| } | |
| [class*="gradio-container"] [data-testid="dropdown"] > .wrap, | |
| [class*="gradio-container"] [data-testid="dropdown"] .secondary-wrap, | |
| [class*="gradio-container"] [data-testid="dropdown"] .wrap-inner, | |
| [class*="gradio-container"] [data-testid="dropdown"] .input-container, | |
| [class*="gradio-container"] [data-testid="dropdown"] .single-select, | |
| [class*="gradio-container"] .gradio-dropdown .wrap, | |
| [class*="gradio-container"] .gradio-dropdown .wrap-inner, | |
| [class*="gradio-container"] .gradio-dropdown .secondary-wrap, | |
| [class*="gradio-container"] .gradio-dropdown .input-container, | |
| [class*="gradio-container"] .gradio-dropdown .single-select, | |
| [class*="gradio-container"] [class*="dropdown"] .wrap { | |
| background: transparent !important; | |
| border: 0 !important; | |
| outline: 0 !important; | |
| box-shadow: none !important; | |
| border-radius: 0 !important; | |
| width: 100% !important; | |
| min-height: 44px !important; | |
| padding: 0 14px !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| box-sizing: border-box !important; | |
| } | |
| [class*="gradio-container"] [data-testid="dropdown"] input, | |
| [class*="gradio-container"] .gradio-dropdown input, | |
| [class*="gradio-container"] [data-testid="dropdown"] select, | |
| [class*="gradio-container"] .gradio-dropdown select { | |
| background: transparent !important; | |
| border: 0 !important; | |
| outline: 0 !important; | |
| box-shadow: none !important; | |
| padding: 0 !important; | |
| height: 44px !important; | |
| line-height: 44px !important; | |
| font-size: 0.95rem !important; | |
| width: 100% !important; | |
| border-radius: 0 !important; | |
| } | |
| /* Force-remove any nested pill/rounded background that makes the dropdown | |
| look like it has two concentric frames. */ | |
| [class*="gradio-container"] [data-testid="dropdown"] .container, | |
| [class*="gradio-container"] [data-testid="dropdown"] .wrap > .wrap, | |
| [class*="gradio-container"] .gradio-dropdown .container, | |
| [class*="gradio-container"] .gradio-dropdown .wrap > .wrap { | |
| border: 0 !important; | |
| outline: 0 !important; | |
| box-shadow: none !important; | |
| background: transparent !important; | |
| border-radius: 0 !important; | |
| padding: 0 !important; | |
| } | |
| /* The little caret/arrow icon container — vertically center it */ | |
| [class*="gradio-container"] [data-testid="dropdown"] .icon-wrap, | |
| [class*="gradio-container"] .gradio-dropdown .icon-wrap { | |
| top: 50% !important; | |
| transform: translateY(-50%) !important; | |
| right: 14px !important; | |
| } | |
| [class*="gradio-container"] .options ul, | |
| [class*="gradio-container"] .options { | |
| background: var(--q-paper) !important; | |
| border: 1px solid var(--q-line) !important; | |
| border-radius: var(--q-radius-md) !important; | |
| box-shadow: 0 10px 30px rgba(10,15,40,0.12) !important; | |
| } | |
| [class*="gradio-container"] .options li[aria-selected="true"], | |
| [class*="gradio-container"] .options li:hover { | |
| background: var(--q-accent-soft) !important; | |
| color: var(--q-accent) !important; | |
| } | |
| /* Info hint text under inputs */ | |
| [class*="gradio-container"] .info, | |
| [class*="gradio-container"] [data-testid*="info"], | |
| [class*="gradio-container"] .gr-info { | |
| color: var(--q-muted) !important; | |
| background: transparent !important; | |
| font-size: 12px !important; | |
| } | |
| /* === Sliders ============================================================ */ | |
| /* Flatten the Slider's outer wrapper — Gradio paints a rectangular block | |
| around the label + track + value-input by default; remove it. */ | |
| .section-card .gradio-slider, | |
| .section-card .gradio-slider > div, | |
| .section-card .gradio-slider .form, | |
| .section-card .gradio-slider .gr-form, | |
| .section-card .gradio-slider .wrap, | |
| .section-card .gradio-slider .container, | |
| .section-card .gradio-slider .head { | |
| background: transparent !important; | |
| border: 0 !important; | |
| box-shadow: none !important; | |
| padding: 0 !important; | |
| } | |
| [class*="gradio-container"] input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| background: var(--q-surface-alt); | |
| border-radius: 999px; | |
| outline: none; | |
| box-shadow: none !important; | |
| border: none !important; | |
| } | |
| [class*="gradio-container"] input[type="range"]::-webkit-slider-runnable-track { | |
| height: 6px; | |
| background: linear-gradient(90deg,var(--q-accent) var(--val,50%), var(--q-surface-alt) var(--val,50%)); | |
| border-radius: 999px; | |
| } | |
| [class*="gradio-container"] input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: #ffffff; | |
| border: 2px solid var(--q-accent); | |
| box-shadow: 0 2px 6px rgba(190,91,43,0.25); | |
| margin-top: -6px; | |
| cursor: pointer; | |
| } | |
| [class*="gradio-container"] input[type="range"]::-moz-range-track { | |
| height: 6px; | |
| background: var(--q-surface-alt); | |
| border-radius: 999px; | |
| } | |
| [class*="gradio-container"] input[type="range"]::-moz-range-progress { | |
| height: 6px; | |
| background: var(--q-accent); | |
| border-radius: 999px; | |
| } | |
| [class*="gradio-container"] input[type="range"]::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: #ffffff; | |
| border: 2px solid var(--q-accent); | |
| box-shadow: 0 2px 6px rgba(190,91,43,0.25); | |
| } | |
| /* === Tabs =============================================================== */ | |
| [class*="gradio-container"] .tabs, | |
| [class*="gradio-container"] .tab-container, | |
| [class*="gradio-container"] .tab-wrapper { background: transparent !important; } | |
| [class*="gradio-container"] .tab-container::after { background: var(--q-line) !important; } | |
| [class*="gradio-container"] .tab-wrapper button { | |
| color: var(--q-muted) !important; | |
| font-weight: 700 !important; | |
| letter-spacing: 0.04em !important; | |
| text-transform: uppercase !important; | |
| font-size: 0.78rem !important; | |
| } | |
| [class*="gradio-container"] .tab-wrapper button.selected { color: var(--q-accent) !important; } | |
| [class*="gradio-container"] .tab-wrapper button.selected::after { background: var(--q-accent) !important; } | |
| /* Hide the orange streaming-progress bar that Gradio paints at the top of | |
| the Markdown/Code panel while a run is in flight. */ | |
| [class*="gradio-container"] .progress, | |
| [class*="gradio-container"] .progress-level, | |
| [class*="gradio-container"] .progress-level-inner, | |
| [class*="gradio-container"] .progress-bar, | |
| [class*="gradio-container"] .progress-text, | |
| [class*="gradio-container"] [class*="progress-level"], | |
| [class*="gradio-container"] .generating, | |
| [class*="gradio-container"] div[class*="progress-bar"] { | |
| display: none !important; | |
| background: transparent !important; | |
| border: 0 !important; | |
| height: 0 !important; | |
| } | |
| /* Kill any stray orange/thick separator that Gradio paints above the tab | |
| panel content (border-top or ::before on the tab content wrapper). */ | |
| [class*="gradio-container"] .tabitem, | |
| [class*="gradio-container"] .tab-content, | |
| [class*="gradio-container"] .gradio-tabitem, | |
| [class*="gradio-container"] .tabs > div.tabitem { | |
| border-top: 0 !important; | |
| box-shadow: none !important; | |
| background: transparent !important; | |
| } | |
| [class*="gradio-container"] .tabitem::before, | |
| [class*="gradio-container"] .tab-content::before, | |
| [class*="gradio-container"] .gradio-tabitem::before { content: none !important; } | |
| [class*="gradio-container"] .tab-nav, | |
| [class*="gradio-container"] .tab-wrapper { | |
| border-bottom: 1px solid var(--q-line) !important; | |
| border-top: 0 !important; | |
| } | |
| [class*="gradio-container"] .tab-nav::before, | |
| [class*="gradio-container"] .tab-wrapper::before { content: none !important; } | |
| /* Block labels above components */ | |
| [class*="gradio-container"] .gr-block label, | |
| [class*="gradio-container"] .gradio-slider label, | |
| [class*="gradio-container"] .gradio-dropdown label, | |
| [class*="gradio-container"] .gradio-textbox label { | |
| color: var(--q-muted) !important; | |
| font-weight: 700 !important; | |
| font-size: 0.74rem !important; | |
| letter-spacing: 0.08em !important; | |
| text-transform: uppercase !important; | |
| } | |
| /* === Markdown / prose =================================================== */ | |
| [class*="gradio-container"] .gr-markdown, | |
| [class*="gradio-container"] .prose, | |
| [class*="gradio-container"] .markdown { | |
| color: var(--q-text) !important; | |
| font-family: "Manrope", ui-sans-serif, system-ui, sans-serif !important; | |
| line-height: 1.75; | |
| } | |
| [class*="gradio-container"] .gr-markdown a, | |
| [class*="gradio-container"] .prose a { color: var(--q-accent) !important; text-decoration: underline; text-decoration-color: rgba(190,91,43,0.35); } | |
| [class*="gradio-container"] .gr-markdown a:hover, | |
| [class*="gradio-container"] .prose a:hover { text-decoration-color: var(--q-accent); } | |
| [class*="gradio-container"] .gr-markdown h1, | |
| [class*="gradio-container"] .gr-markdown h2, | |
| [class*="gradio-container"] .gr-markdown h3, | |
| [class*="gradio-container"] .prose h1, | |
| [class*="gradio-container"] .prose h2, | |
| [class*="gradio-container"] .prose h3 { | |
| font-family: "Source Serif 4", "Source Serif Pro", ui-serif, Georgia, serif !important; | |
| font-weight: 600 !important; | |
| letter-spacing: -0.01em !important; | |
| color: var(--q-text) !important; | |
| } | |
| [class*="gradio-container"] .gr-markdown code, | |
| [class*="gradio-container"] .prose code { | |
| background: var(--q-surface-alt); | |
| border: 1px solid var(--q-line); | |
| padding: 1px 6px; | |
| border-radius: 6px; | |
| font-size: 0.9em; | |
| } | |
| /* === Code block (Record tab) ============================================ */ | |
| [class*="gradio-container"] .codemirror-wrapper, | |
| [class*="gradio-container"] .cm-editor, | |
| [class*="gradio-container"] .cm-scroller, | |
| [class*="gradio-container"] .cm-gutters, | |
| [class*="gradio-container"] .cm-content { | |
| background: var(--q-surface-alt) !important; | |
| color: var(--q-text) !important; | |
| border: none !important; | |
| font-family: "JetBrains Mono", ui-monospace, monospace !important; | |
| } | |
| [class*="gradio-container"] .cm-gutters { | |
| border-right: 1px solid var(--q-line) !important; | |
| color: var(--q-muted) !important; | |
| } | |
| /* === Rounded corners on everything ====================================== */ | |
| [class*="gradio-container"] .block, | |
| [class*="gradio-container"] .form, | |
| [class*="gradio-container"] .gr-box, | |
| [class*="gradio-container"] .gr-panel, | |
| [class*="gradio-container"] .gr-group, | |
| [class*="gradio-container"] [data-testid="textbox"], | |
| [class*="gradio-container"] [data-testid="dropdown"], | |
| [class*="gradio-container"] .tabitem, | |
| [class*="gradio-container"] .tab-content, | |
| [class*="gradio-container"] .gradio-markdown, | |
| [class*="gradio-container"] .gradio-code { border-radius: var(--q-radius-md) !important; } | |
| [class*="gradio-container"] button { border-radius: 999px !important; } | |
| /* === Example buttons ==================================================== */ | |
| .example-note { color: var(--q-muted); font-size: 13px; margin: 0 0 12px 0; line-height: 1.5; } | |
| .memory-help { | |
| color: var(--q-muted); | |
| font-size: 12.5px; | |
| line-height: 1.55; | |
| margin: 6px 0 0 0; | |
| padding: 10px 12px; | |
| background: var(--q-surface-alt); | |
| border: 1px solid var(--q-line); | |
| border-radius: 8px; | |
| } | |
| .memory-help b { color: var(--q-text); font-weight: 600; } | |
| .example-buttons { display: grid; gap: 10px; margin-top: 4px; } | |
| [class*="gradio-container"] .example-btn { | |
| text-align: left !important; | |
| justify-content: flex-start !important; | |
| white-space: normal !important; | |
| line-height: 1.5 !important; | |
| padding: 14px 16px !important; | |
| font-size: 14px !important; | |
| color: var(--q-text) !important; | |
| background: var(--q-paper) !important; | |
| border: 1px solid var(--q-line) !important; | |
| border-radius: var(--q-radius-md) !important; | |
| box-shadow: none !important; | |
| font-weight: 500 !important; | |
| letter-spacing: normal !important; | |
| text-transform: none !important; | |
| } | |
| [class*="gradio-container"] .example-btn:hover { | |
| background: var(--q-accent-soft) !important; | |
| border-color: var(--q-accent-line) !important; | |
| color: var(--q-accent) !important; | |
| } | |
| [class*="gradio-container"] .example-btn > * { | |
| color: inherit !important; | |
| white-space: normal !important; | |
| display: inline !important; | |
| } | |
| /* Footer tagline block */ | |
| .quest-footer { | |
| margin-top: 28px; | |
| padding: 18px 24px; | |
| border: 1px solid var(--q-line); | |
| border-radius: var(--q-radius-xl); | |
| background: var(--q-paper); | |
| box-shadow: var(--q-shadow-card); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 20px; | |
| color: var(--q-muted); | |
| font-size: 0.86rem; | |
| line-height: 1.65; | |
| } | |
| .quest-footer a { color: var(--q-muted); text-decoration: none; } | |
| .quest-footer a:hover { color: var(--q-text); } | |
| .quest-footer-links { display: flex; gap: 16px; flex-wrap: wrap; } | |
| /* Tiny mark that replaces the HF watermark block. */ | |
| footer { display: none !important; } | |
| /* === Responsive ========================================================= */ | |
| @media (max-width: 1100px) { | |
| .quest-cover-inner { grid-template-columns: 1fr; } | |
| .quest-cover-panel.wide { grid-column: auto; min-height: 180px; } | |
| } | |
| @media (max-width: 760px) { | |
| [class*="gradio-container"] { padding: 16px !important; } | |
| .quest-footer { flex-direction: column; align-items: flex-start; } | |
| } | |
| """ | |
| class AgentState: | |
| searched_queries: List[str] = field(default_factory=list) | |
| visited_urls: List[str] = field(default_factory=list) | |
| searched_query_set: Set[str] = field(default_factory=set) | |
| visited_url_set: Set[str] = field(default_factory=set) | |
| trusted_notes: List[str] = field(default_factory=list) | |
| trace: List[Dict[str, Any]] = field(default_factory=list) | |
| # Accept a variety of placeholder-only answers: a bare ellipsis (ASCII `...` | |
| # or unicode `…`), a single interpunct, and any whitespace-only content. These | |
| # show up when the model echoes a literal `<answer>...</answer>` template | |
| # from the prompt instead of producing a real answer. | |
| _PLACEHOLDER_ANSWER_RE = re.compile(r"^[\s.\u2026\u00b7]*$") | |
| # Pipe-table separator line, e.g. `| --- | :---: |`. The outer pipes are | |
| # optional in some GFM dialects, so we accept both. | |
| _TABLE_SEPARATOR_RE = re.compile( | |
| r"^\s*\|?\s*:?-{2,}:?(?:\s*\|\s*:?-{2,}:?)+\s*\|?\s*$" | |
| ) | |
| def strip_think_blocks(text: str) -> str: | |
| """Remove any <think>...</think> reasoning blocks. | |
| Quest-4B (Qwen3 family) emits `<think>` reasoning before the final | |
| answer. When the endpoint is deployed without a reasoning parser, the raw | |
| tags leak into chat completion `content`; stripping them here keeps the | |
| extracted answer clean for Markdown rendering. | |
| """ | |
| return re.sub( | |
| r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE | |
| ) | |
| def decode_escaped_whitespace(text: str) -> str: | |
| """Decode literal `\\n`/`\\t`/`\\r` sequences back to real whitespace. | |
| Some OpenAI-compatible servers (and some vLLM builds when a tokenizer's | |
| chat template escapes control characters) return `choices[0].message.content` | |
| with newlines stored as the two-character backslash+n sequence rather than | |
| as a real newline. That breaks Markdown rendering because a pipe table on | |
| a single line is not a table — it is just a sentence with `|` in it, which | |
| is exactly the symptom we saw with: | |
| \\n| Color | Hex |\\n|---|---|\\n| Red | #FF0000 |... | |
| We only decode when the escapes dominate (at least 3 of them, and at | |
| least as many as the real newlines in the text). That keeps us from | |
| corrupting legitimate backslash-n pairs that happen to appear in a code | |
| sample the model produced. | |
| """ | |
| if not text: | |
| return text | |
| escaped_newlines = text.count("\\n") | |
| if escaped_newlines == 0 and "\\t" not in text and "\\r" not in text: | |
| return text | |
| real_newlines = text.count("\n") | |
| if escaped_newlines < max(3, real_newlines + 1): | |
| return text | |
| # Preserve real backslashes so that `\\\\n` (an actual `\n` the model | |
| # wrote) doesn't get collapsed to a newline. | |
| sentinel = "\x00__BS__\x00" | |
| out = text.replace("\\\\", sentinel) | |
| out = out.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t") | |
| out = out.replace(sentinel, "\\") | |
| return out | |
| def _is_placeholder_answer(text: str) -> bool: | |
| return bool(_PLACEHOLDER_ANSWER_RE.match(text or "")) | |
| def ensure_markdown_table_blank_lines(text: str) -> str: | |
| """Insert a blank line before any pipe-table header row. | |
| GitHub-Flavored Markdown requires a pipe table to be preceded by a | |
| paragraph break; otherwise the header row is folded into the previous | |
| paragraph and the whole table renders as raw text. Models sometimes glue | |
| the table directly under a sentence (e.g. "Here's the comparison: | Col | |
| ..."), so we fix that up defensively. | |
| """ | |
| lines = text.split("\n") | |
| out: List[str] = [] | |
| for idx, line in enumerate(lines): | |
| is_header = ( | |
| "|" in line | |
| and idx + 1 < len(lines) | |
| and _TABLE_SEPARATOR_RE.match(lines[idx + 1]) is not None | |
| ) | |
| if is_header and out and out[-1].strip() != "": | |
| out.append("") | |
| out.append(line) | |
| return "\n".join(out) | |
| def extract_answer(text: str) -> Optional[str]: | |
| """Return the content of the first `<answer>...</answer>` block. | |
| Tries two strategies, in order, and discards placeholder-only content | |
| (bare ellipses) that the model sometimes echoes from the prompt: | |
| 1. Well-formed `<answer>...</answer>` block. | |
| 2. Truncated `<answer>...` with no closing tag (tokens ran out); | |
| in that case we take everything after the opening tag. | |
| """ | |
| # Decode escaped whitespace on the whole output first so the <answer> | |
| # regex can actually match the opening and closing tags across lines. | |
| decoded = decode_escaped_whitespace(text or "") | |
| cleaned = strip_think_blocks(decoded) | |
| full_match = re.search( | |
| r"<answer>\s*(.*?)\s*</answer>", | |
| cleaned, | |
| flags=re.DOTALL | re.IGNORECASE, | |
| ) | |
| if full_match is not None: | |
| candidate = decode_escaped_whitespace(full_match.group(1).strip()) | |
| if candidate and not _is_placeholder_answer(candidate): | |
| return candidate | |
| # Closed block was a placeholder / empty: fail fast. Do NOT fall | |
| # through to the open-ended strategy, or it would re-match the same | |
| # tag and incorrectly capture `...</answer>` as the answer. | |
| return None | |
| open_match = re.search( | |
| r"<answer>\s*(.*)$", cleaned, flags=re.DOTALL | re.IGNORECASE | |
| ) | |
| if open_match is not None: | |
| candidate = decode_escaped_whitespace(open_match.group(1).strip()) | |
| if candidate and not _is_placeholder_answer(candidate): | |
| return candidate | |
| return None | |
| def parse_tool_call(text: str) -> Tuple[Optional[str], Optional[Dict[str, Any]], Optional[str]]: | |
| cleaned = strip_think_blocks(text or "") | |
| match = re.search(r"<tool_call>\s*(.*?)\s*</tool_call>", cleaned, flags=re.DOTALL | re.IGNORECASE) | |
| if not match: | |
| return None, None, None | |
| payload = match.group(1).strip() | |
| try: | |
| data = json.loads(payload) | |
| except json.JSONDecodeError: | |
| return None, None, "Invalid JSON in <tool_call> block." | |
| name = data.get("name") | |
| arguments = data.get("arguments", {}) | |
| if not isinstance(name, str) or not isinstance(arguments, dict): | |
| return None, None, "Invalid tool format. Expect name(str) and arguments(dict)." | |
| return name, arguments, None | |
| _SEARCH_UNAVAILABLE_HINT = ( | |
| "The web-search backend is currently rate-limited or unreachable. " | |
| "If this question can be answered confidently from your own training " | |
| "knowledge (e.g. common product specs, historical facts, definitions), " | |
| "please produce your best answer now inside <answer>...</answer>, and " | |
| "mention any value that might be out of date. Only ask the user to " | |
| "retry later if the question truly requires a fresh web lookup." | |
| ) | |
| # Google Serper API key. Either SERPER_API_KEY or SERPER_KEY_ID is accepted | |
| # so that the Space matches the env-var name used by the research repo. | |
| SERPER_API_KEY = ( | |
| os.getenv("SERPER_API_KEY") or os.getenv("SERPER_KEY_ID") or "" | |
| ).strip() | |
| SERPER_ENDPOINT = os.getenv("SERPER_ENDPOINT", "https://google.serper.dev/search") | |
| def _serper_search(query: str, max_results: int) -> Dict[str, Any]: | |
| """Hit the Google Serper API. Returns the same shape as `_ddg_search`. | |
| Serper responds in well under a second and is not subject to the 202 | |
| Ratelimit we get from html.duckduckgo.com, so preferring it when the | |
| key is set cuts latency dramatically and eliminates most search | |
| failures on shared Space IPs. | |
| """ | |
| try: | |
| resp = requests.post( | |
| SERPER_ENDPOINT, | |
| headers={ | |
| "X-API-KEY": SERPER_API_KEY, | |
| "Content-Type": "application/json", | |
| }, | |
| json={"q": query, "num": max_results}, | |
| timeout=15, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| except Exception as exc: | |
| return { | |
| "ok": False, | |
| "query": query, | |
| "error": f"Serper error: {type(exc).__name__}: {exc}", | |
| "results": [], | |
| "backend": "serper", | |
| } | |
| rows: List[Dict[str, str]] = [] | |
| for item in (data.get("organic") or [])[:max_results]: | |
| rows.append( | |
| { | |
| "title": item.get("title", ""), | |
| "href": item.get("link", ""), | |
| "body": item.get("snippet", ""), | |
| } | |
| ) | |
| # Fold in the answer box and knowledge graph when present; these often | |
| # carry the exact fact the model is looking for in a compact form. | |
| answer_box = data.get("answerBox") or {} | |
| if answer_box: | |
| rows.insert( | |
| 0, | |
| { | |
| "title": answer_box.get("title", "Answer box"), | |
| "href": answer_box.get("link", ""), | |
| "body": answer_box.get("snippet") | |
| or answer_box.get("answer") | |
| or "", | |
| }, | |
| ) | |
| if not rows: | |
| return { | |
| "ok": False, | |
| "query": query, | |
| "error": "Serper returned no organic results", | |
| "results": [], | |
| "backend": "serper", | |
| } | |
| return { | |
| "ok": True, | |
| "query": query, | |
| "results": rows, | |
| "cached": False, | |
| "backend": "serper", | |
| } | |
| def _ddg_search(query: str, max_results: int) -> Dict[str, Any]: | |
| """Fallback path: scrape DuckDuckGo. Rate-limits on shared IPs.""" | |
| last_exc: Optional[BaseException] = None | |
| for attempt in range(2): | |
| try: | |
| rows: List[Dict[str, str]] = [] | |
| with DDGS() as ddgs: | |
| for item in ddgs.text(query, max_results=max_results): | |
| rows.append( | |
| { | |
| "title": item.get("title", ""), | |
| "href": item.get("href", ""), | |
| "body": item.get("body", ""), | |
| } | |
| ) | |
| return { | |
| "ok": True, | |
| "query": query, | |
| "results": rows, | |
| "cached": False, | |
| "backend": "duckduckgo", | |
| } | |
| except Exception as exc: | |
| last_exc = exc | |
| if attempt == 0: | |
| time.sleep(1.5) | |
| continue | |
| err = f"{type(last_exc).__name__}: {last_exc}" if last_exc else "unknown error" | |
| return { | |
| "ok": False, | |
| "query": query, | |
| "error": f"DuckDuckGo unavailable ({err}).", | |
| "results": [], | |
| "backend": "duckduckgo", | |
| } | |
| def _run_search_single(query: str, max_results: int) -> Dict[str, Any]: | |
| """Run one search query, preferring Serper when the key is set. | |
| Returns a structured dict on both success and failure; never raises. | |
| Order of preference: | |
| 1. Google Serper (fast, no scraping, requires `SERPER_API_KEY` / | |
| `SERPER_KEY_ID`). | |
| 2. DuckDuckGo HTML backend (free, but rate-limits on shared Space IPs). | |
| 3. Graceful `ok: False` payload with a hint that tells the agent to | |
| answer from its own knowledge if it reasonably can. | |
| """ | |
| if not query.strip(): | |
| return {"ok": False, "error": "Search query cannot be empty."} | |
| cache_key = f"{query.strip().lower()}::{max_results}" | |
| if cache_key in SEARCH_CACHE: | |
| return {**SEARCH_CACHE[cache_key], "cached": True} | |
| tried: List[Dict[str, Any]] = [] | |
| if SERPER_API_KEY: | |
| serper_result = _serper_search(query, max_results) | |
| if serper_result.get("ok"): | |
| SEARCH_CACHE[cache_key] = serper_result | |
| return serper_result | |
| tried.append(serper_result) | |
| ddg_result = _ddg_search(query, max_results) | |
| if ddg_result.get("ok"): | |
| SEARCH_CACHE[cache_key] = ddg_result | |
| return ddg_result | |
| tried.append(ddg_result) | |
| # Both backends failed (or no Serper key and DDG rate-limited). | |
| errors = "; ".join( | |
| f"{r.get('backend', 'unknown')}: {r.get('error', 'no results')}" | |
| for r in tried | |
| ) | |
| return { | |
| "ok": False, | |
| "query": query, | |
| "error": f"All search backends failed ({errors}).", | |
| "results": [], | |
| "hint": _SEARCH_UNAVAILABLE_HINT, | |
| } | |
| def run_search(query: Union[str, List[str]], max_results: int = 5) -> Dict[str, Any]: | |
| """Runs one or more queries through DuckDuckGo. | |
| QUEST's schema passes `query` as an array of strings, while the simpler | |
| starter schema used a single string. We accept both shapes. | |
| """ | |
| if isinstance(query, list): | |
| sub_results: List[Dict[str, Any]] = [] | |
| for q in query: | |
| if not isinstance(q, str) or not q.strip(): | |
| continue | |
| sub_results.append(_run_search_single(q, max_results)) | |
| return {"ok": True, "queries": query, "results": sub_results} | |
| return _run_search_single(str(query or "").strip(), max_results) | |
| def _clean_html_to_text(html: str, max_chars: int) -> str: | |
| soup = BeautifulSoup(html, "html.parser") | |
| for tag in soup(["script", "style", "noscript"]): | |
| tag.decompose() | |
| text = soup.get_text(separator=" ", strip=True) | |
| text = re.sub(r"\s+", " ", text) | |
| return text[:max_chars] | |
| def _run_visit_single(url: str, max_chars: int, goal: str = "") -> Dict[str, Any]: | |
| if not url.strip(): | |
| return {"ok": False, "error": "URL cannot be empty."} | |
| cache_key = f"{url.strip()}::{max_chars}" | |
| if cache_key in VISIT_CACHE: | |
| return {**VISIT_CACHE[cache_key], "cached": True, "goal": goal} | |
| try: | |
| resp = requests.get( | |
| url, | |
| timeout=20, | |
| headers={"User-Agent": "Mozilla/5.0 (compatible; DeepResearchSpace/1.0)"}, | |
| ) | |
| resp.raise_for_status() | |
| content_type = resp.headers.get("content-type", "") | |
| if "text/html" in content_type or "<html" in resp.text[:200].lower(): | |
| text = _clean_html_to_text(resp.text, max_chars=max_chars) | |
| else: | |
| text = resp.text[:max_chars] | |
| payload = {"ok": True, "url": url, "content": text, "cached": False, "goal": goal} | |
| VISIT_CACHE[cache_key] = payload | |
| return payload | |
| except Exception as exc: | |
| return {"ok": False, "url": url, "error": str(exc), "goal": goal} | |
| def run_visit( | |
| url: Union[str, List[str]], | |
| max_chars: int = 6000, | |
| goal: str = "", | |
| ) -> Dict[str, Any]: | |
| """Fetches one or more URLs. Accepts string or list (QUEST schema).""" | |
| if isinstance(url, list): | |
| sub_results: List[Dict[str, Any]] = [] | |
| for u in url: | |
| if not isinstance(u, str) or not u.strip(): | |
| continue | |
| sub_results.append(_run_visit_single(u, max_chars, goal)) | |
| return {"ok": True, "goal": goal, "results": sub_results} | |
| return _run_visit_single(str(url or "").strip(), max_chars, goal) | |
| def _build_client_for_model(model: str) -> Tuple[InferenceClient, str, List[str]]: | |
| """Returns (client, primary_model_id, fallback_model_ids). | |
| When the user picks the Quest model and QUEST_BASE_URL is configured, the | |
| InferenceClient is pointed at the dedicated endpoint; otherwise we hit the | |
| shared HF Inference API and let the starter fall back across free models. | |
| """ | |
| token = os.getenv("HF_TOKEN") | |
| quest_timeout = int(os.getenv("QUEST_REQUEST_TIMEOUT", "600")) | |
| if model == QUEST_MODEL_ID and QUEST_BASE_URL: | |
| client = InferenceClient( | |
| base_url=QUEST_BASE_URL, | |
| token=token, | |
| timeout=quest_timeout, | |
| ) | |
| return client, QUEST_ENDPOINT_MODEL, [] | |
| client = InferenceClient(token=token, timeout=quest_timeout) | |
| return client, model, [] | |
| def call_model( | |
| client: InferenceClient, | |
| messages: List[Dict[str, str]], | |
| preferred_model: str, | |
| candidate_models: List[str], | |
| temperature: float, | |
| max_new_tokens: int, | |
| ) -> Tuple[str, str]: | |
| model_order: List[str] = [] | |
| for m in [preferred_model] + candidate_models: | |
| if m and m not in model_order: | |
| model_order.append(m) | |
| last_error = None | |
| for model_name in model_order: | |
| try: | |
| completion = client.chat_completion( | |
| model=model_name, | |
| messages=messages, | |
| temperature=temperature, | |
| max_tokens=max_new_tokens, | |
| ) | |
| return completion.choices[0].message.content or "", model_name | |
| except Exception as exc: | |
| last_error = exc | |
| continue | |
| raise RuntimeError(f"All model candidates failed. Last error: {last_error}") | |
| def _render_progress( | |
| lines: List[str], | |
| used_model: str, | |
| question: str, | |
| ) -> str: | |
| """Render the in-progress status view that replaces the Markdown panel | |
| while the agent is still running, so the user is not staring at a blank | |
| box for the 20-60 seconds a full Quest-4B research run can take.""" | |
| header = ( | |
| f"### ⏳ Researching…\n\n" | |
| f"**Model:** `{used_model}` \n" | |
| f"**Question:** {question.strip()[:200]}" | |
| ) | |
| if not lines: | |
| body = "_Starting agent…_" | |
| else: | |
| body = "\n".join(f"- {line}" for line in lines) | |
| return f"{header}\n\n{body}" | |
| def _trace_to_json(state: "AgentState", used_model: str) -> str: | |
| return json.dumps( | |
| { | |
| "used_model": used_model, | |
| "searched_queries": state.searched_queries, | |
| "visited_urls": state.visited_urls, | |
| "trusted_notes": state.trusted_notes[-10:], | |
| "trace": state.trace, | |
| }, | |
| ensure_ascii=False, | |
| indent=2, | |
| ) | |
| MEMORY_STRATEGIES = ("vanilla", "condenser", "discard-all", "hide-tool-results") | |
| def _normalize_memory_strategy(strategy: str) -> str: | |
| s = (strategy or "condenser").strip().lower().replace("_", "-") | |
| return s if s in MEMORY_STRATEGIES else "condenser" | |
| def _apply_memory_strategy(messages: List[Dict[str, str]], strategy: str, turn: int) -> None: | |
| """Keep the message history inside a manageable context budget. | |
| - condenser: no-op (the main loop also injects a periodic trusted-note | |
| summary; that is the light "condenser" this Space ships with). | |
| - discard-all: every 8 turns, reset history to [system, user question] | |
| so the model pays for fresh context rather than replaying old tool | |
| results. | |
| - hide-tool-results: cap the number of surviving tool-response user | |
| messages at 3 — older ones get their content replaced with a stub. | |
| """ | |
| if strategy == "discard-all": | |
| if turn > 1 and turn % 8 == 0 and len(messages) > 2: | |
| system_msg = messages[0] | |
| question_msg = messages[1] | |
| messages.clear() | |
| messages.append(system_msg) | |
| messages.append(question_msg) | |
| messages.append( | |
| { | |
| "role": "user", | |
| "content": "[memory discarded at turn " | |
| f"{turn} — continue the research from the original question]", | |
| } | |
| ) | |
| elif strategy == "hide-tool-results": | |
| keep_tail = 3 | |
| tool_indices = [ | |
| i for i, m in enumerate(messages) | |
| if m.get("role") == "user" and str(m.get("content", "")).startswith("<tool_response>") | |
| ] | |
| if len(tool_indices) > keep_tail: | |
| for i in tool_indices[:-keep_tail]: | |
| if messages[i]["content"] != "<tool_response>[hidden]</tool_response>": | |
| messages[i] = { | |
| "role": "user", | |
| "content": "<tool_response>[hidden]</tool_response>", | |
| } | |
| def build_research_agent( | |
| question: str, | |
| model: str, | |
| max_turns: int, | |
| temperature: float, | |
| memory_strategy: str = "condenser", | |
| ): | |
| """Run the ReAct research loop as a generator. | |
| Each `yield` emits a `(markdown_for_answer_panel, json_for_record_panel)` | |
| tuple. Intermediate yields show progress so that Gradio streams the | |
| status lines into the UI as work happens. The last yield contains the | |
| final answer and the final trace. | |
| """ | |
| client, primary_model, fallback_models = _build_client_for_model(model) | |
| # Display label: the real HF repo id is nicer than the TGI shim name. | |
| display_primary = model if (model == QUEST_MODEL_ID) else primary_model | |
| state = AgentState() | |
| used_model = display_primary | |
| status_lines: List[str] = [] | |
| def _emit(): | |
| """Yield the current progress snapshot to Gradio.""" | |
| return ( | |
| _render_progress(status_lines, used_model, question), | |
| _trace_to_json(state, used_model), | |
| ) | |
| messages: List[Dict[str, str]] = [ | |
| {"role": "system", "content": build_system_prompt()}, | |
| {"role": "user", "content": question}, | |
| ] | |
| final_answer: Optional[str] = None | |
| status_lines.append("🚀 Starting research agent") | |
| yield _emit() | |
| strategy = _normalize_memory_strategy(memory_strategy) | |
| os.environ["MEMORY_STRATEGY"] = strategy | |
| for turn in range(1, max_turns + 1): | |
| _apply_memory_strategy(messages, strategy, turn) | |
| if strategy == "condenser" and state.trusted_notes and turn > 1 and turn % 3 == 0: | |
| summary_lines = "\n".join(f"- {n}" for n in state.trusted_notes[-6:]) | |
| messages.append( | |
| { | |
| "role": "user", | |
| "content": f"RESEARCH STATE SUMMARY\n{summary_lines}\nUse this summary to avoid repeating work.", | |
| } | |
| ) | |
| status_lines.append(f"🧠 turn {turn}: thinking…") | |
| yield _emit() | |
| t0 = time.time() | |
| raw_output, endpoint_model = call_model( | |
| client=client, | |
| messages=messages, | |
| preferred_model=primary_model, | |
| candidate_models=fallback_models, | |
| temperature=temperature, | |
| max_new_tokens=int(os.getenv("QUEST_MAX_NEW_TOKENS", "4096")), | |
| ) | |
| dt = time.time() - t0 | |
| model_output = raw_output | |
| # Preserve the human-friendly model id for the trace even if the | |
| # endpoint ignores the "model" param and returns the TGI shim name. | |
| used_model = display_primary if endpoint_model == primary_model == QUEST_ENDPOINT_MODEL else endpoint_model | |
| messages.append({"role": "assistant", "content": model_output}) | |
| state.trace.append({"turn": turn, "assistant": model_output, "elapsed_s": round(dt, 2)}) | |
| status_lines[-1] = f"🧠 turn {turn}: model reply in {dt:.1f}s" | |
| yield _emit() | |
| extracted_answer = extract_answer(model_output) | |
| if extracted_answer: | |
| final_answer = extracted_answer | |
| status_lines.append("✍️ writing final answer") | |
| yield _emit() | |
| break | |
| tool_name, tool_args, tool_err = parse_tool_call(model_output) | |
| if tool_err: | |
| tool_response = {"ok": False, "error": tool_err} | |
| status_lines.append(f"⚠️ turn {turn}: malformed tool call — {tool_err}") | |
| yield _emit() | |
| elif not tool_name: | |
| # No explicit tool call and no final answer: force finalization. | |
| # IMPORTANT: do not write the literal characters `<answer>...</answer>` | |
| # here. Some models (notably the Qwen3 family that Quest-4B is | |
| # built on) will echo the template verbatim, which means the | |
| # extracted answer ends up being the three-dot placeholder `...` | |
| # and the user sees an empty-looking result. | |
| messages.append( | |
| { | |
| "role": "user", | |
| "content": ( | |
| "You did not call a tool and did not produce a final " | |
| "answer. Please now write your best final answer, " | |
| "wrapped between an opening <answer> tag and a " | |
| "closing </answer> tag. Put the real answer text " | |
| "between those tags; do not write a literal ellipsis " | |
| "or other placeholder. If the question asks for " | |
| "tabular data, use GitHub-Flavored Markdown pipe " | |
| "tables (`| col1 | col2 |` + `|---|---|`) and put a " | |
| "blank line before the first row so the table renders." | |
| ), | |
| } | |
| ) | |
| status_lines.append(f"🙃 turn {turn}: model stalled; asking for an answer") | |
| yield _emit() | |
| continue | |
| else: | |
| if tool_name == "search": | |
| raw_query = tool_args.get("query", "") | |
| queries: List[str] | |
| if isinstance(raw_query, list): | |
| queries = [str(q).strip() for q in raw_query if str(q).strip()] | |
| else: | |
| queries = [str(raw_query).strip()] if str(raw_query).strip() else [] | |
| max_results = int(tool_args.get("max_results", DEFAULT_MAX_SEARCH_RESULTS)) | |
| max_results = max(1, min(max_results, DEFAULT_MAX_SEARCH_RESULTS)) | |
| queries_preview = ", ".join(f"`{q}`" for q in queries) or "_(empty)_" | |
| status_lines.append(f"🔍 turn {turn}: searching {queries_preview}") | |
| yield _emit() | |
| per_query: List[Dict[str, Any]] = [] | |
| backend_labels: List[str] = [] | |
| hits_total = 0 | |
| for q in queries: | |
| if q in state.searched_query_set: | |
| per_query.append({ | |
| "ok": True, | |
| "query": q, | |
| "cached": True, | |
| "note": "Already searched; reusing cached result.", | |
| "results": [], | |
| }) | |
| backend_labels.append("cache") | |
| continue | |
| state.searched_queries.append(q) | |
| state.searched_query_set.add(q) | |
| single = _run_search_single(q, max_results) | |
| per_query.append(single) | |
| backend_labels.append(single.get("backend", "unknown")) | |
| if single.get("ok"): | |
| hits_total += len(single.get("results", [])) | |
| first_titles = [r.get("title", "") for r in single.get("results", [])[:2]] | |
| if first_titles: | |
| state.trusted_notes.append( | |
| f"Searched '{q}' and found leads: {', '.join(t for t in first_titles if t)}" | |
| ) | |
| else: | |
| status_lines.append( | |
| f"⚠️ search failed on `{q}` via {single.get('backend', 'unknown')}: " | |
| f"{single.get('error', 'no results')}" | |
| ) | |
| tool_response = ( | |
| per_query[0] | |
| if len(per_query) == 1 | |
| else {"ok": True, "queries": queries, "results": per_query} | |
| ) | |
| unique_backends = sorted(set(backend_labels)) | |
| backend_str = "/".join(unique_backends) if unique_backends else "?" | |
| status_lines.append( | |
| f"✅ turn {turn}: got {hits_total} hit(s) via {backend_str}" | |
| ) | |
| yield _emit() | |
| elif tool_name == "visit": | |
| raw_url = tool_args.get("url", "") | |
| urls: List[str] | |
| if isinstance(raw_url, list): | |
| urls = [str(u).strip() for u in raw_url if str(u).strip()] | |
| else: | |
| urls = [str(raw_url).strip()] if str(raw_url).strip() else [] | |
| goal = str(tool_args.get("goal", "")).strip() | |
| max_chars = int(tool_args.get("max_chars", 6000)) | |
| max_chars = max(500, min(max_chars, 20000)) | |
| urls_preview = ", ".join(f"`{u[:60]}`" for u in urls) or "_(empty)_" | |
| status_lines.append(f"🌐 turn {turn}: visiting {urls_preview}") | |
| yield _emit() | |
| per_url: List[Dict[str, Any]] = [] | |
| visit_ok = 0 | |
| for u in urls: | |
| if u in state.visited_url_set: | |
| per_url.append({ | |
| "ok": True, | |
| "url": u, | |
| "cached": True, | |
| "note": "Already visited; reusing cached result.", | |
| }) | |
| visit_ok += 1 | |
| continue | |
| state.visited_urls.append(u) | |
| state.visited_url_set.add(u) | |
| single = _run_visit_single(u, max_chars, goal) | |
| per_url.append(single) | |
| if single.get("ok"): | |
| visit_ok += 1 | |
| snippet = str(single.get("content", ""))[:180] | |
| if snippet: | |
| state.trusted_notes.append( | |
| f"Visited {u} and extracted key context: {snippet}" | |
| ) | |
| tool_response = ( | |
| per_url[0] | |
| if len(per_url) == 1 | |
| else {"ok": True, "goal": goal, "results": per_url} | |
| ) | |
| status_lines.append( | |
| f"✅ turn {turn}: read {visit_ok}/{len(urls)} page(s)" | |
| ) | |
| yield _emit() | |
| else: | |
| tool_response = {"ok": False, "error": f"Unknown tool: {tool_name}"} | |
| status_lines.append(f"⚠️ turn {turn}: unknown tool `{tool_name}`") | |
| yield _emit() | |
| state.trace.append({"turn": turn, "tool": tool_name, "tool_response": tool_response}) | |
| messages.append( | |
| { | |
| "role": "user", | |
| "content": TOOL_RESPONSE_TEMPLATE.format( | |
| payload=json.dumps(tool_response, ensure_ascii=False) | |
| ), | |
| } | |
| ) | |
| if final_answer is None: | |
| final_answer = ( | |
| "I could not finish a complete research answer within the configured turns. " | |
| "Try increasing max turns or switching to a stronger model." | |
| ) | |
| else: | |
| final_answer = ensure_markdown_table_blank_lines(final_answer) | |
| citations = "\n".join(f"- {url}" for url in sorted(set(state.visited_urls))) | |
| final_answer = f"**Model used:** `{used_model}`\n\n{final_answer}" | |
| if citations: | |
| final_answer = f"{final_answer}\n\n### Visited Sources\n{citations}" | |
| trace_text = _trace_to_json(state, used_model) | |
| yield (final_answer, trace_text) | |
| def run_ui( | |
| question: str, | |
| max_turns: int, | |
| memory_strategy: str, | |
| temperature: float, | |
| ): | |
| if not question.strip(): | |
| yield "Please input a question.", "{}" | |
| return | |
| if not os.getenv("HF_TOKEN"): | |
| warning = ( | |
| "HF_TOKEN is not configured in Space Secrets. " | |
| "Go to Settings -> Secrets -> add `HF_TOKEN`, then retry." | |
| ) | |
| yield warning, json.dumps({"error": warning}, ensure_ascii=False, indent=2) | |
| return | |
| if not QUEST_BASE_URL: | |
| warning = ( | |
| f"`{QUEST_MODEL_ID}` needs a private HF Inference Endpoint. " | |
| "Create one at https://ui.endpoints.huggingface.co/, then set " | |
| "`QUEST_BASE_URL` in Space Secrets to the endpoint's `/v1/` URL." | |
| ) | |
| yield warning, json.dumps({"error": warning}, ensure_ascii=False, indent=2) | |
| return | |
| try: | |
| for partial_answer, partial_trace in build_research_agent( | |
| question=question, | |
| model=QUEST_MODEL_ID, | |
| max_turns=max_turns, | |
| temperature=temperature, | |
| memory_strategy=memory_strategy, | |
| ): | |
| yield partial_answer, partial_trace | |
| except Exception as exc: | |
| yield f"Error: {exc}", json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2) | |
| EXAMPLES = [ | |
| { | |
| "category": "Multi-hop facts", | |
| "icon": "🎯", | |
| "text": "Who was the first person to walk on the Moon, and which U.S. President set that goal in his famous 1962 “Moon speech”?", | |
| }, | |
| { | |
| "category": "Time-varying + multi-hop", | |
| "icon": "📈", | |
| "text": "Who is the current CEO of the company that acquired GitHub in 2018, and what was that company's market capitalization at the close of the most recent quarter?", | |
| }, | |
| { | |
| "category": "Multi-constraint", | |
| "icon": "🧩", | |
| "text": "Find a 2-day itinerary in Tokyo under $250 focused on contemporary art museums and vegetarian restaurants, including transit between sites.", | |
| }, | |
| { | |
| "category": "Long-form research report", | |
| "icon": "📚", | |
| "text": "Compare the LLM-safety research approaches of Anthropic, OpenAI, and Google DeepMind over the past 18 months, focusing on alignment techniques and red-teaming methodologies.", | |
| }, | |
| ] | |
| def _example_label(ex: Dict[str, str]) -> str: | |
| return f"{ex['icon']} {ex['category']} — {ex['text']}" | |
| with gr.Blocks( | |
| title="Quest · Deep Research by OSU NLP", | |
| theme=APP_THEME, | |
| css=CUSTOM_CSS, | |
| fill_width=True, | |
| ) as demo: | |
| # --- Quest-style header (Q mark + title + byline) --- | |
| gr.HTML( | |
| """ | |
| <header class="quest-header"> | |
| <div class="quest-header-text"> | |
| <h1 class="quest-header-title"><span class="quest-name">Quest</span>: A Fully Open Recipe for Training Deep Research Agents from Scratch</h1> | |
| <a class="quest-header-byline" href="https://x.com/osunlp" target="_blank" rel="noopener noreferrer">Built by OSU NLP Group</a> | |
| </div> | |
| </header> | |
| """ | |
| ) | |
| # --- Main two-column layout --- | |
| with gr.Row(elem_classes="layout-gap"): | |
| with gr.Column(scale=6, min_width=420): | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML( | |
| '<div class="section-heading">Ask the agent</div>' | |
| '<div class="hero-heading"><span class="quest-name">Quest</span>: What I can research for you?</div>' | |
| ) | |
| question = gr.Textbox( | |
| show_label=False, | |
| placeholder="Ask anything you want to research in depth...", | |
| lines=6, | |
| ) | |
| with gr.Row(elem_classes="action-row"): | |
| run_btn = gr.Button("Run Research", variant="primary", size="lg") | |
| stop_btn = gr.Button("Stop", variant="stop", size="lg") | |
| clear_btn = gr.Button("Clear", variant="secondary", size="lg") | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML( | |
| '<div class="section-heading">Try examples</div>' | |
| '<div class="example-note"><span class="quest-name">Quest</span> can handle multiple types of queries as shown below.</div>' | |
| ) | |
| with gr.Column(elem_classes="example-buttons"): | |
| example_buttons = [ | |
| gr.Button(_example_label(ex), variant="secondary", elem_classes="example-btn") | |
| for ex in EXAMPLES | |
| ] | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML('<div class="section-heading">Output</div>') | |
| with gr.Tabs(): | |
| with gr.TabItem("Result"): | |
| answer = gr.Markdown(label="Final Answer") | |
| with gr.TabItem("Record"): | |
| trace = gr.Code(label="Execution Trace (JSON)", language="json") | |
| with gr.Column(scale=4, min_width=340, elem_classes="right-stack"): | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML( | |
| f""" | |
| <div class="section-heading">Open release</div> | |
| <div class="icon-grid"> | |
| <a class="icon-link" href="{PAPER_URL}" target="_blank" rel="noopener noreferrer">Paper</a> | |
| <a class="icon-link" href="{CODE_URL}" target="_blank" rel="noopener noreferrer">Code</a> | |
| <a class="icon-link" href="{DATASET_URL}" target="_blank" rel="noopener noreferrer">Dataset</a> | |
| <a class="icon-link" href="{MODEL_URL}" target="_blank" rel="noopener noreferrer">Model</a> | |
| </div> | |
| """ | |
| ) | |
| with gr.Group(elem_classes="section-card"): | |
| gr.HTML('<div class="section-heading">Settings</div>') | |
| gr.Textbox( | |
| label="Model", | |
| value=QUEST_MODEL_ID, | |
| interactive=False, | |
| ) | |
| memory_strategy = gr.Dropdown( | |
| label="Memory Strategy", | |
| choices=list(MEMORY_STRATEGIES), | |
| value="condenser", | |
| ) | |
| gr.HTML( | |
| '<div class="memory-help">' | |
| '<b>vanilla</b> — full history kept every turn, no management.<br>' | |
| '<b>condenser</b> — keep history, inject a research-state summary every 3 turns.<br>' | |
| '<b>discard-all</b> — every 8 turns, reset to system prompt + original question only.<br>' | |
| '<b>hide-tool-results</b> — keep at most the 3 most recent tool responses; older ones are stubbed out.' | |
| '</div>' | |
| ) | |
| max_turns = gr.Slider( | |
| label="Max Turns", | |
| minimum=2, | |
| maximum=100, | |
| value=6, | |
| step=1, | |
| ) | |
| temperature = gr.Slider( | |
| label="Temperature", | |
| minimum=0.0, | |
| maximum=1.5, | |
| value=1.0, | |
| step=0.1, | |
| ) | |
| gr.HTML( | |
| """ | |
| <footer class="quest-footer"> | |
| <p>Quest is a fully open recipe for training deep research agents from scratch — covering data synthesis, memory management, infrastructure, and long-horizon training.</p> | |
| <div class="quest-footer-links"> | |
| <a href="https://nlp.osu.edu/" target="_blank" rel="noopener noreferrer">OSU NLP</a> | |
| <a href="https://huggingface.co/osunlp" target="_blank" rel="noopener noreferrer">Hugging Face</a> | |
| </div> | |
| </footer> | |
| """ | |
| ) | |
| run_event = run_btn.click( | |
| fn=run_ui, | |
| inputs=[question, max_turns, memory_strategy, temperature], | |
| outputs=[answer, trace], | |
| ) | |
| for btn, ex in zip(example_buttons, EXAMPLES): | |
| btn.click( | |
| fn=(lambda text=ex["text"]: text), | |
| inputs=[], | |
| outputs=[question], | |
| ) | |
| stop_btn.click(fn=None, cancels=[run_event]) | |
| clear_btn.click( | |
| fn=lambda: ("", "", "{}"), | |
| inputs=[], | |
| outputs=[question, answer, trace], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |