langchain-trigger-server 0.3.22__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.
- langchain_trigger_server-0.3.22.dist-info/METADATA +38 -0
- langchain_trigger_server-0.3.22.dist-info/RECORD +14 -0
- langchain_trigger_server-0.3.22.dist-info/WHEEL +4 -0
- langchain_triggers/__init__.py +22 -0
- langchain_triggers/app.py +676 -0
- langchain_triggers/auth/__init__.py +3 -0
- langchain_triggers/core.py +59 -0
- langchain_triggers/cron_manager.py +329 -0
- langchain_triggers/database/__init__.py +5 -0
- langchain_triggers/database/interface.py +107 -0
- langchain_triggers/decorators.py +141 -0
- langchain_triggers/triggers/__init__.py +7 -0
- langchain_triggers/triggers/cron_trigger.py +177 -0
- langchain_triggers/util.py +122 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Cron-based trigger for scheduled agent execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from croniter import croniter
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
from langgraph_sdk import get_client
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from langchain_triggers.core import (
|
|
14
|
+
TriggerRegistrationModel,
|
|
15
|
+
TriggerRegistrationResult,
|
|
16
|
+
TriggerType,
|
|
17
|
+
)
|
|
18
|
+
from langchain_triggers.decorators import TriggerTemplate
|
|
19
|
+
from langchain_triggers.util import (
|
|
20
|
+
create_service_auth_headers,
|
|
21
|
+
get_langgraph_url,
|
|
22
|
+
is_assistant_triggers_paused,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Global constant for cron trigger ID (UUID format to match database schema)
|
|
28
|
+
CRON_TRIGGER_ID = "c809e66e-0000-4000-8000-000000000001"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CronRegistration(TriggerRegistrationModel):
|
|
32
|
+
"""Registration model for cron triggers - just a crontab pattern."""
|
|
33
|
+
|
|
34
|
+
crontab: str = Field(
|
|
35
|
+
...,
|
|
36
|
+
description="Cron pattern (e.g., '0 9 * * MON-FRI', '*/15 * * * *')",
|
|
37
|
+
examples=["0 9 * * MON-FRI", "*/15 * * * *", "0 2 * * SUN"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def cron_registration_handler(
|
|
42
|
+
request: Request, user_id: str, registration: CronRegistration
|
|
43
|
+
) -> TriggerRegistrationResult:
|
|
44
|
+
"""Handle cron trigger registration - validates cron pattern and prepares for scheduling."""
|
|
45
|
+
logger.info(f"Cron registration request: {registration}")
|
|
46
|
+
|
|
47
|
+
cron_pattern = registration.crontab.strip()
|
|
48
|
+
|
|
49
|
+
# Validate cron pattern
|
|
50
|
+
try:
|
|
51
|
+
if not croniter.is_valid(cron_pattern):
|
|
52
|
+
return TriggerRegistrationResult(
|
|
53
|
+
create_registration=False,
|
|
54
|
+
response_body={
|
|
55
|
+
"success": False,
|
|
56
|
+
"error": "invalid_cron_pattern",
|
|
57
|
+
"message": f"Invalid cron pattern: '{cron_pattern}'",
|
|
58
|
+
},
|
|
59
|
+
status_code=400,
|
|
60
|
+
)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return TriggerRegistrationResult(
|
|
63
|
+
create_registration=False,
|
|
64
|
+
response_body={
|
|
65
|
+
"success": False,
|
|
66
|
+
"error": "cron_validation_failed",
|
|
67
|
+
"message": f"Failed to validate cron pattern: {str(e)}",
|
|
68
|
+
},
|
|
69
|
+
status_code=400,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
logger.info(f"Successfully validated cron pattern: {cron_pattern}")
|
|
73
|
+
return TriggerRegistrationResult(
|
|
74
|
+
metadata={
|
|
75
|
+
"cron_pattern": cron_pattern,
|
|
76
|
+
"timezone": "UTC",
|
|
77
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
78
|
+
"validated": True,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def cron_poll_handler(
|
|
84
|
+
registration: dict[str, Any],
|
|
85
|
+
database,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""Polling handler for generic cron - invokes agents directly."""
|
|
88
|
+
registration_id = registration["id"]
|
|
89
|
+
user_id = str(registration["user_id"])
|
|
90
|
+
tenant_id = str(registration.get("tenant_id", ""))
|
|
91
|
+
organization_id = str(registration.get("organization_id", ""))
|
|
92
|
+
langgraph_url = get_langgraph_url(registration)
|
|
93
|
+
|
|
94
|
+
agent_links = await database.get_agents_for_trigger(registration_id)
|
|
95
|
+
|
|
96
|
+
if not agent_links:
|
|
97
|
+
logger.info(f"cron_no_linked_agents registration_id={registration_id}")
|
|
98
|
+
return {"success": True, "message": "No linked agents", "agents_invoked": 0}
|
|
99
|
+
|
|
100
|
+
client = get_client(url=langgraph_url, api_key=None)
|
|
101
|
+
headers = create_service_auth_headers(user_id, tenant_id, organization_id)
|
|
102
|
+
|
|
103
|
+
current_time = datetime.utcnow()
|
|
104
|
+
current_time_str = current_time.strftime("%A, %B %d, %Y at %H:%M UTC")
|
|
105
|
+
|
|
106
|
+
agents_invoked = 0
|
|
107
|
+
for agent_link in agent_links:
|
|
108
|
+
agent_id = str(
|
|
109
|
+
agent_link if isinstance(agent_link, str) else agent_link.get("agent_id")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
paused = await is_assistant_triggers_paused(client, agent_id, headers)
|
|
113
|
+
if paused:
|
|
114
|
+
logger.info(
|
|
115
|
+
f"cron_triggers_paused_skip agent_id={agent_id} registration_id={registration_id}"
|
|
116
|
+
)
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
thread_id = str(uuid.uuid4())
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
thread_result = await client.threads.create(
|
|
124
|
+
thread_id=thread_id,
|
|
125
|
+
if_exists="do_nothing",
|
|
126
|
+
metadata={
|
|
127
|
+
"triggered_by": "cron-trigger",
|
|
128
|
+
"user_id": user_id,
|
|
129
|
+
"tenant_id": tenant_id,
|
|
130
|
+
"registration_id": str(registration_id),
|
|
131
|
+
},
|
|
132
|
+
headers=headers,
|
|
133
|
+
)
|
|
134
|
+
logger.info(
|
|
135
|
+
f"cron_thread_create_success thread_id={thread_id} result={thread_result}"
|
|
136
|
+
)
|
|
137
|
+
except Exception as thread_err:
|
|
138
|
+
logger.exception(
|
|
139
|
+
f"cron_thread_create_failed thread_id={thread_id} error={str(thread_err)}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
await client.runs.create(
|
|
143
|
+
thread_id,
|
|
144
|
+
agent_id,
|
|
145
|
+
input={
|
|
146
|
+
"messages": [
|
|
147
|
+
{
|
|
148
|
+
"role": "human",
|
|
149
|
+
"content": f"Cron trigger fired at {current_time_str}",
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
headers=headers,
|
|
154
|
+
metadata={"source": "trigger"},
|
|
155
|
+
)
|
|
156
|
+
logger.info(
|
|
157
|
+
f"cron_run_ok registration_id={registration_id} agent_id={agent_id} thread_id={thread_id}"
|
|
158
|
+
)
|
|
159
|
+
agents_invoked += 1
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.exception(
|
|
162
|
+
f"cron_run_err registration_id={registration_id} agent_id={agent_id} error={str(e)}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return {"success": True, "agents_invoked": agents_invoked}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
cron_trigger = TriggerTemplate(
|
|
169
|
+
id=CRON_TRIGGER_ID,
|
|
170
|
+
description="Triggers agents on a predetermined schedule",
|
|
171
|
+
registration_model=CronRegistration,
|
|
172
|
+
registration_handler=cron_registration_handler,
|
|
173
|
+
trigger_type=TriggerType.POLLING,
|
|
174
|
+
poll_handler=cron_poll_handler,
|
|
175
|
+
display_name="Cron",
|
|
176
|
+
integration=None,
|
|
177
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Utility functions for trigger handlers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
from langgraph_sdk.client import LangGraphClient
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_x_service_jwt_token(
|
|
15
|
+
payload: dict[str, Any] | None = None, expiration_seconds: int = 60 * 60
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Create X-Service-Key JWT token for service-to-service authentication.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
payload: Optional payload to include in JWT
|
|
21
|
+
expiration_seconds: Token expiration time in seconds (default 1 hour)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
JWT token string
|
|
25
|
+
"""
|
|
26
|
+
exp_datetime = datetime.now(tz=UTC) + timedelta(seconds=expiration_seconds)
|
|
27
|
+
exp = int(exp_datetime.timestamp())
|
|
28
|
+
|
|
29
|
+
payload = payload or {}
|
|
30
|
+
payload = {
|
|
31
|
+
"sub": "unspecified",
|
|
32
|
+
"exp": exp,
|
|
33
|
+
**payload,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
secret = os.environ["X_SERVICE_AUTH_JWT_SECRET"]
|
|
37
|
+
|
|
38
|
+
return jwt.encode(
|
|
39
|
+
payload,
|
|
40
|
+
secret,
|
|
41
|
+
algorithm="HS256",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_service_auth_headers(
|
|
46
|
+
user_id: str,
|
|
47
|
+
tenant_id: str,
|
|
48
|
+
organization_id: str,
|
|
49
|
+
) -> dict[str, str]:
|
|
50
|
+
"""Create authentication headers with X-Service-Key JWT token.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
user_id: User ID for the request
|
|
54
|
+
tenant_id: Tenant ID for the request
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dictionary of authentication headers
|
|
58
|
+
"""
|
|
59
|
+
headers = {
|
|
60
|
+
"x-api-key": "",
|
|
61
|
+
"x-auth-scheme": "langsmith-agent",
|
|
62
|
+
"x-user-id": user_id,
|
|
63
|
+
"x-tenant-id": tenant_id,
|
|
64
|
+
"x-organization-id": organization_id,
|
|
65
|
+
"x-service-key": get_x_service_jwt_token(
|
|
66
|
+
payload={
|
|
67
|
+
"tenant_id": tenant_id,
|
|
68
|
+
"user_id": user_id,
|
|
69
|
+
}
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return headers
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_langgraph_url(registration: dict[str, Any]) -> str:
|
|
77
|
+
"""Get the LangGraph API URL for a given registration
|
|
78
|
+
by comparing the registration's organization ID to the
|
|
79
|
+
LANGCHAIN_ORGANIZATION_ID setting."""
|
|
80
|
+
reg_organization_id = str(registration.get("organization_id"))
|
|
81
|
+
|
|
82
|
+
langgraph_api_url = os.getenv("LANGGRAPH_API_URL", "http://localhost:2024")
|
|
83
|
+
langgraph_api_url_public = os.getenv(
|
|
84
|
+
"LANGGRAPH_API_URL_PUBLIC", "http://localhost:2024"
|
|
85
|
+
)
|
|
86
|
+
langchain_organization_id = os.getenv(
|
|
87
|
+
"LANGCHAIN_ORGANIZATION_ID", "f5c798a2-2155-4999-ad27-6d466bd26e1c"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
langgraph_api_url
|
|
92
|
+
if reg_organization_id == langchain_organization_id
|
|
93
|
+
else langgraph_api_url_public
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def is_assistant_triggers_paused(
|
|
98
|
+
client: LangGraphClient, agent_id: str, headers: dict[str, str] | None = None
|
|
99
|
+
) -> bool:
|
|
100
|
+
"""Check if triggers are paused for the given assistant.
|
|
101
|
+
|
|
102
|
+
Looks for a boolean `triggers_paused` under `assistant.config.configurable`.
|
|
103
|
+
If the assistant cannot be fetched or the field is absent, returns False.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
assistant = await client.assistants.get(agent_id, headers=headers)
|
|
107
|
+
if not assistant:
|
|
108
|
+
logger.warning(
|
|
109
|
+
f"assistant_triggers_paused_check_failed agent_id={agent_id} error=assistant_not_found"
|
|
110
|
+
)
|
|
111
|
+
return False
|
|
112
|
+
config = assistant.get("config") or {}
|
|
113
|
+
configurable = config.get("configurable") or {}
|
|
114
|
+
paused = bool(configurable.get("triggers_paused", False))
|
|
115
|
+
if paused:
|
|
116
|
+
logger.debug(f"assistant_triggers_paused agent_id={agent_id} paused=True")
|
|
117
|
+
return paused
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.warning(
|
|
120
|
+
f"assistant_triggers_paused_check_failed agent_id={agent_id} error={str(e)}"
|
|
121
|
+
)
|
|
122
|
+
return False
|