signalwire-agents 0.1.9__py3-none-any.whl → 0.1.11__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 (37) hide show
  1. signalwire_agents/__init__.py +39 -4
  2. signalwire_agents/agent_server.py +46 -2
  3. signalwire_agents/cli/__init__.py +9 -0
  4. signalwire_agents/cli/test_swaig.py +2545 -0
  5. signalwire_agents/core/agent_base.py +691 -82
  6. signalwire_agents/core/contexts.py +289 -0
  7. signalwire_agents/core/data_map.py +499 -0
  8. signalwire_agents/core/function_result.py +57 -10
  9. signalwire_agents/core/skill_base.py +31 -1
  10. signalwire_agents/core/skill_manager.py +89 -23
  11. signalwire_agents/core/swaig_function.py +13 -1
  12. signalwire_agents/core/swml_handler.py +37 -13
  13. signalwire_agents/core/swml_service.py +37 -28
  14. signalwire_agents/skills/datasphere/__init__.py +12 -0
  15. signalwire_agents/skills/datasphere/skill.py +229 -0
  16. signalwire_agents/skills/datasphere_serverless/__init__.py +1 -0
  17. signalwire_agents/skills/datasphere_serverless/skill.py +156 -0
  18. signalwire_agents/skills/datetime/skill.py +7 -3
  19. signalwire_agents/skills/joke/__init__.py +1 -0
  20. signalwire_agents/skills/joke/skill.py +88 -0
  21. signalwire_agents/skills/math/skill.py +8 -5
  22. signalwire_agents/skills/registry.py +23 -4
  23. signalwire_agents/skills/web_search/skill.py +58 -33
  24. signalwire_agents/skills/wikipedia/__init__.py +9 -0
  25. signalwire_agents/skills/wikipedia/skill.py +180 -0
  26. signalwire_agents/utils/__init__.py +2 -0
  27. signalwire_agents/utils/schema_utils.py +111 -44
  28. signalwire_agents/utils/serverless.py +38 -0
  29. signalwire_agents-0.1.11.dist-info/METADATA +756 -0
  30. signalwire_agents-0.1.11.dist-info/RECORD +58 -0
  31. {signalwire_agents-0.1.9.dist-info → signalwire_agents-0.1.11.dist-info}/WHEEL +1 -1
  32. signalwire_agents-0.1.11.dist-info/entry_points.txt +2 -0
  33. signalwire_agents-0.1.9.dist-info/METADATA +0 -311
  34. signalwire_agents-0.1.9.dist-info/RECORD +0 -44
  35. {signalwire_agents-0.1.9.data → signalwire_agents-0.1.11.data}/data/schema.json +0 -0
  36. {signalwire_agents-0.1.9.dist-info → signalwire_agents-0.1.11.dist-info}/licenses/LICENSE +0 -0
  37. {signalwire_agents-0.1.9.dist-info → signalwire_agents-0.1.11.dist-info}/top_level.txt +0 -0
@@ -21,6 +21,9 @@ import base64
21
21
  import logging
22
22
  import inspect
23
23
  import functools
24
+ import re
25
+ import signal
26
+ import sys
24
27
  from typing import Optional, Union, List, Dict, Any, Tuple, Callable, Type
25
28
  from urllib.parse import urlparse, urlencode
26
29
 
@@ -51,11 +54,11 @@ try:
51
54
  structlog.stdlib.add_logger_name,
52
55
  structlog.stdlib.add_log_level,
53
56
  structlog.stdlib.PositionalArgumentsFormatter(),
54
- structlog.processors.TimeStamper(fmt="iso"),
57
+ structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
55
58
  structlog.processors.StackInfoRenderer(),
56
59
  structlog.processors.format_exc_info,
57
60
  structlog.processors.UnicodeDecoder(),
58
- structlog.processors.JSONRenderer()
61
+ structlog.dev.ConsoleRenderer()
59
62
  ],
60
63
  context_class=dict,
61
64
  logger_factory=structlog.stdlib.LoggerFactory(),
@@ -76,6 +79,8 @@ from signalwire_agents.core.state import StateManager, FileStateManager
76
79
  from signalwire_agents.core.swml_service import SWMLService
77
80
  from signalwire_agents.core.swml_handler import AIVerbHandler
78
81
  from signalwire_agents.core.skill_manager import SkillManager
82
+ from signalwire_agents.utils.schema_utils import SchemaUtils
83
+ from signalwire_agents.utils.serverless import get_execution_mode, is_serverless_mode
79
84
 
80
85
  # Create a logger
81
86
  logger = structlog.get_logger("agent_base")
@@ -282,7 +287,9 @@ class AgentBase(SWMLService):
282
287
  agent_id: Optional[str] = None,
283
288
  native_functions: Optional[List[str]] = None,
284
289
  schema_path: Optional[str] = None,
285
- suppress_logs: bool = False
290
+ suppress_logs: bool = False,
291
+ enable_post_prompt_override: bool = False,
292
+ check_for_input_override: bool = False
286
293
  ):
287
294
  """
288
295
  Initialize a new agent
@@ -306,6 +313,8 @@ class AgentBase(SWMLService):
306
313
  native_functions: Optional list of native functions to include in the SWAIG object
307
314
  schema_path: Optional path to the schema file
308
315
  suppress_logs: Whether to suppress structured logs
316
+ enable_post_prompt_override: Whether to enable post-prompt override
317
+ check_for_input_override: Whether to enable check-for-input override
309
318
  """
310
319
  # Import SWMLService here to avoid circular imports
311
320
  from signalwire_agents.core.swml_service import SWMLService
@@ -325,7 +334,7 @@ class AgentBase(SWMLService):
325
334
 
326
335
  # Log the schema path if found and not suppressing logs
327
336
  if self.schema_utils and self.schema_utils.schema_path and not suppress_logs:
328
- print(f"Using schema.json at: {self.schema_utils.schema_path}")
337
+ self.log.debug("using_schema_path", path=self.schema_utils.schema_path)
329
338
 
