django-agent-runtime 0.3.6__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.
- django_agent_runtime/__init__.py +25 -0
- django_agent_runtime/admin.py +155 -0
- django_agent_runtime/api/__init__.py +26 -0
- django_agent_runtime/api/permissions.py +109 -0
- django_agent_runtime/api/serializers.py +114 -0
- django_agent_runtime/api/views.py +472 -0
- django_agent_runtime/apps.py +26 -0
- django_agent_runtime/conf.py +241 -0
- django_agent_runtime/examples/__init__.py +10 -0
- django_agent_runtime/examples/langgraph_adapter.py +164 -0
- django_agent_runtime/examples/langgraph_tools.py +179 -0
- django_agent_runtime/examples/simple_chat.py +69 -0
- django_agent_runtime/examples/tool_agent.py +157 -0
- django_agent_runtime/management/__init__.py +2 -0
- django_agent_runtime/management/commands/__init__.py +2 -0
- django_agent_runtime/management/commands/runagent.py +419 -0
- django_agent_runtime/migrations/0001_initial.py +117 -0
- django_agent_runtime/migrations/0002_persistence_models.py +129 -0
- django_agent_runtime/migrations/0003_persistenceconversation_active_branch_id_and_more.py +212 -0
- django_agent_runtime/migrations/0004_add_anonymous_session_id.py +18 -0
- django_agent_runtime/migrations/__init__.py +2 -0
- django_agent_runtime/models/__init__.py +54 -0
- django_agent_runtime/models/base.py +450 -0
- django_agent_runtime/models/concrete.py +146 -0
- django_agent_runtime/persistence/__init__.py +60 -0
- django_agent_runtime/persistence/helpers.py +148 -0
- django_agent_runtime/persistence/models.py +506 -0
- django_agent_runtime/persistence/stores.py +1191 -0
- django_agent_runtime/runtime/__init__.py +23 -0
- django_agent_runtime/runtime/events/__init__.py +65 -0
- django_agent_runtime/runtime/events/base.py +135 -0
- django_agent_runtime/runtime/events/db.py +129 -0
- django_agent_runtime/runtime/events/redis.py +228 -0
- django_agent_runtime/runtime/events/sync.py +140 -0
- django_agent_runtime/runtime/interfaces.py +475 -0
- django_agent_runtime/runtime/llm/__init__.py +91 -0
- django_agent_runtime/runtime/llm/anthropic.py +249 -0
- django_agent_runtime/runtime/llm/litellm_adapter.py +173 -0
- django_agent_runtime/runtime/llm/openai.py +230 -0
- django_agent_runtime/runtime/queue/__init__.py +75 -0
- django_agent_runtime/runtime/queue/base.py +158 -0
- django_agent_runtime/runtime/queue/postgres.py +248 -0
- django_agent_runtime/runtime/queue/redis_streams.py +336 -0
- django_agent_runtime/runtime/queue/sync.py +277 -0
- django_agent_runtime/runtime/registry.py +186 -0
- django_agent_runtime/runtime/runner.py +540 -0
- django_agent_runtime/runtime/tracing/__init__.py +48 -0
- django_agent_runtime/runtime/tracing/langfuse.py +117 -0
- django_agent_runtime/runtime/tracing/noop.py +36 -0
- django_agent_runtime/urls.py +39 -0
- django_agent_runtime-0.3.6.dist-info/METADATA +723 -0
- django_agent_runtime-0.3.6.dist-info/RECORD +55 -0
- django_agent_runtime-0.3.6.dist-info/WHEEL +5 -0
- django_agent_runtime-0.3.6.dist-info/licenses/LICENSE +22 -0
- django_agent_runtime-0.3.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base API views for agent runtime.
|
|
3
|
+
|
|
4
|
+
These are abstract base classes - inherit from them in your project
|
|
5
|
+
and set your own authentication_classes and permission_classes.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from django_agent_runtime.api.views import BaseAgentRunViewSet
|
|
9
|
+
from myapp.permissions import MyPermission
|
|
10
|
+
|
|
11
|
+
class AgentRunViewSet(BaseAgentRunViewSet):
|
|
12
|
+
permission_classes = [MyPermission]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
from django.http import StreamingHttpResponse
|
|
20
|
+
from rest_framework import viewsets, status
|
|
21
|
+
from rest_framework.decorators import action
|
|
22
|
+
from rest_framework.response import Response
|
|
23
|
+
|
|
24
|
+
from django_agent_runtime.models import AgentRun, AgentConversation, AgentEvent
|
|
25
|
+
from django_agent_runtime.models.base import RunStatus
|
|
26
|
+
from django_agent_runtime.api.serializers import (
|
|
27
|
+
AgentRunSerializer,
|
|
28
|
+
AgentRunCreateSerializer,
|
|
29
|
+
AgentRunDetailSerializer,
|
|
30
|
+
AgentConversationSerializer,
|
|
31
|
+
AgentEventSerializer,
|
|
32
|
+
)
|
|
33
|
+
from django_agent_runtime.api.permissions import get_anonymous_session
|
|
34
|
+
from django_agent_runtime.conf import runtime_settings, get_hook
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BaseAgentConversationViewSet(viewsets.ModelViewSet):
|
|
38
|
+
"""
|
|
39
|
+
Base ViewSet for managing agent conversations.
|
|
40
|
+
|
|
41
|
+
Inherit from this and set your own permission_classes and authentication_classes.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
serializer_class = AgentConversationSerializer
|
|
45
|
+
|
|
46
|
+
def get_queryset(self):
|
|
47
|
+
"""Filter conversations by user or anonymous session."""
|
|
48
|
+
if self.request.user and self.request.user.is_authenticated:
|
|
49
|
+
return AgentConversation.objects.filter(user=self.request.user)
|
|
50
|
+
|
|
51
|
+
# For anonymous sessions, filter by anonymous_session_id
|
|
52
|
+
session = get_anonymous_session(self.request)
|
|
53
|
+
if session:
|
|
54
|
+
return AgentConversation.objects.filter(anonymous_session_id=session.id)
|
|
55
|
+
|
|
56
|
+
return AgentConversation.objects.none()
|
|
57
|
+
|
|
58
|
+
def perform_create(self, serializer):
|
|
59
|
+
"""Set user or anonymous session on creation."""
|
|
60
|
+
if self.request.user and self.request.user.is_authenticated:
|
|
61
|
+
serializer.save(user=self.request.user)
|
|
62
|
+
else:
|
|
63
|
+
session = get_anonymous_session(self.request)
|
|
64
|
+
if session:
|
|
65
|
+
# Use the setter which handles both FK and UUID field
|
|
66
|
+
serializer.save(anonymous_session=session)
|
|
67
|
+
else:
|
|
68
|
+
serializer.save()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BaseAgentRunViewSet(viewsets.ModelViewSet):
|
|
72
|
+
"""
|
|
73
|
+
Base ViewSet for managing agent runs.
|
|
74
|
+
|
|
75
|
+
Inherit from this and set your own permission_classes and authentication_classes.
|
|
76
|
+
|
|
77
|
+
Endpoints:
|
|
78
|
+
- POST /runs/ - Create a new run
|
|
79
|
+
- GET /runs/ - List runs
|
|
80
|
+
- GET /runs/{id}/ - Get run details
|
|
81
|
+
- POST /runs/{id}/cancel/ - Cancel a run
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def get_serializer_class(self):
|
|
85
|
+
if self.action == "create":
|
|
86
|
+
return AgentRunCreateSerializer
|
|
87
|
+
elif self.action == "retrieve":
|
|
88
|
+
return AgentRunDetailSerializer
|
|
89
|
+
return AgentRunSerializer
|
|
90
|
+
|
|
91
|
+
def get_queryset(self):
|
|
92
|
+
"""Filter runs by user's conversations or anonymous session."""
|
|
93
|
+
from django.db.models import Q
|
|
94
|
+
|
|
95
|
+
if self.request.user and self.request.user.is_authenticated:
|
|
96
|
+
# Include runs with user's conversations OR runs without conversation
|
|
97
|
+
# that were created by this user (stored in metadata)
|
|
98
|
+
return AgentRun.objects.filter(
|
|
99
|
+
Q(conversation__user=self.request.user) |
|
|
100
|
+
Q(conversation__isnull=True, metadata__user_id=self.request.user.id)
|
|
101
|
+
).select_related("conversation")
|
|
102
|
+
|
|
103
|
+
# For anonymous sessions - filter by anonymous_session_id
|
|
104
|
+
session = get_anonymous_session(self.request)
|
|
105
|
+
if session:
|
|
106
|
+
return AgentRun.objects.filter(
|
|
107
|
+
Q(conversation__anonymous_session_id=session.id) |
|
|
108
|
+
Q(conversation__isnull=True, metadata__anonymous_token=session.token)
|
|
109
|
+
).select_related("conversation")
|
|
110
|
+
|
|
111
|
+
return AgentRun.objects.none()
|
|
112
|
+
|
|
113
|
+
def create(self, request, *args, **kwargs):
|
|
114
|
+
"""Create a new agent run."""
|
|
115
|
+
serializer = self.get_serializer(data=request.data)
|
|
116
|
+
serializer.validate(request.data)
|
|
117
|
+
serializer.is_valid(raise_exception=True)
|
|
118
|
+
|
|
119
|
+
data = serializer.validated_data
|
|
120
|
+
|
|
121
|
+
# Check authorization hooks if configured
|
|
122
|
+
settings = runtime_settings()
|
|
123
|
+
if request.user and request.user.is_authenticated:
|
|
124
|
+
authz_hook = get_hook(settings.AUTHZ_HOOK)
|
|
125
|
+
if authz_hook and not authz_hook(request.user, "create_run", data):
|
|
126
|
+
return Response(
|
|
127
|
+
{"error": "Not authorized to create this run"},
|
|
128
|
+
status=status.HTTP_403_FORBIDDEN,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Check quota
|
|
132
|
+
quota_hook = get_hook(settings.QUOTA_HOOK)
|
|
133
|
+
if quota_hook and not quota_hook(request.user, data["agent_key"]):
|
|
134
|
+
return Response(
|
|
135
|
+
{"error": "Quota exceeded"},
|
|
136
|
+
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Get or create conversation
|
|
140
|
+
conversation = None
|
|
141
|
+
session = get_anonymous_session(request)
|
|
142
|
+
|
|
143
|
+
if data.get("conversation_id"):
|
|
144
|
+
try:
|
|
145
|
+
if request.user and request.user.is_authenticated:
|
|
146
|
+
conversation = AgentConversation.objects.get(
|
|
147
|
+
id=data["conversation_id"],
|
|
148
|
+
user=request.user,
|
|
149
|
+
)
|
|
150
|
+
elif session:
|
|
151
|
+
conversation = AgentConversation.objects.get(
|
|
152
|
+
id=data["conversation_id"],
|
|
153
|
+
anonymous_session_id=session.id,
|
|
154
|
+
)
|
|
155
|
+
except AgentConversation.DoesNotExist:
|
|
156
|
+
return Response(
|
|
157
|
+
{"error": "Conversation not found"},
|
|
158
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Check idempotency
|
|
162
|
+
if data.get("idempotency_key"):
|
|
163
|
+
existing = AgentRun.objects.filter(
|
|
164
|
+
idempotency_key=data["idempotency_key"]
|
|
165
|
+
).first()
|
|
166
|
+
if existing:
|
|
167
|
+
return Response(
|
|
168
|
+
AgentRunSerializer(existing).data,
|
|
169
|
+
status=status.HTTP_200_OK,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Build metadata with session/user info
|
|
173
|
+
metadata = {
|
|
174
|
+
**data.get("metadata", {}),
|
|
175
|
+
"conversation_id": str(conversation.id) if conversation else None,
|
|
176
|
+
}
|
|
177
|
+
if request.user and request.user.is_authenticated:
|
|
178
|
+
metadata["user_id"] = request.user.id
|
|
179
|
+
if session:
|
|
180
|
+
metadata["anonymous_token"] = session.token
|
|
181
|
+
|
|
182
|
+
# Create the run
|
|
183
|
+
run = AgentRun.objects.create(
|
|
184
|
+
conversation=conversation,
|
|
185
|
+
agent_key=data["agent_key"],
|
|
186
|
+
input={
|
|
187
|
+
"messages": data["messages"],
|
|
188
|
+
"params": data.get("params", {}),
|
|
189
|
+
},
|
|
190
|
+
max_attempts=data.get("max_attempts", 3),
|
|
191
|
+
idempotency_key=data.get("idempotency_key"),
|
|
192
|
+
metadata=metadata,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Enqueue to Redis if using Redis queue
|
|
196
|
+
if settings.QUEUE_BACKEND == "redis_streams":
|
|
197
|
+
asyncio.run(self._enqueue_to_redis(run))
|
|
198
|
+
|
|
199
|
+
return Response(
|
|
200
|
+
AgentRunSerializer(run).data,
|
|
201
|
+
status=status.HTTP_201_CREATED,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def _enqueue_to_redis(self, run: AgentRun):
|
|
205
|
+
"""Enqueue run to Redis stream."""
|
|
206
|
+
from django_agent_runtime.runtime.queue.redis_streams import RedisStreamsQueue
|
|
207
|
+
|
|
208
|
+
settings = runtime_settings()
|
|
209
|
+
queue = RedisStreamsQueue(redis_url=settings.REDIS_URL)
|
|
210
|
+
await queue.enqueue(run.id, run.agent_key)
|
|
211
|
+
await queue.close()
|
|
212
|
+
|
|
213
|
+
@action(detail=True, methods=["post"])
|
|
214
|
+
def cancel(self, request, pk=None):
|
|
215
|
+
"""Cancel a running agent run."""
|
|
216
|
+
run = self.get_object()
|
|
217
|
+
|
|
218
|
+
if run.is_terminal:
|
|
219
|
+
return Response(
|
|
220
|
+
{"error": "Run is already complete"},
|
|
221
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Request cancellation
|
|
225
|
+
from django.utils import timezone
|
|
226
|
+
|
|
227
|
+
run.cancel_requested_at = timezone.now()
|
|
228
|
+
run.save(update_fields=["cancel_requested_at"])
|
|
229
|
+
|
|
230
|
+
return Response({"status": "cancellation_requested"})
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def sync_event_stream(request, run_id: str):
|
|
234
|
+
"""
|
|
235
|
+
Sync SSE endpoint for streaming events.
|
|
236
|
+
|
|
237
|
+
This is a plain Django view (not DRF) to avoid content negotiation issues.
|
|
238
|
+
|
|
239
|
+
Authorization is checked by verifying the user owns the run (via conversation
|
|
240
|
+
or metadata). The outer project controls authentication via middleware.
|
|
241
|
+
|
|
242
|
+
For token-based auth with SSE (where headers can't be set), pass the token
|
|
243
|
+
as a query parameter: ?token=<auth_token>
|
|
244
|
+
|
|
245
|
+
Query Parameters:
|
|
246
|
+
from_seq: Start from this sequence number (default: 0)
|
|
247
|
+
include_debug: Include debug-level events (default: false)
|
|
248
|
+
include_all: Include all events including internal (default: false)
|
|
249
|
+
"""
|
|
250
|
+
import time
|
|
251
|
+
from django.http import JsonResponse
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
run_uuid = UUID(run_id)
|
|
255
|
+
except ValueError:
|
|
256
|
+
return JsonResponse({"error": "Invalid run ID"}, status=400)
|
|
257
|
+
|
|
258
|
+
from_seq = int(request.GET.get("from_seq", 0))
|
|
259
|
+
include_debug = request.GET.get("include_debug", "").lower() in ("true", "1", "yes")
|
|
260
|
+
include_all = request.GET.get("include_all", "").lower() in ("true", "1", "yes")
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
run = AgentRun.objects.select_related("conversation").get(id=run_uuid)
|
|
264
|
+
except AgentRun.DoesNotExist:
|
|
265
|
+
return JsonResponse({"error": "Run not found"}, status=404)
|
|
266
|
+
|
|
267
|
+
# Check access - user must own the run
|
|
268
|
+
has_access = False
|
|
269
|
+
|
|
270
|
+
# Get authenticated user (may be set by middleware or we need to check token)
|
|
271
|
+
user = request.user if hasattr(request, 'user') else None
|
|
272
|
+
|
|
273
|
+
# Support token auth via query param for SSE (browsers can't set headers on EventSource)
|
|
274
|
+
if (not user or not user.is_authenticated) and request.GET.get('token'):
|
|
275
|
+
from rest_framework.authtoken.models import Token
|
|
276
|
+
try:
|
|
277
|
+
token = Token.objects.select_related('user').get(key=request.GET.get('token'))
|
|
278
|
+
user = token.user
|
|
279
|
+
except Token.DoesNotExist:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
if user and user.is_authenticated:
|
|
283
|
+
# User owns the conversation
|
|
284
|
+
if run.conversation and run.conversation.user == user:
|
|
285
|
+
has_access = True
|
|
286
|
+
# Run without conversation - check metadata
|
|
287
|
+
elif not run.conversation and run.metadata.get("user_id") == user.id:
|
|
288
|
+
has_access = True
|
|
289
|
+
# Allow access to runs without ownership info (backwards compat)
|
|
290
|
+
elif not run.conversation and "user_id" not in run.metadata:
|
|
291
|
+
has_access = True
|
|
292
|
+
|
|
293
|
+
# Check anonymous session if configured
|
|
294
|
+
if not has_access:
|
|
295
|
+
anonymous_token = request.headers.get('X-Anonymous-Token') or request.GET.get('anonymous_token')
|
|
296
|
+
if anonymous_token:
|
|
297
|
+
from django_agent_runtime.api.permissions import _get_anonymous_session_model
|
|
298
|
+
AnonymousSession = _get_anonymous_session_model()
|
|
299
|
+
if AnonymousSession:
|
|
300
|
+
try:
|
|
301
|
+
session = AnonymousSession.objects.get(token=anonymous_token)
|
|
302
|
+
is_expired = getattr(session, 'is_expired', False)
|
|
303
|
+
if not is_expired:
|
|
304
|
+
# Check by anonymous_session_id (UUID field)
|
|
305
|
+
if run.conversation and run.conversation.anonymous_session_id == session.id:
|
|
306
|
+
has_access = True
|
|
307
|
+
elif not run.conversation and run.metadata.get("anonymous_token") == anonymous_token:
|
|
308
|
+
has_access = True
|
|
309
|
+
except AnonymousSession.DoesNotExist:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
if not has_access:
|
|
313
|
+
return JsonResponse({"error": "Not authorized"}, status=403)
|
|
314
|
+
|
|
315
|
+
settings = runtime_settings()
|
|
316
|
+
if not settings.ENABLE_SSE:
|
|
317
|
+
return JsonResponse({"error": "SSE streaming is disabled"}, status=503)
|
|
318
|
+
|
|
319
|
+
# Import visibility helper
|
|
320
|
+
from django_agent_runtime.conf import get_event_visibility
|
|
321
|
+
|
|
322
|
+
def should_include_event(event_type: str) -> bool:
|
|
323
|
+
"""Determine if an event should be included based on visibility settings."""
|
|
324
|
+
if include_all:
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
visibility_level, ui_visible = get_event_visibility(event_type)
|
|
328
|
+
|
|
329
|
+
if visibility_level == "internal":
|
|
330
|
+
return False
|
|
331
|
+
elif visibility_level == "debug":
|
|
332
|
+
return include_debug or settings.DEBUG_MODE
|
|
333
|
+
else: # "user"
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
def event_generator():
|
|
337
|
+
current_seq = from_seq
|
|
338
|
+
|
|
339
|
+
while True:
|
|
340
|
+
# Get new events from database
|
|
341
|
+
events = list(
|
|
342
|
+
AgentEvent.objects.filter(
|
|
343
|
+
run_id=run_uuid,
|
|
344
|
+
seq__gte=current_seq,
|
|
345
|
+
).order_by("seq")
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
for event in events:
|
|
349
|
+
current_seq = event.seq + 1
|
|
350
|
+
|
|
351
|
+
# Check for terminal events (always process these for loop control)
|
|
352
|
+
is_terminal = event.event_type in (
|
|
353
|
+
"run.succeeded",
|
|
354
|
+
"run.failed",
|
|
355
|
+
"run.cancelled",
|
|
356
|
+
"run.timed_out",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Filter by visibility
|
|
360
|
+
if should_include_event(event.event_type):
|
|
361
|
+
# Get visibility info for the response
|
|
362
|
+
visibility_level, ui_visible = get_event_visibility(event.event_type)
|
|
363
|
+
|
|
364
|
+
data = {
|
|
365
|
+
"run_id": str(event.run_id),
|
|
366
|
+
"seq": event.seq,
|
|
367
|
+
"type": event.event_type,
|
|
368
|
+
"payload": event.payload,
|
|
369
|
+
"ts": event.timestamp.isoformat(),
|
|
370
|
+
"visibility_level": visibility_level,
|
|
371
|
+
"ui_visible": ui_visible,
|
|
372
|
+
}
|
|
373
|
+
# Use named events so browsers can use addEventListener
|
|
374
|
+
yield f"event: {event.event_type}\ndata: {json.dumps(data)}\n\n"
|
|
375
|
+
|
|
376
|
+
if is_terminal:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
# Check if run is complete
|
|
380
|
+
try:
|
|
381
|
+
run_check = AgentRun.objects.get(id=run_uuid)
|
|
382
|
+
if run_check.is_terminal:
|
|
383
|
+
return
|
|
384
|
+
except AgentRun.DoesNotExist:
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# Send keepalive
|
|
388
|
+
yield ": keepalive\n\n"
|
|
389
|
+
|
|
390
|
+
# Wait before polling again
|
|
391
|
+
time.sleep(0.5)
|
|
392
|
+
|
|
393
|
+
response = StreamingHttpResponse(
|
|
394
|
+
event_generator(),
|
|
395
|
+
content_type="text/event-stream",
|
|
396
|
+
)
|
|
397
|
+
response["Cache-Control"] = "no-cache"
|
|
398
|
+
response["X-Accel-Buffering"] = "no"
|
|
399
|
+
response["Access-Control-Allow-Origin"] = "*"
|
|
400
|
+
return response
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def async_event_stream(request, run_id: str):
|
|
404
|
+
"""
|
|
405
|
+
Async SSE endpoint for streaming events.
|
|
406
|
+
|
|
407
|
+
Use this with ASGI servers (uvicorn, daphne) for better performance.
|
|
408
|
+
"""
|
|
409
|
+
from django.http import StreamingHttpResponse, JsonResponse
|
|
410
|
+
from asgiref.sync import sync_to_async
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
run_uuid = UUID(run_id)
|
|
414
|
+
except ValueError:
|
|
415
|
+
return JsonResponse({"error": "Invalid run ID"}, status=400)
|
|
416
|
+
|
|
417
|
+
from_seq = int(request.GET.get("from_seq", 0))
|
|
418
|
+
|
|
419
|
+
@sync_to_async
|
|
420
|
+
def check_access():
|
|
421
|
+
try:
|
|
422
|
+
run = AgentRun.objects.select_related("conversation").get(id=run_uuid)
|
|
423
|
+
except AgentRun.DoesNotExist:
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
user = request.user if hasattr(request, 'user') else None
|
|
427
|
+
|
|
428
|
+
if user and user.is_authenticated:
|
|
429
|
+
if run.conversation and run.conversation.user == user:
|
|
430
|
+
return run
|
|
431
|
+
elif not run.conversation and run.metadata.get("user_id") == user.id:
|
|
432
|
+
return run
|
|
433
|
+
elif not run.conversation and "user_id" not in run.metadata:
|
|
434
|
+
return run
|
|
435
|
+
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
run = await check_access()
|
|
439
|
+
if not run:
|
|
440
|
+
return JsonResponse({"error": "Not found or not authorized"}, status=404)
|
|
441
|
+
|
|
442
|
+
async def event_generator():
|
|
443
|
+
from django_agent_runtime.runtime.events import get_event_bus
|
|
444
|
+
|
|
445
|
+
settings = runtime_settings()
|
|
446
|
+
event_bus = get_event_bus(settings.EVENT_BUS_BACKEND)
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
async for event in event_bus.subscribe(run_uuid, from_seq=from_seq):
|
|
450
|
+
data = event.to_dict()
|
|
451
|
+
event_type = data.get("type", "message")
|
|
452
|
+
# Use named events so browsers can use addEventListener
|
|
453
|
+
yield f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
|
454
|
+
|
|
455
|
+
# Check for terminal events
|
|
456
|
+
if event.event_type in (
|
|
457
|
+
"run.succeeded",
|
|
458
|
+
"run.failed",
|
|
459
|
+
"run.cancelled",
|
|
460
|
+
"run.timed_out",
|
|
461
|
+
):
|
|
462
|
+
break
|
|
463
|
+
finally:
|
|
464
|
+
await event_bus.close()
|
|
465
|
+
|
|
466
|
+
response = StreamingHttpResponse(
|
|
467
|
+
event_generator(),
|
|
468
|
+
content_type="text/event-stream",
|
|
469
|
+
)
|
|
470
|
+
response["Cache-Control"] = "no-cache"
|
|
471
|
+
response["X-Accel-Buffering"] = "no"
|
|
472
|
+
return response
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django app configuration for django_agent_runtime.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DjangoAgentRuntimeConfig(AppConfig):
|
|
9
|
+
"""Configuration for the Django Agent Runtime app."""
|
|
10
|
+
|
|
11
|
+
name = "django_agent_runtime"
|
|
12
|
+
verbose_name = "Django Agent Runtime"
|
|
13
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
14
|
+
|
|
15
|
+
def ready(self):
|
|
16
|
+
"""
|
|
17
|
+
Called when Django starts. Used to:
|
|
18
|
+
- Auto-discover agent runtime plugins
|
|
19
|
+
- Register signal handlers
|
|
20
|
+
- Validate configuration
|
|
21
|
+
"""
|
|
22
|
+
from django_agent_runtime.runtime.registry import autodiscover_runtimes
|
|
23
|
+
|
|
24
|
+
# Auto-discover runtimes from entry points and settings
|
|
25
|
+
autodiscover_runtimes()
|
|
26
|
+
|