langchain-trigger-server 0.1.8__tar.gz → 0.1.10__tar.gz

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.

Potentially problematic release.


This version of langchain-trigger-server might be problematic. Click here for more details.

Files changed (16) hide show
  1. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/PKG-INFO +3 -1
  2. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/__init__.py +3 -2
  3. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/app.py +53 -198
  4. langchain_trigger_server-0.1.10/langchain_triggers/core.py +78 -0
  5. langchain_trigger_server-0.1.10/langchain_triggers/cron_manager.py +259 -0
  6. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/database/interface.py +7 -7
  7. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/database/supabase.py +17 -33
  8. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/decorators.py +7 -13
  9. langchain_trigger_server-0.1.10/langchain_triggers/triggers/__init__.py +7 -0
  10. langchain_trigger_server-0.1.10/langchain_triggers/triggers/cron_trigger.py +97 -0
  11. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/pyproject.toml +3 -1
  12. langchain_trigger_server-0.1.8/langchain_triggers/core.py +0 -83
  13. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/.github/workflows/release.yml +0 -0
  14. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/README.md +0 -0
  15. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/langchain_triggers/database/__init__.py +0 -0
  16. {langchain_trigger_server-0.1.8 → langchain_trigger_server-0.1.10}/test_framework.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-trigger-server
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: Generic event-driven triggers framework
5
5
  Project-URL: Homepage, https://github.com/langchain-ai/open-agent-platform
6
6
  Project-URL: Repository, https://github.com/langchain-ai/open-agent-platform
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Requires-Python: >=3.9
19
+ Requires-Dist: apscheduler>=3.10.0
20
+ Requires-Dist: croniter>=1.4.0
19
21
  Requires-Dist: cryptography>=3.0.0
20
22
  Requires-Dist: fastapi>=0.100.0
21
23
  Requires-Dist: httpx>=0.24.0
@@ -1,8 +1,9 @@
1
1
  """LangChain Triggers Framework - Event-driven triggers for AI agents."""
2
2
 
3
- from .core import UserAuthInfo, TriggerRegistrationModel, TriggerHandlerResult, TriggerRegistrationResult, MetadataManager
3
+ from .core import UserAuthInfo, TriggerRegistrationModel, TriggerHandlerResult, TriggerRegistrationResult
4
4
  from .decorators import TriggerTemplate
5
5
  from .app import TriggerServer
6
+ from .triggers.cron_trigger import cron_trigger
6
7
 
7
8
  __version__ = "0.1.0"
8
9
 
@@ -11,7 +12,7 @@ __all__ = [
11
12
  "TriggerRegistrationModel",
12
13
  "TriggerHandlerResult",
13
14
  "TriggerRegistrationResult",
14
- "MetadataManager",
15
15
  "TriggerTemplate",
16
16
  "TriggerServer",
17
+ "cron_trigger",
17
18
  ]
@@ -6,16 +6,15 @@ import logging
6
6
  import os
7
7
  from typing import Any, Callable, Dict, List, Optional
8
8
 
9
- import httpx
10
-
11
9
  from fastapi import FastAPI, HTTPException, Request, Depends
12
10
  from langgraph_sdk import get_client
11
+ from langchain_auth.client import Client
13
12
  from starlette.middleware.base import BaseHTTPMiddleware
14
13
  from starlette.responses import Response
15
14
 
16
- from .core import UserAuthInfo, ProviderAuthInfo, MetadataManager
17
15
  from .decorators import TriggerTemplate
18
16
  from .database import create_database, TriggerDatabaseInterface
17
+ from .cron_manager import CronTriggerManager
19
18
 
20
19
  logger = logging.getLogger(__name__)
21
20
 
@@ -72,7 +71,6 @@ class TriggerServer:
72
71
  def __init__(
73
72
  self,
74
73
  auth_handler: Callable,
75
- langgraph_headers_builder: Callable,
76
74
  ):