330
339
  # Setup logger for this instance
331
340
  self.log = logger.bind(agent=name)
@@ -360,7 +369,7 @@ class AgentBase(SWMLService):
360
369
  self.pom = None
361
370
 
362
371
  # Initialize tool registry (separate from SWMLService verb registry)
363
- self._swaig_functions: Dict[str, SWAIGFunction] = {}
372
+ self._swaig_functions = {}
364
373
 
365
374
  # Initialize session manager
366
375
  self._session_manager = SessionManager(token_expiry_secs=token_expiry_secs)
@@ -408,6 +417,15 @@ class AgentBase(SWMLService):
408
417
 
409
418
  # Initialize skill manager
410
419
  self.skill_manager = SkillManager(self)
420
+
421
+ # Initialize contexts system
422
+ self._contexts_builder = None
423
+ self._contexts_defined = False
424
+
425
+ self.schema_utils = SchemaUtils(schema_path)
426
+ if self.schema_utils and self.schema_utils.schema:
427
+ self.log.debug("schema_loaded", path=self.schema_utils.schema_path)
428
+
411
429
 
412
430
  def _process_prompt_sections(self):
413
431
  """
@@ -510,6 +528,39 @@ class AgentBase(SWMLService):
510
528
  # Prompt Building Methods
511
529
  # ----------------------------------------------------------------------
512
530
 
531
+ def define_contexts(self) -> 'ContextBuilder':
532
+ """
533
+ Define contexts and steps for this agent (alternative to POM/prompt)
534
+
535
+ Returns:
536
+ ContextBuilder for method chaining
537
+
538
+ Note:
539
+ Contexts can coexist with traditional prompts. The restriction is only
540
+ that you can't mix POM sections with raw text in the main prompt.
541
+ """
542
+ # Import here to avoid circular imports
543
+ from signalwire_agents.core.contexts import ContextBuilder
544
+
545
+ if self._contexts_builder is None:
546
+ self._contexts_builder = ContextBuilder(self)
547
+ self._contexts_defined = True
548
+
549
+ return self._contexts_builder
550
+
551
+ def _validate_prompt_mode_exclusivity(self):
552
+ """
553
+ Validate that POM sections and raw text are not mixed in the main prompt
554
+
555
+ Note: This does NOT prevent contexts from being used alongside traditional prompts
556
+ """
557
+ # Only check for mixing POM sections with raw text in the main prompt
558
+ if self._raw_prompt and (self.pom and hasattr(self.pom, 'sections') and self.pom.sections):
559
+ raise ValueError(
560
+ "Cannot mix raw text prompt with POM sections in the main prompt. "
561
+ "Use either set_prompt_text() OR prompt_add_section() methods, not both."
562
+ )
563
+
513
564
  def set_prompt_text(self, text: str) -> 'AgentBase':
514
565
  """
515
566
  Set the prompt as raw text instead of using POM
@@ -520,6 +571,7 @@ class AgentBase(SWMLService):
520
571
  Returns:
521
572
  Self for method chaining
522
573
  """
574
+ self._validate_prompt_mode_exclusivity()
523
575
  self._raw_prompt = text
524
576
  return self
525
577
 
@@ -575,6 +627,7 @@ class AgentBase(SWMLService):
575
627
  Returns:
576
628
  Self for method chaining
577
629
  """
630
+ self._validate_prompt_mode_exclusivity()
578
631
  if self._use_pom and self.pom:
579
632
  # Create parameters for add_section based on what's supported
580
633
  kwargs = {}
@@ -690,7 +743,9 @@ class AgentBase(SWMLService):
690
743
  parameters: Dict[str, Any],
691
744
  handler: Callable,
692
745
  secure: bool = True,
693
- fillers: Optional[Dict[str, List[str]]] = None
746
+ fillers: Optional[Dict[str, List[str]]] = None,
747
+ webhook_url: Optional[str] = None,
748
+ **swaig_fields
694
749
  ) -> 'AgentBase':
695
750
  """
696
751
  Define a SWAIG function that the AI can call
@@ -702,6 +757,8 @@ class AgentBase(SWMLService):
702
757
  handler: Function to call when invoked
703
758
  secure: Whether to require token validation
704
759
  fillers: Optional dict mapping language codes to arrays of filler phrases
760
+ webhook_url: Optional external webhook URL to use instead of local handling
761
+ **swaig_fields: Additional SWAIG fields to include in function definition
705
762
 
706
763
  Returns:
707
764
  Self for method chaining
@@ -715,10 +772,34 @@ class AgentBase(SWMLService):
715
772
  parameters=parameters,
716
773
  handler=handler,
717
774
  secure=secure,
718
- fillers=fillers
775
+ fillers=fillers,
776
+ webhook_url=webhook_url,
777
+ **swaig_fields
719
778
  )
720
779
  return self
721
780
 
781
+ def register_swaig_function(self, function_dict: Dict[str, Any]) -> 'AgentBase':
782
+ """
783
+ Register a raw SWAIG function dictionary (e.g., from DataMap.to_swaig_function())
784
+
785
+ Args:
786
+ function_dict: Complete SWAIG function definition dictionary
787
+
788
+ Returns:
789
+ Self for method chaining
790
+ """
791
+ function_name = function_dict.get('function')
792
+ if not function_name:
793
+ raise ValueError("Function dictionary must contain 'function' field with the function name")
794
+
795
+ if function_name in self._swaig_functions:
796
+ raise ValueError(f"Tool with name '{function_name}' already exists")
797
+
798
+ # Store the raw function dictionary for data_map tools
799
+ # These don't have handlers since they execute on SignalWire's server
800
+ self._swaig_functions[function_name] = function_dict
801
+ return self
802
+
722
803
  def _tool_decorator(self, name=None, **kwargs):
723
804
  """
724
805
  Decorator for defining SWAIG tools in a class
