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.
Files changed (46) hide show
  1. webagents/__init__.py +9 -0
  2. webagents/agents/core/base_agent.py +865 -69
  3. webagents/agents/core/handoffs.py +14 -6
  4. webagents/agents/skills/base.py +33 -2
  5. webagents/agents/skills/core/llm/litellm/skill.py +906 -27
  6. webagents/agents/skills/core/memory/vector_memory/skill.py +8 -16
  7. webagents/agents/skills/ecosystem/crewai/__init__.py +3 -1
  8. webagents/agents/skills/ecosystem/crewai/skill.py +158 -0
  9. webagents/agents/skills/ecosystem/database/__init__.py +3 -1
  10. webagents/agents/skills/ecosystem/database/skill.py +522 -0
  11. webagents/agents/skills/ecosystem/mongodb/__init__.py +3 -0
  12. webagents/agents/skills/ecosystem/mongodb/skill.py +428 -0
  13. webagents/agents/skills/ecosystem/n8n/README.md +287 -0
  14. webagents/agents/skills/ecosystem/n8n/__init__.py +3 -0
  15. webagents/agents/skills/ecosystem/n8n/skill.py +341 -0
  16. webagents/agents/skills/ecosystem/openai/__init__.py +6 -0
  17. webagents/agents/skills/ecosystem/openai/skill.py +867 -0
  18. webagents/agents/skills/ecosystem/replicate/README.md +440 -0
  19. webagents/agents/skills/ecosystem/replicate/__init__.py +10 -0
  20. webagents/agents/skills/ecosystem/replicate/skill.py +517 -0
  21. webagents/agents/skills/ecosystem/x_com/README.md +401 -0
  22. webagents/agents/skills/ecosystem/x_com/__init__.py +3 -0
  23. webagents/agents/skills/ecosystem/x_com/skill.py +1048 -0
  24. webagents/agents/skills/ecosystem/zapier/README.md +363 -0
  25. webagents/agents/skills/ecosystem/zapier/__init__.py +3 -0
  26. webagents/agents/skills/ecosystem/zapier/skill.py +337 -0
  27. webagents/agents/skills/examples/__init__.py +6 -0
  28. webagents/agents/skills/examples/music_player.py +329 -0
  29. webagents/agents/skills/robutler/handoff/__init__.py +6 -0
  30. webagents/agents/skills/robutler/handoff/skill.py +191 -0
  31. webagents/agents/skills/robutler/nli/skill.py +180 -24
  32. webagents/agents/skills/robutler/payments/exceptions.py +27 -7
  33. webagents/agents/skills/robutler/payments/skill.py +64 -14
  34. webagents/agents/skills/robutler/storage/files/skill.py +2 -2
  35. webagents/agents/tools/decorators.py +243 -47
  36. webagents/agents/widgets/__init__.py +6 -0
  37. webagents/agents/widgets/renderer.py +150 -0
  38. webagents/server/core/app.py +130 -15
  39. webagents/server/core/models.py +1 -1
  40. webagents/utils/logging.py +13 -1
  41. {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/METADATA +16 -9
  42. {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/RECORD +45 -24
  43. webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
  44. {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/WHEEL +0 -0
  45. {webagents-0.2.0.dist-info → webagents-0.2.3.dist-info}/entry_points.txt +0 -0
  46. {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 (align with DynamicAgentFactory style)
138
- self.logger = get_logger('base_agent', 'core')
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}' type='{handoff_item.handoff_type}'")
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
- handoff_type=getattr(handoff_item, '_handoff_type', 'agent'),
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 = {'function': handoff_item}
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}' type='{handoff_config.handoff_type}'")
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
- handoff_type=getattr(capability_func, '_handoff_type', 'agent'),
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 = {'function': capability_func}
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
- handoff_type=getattr(attr, '_handoff_type', 'agent'),
385
- description=getattr(attr, '_handoff_description', ''),
386
- scope=getattr(attr, '_handoff_scope', None)
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
- self.logger.debug(f"📨 Handoff registered target='{handoff_config.target}' type='{handoff_config.handoff_type}' source='{source}'")
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"Your name is {self.name}, you are an AI agent in the Internet of Agents. Current time: {datetime.now().isoformat()}")
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
- parts.append(original_content)
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
- parts.append(dynamic_prompts)
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
- # Find primary LLM skill
865
- llm_skill = self.skills.get("primary_llm")
866
- if not llm_skill:
867
- raise ValueError("No LLM skill configured")
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 LLM call
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 LLM call
884
- self.logger.debug(f"🚀 Calling LLM for agent '{self.name}' (iteration {tool_iterations}) with {len(all_tools)} tools")
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 LLM call
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
- content_preview = str(msg.get('content', ''))[:100] + ('...' if len(str(msg.get('content', ''))) > 100 else '')
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
- # Call LLM with current conversation history
910
- response = await llm_skill.chat_completion(conversation_messages, tools=all_tools, stream=False)
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)[:100] + ('...' if len(str(content)) > 100 else '') if content else '[None]'
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
- # Find primary LLM skill
1292
- llm_skill = self.skills.get("primary_llm")
1293
- if not llm_skill:
1294
- raise ValueError("No LLM skill configured")
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 LLM call
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
- self.logger.debug(f"🚀 Streaming LLM for agent '{self.name}' (iteration {tool_iterations}) with {len(all_tools)} tools")
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 LLM call
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
- content_preview = str(msg.get('content', ''))[:100] + ('...' if len(str(msg.get('content', ''))) > 100 else '')
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
- # Stream from LLM and collect chunks
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
- async for chunk in llm_skill.chat_completion_stream(conversation_messages, tools=all_tools):
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 finish_reason == "tool_calls":
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
- self.logger.debug(f"🔧 STREAMING: Tool calls complete at chunk #{chunk_count}")
1368
- break # Exit streaming loop to process tools
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 final chunk
1377
- if finish_reason and modified_chunk.get('usage'):
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
- # Execute cleanup hooks
1461
- context = await self._execute_hooks("on_message", context)
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
- # Execute final hooks
1539
- context = await self._execute_hooks("on_message", context)
1540
- context = await self._execute_hooks("finalize_connection", context)
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
- await self._execute_hooks("finalize_connection", context)
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 call data
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, return the last chunk
1637
- logger.debug(f"🔧 RECONSTRUCTION: No tool calls found, returning last chunk")
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
- agent_tool_defs = [tool['definition'] for tool in agent_tools if tool.get('definition')]
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, handoff_type: str = "agent",
1812
- description: Optional[str] = None, scope: Union[str, List[str]] = "all"):
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(handoff_type="agent")
1817
- async def escalate_to_supervisor(issue: str):
1818
- return HandoffResult(result=f"Escalated: {issue}", handoff_type="agent")
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, handoff_type=handoff_type,
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
- handoff_type=getattr(decorated_func, '_handoff_type', 'agent'),
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 = {'function': decorated_func}
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