tinyagent-py 0.0.1__py3-none-any.whl → 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,966 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import re
6
+ import shutil
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Set, Union
10
+
11
+ import tiktoken
12
+ from tinyagent import TinyAgent
13
+ import gradio as gr
14
+ from gradio import ChatMessage
15
+
16
+ # Check if gradio is available
17
+ try:
18
+ import gradio as gr
19
+
20
+ except ImportError:
21
+ raise ModuleNotFoundError(
22
+ "Please install 'gradio' to use the GradioCallback: `pip install gradio`"
23
+ )
24
+
25
+
26
+ class GradioCallback:
27
+ """
28
+ A callback for TinyAgent that provides a Gradio web interface.
29
+ This allows for interactive chat with the agent through a web UI.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ file_upload_folder: Optional[str] = None,
35
+ allowed_file_types: Optional[List[str]] = None,
36
+ show_thinking: bool = True,
37
+ show_tool_calls: bool = True,
38
+ logger: Optional[logging.Logger] = None,
39
+ ):
40
+ """
41
+ Initialize the Gradio callback.
42
+
43
+ Args:
44
+ file_upload_folder: Optional folder to store uploaded files
45
+ allowed_file_types: List of allowed file extensions (default: [".pdf", ".docx", ".txt"])
46
+ show_thinking: Whether to show the thinking process
47
+ show_tool_calls: Whether to show tool calls
48
+ logger: Optional logger to use
49
+ """
50
+ self.logger = logger or logging.getLogger(__name__)
51
+ self.show_thinking = show_thinking
52
+ self.show_tool_calls = show_tool_calls
53
+
54
+ # File upload settings
55
+ self.file_upload_folder = Path(file_upload_folder) if file_upload_folder else None
56
+ self.allowed_file_types = allowed_file_types or [".pdf", ".docx", ".txt"]
57
+
58
+ if self.file_upload_folder and not self.file_upload_folder.exists():
59
+ self.file_upload_folder.mkdir(parents=True, exist_ok=True)
60
+ self.logger.info(f"Created file upload folder: {self.file_upload_folder}")
61
+
62
+ # Initialize tiktoken encoder for token counting
63
+ try:
64
+ self.encoder = tiktoken.get_encoding("o200k_base")
65
+ self.logger.debug("Initialized tiktoken encoder with o200k_base encoding")
66
+ except Exception as e:
67
+ self.logger.error(f"Failed to initialize tiktoken encoder: {e}")
68
+ self.encoder = None
69
+
70
+ # State tracking for the current agent interaction
71
+ self.current_agent = None
72
+ self.current_user_input = ""
73
+ self.thinking_content = ""
74
+ self.tool_calls = []
75
+ self.tool_call_details = []
76
+ self.assistant_text_responses = []
77
+ self.token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
78
+ self.is_running = False
79
+ self.last_update_yield_time = 0
80
+
81
+ # References to Gradio UI components (will be set in create_app)
82
+ self._chatbot_component = None
83
+ self._token_usage_component = None
84
+
85
+ self.logger.debug("GradioCallback initialized")
86
+
87
+ def count_tokens(self, text: str) -> int:
88
+ """Count tokens in a string using tiktoken."""
89
+ if not self.encoder or not text:
90
+ return 0
91
+ try:
92
+ return len(self.encoder.encode(text))
93
+ except Exception as e:
94
+ self.logger.error(f"Error counting tokens: {e}")
95
+ return 0
96
+
97
+ async def __call__(self, event_name: str, agent: Any, **kwargs: Any) -> None:
98
+ """
99
+ Process events from the TinyAgent.
100
+
101
+ Args:
102
+ event_name: The name of the event
103
+ agent: The TinyAgent instance
104
+ **kwargs: Additional event data
105
+ """
106
+ self.logger.debug(f"Callback Event: {event_name}")
107
+ self.current_agent = agent
108
+
109
+ if event_name == "agent_start":
110
+ await self._handle_agent_start(agent, **kwargs)
111
+ elif event_name == "message_add":
112
+ await self._handle_message_add(agent, **kwargs)
113
+ elif event_name == "llm_start":
114
+ await self._handle_llm_start(agent, **kwargs)
115
+ elif event_name == "llm_end":
116
+ await self._handle_llm_end(agent, **kwargs)
117
+ elif event_name == "agent_end":
118
+ await self._handle_agent_end(agent, **kwargs)
119
+
120
+ async def _handle_agent_start(self, agent: Any, **kwargs: Any) -> None:
121
+ """Handle the agent_start event. Reset state."""
122
+ self.logger.debug("Handling agent_start event")
123
+ self.current_user_input = kwargs.get("user_input", "")
124
+ self.thinking_content = ""
125
+ self.tool_calls = []
126
+ self.tool_call_details = []
127
+ self.assistant_text_responses = []
128
+ self.token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
129
+ self.is_running = True
130
+ self.last_update_yield_time = 0
131
+ self.logger.debug(f"Agent started for input: {self.current_user_input[:50]}...")
132
+
133
+ async def _handle_message_add(self, agent: Any, **kwargs: Any) -> None:
134
+ """Handle the message_add event. Store message details."""
135
+ message = kwargs.get("message", {})
136
+ role = message.get("role", "unknown")
137
+ self.logger.debug(f"Handling message_add event: {role}")
138
+ current_time = asyncio.get_event_loop().time()
139
+
140
+ if role == "assistant":
141
+ if "tool_calls" in message and message.get("tool_calls"):
142
+ self.logger.debug(f"Processing {len(message['tool_calls'])} tool calls")
143
+ for tool_call in message["tool_calls"]:
144
+ function_info = tool_call.get("function", {})
145
+ tool_name = function_info.get("name", "unknown")
146
+ args = function_info.get("arguments", "{}")
147
+ tool_id = tool_call.get("id", "unknown")
148
+
149
+ try:
150
+ # Attempt pretty formatting, fallback to raw string
151
+ parsed_args = json.loads(args)
152
+ formatted_args = json.dumps(parsed_args, indent=2)
153
+ except json.JSONDecodeError:
154
+ formatted_args = args # Keep as is if not valid JSON
155
+
156
+ token_count = self.count_tokens(f"{tool_name}({formatted_args})") # Count formatted
157
+
158
+ # Add to detailed tool call info if not already present by ID
159
+ if not any(tc['id'] == tool_id for tc in self.tool_call_details):
160
+ self.tool_call_details.append({
161
+ "id": tool_id,
162
+ "name": tool_name,
163
+ "arguments": formatted_args,
164
+ "result": None,
165
+ "token_count": token_count,
166
+ "result_token_count": 0,
167
+ "timestamp": current_time,
168
+ "result_timestamp": None
169
+ })
170
+ self.logger.debug(f"Added tool call detail: {tool_name} (ID: {tool_id}, Tokens: {token_count})")
171
+
172
+ # If this is a final_answer or ask_question tool, we'll handle it specially later
173
+ # when the result comes in
174
+ else:
175
+ self.logger.debug(f"Tool call detail already exists for ID: {tool_id}")
176
+
177
+ elif "content" in message and message.get("content"):
178
+ content = message["content"]
179
+ token_count = self.count_tokens(content)
180
+ self.assistant_text_responses.append({
181
+ "content": content,
182
+ "token_count": token_count,
183
+ "timestamp": current_time
184
+ })
185
+ self.logger.debug(f"Added assistant text response: {content[:50]}... (Tokens: {token_count})")
186
+
187
+ elif role == "tool":
188
+ tool_name = message.get("name", "unknown")
189
+ content = message.get("content", "")
190
+ tool_call_id = message.get("tool_call_id", None)
191
+ token_count = self.count_tokens(content)
192
+
193
+ if tool_call_id:
194
+ updated = False
195
+ for tool_detail in self.tool_call_details:
196
+ if tool_detail["id"] == tool_call_id:
197
+ tool_detail["result"] = content
198
+ tool_detail["result_token_count"] = token_count
199
+ tool_detail["result_timestamp"] = current_time
200
+ self.logger.debug(f"Updated tool call {tool_call_id} with result (Tokens: {token_count})")
201
+
202
+ # Special handling for final_answer and ask_question tools
203
+ # Add their results directly as assistant messages in the chat
204
+ if tool_detail["name"] in ["final_answer", "ask_question"]:
205
+ self.assistant_text_responses.append({
206
+ "content": content,
207
+ "token_count": token_count,
208
+ "timestamp": current_time,
209
+ "from_tool": True,
210
+ "tool_name": tool_detail["name"]
211
+ })
212
+ self.logger.debug(f"Added {tool_detail['name']} result as assistant message")
213
+
214
+ updated = True
215
+ break
216
+ if not updated:
217
+ self.logger.warning(f"Received tool result for unknown tool_call_id: {tool_call_id}")
218
+ else:
219
+ self.logger.warning(f"Received tool result without tool_call_id: {tool_name}")
220
+
221
+ async def _handle_llm_start(self, agent: Any, **kwargs: Any) -> None:
222
+ """Handle the llm_start event."""
223
+ self.logger.debug("Handling llm_start event")
224
+ # Optionally clear previous thinking content if desired per LLM call
225
+ # self.thinking_content = ""
226
+
227
+ async def _handle_llm_end(self, agent: Any, **kwargs: Any) -> None:
228
+ """Handle the llm_end event. Store thinking content and token usage."""
229
+ self.logger.debug("Handling llm_end event")
230
+ response = kwargs.get("response", {})
231
+
232
+ # Extract thinking content (often the raw message content before tool parsing)
233
+ try:
234
+ message = response.choices[0].message
235
+ # Only update thinking if there's actual content and no tool calls in this specific message
236
+ # Tool calls are handled separately via message_add
237
+ if hasattr(message, "content") and message.content and not getattr(message, "tool_calls", None):
238
+ # Check if this content is already in assistant_text_responses to avoid duplication
239
+ if not any(resp['content'] == message.content for resp in self.assistant_text_responses):
240
+ self.thinking_content = message.content # Store as potential thinking
241
+ self.logger.debug(f"Stored potential thinking content: {self.thinking_content[:50]}...")
242
+ else:
243
+ self.logger.debug("Content from llm_end already captured as assistant response.")
244
+
245
+ except (AttributeError, IndexError, TypeError) as e:
246
+ self.logger.debug(f"Could not extract thinking content from llm_end: {e}")
247
+
248
+ # Track token usage
249
+ try:
250
+ usage = response.usage
251
+ self.logger.debug(f"Token usage: {usage}")
252
+ if usage:
253
+ prompt_tokens = getattr(usage, "prompt_tokens", 0)
254
+ completion_tokens = getattr(usage, "completion_tokens", 0)
255
+ self.token_usage["prompt_tokens"] += prompt_tokens
256
+ self.token_usage["completion_tokens"] += completion_tokens
257
+ # Recalculate total based on potentially cumulative prompt/completion
258
+ self.token_usage["total_tokens"] = self.token_usage["prompt_tokens"] + self.token_usage["completion_tokens"]
259
+ self.logger.debug(f"Updated token usage: Prompt +{prompt_tokens}, Completion +{completion_tokens}. Total: {self.token_usage}")
260
+ except (AttributeError, TypeError) as e:
261
+ self.logger.debug(f"Could not extract token usage from llm_end: {e}")
262
+
263
+ async def _handle_agent_end(self, agent: Any, **kwargs: Any) -> None:
264
+ """Handle the agent_end event. Mark agent as not running."""
265
+ self.logger.debug("Handling agent_end event")
266
+ self.is_running = False
267
+ # Final result is handled by interact_with_agent after agent.run completes
268
+ self.logger.debug(f"Agent finished. Final result: {kwargs.get('result', 'N/A')[:50]}...")
269
+
270
+ def upload_file(self, file, file_uploads_log):
271
+ """
272
+ Handle file uploads in the Gradio interface.
273
+
274
+ Args:
275
+ file: The uploaded file
276
+ file_uploads_log: List of previously uploaded files
277
+
278
+ Returns:
279
+ Tuple of (status_message, updated_file_uploads_log)
280
+ """
281
+ if file is None:
282
+ return gr.Textbox(value="No file uploaded", visible=True), file_uploads_log
283
+
284
+ file_ext = os.path.splitext(file.name)[1].lower()
285
+ if file_ext not in self.allowed_file_types:
286
+ return gr.Textbox("File type not allowed", visible=True), file_uploads_log
287
+
288
+ original_name = os.path.basename(file.name)
289
+ sanitized_name = re.sub(r"[^\w\-.]", "_", original_name)
290
+
291
+ file_path = os.path.join(self.file_upload_folder, sanitized_name)
292
+ shutil.copy(file.name, file_path)
293
+
294
+ return gr.Textbox(f"File uploaded: {file_path}", visible=True), file_uploads_log + [file_path]
295
+
296
+ def log_user_message(self, message, file_uploads_log):
297
+ """
298
+ Process user message, add files, and update chatbot history.
299
+ This now ONLY prepares the input and adds the user message to the chat.
300
+ It disables the send button while processing.
301
+
302
+ Args:
303
+ message: User message text
304
+ file_uploads_log: List of uploaded files
305
+
306
+ Returns:
307
+ Tuple of (processed_message, initial_chatbot_state, disable_send_button)
308
+ """
309
+ processed_message = message
310
+ # Check if there are file references to add to the message
311
+ if file_uploads_log and len(file_uploads_log) > 0:
312
+ file_list = "\n".join([f"- {os.path.basename(f)}" for f in file_uploads_log])
313
+ processed_message = f"{message}\n\nFiles available:\n{file_list}"
314
+
315
+ # Prepare the initial chatbot state for this turn
316
+ # Assumes chatbot history is passed correctly or managed via gr.State
317
+ # For simplicity, let's assume we get the history and append to it.
318
+ # We need the actual chatbot component value here.
319
+ # This part is tricky without direct access in this function signature.
320
+ # Let's modify interact_with_agent to handle this.
321
+
322
+ # Just return the processed message and disable the button
323
+ # The chatbot update will happen in interact_with_agent
324
+ return processed_message, gr.Button(interactive=False)
325
+
326
+ def _build_current_assistant_message(self) -> str:
327
+ """
328
+ Construct the content for the assistant's message bubble based on current state.
329
+ Prioritizes: Latest Text Response > Tool Calls > Thinking Content.
330
+ """
331
+ parts = []
332
+ display_content = "Thinking..." # Default if nothing else is available yet
333
+
334
+ # Sort details for consistent display order
335
+ sorted_tool_details = sorted(self.tool_call_details, key=lambda x: x.get("timestamp", 0))
336
+ sorted_text_responses = sorted(self.assistant_text_responses, key=lambda x: x.get("timestamp", 0))
337
+
338
+ # 1. Get the latest assistant text response (if any)
339
+ if sorted_text_responses:
340
+ display_content = sorted_text_responses[-1]["content"]
341
+ parts.append(display_content)
342
+ # If there's no text response yet, but we have tool calls or thinking, use a placeholder
343
+ elif sorted_tool_details or (self.show_thinking and self.thinking_content):
344
+ parts.append("Working on it...") # More informative than just "Thinking..."
345
+
346
+ # 2. Add Tool Call details (if enabled and available)
347
+ if self.show_tool_calls and sorted_tool_details:
348
+ for i, tool_detail in enumerate(sorted_tool_details):
349
+ tool_name = tool_detail["name"]
350
+ arguments = tool_detail["arguments"]
351
+ result = tool_detail["result"]
352
+ result_status = "⏳ Processing..." if result is None else "✅ Done"
353
+ input_tokens = tool_detail.get("token_count", 0)
354
+ output_tokens = tool_detail.get("result_token_count", 0)
355
+
356
+ # Special handling for final_answer and ask_question tools
357
+ #if tool_name in ["final_answer", "ask_question"] and result is not None:
358
+ # Don't add these as tool calls, they'll be shown as regular messages
359
+ #continue
360
+
361
+ # Create collapsible tool call section using Gradio's markdown format
362
+ parts.append(f"\n\n<details><summary>🛠️ **Tool: {tool_name}** ({result_status}) - {input_tokens+output_tokens} tokens</summary>")
363
+ parts.append(f"\n\n**Input Arguments:**\n```json\n{arguments}\n```")
364
+
365
+ if result is not None:
366
+ parts.append(f"\n\n**Output:** ({output_tokens} tokens)\n```\n{result}\n```")
367
+
368
+ parts.append("\n</details>")
369
+
370
+ # 3. Add Thinking Process (if enabled and available, and no text response yet)
371
+ # Only show thinking if there isn't a more concrete text response or tool call happening
372
+ if self.show_thinking and self.thinking_content and not sorted_text_responses and not sorted_tool_details:
373
+ parts.append("\n\n<details><summary>🧠 **Thinking Process**</summary>\n\n```\n" + self.thinking_content + "\n```\n</details>")
374
+
375
+ # If parts is empty after all checks, use the initial display_content
376
+ if not parts:
377
+ return display_content
378
+ else:
379
+ return "".join(parts)
380
+
381
+ def _get_token_usage_text(self) -> str:
382
+ """Format the token usage string."""
383
+ if not any(self.token_usage.values()):
384
+ return "Tokens: 0"
385
+ return (f"Tokens: I {self.token_usage['prompt_tokens']} | " +
386
+ f"O {self.token_usage['completion_tokens']} | " +
387
+ f"Total {self.token_usage['total_tokens']}")
388
+
389
+ async def interact_with_agent(self, user_input_processed, chatbot_history):
390
+ """
391
+ Process user input, interact with the agent, and stream updates to Gradio UI.
392
+ Each tool call and response will be shown as a separate message.
393
+ """
394
+ self.logger.info(f"Starting interaction for: {user_input_processed[:50]}...")
395
+
396
+ # 1. Add user message to chatbot history as a ChatMessage
397
+ chatbot_history.append(
398
+ ChatMessage(role="user", content=user_input_processed)
399
+ )
400
+
401
+ # Initial yield to show user message
402
+ yield chatbot_history, self._get_token_usage_text()
403
+
404
+ # Kick off the agent in the background
405
+ loop = asyncio.get_event_loop()
406
+ agent_task = asyncio.create_task(self.current_agent.run(user_input_processed))
407
+
408
+ displayed_tool_calls = set()
409
+ displayed_text_responses = set()
410
+ thinking_message_added = False
411
+ update_interval = 0.3
412
+ min_yield_interval = 0.2
413
+
414
+ # Track tool calls that are in progress (showing "working...")
415
+ in_progress_tool_calls = {}
416
+
417
+ while not agent_task.done():
418
+ now = time.time()
419
+ if now - self.last_update_yield_time >= min_yield_interval:
420
+ sorted_tool_details = sorted(self.tool_call_details, key=lambda x: x.get("timestamp", 0))
421
+ sorted_text_responses = sorted(self.assistant_text_responses, key=lambda x: x.get("timestamp", 0))
422
+
423
+ # → New assistant text chunks
424
+ for resp in sorted_text_responses:
425
+ content = resp["content"]
426
+ if content not in displayed_text_responses:
427
+ chatbot_history.append(
428
+ ChatMessage(role="assistant", content=content)
429
+ )
430
+ displayed_text_responses.add(content)
431
+ self.logger.debug(f"Added new text response: {content[:50]}...")
432
+
433
+ # → Thinking placeholder (optional)
434
+ if self.show_thinking and self.thinking_content \
435
+ and not thinking_message_added \
436
+ and not displayed_text_responses:
437
+ thinking_msg = (
438
+ "Working on it...\n\n"
439
+ "```"
440
+ f"{self.thinking_content}"
441
+ "```"
442
+ )
443
+ chatbot_history.append(
444
+ ChatMessage(role="assistant", content=thinking_msg)
445
+ )
446
+ thinking_message_added = True
447
+ self.logger.debug("Added thinking message")
448
+
449
+ # → Show tool calls with "working..." status when they start
450
+ if self.show_tool_calls:
451
+ for tool in sorted_tool_details:
452
+ tid = tool["id"]
453
+ tname = tool["name"]
454
+
455
+ # If we haven't displayed this tool call yet
456
+ if tid not in displayed_tool_calls and tid not in in_progress_tool_calls:
457
+ in_tok = tool.get("token_count", 0)
458
+ # Create "working..." message for this tool call
459
+ body = (
460
+ f"**Input Arguments:**\n```json\n{tool['arguments']}\n```\n\n"
461
+ f"**Output:** ⏳ Working...\n"
462
+ )
463
+ # Add to chatbot with "working" status
464
+ msg = ChatMessage(
465
+ role="assistant",
466
+ content=body,
467
+ metadata={
468
+ "title": f"🛠️ {tname} — {in_tok} tokens",
469
+ "status": "pending"
470
+ }
471
+ )
472
+ chatbot_history.append(msg)
473
+ # Track this tool call as in progress
474
+ in_progress_tool_calls[tid] = len(chatbot_history) - 1
475
+ self.logger.debug(f"Added in-progress tool call: {tname}")
476
+
477
+ # If this tool call has completed and we're tracking it as in-progress
478
+ elif tid in in_progress_tool_calls and tool.get("result") is not None:
479
+ # Get the position in the chatbot history
480
+ pos = in_progress_tool_calls[tid]
481
+ in_tok = tool.get("token_count", 0)
482
+ out_tok = tool.get("result_token_count", 0)
483
+ tot_tok = in_tok + out_tok
484
+
485
+ # Update the message with completed status and result
486
+ body = (
487
+ f"**Input Arguments:**\n```json\n{tool['arguments']}\n```\n\n"
488
+ f"**Output:** ({out_tok} tokens)\n```json\n{tool['result']}\n```\n"
489
+ )
490
+ # Update the existing message
491
+ chatbot_history[pos] = ChatMessage(
492
+ role="assistant",
493
+ content=body,
494
+ metadata={
495
+ "title": f"🛠️ {tname} — {tot_tok} tokens ✅",
496
+ "status": "done"
497
+ }
498
+ )
499
+ # Mark as displayed and remove from in-progress
500
+ displayed_tool_calls.add(tid)
501
+ del in_progress_tool_calls[tid]
502
+ self.logger.debug(f"Updated tool call to completed: {tname}")
503
+
504
+ # yield updated history + token usage
505
+ token_text = self._get_token_usage_text()
506
+ yield chatbot_history, token_text
507
+ self.last_update_yield_time = now
508
+
509
+ await asyncio.sleep(update_interval)
510
+
511
+ # once the agent_task is done, add its final result if any
512
+ try:
513
+ final_text = await agent_task
514
+ except Exception as e:
515
+ final_text = f"Error: {e}"
516
+ self.is_running = False
517
+
518
+ if final_text not in displayed_text_responses:
519
+ chatbot_history.append(
520
+ ChatMessage(role="assistant", content=final_text)
521
+ )
522
+ self.logger.debug(f"Added final result: {final_text[:50]}...")
523
+
524
+ # final token usage
525
+ yield chatbot_history, self._get_token_usage_text()
526
+
527
+ def _format_response(self, response_text):
528
+ """
529
+ Format the final response with thinking process, tool calls, and token usage.
530
+
531
+ Args:
532
+ response_text: The final response text from the agent
533
+
534
+ Returns:
535
+ Formatted response string with additional information in Markdown.
536
+ """
537
+ formatted_parts = []
538
+
539
+ # Add the main response text
540
+ formatted_parts.append(response_text)
541
+
542
+ # Sort details for consistent display order
543
+ sorted_tool_details = sorted(self.tool_call_details, key=lambda x: x.get("timestamp", 0))
544
+
545
+ # Add tool calls if enabled and details exist
546
+ if self.show_tool_calls and sorted_tool_details:
547
+ formatted_parts.append("\n\n---\n")
548
+
549
+ for i, tool_detail in enumerate(sorted_tool_details):
550
+ tool_name = tool_detail["name"]
551
+ arguments = tool_detail["arguments"]
552
+ result = tool_detail["result"] or "No result captured."
553
+ input_tokens = tool_detail.get("token_count", 0)
554
+ output_tokens = tool_detail.get("result_token_count", 0)
555
+
556
+ # Skip final_answer and ask_question tools in the tool call section
557
+ # as they're already shown as regular messages
558
+ #if tool_name in ["final_answer", "ask_question"]:
559
+ # continue
560
+
561
+ formatted_parts.append(f"\n<details><summary>🛠️ **Tool {i+1}: {tool_name}** - {input_tokens+output_tokens} tokens</summary>\n")
562
+ formatted_parts.append(f"\n**Input Arguments:**\n```json\n{arguments}\n```")
563
+ formatted_parts.append(f"\n**Output:** ({output_tokens} tokens)\n```\n{result}\n```\n</details>")
564
+
565
+ # Add thinking process if enabled and content exists
566
+ if self.show_thinking and self.thinking_content:
567
+ # Avoid showing thinking if it's identical to the final response text
568
+ if self.thinking_content.strip() != response_text.strip():
569
+ formatted_parts.append("\n\n<details><summary>🧠 **Thinking Process**</summary>\n\n```\n" + self.thinking_content + "\n```\n</details>")
570
+
571
+ # Add token usage summary
572
+ if any(self.token_usage.values()):
573
+ formatted_parts.append("\n\n---\n")
574
+ formatted_parts.append(f"**Token Usage:** Prompt: {self.token_usage['prompt_tokens']} | " +
575
+ f"Completion: {self.token_usage['completion_tokens']} | " +
576
+ f"Total: {self.token_usage['total_tokens']}")
577
+
578
+ return "".join(formatted_parts)
579
+
580
+ def create_app(self, agent: TinyAgent, title: str = "TinyAgent Chat", description: str = None):
581
+ """
582
+ Create a Gradio app for the agent.
583
+
584
+ Args:
585
+ agent: The TinyAgent instance
586
+ title: Title for the app
587
+ description: Optional description
588
+
589
+ Returns:
590
+ A Gradio Blocks application
591
+ """
592
+ self.logger.debug("Creating Gradio app")
593
+ self.current_agent = agent # Store agent reference
594
+
595
+ with gr.Blocks(
596
+ title=title,
597
+ theme=gr.themes.Default(font=[gr.themes.GoogleFont("Inter"), "Arial", "sans-serif"])
598
+ ) as app:
599
+ file_uploads_log = gr.State([])
600
+
601
+ with gr.Row():
602
+ # -- Left Sidebar --
603
+ with gr.Column(scale=1):
604
+ gr.Markdown(f"# {title}")
605
+ if description:
606
+ gr.Markdown(description)
607
+
608
+ # 1) Collapsible File Upload Section
609
+ if self.file_upload_folder:
610
+ with gr.Accordion("Upload Files", open=False):
611
+ gr.Markdown("Upload files to be used by the agent")
612
+ file_upload = gr.File(label="Choose a file")
613
+ upload_status = gr.Textbox(label="Upload Status", visible=False, interactive=False)
614
+ file_upload.change(
615
+ fn=self.upload_file,
616
+ inputs=[file_upload, file_uploads_log],
617
+ outputs=[upload_status, file_uploads_log]
618
+ )
619
+
620
+ # 2) Available Tools Section
621
+ tools = getattr(agent, "available_tools", [])
622
+ with gr.Accordion(f"Available Tools ({len(tools)})", open=True):
623
+
624
+ if not tools:
625
+ gr.Markdown("_No tools registered_")
626
+ else:
627
+ for tool_meta in tools:
628
+ fn = tool_meta.get("function", {})
629
+ tool_name = fn.get("name", "unknown")
630
+ with gr.Accordion(tool_name, open=False):
631
+ # Description
632
+ desc = fn.get("description")
633
+ if desc:
634
+ gr.Markdown(f"**Description:** {desc}")
635
+
636
+ # JSON schema for function calling
637
+ schema = fn.get("parameters")
638
+ if schema:
639
+ gr.JSON(value=schema, label="Function Calling Schema")
640
+ else:
641
+ gr.Markdown("_No schema available_")
642
+
643
+ # 3) Thinking / Tool‐call Toggles
644
+ with gr.Group():
645
+ gr.Markdown("## Display Options")
646
+ show_thinking_checkbox = gr.Checkbox(
647
+ label="Show thinking process",
648
+ value=self.show_thinking
649
+ )
650
+ show_tool_calls_checkbox = gr.Checkbox(
651
+ label="Show tool calls",
652
+ value=self.show_tool_calls
653
+ )
654
+ show_thinking_checkbox.change(
655
+ fn=lambda x: setattr(self, "show_thinking", x),
656
+ inputs=show_thinking_checkbox,
657
+ outputs=None
658
+ )
659
+ show_tool_calls_checkbox.change(
660
+ fn=lambda x: setattr(self, "show_tool_calls", x),
661
+ inputs=show_tool_calls_checkbox,
662
+ outputs=None
663
+ )
664
+
665
+ # 4) Token Usage Display
666
+ with gr.Group():
667
+ gr.Markdown("## Token Usage")
668
+ self._token_usage_component = gr.Textbox(
669
+ label="Token Usage",
670
+ interactive=False,
671
+ value=self._get_token_usage_text()
672
+ )
673
+
674
+ # Footer
675
+ gr.Markdown(
676
+ "<div style='text-align: center; margin-top: 20px;'>"
677
+ "Powered by <a href='https://github.com/askbudi/tinyagent' target='_blank'>TinyAgent</a>"
678
+ "</div>"
679
+ )
680
+
681
+ # -- Right Chat Column (unchanged) --
682
+ with gr.Column(scale=3):
683
+ # Chat interface - Assign component to self for updates
684
+ self._chatbot_component = gr.Chatbot(
685
+ [], # Start empty
686
+ label="Chat History",
687
+ height=600,
688
+ type="messages", # Use messages type for better formatting
689
+ bubble_full_width=False,
690
+ show_copy_button=True,
691
+ render_markdown=True # Enable markdown rendering
692
+ )
693
+
694
+ with gr.Row():
695
+ user_input = gr.Textbox(
696
+ placeholder="Type your message here...",
697
+ show_label=False,
698
+ container=False,
699
+ scale=9
700
+ )
701
+ submit_btn = gr.Button("Send", scale=1, variant="primary")
702
+
703
+ # Clear button
704
+ clear_btn = gr.Button("Clear Conversation")
705
+
706
+ # Store processed input temporarily between steps
707
+ processed_input_state = gr.State("")
708
+
709
+ # Event handlers - Chained logic
710
+ # 1. Process input, disable button
711
+ submit_action = submit_btn.click(
712
+ fn=self.log_user_message,
713
+ inputs=[user_input, file_uploads_log],
714
+ outputs=[processed_input_state, submit_btn], # Store processed input, disable btn
715
+ queue=False # Run quickly
716
+ ).then(
717
+ # 2. Clear the raw input box
718
+ fn=lambda: gr.Textbox(value=""),
719
+ inputs=None,
720
+ outputs=[user_input],
721
+ queue=False # Run quickly
722
+ ).then(
723
+ # 3. Run the main interaction loop (this yields updates)
724
+ fn=self.interact_with_agent,
725
+ inputs=[processed_input_state, self._chatbot_component],
726
+ outputs=[self._chatbot_component, self._token_usage_component], # Update chat and tokens
727
+ queue=True # Explicitly enable queue for this async generator
728
+ ).then(
729
+ # 4. Re-enable the button after interaction finishes
730
+ fn=lambda: gr.Button(interactive=True),
731
+ inputs=None,
732
+ outputs=[submit_btn],
733
+ queue=False # Run quickly
734
+ )
735
+
736
+ # Also trigger on Enter key using the same chain
737
+ input_action = user_input.submit(
738
+ fn=self.log_user_message,
739
+ inputs=[user_input, file_uploads_log],
740
+ outputs=[processed_input_state, submit_btn], # Store processed input, disable btn
741
+ queue=False # Run quickly
742
+ ).then(
743
+ # 2. Clear the raw input box
744
+ fn=lambda: gr.Textbox(value=""),
745
+ inputs=None,
746
+ outputs=[user_input],
747
+ queue=False # Run quickly
748
+ ).then(
749
+ # 3. Run the main interaction loop (this yields updates)
750
+ fn=self.interact_with_agent,
751
+ inputs=[processed_input_state, self._chatbot_component],
752
+ outputs=[self._chatbot_component, self._token_usage_component], # Update chat and tokens
753
+ queue=True # Explicitly enable queue for this async generator
754
+ ).then(
755
+ # 4. Re-enable the button after interaction finishes
756
+ fn=lambda: gr.Button(interactive=True),
757
+ inputs=None,
758
+ outputs=[submit_btn],
759
+ queue=False # Run quickly
760
+ )
761
+
762
+ # Clear conversation
763
+ clear_btn.click(
764
+ fn=self.clear_conversation,
765
+ inputs=None, # No inputs needed
766
+ # Outputs: Clear chatbot and reset token text
767
+ outputs=[self._chatbot_component, self._token_usage_component],
768
+ queue=False # Run quickly
769
+ )
770
+
771
+ self.logger.debug("Gradio app created")
772
+ return app
773
+
774
+ def clear_conversation(self):
775
+ """Clear the conversation history (UI + agent), reset state, and update UI."""
776
+ self.logger.debug("Clearing conversation (UI + agent)")
777
+ # Reset UI‐side state
778
+ self.thinking_content = ""
779
+ self.tool_calls = []
780
+ self.tool_call_details = []
781
+ self.assistant_text_responses = []
782
+ self.token_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
783
+ self.is_running = False
784
+
785
+ # Also clear the agent's conversation history
786
+ try:
787
+ if self.current_agent and hasattr(self.current_agent, "clear_conversation"):
788
+ self.current_agent.clear_conversation()
789
+ self.logger.debug("Cleared TinyAgent internal conversation.")
790
+ except Exception as e:
791
+ self.logger.error(f"Failed to clear TinyAgent conversation: {e}")
792
+
793
+ # Return cleared UI components: empty chat + fresh token usage
794
+ return [], self._get_token_usage_text()
795
+
796
+ def launch(self, agent, title="TinyAgent Chat", description=None, share=False, **kwargs):
797
+ """
798
+ Launch the Gradio app.
799
+
800
+ Args:
801
+ agent: The TinyAgent instance
802
+ title: Title for the app
803
+ description: Optional description
804
+ share: Whether to create a public link
805
+ **kwargs: Additional arguments to pass to gradio.launch()
806
+
807
+ Returns:
808
+ The Gradio app instance and launch URLs.
809
+ """
810
+ self.logger.debug("Launching Gradio app")
811
+ # Ensure the agent has this callback added
812
+ if self not in agent.callbacks:
813
+ agent.add_callback(self)
814
+ self.logger.info("GradioCallback automatically added to the agent.")
815
+
816
+ app = self.create_app(agent, title, description)
817
+
818
+ # Use the same event loop for Gradio
819
+ launch_kwargs = {
820
+ "share": share,
821
+ "prevent_thread_lock": True # This is crucial - allows the main event loop to continue running
822
+ }
823
+ launch_kwargs.update(kwargs) # Allow overriding share/debug etc.
824
+
825
+ # Get the current event loop
826
+ loop = asyncio.get_event_loop()
827
+ self.logger.debug(f"Using event loop for Gradio: {loop}")
828
+
829
+ app.queue()
830
+ return app.launch(**launch_kwargs) # Return the app instance
831
+
832
+
833
+ from tinyagent.tiny_agent import tool
834
+ @tool(name="get_weather",description="Get the weather for a given city.")
835
+ def get_weather(city: str)->str:
836
+ """Get the weather for a given city.
837
+ Args:
838
+ city: The city to get the weather for
839
+
840
+ Returns:
841
+ The weather for the given city
842
+ """
843
+
844
+ return f"The weather in {city} is sunny."
845
+
846
+ async def run_example():
847
+ """Example usage of GradioCallback with TinyAgent."""
848
+ import os
849
+ import sys
850
+ import tempfile
851
+ import shutil
852
+ import asyncio
853
+ from tinyagent import TinyAgent # Assuming TinyAgent is importable
854
+ from tinyagent.hooks.logging_manager import LoggingManager # Assuming LoggingManager exists
855
+
856
+ # --- Logging Setup (Simplified) ---
857
+ log_manager = LoggingManager(default_level=logging.INFO)
858
+ log_manager.set_levels({
859
+ 'tinyagent.hooks.gradio_callback': logging.DEBUG,
860
+ 'tinyagent.tiny_agent': logging.DEBUG,
861
+ 'tinyagent.mcp_client': logging.DEBUG,
862
+ })
863
+ console_handler = logging.StreamHandler(sys.stdout)
864
+ log_manager.configure_handler(
865
+ console_handler,
866
+ format_string='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
867
+ level=logging.DEBUG
868
+ )
869
+ ui_logger = log_manager.get_logger('tinyagent.hooks.gradio_callback')
870
+ agent_logger = log_manager.get_logger('tinyagent.tiny_agent')
871
+ ui_logger.info("--- Starting GradioCallback Example ---")
872
+ # --- End Logging Setup ---
873
+
874
+ api_key = os.environ.get("OPENAI_API_KEY")
875
+ if not api_key:
876
+ ui_logger.error("OPENAI_API_KEY environment variable not set.")
877
+ return
878
+
879
+ # Create a temporary folder for file uploads
880
+ upload_folder = tempfile.mkdtemp(prefix="gradio_uploads_")
881
+ ui_logger.info(f"Created temporary upload folder: {upload_folder}")
882
+
883
+ # Ensure we're using a single event loop for everything
884
+ loop = asyncio.get_event_loop()
885
+ ui_logger.debug(f"Using event loop: {loop}")
886
+
887
+ # Initialize the agent
888
+ agent = TinyAgent(model="gpt-4.1-mini", api_key=api_key, logger=agent_logger)
889
+
890
+ agent.add_tool(get_weather)
891
+
892
+ # Create the Gradio callback
893
+ gradio_ui = GradioCallback(
894
+ file_upload_folder=upload_folder,
895
+ show_thinking=True,
896
+ show_tool_calls=True,
897
+ logger=ui_logger # Pass the specific logger
898
+ )
899
+ agent.add_callback(gradio_ui)
900
+
901
+ # Connect to MCP servers
902
+ try:
903
+ ui_logger.info("Connecting to MCP servers...")
904
+ # Use standard MCP servers as per contribution guide
905
+ await agent.connect_to_server("npx",["-y","@openbnb/mcp-server-airbnb","--ignore-robots-txt"])
906
+ await agent.connect_to_server("npx", ["-y", "@modelcontextprotocol/server-sequential-thinking"])
907
+ ui_logger.info("Connected to MCP servers.")
908
+ except Exception as e:
909
+ ui_logger.error(f"Failed to connect to MCP servers: {e}", exc_info=True)
910
+ # Continue without servers - we still have the local get_weather tool
911
+
912
+ # Create the Gradio app but don't launch it yet
913
+ #app = gradio_ui.create_app(
914
+ # agent,
915
+ # title="TinyAgent Chat Interface",
916
+ # description="Chat with TinyAgent. Try asking: 'Plan a trip to Toronto for 7 days in the next month.'",
917
+ #)
918
+
919
+ # Configure the queue without extra parameters
920
+ #app.queue()
921
+
922
+ # Launch the app in a way that doesn't block our event loop
923
+ ui_logger.info("Launching Gradio interface...")
924
+ try:
925
+ # Launch without blocking
926
+ #app.launch(
927
+ # share=False,
928
+ # prevent_thread_lock=True, # Critical to not block our event loop
929
+ # show_error=True
930
+ #)
931
+ gradio_ui.launch(
932
+ agent,
933
+ title="TinyAgent Chat Interface",
934
+ description="Chat with TinyAgent. Try asking: 'Plan a trip to Toronto for 7 days in the next month.'",
935
+ share=False,
936
+ prevent_thread_lock=True, # Critical to not block our event loop
937
+ show_error=True
938
+ )
939
+ ui_logger.info("Gradio interface launched (non-blocking).")
940
+
941
+ # Keep the main event loop running to handle both Gradio and MCP operations
942
+ # This is the key part - we need to keep our main event loop running
943
+ # but also allow it to process both Gradio and MCP client operations
944
+ while True:
945
+ await asyncio.sleep(1) # More efficient than an Event().wait()
946
+
947
+ except KeyboardInterrupt:
948
+ ui_logger.info("Received keyboard interrupt, shutting down...")
949
+ except Exception as e:
950
+ ui_logger.error(f"Failed to launch or run Gradio app: {e}", exc_info=True)
951
+ finally:
952
+ # Clean up
953
+ ui_logger.info("Cleaning up resources...")
954
+ if os.path.exists(upload_folder):
955
+ ui_logger.info(f"Removing temporary upload folder: {upload_folder}")
956
+ shutil.rmtree(upload_folder)
957
+ await agent.close()
958
+ ui_logger.info("--- GradioCallback Example Finished ---")
959
+
960
+
961
+ if __name__ == "__main__":
962
+ # Ensure asyncio event loop is handled correctly
963
+ try:
964
+ asyncio.run(run_example())
965
+ except KeyboardInterrupt:
966
+ print("\nExiting...")