tinyagent-py 0.0.13__py3-none-any.whl → 0.0.15__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,1464 @@
1
+ from contextvars import ContextVar
2
+ import io
3
+ import logging
4
+ from contextlib import redirect_stdout
5
+ from typing import Any, List, Optional
6
+ import asyncio
7
+ import html
8
+ import json
9
+ import re
10
+
11
+ from IPython.display import display
12
+ from ipywidgets import Accordion, HTML, Output, VBox, Button, HBox
13
+ from ipywidgets import Text as IPyText
14
+ from rich.console import Console
15
+ from rich.logging import RichHandler
16
+ from rich.markdown import Markdown
17
+ from rich.panel import Panel
18
+ from rich.text import Text
19
+ from rich.json import JSON
20
+ from rich.rule import Rule
21
+
22
+ # Import token tracking for usage display
23
+ try:
24
+ from .token_tracker import TokenTracker, create_token_tracker
25
+ TOKEN_TRACKING_AVAILABLE = True
26
+ except ImportError:
27
+ TOKEN_TRACKING_AVAILABLE = False
28
+
29
+ # Try to import markdown for enhanced rendering
30
+ try:
31
+ import markdown
32
+ MARKDOWN_AVAILABLE = True
33
+ except ImportError:
34
+ MARKDOWN_AVAILABLE = False
35
+
36
+ # Context variable to hold the stack of container widgets
37
+ _ui_context_stack = ContextVar("ui_context_stack", default=None)
38
+
39
+
40
+ class OptimizedJupyterNotebookCallback:
41
+ """
42
+ An optimized version of JupyterNotebookCallback designed for long agent runs.
43
+ Uses minimal widgets and efficient HTML accumulation to prevent UI freeze.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ logger: Optional[logging.Logger] = None,
49
+ auto_display: bool = True,
50
+ max_turns: int = 30,
51
+ max_content_length: int = 100000, # Limit total HTML content length
52
+ max_visible_turns: int = 20, # Limit visible conversation turns
53
+ enable_markdown: bool = True, # Whether to process markdown
54
+ show_raw_responses: bool = False, # Show raw responses instead of formatted
55
+ enable_token_tracking: bool = True # Whether to show token tracking accordion
56
+ ):
57
+ """
58
+ Initialize the optimized callback.
59
+
60
+ Args:
61
+ logger: Optional logger instance
62
+ auto_display: Whether to automatically display the UI
63
+ max_turns: Maximum turns for agent runs
64
+ max_content_length: Maximum HTML content length before truncation
65
+ max_visible_turns: Maximum visible conversation turns (older ones get archived)
66
+ enable_markdown: Whether to process markdown (set False for better performance)
67
+ show_raw_responses: Show raw responses instead of formatted (better performance)
68
+ enable_token_tracking: Whether to show token tracking accordion
69
+ """
70
+ self.logger = logger or logging.getLogger(__name__)
71
+ self.max_turns = max_turns
72
+ self.max_content_length = max_content_length
73
+ self.max_visible_turns = max_visible_turns
74
+ self.enable_markdown = enable_markdown
75
+ self.show_raw_responses = show_raw_responses
76
+ self.enable_token_tracking = enable_token_tracking and TOKEN_TRACKING_AVAILABLE
77
+ self.agent: Optional[Any] = None
78
+ self._auto_display = auto_display
79
+
80
+ # Content accumulation
81
+ self.content_buffer = []
82
+ self.turn_count = 0
83
+ self.archived_turns = 0
84
+
85
+ # Token tracking
86
+ self.token_tracker: Optional[TokenTracker] = None
87
+ self._last_token_update = 0 # Throttle token updates
88
+ self._token_update_interval = 2.0 # Update every 2 seconds at most
89
+
90
+ # Single widgets for the entire UI
91
+ self.content_html = HTML(value="")
92
+ self._create_footer()
93
+ self._create_token_accordion()
94
+
95
+ # Build main container with token tracking if enabled
96
+ if self.enable_token_tracking:
97
+ self.main_container = VBox([self.content_html, self.footer_box, self.token_accordion])
98
+ else:
99
+ self.main_container = VBox([self.content_html, self.footer_box])
100
+
101
+ if self._auto_display:
102
+ self._initialize_ui()
103
+
104
+ def _initialize_ui(self):
105
+ """Initialize the UI display."""
106
+ display(self.main_container)
107
+ self.logger.debug("OptimizedJupyterNotebookCallback UI initialized")
108
+
109
+ def _create_footer(self):
110
+ """Creates the footer widgets for user interaction."""
111
+ self.input_text = IPyText(
112
+ placeholder='Send a message to the agent...',
113
+ layout={'width': '70%'},
114
+ disabled=True
115
+ )
116
+ self.submit_button = Button(
117
+ description="Submit",
118
+ tooltip="Send the message to the agent",
119
+ disabled=True,
120
+ button_style='primary'
121
+ )
122
+ self.resume_button = Button(
123
+ description="Resume",
124
+ tooltip="Resume the agent's operation",
125
+ disabled=True
126
+ )
127
+ self.clear_button = Button(
128
+ description="Clear",
129
+ tooltip="Clear the conversation display",
130
+ disabled=False,
131
+ button_style='warning'
132
+ )
133
+ self.footer_box = HBox([self.input_text, self.submit_button, self.resume_button, self.clear_button])
134
+
135
+ def _setup_footer_handlers(self):
136
+ """Sets up event handlers for the footer widgets."""
137
+ if not self.agent:
138
+ return
139
+
140
+ async def _run_agent_task(coro):
141
+ """Wrapper to run agent tasks and manage widget states."""
142
+ self.input_text.disabled = True
143
+ self.submit_button.disabled = True
144
+ self.resume_button.disabled = True
145
+ try:
146
+ result = await coro
147
+ self.logger.debug(f"Agent task completed with result: {result}")
148
+ return result
149
+ except Exception as e:
150
+ self.logger.error(f"Error running agent from UI: {e}", exc_info=True)
151
+ self._add_content(f'<div style="color: red; padding: 10px; border: 1px solid red; margin: 5px 0;"><strong>Error:</strong> {html.escape(str(e))}</div>')
152
+ finally:
153
+ self.input_text.disabled = False
154
+ self.submit_button.disabled = False
155
+ self.resume_button.disabled = False
156
+
157
+ def on_submit(widget):
158
+ value = widget.value
159
+ if not value or not self.agent:
160
+ return
161
+ widget.value = ""
162
+
163
+ try:
164
+ loop = asyncio.get_event_loop()
165
+ if loop.is_running():
166
+ asyncio.ensure_future(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
167
+ else:
168
+ asyncio.create_task(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
169
+ except RuntimeError:
170
+ loop = asyncio.new_event_loop()
171
+ asyncio.set_event_loop(loop)
172
+ loop.run_until_complete(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
173
+
174
+ def on_submit_click(button):
175
+ value = self.input_text.value
176
+ if not value or not self.agent:
177
+ return
178
+ self.input_text.value = ""
179
+
180
+ try:
181
+ loop = asyncio.get_event_loop()
182
+ if loop.is_running():
183
+ asyncio.ensure_future(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
184
+ else:
185
+ asyncio.create_task(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
186
+ except RuntimeError:
187
+ loop = asyncio.new_event_loop()
188
+ asyncio.set_event_loop(loop)
189
+ loop.run_until_complete(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
190
+
191
+ def on_resume_click(button):
192
+ if not self.agent:
193
+ return
194
+
195
+ try:
196
+ loop = asyncio.get_event_loop()
197
+ if loop.is_running():
198
+ asyncio.ensure_future(_run_agent_task(self.agent.resume()))
199
+ else:
200
+ asyncio.create_task(_run_agent_task(self.agent.resume()))
201
+ except RuntimeError:
202
+ loop = asyncio.new_event_loop()
203
+ asyncio.set_event_loop(loop)
204
+ loop.run_until_complete(_run_agent_task(self.agent.resume()))
205
+
206
+ def on_clear_click(button):
207
+ """Clear the conversation display."""
208
+ self.content_buffer = []
209
+ self.turn_count = 0
210
+ self.archived_turns = 0
211
+ self._update_display()
212
+
213
+ self.input_text.on_submit(on_submit)
214
+ self.submit_button.on_click(on_submit_click)
215
+ self.resume_button.on_click(on_resume_click)
216
+ self.clear_button.on_click(on_clear_click)
217
+
218
+ def _create_token_accordion(self):
219
+ """Create the token tracking accordion widget."""
220
+ if not self.enable_token_tracking:
221
+ self.token_accordion = VBox() # Empty container
222
+ return
223
+
224
+ # Create the content area for token information
225
+ self.token_content = HTML(value=self._get_initial_token_display())
226
+
227
+ # Create refresh button
228
+ self.refresh_tokens_button = Button(
229
+ description="🔄 Refresh",
230
+ tooltip="Refresh token usage information",
231
+ button_style='info',
232
+ layout={'width': 'auto'}
233
+ )
234
+ self.refresh_tokens_button.on_click(self._refresh_token_display)
235
+
236
+ # Create the accordion content
237
+ token_box = VBox([
238
+ HBox([self.refresh_tokens_button]),
239
+ self.token_content
240
+ ])
241
+
242
+ # Create the accordion
243
+ self.token_accordion = Accordion(
244
+ children=[token_box],
245
+ titles=["💰 Token Usage & Costs"],
246
+ selected_index=None # Start collapsed
247
+ )
248
+
249
+ def _get_initial_token_display(self) -> str:
250
+ """Get the initial token display HTML."""
251
+ return """
252
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 15px;">
253
+ <div style="color: #666; text-align: center; padding: 20px;">
254
+ <p>🔌 <strong>Token tracking will appear here once the agent starts running.</strong></p>
255
+ <p style="font-size: 0.9em;">Real-time token counts and costs will be displayed automatically.</p>
256
+ </div>
257
+ </div>
258
+ """
259
+
260
+ def _refresh_token_display(self, button=None):
261
+ """Refresh the token display manually."""
262
+ if self.token_tracker:
263
+ self._update_token_display()
264
+ else:
265
+ # Try to find token tracker from agent callbacks
266
+ if self.agent and hasattr(self.agent, 'callbacks'):
267
+ for callback in self.agent.callbacks:
268
+ if hasattr(callback, 'get_total_usage'): # Duck typing check for TokenTracker
269
+ self.token_tracker = callback
270
+ self._update_token_display()
271
+ return
272
+
273
+ # No tracker found
274
+ self.token_content.value = """
275
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 15px;">
276
+ <div style="color: #ff6b6b; text-align: center; padding: 20px; border: 1px solid #ff6b6b; border-radius: 5px; background-color: #fff5f5;">
277
+ <p><strong>⚠️ No token tracker found</strong></p>
278
+ <p style="font-size: 0.9em;">Add a TokenTracker to your agent to see usage information:<br>
279
+ <code>agent.add_callback(create_token_tracker("my_agent"))</code></p>
280
+ </div>
281
+ </div>
282
+ """
283
+
284
+ def _update_token_display(self):
285
+ """Update the token display with current usage information."""
286
+ if not self.token_tracker or not self.enable_token_tracking:
287
+ return
288
+
289
+ try:
290
+ # Get usage data
291
+ total_usage = self.token_tracker.get_total_usage(include_children=True)
292
+ model_breakdown = self.token_tracker.get_model_breakdown(include_children=True)
293
+ provider_breakdown = self.token_tracker.get_provider_breakdown(include_children=True)
294
+ session_duration = self.token_tracker.get_session_duration()
295
+
296
+ # Build HTML display
297
+ html_content = self._build_token_display_html(
298
+ total_usage, model_breakdown, provider_breakdown, session_duration
299
+ )
300
+
301
+ self.token_content.value = html_content
302
+
303
+ except Exception as e:
304
+ self.logger.error(f"Error updating token display: {e}")
305
+ self.token_content.value = f"""
306
+ <div style="color: #ff6b6b; padding: 15px; border: 1px solid #ff6b6b; border-radius: 5px; background-color: #fff5f5;">
307
+ <p><strong>❌ Error updating token display:</strong></p>
308
+ <p style="font-size: 0.9em;">{html.escape(str(e))}</p>
309
+ </div>
310
+ """
311
+
312
+ def _build_token_display_html(self, total_usage, model_breakdown, provider_breakdown, session_duration) -> str:
313
+ """Build the HTML content for token display."""
314
+
315
+ # Main stats
316
+ total_tokens = f"{total_usage.total_tokens:,}" if total_usage.total_tokens else "0"
317
+ total_cost = f"${total_usage.cost:.6f}" if total_usage.cost else "$0.000000"
318
+ api_calls = f"{total_usage.call_count}" if total_usage.call_count else "0"
319
+ duration_mins = f"{session_duration/60:.1f}" if session_duration else "0.0"
320
+
321
+ html_parts = [
322
+ """
323
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 15px;">
324
+ """,
325
+ # Main summary
326
+ f"""
327
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
328
+ <h3 style="margin: 0 0 10px 0; font-size: 1.1em;">📊 Overall Usage</h3>
329
+ <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; font-size: 0.9em;">
330
+ <div><strong>Total Tokens:</strong> {total_tokens}</div>
331
+ <div><strong>Total Cost:</strong> {total_cost}</div>
332
+ <div><strong>API Calls:</strong> {api_calls}</div>
333
+ <div><strong>Session Time:</strong> {duration_mins} min</div>
334
+ </div>
335
+ </div>
336
+ """
337
+ ]
338
+
339
+ # Token breakdown
340
+ if total_usage.prompt_tokens or total_usage.completion_tokens:
341
+ html_parts.append(f"""
342
+ <div style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 12px; margin-bottom: 15px;">
343
+ <h4 style="margin: 0 0 8px 0; color: #495057; font-size: 1em;">🔢 Token Breakdown</h4>
344
+ <div style="font-size: 0.85em; color: #6c757d;">
345
+ <div>📝 <strong>Prompt tokens:</strong> {total_usage.prompt_tokens:,}</div>
346
+ <div>💬 <strong>Completion tokens:</strong> {total_usage.completion_tokens:,}</div>
347
+ """)
348
+
349
+ # Add special token types if present
350
+ if total_usage.thinking_tokens > 0:
351
+ html_parts.append(f"<div>🤔 <strong>Thinking tokens:</strong> {total_usage.thinking_tokens:,}</div>")
352
+ if total_usage.reasoning_tokens > 0:
353
+ html_parts.append(f"<div>🧠 <strong>Reasoning tokens:</strong> {total_usage.reasoning_tokens:,}</div>")
354
+ if total_usage.cache_creation_input_tokens > 0:
355
+ html_parts.append(f"<div>💾 <strong>Cache creation:</strong> {total_usage.cache_creation_input_tokens:,}</div>")
356
+ if total_usage.cache_read_input_tokens > 0:
357
+ html_parts.append(f"<div>📖 <strong>Cache read:</strong> {total_usage.cache_read_input_tokens:,}</div>")
358
+
359
+ html_parts.append("</div></div>")
360
+
361
+ # Model breakdown
362
+ if len(model_breakdown) > 0:
363
+ html_parts.append("""
364
+ <div style="background-color: #e3f2fd; border: 1px solid #bbdefb; border-radius: 6px; padding: 12px; margin-bottom: 15px;">
365
+ <h4 style="margin: 0 0 8px 0; color: #1565c0; font-size: 1em;">🤖 By Model</h4>
366
+ <div style="font-size: 0.85em;">
367
+ """)
368
+
369
+ for model, stats in sorted(model_breakdown.items(), key=lambda x: x[1].cost, reverse=True):
370
+ cost_str = f"${stats.cost:.6f}" if stats.cost else "$0.000000"
371
+ html_parts.append(f"""
372
+ <div style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #e3f2fd;">
373
+ <span><strong>{html.escape(model)}</strong></span>
374
+ <span>{stats.total_tokens:,} tokens • {cost_str}</span>
375
+ </div>
376
+ """)
377
+
378
+ html_parts.append("</div></div>")
379
+
380
+ # Provider breakdown (if multiple providers)
381
+ if len(provider_breakdown) > 1:
382
+ html_parts.append("""
383
+ <div style="background-color: #e8f5e8; border: 1px solid #c8e6c9; border-radius: 6px; padding: 12px; margin-bottom: 15px;">
384
+ <h4 style="margin: 0 0 8px 0; color: #2e7d32; font-size: 1em;">🏢 By Provider</h4>
385
+ <div style="font-size: 0.85em;">
386
+ """)
387
+
388
+ for provider, stats in sorted(provider_breakdown.items(), key=lambda x: x[1].cost, reverse=True):
389
+ cost_str = f"${stats.cost:.6f}" if stats.cost else "$0.000000"
390
+ html_parts.append(f"""
391
+ <div style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #e8f5e8;">
392
+ <span><strong>{html.escape(provider.title())}</strong></span>
393
+ <span>{stats.total_tokens:,} tokens • {cost_str}</span>
394
+ </div>
395
+ """)
396
+
397
+ html_parts.append("</div></div>")
398
+
399
+ # Cost efficiency (if we have data)
400
+ if total_usage.call_count > 0 and total_usage.total_tokens > 0:
401
+ avg_cost_per_call = total_usage.cost / total_usage.call_count
402
+ cost_per_1k_tokens = (total_usage.cost / total_usage.total_tokens) * 1000
403
+
404
+ html_parts.append(f"""
405
+ <div style="background-color: #fff3e0; border: 1px solid #ffcc02; border-radius: 6px; padding: 12px;">
406
+ <h4 style="margin: 0 0 8px 0; color: #ef6c00; font-size: 1em;">💡 Efficiency</h4>
407
+ <div style="font-size: 0.85em; color: #ef6c00;">
408
+ <div>📊 <strong>Avg cost/call:</strong> ${avg_cost_per_call:.6f}</div>
409
+ <div>📈 <strong>Cost per 1K tokens:</strong> ${cost_per_1k_tokens:.6f}</div>
410
+ </div>
411
+ </div>
412
+ """)
413
+
414
+ html_parts.append("</div>")
415
+
416
+ return "".join(html_parts)
417
+
418
+ def _get_base_styles(self) -> str:
419
+ """Get base CSS styles for formatting."""
420
+ return """
421
+ <style>
422
+ .opt-content {
423
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
424
+ line-height: 1.5;
425
+ padding: 8px;
426
+ margin: 3px 0;
427
+ border-radius: 4px;
428
+ border-left: 3px solid #ddd;
429
+ }
430
+ .opt-user { background-color: #e3f2fd; border-left-color: #2196f3; }
431
+ .opt-assistant { background-color: #e8f5e8; border-left-color: #4caf50; }
432
+ .opt-tool { background-color: #fff3e0; border-left-color: #ff9800; }
433
+ .opt-result { background-color: #f3e5f5; border-left-color: #9c27b0; }
434
+ .opt-code {
435
+ font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
436
+ background-color: #f8f9fa;
437
+ border: 1px solid #e9ecef;
438
+ border-radius: 3px;
439
+ padding: 8px;
440
+ margin: 4px 0;
441
+ overflow-x: auto;
442
+ white-space: pre-wrap;
443
+ }
444
+ .opt-summary {
445
+ background-color: #f0f0f0;
446
+ border: 1px solid #ccc;
447
+ padding: 8px;
448
+ margin: 5px 0;
449
+ border-radius: 4px;
450
+ font-size: 0.9em;
451
+ }
452
+ </style>
453
+ """
454
+
455
+ def _process_content(self, content: str, content_type: str = "text") -> str:
456
+ """Process content for display with minimal overhead."""
457
+ if self.show_raw_responses:
458
+ return html.escape(str(content))
459
+
460
+ if content_type == "markdown" and self.enable_markdown and MARKDOWN_AVAILABLE:
461
+ try:
462
+ md = markdown.Markdown(extensions=['fenced_code'])
463
+ return md.convert(content)
464
+ except:
465
+ return html.escape(str(content))
466
+
467
+ # Simple markdown-like processing for performance
468
+ content = html.escape(str(content))
469
+ content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', content)
470
+ content = re.sub(r'`([^`]+)`', r'<code>\1</code>', content)
471
+ content = content.replace('\n', '<br>')
472
+ return content
473
+
474
+ def _add_content(self, html_content: str):
475
+ """Add content to the buffer and update display."""
476
+ self.content_buffer.append(html_content)
477
+
478
+ # Limit buffer size to prevent memory issues
479
+ if len(self.content_buffer) > self.max_visible_turns * 5: # Rough estimate of items per turn
480
+ removed = self.content_buffer.pop(0)
481
+ self.archived_turns += 1
482
+
483
+ self._update_display()
484
+
485
+ def _update_display(self):
486
+ """Update the main HTML widget with accumulated content."""
487
+ # Build the complete HTML
488
+ styles = self._get_base_styles()
489
+
490
+ content_html = [styles]
491
+
492
+ # Add archived turns summary if any
493
+ if self.archived_turns > 0:
494
+ content_html.append(
495
+ f'<div class="opt-summary">📁 {self.archived_turns} earlier conversation turns archived for performance</div>'
496
+ )
497
+
498
+ # Add current content
499
+ content_html.extend(self.content_buffer)
500
+
501
+ full_html = ''.join(content_html)
502
+
503
+ # Truncate if too long
504
+ if len(full_html) > self.max_content_length:
505
+ truncate_point = self.max_content_length - 200
506
+ full_html = full_html[:truncate_point] + '<div class="opt-summary">... [Content truncated for performance]</div>'
507
+
508
+ self.content_html.value = full_html
509
+
510
+ async def __call__(self, event_name: str, agent: Any, **kwargs: Any) -> None:
511
+ """Main callback entry point."""
512
+ if self.agent is None:
513
+ self.agent = agent
514
+ self._setup_footer_handlers()
515
+ self._setup_token_tracking()
516
+
517
+ handler = getattr(self, f"_handle_{event_name}", None)
518
+ if handler:
519
+ await handler(agent, **kwargs)
520
+
521
+ # Update token display after LLM events (with throttling to prevent UI freeze)
522
+ if event_name in ["llm_end", "agent_end"] and self.enable_token_tracking:
523
+ self._update_token_display_throttled()
524
+
525
+ def _update_token_display_throttled(self):
526
+ """Update the token display with throttling to prevent UI freeze."""
527
+ import time
528
+ current_time = time.time()
529
+
530
+ # Only update if enough time has passed since last update
531
+ if current_time - self._last_token_update < self._token_update_interval:
532
+ return
533
+
534
+ self._last_token_update = current_time
535
+ self._update_token_display()
536
+
537
+ def _setup_token_tracking(self):
538
+ """Set up token tracking by finding or creating a token tracker."""
539
+ if not self.enable_token_tracking or self.token_tracker:
540
+ return
541
+
542
+ # Try to find existing token tracker in agent callbacks
543
+ if self.agent and hasattr(self.agent, 'callbacks'):
544
+ for callback in self.agent.callbacks:
545
+ if hasattr(callback, 'get_total_usage'): # Duck typing check for TokenTracker
546
+ self.token_tracker = callback
547
+ self.logger.debug(f"Found existing TokenTracker: {callback.name if hasattr(callback, 'name') else type(callback).__name__}")
548
+ # Force an initial update to populate the display
549
+ try:
550
+ self._update_token_display()
551
+ except Exception as e:
552
+ self.logger.warning(f"Failed to update token display after setup: {e}")
553
+ return
554
+
555
+ # If no tracker found, suggest adding one in the display
556
+ self.logger.debug("No TokenTracker found in agent callbacks")
557
+ # Update display to show the "no tracker" message
558
+ if hasattr(self, 'token_content'):
559
+ self._refresh_token_display()
560
+
561
+ async def _handle_agent_start(self, agent: Any, **kwargs: Any):
562
+ """Handle agent start event."""
563
+ self.input_text.disabled = True
564
+ self.submit_button.disabled = True
565
+ self.resume_button.disabled = True
566
+
567
+ self.turn_count += 1
568
+ agent_name = agent.metadata.get("name", f"Agent Run #{self.turn_count}")
569
+
570
+ self._add_content(
571
+ f'<div class="opt-content opt-assistant">'
572
+ f'<strong>🚀 Agent Start:</strong> {html.escape(agent_name)} (Session: {agent.session_id})'
573
+ f'</div>'
574
+ )
575
+
576
+ async def _handle_agent_end(self, agent: Any, **kwargs: Any):
577
+ """Handle agent end event."""
578
+ self.input_text.disabled = False
579
+ self.submit_button.disabled = False
580
+ self.resume_button.disabled = False
581
+
582
+ result = kwargs.get("result", "")
583
+ self._add_content(
584
+ f'<div class="opt-content opt-assistant">'
585
+ f'<strong>✅ Agent Completed</strong><br>'
586
+ f'Result: {self._process_content(result)}'
587
+ f'</div>'
588
+ )
589
+
590
+ async def _handle_message_add(self, agent: Any, **kwargs: Any):
591
+ """Handle message add event."""
592
+ message = kwargs.get("message", {})
593
+ role = message.get("role")
594
+ content = message.get("content", "")
595
+
596
+ if role == "user":
597
+ self._add_content(
598
+ f'<div class="opt-content opt-user">'
599
+ f'<strong>👤 User:</strong><br>'
600
+ f'{self._process_content(content, "markdown")}'
601
+ f'</div>'
602
+ )
603
+ elif role == "assistant" and content:
604
+ self._add_content(
605
+ f'<div class="opt-content opt-assistant">'
606
+ f'<strong>🤖 Assistant:</strong><br>'
607
+ f'{self._process_content(content, "markdown")}'
608
+ f'</div>'
609
+ )
610
+
611
+ async def _handle_tool_start(self, agent: Any, **kwargs: Any):
612
+ """Handle tool start event."""
613
+ tool_call = kwargs.get("tool_call", {})
614
+ func_info = tool_call.get("function", {})
615
+ tool_name = func_info.get("name", "unknown_tool")
616
+
617
+ try:
618
+ args = json.loads(func_info.get("arguments", "{}"))
619
+ args_display = json.dumps(args, indent=2) if args else "No arguments"
620
+ except:
621
+ args_display = func_info.get("arguments", "Invalid JSON")
622
+
623
+ self._add_content(
624
+ f'<div class="opt-content opt-tool">'
625
+ f'<strong>🛠️ Tool Call:</strong> {html.escape(tool_name)}<br>'
626
+ f'<details><summary>Arguments</summary>'
627
+ f'<pre class="opt-code">{html.escape(args_display)}</pre>'
628
+ f'</details>'
629
+ f'</div>'
630
+ )
631
+
632
+ async def _handle_tool_end(self, agent: Any, **kwargs: Any):
633
+ """Handle tool end event."""
634
+ result = kwargs.get("result", "")
635
+
636
+ # Limit result size for display
637
+ if len(result) > 1000:
638
+ result_display = result[:1000] + "\n... [truncated]"
639
+ else:
640
+ result_display = result
641
+
642
+ self._add_content(
643
+ f'<div class="opt-content opt-result">'
644
+ f'<strong>📤 Tool Result:</strong><br>'
645
+ f'<details><summary>Show Result</summary>'
646
+ f'<pre class="opt-code">{html.escape(result_display)}</pre>'
647
+ f'</details>'
648
+ f'</div>'
649
+ )
650
+
651
+ async def _handle_llm_start(self, agent: Any, **kwargs: Any):
652
+ """Handle LLM start event."""
653
+ messages = kwargs.get("messages", [])
654
+ self._add_content(
655
+ f'<div class="opt-content opt-assistant">'
656
+ f'🧠 <strong>LLM Call</strong> with {len(messages)} messages'
657
+ f'</div>'
658
+ )
659
+
660
+ def reinitialize_ui(self):
661
+ """Reinitialize the UI display."""
662
+ self.logger.debug("Reinitializing OptimizedJupyterNotebookCallback UI")
663
+ display(self.main_container)
664
+ if self.agent:
665
+ self._setup_footer_handlers()
666
+
667
+ def show_ui(self):
668
+ """Display the UI."""
669
+ display(self.main_container)
670
+
671
+ async def close(self):
672
+ """Clean up resources."""
673
+ self.content_buffer = []
674
+ self.logger.debug("OptimizedJupyterNotebookCallback closed")
675
+
676
+ async def _handle_agent_cleanup(self, agent: Any, **kwargs: Any):
677
+ """Handle agent cleanup."""
678
+ await self.close()
679
+
680
+
681
+ class JupyterNotebookCallback:
682
+ """
683
+ A callback for TinyAgent that provides a rich, hierarchical, and collapsible
684
+ UI within a Jupyter Notebook environment using ipywidgets with enhanced markdown support.
685
+ """
686
+
687
+ def __init__(self, logger: Optional[logging.Logger] = None, auto_display: bool = True, max_turns: int = 30, enable_token_tracking: bool = True):
688
+ self.logger = logger or logging.getLogger(__name__)
689
+ self.max_turns = max_turns
690
+ self._token = None
691
+ self.agent: Optional[Any] = None
692
+ self._auto_display = auto_display
693
+ self.enable_token_tracking = enable_token_tracking and TOKEN_TRACKING_AVAILABLE
694
+
695
+ # Token tracking
696
+ self.token_tracker: Optional[TokenTracker] = None
697
+ self._last_token_update = 0 # Throttle token updates
698
+ self._token_update_interval = 2.0 # Update every 2 seconds at most
699
+
700
+ # 1. Create the main UI structure for this instance.
701
+ self.root_container = VBox()
702
+ self._create_footer()
703
+ self._create_token_accordion()
704
+
705
+ # Build main container with token tracking if enabled
706
+ if self.enable_token_tracking:
707
+ self.main_container = VBox([self.root_container, self.footer_box, self.token_accordion])
708
+ else:
709
+ self.main_container = VBox([self.root_container, self.footer_box])
710
+
711
+ # 2. Always set up a new context stack for this instance.
712
+ # This ensures each callback instance gets its own UI display.
713
+ if self._auto_display:
714
+ self._initialize_ui()
715
+
716
+ def _initialize_ui(self):
717
+ """Initialize the UI display for this callback instance."""
718
+ # Reset any existing context to ensure clean state
719
+ try:
720
+ # Clear any existing context for this instance
721
+ if _ui_context_stack.get() is not None:
722
+ # If there's an existing context, we'll create our own fresh one
723
+ pass
724
+ except LookupError:
725
+ # No existing context, which is fine
726
+ pass
727
+
728
+ # Set up our own context stack
729
+ self._token = _ui_context_stack.set([self.root_container])
730
+
731
+ # Display the entire structure for this instance
732
+ display(self.main_container)
733
+
734
+ self.logger.debug("JupyterNotebookCallback UI initialized and displayed")
735
+
736
+ def _create_footer(self):
737
+ """Creates the footer widgets for user interaction."""
738
+ self.input_text = IPyText(
739
+ placeholder='Send a message to the agent...',
740
+ layout={'width': '70%'},
741
+ disabled=True
742
+ )
743
+ self.submit_button = Button(
744
+ description="Submit",
745
+ tooltip="Send the message to the agent",
746
+ disabled=True,
747
+ button_style='primary'
748
+ )
749
+ self.resume_button = Button(
750
+ description="Resume",
751
+ tooltip="Resume the agent's operation",
752
+ disabled=True
753
+ )
754
+ self.footer_box = HBox([self.input_text, self.submit_button, self.resume_button])
755
+
756
+ def _setup_footer_handlers(self):
757
+ """Sets up event handlers for the footer widgets."""
758
+ if not self.agent:
759
+ return
760
+
761
+ async def _run_agent_task(coro):
762
+ """Wrapper to run agent tasks and manage widget states."""
763
+ self.input_text.disabled = True
764
+ self.submit_button.disabled = True
765
+ self.resume_button.disabled = True
766
+ try:
767
+ result = await coro
768
+ self.logger.debug(f"Agent task completed with result: {result}")
769
+ return result
770
+ except Exception as e:
771
+ self.logger.error(f"Error running agent from UI: {e}", exc_info=True)
772
+ # Create an error HTML widget to show the error to the user
773
+ container = self._get_current_container()
774
+ error_html = HTML(value=f"<div style='color: red; padding: 10px; border: 1px solid red;'><strong>Error:</strong> {html.escape(str(e))}</div>")
775
+ container.children += (error_html,)
776
+ finally:
777
+ # agent_end event re-enables widgets, but this is a fallback.
778
+ self.input_text.disabled = False
779
+ self.submit_button.disabled = False
780
+ self.resume_button.disabled = False
781
+
782
+ def on_submit(widget):
783
+ value = widget.value
784
+ if not value or not self.agent:
785
+ return
786
+ widget.value = ""
787
+
788
+ # Use asyncio.ensure_future instead of create_task for better Jupyter compatibility
789
+ try:
790
+ # Get the current event loop
791
+ loop = asyncio.get_event_loop()
792
+ if loop.is_running():
793
+ # If the loop is already running (typical in Jupyter), use ensure_future
794
+ asyncio.ensure_future(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
795
+ else:
796
+ # If no loop is running, create a task
797
+ asyncio.create_task(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
798
+ except RuntimeError:
799
+ # Fallback for edge cases
800
+ loop = asyncio.new_event_loop()
801
+ asyncio.set_event_loop(loop)
802
+ loop.run_until_complete(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
803
+
804
+ def on_submit_click(button):
805
+ value = self.input_text.value
806
+ if not value or not self.agent:
807
+ return
808
+ self.input_text.value = ""
809
+
810
+ # Use asyncio.ensure_future instead of create_task for better Jupyter compatibility
811
+ try:
812
+ # Get the current event loop
813
+ loop = asyncio.get_event_loop()
814
+ if loop.is_running():
815
+ # If the loop is already running (typical in Jupyter), use ensure_future
816
+ asyncio.ensure_future(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
817
+ else:
818
+ # If no loop is running, create a task
819
+ asyncio.create_task(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
820
+ except RuntimeError:
821
+ # Fallback for edge cases
822
+ loop = asyncio.new_event_loop()
823
+ asyncio.set_event_loop(loop)
824
+ loop.run_until_complete(_run_agent_task(self.agent.run(value, max_turns=self.max_turns)))
825
+
826
+ def on_resume_click(button):
827
+ if not self.agent:
828
+ return
829
+
830
+ # Use asyncio.ensure_future instead of create_task for better Jupyter compatibility
831
+ try:
832
+ # Get the current event loop
833
+ loop = asyncio.get_event_loop()
834
+ if loop.is_running():
835
+ # If the loop is already running (typical in Jupyter), use ensure_future
836
+ asyncio.ensure_future(_run_agent_task(self.agent.resume()))
837
+ else:
838
+ # If no loop is running, create a task
839
+ asyncio.create_task(_run_agent_task(self.agent.resume()))
840
+ except RuntimeError:
841
+ # Fallback for edge cases
842
+ loop = asyncio.new_event_loop()
843
+ asyncio.set_event_loop(loop)
844
+ loop.run_until_complete(_run_agent_task(self.agent.resume()))
845
+
846
+ self.input_text.on_submit(on_submit)
847
+ self.submit_button.on_click(on_submit_click)
848
+ self.resume_button.on_click(on_resume_click)
849
+
850
+ def _create_token_accordion(self):
851
+ """Create the token tracking accordion widget."""
852
+ if not self.enable_token_tracking:
853
+ self.token_accordion = VBox() # Empty container
854
+ return
855
+
856
+ # Create the content area for token information
857
+ self.token_content = HTML(value=self._get_initial_token_display())
858
+
859
+ # Create refresh button
860
+ self.refresh_tokens_button = Button(
861
+ description="🔄 Refresh",
862
+ tooltip="Refresh token usage information",
863
+ button_style='info',
864
+ layout={'width': 'auto'}
865
+ )
866
+ self.refresh_tokens_button.on_click(self._refresh_token_display)
867
+
868
+ # Create the accordion content
869
+ token_box = VBox([
870
+ HBox([self.refresh_tokens_button]),
871
+ self.token_content
872
+ ])
873
+
874
+ # Create the accordion
875
+ self.token_accordion = Accordion(
876
+ children=[token_box],
877
+ titles=["💰 Token Usage & Costs"],
878
+ selected_index=None # Start collapsed
879
+ )
880
+
881
+ def _get_initial_token_display(self) -> str:
882
+ """Get the initial token display HTML."""
883
+ return """
884
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 15px;">
885
+ <div style="color: #666; text-align: center; padding: 20px;">
886
+ <p>🔌 <strong>Token tracking will appear here once the agent starts running.</strong></p>
887
+ <p style="font-size: 0.9em;">Real-time token counts and costs will be displayed automatically.</p>
888
+ </div>
889
+ </div>
890
+ """
891
+
892
+ def _refresh_token_display(self, button=None):
893
+ """Refresh the token display manually."""
894
+ if self.token_tracker:
895
+ self._update_token_display()
896
+ else:
897
+ # Try to find token tracker from agent callbacks
898
+ if self.agent and hasattr(self.agent, 'callbacks'):
899
+ for callback in self.agent.callbacks:
900
+ if hasattr(callback, 'get_total_usage'): # Duck typing check for TokenTracker
901
+ self.token_tracker = callback
902
+ self._update_token_display()
903
+ return
904
+
905
+ # No tracker found
906
+ self.token_content.value = """
907
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 15px;">
908
+ <div style="color: #ff6b6b; text-align: center; padding: 20px; border: 1px solid #ff6b6b; border-radius: 5px; background-color: #fff5f5;">
909
+ <p><strong>⚠️ No token tracker found</strong></p>
910
+ <p style="font-size: 0.9em;">Add a TokenTracker to your agent to see usage information:<br>
911
+ <code>agent.add_callback(create_token_tracker("my_agent"))</code></p>
912
+ </div>
913
+ </div>
914
+ """
915
+
916
+ def _update_token_display(self):
917
+ """Update the token display with current usage information."""
918
+ if not self.token_tracker or not self.enable_token_tracking:
919
+ return
920
+
921
+ try:
922
+ # Get usage data
923
+ total_usage = self.token_tracker.get_total_usage(include_children=True)
924
+ model_breakdown = self.token_tracker.get_model_breakdown(include_children=True)
925
+ provider_breakdown = self.token_tracker.get_provider_breakdown(include_children=True)
926
+ session_duration = self.token_tracker.get_session_duration()
927
+
928
+ # Build HTML display
929
+ html_content = self._build_token_display_html(
930
+ total_usage, model_breakdown, provider_breakdown, session_duration
931
+ )
932
+
933
+ self.token_content.value = html_content
934
+
935
+ except Exception as e:
936
+ self.logger.error(f"Error updating token display: {e}")
937
+ self.token_content.value = f"""
938
+ <div style="color: #ff6b6b; padding: 15px; border: 1px solid #ff6b6b; border-radius: 5px; background-color: #fff5f5;">
939
+ <p><strong>❌ Error updating token display:</strong></p>
940
+ <p style="font-size: 0.9em;">{html.escape(str(e))}</p>
941
+ </div>
942
+ """
943
+
944
+ def _build_token_display_html(self, total_usage, model_breakdown, provider_breakdown, session_duration) -> str:
945
+ """Build the HTML content for token display."""
946
+
947
+ # Main stats
948
+ total_tokens = f"{total_usage.total_tokens:,}" if total_usage.total_tokens else "0"
949
+ total_cost = f"${total_usage.cost:.6f}" if total_usage.cost else "$0.000000"
950
+ api_calls = f"{total_usage.call_count}" if total_usage.call_count else "0"
951
+ duration_mins = f"{session_duration/60:.1f}" if session_duration else "0.0"
952
+
953
+ html_parts = [
954
+ """
955
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 15px;">
956
+ """,
957
+ # Main summary
958
+ f"""
959
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
960
+ <h3 style="margin: 0 0 10px 0; font-size: 1.1em;">📊 Overall Usage</h3>
961
+ <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; font-size: 0.9em;">
962
+ <div><strong>Total Tokens:</strong> {total_tokens}</div>
963
+ <div><strong>Total Cost:</strong> {total_cost}</div>
964
+ <div><strong>API Calls:</strong> {api_calls}</div>
965
+ <div><strong>Session Time:</strong> {duration_mins} min</div>
966
+ </div>
967
+ </div>
968
+ """
969
+ ]
970
+
971
+ # Token breakdown
972
+ if total_usage.prompt_tokens or total_usage.completion_tokens:
973
+ html_parts.append(f"""
974
+ <div style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 6px; padding: 12px; margin-bottom: 15px;">
975
+ <h4 style="margin: 0 0 8px 0; color: #495057; font-size: 1em;">🔢 Token Breakdown</h4>
976
+ <div style="font-size: 0.85em; color: #6c757d;">
977
+ <div>📝 <strong>Prompt tokens:</strong> {total_usage.prompt_tokens:,}</div>
978
+ <div>💬 <strong>Completion tokens:</strong> {total_usage.completion_tokens:,}</div>
979
+ """)
980
+
981
+ # Add special token types if present
982
+ if total_usage.thinking_tokens > 0:
983
+ html_parts.append(f"<div>🤔 <strong>Thinking tokens:</strong> {total_usage.thinking_tokens:,}</div>")
984
+ if total_usage.reasoning_tokens > 0:
985
+ html_parts.append(f"<div>🧠 <strong>Reasoning tokens:</strong> {total_usage.reasoning_tokens:,}</div>")
986
+ if total_usage.cache_creation_input_tokens > 0:
987
+ html_parts.append(f"<div>💾 <strong>Cache creation:</strong> {total_usage.cache_creation_input_tokens:,}</div>")
988
+ if total_usage.cache_read_input_tokens > 0:
989
+ html_parts.append(f"<div>📖 <strong>Cache read:</strong> {total_usage.cache_read_input_tokens:,}</div>")
990
+
991
+ html_parts.append("</div></div>")
992
+
993
+ # Model breakdown
994
+ if len(model_breakdown) > 0:
995
+ html_parts.append("""
996
+ <div style="background-color: #e3f2fd; border: 1px solid #bbdefb; border-radius: 6px; padding: 12px; margin-bottom: 15px;">
997
+ <h4 style="margin: 0 0 8px 0; color: #1565c0; font-size: 1em;">🤖 By Model</h4>
998
+ <div style="font-size: 0.85em;">
999
+ """)
1000
+
1001
+ for model, stats in sorted(model_breakdown.items(), key=lambda x: x[1].cost, reverse=True):
1002
+ cost_str = f"${stats.cost:.6f}" if stats.cost else "$0.000000"
1003
+ html_parts.append(f"""
1004
+ <div style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #e3f2fd;">
1005
+ <span><strong>{html.escape(model)}</strong></span>
1006
+ <span>{stats.total_tokens:,} tokens • {cost_str}</span>
1007
+ </div>
1008
+ """)
1009
+
1010
+ html_parts.append("</div></div>")
1011
+
1012
+ # Provider breakdown (if multiple providers)
1013
+ if len(provider_breakdown) > 1:
1014
+ html_parts.append("""
1015
+ <div style="background-color: #e8f5e8; border: 1px solid #c8e6c9; border-radius: 6px; padding: 12px; margin-bottom: 15px;">
1016
+ <h4 style="margin: 0 0 8px 0; color: #2e7d32; font-size: 1em;">🏢 By Provider</h4>
1017
+ <div style="font-size: 0.85em;">
1018
+ """)
1019
+
1020
+ for provider, stats in sorted(provider_breakdown.items(), key=lambda x: x[1].cost, reverse=True):
1021
+ cost_str = f"${stats.cost:.6f}" if stats.cost else "$0.000000"
1022
+ html_parts.append(f"""
1023
+ <div style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #e8f5e8;">
1024
+ <span><strong>{html.escape(provider.title())}</strong></span>
1025
+ <span>{stats.total_tokens:,} tokens • {cost_str}</span>
1026
+ </div>
1027
+ """)
1028
+
1029
+ html_parts.append("</div></div>")
1030
+
1031
+ # Cost efficiency (if we have data)
1032
+ if total_usage.call_count > 0 and total_usage.total_tokens > 0:
1033
+ avg_cost_per_call = total_usage.cost / total_usage.call_count
1034
+ cost_per_1k_tokens = (total_usage.cost / total_usage.total_tokens) * 1000
1035
+
1036
+ html_parts.append(f"""
1037
+ <div style="background-color: #fff3e0; border: 1px solid #ffcc02; border-radius: 6px; padding: 12px;">
1038
+ <h4 style="margin: 0 0 8px 0; color: #ef6c00; font-size: 1em;">💡 Efficiency</h4>
1039
+ <div style="font-size: 0.85em; color: #ef6c00;">
1040
+ <div>📊 <strong>Avg cost/call:</strong> ${avg_cost_per_call:.6f}</div>
1041
+ <div>📈 <strong>Cost per 1K tokens:</strong> ${cost_per_1k_tokens:.6f}</div>
1042
+ </div>
1043
+ </div>
1044
+ """)
1045
+
1046
+ html_parts.append("</div>")
1047
+
1048
+ return "".join(html_parts)
1049
+
1050
+ def _setup_token_tracking(self):
1051
+ """Set up token tracking by finding or creating a token tracker."""
1052
+ if not self.enable_token_tracking or self.token_tracker:
1053
+ return
1054
+
1055
+ # Try to find existing token tracker in agent callbacks
1056
+ if self.agent and hasattr(self.agent, 'callbacks'):
1057
+ for callback in self.agent.callbacks:
1058
+ if hasattr(callback, 'get_total_usage'): # Duck typing check for TokenTracker
1059
+ self.token_tracker = callback
1060
+ self.logger.debug("Found existing TokenTracker in agent callbacks")
1061
+ return
1062
+
1063
+ # If no tracker found, suggest adding one in the display
1064
+ self.logger.debug("No TokenTracker found in agent callbacks")
1065
+
1066
+ # --- Context Stack Management ---
1067
+ def _get_current_container(self) -> VBox:
1068
+ """Get the current container widget from the top of the stack."""
1069
+ stack = _ui_context_stack.get()
1070
+ if not stack:
1071
+ raise RuntimeError("UI context stack is not initialized.")
1072
+ return stack[-1]
1073
+
1074
+ def _push_container(self, new_container: VBox):
1075
+ """Push a new container widget onto the stack."""
1076
+ stack = _ui_context_stack.get()
1077
+ stack.append(new_container)
1078
+ _ui_context_stack.set(stack)
1079
+
1080
+ def _pop_container(self):
1081
+ """Pop a container widget from the stack."""
1082
+ stack = _ui_context_stack.get()
1083
+ if len(stack) > 1:
1084
+ stack.pop()
1085
+ _ui_context_stack.set(stack)
1086
+
1087
+ # --- Enhanced Rendering Logic ---
1088
+ def _get_base_styles(self) -> str:
1089
+ """Get base CSS styles for better formatting."""
1090
+ return """
1091
+ <style>
1092
+ .tinyagent-content {
1093
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1094
+ line-height: 1.6;
1095
+ padding: 12px;
1096
+ margin: 5px 0;
1097
+ border-radius: 6px;
1098
+ }
1099
+ .tinyagent-code {
1100
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
1101
+ background-color: #f6f8fa;
1102
+ border: 1px solid #d0d7de;
1103
+ border-radius: 6px;
1104
+ padding: 12px;
1105
+ margin: 8px 0;
1106
+ overflow-x: auto;
1107
+ white-space: pre-wrap;
1108
+ }
1109
+ .tinyagent-inline-code {
1110
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
1111
+ background-color: rgba(175, 184, 193, 0.2);
1112
+ padding: 2px 4px;
1113
+ border-radius: 3px;
1114
+ font-size: 0.9em;
1115
+ }
1116
+ .tinyagent-key {
1117
+ font-weight: 600;
1118
+ color: #0969da;
1119
+ }
1120
+ .tinyagent-value {
1121
+ color: #656d76;
1122
+ }
1123
+ .tinyagent-json {
1124
+ background-color: #f6f8fa;
1125
+ border-left: 4px solid #0969da;
1126
+ padding: 12px;
1127
+ margin: 8px 0;
1128
+ border-radius: 0 6px 6px 0;
1129
+ }
1130
+ </style>
1131
+ """
1132
+
1133
+ def _process_markdown(self, content: str) -> str:
1134
+ """Process markdown content and return HTML."""
1135
+ if not MARKDOWN_AVAILABLE:
1136
+ # Fallback: simple processing for basic markdown
1137
+ content = self._simple_markdown_fallback(content)
1138
+ return content
1139
+
1140
+ # Use full markdown processing
1141
+ md = markdown.Markdown(extensions=['fenced_code', 'codehilite', 'tables'])
1142
+ return md.convert(content)
1143
+
1144
+ def _simple_markdown_fallback(self, content: str) -> str:
1145
+ """Simple markdown processing when markdown library is not available."""
1146
+ # Basic markdown patterns
1147
+ content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', content) # Bold
1148
+ content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', content) # Italic
1149
+ content = re.sub(r'`([^`]+)`', r'<code class="tinyagent-inline-code">\1</code>', content) # Inline code
1150
+
1151
+ # Code blocks
1152
+ content = re.sub(r'```(\w+)?\n(.*?)\n```',
1153
+ r'<pre class="tinyagent-code">\2</pre>',
1154
+ content, flags=re.DOTALL)
1155
+
1156
+ # Convert newlines to <br>
1157
+ content = content.replace('\n', '<br>')
1158
+
1159
+ return content
1160
+
1161
+ def _format_key_value_pairs(self, data: dict, max_value_length: int = 200) -> str:
1162
+ """Format key-value pairs in a human-readable way."""
1163
+ formatted_items = []
1164
+
1165
+ for key, value in data.items():
1166
+ # Format the key
1167
+ key_html = f'<span class="tinyagent-key">{html.escape(str(key))}</span>'
1168
+
1169
+ # Format the value based on its type
1170
+ if isinstance(value, str):
1171
+ # Check if it looks like code or JSON
1172
+ if value.strip().startswith(('{', '[')) or '\n' in value:
1173
+ if len(value) > max_value_length:
1174
+ value = value[:max_value_length] + "... (truncated)"
1175
+ value_html = f'<pre class="tinyagent-code">{html.escape(value)}</pre>'
1176
+ else:
1177
+ # Process as potential markdown
1178
+ if len(value) > max_value_length:
1179
+ value = value[:max_value_length] + "... (truncated)"
1180
+ value_html = f'<span class="tinyagent-value">{self._process_markdown(value)}</span>'
1181
+ elif isinstance(value, (dict, list)):
1182
+ # JSON-like formatting
1183
+ json_str = json.dumps(value, indent=2, ensure_ascii=False)
1184
+ if len(json_str) > max_value_length:
1185
+ json_str = json_str[:max_value_length] + "... (truncated)"
1186
+ value_html = f'<div class="tinyagent-json"><pre>{html.escape(json_str)}</pre></div>'
1187
+ else:
1188
+ value_html = f'<span class="tinyagent-value">{html.escape(str(value))}</span>'
1189
+
1190
+ formatted_items.append(f'{key_html}: {value_html}')
1191
+
1192
+ return '<br>'.join(formatted_items)
1193
+
1194
+ def _create_enhanced_html_widget(self, content: str, style: str = "", content_type: str = "text") -> HTML:
1195
+ """Create an enhanced HTML widget with better formatting."""
1196
+ base_style = "font-family: inherit; margin: 5px 0;"
1197
+ full_style = base_style + style
1198
+
1199
+ # Add base styles
1200
+ styles = self._get_base_styles()
1201
+
1202
+ if content_type == "markdown":
1203
+ processed_content = self._process_markdown(content)
1204
+ html_content = f'{styles}<div class="tinyagent-content" style="{full_style}">{processed_content}</div>'
1205
+ elif content_type == "code":
1206
+ escaped_content = html.escape(str(content))
1207
+ html_content = f'{styles}<div style="{full_style}"><pre class="tinyagent-code">{escaped_content}</pre></div>'
1208
+ elif content_type == "json":
1209
+ try:
1210
+ parsed = json.loads(content)
1211
+ formatted_json = json.dumps(parsed, indent=2, ensure_ascii=False)
1212
+ escaped_content = html.escape(formatted_json)
1213
+ html_content = f'{styles}<div style="{full_style}"><div class="tinyagent-json"><pre>{escaped_content}</pre></div></div>'
1214
+ except:
1215
+ escaped_content = html.escape(str(content))
1216
+ html_content = f'{styles}<div style="{full_style}"><pre class="tinyagent-code">{escaped_content}</pre></div>'
1217
+ else:
1218
+ escaped_content = html.escape(str(content))
1219
+ html_content = f'{styles}<div class="tinyagent-content" style="{full_style}">{escaped_content}</div>'
1220
+
1221
+ return HTML(value=html_content)
1222
+
1223
+ def _render_enhanced_text(self, content: str, title: str = "", style: str = "", content_type: str = "markdown"):
1224
+ """Render text content using enhanced HTML widgets with markdown support."""
1225
+ container = self._get_current_container()
1226
+
1227
+ if title:
1228
+ title_style = "font-weight: bold; color: #2196F3; border-bottom: 1px solid #ccc; margin-bottom: 10px; padding-bottom: 5px;"
1229
+ title_widget = HTML(value=f'{self._get_base_styles()}<div style="{title_style}">{html.escape(title)}</div>')
1230
+ container.children += (title_widget,)
1231
+
1232
+ content_widget = self._create_enhanced_html_widget(content, style, content_type)
1233
+ container.children += (content_widget,)
1234
+
1235
+ # --- Main Callback Entry Point ---
1236
+ async def __call__(self, event_name: str, agent: Any, **kwargs: Any) -> None:
1237
+ """Main callback entry point."""
1238
+ if self.agent is None:
1239
+ self.agent = agent
1240
+ self._setup_footer_handlers()
1241
+ self._setup_token_tracking()
1242
+
1243
+ handler = getattr(self, f"_handle_{event_name}", None)
1244
+ if handler:
1245
+ await handler(agent, **kwargs)
1246
+
1247
+ # Update token display after LLM events (with throttling to prevent UI freeze)
1248
+ if event_name in ["llm_end", "agent_end"] and self.enable_token_tracking:
1249
+ self._update_token_display_throttled()
1250
+
1251
+ # --- Event Handlers ---
1252
+ async def _handle_agent_start(self, agent: Any, **kwargs: Any):
1253
+ parent_container = self._get_current_container()
1254
+ self.input_text.disabled = True
1255
+ self.submit_button.disabled = True
1256
+ self.resume_button.disabled = True
1257
+
1258
+ agent_content_box = VBox()
1259
+ agent_name = agent.metadata.get("name", f"Agent Run (Session: {agent.session_id})")
1260
+ accordion = Accordion(children=[agent_content_box], titles=[f"▶️ Agent Start: {agent_name}"])
1261
+
1262
+ parent_container.children += (accordion,)
1263
+ self._push_container(agent_content_box)
1264
+
1265
+ async def _handle_agent_end(self, agent: Any, **kwargs: Any):
1266
+ self._pop_container()
1267
+ self.input_text.disabled = False
1268
+ self.submit_button.disabled = False
1269
+ self.resume_button.disabled = False
1270
+
1271
+ async def _handle_tool_start(self, agent: Any, **kwargs: Any):
1272
+ parent_container = self._get_current_container()
1273
+ tool_call = kwargs.get("tool_call", {})
1274
+ func_info = tool_call.get("function", {})
1275
+ tool_name = func_info.get("name", "unknown_tool")
1276
+
1277
+ tool_content_box = VBox()
1278
+ accordion = Accordion(children=[tool_content_box], titles=[f"🛠️ Tool Call: {tool_name}"])
1279
+
1280
+ parent_container.children += (accordion,)
1281
+
1282
+ # Render arguments with enhanced formatting
1283
+ try:
1284
+ args = json.loads(func_info.get("arguments", "{}"))
1285
+ if args:
1286
+ self._push_container(tool_content_box)
1287
+ args_html = self._format_key_value_pairs(args)
1288
+ styles = self._get_base_styles()
1289
+ widget = HTML(value=f'{styles}<div class="tinyagent-content" style="background-color: #e3f2fd;"><strong>Arguments:</strong><br>{args_html}</div>')
1290
+ tool_content_box.children += (widget,)
1291
+ self._pop_container()
1292
+ else:
1293
+ self._push_container(tool_content_box)
1294
+ self._render_enhanced_text("No arguments", style="background-color: #f5f5f5;")
1295
+ self._pop_container()
1296
+ except json.JSONDecodeError:
1297
+ # Fallback for invalid JSON
1298
+ self._push_container(tool_content_box)
1299
+ self._render_enhanced_text(f"**Arguments (raw):**\n```\n{func_info.get('arguments', '{}')}\n```",
1300
+ style="background-color: #fff3e0;", content_type="markdown")
1301
+ self._pop_container()
1302
+
1303
+ self._push_container(tool_content_box)
1304
+
1305
+ async def _handle_tool_end(self, agent: Any, **kwargs: Any):
1306
+ result = kwargs.get("result", "")
1307
+
1308
+ try:
1309
+ # Try to parse as JSON first
1310
+ parsed_result = json.loads(result)
1311
+ if isinstance(parsed_result, dict):
1312
+ # Create enhanced output for dictionary results
1313
+ result_html = self._format_key_value_pairs(parsed_result)
1314
+ styles = self._get_base_styles()
1315
+ widget = HTML(value=f'{styles}<div class="tinyagent-content" style="background-color: #e8f5e8; border-left: 3px solid #4caf50;"><strong>Result:</strong><br>{result_html}</div>')
1316
+ container = self._get_current_container()
1317
+ container.children += (widget,)
1318
+ else:
1319
+ # Non-dictionary JSON result
1320
+ self._render_enhanced_text(f"**Result:**\n```json\n{json.dumps(parsed_result, indent=2)}\n```",
1321
+ style="background-color: #e8f5e8; border-left: 3px solid #4caf50;",
1322
+ content_type="markdown")
1323
+
1324
+ except (json.JSONDecodeError, TypeError):
1325
+ # Not JSON, treat as potential markdown
1326
+ # Check if it looks like code or structured data
1327
+ if result.strip().startswith(('{', '[', '<')) or '\n' in result:
1328
+ self._render_enhanced_text(f"**Result:**\n```\n{result}\n```",
1329
+ style="background-color: #e8f5e8; border-left: 3px solid #4caf50;",
1330
+ content_type="markdown")
1331
+ else:
1332
+ self._render_enhanced_text(f"**Result:** {result}",
1333
+ style="background-color: #e8f5e8; border-left: 3px solid #4caf50;",
1334
+ content_type="markdown")
1335
+
1336
+ # Finally, pop the container off the stack
1337
+ self._pop_container()
1338
+
1339
+ async def _handle_llm_start(self, agent: Any, **kwargs: Any):
1340
+ messages = kwargs.get("messages", [])
1341
+ text = f"🧠 **LLM Start:** Calling model with {len(messages)} messages..."
1342
+ self._render_enhanced_text(text, style="background-color: #f3e5f5; border-left: 3px solid #9c27b0;", content_type="markdown")
1343
+
1344
+ async def _handle_message_add(self, agent: Any, **kwargs: Any):
1345
+ message = kwargs.get("message", {})
1346
+ role = message.get("role")
1347
+ content = message.get("content", "")
1348
+
1349
+ if role == "user":
1350
+ self._render_enhanced_text(f"👤 **User:**\n\n{content}",
1351
+ style="background-color: #e3f2fd; border-left: 3px solid #2196f3;",
1352
+ content_type="markdown")
1353
+ elif role == "assistant" and content:
1354
+ self._render_enhanced_text(f"🤖 **Assistant:**\n\n{content}",
1355
+ style="background-color: #e8f5e8; border-left: 3px solid #4caf50;",
1356
+ content_type="markdown")
1357
+
1358
+ # --- UI Management ---
1359
+ def reinitialize_ui(self):
1360
+ """Reinitialize the UI display. Useful if UI disappeared after creating new agents."""
1361
+ self.logger.debug("Reinitializing JupyterNotebookCallback UI")
1362
+
1363
+ # Clean up existing context if any
1364
+ if self._token:
1365
+ try:
1366
+ _ui_context_stack.reset(self._token)
1367
+ except LookupError:
1368
+ # Context was already reset, which is fine
1369
+ pass
1370
+ self._token = None
1371
+
1372
+ # Clear existing children to avoid duplicates
1373
+ self.root_container.children = ()
1374
+
1375
+ # Reinitialize the UI
1376
+ self._initialize_ui()
1377
+
1378
+ # Re-setup handlers if agent is available
1379
+ if self.agent:
1380
+ self._setup_footer_handlers()
1381
+
1382
+ def show_ui(self):
1383
+ """Display the UI if it's not already shown."""
1384
+ if not self._token:
1385
+ self._initialize_ui()
1386
+ else:
1387
+ # UI is already initialized, just display it again
1388
+ display(self.main_container)
1389
+
1390
+ # --- Cleanup ---
1391
+ async def close(self):
1392
+ """Clean up resources."""
1393
+ if self._token:
1394
+ try:
1395
+ _ui_context_stack.reset(self._token)
1396
+ except LookupError:
1397
+ # Context was already reset, which is fine
1398
+ pass
1399
+ self._token = None
1400
+ self.logger.debug("JupyterNotebookCallback closed and cleaned up")
1401
+
1402
+ async def _handle_agent_cleanup(self, agent: Any, **kwargs: Any):
1403
+ """Handle agent cleanup to reset the UI context."""
1404
+ await self.close()
1405
+
1406
+
1407
+ async def run_example():
1408
+ """Example usage of JupyterNotebookCallback with TinyAgent in Jupyter."""
1409
+ import os
1410
+ from tinyagent import TinyAgent
1411
+
1412
+ # Get API key from environment
1413
+ api_key = os.environ.get("OPENAI_API_KEY")
1414
+ if not api_key:
1415
+ print("Please set the OPENAI_API_KEY environment variable")
1416
+ return
1417
+
1418
+ # Initialize the agent
1419
+ agent = TinyAgent(model="gpt-4.1-mini", api_key=api_key)
1420
+
1421
+ # Add the Jupyter Notebook callback
1422
+ jupyter_ui = JupyterNotebookCallback()
1423
+ agent.add_callback(jupyter_ui)
1424
+
1425
+ # Connect to MCP servers as per contribution guide
1426
+ await agent.connect_to_server("npx", ["-y", "@openbnb/mcp-server-airbnb", "--ignore-robots-txt"])
1427
+ await agent.connect_to_server("npx", ["-y", "@modelcontextprotocol/server-sequential-thinking"])
1428
+
1429
+ print("Enhanced JupyterNotebookCallback example setup complete. Use the input field above to interact with the agent.")
1430
+
1431
+ # Clean up
1432
+ # await agent.close() # Commented out so the UI remains active for interaction
1433
+
1434
+ async def run_optimized_example():
1435
+ """Example usage of OptimizedJupyterNotebookCallback with TinyAgent in Jupyter."""
1436
+ import os
1437
+ from tinyagent import TinyAgent
1438
+
1439
+ # Get API key from environment
1440
+ api_key = os.environ.get("OPENAI_API_KEY")
1441
+ if not api_key:
1442
+ print("Please set the OPENAI_API_KEY environment variable")
1443
+ return
1444
+
1445
+ # Initialize the agent
1446
+ agent = TinyAgent(model="gpt-4.1-mini", api_key=api_key)
1447
+
1448
+ # Add the OPTIMIZED Jupyter Notebook callback for better performance
1449
+ jupyter_ui = OptimizedJupyterNotebookCallback(
1450
+ max_visible_turns=15, # Limit visible turns
1451
+ max_content_length=50000, # Limit total content
1452
+ enable_markdown=True, # Keep markdown but optimized
1453
+ show_raw_responses=False # Show formatted responses
1454
+ )
1455
+ agent.add_callback(jupyter_ui)
1456
+
1457
+ # Connect to MCP servers as per contribution guide
1458
+ await agent.connect_to_server("npx", ["-y", "@openbnb/mcp-server-airbnb", "--ignore-robots-txt"])
1459
+ await agent.connect_to_server("npx", ["-y", "@modelcontextprotocol/server-sequential-thinking"])
1460
+
1461
+ print("OptimizedJupyterNotebookCallback example setup complete. This version handles long agent runs much better!")
1462
+
1463
+ # Clean up
1464
+ # await agent.close() # Commented out so the UI remains active for interaction