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.
Files changed (55) hide show
  1. django_agent_runtime/__init__.py +25 -0
  2. django_agent_runtime/admin.py +155 -0
  3. django_agent_runtime/api/__init__.py +26 -0
  4. django_agent_runtime/api/permissions.py +109 -0
  5. django_agent_runtime/api/serializers.py +114 -0
  6. django_agent_runtime/api/views.py +472 -0
  7. django_agent_runtime/apps.py +26 -0
  8. django_agent_runtime/conf.py +241 -0
  9. django_agent_runtime/examples/__init__.py +10 -0
  10. django_agent_runtime/examples/langgraph_adapter.py +164 -0
  11. django_agent_runtime/examples/langgraph_tools.py +179 -0
  12. django_agent_runtime/examples/simple_chat.py +69 -0
  13. django_agent_runtime/examples/tool_agent.py +157 -0
  14. django_agent_runtime/management/__init__.py +2 -0
  15. django_agent_runtime/management/commands/__init__.py +2 -0
  16. django_agent_runtime/management/commands/runagent.py +419 -0
  17. django_agent_runtime/migrations/0001_initial.py +117 -0
  18. django_agent_runtime/migrations/0002_persistence_models.py +129 -0
  19. django_agent_runtime/migrations/0003_persistenceconversation_active_branch_id_and_more.py +212 -0
  20. django_agent_runtime/migrations/0004_add_anonymous_session_id.py +18 -0
  21. django_agent_runtime/migrations/__init__.py +2 -0
  22. django_agent_runtime/models/__init__.py +54 -0
  23. django_agent_runtime/models/base.py +450 -0
  24. django_agent_runtime/models/concrete.py +146 -0
  25. django_agent_runtime/persistence/__init__.py +60 -0
  26. django_agent_runtime/persistence/helpers.py +148 -0
  27. django_agent_runtime/persistence/models.py +506 -0
  28. django_agent_runtime/persistence/stores.py +1191 -0
  29. django_agent_runtime/runtime/__init__.py +23 -0
  30. django_agent_runtime/runtime/events/__init__.py +65 -0
  31. django_agent_runtime/runtime/events/base.py +135 -0
  32. django_agent_runtime/runtime/events/db.py +129 -0
  33. django_agent_runtime/runtime/events/redis.py +228 -0
  34. django_agent_runtime/runtime/events/sync.py +140 -0
  35. django_agent_runtime/runtime/interfaces.py +475 -0
  36. django_agent_runtime/runtime/llm/__init__.py +91 -0
  37. django_agent_runtime/runtime/llm/anthropic.py +249 -0
  38. django_agent_runtime/runtime/llm/litellm_adapter.py +173 -0
  39. django_agent_runtime/runtime/llm/openai.py +230 -0
  40. django_agent_runtime/runtime/queue/__init__.py +75 -0
  41. django_agent_runtime/runtime/queue/base.py +158 -0
  42. django_agent_runtime/runtime/queue/postgres.py +248 -0
  43. django_agent_runtime/runtime/queue/redis_streams.py +336 -0
  44. django_agent_runtime/runtime/queue/sync.py +277 -0
  45. django_agent_runtime/runtime/registry.py +186 -0
  46. django_agent_runtime/runtime/runner.py +540 -0
  47. django_agent_runtime/runtime/tracing/__init__.py +48 -0
  48. django_agent_runtime/runtime/tracing/langfuse.py +117 -0
  49. django_agent_runtime/runtime/tracing/noop.py +36 -0
  50. django_agent_runtime/urls.py +39 -0
  51. django_agent_runtime-0.3.6.dist-info/METADATA +723 -0
  52. django_agent_runtime-0.3.6.dist-info/RECORD +55 -0
  53. django_agent_runtime-0.3.6.dist-info/WHEEL +5 -0
  54. django_agent_runtime-0.3.6.dist-info/licenses/LICENSE +22 -0
  55. 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
+