webagents 0.2.0__py3-none-any.whl → 0.2.3__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.
- webagents/__init__.py +9 -0
- webagents/agents/core/base_agent.py +865 -69
- webagents/agents/core/handoffs.py +14 -6
- webagents/agents/skills/base.py +33 -2
- webagents/agents/skills/core/llm/litellm/skill.py +906 -27
- webagents/agents/skills/core/memory/vector_memory/skill.py +8 -16
- webagents/agents/skills/ecosystem/crewai/__init__.py +3 -1
- webagents/agents/skills/ecosystem/crewai/skill.py +158 -0
- webagents/agents/skills/ecosystem/database/__init__.py +3 -1
- webagents/agents/skills/ecosystem/database/skill.py +522 -0
- webagents/agents/skills/ecosystem/mongodb/__init__.py +3 -0
- webagents/agents/skills/ecosystem/mongodb/skill.py +428 -0
- webagents/agents/skills/ecosystem/n8n/README.md +287 -0
- webagents/agents/skills/ecosystem/n8n/__init__.py +3 -0
- webagents/agents/skills/ecosystem/n8n/skill.py +341 -0
- webagents/agents/skills/ecosystem/openai/__init__.py +6 -0
- webagents/agents/skills/ecosystem/openai/skill.py +867 -0
- webagents/agents/skills/ecosystem/replicate/README.md +440 -0
- webagents/agents/skills/ecosystem/replicate/__init__.py +10 -0
- webagents/agents/skills/ecosystem/replicate/skill.py +517 -0
- webagents/agents/skills/ecosystem/x_com/README.md +401 -0
- webagents/agents/skills/ecosystem/x_com/__init__.py +3 -0
- webagents/agents/skills/ecosystem/x_com/skill.py +1048 -0
- webagents/agents/skills/ecosystem/zapier/README.md +363 -0
- webagents/agents/skills/ecosystem/zapier/__init__.py +3 -0
- webagents/agents/skills/ecosystem/zapier/skill.py +337 -0
- webagents/agents/skills/examples/__init__.py +6 -0
- webagents/agents/skills/examples/music_player.py +329 -0
- webagents/agents/skills/robutler/handoff/__init__.py +6 -0
- webagents/agents/skills/robutler/handoff/skill.py +191 -0
- webagents/agents/skills/robutler/nli/skill.py +180 -24
- webagents/agents/skills/robutler/payments/exceptions.py +27 -7
- webagents/agents/skills/robutler/payments/skill.py +64 -14
- webagents/agents/skills/robutler/storage/files/skill.py +2 -2
- webagents/agents/tools/decorators.py +243 -47
- webagents/agents/widgets/__init__.py +6 -0
- webagents/agents/widgets/renderer.py +150 -0
- webagents/server/core/app.py +130 -15
- webagents/server/core/models.py +1 -1
- webagents/utils/logging.py +13 -1
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/METADATA +16 -9
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/RECORD +45 -24
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/WHEEL +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/entry_points.txt +0 -0
- {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -20,7 +20,7 @@ import json
|
|
20
20
|
import threading
|
21
21
|
import time
|
22
22
|
import uuid
|
23
|
-
from typing import Dict, Any, List, Optional, Callable, Union, AsyncGenerator
|
23
|
+
from typing import Dict, Any, List, Optional, Callable, Union, AsyncGenerator, Awaitable
|
24
24
|
from datetime import datetime
|
25
25
|
|
26
26
|
from ..skills.base import Skill, Handoff, HandoffResult
|
@@ -125,17 +125,21 @@ class BaseAgent:
|
|
125
125
|
self._registered_hooks: Dict[str, List[Dict[str, Any]]] = {}
|
126
126
|
self._registered_handoffs: List[Dict[str, Any]] = []
|
127
127
|
self._registered_prompts: List[Dict[str, Any]] = []
|
128
|
+
self._registered_widgets: List[Dict[str, Any]] = []
|
128
129
|
self._registered_http_handlers: List[Dict[str, Any]] = []
|
129
130
|
self._registration_lock = threading.Lock()
|
130
131
|
|
131
132
|
# Track tools overridden by external tools (per request)
|
132
133
|
self._overridden_tools: set = set()
|
133
134
|
|
135
|
+
# Active handoff (completion handler) - set to lowest priority handoff after initialization
|
136
|
+
self.active_handoff: Optional[Handoff] = None
|
137
|
+
|
134
138
|
# Skills management
|
135
139
|
self.skills: Dict[str, Skill] = {}
|
136
140
|
|
137
|
-
# Structured logger setup (
|
138
|
-
self.logger = get_logger('base_agent',
|
141
|
+
# Structured logger setup (use agent name as subsystem for clear log attribution)
|
142
|
+
self.logger = get_logger('base_agent', self.name)
|
139
143
|
self._ensure_logger_handler()
|
140
144
|
|
141
145
|
# Process model parameter and initialize skills
|
@@ -252,18 +256,21 @@ class BaseAgent:
|
|
252
256
|
if isinstance(handoff_item, Handoff):
|
253
257
|
# Direct Handoff object
|
254
258
|
self.register_handoff(handoff_item, source="agent")
|
255
|
-
self.logger.debug(f"📨 Registered handoff target='{handoff_item.target}'
|
259
|
+
self.logger.debug(f"📨 Registered handoff target='{handoff_item.target}'")
|
256
260
|
elif callable(handoff_item) and hasattr(handoff_item, '_webagents_is_handoff'):
|
257
261
|
# Function with @handoff decorator
|
258
262
|
handoff_config = Handoff(
|
259
263
|
target=getattr(handoff_item, '_handoff_name', handoff_item.__name__),
|
260
|
-
|
261
|
-
description=getattr(handoff_item, '_handoff_description', ''),
|
264
|
+
description=getattr(handoff_item, '_handoff_prompt', ''),
|
262
265
|
scope=getattr(handoff_item, '_handoff_scope', self.scopes)
|
263
266
|
)
|
264
|
-
handoff_config.metadata = {
|
267
|
+
handoff_config.metadata = {
|
268
|
+
'function': handoff_item,
|
269
|
+
'priority': getattr(handoff_item, '_handoff_priority', 50),
|
270
|
+
'is_generator': getattr(handoff_item, '_handoff_is_generator', False)
|
271
|
+
}
|
265
272
|
self.register_handoff(handoff_config, source="agent")
|
266
|
-
self.logger.debug(f"📨 Registered handoff target='{handoff_config.target}'
|
273
|
+
self.logger.debug(f"📨 Registered handoff target='{handoff_config.target}'")
|
267
274
|
|
268
275
|
# Register HTTP handlers
|
269
276
|
if http_handlers:
|
@@ -286,11 +293,14 @@ class BaseAgent:
|
|
286
293
|
elif hasattr(capability_func, '_webagents_is_handoff') and capability_func._webagents_is_handoff:
|
287
294
|
handoff_config = Handoff(
|
288
295
|
target=getattr(capability_func, '_handoff_name', capability_func.__name__),
|
289
|
-
|
290
|
-
description=getattr(capability_func, '_handoff_description', ''),
|
296
|
+
description=getattr(capability_func, '_handoff_prompt', ''),
|
291
297
|
scope=getattr(capability_func, '_handoff_scope', self.scopes)
|
292
298
|
)
|
293
|
-
handoff_config.metadata = {
|
299
|
+
handoff_config.metadata = {
|
300
|
+
'function': capability_func,
|
301
|
+
'priority': getattr(capability_func, '_handoff_priority', 50),
|
302
|
+
'is_generator': getattr(capability_func, '_handoff_is_generator', False)
|
303
|
+
}
|
294
304
|
self.register_handoff(handoff_config, source="agent")
|
295
305
|
elif hasattr(capability_func, '_webagents_is_http') and capability_func._webagents_is_http:
|
296
306
|
self.register_http_handler(capability_func)
|
@@ -381,16 +391,45 @@ class BaseAgent:
|
|
381
391
|
elif hasattr(attr, '_webagents_is_handoff') and attr._webagents_is_handoff:
|
382
392
|
handoff_config = Handoff(
|
383
393
|
target=getattr(attr, '_handoff_name', attr_name),
|
384
|
-
|
385
|
-
|
386
|
-
|
394
|
+
description=getattr(attr, '_handoff_prompt', ''), # prompt becomes description
|
395
|
+
scope=getattr(attr, '_handoff_scope', None),
|
396
|
+
metadata={
|
397
|
+
'function': attr,
|
398
|
+
'priority': getattr(attr, '_handoff_priority', 50),
|
399
|
+
'is_generator': getattr(attr, '_handoff_is_generator', False)
|
400
|
+
}
|
387
401
|
)
|
388
|
-
handoff_config.metadata = {'function': attr}
|
389
402
|
self.register_handoff(handoff_config, source=skill_name)
|
403
|
+
|
404
|
+
# Auto-create invocation tool if requested
|
405
|
+
if hasattr(attr, '_handoff_auto_tool') and attr._handoff_auto_tool:
|
406
|
+
target_name = handoff_config.target
|
407
|
+
tool_desc = getattr(attr, '_handoff_auto_tool_description', f"Switch to {target_name} handoff")
|
408
|
+
|
409
|
+
# Create tool function that returns handoff request marker
|
410
|
+
async def invoke_handoff_tool(skill_instance=skill):
|
411
|
+
return skill_instance.request_handoff(target_name)
|
412
|
+
|
413
|
+
# Register as tool
|
414
|
+
invoke_handoff_tool.__name__ = f"use_{target_name}"
|
415
|
+
invoke_handoff_tool._webagents_is_tool = True
|
416
|
+
invoke_handoff_tool._tool_description = tool_desc
|
417
|
+
invoke_handoff_tool._tool_scope = handoff_config.scope
|
418
|
+
|
419
|
+
self.register_tool(
|
420
|
+
invoke_handoff_tool,
|
421
|
+
source=f"{skill_name}_handoff_tool"
|
422
|
+
)
|
423
|
+
self.logger.debug(f"🔧 Auto-registered handoff invocation tool: use_{target_name}")
|
390
424
|
|
391
425
|
# Check for @http decorator
|
392
426
|
elif hasattr(attr, '_webagents_is_http') and attr._webagents_is_http:
|
393
427
|
self.register_http_handler(attr, source=skill_name)
|
428
|
+
|
429
|
+
# Check for @widget decorator
|
430
|
+
elif hasattr(attr, '_webagents_is_widget') and attr._webagents_is_widget:
|
431
|
+
scope = getattr(attr, '_widget_scope', None)
|
432
|
+
self.register_widget(attr, source=skill_name, scope=scope)
|
394
433
|
|
395
434
|
# Central registration methods (thread-safe)
|
396
435
|
def register_tool(self, tool_func: Callable, source: str = "manual", scope: Union[str, List[str]] = None):
|
@@ -407,6 +446,41 @@ class BaseAgent:
|
|
407
446
|
self._registered_tools.append(tool_config)
|
408
447
|
self.logger.debug(f"🛠️ Tool registered name='{tool_config['name']}' source='{source}' scope={scope}")
|
409
448
|
|
449
|
+
def register_widget(self, widget_func: Callable, source: str = "manual", scope: Union[str, List[str]] = None):
|
450
|
+
"""Register a widget function
|
451
|
+
|
452
|
+
Widgets are registered both as widgets (for browser filtering) and as tools (for execution).
|
453
|
+
"""
|
454
|
+
widget_name = getattr(widget_func, '_widget_name', widget_func.__name__)
|
455
|
+
widget_definition = getattr(widget_func, '_webagents_widget_definition', {})
|
456
|
+
|
457
|
+
with self._registration_lock:
|
458
|
+
# Register as widget (for browser filtering)
|
459
|
+
widget_config = {
|
460
|
+
'function': widget_func,
|
461
|
+
'source': source,
|
462
|
+
'scope': scope,
|
463
|
+
'name': widget_name,
|
464
|
+
'description': getattr(widget_func, '_widget_description', widget_func.__doc__ or ''),
|
465
|
+
'definition': widget_definition,
|
466
|
+
'template': getattr(widget_func, '_widget_template', None)
|
467
|
+
}
|
468
|
+
self._registered_widgets.append(widget_config)
|
469
|
+
|
470
|
+
# Also register as tool (for execution)
|
471
|
+
# This allows _get_tool_function_by_name to find it and mark it as internal
|
472
|
+
tool_config = {
|
473
|
+
'function': widget_func,
|
474
|
+
'name': widget_name,
|
475
|
+
'description': getattr(widget_func, '_widget_description', widget_func.__doc__ or ''),
|
476
|
+
'definition': widget_definition,
|
477
|
+
'source': source,
|
478
|
+
'scope': scope
|
479
|
+
}
|
480
|
+
self._registered_tools.append(tool_config)
|
481
|
+
|
482
|
+
self.logger.debug(f"🎨 Widget registered name='{widget_name}' source='{source}' scope={scope} (also registered as tool for execution)")
|
483
|
+
|
410
484
|
def register_hook(self, event: str, handler: Callable, priority: int = 50, source: str = "manual", scope: Union[str, List[str]] = None):
|
411
485
|
"""Register a hook handler for an event"""
|
412
486
|
with self._registration_lock:
|
@@ -426,13 +500,102 @@ class BaseAgent:
|
|
426
500
|
self.logger.debug(f"🪝 Hook registered event='{event}' priority={priority} source='{source}' scope={scope}")
|
427
501
|
|
428
502
|
def register_handoff(self, handoff_config: Handoff, source: str = "manual"):
|
429
|
-
"""Register a handoff configuration
|
503
|
+
"""Register a handoff configuration with priority-based default selection
|
504
|
+
|
505
|
+
Args:
|
506
|
+
handoff_config: Handoff configuration
|
507
|
+
source: Source of registration (skill name, "agent", "manual")
|
508
|
+
"""
|
430
509
|
with self._registration_lock:
|
510
|
+
function = handoff_config.metadata.get('function')
|
511
|
+
|
512
|
+
# Auto-detect if generator
|
513
|
+
is_generator = inspect.isasyncgenfunction(function) if function else False
|
514
|
+
priority = handoff_config.metadata.get('priority', 50)
|
515
|
+
|
516
|
+
# Store metadata
|
517
|
+
handoff_config.metadata.update({
|
518
|
+
'is_generator': is_generator,
|
519
|
+
'priority': priority
|
520
|
+
})
|
521
|
+
|
431
522
|
self._registered_handoffs.append({
|
432
523
|
'config': handoff_config,
|
433
524
|
'source': source
|
434
525
|
})
|
435
|
-
|
526
|
+
|
527
|
+
# Sort handoffs by priority (lower = higher priority)
|
528
|
+
self._registered_handoffs.sort(key=lambda x: (
|
529
|
+
x['config'].metadata.get('priority', 50), # Primary: priority
|
530
|
+
x['source'], # Secondary: source name
|
531
|
+
x['config'].target # Tertiary: target name
|
532
|
+
))
|
533
|
+
|
534
|
+
# Set as default if this is the highest priority handoff
|
535
|
+
if not self.active_handoff or priority < self.active_handoff.metadata.get('priority', 50):
|
536
|
+
self.active_handoff = handoff_config
|
537
|
+
self.logger.info(f"📨 Set default handoff: {handoff_config.target} (priority={priority})")
|
538
|
+
|
539
|
+
self.logger.debug(
|
540
|
+
f"📨 Handoff registered target='{handoff_config.target}' "
|
541
|
+
f"priority={priority} generator={is_generator} source='{source}'"
|
542
|
+
)
|
543
|
+
|
544
|
+
# Register handoff's prompt if present
|
545
|
+
if handoff_config.description:
|
546
|
+
self._register_handoff_prompt(handoff_config, source)
|
547
|
+
|
548
|
+
def get_handoff_by_target(self, target_name: str) -> Optional[Handoff]:
|
549
|
+
"""Get handoff configuration by target name
|
550
|
+
|
551
|
+
Args:
|
552
|
+
target_name: Target name of the handoff (e.g., 'openai_workflow', 'specialist_agent')
|
553
|
+
|
554
|
+
Returns:
|
555
|
+
Handoff configuration if found, None otherwise
|
556
|
+
"""
|
557
|
+
with self._registration_lock:
|
558
|
+
for entry in self._registered_handoffs:
|
559
|
+
if entry['config'].target == target_name:
|
560
|
+
return entry['config']
|
561
|
+
return None
|
562
|
+
|
563
|
+
def list_available_handoffs(self) -> List[Dict[str, Any]]:
|
564
|
+
"""List all registered handoffs with their metadata
|
565
|
+
|
566
|
+
Returns:
|
567
|
+
List of dicts with: target, description, priority, source, scope
|
568
|
+
"""
|
569
|
+
with self._registration_lock:
|
570
|
+
return [
|
571
|
+
{
|
572
|
+
'target': entry['config'].target,
|
573
|
+
'description': entry['config'].description,
|
574
|
+
'priority': entry['config'].metadata.get('priority', 50),
|
575
|
+
'source': entry['source'],
|
576
|
+
'scope': entry['config'].scope
|
577
|
+
}
|
578
|
+
for entry in self._registered_handoffs
|
579
|
+
]
|
580
|
+
|
581
|
+
def _register_handoff_prompt(self, handoff_config: Handoff, source: str):
|
582
|
+
"""Register handoff's prompt as dynamic prompt provider"""
|
583
|
+
prompt_text = handoff_config.description
|
584
|
+
priority = handoff_config.metadata.get('priority', 50)
|
585
|
+
|
586
|
+
# Create prompt provider function
|
587
|
+
def handoff_prompt_provider(context=None):
|
588
|
+
return prompt_text
|
589
|
+
|
590
|
+
# Register as prompt with same priority as handoff
|
591
|
+
self.register_prompt(
|
592
|
+
handoff_prompt_provider,
|
593
|
+
priority=priority,
|
594
|
+
source=f"{source}_handoff_prompt",
|
595
|
+
scope=handoff_config.scope
|
596
|
+
)
|
597
|
+
|
598
|
+
self.logger.debug(f"📨 Registered handoff prompt for '{handoff_config.target}'")
|
436
599
|
|
437
600
|
def register_prompt(self, prompt_func: Callable, priority: int = 50, source: str = "manual", scope: Union[str, List[str]] = None):
|
438
601
|
"""Register a prompt provider function"""
|
@@ -554,6 +717,11 @@ class BaseAgent:
|
|
554
717
|
with self._registration_lock:
|
555
718
|
return self._registered_tools.copy()
|
556
719
|
|
720
|
+
def get_all_widgets(self) -> List[Dict[str, Any]]:
|
721
|
+
"""Get all registered widgets regardless of scope"""
|
722
|
+
with self._registration_lock:
|
723
|
+
return self._registered_widgets.copy()
|
724
|
+
|
557
725
|
def get_all_http_handlers(self) -> List[Dict[str, Any]]:
|
558
726
|
"""Get all registered HTTP handlers"""
|
559
727
|
with self._registration_lock:
|
@@ -641,7 +809,7 @@ class BaseAgent:
|
|
641
809
|
# Log prompt execution error but continue
|
642
810
|
self.logger.warning(f"⚠️ Prompt execution error handler='{getattr(handler, '__name__', str(handler))}' error='{e}'")
|
643
811
|
|
644
|
-
prompt_parts.append(f"
|
812
|
+
prompt_parts.append(f"@{self.name}, time: {datetime.now().isoformat()}")
|
645
813
|
|
646
814
|
# Combine all prompt parts with newlines
|
647
815
|
return "\n\n".join(prompt_parts) if prompt_parts else ""
|
@@ -688,18 +856,65 @@ class BaseAgent:
|
|
688
856
|
original_content = message.get("content", "")
|
689
857
|
base_instructions = self.instructions or ""
|
690
858
|
parts = []
|
859
|
+
|
860
|
+
# Add base instructions first (agent-specific + CORE_SYSTEM_PROMPT)
|
691
861
|
if base_instructions:
|
692
862
|
parts.append(base_instructions)
|
863
|
+
|
864
|
+
# Only add original_content if it's not already in base_instructions
|
865
|
+
# (prevents duplicate CORE_SYSTEM_PROMPT)
|
693
866
|
if original_content:
|
694
|
-
|
867
|
+
# Check if original_content is substantially different from base_instructions
|
868
|
+
# Skip if it's just the CORE_SYSTEM_PROMPT that's already in base_instructions
|
869
|
+
original_trimmed = original_content.strip()
|
870
|
+
base_trimmed = base_instructions.strip()
|
871
|
+
|
872
|
+
# If original is not a substring of base, it's new content - add it
|
873
|
+
if original_trimmed and original_trimmed not in base_trimmed:
|
874
|
+
parts.append(original_content)
|
875
|
+
else:
|
876
|
+
self.logger.debug("🔧 Skipped duplicate original_content (already in base_instructions)")
|
877
|
+
|
878
|
+
# Check if dynamic_prompts contains content already in base_instructions
|
879
|
+
# This prevents CORE_SYSTEM_PROMPT duplication when it's included in both
|
695
880
|
if dynamic_prompts:
|
696
|
-
|
881
|
+
dynamic_trimmed = dynamic_prompts.strip()
|
882
|
+
# Check if dynamic content is substantially overlapping with base instructions
|
883
|
+
# If >80% of dynamic content is already in base, skip it (likely duplicate CORE_SYSTEM_PROMPT)
|
884
|
+
if base_trimmed and len(dynamic_trimmed) > 100:
|
885
|
+
# Count how many lines from dynamic are already in base
|
886
|
+
dynamic_lines = set(line.strip() for line in dynamic_trimmed.split('\n') if line.strip())
|
887
|
+
matching_lines = sum(1 for line in dynamic_lines if line in base_trimmed)
|
888
|
+
overlap_ratio = matching_lines / len(dynamic_lines) if dynamic_lines else 0
|
889
|
+
|
890
|
+
if overlap_ratio > 0.8:
|
891
|
+
self.logger.debug(f"🔧 Skipped duplicate dynamic_prompts ({overlap_ratio:.1%} overlap with base_instructions)")
|
892
|
+
else:
|
893
|
+
parts.append(dynamic_prompts)
|
894
|
+
else:
|
895
|
+
parts.append(dynamic_prompts)
|
896
|
+
|
697
897
|
enhanced_content = "\n\n".join(parts).strip()
|
698
898
|
enhanced_messages.append({
|
699
899
|
**message,
|
700
900
|
"content": enhanced_content
|
701
901
|
})
|
702
902
|
self.logger.debug("🔧 Enhanced existing system message")
|
903
|
+
|
904
|
+
# Log system prompt breakdown for optimization
|
905
|
+
breakdown = []
|
906
|
+
if base_instructions:
|
907
|
+
breakdown.append(f" - Base instructions: {len(base_instructions)} chars")
|
908
|
+
if original_content and original_content.strip() not in base_instructions.strip():
|
909
|
+
breakdown.append(f" - Original content: {len(original_content)} chars")
|
910
|
+
if dynamic_prompts:
|
911
|
+
breakdown.append(f" - Dynamic prompts: {len(dynamic_prompts)} chars")
|
912
|
+
|
913
|
+
# Only log on first request (2 messages: system + first user message)
|
914
|
+
# Skip if conversation has more history
|
915
|
+
incoming_count = len([m for m in messages if m.get("role") in ("user", "assistant")])
|
916
|
+
if incoming_count <= 1: # First user message only
|
917
|
+
self.logger.info(f"📋 System prompt: {len(enhanced_content)} chars\n" + "\n".join(breakdown))
|
703
918
|
else:
|
704
919
|
enhanced_messages.append(message)
|
705
920
|
|
@@ -714,11 +929,113 @@ class BaseAgent:
|
|
714
929
|
"content": system_content
|
715
930
|
})
|
716
931
|
self.logger.debug("🔧 Created new system message with base instructions + dynamic prompts")
|
932
|
+
|
933
|
+
# Only log on first request (1 message: first user message)
|
934
|
+
incoming_count = len([m for m in messages if m.get("role") in ("user", "assistant")])
|
935
|
+
if incoming_count <= 1:
|
936
|
+
self.logger.info(f"📋 System prompt: {len(system_content)} chars\n - Base instructions: {len(base_instructions)} chars\n - Dynamic prompts: {len(dynamic_prompts)} chars")
|
717
937
|
|
718
938
|
self.logger.debug(f"📦 Enhanced messages count={len(enhanced_messages)}")
|
719
939
|
|
720
940
|
return enhanced_messages
|
721
941
|
|
942
|
+
# Handoff execution methods
|
943
|
+
|
944
|
+
def _execute_handoff(
|
945
|
+
self,
|
946
|
+
handoff_config: Handoff,
|
947
|
+
messages: List[Dict[str, Any]],
|
948
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
949
|
+
stream: bool = False,
|
950
|
+
**kwargs
|
951
|
+
) -> Union['Awaitable[Dict[str, Any]]', 'AsyncGenerator[Dict[str, Any], None]']:
|
952
|
+
"""Execute handoff - returns appropriate type based on mode
|
953
|
+
|
954
|
+
Args:
|
955
|
+
handoff_config: Handoff configuration to execute
|
956
|
+
messages: Conversation messages
|
957
|
+
tools: Available tools
|
958
|
+
stream: Whether to stream response
|
959
|
+
**kwargs: Additional arguments to pass to handoff function
|
960
|
+
|
961
|
+
Returns:
|
962
|
+
- If stream=False: Awaitable[Dict] (coroutine to await)
|
963
|
+
- If stream=True: AsyncGenerator (async iterator - NO await!)
|
964
|
+
|
965
|
+
Note: Caller must handle appropriately:
|
966
|
+
- Non-streaming: response = await self._execute_handoff(..., stream=False)
|
967
|
+
- Streaming: async for chunk in self._execute_handoff(..., stream=True)
|
968
|
+
"""
|
969
|
+
function = handoff_config.metadata.get('function')
|
970
|
+
is_generator = handoff_config.metadata.get('is_generator', False)
|
971
|
+
|
972
|
+
if not function:
|
973
|
+
raise ValueError(f"No function for handoff: {handoff_config.target}")
|
974
|
+
|
975
|
+
call_kwargs = {'messages': messages, 'tools': tools, **kwargs}
|
976
|
+
|
977
|
+
if stream:
|
978
|
+
# STREAMING MODE - return AsyncGenerator
|
979
|
+
if is_generator:
|
980
|
+
# Generator function - return directly (NO await!)
|
981
|
+
return function(**call_kwargs)
|
982
|
+
else:
|
983
|
+
# Regular async function - adapt to streaming
|
984
|
+
return self._adapt_response_to_streaming(function, call_kwargs)
|
985
|
+
else:
|
986
|
+
# NON-STREAMING MODE - return Awaitable[Dict]
|
987
|
+
if is_generator:
|
988
|
+
# Generator function - consume all chunks to response
|
989
|
+
return self._consume_generator_to_response(function(**call_kwargs))
|
990
|
+
else:
|
991
|
+
# Regular async function - return coroutine directly (NO await!)
|
992
|
+
return function(**call_kwargs)
|
993
|
+
|
994
|
+
async def _consume_generator_to_response(
|
995
|
+
self,
|
996
|
+
generator: 'AsyncGenerator[Dict[str, Any], None]'
|
997
|
+
) -> Dict[str, Any]:
|
998
|
+
"""Consume streaming generator and return final response
|
999
|
+
|
1000
|
+
Used when generator handoff is called in non-streaming mode.
|
1001
|
+
Reconstructs full response from chunks.
|
1002
|
+
|
1003
|
+
Args:
|
1004
|
+
generator: Async generator yielding streaming chunks
|
1005
|
+
|
1006
|
+
Returns:
|
1007
|
+
Full OpenAI-compatible response dict
|
1008
|
+
"""
|
1009
|
+
chunks = []
|
1010
|
+
async for chunk in generator:
|
1011
|
+
chunks.append(chunk)
|
1012
|
+
|
1013
|
+
# Reconstruct full response from chunks
|
1014
|
+
return self._reconstruct_response_from_chunks(chunks)
|
1015
|
+
|
1016
|
+
async def _adapt_response_to_streaming(
|
1017
|
+
self,
|
1018
|
+
function: Callable,
|
1019
|
+
call_kwargs: Dict[str, Any]
|
1020
|
+
) -> 'AsyncGenerator[Dict[str, Any], None]':
|
1021
|
+
"""Adapt non-streaming function to streaming by wrapping response as chunk
|
1022
|
+
|
1023
|
+
Used when regular handoff is called in streaming mode.
|
1024
|
+
|
1025
|
+
Args:
|
1026
|
+
function: The handoff function to call
|
1027
|
+
call_kwargs: Arguments to pass to function
|
1028
|
+
|
1029
|
+
Yields:
|
1030
|
+
Single streaming chunk containing full response
|
1031
|
+
"""
|
1032
|
+
# Call function
|
1033
|
+
response = await function(**call_kwargs)
|
1034
|
+
|
1035
|
+
# Convert to streaming chunk and yield once
|
1036
|
+
chunk = self._convert_response_to_streaming_chunk(response)
|
1037
|
+
yield chunk
|
1038
|
+
|
722
1039
|
# Tool execution methods
|
723
1040
|
def _get_tool_function_by_name(self, function_name: str) -> Optional[Callable]:
|
724
1041
|
"""Get a registered tool function by name, respecting external tool overrides"""
|
@@ -745,6 +1062,8 @@ class BaseAgent:
|
|
745
1062
|
else:
|
746
1063
|
self.logger.debug(f"🔧 Using existing tool_call_id '{tool_call_id}' for {function_name}")
|
747
1064
|
|
1065
|
+
# Finalization runs at end-of-loop or on exception
|
1066
|
+
|
748
1067
|
try:
|
749
1068
|
# Parse function arguments
|
750
1069
|
function_args = json.loads(function_args_str)
|
@@ -851,6 +1170,10 @@ class BaseAgent:
|
|
851
1170
|
)
|
852
1171
|
set_context(context)
|
853
1172
|
|
1173
|
+
# Get the default handoff (first registered handoff) to reset to at end of turn
|
1174
|
+
# Define this BEFORE the try block so it's available in the except block
|
1175
|
+
default_handoff = self._registered_handoffs[0]['config'] if self._registered_handoffs else self.active_handoff
|
1176
|
+
|
854
1177
|
try:
|
855
1178
|
# Ensure all skills are initialized with agent reference
|
856
1179
|
await self._ensure_skills_initialized()
|
@@ -861,12 +1184,15 @@ class BaseAgent:
|
|
861
1184
|
# Merge external tools with agent tools
|
862
1185
|
all_tools = self._merge_tools(tools or [])
|
863
1186
|
|
864
|
-
#
|
865
|
-
|
866
|
-
|
867
|
-
|
1187
|
+
# Ensure we have an active handoff (completion handler)
|
1188
|
+
if not self.active_handoff:
|
1189
|
+
raise ValueError(
|
1190
|
+
f"No handoff registered for agent '{self.name}'. "
|
1191
|
+
"Agent needs at least one skill with @handoff decorator or "
|
1192
|
+
"manual handoff registration via register_handoff()."
|
1193
|
+
)
|
868
1194
|
|
869
|
-
# Enhance messages with dynamic prompts before first
|
1195
|
+
# Enhance messages with dynamic prompts before first handoff call
|
870
1196
|
enhanced_messages = await self._enhance_messages_with_prompts(messages, context)
|
871
1197
|
|
872
1198
|
# Maintain conversation history for agentic loop
|
@@ -880,14 +1206,36 @@ class BaseAgent:
|
|
880
1206
|
while tool_iterations < max_tool_iterations:
|
881
1207
|
tool_iterations += 1
|
882
1208
|
|
883
|
-
# Debug logging for
|
884
|
-
|
1209
|
+
# Debug logging for handoff call
|
1210
|
+
handoff_name = self.active_handoff.target
|
1211
|
+
self.logger.debug(f"🚀 Calling handoff '{handoff_name}' for agent '{self.name}' (iteration {tool_iterations}) with {len(all_tools)} tools")
|
885
1212
|
|
886
|
-
# Enhanced debugging: Log conversation history before
|
1213
|
+
# Enhanced debugging: Log conversation history before handoff call
|
887
1214
|
self.logger.debug(f"📝 ITERATION {tool_iterations} - Conversation history ({len(conversation_messages)} messages):")
|
888
1215
|
for i, msg in enumerate(conversation_messages):
|
889
1216
|
role = msg.get('role', 'unknown')
|
890
|
-
|
1217
|
+
content = msg.get('content', '')
|
1218
|
+
|
1219
|
+
# Truncate data URLs in content to avoid logging huge base64 strings
|
1220
|
+
if isinstance(content, list):
|
1221
|
+
# Multimodal content - check for image_url parts
|
1222
|
+
content_summary = []
|
1223
|
+
for part in content:
|
1224
|
+
if isinstance(part, dict) and part.get('type') == 'image_url':
|
1225
|
+
url = part.get('image_url', {}).get('url', '')
|
1226
|
+
if url.startswith('data:'):
|
1227
|
+
content_summary.append('[data:image]')
|
1228
|
+
else:
|
1229
|
+
content_summary.append(f'[image:{url[:50]}...]')
|
1230
|
+
elif isinstance(part, dict) and part.get('type') == 'text':
|
1231
|
+
text = part.get('text', '')[:50]
|
1232
|
+
content_summary.append(f'"{text}..."' if len(part.get('text', '')) > 50 else f'"{text}"')
|
1233
|
+
else:
|
1234
|
+
content_summary.append(str(part)[:30])
|
1235
|
+
content_preview = ', '.join(content_summary)
|
1236
|
+
else:
|
1237
|
+
content_preview = str(content)[:100] + ('...' if len(str(content)) > 100 else '')
|
1238
|
+
|
891
1239
|
tool_calls = msg.get('tool_calls', [])
|
892
1240
|
tool_call_id = msg.get('tool_call_id', '')
|
893
1241
|
|
@@ -906,12 +1254,27 @@ class BaseAgent:
|
|
906
1254
|
else:
|
907
1255
|
self.logger.debug(f" [{i}] {role.upper()}: {content_preview}")
|
908
1256
|
|
909
|
-
#
|
910
|
-
|
1257
|
+
# Execute before_llm_call hooks to allow message preprocessing
|
1258
|
+
context.set('conversation_messages', conversation_messages)
|
1259
|
+
context.set('tools', all_tools)
|
1260
|
+
context = await self._execute_hooks("before_llm_call", context)
|
1261
|
+
conversation_messages = context.get('conversation_messages', conversation_messages)
|
1262
|
+
all_tools = context.get('tools', all_tools)
|
1263
|
+
|
1264
|
+
# Call active handoff with current conversation history
|
1265
|
+
response = await self._execute_handoff(
|
1266
|
+
self.active_handoff,
|
1267
|
+
conversation_messages,
|
1268
|
+
tools=all_tools,
|
1269
|
+
stream=False
|
1270
|
+
)
|
911
1271
|
|
912
1272
|
# Store LLM response in context for cost tracking
|
913
1273
|
context.set('llm_response', response)
|
914
1274
|
|
1275
|
+
# Execute after_llm_call hooks
|
1276
|
+
context = await self._execute_hooks("after_llm_call", context)
|
1277
|
+
|
915
1278
|
# Log LLM token usage
|
916
1279
|
self._log_llm_usage(response, streaming=False)
|
917
1280
|
|
@@ -927,7 +1290,7 @@ class BaseAgent:
|
|
927
1290
|
content = message.content if hasattr(message, 'content') else message.get('content', '')
|
928
1291
|
tool_calls = message.tool_calls if hasattr(message, 'tool_calls') else message.get('tool_calls', [])
|
929
1292
|
|
930
|
-
content_preview = str(content)[:
|
1293
|
+
content_preview = str(content)[:500] + ('...' if len(str(content)) > 500 else '') if content else '[None]'
|
931
1294
|
self.logger.debug(f" Content: {content_preview}")
|
932
1295
|
self.logger.debug(f" Finish reason: {finish_reason}")
|
933
1296
|
|
@@ -1083,6 +1446,40 @@ class BaseAgent:
|
|
1083
1446
|
# Execute tool
|
1084
1447
|
result = await self._execute_single_tool(tool_call)
|
1085
1448
|
|
1449
|
+
# Check if tool result is a handoff request
|
1450
|
+
if isinstance(result.get('content', ''), str) and result.get('content', '').startswith("__HANDOFF_REQUEST__:"):
|
1451
|
+
target_name = result.get('content', '').split(":", 1)[1]
|
1452
|
+
self.logger.info(f"🔀 Dynamic handoff requested to: {target_name}")
|
1453
|
+
|
1454
|
+
# Find the requested handoff
|
1455
|
+
requested_handoff = self.get_handoff_by_target(target_name)
|
1456
|
+
if not requested_handoff:
|
1457
|
+
# Invalid target - add error to conversation and continue
|
1458
|
+
error_msg = f"❌ Handoff target '{target_name}' not found"
|
1459
|
+
self.logger.warning(error_msg)
|
1460
|
+
tool_message = {
|
1461
|
+
"role": "tool",
|
1462
|
+
"tool_call_id": tool_call["id"],
|
1463
|
+
"content": error_msg
|
1464
|
+
}
|
1465
|
+
conversation_messages.append(tool_message)
|
1466
|
+
continue
|
1467
|
+
|
1468
|
+
# Switch to the requested handoff - don't execute inline
|
1469
|
+
self.active_handoff = requested_handoff
|
1470
|
+
self.logger.info(f"🔀 Switching active handoff to: {target_name}")
|
1471
|
+
|
1472
|
+
# Add tool result to conversation
|
1473
|
+
tool_message = {
|
1474
|
+
"role": "tool",
|
1475
|
+
"tool_call_id": tool_call["id"],
|
1476
|
+
"content": f"✓ Switching to {target_name}"
|
1477
|
+
}
|
1478
|
+
conversation_messages.append(tool_message)
|
1479
|
+
|
1480
|
+
# Break from tool execution - the agentic loop will continue with new handoff
|
1481
|
+
break
|
1482
|
+
|
1086
1483
|
# Enhanced debugging: Log tool result
|
1087
1484
|
result_content = result.get('content', '')
|
1088
1485
|
result_preview = result_content[:200] + ('...' if len(result_content) > 200 else '')
|
@@ -1124,11 +1521,26 @@ class BaseAgent:
|
|
1124
1521
|
# Execute finalize_connection hooks
|
1125
1522
|
context = await self._execute_hooks("finalize_connection", context)
|
1126
1523
|
|
1524
|
+
# Reset to default handoff for next turn
|
1525
|
+
if self.active_handoff != default_handoff and default_handoff is not None:
|
1526
|
+
from_target = self.active_handoff.target if self.active_handoff else 'None'
|
1527
|
+
to_target = default_handoff.target if default_handoff else 'None'
|
1528
|
+
self.logger.info(f"🔄 Resetting active handoff from '{from_target}' to default '{to_target}'")
|
1529
|
+
self.active_handoff = default_handoff
|
1530
|
+
|
1127
1531
|
return response
|
1128
1532
|
|
1129
1533
|
except Exception as e:
|
1130
1534
|
# Handle errors and cleanup
|
1131
1535
|
self.logger.exception(f"💥 Agent execution error agent='{self.name}' error='{e}'")
|
1536
|
+
|
1537
|
+
# Reset to default handoff even on error
|
1538
|
+
if self.active_handoff != default_handoff and default_handoff is not None:
|
1539
|
+
from_target = self.active_handoff.target if self.active_handoff else 'None'
|
1540
|
+
to_target = default_handoff.target if default_handoff else 'None'
|
1541
|
+
self.logger.info(f"🔄 Resetting active handoff from '{from_target}' to default '{to_target}' (error path)")
|
1542
|
+
self.active_handoff = default_handoff
|
1543
|
+
|
1132
1544
|
await self._execute_hooks("finalize_connection", context)
|
1133
1545
|
raise
|
1134
1546
|
|
@@ -1278,6 +1690,10 @@ class BaseAgent:
|
|
1278
1690
|
)
|
1279
1691
|
set_context(context)
|
1280
1692
|
|
1693
|
+
# Get the default handoff (first registered handoff) to reset to at end of turn
|
1694
|
+
# Define this BEFORE the try block so it's available in the except block
|
1695
|
+
default_handoff = self._registered_handoffs[0]['config'] if self._registered_handoffs else self.active_handoff
|
1696
|
+
|
1281
1697
|
try:
|
1282
1698
|
# Ensure all skills are initialized with agent reference
|
1283
1699
|
await self._ensure_skills_initialized()
|
@@ -1288,12 +1704,15 @@ class BaseAgent:
|
|
1288
1704
|
# Merge external tools
|
1289
1705
|
all_tools = self._merge_tools(tools or [])
|
1290
1706
|
|
1291
|
-
#
|
1292
|
-
|
1293
|
-
|
1294
|
-
|
1707
|
+
# Ensure we have an active handoff (completion handler)
|
1708
|
+
if not self.active_handoff:
|
1709
|
+
raise ValueError(
|
1710
|
+
f"No handoff registered for agent '{self.name}'. "
|
1711
|
+
"Agent needs at least one skill with @handoff decorator or "
|
1712
|
+
"manual handoff registration via register_handoff()."
|
1713
|
+
)
|
1295
1714
|
|
1296
|
-
# Enhance messages with dynamic prompts before first
|
1715
|
+
# Enhance messages with dynamic prompts before first handoff call
|
1297
1716
|
enhanced_messages = await self._enhance_messages_with_prompts(messages, context)
|
1298
1717
|
|
1299
1718
|
# Maintain conversation history for agentic loop
|
@@ -1302,18 +1721,47 @@ class BaseAgent:
|
|
1302
1721
|
# Agentic loop for streaming
|
1303
1722
|
max_tool_iterations = 10
|
1304
1723
|
tool_iterations = 0
|
1724
|
+
pending_handoff_tag = None # Store handoff tag to prepend to next iteration's first chunk
|
1725
|
+
in_thinking_block = False # Track if we're currently in a <think> block
|
1726
|
+
pending_widget_html = None # Store widget HTML from tool results to inject into next LLM response
|
1727
|
+
first_chunk_of_iteration = False # Track if this is the first chunk after tool calls (need space)
|
1305
1728
|
|
1306
1729
|
while tool_iterations < max_tool_iterations:
|
1307
1730
|
tool_iterations += 1
|
1731
|
+
# Mark that we need a space at the start of this iteration if it's not the first one
|
1732
|
+
if tool_iterations > 1:
|
1733
|
+
first_chunk_of_iteration = True
|
1308
1734
|
|
1309
1735
|
# Debug logging
|
1310
|
-
|
1736
|
+
handoff_name = self.active_handoff.target
|
1737
|
+
self.logger.debug(f"🚀 Streaming handoff '{handoff_name}' for agent '{self.name}' (iteration {tool_iterations}) with {len(all_tools)} tools")
|
1311
1738
|
|
1312
|
-
# Enhanced debugging: Log conversation history before streaming
|
1739
|
+
# Enhanced debugging: Log conversation history before streaming handoff call
|
1313
1740
|
self.logger.debug(f"📝 STREAMING ITERATION {tool_iterations} - Conversation history ({len(conversation_messages)} messages):")
|
1314
1741
|
for i, msg in enumerate(conversation_messages):
|
1315
1742
|
role = msg.get('role', 'unknown')
|
1316
|
-
|
1743
|
+
content = msg.get('content', '')
|
1744
|
+
|
1745
|
+
# Truncate data URLs in content to avoid logging huge base64 strings
|
1746
|
+
if isinstance(content, list):
|
1747
|
+
# Multimodal content - check for image_url parts
|
1748
|
+
content_summary = []
|
1749
|
+
for part in content:
|
1750
|
+
if isinstance(part, dict) and part.get('type') == 'image_url':
|
1751
|
+
url = part.get('image_url', {}).get('url', '')
|
1752
|
+
if url.startswith('data:'):
|
1753
|
+
content_summary.append('[data:image]')
|
1754
|
+
else:
|
1755
|
+
content_summary.append(f'[image:{url[:50]}...]')
|
1756
|
+
elif isinstance(part, dict) and part.get('type') == 'text':
|
1757
|
+
text = part.get('text', '')[:50]
|
1758
|
+
content_summary.append(f'"{text}..."' if len(part.get('text', '')) > 50 else f'"{text}"')
|
1759
|
+
else:
|
1760
|
+
content_summary.append(str(part)[:30])
|
1761
|
+
content_preview = ', '.join(content_summary)
|
1762
|
+
else:
|
1763
|
+
content_preview = str(content)[:100] + ('...' if len(str(content)) > 100 else '')
|
1764
|
+
|
1317
1765
|
tool_calls = msg.get('tool_calls', [])
|
1318
1766
|
tool_call_id = msg.get('tool_call_id', '')
|
1319
1767
|
|
@@ -1332,13 +1780,30 @@ class BaseAgent:
|
|
1332
1780
|
else:
|
1333
1781
|
self.logger.debug(f" [{i}] {role.upper()}: {content_preview}")
|
1334
1782
|
|
1335
|
-
#
|
1783
|
+
# Execute before_llm_call hooks to allow message preprocessing
|
1784
|
+
context.set('conversation_messages', conversation_messages)
|
1785
|
+
context.set('tools', all_tools)
|
1786
|
+
context = await self._execute_hooks("before_llm_call", context)
|
1787
|
+
conversation_messages = context.get('conversation_messages', conversation_messages)
|
1788
|
+
all_tools = context.get('tools', all_tools)
|
1789
|
+
|
1790
|
+
# Stream from active handoff and collect chunks
|
1791
|
+
# NOTE: NO await! _execute_handoff returns generator directly in streaming mode
|
1336
1792
|
full_response_chunks = []
|
1337
1793
|
held_chunks = [] # Chunks with tool fragments
|
1338
1794
|
tool_calls_detected = False
|
1795
|
+
waiting_for_usage_after_tool_calls = False # Track if we're waiting for usage chunk
|
1796
|
+
chunks_since_tool_calls = 0 # Safety counter to avoid waiting forever
|
1339
1797
|
chunk_count = 0
|
1340
1798
|
|
1341
|
-
|
1799
|
+
stream_gen = self._execute_handoff(
|
1800
|
+
self.active_handoff,
|
1801
|
+
conversation_messages,
|
1802
|
+
tools=all_tools,
|
1803
|
+
stream=True
|
1804
|
+
)
|
1805
|
+
|
1806
|
+
async for chunk in stream_gen:
|
1342
1807
|
chunk_count += 1
|
1343
1808
|
|
1344
1809
|
# Execute on_chunk hooks
|
@@ -1357,34 +1822,169 @@ class BaseAgent:
|
|
1357
1822
|
|
1358
1823
|
# Check if we have tool call fragments
|
1359
1824
|
if delta_tool_calls is not None:
|
1825
|
+
# Before holding tool call chunks, yield any text content in this chunk
|
1826
|
+
# This prevents cutting off mid-word/mid-sentence before tool calls
|
1827
|
+
if delta.get('content'):
|
1828
|
+
text_chunk = dict(modified_chunk)
|
1829
|
+
text_chunk['choices'] = [dict(choice)]
|
1830
|
+
text_chunk['choices'][0]['delta'] = {'content': delta['content']}
|
1831
|
+
self.logger.debug(f"💬 STREAMING: Yielding text content before tool call: {delta['content'][:50]}...")
|
1832
|
+
yield text_chunk
|
1833
|
+
|
1360
1834
|
held_chunks.append(modified_chunk)
|
1361
1835
|
self.logger.debug(f"🔧 STREAMING: Tool call fragment in chunk #{chunk_count}")
|
1362
1836
|
continue # Don't yield tool fragments
|
1363
1837
|
|
1364
1838
|
# Check if tool calls are complete
|
1365
|
-
if
|
1839
|
+
# IMPORTANT: Only break if we actually have tool call data accumulated
|
1840
|
+
if finish_reason == "tool_calls" and held_chunks:
|
1366
1841
|
tool_calls_detected = True
|
1367
|
-
|
1368
|
-
|
1842
|
+
waiting_for_usage_after_tool_calls = True
|
1843
|
+
self.logger.debug(f"🔧 STREAMING: Tool calls complete at chunk #{chunk_count}, waiting for usage")
|
1844
|
+
# Don't break yet - continue to get usage chunk
|
1845
|
+
continue
|
1846
|
+
|
1847
|
+
# If we're waiting for usage after tool_calls, check if this chunk has it
|
1848
|
+
if waiting_for_usage_after_tool_calls:
|
1849
|
+
chunks_since_tool_calls += 1
|
1850
|
+
# Log usage if present
|
1851
|
+
if modified_chunk.get('usage'):
|
1852
|
+
self.logger.debug(f"💰 Got usage chunk after tool_calls at chunk #{chunk_count}, logging and breaking")
|
1853
|
+
# Let the usage logging below handle it
|
1854
|
+
if modified_chunk.get('usage') or chunks_since_tool_calls > 5:
|
1855
|
+
# Break either when we get usage or after waiting too long
|
1856
|
+
if chunks_since_tool_calls > 5 and not modified_chunk.get('usage'):
|
1857
|
+
self.logger.debug(f"⚠️ No usage after {chunks_since_tool_calls} chunks, breaking anyway")
|
1858
|
+
break # Exit streaming loop to process tools
|
1859
|
+
# Continue consuming chunks until we get usage or run out
|
1860
|
+
continue
|
1369
1861
|
|
1370
1862
|
# Yield content chunks
|
1371
1863
|
# - In first iteration: yield all non-tool chunks for real-time display
|
1372
1864
|
# - In subsequent iterations: yield the final response after tools
|
1373
1865
|
if not delta_tool_calls:
|
1866
|
+
# Track thinking block state (ensure content is a string, not None)
|
1867
|
+
content = delta.get('content') or ''
|
1868
|
+
if '<think>' in content:
|
1869
|
+
in_thinking_block = True
|
1870
|
+
if '</think>' in content:
|
1871
|
+
in_thinking_block = False
|
1872
|
+
|
1873
|
+
# Handle content modifications for first chunk of iteration
|
1874
|
+
if delta.get('content') and (pending_handoff_tag or pending_widget_html or first_chunk_of_iteration):
|
1875
|
+
modified_chunk = dict(modified_chunk)
|
1876
|
+
modified_chunk['choices'] = [dict(modified_chunk['choices'][0])]
|
1877
|
+
modified_chunk['choices'][0]['delta'] = dict(modified_chunk['choices'][0].get('delta', {}))
|
1878
|
+
|
1879
|
+
# Prepend widget HTML first (if present), then handoff tag
|
1880
|
+
prepend_content = ''
|
1881
|
+
if pending_widget_html:
|
1882
|
+
prepend_content += pending_widget_html
|
1883
|
+
self.logger.debug(f"🎨 Injecting widget HTML into first chunk (len={len(pending_widget_html)})")
|
1884
|
+
pending_widget_html = None # Clear after using
|
1885
|
+
if pending_handoff_tag:
|
1886
|
+
prepend_content += pending_handoff_tag
|
1887
|
+
self.logger.debug(f"🔀 Prepended handoff tag to first chunk: {pending_handoff_tag[:50]}")
|
1888
|
+
pending_handoff_tag = None # Clear after using
|
1889
|
+
|
1890
|
+
# Get the new content
|
1891
|
+
new_content = modified_chunk['choices'][0]['delta'].get('content', '')
|
1892
|
+
|
1893
|
+
# If this is the first chunk of a new iteration (after tool calls), ensure space
|
1894
|
+
if first_chunk_of_iteration and new_content:
|
1895
|
+
# Add a space at the start if the content doesn't already start with whitespace
|
1896
|
+
if not new_content[0].isspace():
|
1897
|
+
new_content = ' ' + new_content
|
1898
|
+
self.logger.debug(f"➕ Added space to start of first chunk in iteration {tool_iterations}")
|
1899
|
+
first_chunk_of_iteration = False # Clear flag after first chunk
|
1900
|
+
|
1901
|
+
# Ensure proper spacing between prepended content and new content
|
1902
|
+
# Add a space if prepended content doesn't end with whitespace and new content doesn't start with whitespace
|
1903
|
+
if prepend_content and new_content and not prepend_content[-1].isspace() and not new_content[0].isspace():
|
1904
|
+
modified_chunk['choices'][0]['delta']['content'] = prepend_content + ' ' + new_content
|
1905
|
+
else:
|
1906
|
+
modified_chunk['choices'][0]['delta']['content'] = prepend_content + new_content
|
1374
1907
|
yield modified_chunk
|
1375
1908
|
|
1376
|
-
# Log usage if
|
1377
|
-
if
|
1909
|
+
# Log usage if present in chunk (LiteLLM sends usage in separate chunk)
|
1910
|
+
if modified_chunk.get('usage'):
|
1911
|
+
self.logger.debug(f"💰 Found usage in streaming chunk #{chunk_count}, logging to context")
|
1378
1912
|
self._log_llm_usage(modified_chunk, streaming=True)
|
1379
1913
|
|
1380
1914
|
# If no tool calls detected, we're done
|
1381
1915
|
if not tool_calls_detected:
|
1916
|
+
# Check if we got any content at all
|
1917
|
+
total_content = ""
|
1918
|
+
for chunk in full_response_chunks:
|
1919
|
+
choice = chunk.get("choices", [{}])[0] if isinstance(chunk, dict) else {}
|
1920
|
+
delta = choice.get("delta", {}) if isinstance(choice, dict) else {}
|
1921
|
+
delta_content = delta.get("content", "")
|
1922
|
+
if delta_content:
|
1923
|
+
total_content += delta_content
|
1924
|
+
|
1925
|
+
if not total_content and chunk_count > 0:
|
1926
|
+
self.logger.warning(f"⚠️ LLM generated {chunk_count} chunks but NO content! This may be a safety filter or empty response issue.")
|
1927
|
+
self.logger.warning(f"⚠️ First chunk details:")
|
1928
|
+
if full_response_chunks:
|
1929
|
+
first_chunk = full_response_chunks[0]
|
1930
|
+
self.logger.warning(f" - Keys: {first_chunk.keys() if isinstance(first_chunk, dict) else 'not a dict'}")
|
1931
|
+
if isinstance(first_chunk, dict) and 'choices' in first_chunk:
|
1932
|
+
self.logger.warning(f" - Choices: {first_chunk['choices']}")
|
1933
|
+
|
1934
|
+
# CRITICAL FIX: Yield error message to client when LLM returns no content
|
1935
|
+
self.logger.warning(f"⚠️ Yielding error message to client due to empty LLM response")
|
1936
|
+
error_message = "I apologize, but I encountered an issue generating a response. This might be due to content filtering or a temporary problem. Please try rephrasing your request."
|
1937
|
+
|
1938
|
+
# Get metadata from first chunk if available
|
1939
|
+
first_chunk = full_response_chunks[0] if full_response_chunks else {}
|
1940
|
+
|
1941
|
+
# Yield error content chunk
|
1942
|
+
yield {
|
1943
|
+
"id": first_chunk.get("id", "error"),
|
1944
|
+
"created": first_chunk.get("created", 0),
|
1945
|
+
"model": first_chunk.get("model", "unknown"),
|
1946
|
+
"object": "chat.completion.chunk",
|
1947
|
+
"choices": [{
|
1948
|
+
"index": 0,
|
1949
|
+
"delta": {"role": "assistant", "content": error_message},
|
1950
|
+
"finish_reason": None
|
1951
|
+
}]
|
1952
|
+
}
|
1953
|
+
|
1954
|
+
# Yield finish chunk
|
1955
|
+
yield {
|
1956
|
+
"id": first_chunk.get("id", "error"),
|
1957
|
+
"created": first_chunk.get("created", 0),
|
1958
|
+
"model": first_chunk.get("model", "unknown"),
|
1959
|
+
"object": "chat.completion.chunk",
|
1960
|
+
"choices": [{
|
1961
|
+
"index": 0,
|
1962
|
+
"delta": {},
|
1963
|
+
"finish_reason": "stop"
|
1964
|
+
}]
|
1965
|
+
}
|
1966
|
+
else:
|
1967
|
+
self.logger.debug(f"✅ Streaming finished with content (len={len(total_content)}) after {tool_iterations} iteration(s)")
|
1968
|
+
|
1969
|
+
# CRITICAL FIX: Reconstruct and store LLM response for payment tracking
|
1970
|
+
# Even when there are no tool calls, we need to track LLM costs
|
1971
|
+
if full_response_chunks:
|
1972
|
+
final_response = self._reconstruct_response_from_chunks(full_response_chunks)
|
1973
|
+
context.set('llm_response', final_response)
|
1974
|
+
# NOTE: Usage is already logged at line 1682 when the usage chunk arrives
|
1975
|
+
|
1382
1976
|
self.logger.debug(f"✅ Streaming finished (no tool calls) after {tool_iterations} iteration(s)")
|
1383
1977
|
break
|
1384
1978
|
|
1385
1979
|
# Reconstruct response from chunks to process tool calls
|
1386
1980
|
full_response = self._reconstruct_response_from_chunks(full_response_chunks)
|
1387
1981
|
|
1982
|
+
# Store LLM response in context and execute after_llm_call hooks
|
1983
|
+
context.set('llm_response', full_response)
|
1984
|
+
# NOTE: Usage is already logged at line 1683 when the usage chunk arrives
|
1985
|
+
context = await self._execute_hooks("after_llm_call", context)
|
1986
|
+
full_response = context.get('llm_response', full_response)
|
1987
|
+
|
1388
1988
|
if not self._has_tool_calls(full_response):
|
1389
1989
|
# No tool calls after all - shouldn't happen but handle gracefully
|
1390
1990
|
self.logger.debug("🔧 STREAMING: No tool calls found in reconstructed response")
|
@@ -1457,10 +2057,8 @@ class BaseAgent:
|
|
1457
2057
|
final_chunk = self._convert_response_to_chunk(final_response)
|
1458
2058
|
yield final_chunk
|
1459
2059
|
|
1460
|
-
#
|
1461
|
-
|
1462
|
-
context = await self._execute_hooks("finalize_connection", context)
|
1463
|
-
return
|
2060
|
+
# Exit the loop; finalization runs after the loop
|
2061
|
+
break
|
1464
2062
|
|
1465
2063
|
# All tools are internal - execute and continue loop
|
1466
2064
|
self.logger.debug(f"⚙️ Executing {len(internal_tools)} internal tool(s)")
|
@@ -1510,6 +2108,59 @@ class BaseAgent:
|
|
1510
2108
|
# Execute tool
|
1511
2109
|
result = await self._execute_single_tool(tool_call)
|
1512
2110
|
|
2111
|
+
# Check if tool result is a handoff request
|
2112
|
+
if isinstance(result.get('content', ''), str) and result.get('content', '').startswith("__HANDOFF_REQUEST__:"):
|
2113
|
+
target_name = result.get('content', '').split(":", 1)[1]
|
2114
|
+
self.logger.info(f"🔀 Dynamic handoff requested to: {target_name}")
|
2115
|
+
|
2116
|
+
# Find the requested handoff
|
2117
|
+
requested_handoff = self.get_handoff_by_target(target_name)
|
2118
|
+
if not requested_handoff:
|
2119
|
+
# Invalid target - yield error and continue
|
2120
|
+
error_msg = f"❌ Handoff target '{target_name}' not found"
|
2121
|
+
self.logger.warning(error_msg)
|
2122
|
+
yield {
|
2123
|
+
"choices": [{
|
2124
|
+
"delta": {"content": error_msg},
|
2125
|
+
"finish_reason": None
|
2126
|
+
}]
|
2127
|
+
}
|
2128
|
+
# Add to conversation and continue loop
|
2129
|
+
tool_message = {
|
2130
|
+
"role": "tool",
|
2131
|
+
"tool_call_id": tool_call["id"],
|
2132
|
+
"content": error_msg
|
2133
|
+
}
|
2134
|
+
conversation_messages.append(tool_message)
|
2135
|
+
continue
|
2136
|
+
|
2137
|
+
# Switch to the requested handoff - don't execute inline
|
2138
|
+
self.active_handoff = requested_handoff
|
2139
|
+
self.logger.info(f"🔀 Switching active handoff to: {target_name}")
|
2140
|
+
|
2141
|
+
# Build handoff tag with optional thinking closure
|
2142
|
+
handoff_tag_parts = []
|
2143
|
+
if in_thinking_block:
|
2144
|
+
self.logger.debug(f"🔀 Will close open thinking block before handoff")
|
2145
|
+
handoff_tag_parts.append("</think>\n\n")
|
2146
|
+
in_thinking_block = False
|
2147
|
+
handoff_tag_parts.append(f"<handoff>Handoff to {target_name}</handoff>\n\n")
|
2148
|
+
|
2149
|
+
# Store handoff indicator to prepend to next iteration's first chunk
|
2150
|
+
pending_handoff_tag = "".join(handoff_tag_parts)
|
2151
|
+
self.logger.debug(f"🔀 Stored handoff indicator for next iteration: {pending_handoff_tag[:100]}")
|
2152
|
+
|
2153
|
+
# Add tool result to conversation
|
2154
|
+
tool_message = {
|
2155
|
+
"role": "tool",
|
2156
|
+
"tool_call_id": tool_call["id"],
|
2157
|
+
"content": f"✓ Switching to {target_name}"
|
2158
|
+
}
|
2159
|
+
conversation_messages.append(tool_message)
|
2160
|
+
|
2161
|
+
# Break from tool execution - the agentic loop will continue with new handoff
|
2162
|
+
break
|
2163
|
+
|
1513
2164
|
# Enhanced debugging: Log streaming tool result
|
1514
2165
|
result_content = result.get('content', '')
|
1515
2166
|
result_preview = result_content[:200] + ('...' if len(result_content) > 200 else '')
|
@@ -1522,6 +2173,12 @@ class BaseAgent:
|
|
1522
2173
|
else:
|
1523
2174
|
self.logger.debug(f"✅ STREAMING ITERATION {tool_iterations} - Tool call ID matches: {tc_id}")
|
1524
2175
|
|
2176
|
+
# Check if result contains widget HTML - store it to prepend to next LLM response
|
2177
|
+
if result_content and '<widget' in result_content:
|
2178
|
+
self.logger.debug(f"🎨 Widget detected in tool result (len={len(result_content)}), will inject into next LLM response")
|
2179
|
+
# Store widget HTML to prepend to first chunk of next iteration
|
2180
|
+
pending_widget_html = f"\n\n{result_content}\n\n"
|
2181
|
+
|
1525
2182
|
# Add result to conversation
|
1526
2183
|
conversation_messages.append(result)
|
1527
2184
|
|
@@ -1535,13 +2192,40 @@ class BaseAgent:
|
|
1535
2192
|
if tool_iterations >= max_tool_iterations:
|
1536
2193
|
self.logger.warning(f"⚠️ Reached max tool iterations ({max_tool_iterations})")
|
1537
2194
|
|
1538
|
-
#
|
1539
|
-
|
1540
|
-
|
2195
|
+
# Finalize after breaking out (normal end)
|
2196
|
+
self.logger.debug("🔚 Executing finalization hooks")
|
2197
|
+
try:
|
2198
|
+
context = await self._execute_hooks("on_message", context)
|
2199
|
+
context = await self._execute_hooks("finalize_connection", context)
|
2200
|
+
self.logger.debug("✅ Finalization hooks completed")
|
2201
|
+
except Exception as hook_error:
|
2202
|
+
self.logger.error(f"Error executing finalization hooks: {hook_error}")
|
2203
|
+
|
2204
|
+
# Reset to default handoff for next turn (always, even if hooks failed)
|
2205
|
+
if self.active_handoff != default_handoff and default_handoff is not None:
|
2206
|
+
from_target = self.active_handoff.target if self.active_handoff else 'None'
|
2207
|
+
to_target = default_handoff.target if default_handoff else 'None'
|
2208
|
+
self.logger.info(f"🔄 Resetting active handoff from '{from_target}' to default '{to_target}'")
|
2209
|
+
self.active_handoff = default_handoff
|
1541
2210
|
|
1542
2211
|
except Exception as e:
|
1543
2212
|
self.logger.exception(f"💥 Streaming execution error agent='{self.name}' error='{e}'")
|
1544
|
-
|
2213
|
+
# Finalize even on error
|
2214
|
+
self.logger.debug("🔚 Executing finalization hooks (error path)")
|
2215
|
+
try:
|
2216
|
+
context = await self._execute_hooks("on_message", context)
|
2217
|
+
context = await self._execute_hooks("finalize_connection", context)
|
2218
|
+
self.logger.debug("✅ Finalization hooks completed")
|
2219
|
+
except Exception as hook_error:
|
2220
|
+
self.logger.error(f"Error executing finalization hooks: {hook_error}")
|
2221
|
+
|
2222
|
+
# Reset to default handoff for next turn (always, even on error)
|
2223
|
+
if self.active_handoff != default_handoff and default_handoff is not None:
|
2224
|
+
from_target = self.active_handoff.target if self.active_handoff else 'None'
|
2225
|
+
to_target = default_handoff.target if default_handoff else 'None'
|
2226
|
+
self.logger.info(f"🔄 Resetting active handoff from '{from_target}' to default '{to_target}' (error path)")
|
2227
|
+
self.active_handoff = default_handoff
|
2228
|
+
|
1545
2229
|
raise
|
1546
2230
|
|
1547
2231
|
def _reconstruct_response_from_chunks(self, chunks: List[Dict[str, Any]]) -> Dict[str, Any]:
|
@@ -1561,15 +2245,34 @@ class BaseAgent:
|
|
1561
2245
|
# Reconstruct from streaming delta chunks
|
1562
2246
|
logger.debug(f"🔧 RECONSTRUCTION: Reconstructing from {len(chunks)} delta chunks")
|
1563
2247
|
|
1564
|
-
# Accumulate streaming tool
|
2248
|
+
# Accumulate streaming data (both content and tool calls)
|
1565
2249
|
accumulated_tool_calls = {}
|
2250
|
+
accumulated_content = []
|
2251
|
+
role = "assistant"
|
1566
2252
|
final_chunk = chunks[-1] if chunks else {}
|
2253
|
+
finish_reason = None
|
1567
2254
|
|
1568
2255
|
for i, chunk in enumerate(chunks):
|
1569
2256
|
choice = chunk.get("choices", [{}])[0]
|
1570
2257
|
delta = choice.get("delta", {}) if isinstance(choice, dict) else {}
|
1571
2258
|
delta_tool_calls = delta.get("tool_calls") if isinstance(delta, dict) else None
|
1572
2259
|
|
2260
|
+
# Accumulate content from deltas
|
2261
|
+
delta_content = delta.get("content") if isinstance(delta, dict) else None
|
2262
|
+
if delta_content:
|
2263
|
+
accumulated_content.append(delta_content)
|
2264
|
+
|
2265
|
+
# Capture role if present
|
2266
|
+
delta_role = delta.get("role") if isinstance(delta, dict) else None
|
2267
|
+
if delta_role:
|
2268
|
+
role = delta_role
|
2269
|
+
|
2270
|
+
# Capture finish_reason
|
2271
|
+
choice_finish = choice.get("finish_reason") if isinstance(choice, dict) else None
|
2272
|
+
if choice_finish:
|
2273
|
+
finish_reason = choice_finish
|
2274
|
+
|
2275
|
+
# Accumulate tool calls
|
1573
2276
|
if delta_tool_calls:
|
1574
2277
|
for tool_call in delta_tool_calls:
|
1575
2278
|
tool_index = tool_call.get("index", 0)
|
@@ -1633,8 +2336,31 @@ class BaseAgent:
|
|
1633
2336
|
logger.debug(f"🔧 RECONSTRUCTION: Reconstructed {len(tool_calls_list)} tool calls")
|
1634
2337
|
return reconstructed
|
1635
2338
|
|
1636
|
-
# No tool calls found
|
1637
|
-
|
2339
|
+
# No tool calls found - check if we have content to return
|
2340
|
+
content_text = "".join(accumulated_content) if accumulated_content else None
|
2341
|
+
|
2342
|
+
if content_text or finish_reason:
|
2343
|
+
# Create a proper response with message format
|
2344
|
+
logger.debug(f"🔧 RECONSTRUCTION: No tool calls, reconstructing content response (content_len={len(content_text) if content_text else 0})")
|
2345
|
+
reconstructed = {
|
2346
|
+
"id": final_chunk.get("id", "chatcmpl-reconstructed"),
|
2347
|
+
"created": final_chunk.get("created", 0),
|
2348
|
+
"model": final_chunk.get("model", "unknown"),
|
2349
|
+
"object": "chat.completion",
|
2350
|
+
"choices": [{
|
2351
|
+
"index": 0,
|
2352
|
+
"finish_reason": finish_reason or "stop",
|
2353
|
+
"message": {
|
2354
|
+
"role": role,
|
2355
|
+
"content": content_text
|
2356
|
+
}
|
2357
|
+
}],
|
2358
|
+
"usage": final_chunk.get("usage", {})
|
2359
|
+
}
|
2360
|
+
return reconstructed
|
2361
|
+
|
2362
|
+
# No content and no tool calls - return last chunk as-is (shouldn't happen often)
|
2363
|
+
logger.warning(f"🔧 RECONSTRUCTION: No tool calls and no content found, returning last chunk as-is")
|
1638
2364
|
return final_chunk
|
1639
2365
|
|
1640
2366
|
def _convert_response_to_chunk(self, response: Dict[str, Any]) -> Dict[str, Any]:
|
@@ -1694,6 +2420,32 @@ class BaseAgent:
|
|
1694
2420
|
except Exception:
|
1695
2421
|
return
|
1696
2422
|
|
2423
|
+
def _is_browser_request(self, context=None) -> bool:
|
2424
|
+
"""Check if the request came from a browser based on User-Agent header
|
2425
|
+
|
2426
|
+
Args:
|
2427
|
+
context: Optional context object (uses get_context() if not provided)
|
2428
|
+
|
2429
|
+
Returns:
|
2430
|
+
True if User-Agent contains browser markers (Mozilla, Chrome, Safari, Firefox)
|
2431
|
+
"""
|
2432
|
+
if context is None:
|
2433
|
+
context = get_context()
|
2434
|
+
|
2435
|
+
if not context or not hasattr(context, 'request') or not context.request:
|
2436
|
+
self.logger.debug("🌐 No context or request available for browser detection")
|
2437
|
+
return False
|
2438
|
+
|
2439
|
+
user_agent = context.request.headers.get('user-agent', '').lower() if hasattr(context.request, 'headers') else ''
|
2440
|
+
|
2441
|
+
# Check for common browser User-Agent markers
|
2442
|
+
browser_markers = ['mozilla', 'chrome', 'safari', 'firefox', 'edge']
|
2443
|
+
is_browser = any(marker in user_agent for marker in browser_markers)
|
2444
|
+
|
2445
|
+
self.logger.debug(f"🌐 User-Agent: {user_agent[:100] if user_agent else '(empty)'} -> is_browser: {is_browser}")
|
2446
|
+
|
2447
|
+
return is_browser
|
2448
|
+
|
1697
2449
|
def _merge_tools(self, external_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
1698
2450
|
"""Merge external tools with agent tools - external tools have priority"""
|
1699
2451
|
# Clear previous overrides (fresh for each request)
|
@@ -1703,8 +2455,50 @@ class BaseAgent:
|
|
1703
2455
|
context = get_context()
|
1704
2456
|
auth_scope = context.auth_scope if context else "all"
|
1705
2457
|
|
2458
|
+
# Get all agent tools (now includes widgets since they're also registered as tools)
|
1706
2459
|
agent_tools = self.get_tools_for_scope(auth_scope)
|
1707
|
-
|
2460
|
+
|
2461
|
+
# Get widget names for filtering
|
2462
|
+
widget_names = {w['name'] for w in self._registered_widgets}
|
2463
|
+
|
2464
|
+
# Filter widgets out of regular tools list (we'll add them conditionally below)
|
2465
|
+
agent_tools_no_widgets = [tool for tool in agent_tools if tool.get('name') not in widget_names]
|
2466
|
+
agent_tool_defs = [tool['definition'] for tool in agent_tools_no_widgets if tool.get('definition')]
|
2467
|
+
|
2468
|
+
# Add widgets only for browser requests
|
2469
|
+
is_browser = self._is_browser_request(context)
|
2470
|
+
self.logger.debug(f"🌐 Browser request check: {is_browser}")
|
2471
|
+
if is_browser:
|
2472
|
+
agent_widgets = self.get_all_widgets()
|
2473
|
+
self.logger.debug(f"🎨 Found {len(agent_widgets)} registered widgets")
|
2474
|
+
scope_hierarchy = {"admin": 3, "owner": 2, "all": 1}
|
2475
|
+
user_level = scope_hierarchy.get(auth_scope, 1)
|
2476
|
+
|
2477
|
+
# Convert widget configs to tool-like definitions for LLM context
|
2478
|
+
widgets_added = 0
|
2479
|
+
for widget in agent_widgets:
|
2480
|
+
# Filter by scope (similar to tools)
|
2481
|
+
widget_scope = widget.get('scope', 'all')
|
2482
|
+
scope_matched = False
|
2483
|
+
|
2484
|
+
if isinstance(widget_scope, list):
|
2485
|
+
# If scope is a list, check if user scope is in it
|
2486
|
+
if auth_scope in widget_scope or 'all' in widget_scope:
|
2487
|
+
scope_matched = True
|
2488
|
+
else:
|
2489
|
+
# Single scope - check hierarchy
|
2490
|
+
required_level = scope_hierarchy.get(widget_scope, 1)
|
2491
|
+
if user_level >= required_level:
|
2492
|
+
scope_matched = True
|
2493
|
+
|
2494
|
+
if scope_matched:
|
2495
|
+
widget_def = widget.get('definition')
|
2496
|
+
if widget_def:
|
2497
|
+
agent_tool_defs.append(widget_def)
|
2498
|
+
widgets_added += 1
|
2499
|
+
|
2500
|
+
if widgets_added > 0:
|
2501
|
+
self.logger.debug(f"🎨 Added {widgets_added} widgets for browser request")
|
1708
2502
|
|
1709
2503
|
# Debug logging
|
1710
2504
|
logger = self.logger
|
@@ -1808,26 +2602,28 @@ class BaseAgent:
|
|
1808
2602
|
|
1809
2603
|
return decorator
|
1810
2604
|
|
1811
|
-
def handoff(self, name: Optional[str] = None,
|
1812
|
-
|
2605
|
+
def handoff(self, name: Optional[str] = None, prompt: Optional[str] = None,
|
2606
|
+
scope: Union[str, List[str]] = "all", priority: int = 50):
|
1813
2607
|
"""Register a handoff directly on the agent instance
|
1814
2608
|
|
1815
2609
|
Usage:
|
1816
|
-
@agent.handoff(
|
1817
|
-
async def escalate_to_supervisor(
|
1818
|
-
return
|
2610
|
+
@agent.handoff(name="specialist", prompt="Hand off to specialist")
|
2611
|
+
async def escalate_to_supervisor(messages, tools=None, **kwargs):
|
2612
|
+
return {"choices": [{"message": {"role": "assistant", "content": "Escalated"}}]}
|
1819
2613
|
"""
|
1820
2614
|
def decorator(func: Callable) -> Callable:
|
1821
2615
|
from ..tools.decorators import handoff as handoff_decorator
|
1822
|
-
decorated_func = handoff_decorator(name=name,
|
1823
|
-
description=description, scope=scope)(func)
|
2616
|
+
decorated_func = handoff_decorator(name=name, prompt=prompt, scope=scope, priority=priority)(func)
|
1824
2617
|
handoff_config = Handoff(
|
1825
2618
|
target=getattr(decorated_func, '_handoff_name', decorated_func.__name__),
|
1826
|
-
|
1827
|
-
description=getattr(decorated_func, '_handoff_description', ''),
|
2619
|
+
description=getattr(decorated_func, '_handoff_prompt', ''),
|
1828
2620
|
scope=getattr(decorated_func, '_handoff_scope', scope)
|
1829
2621
|
)
|
1830
|
-
handoff_config.metadata = {
|
2622
|
+
handoff_config.metadata = {
|
2623
|
+
'function': decorated_func,
|
2624
|
+
'priority': getattr(decorated_func, '_handoff_priority', priority),
|
2625
|
+
'is_generator': getattr(decorated_func, '_handoff_is_generator', False)
|
2626
|
+
}
|
1831
2627
|
self.register_handoff(handoff_config, source="agent")
|
1832
2628
|
return decorated_func
|
1833
2629
|
|