@@ -734,10 +815,11 @@ class AgentBase(SWMLService):
734
815
  if name is None:
735
816
  name = func.__name__
736
817
 
737
- parameters = kwargs.get("parameters", {})
738
- description = kwargs.get("description", func.__doc__ or f"Function {name}")
739
- secure = kwargs.get("secure", True)
740
- fillers = kwargs.get("fillers", None)
818
+ parameters = kwargs.pop("parameters", {})
819
+ description = kwargs.pop("description", func.__doc__ or f"Function {name}")
820
+ secure = kwargs.pop("secure", True)
821
+ fillers = kwargs.pop("fillers", None)
822
+ webhook_url = kwargs.pop("webhook_url", None)
741
823
 
742
824
  self.define_tool(
743
825
  name=name,
@@ -745,7 +827,9 @@ class AgentBase(SWMLService):
745
827
  parameters=parameters,
746
828
  handler=func,
747
829
  secure=secure,
748
- fillers=fillers
830
+ fillers=fillers,
831
+ webhook_url=webhook_url,
832
+ **kwargs # Pass through any additional swaig_fields
749
833
  )
750
834
  return func
751
835
  return decorator
@@ -772,20 +856,24 @@ class AgentBase(SWMLService):
772
856
  tool_name = getattr(attr, "_tool_name", name)
773
857
  tool_params = getattr(attr, "_tool_params", {})
774
858
 
775
- # Get description and parameters
776
- description = tool_params.get("description", attr.__doc__ or f"Function {tool_name}")
777
- parameters = tool_params.get("parameters", {})
778
- secure = tool_params.get("secure", True)
779
- fillers = tool_params.get("fillers", None)
859
+ # Extract known parameters and pass through the rest as swaig_fields
860
+ tool_params_copy = tool_params.copy()
861
+ description = tool_params_copy.pop("description", attr.__doc__ or f"Function {tool_name}")
862
+ parameters = tool_params_copy.pop("parameters", {})
863
+ secure = tool_params_copy.pop("secure", True)
864
+ fillers = tool_params_copy.pop("fillers", None)
865
+ webhook_url = tool_params_copy.pop("webhook_url", None)
780
866
 
781
- # Register the tool
867
+ # Register the tool with any remaining params as swaig_fields
782
868
  self.define_tool(
783
869
  name=tool_name,
784
870
  description=description,
785
871
  parameters=parameters,
786
872
  handler=attr.__get__(self, cls), # Bind the method to this instance
787
873
  secure=secure,
788
- fillers=fillers
874
+ fillers=fillers,
875
+ webhook_url=webhook_url,
876
+ **tool_params_copy # Pass through any additional swaig_fields
789
877
  )
790
878
 
791
879
  @classmethod
@@ -812,12 +900,102 @@ class AgentBase(SWMLService):
812
900
 
813
901
  def get_name(self) -> str:
814
902
  """
815
- Get the agent name
903
+ Get agent name
816
904
 
817
905
  Returns:
818
- Agent name/identifier
906
+ Agent name
819
907
  """
820
908
  return self.name
909
+
910
+ def get_app(self):
911
+ """
912
+ Get the FastAPI application instance for deployment adapters like Lambda/Mangum
913
+
914
+ This method ensures the FastAPI app is properly initialized and configured,
915
+ then returns it for use with deployment adapters like Mangum for AWS Lambda.
916
+
917
+ Returns:
918
+ FastAPI: The configured FastAPI application instance
919
+ """
920
+ if self._app is None:
921
+ # Initialize the app if it hasn't been created yet
922
+ # This follows the same initialization logic as serve() but without running uvicorn
923
+ from fastapi import FastAPI
924
+ from fastapi.middleware.cors import CORSMiddleware
925
+
926
+ # Create a FastAPI app with explicit redirect_slashes=False
927
+ app = FastAPI(redirect_slashes=False)
928
+
929
+ # Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
930
+ @app.get("/health")
931
+ @app.post("/health")
932
+ async def health_check():
933
+ """Health check endpoint for Kubernetes liveness probe"""
934
+ return {
935
+ "status": "healthy",
936
+ "agent": self.get_name(),
937
+ "route": self.route,
938
+ "functions": len(self._swaig_functions)
939
+ }
940
+
941
+ @app.get("/ready")
942
+ @app.post("/ready")
943
+ async def readiness_check():
944
+ """Readiness check endpoint for Kubernetes readiness probe"""
945
+ return {
946
+ "status": "ready",
947
+ "agent": self.get_name(),
948
+ "route": self.route,
949
+ "functions": len(self._swaig_functions)
950
+ }
951
+
952
+ # Add CORS middleware if needed
953
+ app.add_middleware(
954
+ CORSMiddleware,
955
+ allow_origins=["*"],
956
+ allow_credentials=True,
957
+ allow_methods=["*"],
958
+ allow_headers=["*"],
959
+ )
960
+
961
+ # Create router and register routes
962
+ router = self.as_router()
963
+
964
+ # Log registered routes for debugging
965
+ self.log.debug("router_routes_registered")
966
+ for route in router.routes:
967
+ if hasattr(route, "path"):
968
+ self.log.debug("router_route", path=route.path)
969
+
970
+ # Include the router
971
+ app.include_router(router, prefix=self.route)
972
+
973
+ # Register a catch-all route for debugging and troubleshooting
974
+ @app.get("/{full_path:path}")
975
+ @app.post("/{full_path:path}")
976
+ async def handle_all_routes(request: Request, full_path: str):
977
+ self.log.debug("request_received", path=full_path)
978
+
979
+ # Check if the path is meant for this agent
980
+ if not full_path.startswith(self.route.lstrip("/")):
981
+ return {"error": "Invalid route"}
982
+
983
+ # Extract the path relative to this agent's route
984
+ relative_path = full_path[len(self.route.lstrip("/")):]
985
+ relative_path = relative_path.lstrip("/")
986
+ self.log.debug("relative_path_extracted", path=relative_path)
987
+
988
+ return {"error": "Path not found"}
989
+
990
+ # Log all app routes for debugging
991
+ self.log.debug("app_routes_registered")
992
+ for route in app.routes:
993
+ if hasattr(route, "path"):
994
+ self.log.debug("app_route", path=route.path)
995
+
996
+ self._app = app
997
+
998
+ return self._app
821
999
 
