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.
- tinyagent/code_agent/helper.py +2 -2
- tinyagent/code_agent/modal_sandbox.py +1 -1
- tinyagent/code_agent/providers/base.py +153 -7
- tinyagent/code_agent/providers/modal_provider.py +141 -27
- tinyagent/code_agent/safety.py +6 -2
- tinyagent/code_agent/tiny_code_agent.py +303 -11
- tinyagent/code_agent/utils.py +97 -1
- tinyagent/hooks/__init__.py +3 -1
- tinyagent/hooks/jupyter_notebook_callback.py +1464 -0
- tinyagent/hooks/token_tracker.py +564 -0
- tinyagent/prompts/summarize.yaml +96 -0
- tinyagent/tiny_agent.py +426 -17
- {tinyagent_py-0.0.13.dist-info → tinyagent_py-0.0.15.dist-info}/METADATA +1 -1
- {tinyagent_py-0.0.13.dist-info → tinyagent_py-0.0.15.dist-info}/RECORD +17 -14
- {tinyagent_py-0.0.13.dist-info → tinyagent_py-0.0.15.dist-info}/WHEEL +0 -0
- {tinyagent_py-0.0.13.dist-info → tinyagent_py-0.0.15.dist-info}/licenses/LICENSE +0 -0
- {tinyagent_py-0.0.13.dist-info → tinyagent_py-0.0.15.dist-info}/top_level.txt +0 -0
@@ -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
|