77
75
  self.app = FastAPI(
78
76
  title="Triggers Server",
@@ -82,11 +80,10 @@ class TriggerServer:
82
80
 
83
81
  self.database = create_database()
84
82
  self.auth_handler = auth_handler
85
- self.langgraph_headers_builder = langgraph_headers_builder
86
83
 
87
84
  # LangGraph configuration
88
85
  self.langgraph_api_url = os.getenv("LANGGRAPH_API_URL")
89
- self.langgraph_api_key = os.getenv("LANGCHAIN_API_KEY")
86
+ self.langsmith_api_key = os.getenv("LANGSMITH_API_KEY")
90
87
 
91
88
  if not self.langgraph_api_url:
92
89
  raise ValueError("LANGGRAPH_API_URL environment variable is required")
@@ -94,32 +91,37 @@ class TriggerServer:
94
91
  self.langgraph_api_url = self.langgraph_api_url.rstrip("/")
95
92
 
96
93
  # Initialize LangGraph SDK client
97
- self.langgraph_client = get_client(url=self.langgraph_api_url)
94
+ self.langgraph_client = get_client(url=self.langgraph_api_url, api_key=self.langsmith_api_key)
98
95
 
99
- self.langchain_auth_client = None
100
- try:
101
- from langchain_auth import Client
102
- langchain_api_key = os.getenv("LANGCHAIN_API_KEY")
103
- if langchain_api_key:
104
- self.langchain_auth_client = Client(api_key=langchain_api_key)
105
- logger.info("Initialized LangChain Auth client for OAuth token injection")
106
- else:
107
- logger.warning("LANGCHAIN_API_KEY not found - OAuth token injection disabled")
108
- except ImportError:
109
- logger.warning("langchain_auth not installed - OAuth token injection disabled")
96
+ # Initialize LangChain auth client
97
+ langchain_api_key = os.getenv("LANGCHAIN_API_KEY")
98
+ if langchain_api_key:
99
+ self.langchain_auth_client = Client(api_key=langchain_api_key)
100
+ logger.info("✓ LangChain auth client initialized")
101
+ else:
102
+ self.langchain_auth_client = None
103
+ logger.warning("LANGCHAIN_API_KEY not found - OAuth token injection disabled")
110
104
 
111
105
  self.triggers: List[TriggerTemplate] = []
112
106
 
107
+ # Initialize CronTriggerManager
108
+ self.cron_manager = CronTriggerManager(self)
109
+
113
110
  # Setup authentication middleware
114
111
  self.app.add_middleware(AuthenticationMiddleware, auth_handler=auth_handler)
115
112
 
116
113
  # Setup routes
117
114
  self._setup_routes()
118
115
 
119
- # Add startup event to ensure trigger templates exist in database
116
+ # Add startup and shutdown events
120
117
  @self.app.on_event("startup")
121
118
  async def startup_event():
122
119
  await self.ensure_trigger_templates()
120
+ await self.cron_manager.start()
121
+
122
+ @self.app.on_event("shutdown")
123
+ async def shutdown_event():
124
+ await self.cron_manager.shutdown()
123
125
 
124
126
  def add_trigger(self, trigger: TriggerTemplate) -> None:
125
127
  """Add a trigger template to the app."""
@@ -255,43 +257,31 @@ class TriggerServer:
255
257
  resource_dict = registration_instance.model_dump()
256
258
  existing_registration = await self.database.find_registration_by_resource(
257
259
  template_id=trigger.id,
258
- resource_data=resource_dict,
259
- user_id=user_id
260
+ resource_data=resource_dict
260
261
  )
261
262
 
263
+ # TODO(sam) figure out how to allow duplicates across users.....very unnatural constraint to have
262
264
  if existing_registration:
263
265
  raise HTTPException(
264
266
  status_code=400,
265
267
  detail=f"A registration with this configuration already exists for trigger type '{trigger.id}'. Registration ID: {existing_registration.get('id')}"
266
268
  )
267
269
 
268
- # Inject OAuth tokens if needed for registration
269
- auth_user = None
270
- if trigger.oauth_providers:
271
- try:
272
- auth_user = await self._get_authenticated_user(trigger, user_id)
273
-
274
- # Check if any provider requires authentication - return early if so
275
- for provider in trigger.oauth_providers.keys():
276
- provider_info = auth_user.providers.get(provider)
277
- if provider_info and provider_info.auth_required:
278
- logger.info(f"User {user_id} needs to authenticate for {provider} - returning auth URL")
279
- return {
280
- "success": True,
281
- "registered": False,
282
- "auth_required": True,
283
- "auth_url": provider_info.auth_url,
284
- "auth_id": provider_info.auth_id,
285
- "provider": provider
286
- }
287
-
288
- except Exception as e:
289
- logger.error(f"OAuth authentication failed during registration: {e}")
290
- raise HTTPException(status_code=500, detail="OAuth authentication failed")
291
-
292
270
 
293
271
  # Call the trigger's registration handler with parsed registration model
294
- result = await trigger.registration_handler(registration_instance, auth_user)
272
+ result = await trigger.registration_handler(user_id, self.langchain_auth_client, registration_instance)
273
+
274
+ # Check if handler requested to skip registration (e.g., for OAuth or URL verification)
275
+ if not result.create_registration:
276
+ logger.info(f"Registration handler requested to skip database creation")
277
+ from fastapi import Response
278
+ import json
279
+ return Response(
280
+ content=json.dumps(result.response_body),
281
+ status_code=result.status_code,
282
+ media_type="application/json"
283
+ )
284
+
295
285
  resource_dict = registration_instance.model_dump()
296
286
 
297
287
  registration = await self.database.create_trigger_registration(
@@ -304,6 +294,9 @@ class TriggerServer:
304
294
  if not registration:
305
295
  raise HTTPException(status_code=500, detail="Failed to create trigger registration")
306
296
 
297
+ # Reload cron manager to pick up any new cron registrations
298
+ await self.cron_manager.reload_from_database()
299
+
307
300
  # Return registration result
308
301
  return {
309
302
  "success": True,
@@ -314,7 +307,7 @@ class TriggerServer:
314
307
  except HTTPException:
315
308
  raise
316
309
  except Exception as e:
317
- logger.error(f"Error creating trigger registration: {e}")
310
+ logger.exception(f"Error creating trigger registration: {e}")
318
311
  raise HTTPException(status_code=500, detail=str(e))
319
312
 
320
313
  @self.app.get("/api/triggers/registrations/{registration_id}/agents")
@@ -418,16 +411,6 @@ class TriggerServer:
418
411
  except Exception as e:
419
412
  logger.error(f"Error unlinking agent from trigger: {e}")
420
413
  raise HTTPException(status_code=500, detail=str(e))
421
-
422
- @self.app.get("/events/subscriptions")
423
- async def list_event_subscriptions() -> Dict[str, Any]:
424
- """List event bus subscriptions."""
425
- if hasattr(self.event_bus, "list_subscriptions"):
426
- subscriptions = self.event_bus.list_subscriptions()
427
- else:
428
- subscriptions = {}
429
-
430
- return {"subscriptions": subscriptions}
431
414
 
432
415
 
433
416
  async def _handle_request(
@@ -437,24 +420,6 @@ class TriggerServer:
437
420
  ) -> Dict[str, Any]:
438
421
  """Handle an incoming request with a handler function."""
439
422
  try:
440
- # Step 1: API Key Authentication (required for webhooks)
441
- # Check for API key in header first, then query params (for Pub/Sub compatibility)
442
- api_key = request.headers.get("x-api-key") or request.query_params.get("api_key")
443
- if not api_key:
444
- logger.warning("Webhook request missing x-api-key header or api_key query parameter")
445
- raise HTTPException(
446
- status_code=401,
447
- detail="Missing x-api-key header or api_key query parameter"
448
- )
449
-
450
- # Validate API key and get user_id
451
- user_id = await self.database.validate_api_key(api_key)
452
- if not user_id:
453
- logger.warning("Invalid API key provided to webhook")
454
- raise HTTPException(
455
- status_code=401,
456
- detail="Invalid API key"
457
- )
458
423
 
459
424
  # Parse request data
460
425
  if request.method == "POST":
@@ -466,108 +431,35 @@ class TriggerServer:
466
431
  payload = {"raw_body": body.decode("utf-8") if body else ""}
467
432
  else:
468
433
  payload = dict(request.query_params)
469
-
470
- # Step 2: Registration resolution
471
- if not trigger.registration_resolver:
472
- raise HTTPException(
473
- status_code=500,
474
- detail=f"Trigger {trigger.id} missing required registration_resolver"
475
- )
476
-
477
- # Extract resource identifiers using resolver (gets both query params and payload)
478
- resource_data = await trigger.registration_resolver(payload, dict(request.query_params))
479
-
480
- # Find matching registration for the authenticated user
481
- # Convert Pydantic model to dict for database lookup
482
- resource_dict = resource_data.model_dump()
483
- registration = await self.database.find_registration_by_resource(
484
- trigger.id,
485
- resource_dict,
486
- user_id
487
- )
488
434
 
489
- if not registration:
490
- logger.warning(f"No registration found for user {user_id}, trigger_id={trigger.id} with resource={resource_dict}")
491
- raise HTTPException(
492
- status_code=400,
493
- detail=f"No registration found for {trigger.id} with resource {resource_dict}"
494
- )
495
-
496
- # Step 3: Inject OAuth tokens if needed
497
- auth_user = None
498
- if trigger.oauth_providers and self.langchain_auth_client:
499
- try:
500
- auth_user = await self._get_authenticated_user(trigger, user_id)
501
-
502
- # Check if any provider requires re-authentication - this shouldn't happen in webhooks
503
- for provider in trigger.oauth_providers.keys():
504
- provider_info = auth_user.providers.get(provider)
505
- if provider_info and provider_info.auth_required:
506
- logger.error(f"User {user_id} needs to re-authenticate for {provider} - this should have been handled during registration")
507
- return {
508
- "success": False,
509
- "error": f"Authentication required for {provider}",
510
- "message": "User needs to re-authenticate this trigger"
511
- }
512
-
513
- except Exception as e:
514
- logger.error(f"OAuth authentication failed: {e}")
515
- # Continue without auth - triggers can handle missing tokens
516
-
517
- # Step 4: Create metadata manager
518
- metadata_manager = MetadataManager(
519
- database=self.database,
520
- registration_id=registration["id"],
521
- initial_metadata=registration.get("metadata", {})
522
- )
523
-
524
- # Step 5: Call handler with parsed registration data
525
- result = await trigger.trigger_handler(payload, auth_user, metadata_manager)
526
- registration_id = registration["id"]
527
-
528
- # Check if we should invoke agents
435
+ query_params = dict(request.query_params)
436
+ result = await trigger.trigger_handler(payload, query_params, self.database, self.langchain_auth_client)
529
437
  if not result.invoke_agent:
530
- logger.info(f"Handler requested no agent invocation for registration {registration_id}")
531
- return {
532
- "success": True,
533
- "agents_invoked": 0
534
- }
535
-
536
- # Get agents linked to this trigger registration
438
+ return result.response_body
439
+
440
+ registration_id = result.registration["id"]
537
441
  agent_links = await self.database.get_agents_for_trigger(registration_id)
538
-
539
- if not agent_links:
540
- logger.info(f"No agents linked to registration {registration_id}")
541
- return {
542
- "success": True,
543
- "agents_invoked": 0
544
- }
545
-
546
- logger.info(f"Processing trigger result for registration {registration_id} with {len(agent_links)} linked agents")
547
-
548
- # Invoke each linked agent
442
+
549
443
  agents_invoked = 0
550
444
  for agent_link in agent_links:
551
445
  agent_id = agent_link if isinstance(agent_link, str) else agent_link.get("agent_id")
552
-
553
- # Use the data string from TriggerHandlerResult directly
446
+
554
447
  agent_input = {
555
448
  "messages": [
556
- {"role": "human", "content": result.data}
449
+ {"role": "human", "content": result.agent_message}
557
450
  ]
558
451
  }
559
452
 
560
453
  try:
561
454
  success = await self._invoke_agent(
562
455
  agent_id=agent_id,
563
- user_id=registration["user_id"],
456
+ user_id=result.registration["user_id"],
564
457
  input_data=agent_input,
565
458
  )
566
459
  if success:
567
460
  agents_invoked += 1
568
461
  except Exception as e:
569
462
  logger.error(f"Error invoking agent {agent_id}: {e}", exc_info=True)
570
-
571
463
  logger.info(f"Processed trigger handler, invoked {agents_invoked} agents")
572
464
 
573
465
  return {
@@ -595,12 +487,10 @@ class TriggerServer:
595
487
  logger.info(f"Invoking LangGraph agent {agent_id} for user {user_id}")
596
488
 
597
489
  try:
598
- # Build headers using the custom function
599
- headers = await self.langgraph_headers_builder(
600
- user_id=user_id,
601
- api_key=self.langgraph_api_key,
602
- agent_id=agent_id
603
- )
490
+ headers = {
491
+ "x-auth-scheme": "oap-trigger",
492
+ "x-supabase-user-id": user_id,
493
+ }
604
494
 
605
495
  thread = await self.langgraph_client.threads.create(
606
496
  metadata={
@@ -634,41 +524,6 @@ class TriggerServer:
634
524
  logger.error(f"Error invoking agent {agent_id}: {e}")
635
525
  raise
636
526
 
637
- async def _get_authenticated_user(self, trigger: TriggerTemplate, user_id: str) -> UserAuthInfo:
638
- """Get authenticated user with OAuth tokens for the trigger's required providers."""
639
- providers = {}
640
-
641
- # Get tokens for each required OAuth provider
642
- for provider, scopes in trigger.oauth_providers.items():
643
- try:
644
- auth_result = await self.langchain_auth_client.authenticate(
645
- provider=provider,
646
- scopes=scopes,
647
- user_id=user_id
648
- )
649
-
650
- # Debug logging
651
- logger.info(f"Auth result for {provider}: {vars(auth_result) if hasattr(auth_result, '__dict__') else 'Not available'}")
652
-
653
- if hasattr(auth_result, 'token') and auth_result.token:
654
- providers[provider] = ProviderAuthInfo(token=auth_result.token)
655
- logger.debug(f"Successfully got {provider} token for user {user_id}")
656
- elif hasattr(auth_result, 'auth_required') and auth_result.auth_required:
657
- logger.info(f"User {user_id} needs to authenticate for {provider}")
658
- providers[provider] = ProviderAuthInfo(
659
- auth_required=True,
660
- auth_url=getattr(auth_result, 'auth_url', None),
661
- auth_id=getattr(auth_result, 'auth_id', None)
662
- )
663
- else:
664
- logger.warning(f"No token returned for {provider} provider")
665
-
666
- except Exception as e:
667
- logger.error(f"Failed to get {provider} token: {e}")
668
- # Continue with other providers
669
-
670
- return UserAuthInfo(user_id=user_id, providers=providers)
671
-
672
527
  def get_app(self) -> FastAPI:
673
528
  """Get the FastAPI app instance."""
674
529
  return self.app
@@ -0,0 +1,78 @@
1
+ """Core types and interfaces for the triggers framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Dict, Optional
7
+ from pydantic import BaseModel, Field
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+
13
+ class ProviderAuthInfo(BaseModel):
14
+ """Authentication info for a specific OAuth provider."""
15
+
16
+ token: Optional[str] = None
17
+ auth_required: bool = False
18
+ auth_url: Optional[str] = None
19
+ auth_id: Optional[str] = None
20
+
21
+
22
+ class UserAuthInfo(BaseModel):
23
+ """User authentication info containing OAuth tokens or auth requirements."""
24
+
25
+ user_id: str
26
+ providers: Dict[str, ProviderAuthInfo] = Field(default_factory=dict)
27
+
28
+ class Config:
29
+ arbitrary_types_allowed = True
30
+
31
+
32
+
33
+
34
+ class AgentInvocationRequest(BaseModel):
35
+ """Request to invoke an AI agent."""
36
+
37
+ assistant_id: str
38
+ user_id: str
39
+ input_data: Any
40
+ thread_id: Optional[str] = None
41
+ metadata: Dict[str, Any] = Field(default_factory=dict)
42
+
43
+
44
+ class TriggerHandlerResult(BaseModel):
45
+ """Result returned by trigger handlers."""
46
+ invoke_agent: bool = Field(default=True, description="Whether to invoke agents for this event")
47
+ agent_message: Optional[str] = Field(default=None, description="String message to send to agents")
48
+ response_body: Optional[Dict[str, Any]] = Field(default=None, description="Custom HTTP response body (when invoke_agent=False)")
49
+ registration: Optional[Dict[str, Any]] = Field(default=None, description="Registration data (required when invoke_agent=True)")
50
+
51
+ def model_post_init(self, __context) -> None:
52
+ """Validate that required fields are provided based on invoke_agent."""
53
+ if self.invoke_agent and not self.agent_message:
54
+ raise ValueError("agent_message is required when invoke_agent=True")
55
+ if self.invoke_agent and not self.registration:
56
+ raise ValueError("registration is required when invoke_agent=True")
57
+ if not self.invoke_agent and not self.response_body:
58
+ raise ValueError("response_body is required when invoke_agent=False")
59
+
60
+
61
+ class TriggerRegistrationResult(BaseModel):
62
+ """Result returned by registration handlers."""
63
+ create_registration: bool = Field(default=True, description="Whether to create database registration (False = return custom response)")
64
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata to store with the registration")
65
+ response_body: Optional[Dict[str, Any]] = Field(default=None, description="Custom HTTP response body (when create_registration=False)")
66
+ status_code: Optional[int] = Field(default=None, description="HTTP status code (when create_registration=False)")
67
+
68
+ def model_post_init(self, __context) -> None:
69
+ """Validate that required fields are provided based on create_registration."""
70
+ if self.create_registration and not self.metadata:
71
+ self.metadata = {} # Allow empty metadata for create_registration=True
72
+ if not self.create_registration and (not self.response_body or not self.status_code):
73
+ raise ValueError("Both response_body and status_code are required when create_registration=False")
74
+
75
+
76
+ class TriggerRegistrationModel(BaseModel):
77
+ """Base class for trigger resource models that define how webhooks are matched to registrations."""
78
+ pass
@@ -0,0 +1,259 @@
1
+ """Dynamic Cron Trigger Manager for scheduled agent execution."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Dict, Any, Optional
6
+
7
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
8
+ from apscheduler.triggers.cron import CronTrigger as APSCronTrigger
9
+ from croniter import croniter
10
+ from pydantic import BaseModel
11
+
12
+ from langchain_triggers.core import TriggerHandlerResult
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CronJobExecution(BaseModel):
18
+ """Model for tracking cron job execution history."""
19
+
20
+ registration_id: str
21
+ cron_pattern: str
22
+ scheduled_time: datetime
23
+ actual_start_time: datetime
24
+ completion_time: Optional[datetime] = None
25
+ status: str # "running", "completed", "failed"
26
+ error_message: Optional[str] = None
27
+ agents_invoked: int = 0
28
+
29
+
30
+ class CronTriggerManager:
31
+ """Manages dynamic cron job scheduling based on database registrations."""
32
+
33
+ def __init__(self, trigger_server):
34
+ self.scheduler = AsyncIOScheduler(timezone='UTC')
35
+ self.trigger_server = trigger_server
36
+ self.active_jobs = {} # registration_id -> job_id mapping
37
+ self.execution_history = [] # Keep recent execution history
38
+ self.max_history = 1000
39
+
40
+ async def start(self):
41
+ """Start scheduler and load existing cron registrations."""
42
+ try:
43
+ self.scheduler.start()
44
+ await self._load_existing_registrations()
45
+ logger.info("✓ CronTriggerManager started")
46
+ except Exception as e:
47
+ logger.error(f"Failed to start CronTriggerManager: {e}")
48
+ raise
49
+
50
+ async def shutdown(self):
51
+ """Shutdown scheduler gracefully."""
52
+ try:
53
+ self.scheduler.shutdown(wait=True)
54
+ logger.info("✓ CronTriggerManager stopped")
55
+ except Exception as e:
56
+ logger.error(f"Error shutting down CronTriggerManager: {e}")
57
+
58
+ async def _load_existing_registrations(self):
59
+ """Load all existing cron registrations from database and schedule them."""
60
+ try:
61
+ registrations = await self.trigger_server.database.get_all_registrations("cron-trigger")
62
+
63
+ scheduled_count = 0
64
+ for registration in registrations:
65
+ if registration.get("status") == "active":
66
+ try:
67
+ await self._schedule_cron_job(registration)
68
+ scheduled_count += 1
69
+ except Exception as e:
70
+ logger.error(f"Failed to schedule existing cron job {registration.get('id')}: {e}")
71
+
72
+ logger.info(f"Loaded {scheduled_count} existing cron registrations from {len(registrations)} total")
73
+
74
+ except Exception as e:
75
+ logger.error(f"Failed to load existing cron registrations: {e}")
76
+
77
+ async def reload_from_database(self):
78
+ """Reload all cron registrations from database, replacing current schedules."""
79
+ try:
80
+ # Clear all current jobs
81
+ for registration_id in list(self.active_jobs.keys()):
82
+ await self._unschedule_cron_job(registration_id)
83
+
84
+ # Reload from database
85
+ await self._load_existing_registrations()
86
+ logger.info("✓ Reloaded cron jobs from database")
87
+
88
+ except Exception as e:
89
+ logger.error(f"Failed to reload cron jobs from database: {e}")
90
+ raise
91
+
92
+ async def on_registration_created(self, registration: Dict[str, Any]):
93
+ """Called when a new cron registration is created."""
94
+ if registration.get("trigger_template_id") == "cron-trigger":
95
+ try:
96
+ await self._schedule_cron_job(registration)
97
+ logger.info(f"Scheduled new cron job for registration {registration['id']}")
98
+ except Exception as e:
99
+ logger.error(f"Failed to schedule new cron job {registration['id']}: {e}")
100
+ raise
101
+
102
+ async def on_registration_deleted(self, registration_id: str):
103
+ """Called when a cron registration is deleted."""
104
+ try:
105
+ await self._unschedule_cron_job(registration_id)
106
+ logger.info(f"Unscheduled cron job for deleted registration {registration_id}")
107
+ except Exception as e:
108
+ logger.error(f"Failed to unschedule cron job {registration_id}: {e}")
109
+
110
+ async def _schedule_cron_job(self, registration: Dict[str, Any]):
111
+ """Add a cron job to the scheduler."""
112
+ registration_id = registration["id"]
113
+ resource_data = registration.get("resource", {})
114
+ crontab = resource_data.get("crontab", "")
115
+
116
+ if not crontab:
117
+ raise ValueError(f"No crontab pattern found in registration {registration_id}")
118
+
119
+ try:
120
+ # Parse cron expression
121
+ cron_parts = crontab.strip().split()
122
+ if len(cron_parts) != 5:
123
+ raise ValueError(f"Invalid cron format: {crontab} (expected 5 parts)")
124
+
125
+ minute, hour, day, month, day_of_week = cron_parts
126
+
127
+ # Create APScheduler cron trigger
128
+ trigger = APSCronTrigger(
129
+ minute=minute,
130
+ hour=hour,
131
+ day=day,
132
+ month=month,
133
+ day_of_week=day_of_week,
134
+ timezone='UTC'
135
+ )
136
+
137
+ # Schedule the job
138
+ job = self.scheduler.add_job(
139
+ self._execute_cron_job_with_monitoring,
140
+ trigger=trigger,
141
+ args=[registration],
142
+ id=f"cron_{registration_id}",
143
+ name=f"Cron job for registration {registration_id}",
144
+ max_instances=1, # Prevent overlapping executions
145
+ replace_existing=True
146
+ )
147
+
148
+ self.active_jobs[registration_id] = job.id
149
+ logger.info(f"✓ Scheduled cron job: '{crontab}' for registration {registration_id}")
150
+
151
+ except Exception as e:
152
+ logger.error(f"Failed to schedule cron job for registration {registration_id}: {e}")
153
+ raise
154
+
155
+ async def _unschedule_cron_job(self, registration_id: str):
156
+ """Remove a cron job from the scheduler."""
157
+ if registration_id in self.active_jobs:
158
+ job_id = self.active_jobs[registration_id]
159
+ try:
160
+ self.scheduler.remove_job(job_id)
161
+ del self.active_jobs[registration_id]
162
+ logger.info(f"✓ Unscheduled cron job for registration {registration_id}")
163
+ except Exception as e:
164
+ logger.error(f"Failed to unschedule cron job {job_id}: {e}")
165
+ raise
166
+ else:
167
+ logger.warning(f"Attempted to unschedule non-existent cron job {registration_id}")
168
+
169
+ async def _execute_cron_job_with_monitoring(self, registration: Dict[str, Any]):
170
+ """Execute a scheduled cron job with full monitoring and error handling."""
171
+ registration_id = registration["id"]
172
+ cron_pattern = registration["resource"]["crontab"]
173
+
174
+ execution = CronJobExecution(
175
+ registration_id=registration_id,
176
+ cron_pattern=cron_pattern,
177
+ scheduled_time=datetime.utcnow(),
178
+ actual_start_time=datetime.utcnow(),
179
+ status="running"
180
+ )
181
+
182
+ logger.info(f"🕐 Executing cron job {registration_id} with pattern '{cron_pattern}'")
183
+
184
+ try:
185
+ agents_invoked = await self._execute_cron_job(registration)
186
+ execution.status = "completed"
187
+ execution.agents_invoked = agents_invoked
188
+ logger.info(f"✓ Cron job {registration_id} completed - invoked {agents_invoked} agents")
189
+
190
+ except Exception as e:
191
+ execution.status = "failed"
192
+ execution.error_message = str(e)
193
+ logger.error(f"✗ Cron job {registration_id} failed: {e}")
194
+
195
+ finally:
196
+ execution.completion_time = datetime.utcnow()
197
+ await self._record_execution(execution)
198
+
199
+ async def _execute_cron_job(self, registration: Dict[str, Any]) -> int:
200
+ """Execute a scheduled cron job - invoke agents."""
201
+ registration_id = registration["id"]
202
+ user_id = registration["user_id"]
203
+
204
+ # Get agent links
205
+ agent_links = await self.trigger_server.database.get_agents_for_trigger(registration_id)
206
+
207
+ if not agent_links:
208
+ logger.warning(f"No agents linked to cron job {registration_id}")
209
+ return 0
210
+
211
+ agents_invoked = 0
212
+ for agent_link in agent_links:
213
+ agent_id = agent_link if isinstance(agent_link, str) else agent_link.get("agent_id")
214
+
215
+ agent_input = {
216
+ "messages": [
217
+ {"role": "human", "content": ""}
218
+ ]
219
+ }
220
+
221
+ try:
222
+ success = await self.trigger_server._invoke_agent(
223
+ agent_id=agent_id,
224
+ user_id=user_id,
225
+ input_data=agent_input,
226
+ )
227
+ if success:
228
+ agents_invoked += 1
229
+ logger.info(f"✓ Invoked agent {agent_id} for cron job {registration_id}")
230
+
231
+ except Exception as e:
232
+ logger.error(f"✗ Error invoking agent {agent_id} for cron job {registration_id}: {e}")
233
+
234
+ return agents_invoked
235
+
236
+ async def _record_execution(self, execution: CronJobExecution):
237
+ """Record execution history (in memory for now)."""
238
+ self.execution_history.append(execution)
239
+
240
+ # Keep only recent executions
241
+ if len(self.execution_history) > self.max_history:
242
+ self.execution_history = self.execution_history[-self.max_history:]
243
+
244
+ def get_active_jobs(self) -> Dict[str, str]:
245
+ """Get currently active cron jobs."""
246
+ return self.active_jobs.copy()
247
+
248
+ def get_execution_history(self, limit: int = 100) -> list[CronJobExecution]:
249
+ """Get recent execution history."""
250
+ return self.execution_history[-limit:]
251
+
252
+ def get_job_status(self) -> Dict[str, Any]:
253
+ """Get status information about the cron manager."""
254
+ return {
255
+ "active_jobs": len(self.active_jobs),
256
+ "scheduler_running": self.scheduler.running,
257
+ "total_executions": len(self.execution_history),
258
+ "active_job_ids": list(self.active_jobs.keys())
259
+ }
@@ -61,10 +61,14 @@ class TriggerDatabaseInterface(ABC):
61
61
  async def find_registration_by_resource(
62
62
  self,
63
63
  template_id: str,
64
- resource_data: Dict[str, Any],
65
- user_id: str
64
+ resource_data: Dict[str, Any]
66
65
  ) -> Optional[Dict[str, Any]]:
67
- """Find trigger registration by matching resource data for a specific user."""
66
+ """Find trigger registration by matching resource data."""
67
+ pass
68
+
69
+ @abstractmethod
70
+ async def get_all_registrations(self, template_id: str) -> List[Dict[str, Any]]:
71
+ """Get all registrations for a specific trigger template."""
68
72
  pass
69
73
 
70
74
  @abstractmethod
@@ -130,7 +134,3 @@ class TriggerDatabaseInterface(ABC):
130
134
  """Get user ID by email from trigger registrations."""
131
135
  pass
132
136
 
133
- @abstractmethod
134
- async def validate_api_key(self, api_key: str) -> Optional[str]:
135
- """Validate API key and return user_id if valid, None if invalid."""
136
- pass
@@ -143,7 +143,7 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
143
143
  return response.data[0] if response.data else None
144
144
 
145
145
  except Exception as e:
146
- logger.error(f"Error creating trigger registration: {e}")
146
+ logger.exception(f"Error creating trigger registration: {e}")
147
147
  return None
148
148
 
149
149
  async def get_user_trigger_registrations(self, user_id: str) -> List[Dict[str, Any]]:
@@ -225,15 +225,14 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
225
225
  async def find_registration_by_resource(
226
226
  self,
227
227
  template_id: str,
228
- resource_data: Dict[str, Any],
229
- user_id: str
228
+ resource_data: Dict[str, Any]
230
229
  ) -> Optional[Dict[str, Any]]:
231
- """Find trigger registration by matching resource data for a specific user."""
230
+ """Find trigger registration by matching resource data."""
232
231
  try:
233
- # Build query to match against trigger_registrations with template_id and user_id filters
232
+ # Build query to match against trigger_registrations with template_id filter
234
233
  query = self.client.table("trigger_registrations").select(
235
234
  "*, trigger_templates(id, name, description)"
236
- ).eq("trigger_templates.id", template_id).eq("user_id", user_id)
235
+ ).eq("trigger_templates.id", template_id)
237
236
 
238
237
  # Add resource field matches
239
238
  for field, value in resource_data.items():
@@ -249,6 +248,18 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
249
248
  logger.error(f"Error finding registration by resource: {e}")
250
249
  return None
251
250
 
251
+ async def get_all_registrations(self, template_id: str) -> List[Dict[str, Any]]:
252
+ """Get all registrations for a specific trigger template."""
253
+ try:
254
+ response = self.client.table("trigger_registrations").select(
255
+ "*, trigger_templates(id, name, description)"
256
+ ).eq("template_id", template_id).execute()
257
+
258
+ return response.data or []
259
+ except Exception as e:
260
+ logger.error(f"Error getting all registrations for template {template_id}: {e}")
261
+ return []
262
+
252
263
  # ========== Agent-Trigger Links ==========
253
264
 
254
265
  async def link_agent_to_trigger(
@@ -346,30 +357,3 @@ class SupabaseTriggerDatabase(TriggerDatabaseInterface):
346
357
  logger.error(f"Error getting user by email: {e}")
347
358
  return None
348
359
 
349
- async def validate_api_key(self, api_key: str) -> Optional[str]:
350
- """Validate API key and return user_id if valid, None if invalid."""
351
- try:
352
- # Query all user API keys to find a match
353
- response = self.client.table("user_api_keys").select("user_id, key_hash").execute()
354
-
355
- if not response.data:
356
- return None
357
-
358
- # Check each encrypted key to see if it matches the provided key
359
- for row in response.data:
360
- try:
361
- decrypted_key = self._decrypt_secret(row["key_hash"])
362
- if decrypted_key == api_key:
363
- logger.info(f"Valid API key authenticated for user: {row['user_id']}")
364
- return row["user_id"]
365
- except Exception as e:
366
- # Skip keys that fail to decrypt
367
- logger.debug(f"Failed to decrypt API key: {e}")
368
- continue
369
-
370
- logger.warning(f"Invalid API key provided")
371
- return None
372
-
373
- except Exception as e:
374
- logger.error(f"Error validating API key: {e}")
375
- return None
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import inspect
6
- from typing import Any, Awaitable, Callable, Dict, List, Optional, Type, get_type_hints
7
- from .core import UserAuthInfo, TriggerRegistrationModel, TriggerHandlerResult, TriggerRegistrationResult, MetadataManager
6
+ from typing import Any, Dict, List, Type, get_type_hints
7
+ from langchain_auth.client import Client
8
+ from .core import TriggerHandlerResult, TriggerRegistrationResult
8
9
  from pydantic import BaseModel
9
10
 
10
11
  class TriggerTemplate:
@@ -19,9 +20,6 @@ class TriggerTemplate:
19
20
 
20
21
  registration_handler,
21
22
  trigger_handler,
22
- registration_resolver,
23
-
24
- oauth_providers: Optional[Dict[str, List[str]]] = None,
25
23
  ):
26
24
  self.id = id
27
25
  self.name = name
@@ -29,21 +27,17 @@ class TriggerTemplate:
29
27
  self.registration_model = registration_model
30
28
  self.registration_handler = registration_handler
31
29
  self.trigger_handler = trigger_handler
32
- self.registration_resolver = registration_resolver
33
- self.oauth_providers = oauth_providers or {}
34
30
 
35
31
  self._validate_handler_signatures()
36
32
 
37
33
  def _validate_handler_signatures(self):
38
34
  """Validate that all handler functions have the correct signatures."""
39
- # Expected: async def handler(registration: RegistrationModel, auth_user: UserAuthInfo) -> TriggerRegistrationResult
40
- self._validate_handler("registration_handler", self.registration_handler, [self.registration_model, UserAuthInfo], TriggerRegistrationResult)
35
+ # Expected: async def handler(user_id: str, auth_client: Client, registration: RegistrationModel) -> TriggerRegistrationResult
36
+ self._validate_handler("registration_handler", self.registration_handler, [str, Client, self.registration_model], TriggerRegistrationResult)
41
37
 
42
- # Expected: async def handler(payload: Dict[str, Any], auth_user: UserAuthInfo, metadata: MetadataManager) -> TriggerHandlerResult
43
- self._validate_handler("trigger_handler", self.trigger_handler, [Dict[str, Any], UserAuthInfo, MetadataManager], TriggerHandlerResult)
38
+ # Expected: async def handler(payload: Dict[str, Any], query_params: Dict[str, str], database, auth_client: Client) -> TriggerHandlerResult
39
+ self._validate_handler("trigger_handler", self.trigger_handler, [Dict[str, Any], Dict[str, str], Any, Client], TriggerHandlerResult)
44
40
 
45
- # Expected: async def resolver(payload: Dict[str, Any], query_params: Dict[str, str]) -> RegistrationModel
46
- self._validate_handler("registration_resolver", self.registration_resolver, [Dict[str, Any], Dict[str, str]], self.registration_model)
47
41
 
48
42
  def _validate_handler(self, handler_name: str, handler_func, expected_types: List[Type], expected_return_type: Type = None):
49
43
  """Common validation logic for all handler functions."""
@@ -0,0 +1,7 @@
1
+ """Built-in trigger templates for the LangChain Triggers Framework."""
2
+
3
+ from .cron_trigger import cron_trigger
4
+
5
+ __all__ = [
6
+ "cron_trigger",
7
+ ]
@@ -0,0 +1,97 @@
1
+ """Cron-based trigger for scheduled agent execution."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Dict, Any
6
+
7
+ from croniter import croniter
8
+ from langchain_auth.client import Client
9
+ from pydantic import Field
10
+
11
+ from langchain_triggers.core import (
12
+ TriggerRegistrationModel,
13
+ TriggerHandlerResult,
14
+ TriggerRegistrationResult,
15
+ )
16
+ from langchain_triggers.decorators import TriggerTemplate
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class CronRegistration(TriggerRegistrationModel):
22
+ """Registration model for cron triggers - just a crontab pattern."""
23
+
24
+ crontab: str = Field(
25
+ ...,
26
+ description="Cron pattern (e.g., '0 9 * * MON-FRI', '*/15 * * * *')",
27
+ examples=["0 9 * * MON-FRI", "*/15 * * * *", "0 2 * * SUN"],
28
+ )
29
+
30
+
31
+ async def cron_registration_handler(
32
+ user_id: str, auth_client: Client, registration: CronRegistration
33
+ ) -> TriggerRegistrationResult:
34
+ """Handle cron trigger registration - validates cron pattern and prepares for scheduling."""
35
+ logger.info(f"Cron registration request: {registration}")
36
+
37
+ cron_pattern = registration.crontab.strip()
38
+
39
+ # Validate cron pattern
40
+ try:
41
+ if not croniter.is_valid(cron_pattern):
42
+ return TriggerRegistrationResult(
43
+ create_registration=False,
44
+ response_body={
45
+ "success": False,
46
+ "error": "invalid_cron_pattern",
47
+ "message": f"Invalid cron pattern: '{cron_pattern}'"
48
+ },
49
+ status_code=400
50
+ )
51
+ except Exception as e:
52
+ return TriggerRegistrationResult(
53
+ create_registration=False,
54
+ response_body={
55
+ "success": False,
56
+ "error": "cron_validation_failed",
57
+ "message": f"Failed to validate cron pattern: {str(e)}"
58
+ },
59
+ status_code=400
60
+ )
61
+
62
+ logger.info(f"Successfully validated cron pattern: {cron_pattern}")
63
+ return TriggerRegistrationResult(
64
+ metadata={
65
+ "cron_pattern": cron_pattern,
66
+ "timezone": "UTC",
67
+ "created_at": datetime.utcnow().isoformat(),
68
+ "validated": True
69
+ }
70
+ )
71
+
72
+
73
+ async def cron_trigger_handler(
74
+ payload: Dict[str, Any],
75
+ query_params: Dict[str, str],
76
+ database,
77
+ auth_client: Client,
78
+ ) -> TriggerHandlerResult:
79
+ """Cron trigger handler - this should never be called via HTTP."""
80
+ logger.warning("Cron trigger handler called via HTTP - this shouldn't happen")
81
+ return TriggerHandlerResult(
82
+ invoke_agent=False,
83
+ response_body={
84
+ "success": False,
85
+ "message": "Cron triggers are executed by scheduler, not HTTP requests"
86
+ }
87
+ )
88
+
89
+
90
+ cron_trigger = TriggerTemplate(
91
+ id="cron-trigger",
92
+ name="Cron Scheduler",
93
+ description="Triggers agents on a cron schedule",
94
+ registration_model=CronRegistration,
95
+ registration_handler=cron_registration_handler,
96
+ trigger_handler=cron_trigger_handler,
97
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-trigger-server"
7
- version = "0.1.8"
7
+ version = "0.1.10"
8
8
  description = "Generic event-driven triggers framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -33,6 +33,8 @@ dependencies = [
33
33
  "langgraph-sdk>=0.2.6",
34
34
  "supabase>=2.0.0",
35
35
  "cryptography>=3.0.0",
36
+ "APScheduler>=3.10.0",
37
+ "croniter>=1.4.0",
36
38
  ]
37
39
 
38
40
  [project.optional-dependencies]
@@ -1,83 +0,0 @@
1
- """Core types and interfaces for the triggers framework."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any, Dict, Optional
6
- from pydantic import BaseModel, Field
7
-
8
-
9
-
10
- class ProviderAuthInfo(BaseModel):
11
- """Authentication info for a specific OAuth provider."""
12
-
13
- token: Optional[str] = None
14
- auth_required: bool = False
15
- auth_url: Optional[str] = None
16
- auth_id: Optional[str] = None
17
-
18
-
19
- class UserAuthInfo(BaseModel):
20
- """User authentication info containing OAuth tokens or auth requirements."""
21
-
22
- user_id: str
23
- providers: Dict[str, ProviderAuthInfo] = Field(default_factory=dict)
24
-
25
- class Config:
26
- arbitrary_types_allowed = True
27
-
28
-
29
- class MetadataManager:
30
- """Manages trigger registration metadata with database persistence."""
31
-
32
- def __init__(self, database: Any, registration_id: str, initial_metadata: Dict[str, Any]):
33
- self.database = database
34
- self.registration_id = registration_id
35
- self.metadata = initial_metadata.copy()
36
-
37
- def get(self, key: str, default: Any = None) -> Any:
38
- """Get a metadata value by key."""
39
- return self.metadata.get(key, default)
40
-
41
- async def update(self, updates: Dict[str, Any]) -> None:
42
- """Update metadata and persist to database."""
43
- # Update local state
44
- self.metadata.update(updates)
45
-
46
- # Persist to database
47
- await self.database.update_trigger_metadata(self.registration_id, updates)
48
-
49
-
50
-
51
- class AgentInvocationRequest(BaseModel):
52
- """Request to invoke an AI agent."""
53
-
54
- assistant_id: str
55
- user_id: str
56
- input_data: Any
57
- thread_id: Optional[str] = None
58
- metadata: Dict[str, Any] = Field(default_factory=dict)
59
-
60
-
61
- class TriggerHandlerResult(BaseModel):
62
- """Result returned by trigger handlers."""
63
-
64
- invoke_agent: bool = Field(default=True, description="Whether to invoke agents for this event")
65
- data: Optional[str] = Field(default=None, description="String data to send to agents")
66
-
67
- def model_post_init(self, __context) -> None:
68
- """Validate that data is provided when invoke_agent is True."""
69
- if self.invoke_agent and not self.data:
70
- raise ValueError("data field is required when invoke_agent is True")
71
-
72
-
73
- class TriggerRegistrationResult(BaseModel):
74
- """Result returned by registration handlers."""
75
-
76
- metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata to store with the registration")
77
-
78
-
79
- class TriggerRegistrationModel(BaseModel):
80
- """Base class for trigger resource models that define how webhooks are matched to registrations."""
81
-
82
- class Config:
83
- arbitrary_types_allowed = True