822
1000
  def get_prompt(self) -> Union[str, List[Dict[str, Any]]]:
823
1001
  """
@@ -855,7 +1033,7 @@ class AgentBase(SWMLService):
855
1033
  return pom_data['_sections']
856
1034
  # Fall through to default if nothing worked
857
1035
  except Exception as e:
858
- print(f"Error rendering POM: {e}")
1036
+ self.log.error("pom_rendering_failed", error=str(e))
859
1037
  # Fall back to raw text if POM fails
860
1038
 
861
1039
  # Return raw text (either explicitly set or default)
@@ -875,11 +1053,19 @@ class AgentBase(SWMLService):
875
1053
  Define the tools this agent can use
876
1054
 
877
1055
  Returns:
878
- List of SWAIGFunction objects
1056
+ List of SWAIGFunction objects or raw dictionaries (for data_map tools)
879
1057
 
880
1058
  This method can be overridden by subclasses.
881
1059
  """
882
- return list(self._swaig_functions.values())
1060
+ tools = []
1061
+ for func in self._swaig_functions.values():
1062
+ if isinstance(func, dict):
1063
+ # Raw dictionary from register_swaig_function (e.g., DataMap)
1064
+ tools.append(func)
1065
+ else:
1066
+ # SWAIGFunction object from define_tool
1067
+ tools.append(func)
1068
+ return tools
883
1069
 
884
1070
  def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
885
1071
  """
@@ -912,7 +1098,18 @@ class AgentBase(SWMLService):
912
1098
  # Get the function
913
1099
  func = self._swaig_functions[name]
914
1100
 
915
- # Call the handler
1101
+ # Check if this is a data_map function (raw dictionary)
1102
+ if isinstance(func, dict):
1103
+ # Data_map functions execute on SignalWire's server, not here
1104
+ # This should never be called, but if it is, return an error
1105
+ return {"response": f"Data map function '{name}' should be executed by SignalWire server, not locally"}
1106
+
1107
+ # Check if this is an external webhook function
1108
+ if hasattr(func, 'webhook_url') and func.webhook_url:
1109
+ # External webhook functions should be called directly by SignalWire, not locally
1110
+ return {"response": f"External webhook function '{name}' should be executed by SignalWire at {func.webhook_url}, not locally"}
1111
+
1112
+ # Call the handler for regular SWAIG functions
916
1113
  try:
917
1114
  result = func.handler(args, raw_data)
918
1115
  if result is None:
@@ -979,8 +1176,19 @@ class AgentBase(SWMLService):
979
1176
  self.log.warning("unknown_function", function=function_name)
980
1177
  return False
981
1178
 
1179
+ # Get the function and check if it's secure
1180
+ func = self._swaig_functions[function_name]
1181
+ is_secure = True # Default to secure
1182
+
1183
+ if isinstance(func, dict):
1184
+ # For raw dictionaries (DataMap functions), they're always secure
1185
+ is_secure = True
1186
+ else:
1187
+ # For SWAIGFunction objects, check the secure attribute
1188
+ is_secure = func.secure
1189
+
982
1190
  # Always allow non-secure functions
983
- if not self._swaig_functions[function_name].secure:
1191
+ if not is_secure:
984
1192
  self.log.debug("non_secure_function_allowed", function=function_name)
985
1193
  return True
986
1194
 
@@ -1107,25 +1315,56 @@ class AgentBase(SWMLService):
1107
1315
  Returns:
1108
1316
  Full URL including host, port, and route (with auth if requested)
1109
1317
  """
1110
- # Start with the base URL (either proxy or local)
1111
- if self._proxy_url_base:
1112
- # Use the proxy URL base from environment, ensuring we don't duplicate the route
1113
- # Strip any trailing slashes from proxy base
1114
- proxy_base = self._proxy_url_base.rstrip('/')
1115
- # Make sure route starts with a slash for consistency
1116
- route = self.route if self.route.startswith('/') else f"/{self.route}"
1117
- base_url = f"{proxy_base}{route}"
1318
+ mode = get_execution_mode()
1319
+
1320
+ if mode == 'cgi':
1321
+ protocol = 'https' if os.getenv('HTTPS') == 'on' else 'http'
1322
+ host = os.getenv('HTTP_HOST') or os.getenv('SERVER_NAME') or 'localhost'
1323
+ script_name = os.getenv('SCRIPT_NAME', '')
1324
+ base_url = f"{protocol}://{host}{script_name}"
1325
+ elif mode == 'lambda':
1326
+ function_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
1327
+ if function_url and ('amazonaws.com' in function_url or 'on.aws' in function_url):
1328
+ base_url = function_url.rstrip('/')
1329
+ else:
1330
+ api_id = os.getenv('AWS_API_GATEWAY_ID')
1331
+ if api_id:
1332
+ region = os.getenv('AWS_REGION', 'us-east-1')
1333
+ stage = os.getenv('AWS_API_GATEWAY_STAGE', 'prod')
1334
+ base_url = f"https://{api_id}.execute-api.{region}.amazonaws.com/{stage}"
1335
+ else:
1336
+ import logging
1337
+ logging.warning("Lambda mode detected but no URL configuration found")
1338
+ base_url = "https://lambda-url-not-configured"
1339
+ elif mode == 'cloud_function':
1340
+ function_url = os.getenv('FUNCTION_URL')
1341
+ if function_url:
1342
+ base_url = function_url
1343
+ else:
1344
+ project = os.getenv('GOOGLE_CLOUD_PROJECT')
1345
+ if project:
1346
+ region = os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
1347
+ service = os.getenv('K_SERVICE', 'function')
1348
+ base_url = f"https://{region}-{project}.cloudfunctions.net/{service}"
1349
+ else:
1350
+ import logging
1351
+ logging.warning("Cloud Function mode detected but no URL configuration found")
1352
+ base_url = "https://cloud-function-url-not-configured"
1118
1353
  else:
