CatoG commited on
Commit
e536b80
·
1 Parent(s): 51db9a3

Add main application logic and tool integrations

Browse files
Files changed (1) hide show
  1. app.py +739 -0
app.py ADDED
@@ -0,0 +1,739 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import uuid
4
+ import random
5
+ import warnings
6
+ import traceback
7
+ from datetime import datetime, timezone
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from dotenv import load_dotenv
11
+
12
+ warnings.filterwarnings("ignore", category=UserWarning, module="wikipedia")
13
+ load_dotenv()
14
+
15
+ if os.path.exists("/data"):
16
+ os.environ.setdefault("HF_HOME", "/data/.huggingface")
17
+
18
+ import gradio as gr
19
+ import yfinance as yf
20
+ import matplotlib
21
+ matplotlib.use("Agg")
22
+ import matplotlib.pyplot as plt
23
+
24
+ from huggingface_hub.errors import HfHubHTTPError
25
+
26
+ from langchain_core.tools import tool
27
+ from langchain.agents import create_agent
28
+ from langchain_community.utilities import WikipediaAPIWrapper, ArxivAPIWrapper
29
+ from langchain_community.tools import DuckDuckGoSearchRun, ArxivQueryRun
30
+ from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
31
+
32
+
33
+ # ============================================================
34
+ # Config
35
+ # ============================================================
36
+
37
+ HF_TOKEN = os.getenv("HUGGINGFACEHUB_API_TOKEN") or os.getenv("HF_TOKEN")
38
+ if not HF_TOKEN:
39
+ raise ValueError("Missing Hugging Face token. Set HUGGINGFACEHUB_API_TOKEN or HF_TOKEN.")
40
+
41
+ MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "1024"))
42
+ CHART_DIR = "charts"
43
+ os.makedirs(CHART_DIR, exist_ok=True)
44
+
45
+ MODEL_OPTIONS = [
46
+ # Meta / Llama
47
+ "meta-llama/Llama-3.1-8B-Instruct",
48
+ "meta-llama/Llama-3.3-70B-Instruct",
49
+
50
+ # OpenAI
51
+ "openai/gpt-oss-20b",
52
+ "openai/gpt-oss-120b",
53
+
54
+ # Qwen
55
+ "Qwen/Qwen3-VL-8B-Instruct",
56
+ "Qwen/Qwen2.5-7B-Instruct",
57
+ "Qwen/Qwen3-8B",
58
+ "Qwen/Qwen3-32B",
59
+
60
+ # Baidu
61
+ "baidu/ERNIE-4.5-21B-A3B-PT",
62
+
63
+ # DeepSeek
64
+ "deepseek-ai/DeepSeek-R1",
65
+ "deepseek-ai/DeepSeek-V3-0324",
66
+
67
+ # GLM
68
+ "zai-org/GLM-5",
69
+ "zai-org/GLM-4.7",
70
+ "zai-org/GLM-4.6",
71
+ "zai-org/GLM-4.5",
72
+
73
+ # MiniMax / Kimi
74
+ "MiniMaxAI/MiniMax-M2.5",
75
+ "moonshotai/Kimi-K2.5",
76
+ "moonshotai/Kimi-K2-Instruct-0905",
77
+ ]
78
+
79
+ DEFAULT_MODEL_ID = "openai/gpt-oss-20b"
80
+
81
+ MODEL_NOTES = {
82
+ "meta-llama/Llama-3.1-8B-Instruct": "Provider model. May require gated access depending on your token.",
83
+ "meta-llama/Llama-3.3-70B-Instruct": "Large provider model. Likely slower and may hit rate limits.",
84
+ "openai/gpt-oss-20b": "Provider model. Good showcase option if available in your enabled providers.",
85
+ "openai/gpt-oss-120b": "Large provider model. May call tools but sometimes fail to return final text.",
86
+ "Qwen/Qwen3-VL-8B-Instruct": "Vision-language model. In this text-only UI it behaves as text-only.",
87
+ "Qwen/Qwen2.5-7B-Instruct": "Provider model. Usually a safer text-only fallback.",
88
+ "Qwen/Qwen3-8B": "Provider model. Availability depends on enabled providers.",
89
+ "Qwen/Qwen3-32B": "Large provider model. Availability depends on enabled providers.",
90
+ "baidu/ERNIE-4.5-21B-A3B-PT": "Provider model. Availability depends on enabled providers.",
91
+ "deepseek-ai/DeepSeek-R1": "Provider model. Availability depends on enabled providers.",
92
+ "deepseek-ai/DeepSeek-V3-0324": "Provider model. Availability depends on enabled providers.",
93
+ "zai-org/GLM-5": "Provider model. Availability depends on enabled providers.",
94
+ "zai-org/GLM-4.7": "Provider model. Availability depends on enabled providers.",
95
+ "zai-org/GLM-4.6": "Provider model. Availability depends on enabled providers.",
96
+ "zai-org/GLM-4.5": "Provider model. Availability depends on enabled providers.",
97
+ "MiniMaxAI/MiniMax-M2.5": "Provider model. Availability depends on enabled providers.",
98
+ "moonshotai/Kimi-K2.5": "Provider model. Availability depends on enabled providers.",
99
+ "moonshotai/Kimi-K2-Instruct-0905": "Provider model. Availability depends on enabled providers.",
100
+ }
101
+
102
+ LLM_CACHE: Dict[str, object] = {}
103
+ AGENT_CACHE: Dict[Tuple[str, Tuple[str, ...]], object] = {}
104
+ RUNTIME_HEALTH: Dict[str, str] = {}
105
+
106
+
107
+ # ============================================================
108
+ # Shared wrappers
109
+ # ============================================================
110
+
111
+ try:
112
+ ddg_search = DuckDuckGoSearchRun()
113
+ except Exception:
114
+ ddg_search = None
115
+
116
+ arxiv_tool = ArxivQueryRun(
117
+ api_wrapper=ArxivAPIWrapper(
118
+ top_k_results=3,
119
+ doc_content_chars_max=1200,
120
+ )
121
+ )
122
+
123
+
124
+ # ============================================================
125
+ # Model helpers
126
+ # ============================================================
127
+
128
+ def model_status_text(model_id: str) -> str:
129
+ note = MODEL_NOTES.get(model_id, "Provider model.")
130
+ health = RUNTIME_HEALTH.get(model_id)
131
+
132
+ if health == "ok":
133
+ return note
134
+ if health == "unavailable":
135
+ return note + " This model previously failed because no enabled provider supported it."
136
+ if health == "gated":
137
+ return note + " This model previously failed due to access restrictions."
138
+ if health == "rate_limited":
139
+ return note + " This model previously hit rate limiting."
140
+ if health == "empty_final":
141
+ return note + " This model previously called tools but returned no final assistant text."
142
+ if health == "error":
143
+ return note + " This model previously failed with a backend/runtime error."
144
+ return note
145
+
146
+
147
+ def build_provider_chat(model_id: str):
148
+ if model_id in LLM_CACHE:
149
+ return LLM_CACHE[model_id]
150
+
151
+ llm = HuggingFaceEndpoint(
152
+ repo_id=model_id,
153
+ task="text-generation",
154
+ provider="auto",
155
+ huggingfacehub_api_token=HF_TOKEN,
156
+ max_new_tokens=MAX_NEW_TOKENS,
157
+ temperature=0.1,
158
+ timeout=120,
159
+ )
160
+ chat = ChatHuggingFace(llm=llm)
161
+ LLM_CACHE[model_id] = chat
162
+ return chat
163
+
164
+
165
+ # ============================================================
166
+ # Chart helpers
167
+ # ============================================================
168
+
169
+ def save_line_chart(
170
+ title: str,
171
+ x_values: List[str],
172
+ y_values: List[float],
173
+ x_label: str = "X",
174
+ y_label: str = "Y",
175
+ ) -> str:
176
+ path = os.path.join(CHART_DIR, f"{uuid.uuid4().hex}.png")
177
+
178
+ fig, ax = plt.subplots(figsize=(9, 4.8))
179
+ ax.plot(x_values, y_values)
180
+ ax.set_title(title)
181
+ ax.set_xlabel(x_label)
182
+ ax.set_ylabel(y_label)
183
+ ax.grid(True)
184
+ fig.autofmt_xdate()
185
+ fig.tight_layout()
186
+ fig.savefig(path, bbox_inches="tight")
187
+ plt.close(fig)
188
+
189
+ return path
190
+
191
+
192
+ def extract_chart_path(text: str) -> Optional[str]:
193
+ if not text:
194
+ return None
195
+
196
+ match = re.search(r"Chart saved to:\s*(.+\.png)", text)
197
+ if not match:
198
+ return None
199
+
200
+ candidate = match.group(1).strip()
201
+ if os.path.exists(candidate):
202
+ return candidate
203
+
204
+ abs_path = os.path.abspath(candidate)
205
+ if os.path.exists(abs_path):
206
+ return abs_path
207
+
208
+ return None
209
+
210
+
211
+ def content_to_text(content) -> str:
212
+ if isinstance(content, str):
213
+ return content
214
+ if isinstance(content, list):
215
+ parts = []
216
+ for item in content:
217
+ if isinstance(item, str):
218
+ parts.append(item)
219
+ elif isinstance(item, dict) and "text" in item:
220
+ parts.append(item["text"])
221
+ else:
222
+ parts.append(str(item))
223
+ return "\n".join(parts).strip()
224
+ return str(content)
225
+
226
+
227
+ def short_text(text: str, limit: int = 1200) -> str:
228
+ text = text or ""
229
+ return text if len(text) <= limit else text[:limit] + "..."
230
+
231
+
232
+ # ============================================================
233
+ # Tools
234
+ # ============================================================
235
+
236
+ @tool
237
+ def add_numbers(a: float, b: float) -> float:
238
+ """Add two numbers."""
239
+ return a + b
240
+
241
+
242
+ @tool
243
+ def subtract_numbers(a: float, b: float) -> float:
244
+ """Subtract the second number from the first."""
245
+ return a - b
246
+
247
+
248
+ @tool
249
+ def multiply_numbers(a: float, b: float) -> float:
250
+ """Multiply two numbers."""
251
+ return a * b
252
+
253
+
254
+ @tool
255
+ def divide_numbers(a: float, b: float) -> float:
256
+ """Divide the first number by the second."""
257
+ if b == 0:
258
+ raise ValueError("Cannot divide by zero.")
259
+ return a / b
260
+
261
+
262
+ @tool
263
+ def power(a: float, b: float) -> float:
264
+ """Raise the first number to the power of the second."""
265
+ return a ** b
266
+
267
+
268
+ @tool
269
+ def square_root(a: float) -> float:
270
+ """Calculate the square root of a number."""
271
+ if a < 0:
272
+ raise ValueError("Cannot calculate square root of a negative number.")
273
+ return a ** 0.5
274
+
275
+
276
+ @tool
277
+ def percentage(part: float, whole: float) -> float:
278
+ """Calculate what percentage the first value is of the second value."""
279
+ if whole == 0:
280
+ raise ValueError("Whole cannot be zero.")
281
+ return (part / whole) * 100
282
+
283
+
284
+ @tool
285
+ def search_wikipedia(query: str) -> str:
286
+ """Search Wikipedia for stable factual information."""
287
+ wiki = WikipediaAPIWrapper()
288
+ return wiki.run(query)
289
+
290
+
291
+ @tool
292
+ def web_search(query: str) -> str:
293
+ """Search the web for recent or changing information."""
294
+ if ddg_search is None:
295
+ return "Web search is unavailable because DDGS is not available."
296
+ return ddg_search.run(query)
297
+
298
+
299
+ @tool
300
+ def search_arxiv(query: str) -> str:
301
+ """Search arXiv for scientific papers and research literature."""
302
+ return arxiv_tool.run(query)
303
+
304
+
305
+ @tool
306
+ def get_current_utc_time(_: str = "") -> str:
307
+ """Return the current UTC date and time."""
308
+ return datetime.now(timezone.utc).isoformat()
309
+
310
+
311
+ @tool
312
+ def get_stock_price(ticker: str) -> str:
313
+ """Get the latest recent close price for a stock, ETF, index, or crypto ticker."""
314
+ ticker = ticker.upper().strip()
315
+ t = yf.Ticker(ticker)
316
+ hist = t.history(period="5d")
317
+
318
+ if hist.empty:
319
+ return f"No recent market data found for {ticker}."
320
+
321
+ last = float(hist["Close"].iloc[-1])
322
+ return f"{ticker} latest close: {last:.2f}"
323
+
324
+
325
+ @tool
326
+ def get_stock_history(ticker: str, period: str = "6mo") -> str:
327
+ """Get historical closing prices for a ticker and generate a chart image."""
328
+ ticker = ticker.upper().strip()
329
+ t = yf.Ticker(ticker)
330
+ hist = t.history(period=period)
331
+
332
+ if hist.empty:
333
+ return f"No historical market data found for {ticker}."
334
+
335
+ x_vals = [str(d.date()) for d in hist.index]
336
+ y_vals = [float(v) for v in hist["Close"].tolist()]
337
+
338
+ chart_path = save_line_chart(
339
+ title=f"{ticker} closing price ({period})",
340
+ x_values=x_vals,
341
+ y_values=y_vals,
342
+ x_label="Date",
343
+ y_label="Close",
344
+ )
345
+
346
+ start_close = y_vals[0]
347
+ end_close = y_vals[-1]
348
+ pct = ((end_close - start_close) / start_close) * 100 if start_close else 0.0
349
+
350
+ return (
351
+ f"Ticker: {ticker}\n"
352
+ f"Period: {period}\n"
353
+ f"Points: {len(y_vals)}\n"
354
+ f"Start close: {start_close:.2f}\n"
355
+ f"End close: {end_close:.2f}\n"
356
+ f"Performance: {pct:+.2f}%\n"
357
+ f"Chart saved to: {chart_path}"
358
+ )
359
+
360
+
361
+ @tool
362
+ def generate_line_chart(
363
+ title: str,
364
+ x_values: list,
365
+ y_values: list,
366
+ x_label: str = "X",
367
+ y_label: str = "Y",
368
+ ) -> str:
369
+ """Generate a line chart from x and y values and save it as an image file."""
370
+ chart_path = save_line_chart(title, x_values, y_values, x_label=x_label, y_label=y_label)
371
+ return f"Chart saved to: {chart_path}"
372
+
373
+
374
+ @tool
375
+ def wikipedia_chaos_oracle(query: str) -> str:
376
+ """Generate a weird chaotic text mashup based on Wikipedia content."""
377
+ wiki = WikipediaAPIWrapper()
378
+ text = wiki.run(query)
379
+
380
+ if not text:
381
+ return "The chaos oracle found only silence."
382
+
383
+ words = re.findall(r"\w+", text)
384
+ if not words:
385
+ return "The chaos oracle found no usable words."
386
+
387
+ random.shuffle(words)
388
+ return " ".join(words[:30])
389
+
390
+
391
+ @tool
392
+ def random_number(min_value: int, max_value: int) -> int:
393
+ """Generate a random integer between the minimum and maximum values."""
394
+ return random.randint(min_value, max_value)
395
+
396
+
397
+ @tool
398
+ def generate_uuid(_: str = "") -> str:
399
+ """Generate a random UUID string."""
400
+ return str(uuid.uuid4())
401
+
402
+
403
+ ALL_TOOLS = {
404
+ "add_numbers": add_numbers,
405
+ "subtract_numbers": subtract_numbers,
406
+ "multiply_numbers": multiply_numbers,
407
+ "divide_numbers": divide_numbers,
408
+ "power": power,
409
+ "square_root": square_root,
410
+ "percentage": percentage,
411
+ "search_wikipedia": search_wikipedia,
412
+ "web_search": web_search,
413
+ "search_arxiv": search_arxiv,
414
+ "get_current_utc_time": get_current_utc_time,
415
+ "get_stock_price": get_stock_price,
416
+ "get_stock_history": get_stock_history,
417
+ "generate_line_chart": generate_line_chart,
418
+ "wikipedia_chaos_oracle": wikipedia_chaos_oracle,
419
+ "random_number": random_number,
420
+ "generate_uuid": generate_uuid,
421
+ }
422
+ TOOL_NAMES = list(ALL_TOOLS.keys())
423
+
424
+
425
+ # ============================================================
426
+ # Agent builder
427
+ # ============================================================
428
+
429
+ def build_agent(model_id: str, selected_tool_names: List[str]):
430
+ tool_key = tuple(sorted(selected_tool_names))
431
+ cache_key = (model_id, tool_key)
432
+
433
+ if cache_key in AGENT_CACHE:
434
+ return AGENT_CACHE[cache_key]
435
+
436
+ tools = [ALL_TOOLS[name] for name in selected_tool_names if name in ALL_TOOLS]
437
+ chat_model = build_provider_chat(model_id)
438
+
439
+ system_prompt = (
440
+ "You are an assistant with tool access. "
441
+ "Use math tools for calculations. "
442
+ "Use Wikipedia for stable facts. "
443
+ "Use web search for recent or changing information. "
444
+ "Use arXiv for research papers. "
445
+ "Use stock tools for financial data. "
446
+ "Generate charts when the user asks for trends or plots. "
447
+ "If a needed tool is unavailable, say so plainly. "
448
+ f"You are currently running with provider-backed model='{model_id}'. "
449
+ "After using tools, always provide a final natural-language answer. "
450
+ "Do not stop after only issuing a tool call. "
451
+ "Be concise."
452
+ )
453
+
454
+ agent = create_agent(
455
+ model=chat_model,
456
+ tools=tools,
457
+ system_prompt=system_prompt,
458
+ )
459
+ AGENT_CACHE[cache_key] = agent
460
+ return agent
461
+
462
+
463
+ # ============================================================
464
+ # Runtime errors
465
+ # ============================================================
466
+
467
+ def classify_backend_error(model_id: str, err: Exception) -> str:
468
+ text = str(err)
469
+
470
+ if isinstance(err, HfHubHTTPError):
471
+ if "model_not_supported" in text or "not supported by any provider" in text:
472
+ RUNTIME_HEALTH[model_id] = "unavailable"
473
+ return "This model exists on Hugging Face, but it is not supported by the provider route used by this app."
474
+ if "401" in text or "403" in text:
475
+ RUNTIME_HEALTH[model_id] = "gated"
476
+ return "This model is not accessible with the current Hugging Face token."
477
+ if "429" in text:
478
+ RUNTIME_HEALTH[model_id] = "rate_limited"
479
+ return "This model is being rate-limited right now. Try again shortly or switch model."
480
+ if "404" in text:
481
+ RUNTIME_HEALTH[model_id] = "unavailable"
482
+ return "This model is not available on the current Hugging Face inference route."
483
+
484
+ RUNTIME_HEALTH[model_id] = "error"
485
+ return f"Provider error: {err}"
486
+
487
+ RUNTIME_HEALTH[model_id] = "error"
488
+ return f"Runtime error: {err}"
489
+
490
+
491
+ # ============================================================
492
+ # Debug builder
493
+ # ============================================================
494
+
495
+ def build_debug_report(
496
+ model_id: str,
497
+ message: str,
498
+ selected_tools: List[str],
499
+ messages: List[object],
500
+ final_answer: str,
501
+ last_nonempty_ai: Optional[str],
502
+ last_tool_content: Optional[str],
503
+ chart_path: Optional[str],
504
+ ) -> str:
505
+ lines = []
506
+ lines.append("=== DEBUG REPORT ===")
507
+ lines.append(f"model_id: {model_id}")
508
+ lines.append(f"user_message: {message}")
509
+ lines.append(f"selected_tools: {selected_tools}")
510
+ lines.append(f"message_count: {len(messages)}")
511
+ lines.append(f"chart_path: {chart_path}")
512
+ lines.append("")
513
+
514
+ for i, msg in enumerate(messages):
515
+ msg_type = getattr(msg, "type", type(msg).__name__)
516
+ raw_content = getattr(msg, "content", "")
517
+ text_content = content_to_text(raw_content)
518
+ tool_calls = getattr(msg, "tool_calls", None)
519
+
520
+ lines.append(f"--- message[{i}] ---")
521
+ lines.append(f"type: {msg_type}")
522
+ lines.append(f"content_empty: {not bool(text_content.strip())}")
523
+ lines.append(f"content_preview: {short_text(text_content, 500)}")
524
+
525
+ if tool_calls:
526
+ lines.append(f"tool_calls: {tool_calls}")
527
+
528
+ additional_kwargs = getattr(msg, "additional_kwargs", None)
529
+ if additional_kwargs:
530
+ lines.append(f"additional_kwargs: {additional_kwargs}")
531
+
532
+ response_metadata = getattr(msg, "response_metadata", None)
533
+ if response_metadata:
534
+ lines.append(f"response_metadata: {response_metadata}")
535
+
536
+ lines.append("")
537
+
538
+ lines.append("=== SUMMARY ===")
539
+ lines.append(f"last_nonempty_ai: {short_text(last_nonempty_ai or '', 500)}")
540
+ lines.append(f"last_tool_content: {short_text(last_tool_content or '', 500)}")
541
+ lines.append(f"final_answer: {short_text(final_answer or '', 500)}")
542
+
543
+ if not final_answer or not final_answer.strip():
544
+ lines.append("warning: final_answer is empty")
545
+ if not last_nonempty_ai and last_tool_content:
546
+ lines.append("warning: model returned tool output but no final AI text")
547
+ if not last_nonempty_ai and not last_tool_content:
548
+ lines.append("warning: neither AI text nor tool content was recovered")
549
+
550
+ return "\n".join(lines)
551
+
552
+
553
+ # ============================================================
554
+ # Run agent
555
+ # ============================================================
556
+
557
+ def run_agent(message, history, selected_tools, model_id):
558
+ history = history or []
559
+
560
+ if not message or not str(message).strip():
561
+ return history, "No input provided.", "", None, model_status_text(model_id), "No input provided."
562
+
563
+ if not selected_tools:
564
+ history.append({"role": "user", "content": message})
565
+ history.append({"role": "assistant", "content": "No tools are enabled. Please enable at least one tool."})
566
+ return history, "No tools enabled.", "", None, model_status_text(model_id), "No tools enabled."
567
+
568
+ chart_path = None
569
+ debug_report = ""
570
+
571
+ try:
572
+ agent = build_agent(model_id, selected_tools)
573
+ response = agent.invoke(
574
+ {"messages": [{"role": "user", "content": message}]}
575
+ )
576
+
577
+ messages = response.get("messages", [])
578
+ tool_lines = []
579
+
580
+ last_nonempty_ai = None
581
+ last_tool_content = None
582
+
583
+ for msg in messages:
584
+ msg_type = getattr(msg, "type", None)
585
+ content = content_to_text(getattr(msg, "content", ""))
586
+
587
+ if msg_type == "ai":
588
+ if getattr(msg, "tool_calls", None):
589
+ for tc in msg.tool_calls:
590
+ tool_name = tc.get("name", "unknown_tool")
591
+ tool_args = tc.get("args", {})
592
+ tool_lines.append(f"▶ {tool_name}({tool_args})")
593
+
594
+ if content and content.strip():
595
+ last_nonempty_ai = content.strip()
596
+
597
+ elif msg_type == "tool":
598
+ shortened = short_text(content, 1500)
599
+ tool_lines.append(f"→ {shortened}")
600
+
601
+ if content and content.strip():
602
+ last_tool_content = content.strip()
603
+
604
+ maybe_chart = extract_chart_path(content)
605
+ if maybe_chart:
606
+ chart_path = maybe_chart
607
+
608
+ if last_nonempty_ai:
609
+ final_answer = last_nonempty_ai
610
+ RUNTIME_HEALTH[model_id] = "ok"
611
+ elif last_tool_content:
612
+ final_answer = f"Tool result:\n{last_tool_content}"
613
+ RUNTIME_HEALTH[model_id] = "empty_final"
614
+ else:
615
+ final_answer = "The model used a tool but did not return a final text response."
616
+ RUNTIME_HEALTH[model_id] = "empty_final"
617
+
618
+ tool_trace = "\n".join(tool_lines) if tool_lines else "No tools used."
619
+
620
+ debug_report = build_debug_report(
621
+ model_id=model_id,
622
+ message=message,
623
+ selected_tools=selected_tools,
624
+ messages=messages,
625
+ final_answer=final_answer,
626
+ last_nonempty_ai=last_nonempty_ai,
627
+ last_tool_content=last_tool_content,
628
+ chart_path=chart_path,
629
+ )
630
+
631
+ except Exception as e:
632
+ final_answer = classify_backend_error(model_id, e)
633
+ tool_trace = "Execution failed."
634
+ debug_report = (
635
+ "=== DEBUG REPORT ===\n"
636
+ f"model_id: {model_id}\n"
637
+ f"user_message: {message}\n"
638
+ f"selected_tools: {selected_tools}\n\n"
639
+ "=== EXCEPTION ===\n"
640
+ f"{traceback.format_exc()}\n"
641
+ )
642
+
643
+ history.append({"role": "user", "content": message})
644
+ history.append({"role": "assistant", "content": final_answer})
645
+
646
+ return history, tool_trace, "", chart_path, model_status_text(model_id), debug_report
647
+
648
+
649
+ # ============================================================
650
+ # UI
651
+ # ============================================================
652
+
653
+ with gr.Blocks(title="Provider Multi-Model Agent", theme=gr.themes.Soft()) as demo:
654
+ gr.Markdown(
655
+ "# Provider Multi-Model Agent\n"
656
+ "Provider-backed models only, with selectable tools and extended debugging."
657
+ )
658
+
659
+ with gr.Row():
660
+ model_dropdown = gr.Dropdown(
661
+ choices=MODEL_OPTIONS,
662
+ value=DEFAULT_MODEL_ID,
663
+ label="Base model",
664
+ )
665
+ model_status = gr.Textbox(
666
+ value=model_status_text(DEFAULT_MODEL_ID),
667
+ label="Model status",
668
+ interactive=False,
669
+ )
670
+
671
+ with gr.Row():
672
+ with gr.Column(scale=3):
673
+ chatbot = gr.Chatbot(label="Conversation", height=460, type="messages")
674
+ user_input = gr.Textbox(
675
+ label="Message",
676
+ placeholder="Ask anything...",
677
+ )
678
+
679
+ with gr.Row():
680
+ send_btn = gr.Button("Send", variant="primary")
681
+ clear_btn = gr.Button("Clear")
682
+
683
+ chart_output = gr.Image(label="Generated chart", type="filepath")
684
+
685
+ with gr.Column(scale=1):
686
+ enabled_tools = gr.CheckboxGroup(
687
+ choices=TOOL_NAMES,
688
+ value=TOOL_NAMES,
689
+ label="Enabled tools",
690
+ )
691
+ tool_trace = gr.Textbox(
692
+ label="Tool trace",
693
+ lines=18,
694
+ interactive=False,
695
+ )
696
+
697
+ debug_output = gr.Textbox(
698
+ label="Debug output",
699
+ lines=28,
700
+ interactive=False,
701
+ )
702
+
703
+ model_dropdown.change(
704
+ fn=model_status_text,
705
+ inputs=[model_dropdown],
706
+ outputs=[model_status],
707
+ show_api=False,
708
+ )
709
+
710
+ send_btn.click(
711
+ fn=run_agent,
712
+ inputs=[user_input, chatbot, enabled_tools, model_dropdown],
713
+ outputs=[chatbot, tool_trace, user_input, chart_output, model_status, debug_output],
714
+ show_api=False,
715
+ )
716
+
717
+ user_input.submit(
718
+ fn=run_agent,
719
+ inputs=[user_input, chatbot, enabled_tools, model_dropdown],
720
+ outputs=[chatbot, tool_trace, user_input, chart_output, model_status, debug_output],
721
+ show_api=False,
722
+ )
723
+
724
+ clear_btn.click(
725
+ fn=lambda model_id: ([], "", "", None, model_status_text(model_id), ""),
726
+ inputs=[model_dropdown],
727
+ outputs=[chatbot, tool_trace, user_input, chart_output, model_status, debug_output],
728
+ show_api=False,
729
+ )
730
+
731
+ if __name__ == "__main__":
732
+ port = int(os.environ.get("PORT", 7860))
733
+ demo.launch(
734
+ server_name="0.0.0.0",
735
+ server_port=port,
736
+ ssr_mode=False,
737
+ allowed_paths=[os.path.abspath(CHART_DIR)],
738
+ debug=True,
739
+ )