1119
- # Default local URL
1120
- if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
1121
- host = "localhost"
1354
+ # Server mode - preserve existing logic
1355
+ if self._proxy_url_base:
1356
+ proxy_base = self._proxy_url_base.rstrip('/')
1357
+ route = self.route if self.route.startswith('/') else f"/{self.route}"
1358
+ base_url = f"{proxy_base}{route}"
1122
1359
  else:
1123
- host = self.host
1124
-
1125
- base_url = f"http://{host}:{self.port}{self.route}"
1360
+ if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
1361
+ host = "localhost"
1362
+ else:
1363
+ host = self.host
1364
+ base_url = f"http://{host}:{self.port}{self.route}"
1126
1365
 
1127
- # Add auth if requested
1128
- if include_auth:
1366
+ # Add auth if requested (only for server mode)
1367
+ if include_auth and mode == 'server':
1129
1368
  username, password = self._basic_auth
1130
1369
  url = urlparse(base_url)
1131
1370
  return url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
@@ -1143,6 +1382,34 @@ class AgentBase(SWMLService):
1143
1382
  Returns:
1144
1383
  Fully constructed webhook URL
1145
1384
  """
1385
+ # Check for serverless environment and use appropriate URL generation
1386
+ mode = get_execution_mode()
1387
+
1388
+ if mode != 'server':
1389
+ # In serverless mode, use the serverless-appropriate URL
1390
+ base_url = self.get_full_url()
1391
+
1392
+ # For serverless, we don't need auth in webhook URLs since auth is handled differently
1393
+ # and we want to return the actual platform URL
1394
+
1395
+ # Ensure the endpoint has a trailing slash to prevent redirects
1396
+ if endpoint in ["swaig", "post_prompt"]:
1397
+ endpoint = f"{endpoint}/"
1398
+
1399
+ # Build the full webhook URL
1400
+ url = f"{base_url}/{endpoint}"
1401
+
1402
+ # Add query parameters if any (only if they have values)
1403
+ if query_params:
1404
+ # Remove any call_id from query params
1405
+ filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
1406
+ if filtered_params:
1407
+ params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
1408
+ url = f"{url}?{params}"
1409
+
1410
+ return url
1411
+
1412
+ # Server mode - use existing logic with proxy/auth support
1146
1413
  # Use the parent class's implementation if available and has the same method
1147
1414
  if hasattr(super(), '_build_webhook_url'):
1148
1415
  # Ensure _proxy_url_base is synchronized
@@ -1152,7 +1419,7 @@ class AgentBase(SWMLService):
1152
1419
  # Call parent's implementation
1153
1420
  return super()._build_webhook_url(endpoint, query_params)
1154
1421
 
1155
- # Otherwise, fall back to our own implementation
1422
+ # Otherwise, fall back to our own implementation for server mode
1156
1423
  # Base URL construction
1157
1424
  if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
1158
1425
  # For proxy URLs
@@ -1251,33 +1518,46 @@ class AgentBase(SWMLService):
1251
1518
 
1252
1519
  # Add each function to the functions array
1253
1520
  for name, func in self._swaig_functions.items():
1254
- # Get token for secure functions when we have a call_id
1255
- token = None
1256
- if func.secure and call_id:
1257
- token = self._create_tool_token(tool_name=name, call_id=call_id)
1258
-
1259
- # Prepare function entry
1260
- function_entry = {
1261
- "function": name,
1262
- "description": func.description,
1263
- "parameters": {
1264
- "type": "object",
1265
- "properties": func.parameters
1521
+ if isinstance(func, dict):
1522
+ # For raw dictionaries (DataMap functions), use the entire dictionary as-is
1523
+ # This preserves data_map and any other special fields
1524
+ function_entry = func.copy()
1525
+
1526
+ # Ensure the function name is set correctly
1527
+ function_entry["function"] = name
1528
+
1529
+ else:
1530
+ # For SWAIGFunction objects, build the entry manually
1531
+ # Check if it's secure and get token for secure functions when we have a call_id
1532
+ token = None
1533
+ if func.secure and call_id:
1534
+ token = self._create_tool_token(tool_name=name, call_id=call_id)
1535
+
1536
+ # Prepare function entry
1537
+ function_entry = {
1538
+ "function": name,
1539
+ "description": func.description,
1540
+ "parameters": {
1541
+ "type": "object",
1542
+ "properties": func.parameters
1543
+ }
1266
1544
  }
1267
- }
1268
-
1269
- # Add fillers if present
1270
- if func.fillers:
1271
- function_entry["fillers"] = func.fillers
1272
1545
 
1273
- # Add token to URL if we have one
1274
- if token:
1275
- # Create token params without call_id
1276
- token_params = {"token": token}
1277
- function_entry["web_hook_url"] = self._build_webhook_url("swaig", token_params)
1546
+ # Add fillers if present
1547
+ if func.fillers:
1548
+ function_entry["fillers"] = func.fillers
1278
1549
 
1279
- functions.append(function_entry)
1550
+ # Handle webhook URL
1551
+ if hasattr(func, 'webhook_url') and func.webhook_url:
1552
+ # External webhook function - use the provided URL directly
1553
+ function_entry["web_hook_url"] = func.webhook_url
1554
+ elif token:
1555
+ # Local function with token - build local webhook URL
1556
+ token_params = {"token": token}
1557
+ function_entry["web_hook_url"] = self._build_webhook_url("swaig", token_params)
1280
1558
 
1559
+ functions.append(function_entry)
1560
+
1281
1561
  # Add functions array to SWAIG object if we have any
1282
1562
  if functions:
1283
1563
  swaig_obj["functions"] = functions
@@ -1312,14 +1592,29 @@ class AgentBase(SWMLService):
1312
1592
  ai_handler = self.verb_registry.get_handler("ai")
1313
1593
  if ai_handler:
1314
1594
  try:
1315
- # Build AI config using the proper handler
1316
- ai_config = ai_handler.build_config(
1317
- prompt_text=None if prompt_is_pom else prompt,
1318
- prompt_pom=prompt if prompt_is_pom else None,
1319
- post_prompt=post_prompt,
1320
- post_prompt_url=post_prompt_url,
1321
- swaig=swaig_obj if swaig_obj else None
1322
- )
1595
+ # Check if we're in contexts mode
1596
+ if self._contexts_defined and self._contexts_builder:
1597
+ # Generate contexts instead of prompt
1598
+ contexts_dict = self._contexts_builder.to_dict()
1599
+
1600
+ # Build AI config with contexts
1601
+ ai_config = ai_handler.build_config(
1602
+ prompt_text=None,
1603
+ prompt_pom=None,
1604
+ contexts=contexts_dict,
1605
+ post_prompt=post_prompt,
1606
+ post_prompt_url=post_prompt_url,
1607
+ swaig=swaig_obj if swaig_obj else None
1608
+ )
1609
+ else:
1610
+ # Build AI config using the traditional prompt approach
1611
+ ai_config = ai_handler.build_config(
1612
+ prompt_text=None if prompt_is_pom else prompt,
1613
+ prompt_pom=prompt if prompt_is_pom else None,
1614
+ post_prompt=post_prompt,
1615
+ post_prompt_url=post_prompt_url,
1616
+ swaig=swaig_obj if swaig_obj else None
1617
+ )
1323
1618
 
1324
1619
  # Add new configuration parameters to the AI config
1325
1620
 
@@ -1345,7 +1640,7 @@ class AgentBase(SWMLService):
1345
1640
 
1346
1641
  except ValueError as e:
1347
1642
  if not self._suppress_logs:
1348
- print(f"Error building AI verb configuration: {str(e)}")
1643
+ self.log.error("ai_verb_config_error", error=str(e))
1349
1644
  else:
1350
1645
  # Fallback if no handler (shouldn't happen but just in case)
1351
1646
  ai_config = {
@@ -1438,9 +1733,9 @@ class AgentBase(SWMLService):
1438
1733
  self._register_routes(router)
1439
1734
 
1440
1735
  # Log all registered routes for debugging
1441
- print(f"Registered routes for {self.name}:")
1736
+ self.log.debug("routes_registered", agent=self.name)
1442
1737
  for route in router.routes:
1443
- print(f" {route.path}")
1738
+ self.log.debug("route_registered", path=route.path)
1444
1739
 
1445
1740
  return router
1446
1741
 
@@ -1458,6 +1753,40 @@ class AgentBase(SWMLService):
1458
1753
  # Create a FastAPI app with explicit redirect_slashes=False
1459
1754
  app = FastAPI(redirect_slashes=False)
1460
1755
 
1756
+ # Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
1757
+ @app.get("/health")
1758
+ @app.post("/health")
1759
+ async def health_check():
1760
+ """Health check endpoint for Kubernetes liveness probe"""
1761
+ return {
1762
+ "status": "healthy",
1763
+ "agent": self.get_name(),
1764
+ "route": self.route,
1765
+ "functions": len(self._swaig_functions)
1766
+ }
1767
+
1768
+ @app.get("/ready")
1769
+ @app.post("/ready")
1770
+ async def readiness_check():
1771
+ """Readiness check endpoint for Kubernetes readiness probe"""
1772
+ # Check if agent is properly initialized
1773
+ ready = (
1774
+ hasattr(self, '_swaig_functions') and
1775
+ hasattr(self, 'schema_utils') and
1776
+ self.schema_utils is not None
1777
+ )
1778
+
1779
+ status_code = 200 if ready else 503
1780
+ return Response(
1781
+ content=json.dumps({
1782
+ "status": "ready" if ready else "not_ready",
1783
+ "agent": self.get_name(),
1784
+ "initialized": ready
1785
+ }),
1786
+ status_code=status_code,
1787
+ media_type="application/json"
1788
+ )
1789
+
1461
1790
  # Get router for this agent
1462
1791
  router = self.as_router()
1463
1792
 
@@ -1465,7 +1794,7 @@ class AgentBase(SWMLService):
1465
1794
  @app.get("/{full_path:path}")
1466
1795
  @app.post("/{full_path:path}")
1467
1796
  async def handle_all_routes(request: Request, full_path: str):
1468
- print(f"Received request for path: {full_path}")
1797
+ self.log.debug("request_received", path=full_path)
1469
1798
 
1470
1799
  # Check if the path is meant for this agent
1471
1800
  if not full_path.startswith(self.route.lstrip("/")):
@@ -1474,7 +1803,7 @@ class AgentBase(SWMLService):
1474
1803
  # Extract the path relative to this agent's route
1475
1804
  relative_path = full_path[len(self.route.lstrip("/")):]
1476
1805
  relative_path = relative_path.lstrip("/")
1477
- print(f"Relative path: {relative_path}")
1806
+ self.log.debug("path_extracted", relative_path=relative_path)
1478
1807
 
1479
1808
  # Perform routing based on the relative path
1480
1809
  if not relative_path or relative_path == "/":
@@ -1509,11 +1838,11 @@ class AgentBase(SWMLService):
1509
1838
  # Include router with prefix
1510
1839
  app.include_router(router, prefix=self.route)
1511
1840
 
1512
- # Print all app routes for debugging
1513
- print(f"All app routes:")
1841
+ # Log all app routes for debugging
1842
+ self.log.debug("app_routes_registered")
1514
1843
  for route in app.routes:
1515
1844
  if hasattr(route, "path"):
1516
- print(f" {route.path}")
1845
+ self.log.debug("app_route", path=route.path)
1517
1846
 
1518
1847
  self._app = app
1519
1848
 
@@ -1522,12 +1851,290 @@ class AgentBase(SWMLService):
1522
1851
 
1523
1852
  # Print the auth credentials with source
1524
1853
  username, password, source = self.get_basic_auth_credentials(include_source=True)
1854
+
1855
+ # Log startup information using structured logging
1856
+ self.log.info("agent_starting",
1857
+ agent=self.name,
1858
+ url=f"http://{host}:{port}{self.route}",
1859
+ username=username,
1860
+ password_length=len(password),
1861
+ auth_source=source)
1862
+
1863
+ # Print user-friendly startup message (keep this for development UX)
1525
1864
  print(f"Agent '{self.name}' is available at:")
1526
1865
  print(f"URL: http://{host}:{port}{self.route}")
1527
1866
  print(f"Basic Auth: {username}:{password} (source: {source})")
1528
1867
 
1529
1868
  uvicorn.run(self._app, host=host, port=port)
1530
1869
 
1870
+ def run(self, event=None, context=None, force_mode=None, host: Optional[str] = None, port: Optional[int] = None):
1871
+ """
1872
+ Smart run method that automatically detects environment and handles accordingly
1873
+
1874
+ Args:
1875
+ event: Serverless event object (Lambda, Cloud Functions)
1876
+ context: Serverless context object (Lambda, Cloud Functions)
1877
+ force_mode: Override automatic mode detection for testing
1878
+ host: Host override for server mode
1879
+ port: Port override for server mode
1880
+
1881
+ Returns:
1882
+ Response for serverless modes, None for server mode
1883
+ """
1884
+ mode = force_mode or get_execution_mode()
1885
+
1886
+ try:
1887
+ if mode in ['cgi', 'cloud_function', 'azure_function']:
1888
+ response = self.handle_serverless_request(event, context, mode)
1889
+ print(response)
1890
+ return response
1891
+ elif mode == 'lambda':
1892
+ return self.handle_serverless_request(event, context, mode)
1893
+ else:
1894
+ # Server mode - use existing serve method
1895
+ self.serve(host, port)
1896
+ except Exception as e:
1897
+ import logging
1898
+ logging.error(f"Error in run method: {e}")
1899
+ if mode == 'lambda':
1900
+ return {
1901
+ "statusCode": 500,
1902
+ "headers": {"Content-Type": "application/json"},
1903
+ "body": json.dumps({"error": str(e)})
1904
+ }
1905
+ else:
1906
+ raise
1907
+
1908
+ def handle_serverless_request(self, event=None, context=None, mode=None):
1909
+ """
1910
+ Handle serverless environment requests (CGI, Lambda, Cloud Functions)
1911
+
1912
+ Args:
1913
+ event: Serverless event object (Lambda, Cloud Functions)
1914
+ context: Serverless context object (Lambda, Cloud Functions)
1915
+ mode: Override execution mode (from force_mode in run())
1916
+
1917
+ Returns:
1918
+ Response appropriate for the serverless platform
1919
+ """
1920
+ if mode is None:
1921
+ mode = get_execution_mode()
1922
+
1923
+ try:
1924
+ if mode == 'cgi':
1925
+ path_info = os.getenv('PATH_INFO', '').strip('/')
1926
+ if not path_info:
1927
+ return self._render_swml()
1928
+ else:
1929
+ # Parse CGI request for SWAIG function call
1930
+ args = {}
1931
+ call_id = None
1932
+ raw_data = None
1933
+
1934
+ # Try to parse POST data from stdin for CGI
1935
+ import sys
1936
+ content_length = os.getenv('CONTENT_LENGTH')
1937
+ if content_length and content_length.isdigit():
1938
+ try:
1939
+ post_data = sys.stdin.read(int(content_length))
1940
+ if post_data:
1941
+ raw_data = json.loads(post_data)
1942
+ call_id = raw_data.get("call_id")
1943
+
1944
+ # Extract arguments like the FastAPI handler does
1945
+ if "argument" in raw_data and isinstance(raw_data["argument"], dict):
1946
+ if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
1947
+ args = raw_data["argument"]["parsed"][0]
1948
+ elif "raw" in raw_data["argument"]:
1949
+ try:
1950
+ args = json.loads(raw_data["argument"]["raw"])
1951
+ except Exception:
1952
+ pass
1953
+ except Exception:
1954
+ # If parsing fails, continue with empty args
1955
+ pass
1956
+
1957
+ return self._execute_swaig_function(path_info, args, call_id, raw_data)
1958
+
1959
+ elif mode == 'lambda':
1960
+ if event:
1961
+ path = event.get('pathParameters', {}).get('proxy', '') if event.get('pathParameters') else ''
1962
+ if not path:
1963
+ swml_response = self._render_swml()
1964
+ return {
1965
+ "statusCode": 200,
1966
+ "headers": {"Content-Type": "application/json"},
1967
+ "body": swml_response
1968
+ }
1969
+ else:
1970
+ # Parse Lambda event for SWAIG function call
1971
+ args = {}
1972
+ call_id = None
1973
+ raw_data = None
1974
+
1975
+ # Parse request body if present
1976
+ body_content = event.get('body')
1977
+ if body_content:
1978
+ try:
1979
+ if isinstance(body_content, str):
1980
+ raw_data = json.loads(body_content)
1981
+ else:
1982
+ raw_data = body_content
1983
+
1984
+ call_id = raw_data.get("call_id")
1985
+
1986
+ # Extract arguments like the FastAPI handler does
1987
+ if "argument" in raw_data and isinstance(raw_data["argument"], dict):
1988
+ if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
1989
+ args = raw_data["argument"]["parsed"][0]
1990
+ elif "raw" in raw_data["argument"]:
1991
+ try:
1992
+ args = json.loads(raw_data["argument"]["raw"])
1993
+ except Exception:
1994
+ pass
1995
+ except Exception:
1996
+ # If parsing fails, continue with empty args
1997
+ pass
1998
+
1999
+ result = self._execute_swaig_function(path, args, call_id, raw_data)
2000
+ return {
2001
+ "statusCode": 200,
2002
+ "headers": {"Content-Type": "application/json"},
2003
+ "body": json.dumps(result) if isinstance(result, dict) else str(result)
2004
+ }
2005
+ else:
2006
+ # Handle case when event is None (direct Lambda call with no event)
2007
+ swml_response = self._render_swml()
2008
+ return {
2009
+ "statusCode": 200,
2010
+ "headers": {"Content-Type": "application/json"},
2011
+ "body": swml_response
2012
+ }
2013
+
2014
+ elif mode in ['cloud_function', 'azure_function']:
2015
+ return self._handle_cloud_function_request(event)
2016
+
2017
+ except Exception as e:
2018
+ import logging
2019
+ logging.error(f"Error in serverless request handler: {e}")
2020
+ if mode == 'lambda':
2021
+ return {
2022
+ "statusCode": 500,
2023
+ "headers": {"Content-Type": "application/json"},
2024
+ "body": json.dumps({"error": str(e)})
2025
+ }
2026
+ else:
2027
+ raise
2028
+
2029
+ def _handle_cloud_function_request(self, request):
2030
+ """
2031
+ Handle Cloud Function specific requests
2032
+
2033
+ Args:
2034
+ request: Cloud Function request object
2035
+
2036
+ Returns:
2037
+ Cloud Function response
2038
+ """
2039
+ # Platform-specific implementation would go here
2040
+ # For now, return basic SWML response
2041
+ return self._render_swml()
2042
+
2043
+ def _execute_swaig_function(self, function_name: str, args: Optional[Dict[str, Any]] = None, call_id: Optional[str] = None, raw_data: Optional[Dict[str, Any]] = None):
2044
+ """
2045
+ Execute a SWAIG function in serverless context
2046
+
2047
+ Args:
2048
+ function_name: Name of the function to execute
2049
+ args: Function arguments dictionary
2050
+ call_id: Optional call ID
2051
+ raw_data: Optional raw request data
2052
+
2053
+ Returns:
2054
+ Function execution result
2055
+ """
2056
+ import structlog
2057
+
2058
+ # Use the existing logger
2059
+ req_log = self.log.bind(
2060
+ endpoint="serverless_swaig",
2061
+ function=function_name
2062
+ )
2063
+
2064
+ if call_id:
2065
+ req_log = req_log.bind(call_id=call_id)
2066
+
2067
+ req_log.debug("serverless_function_call_received")
2068
+
2069
+ try:
2070
+ # Validate function exists
2071
+ if function_name not in self._swaig_functions:
2072
+ req_log.warning("function_not_found", available_functions=list(self._swaig_functions.keys()))
2073
+ return {"error": f"Function '{function_name}' not found"}
2074
+
2075
+ # Use empty args if not provided
2076
+ if args is None:
2077
+ args = {}
2078
+
2079
+ # Use empty raw_data if not provided, but include function call structure
2080
+ if raw_data is None:
2081
+ raw_data = {
2082
+ "function": function_name,
2083
+ "argument": {
2084
+ "parsed": [args] if args else [],
2085
+ "raw": json.dumps(args) if args else "{}"
2086
+ }
2087
+ }
2088
+ if call_id:
2089
+ raw_data["call_id"] = call_id
2090
+
2091
+ req_log.debug("executing_function", args=json.dumps(args))
2092
+
2093
+ # Call the function using the existing on_function_call method
2094
+ result = self.on_function_call(function_name, args, raw_data)
2095
+
2096
+ # Convert result to dict if needed (same logic as in _handle_swaig_request)
2097
+ if isinstance(result, SwaigFunctionResult):
2098
+ result_dict = result.to_dict()
2099
+ elif isinstance(result, dict):
2100
+ result_dict = result
2101
+ else:
2102
+ result_dict = {"response": str(result)}
2103
+
2104
+ req_log.info("serverless_function_executed_successfully")
2105
+ req_log.debug("function_result", result=json.dumps(result_dict))
2106
+ return result_dict
2107
+
2108
+ except Exception as e:
2109
+ req_log.error("serverless_function_execution_error", error=str(e))
2110
+ return {"error": str(e), "function": function_name}
2111
+
2112
+ def setup_graceful_shutdown(self) -> None:
2113
+ """
2114
+ Setup signal handlers for graceful shutdown (useful for Kubernetes)
2115
+ """
2116
+ def signal_handler(signum, frame):
2117
+ self.log.info("shutdown_signal_received", signal=signum)
2118
+
2119
+ # Perform cleanup
2120
+ try:
2121
+ # Close any open resources
2122
+ if hasattr(self, '_session_manager'):
2123
+ # Could add cleanup logic here
2124
+ pass
2125
+
2126
+ self.log.info("cleanup_completed")
2127
+ except Exception as e:
2128
+ self.log.error("cleanup_error", error=str(e))
2129
+ finally:
2130
+ sys.exit(0)
2131
+
2132
+ # Register handlers for common termination signals
2133
+ signal.signal(signal.SIGTERM, signal_handler) # Kubernetes sends this
2134
+ signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
2135
+
2136
+ self.log.debug("graceful_shutdown_handlers_registered")
2137
+
1531
2138
  def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
1532
2139
  """
1533
2140
  Customization point for subclasses to modify SWML based on request data
@@ -1552,6 +2159,8 @@ class AgentBase(SWMLService):
1552
2159
  Args:
1553
2160
  router: FastAPI router to register routes with
1554
2161
  """
2162
+ # Health check endpoints are now registered directly on the main app
2163
+
1555
2164
  # Root endpoint (handles both with and without trailing slash)
1556
2165
  @router.get("/")
1557
2166
  @router.post("/")