django-agent-studio 0.1.0__py3-none-any.whl → 0.1.1__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.
@@ -0,0 +1,376 @@
1
+ """
2
+ DynamicAgentRuntime - An agent that loads its configuration from the database.
3
+
4
+ This allows agents to be defined and modified via the AgentDefinition model
5
+ without requiring code changes.
6
+
7
+ Supports:
8
+ - RAG (Retrieval Augmented Generation) for knowledge sources with inclusion_mode='rag'
9
+ - Conversation-scoped memory via the 'remember' tool
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ from typing import Any, Optional
15
+ from uuid import UUID
16
+
17
+ from agent_runtime_core.registry import AgentRuntime
18
+ from agent_runtime_core.interfaces import RunContext, RunResult, EventType
19
+ from agent_runtime_core.agentic_loop import run_agentic_loop
20
+ from django_agent_runtime.runtime.llm import get_llm_client_for_model, DEFAULT_MODEL
21
+ from django_agent_runtime.models import AgentDefinition
22
+ from django_agent_runtime.dynamic_tools.executor import DynamicToolExecutor
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # =============================================================================
28
+ # Memory Tool Definition
29
+ # =============================================================================
30
+
31
+ MEMORY_TOOL_SCHEMA = {
32
+ "type": "function",
33
+ "function": {
34
+ "name": "remember",
35
+ "description": (
36
+ "Store information to remember for this conversation. Use this to remember "
37
+ "important facts about the user, their preferences, project details, or anything "
38
+ "that would be useful to recall in future messages within this conversation. "
39
+ "Examples: user's name, their goals, preferences, important context."
40
+ ),
41
+ "parameters": {
42
+ "type": "object",
43
+ "properties": {
44
+ "key": {
45
+ "type": "string",
46
+ "description": (
47
+ "A short, descriptive key for what you're remembering "
48
+ "(e.g., 'user_name', 'project_goal', 'preferred_language')"
49
+ ),
50
+ },
51
+ "value": {
52
+ "type": "string",
53
+ "description": "The information to remember",
54
+ },
55
+ },
56
+ "required": ["key", "value"],
57
+ },
58
+ },
59
+ }
60
+
61
+
62
+ class DynamicAgentRuntime(AgentRuntime):
63
+ """
64
+ An agent runtime that loads its configuration from an AgentDefinition.
65
+
66
+ This allows agents to be created and modified via the database/API
67
+ without requiring code changes or deployments.
68
+ """
69
+
70
+ def __init__(self, agent_definition: AgentDefinition):
71
+ self._definition = agent_definition
72
+ self._config: Optional[dict] = None
73
+
74
+ @property
75
+ def key(self) -> str:
76
+ """Return the agent's slug as its key."""
77
+ return self._definition.slug
78
+
79
+ @property
80
+ def config(self) -> dict:
81
+ """Get the effective configuration (cached)."""
82
+ if self._config is None:
83
+ self._config = self._definition.get_effective_config()
84
+ return self._config
85
+
86
+ def refresh_config(self):
87
+ """Refresh the configuration from the database."""
88
+ self._definition.refresh_from_db()
89
+ self._config = None
90
+
91
+ async def run(self, ctx: RunContext) -> RunResult:
92
+ """Execute the agent with the dynamic configuration and agentic loop."""
93
+ config = self.config
94
+
95
+ # Build the messages list
96
+ messages = []
97
+
98
+ # Add system prompt
99
+ system_prompt = config.get("system_prompt", "")
100
+
101
+ # Add knowledge that should always be included
102
+ knowledge_context = self._build_knowledge_context(config)
103
+ if knowledge_context:
104
+ system_prompt = f"{system_prompt}\n\n{knowledge_context}"
105
+
106
+ # Add RAG-retrieved knowledge based on user's query
107
+ rag_context = await self._retrieve_rag_knowledge(config, ctx)
108
+ if rag_context:
109
+ system_prompt = f"{system_prompt}\n\n{rag_context}"
110
+
111
+ # Add conversation memories (if we have a conversation_id and user)
112
+ memory_store = await self._get_memory_store(ctx)
113
+ if memory_store:
114
+ memory_context = await self._recall_memories(memory_store)
115
+ if memory_context:
116
+ system_prompt = f"{system_prompt}\n\n{memory_context}"
117
+
118
+ if system_prompt:
119
+ messages.append({"role": "system", "content": system_prompt})
120
+
121
+ # Add conversation history
122
+ messages.extend(ctx.input_messages)
123
+
124
+ # Build tool schemas - include memory tool
125
+ tools = self._build_tool_schemas(config)
126
+ tools.append(MEMORY_TOOL_SCHEMA) # Add remember tool to all designed agents
127
+
128
+ tool_map = self._build_tool_map(config) # Maps tool name to execution info
129
+
130
+ # Get model: params override > agent config > default
131
+ model = ctx.params.get("model") or config.get("model") or DEFAULT_MODEL
132
+ model_settings = config.get("model_settings", {})
133
+
134
+ # Get LLM client for the model (auto-detects provider)
135
+ llm = get_llm_client_for_model(model)
136
+
137
+ # Initialize tool executor for dynamic tools
138
+ tool_executor = DynamicToolExecutor()
139
+
140
+ # Create tool executor function for the agentic loop
141
+ async def execute_tool(tool_name: str, tool_args: dict) -> str:
142
+ # Handle the built-in remember tool
143
+ if tool_name == "remember":
144
+ return await self._execute_remember_tool(tool_args, memory_store)
145
+
146
+ return await self._execute_tool(
147
+ tool_name, tool_args, tool_map, tool_executor, ctx
148
+ )
149
+
150
+ try:
151
+ # Use the shared agentic loop
152
+ result = await run_agentic_loop(
153
+ llm=llm,
154
+ messages=messages,
155
+ tools=tools if tools else None,
156
+ execute_tool=execute_tool,
157
+ ctx=ctx,
158
+ model=model,
159
+ max_iterations=15,
160
+ **model_settings,
161
+ )
162
+
163
+ # Emit the final assistant message
164
+ if result.final_content:
165
+ await ctx.emit(EventType.ASSISTANT_MESSAGE, {"content": result.final_content})
166
+
167
+ return RunResult(
168
+ final_output={"response": result.final_content},
169
+ final_messages=result.messages,
170
+ )
171
+
172
+ except Exception as e:
173
+ logger.exception(f"Error in DynamicAgentRuntime for {self.key}")
174
+ await ctx.emit(EventType.RUN_FAILED, {"error": str(e)})
175
+ raise
176
+
177
+ async def _get_memory_store(self, ctx: RunContext) -> Optional["ConversationMemoryStore"]:
178
+ """
179
+ Get the memory store for this conversation, if available.
180
+
181
+ Returns None if we don't have the required context (user, conversation_id).
182
+ """
183
+ from django_agent_runtime.persistence.stores import ConversationMemoryStore
184
+
185
+ # Need both user and conversation_id for memory
186
+ user_id = ctx.metadata.get("user_id")
187
+ conversation_id = ctx.conversation_id
188
+
189
+ if not user_id or not conversation_id:
190
+ logger.debug(
191
+ f"Memory not available: user_id={user_id}, conversation_id={conversation_id}"
192
+ )
193
+ return None
194
+
195
+ try:
196
+ # Get the user object
197
+ from django.contrib.auth import get_user_model
198
+ from asgiref.sync import sync_to_async
199
+
200
+ User = get_user_model()
201
+ user = await sync_to_async(User.objects.get)(id=user_id)
202
+
203
+ return ConversationMemoryStore(user=user, conversation_id=conversation_id)
204
+ except Exception as e:
205
+ logger.warning(f"Failed to create memory store: {e}")
206
+ return None
207
+
208
+ async def _recall_memories(self, memory_store: "ConversationMemoryStore") -> str:
209
+ """
210
+ Recall all memories for this conversation and format for the prompt.
211
+ """
212
+ try:
213
+ memories = await memory_store.recall_all()
214
+ if memories:
215
+ logger.info(f"Recalled {len(memories)} memories for conversation")
216
+ return memory_store.format_for_prompt(memories)
217
+ except Exception as e:
218
+ logger.warning(f"Failed to recall memories: {e}")
219
+ return ""
220
+
221
+ async def _execute_remember_tool(
222
+ self,
223
+ args: dict,
224
+ memory_store: Optional["ConversationMemoryStore"],
225
+ ) -> str:
226
+ """Execute the remember tool to store a memory."""
227
+ if not memory_store:
228
+ return json.dumps({
229
+ "error": "Memory not available for this conversation",
230
+ "hint": "Memory requires a logged-in user and conversation context",
231
+ })
232
+
233
+ key = args.get("key", "").strip()
234
+ value = args.get("value", "").strip()
235
+
236
+ if not key:
237
+ return json.dumps({"error": "Missing required parameter: key"})
238
+ if not value:
239
+ return json.dumps({"error": "Missing required parameter: value"})
240
+
241
+ try:
242
+ await memory_store.remember(key, value, source="agent")
243
+ logger.info(f"Stored memory: {key}")
244
+ return json.dumps({
245
+ "success": True,
246
+ "message": f"Remembered: {key}",
247
+ })
248
+ except Exception as e:
249
+ logger.exception(f"Failed to store memory: {key}")
250
+ return json.dumps({"error": str(e)})
251
+
252
+ def _build_knowledge_context(self, config: dict) -> str:
253
+ """Build context string from always-included knowledge sources."""
254
+ parts = []
255
+ for knowledge in config.get("knowledge", []):
256
+ if knowledge.get("inclusion_mode") == "always":
257
+ content = knowledge.get("content")
258
+ if content:
259
+ name = knowledge.get("name", "Knowledge")
260
+ parts.append(f"## {name}\n{content}")
261
+ return "\n\n".join(parts)
262
+
263
+ async def _retrieve_rag_knowledge(self, config: dict, ctx: RunContext) -> str:
264
+ """
265
+ Retrieve relevant knowledge using RAG for knowledge sources with inclusion_mode='rag'.
266
+
267
+ Args:
268
+ config: The agent's effective configuration
269
+ ctx: The run context containing user messages
270
+
271
+ Returns:
272
+ Formatted string of retrieved knowledge, or empty string if no RAG knowledge
273
+ """
274
+ # Check if there are any RAG knowledge sources
275
+ rag_knowledge = [
276
+ k for k in config.get("knowledge", [])
277
+ if k.get("inclusion_mode") == "rag" and k.get("rag", {}).get("status") == "indexed"
278
+ ]
279
+
280
+ if not rag_knowledge:
281
+ return ""
282
+
283
+ # Get the user's query from the last user message
284
+ user_query = ""
285
+ for msg in reversed(ctx.input_messages):
286
+ if msg.get("role") == "user":
287
+ user_query = msg.get("content", "")
288
+ break
289
+
290
+ if not user_query:
291
+ return ""
292
+
293
+ try:
294
+ from django_agent_runtime.rag import KnowledgeRetriever
295
+
296
+ retriever = KnowledgeRetriever()
297
+ rag_config = config.get("rag_config", {})
298
+
299
+ # Retrieve relevant knowledge
300
+ context = await retriever.retrieve_for_agent(
301
+ agent_id=str(self._definition.id),
302
+ query=user_query,
303
+ rag_config=rag_config,
304
+ )
305
+
306
+ return context
307
+
308
+ except Exception as e:
309
+ logger.warning(f"Error retrieving RAG knowledge: {e}")
310
+ return ""
311
+
312
+ def _build_tool_schemas(self, config: dict) -> list:
313
+ """Build OpenAI-format tool schemas from config."""
314
+ schemas = []
315
+ for tool in config.get("tools", []):
316
+ # Skip the _meta field when building schema
317
+ schema = {
318
+ "type": tool.get("type", "function"),
319
+ "function": tool.get("function", {}),
320
+ }
321
+ schemas.append(schema)
322
+ return schemas
323
+
324
+ def _build_tool_map(self, config: dict) -> dict:
325
+ """Build a map of tool name to execution info."""
326
+ tool_map = {}
327
+ for tool in config.get("tools", []):
328
+ func_info = tool.get("function", {})
329
+ tool_name = func_info.get("name")
330
+ if tool_name:
331
+ # Get execution metadata from _meta field
332
+ meta = tool.get("_meta", {})
333
+ tool_map[tool_name] = {
334
+ "function_path": meta.get("function_path"),
335
+ "tool_id": meta.get("tool_id"),
336
+ "is_dynamic": meta.get("is_dynamic", False),
337
+ }
338
+ return tool_map
339
+
340
+ async def _execute_tool(
341
+ self,
342
+ tool_name: str,
343
+ tool_args: dict,
344
+ tool_map: dict,
345
+ executor: "DynamicToolExecutor",
346
+ ctx: RunContext,
347
+ ) -> str:
348
+ """Execute a tool and return its result."""
349
+ tool_info = tool_map.get(tool_name, {})
350
+ function_path = tool_info.get("function_path")
351
+
352
+ if not function_path:
353
+ return json.dumps({"error": f"Tool '{tool_name}' has no function_path configured"})
354
+
355
+ try:
356
+ # Get user_id from context metadata if available
357
+ user_id = ctx.metadata.get("user_id")
358
+
359
+ # Execute the tool
360
+ result = await executor.execute(
361
+ function_path=function_path,
362
+ arguments=tool_args,
363
+ agent_run_id=ctx.run_id,
364
+ user_id=user_id,
365
+ tool_id=tool_info.get("tool_id"),
366
+ )
367
+
368
+ # Convert result to string if needed
369
+ if isinstance(result, str):
370
+ return result
371
+ return json.dumps(result)
372
+
373
+ except Exception as e:
374
+ logger.exception(f"Error executing tool {tool_name}")
375
+ return json.dumps({"error": str(e)})
376
+
@@ -0,0 +1,63 @@
1
+ # Generated by Django 5.2.10 on 2026-01-23 14:27
2
+
3
+ import django.db.models.deletion
4
+ import uuid
5
+ from django.conf import settings
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ('django_agent_runtime', '0007_discoveredfunction_dynamictool_dynamictoolexecution'),
15
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name='ToolApprovalRequest',
21
+ fields=[
22
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
23
+ ('proposed_name', models.CharField(max_length=100)),
24
+ ('proposed_description', models.TextField()),
25
+ ('proposed_is_safe', models.BooleanField(default=False)),
26
+ ('proposed_requires_confirmation', models.BooleanField(default=True)),
27
+ ('proposed_timeout_seconds', models.PositiveIntegerField(default=30)),
28
+ ('request_reason', models.TextField(help_text='Why this tool is needed')),
29
+ ('status', models.CharField(choices=[('pending', 'Pending Review'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('cancelled', 'Cancelled by Requester')], default='pending', max_length=20)),
30
+ ('reviewed_at', models.DateTimeField(blank=True, null=True)),
31
+ ('review_notes', models.TextField(blank=True)),
32
+ ('created_at', models.DateTimeField(auto_now_add=True)),
33
+ ('updated_at', models.DateTimeField(auto_now=True)),
34
+ ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tool_approval_requests', to='django_agent_runtime.agentdefinition')),
35
+ ('created_tool', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approval_request', to='django_agent_runtime.dynamictool')),
36
+ ('discovered_function', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approval_requests', to='django_agent_runtime.discoveredfunction')),
37
+ ('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tool_approval_requests', to=settings.AUTH_USER_MODEL)),
38
+ ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tool_approval_reviews', to=settings.AUTH_USER_MODEL)),
39
+ ],
40
+ options={
41
+ 'verbose_name': 'Tool Approval Request',
42
+ 'verbose_name_plural': 'Tool Approval Requests',
43
+ 'ordering': ['-created_at'],
44
+ },
45
+ ),
46
+ migrations.CreateModel(
47
+ name='UserDynamicToolAccess',
48
+ fields=[
49
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
50
+ ('access_level', models.CharField(choices=[('none', 'No Access'), ('viewer', 'Viewer (read-only)'), ('scanner', 'Scanner (can scan project)'), ('requester', 'Requester (needs approval)'), ('creator', 'Creator (can create tools)'), ('admin', 'Admin (full access)')], default='none', max_length=20)),
51
+ ('granted_at', models.DateTimeField(auto_now_add=True)),
52
+ ('updated_at', models.DateTimeField(auto_now=True)),
53
+ ('notes', models.TextField(blank=True, help_text='Reason for granting this access level')),
54
+ ('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dynamic_tool_access_grants_given', to=settings.AUTH_USER_MODEL)),
55
+ ('restricted_to_agents', models.ManyToManyField(blank=True, help_text='If set, access only applies to these agents', related_name='dynamic_tool_access_grants', to='django_agent_runtime.agentdefinition')),
56
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dynamic_tool_access', to=settings.AUTH_USER_MODEL)),
57
+ ],
58
+ options={
59
+ 'verbose_name': 'User Dynamic Tool Access',
60
+ 'verbose_name_plural': 'User Dynamic Tool Access',
61
+ },
62
+ ),
63
+ ]
File without changes
@@ -0,0 +1,18 @@
1
+ """
2
+ Models for Django Agent Studio.
3
+
4
+ Includes permission and approval workflow models for dynamic tools.
5
+ """
6
+
7
+ from django_agent_studio.models.permissions import (
8
+ DynamicToolAccessLevel,
9
+ UserDynamicToolAccess,
10
+ ToolApprovalRequest,
11
+ )
12
+
13
+ __all__ = [
14
+ 'DynamicToolAccessLevel',
15
+ 'UserDynamicToolAccess',
16
+ 'ToolApprovalRequest',
17
+ ]
18
+
@@ -0,0 +1,191 @@
1
+ """
2
+ Permission models for Dynamic Tool access control.
3
+
4
+ Provides:
5
+ - Access levels for dynamic tool operations
6
+ - Per-user access assignments
7
+ - Approval workflow for tool creation requests
8
+ """
9
+
10
+ import uuid
11
+ from django.conf import settings
12
+ from django.db import models
13
+
14
+
15
+ class DynamicToolAccessLevel(models.TextChoices):
16
+ """
17
+ Access levels for dynamic tool operations.
18
+
19
+ Ordered from least to most privileged:
20
+ - NONE: No access to dynamic tools
21
+ - VIEWER: Can view discovered functions and existing tools
22
+ - SCANNER: Can scan project for functions
23
+ - REQUESTER: Can request tool creation (needs admin approval)
24
+ - CREATOR: Can create tools directly (no approval needed)
25
+ - ADMIN: Full access including approving requests and managing permissions
26
+ """
27
+ NONE = 'none', 'No Access'
28
+ VIEWER = 'viewer', 'Viewer (read-only)'
29
+ SCANNER = 'scanner', 'Scanner (can scan project)'
30
+ REQUESTER = 'requester', 'Requester (needs approval)'
31
+ CREATOR = 'creator', 'Creator (can create tools)'
32
+ ADMIN = 'admin', 'Admin (full access)'
33
+
34
+
35
+ # Define the hierarchy for permission checks
36
+ ACCESS_LEVEL_HIERARCHY = {
37
+ DynamicToolAccessLevel.NONE: 0,
38
+ DynamicToolAccessLevel.VIEWER: 1,
39
+ DynamicToolAccessLevel.SCANNER: 2,
40
+ DynamicToolAccessLevel.REQUESTER: 3,
41
+ DynamicToolAccessLevel.CREATOR: 4,
42
+ DynamicToolAccessLevel.ADMIN: 5,
43
+ }
44
+
45
+
46
+ class UserDynamicToolAccess(models.Model):
47
+ """
48
+ Per-user access level for dynamic tools.
49
+
50
+ If a user doesn't have a record, they default to NONE access
51
+ unless they're a superuser (who always have ADMIN access).
52
+ """
53
+
54
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
55
+
56
+ user = models.OneToOneField(
57
+ settings.AUTH_USER_MODEL,
58
+ on_delete=models.CASCADE,
59
+ related_name='dynamic_tool_access',
60
+ )
61
+
62
+ access_level = models.CharField(
63
+ max_length=20,
64
+ choices=DynamicToolAccessLevel.choices,
65
+ default=DynamicToolAccessLevel.NONE,
66
+ )
67
+
68
+ # Optional: restrict to specific agents
69
+ # If empty, access applies to all agents user can access
70
+ restricted_to_agents = models.ManyToManyField(
71
+ 'django_agent_runtime.AgentDefinition',
72
+ blank=True,
73
+ related_name='dynamic_tool_access_grants',
74
+ help_text='If set, access only applies to these agents',
75
+ )
76
+
77
+ # Audit fields
78
+ granted_by = models.ForeignKey(
79
+ settings.AUTH_USER_MODEL,
80
+ on_delete=models.SET_NULL,
81
+ null=True,
82
+ blank=True,
83
+ related_name='dynamic_tool_access_grants_given',
84
+ )
85
+ granted_at = models.DateTimeField(auto_now_add=True)
86
+ updated_at = models.DateTimeField(auto_now=True)
87
+
88
+ notes = models.TextField(
89
+ blank=True,
90
+ help_text='Reason for granting this access level',
91
+ )
92
+
93
+ class Meta:
94
+ verbose_name = 'User Dynamic Tool Access'
95
+ verbose_name_plural = 'User Dynamic Tool Access'
96
+
97
+ def __str__(self):
98
+ return f"{self.user} - {self.get_access_level_display()}"
99
+
100
+ def has_level(self, required_level: str) -> bool:
101
+ """Check if user has at least the required access level."""
102
+ user_rank = ACCESS_LEVEL_HIERARCHY.get(self.access_level, 0)
103
+ required_rank = ACCESS_LEVEL_HIERARCHY.get(required_level, 0)
104
+ return user_rank >= required_rank
105
+
106
+
107
+ class ToolApprovalRequest(models.Model):
108
+ """
109
+ Request for tool creation that requires admin approval.
110
+
111
+ Created when a user with REQUESTER access level wants to create a tool.
112
+ """
113
+
114
+ class Status(models.TextChoices):
115
+ PENDING = 'pending', 'Pending Review'
116
+ APPROVED = 'approved', 'Approved'
117
+ REJECTED = 'rejected', 'Rejected'
118
+ CANCELLED = 'cancelled', 'Cancelled by Requester'
119
+
120
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
121
+
122
+ # The agent this tool would be added to
123
+ agent = models.ForeignKey(
124
+ 'django_agent_runtime.AgentDefinition',
125
+ on_delete=models.CASCADE,
126
+ related_name='tool_approval_requests',
127
+ )
128
+
129
+ # The discovered function to convert to a tool
130
+ discovered_function = models.ForeignKey(
131
+ 'django_agent_runtime.DiscoveredFunction',
132
+ on_delete=models.CASCADE,
133
+ related_name='approval_requests',
134
+ )
135
+
136
+ # Requester's proposed configuration
137
+ proposed_name = models.CharField(max_length=100)
138
+ proposed_description = models.TextField()
139
+ proposed_is_safe = models.BooleanField(default=False)
140
+ proposed_requires_confirmation = models.BooleanField(default=True)
141
+ proposed_timeout_seconds = models.PositiveIntegerField(default=30)
142
+
143
+ # Request metadata
144
+ requester = models.ForeignKey(
145
+ settings.AUTH_USER_MODEL,
146
+ on_delete=models.CASCADE,
147
+ related_name='tool_approval_requests',
148
+ )
149
+ request_reason = models.TextField(
150
+ help_text='Why this tool is needed',
151
+ )
152
+
153
+ # Status
154
+ status = models.CharField(
155
+ max_length=20,
156
+ choices=Status.choices,
157
+ default=Status.PENDING,
158
+ )
159
+
160
+ # Review metadata
161
+ reviewed_by = models.ForeignKey(
162
+ settings.AUTH_USER_MODEL,
163
+ on_delete=models.SET_NULL,
164
+ null=True,
165
+ blank=True,
166
+ related_name='tool_approval_reviews',
167
+ )
168
+ reviewed_at = models.DateTimeField(null=True, blank=True)
169
+ review_notes = models.TextField(blank=True)
170
+
171
+ # If approved, link to the created tool
172
+ created_tool = models.ForeignKey(
173
+ 'django_agent_runtime.DynamicTool',
174
+ on_delete=models.SET_NULL,
175
+ null=True,
176
+ blank=True,
177
+ related_name='approval_request',
178
+ )
179
+
180
+ # Timestamps
181
+ created_at = models.DateTimeField(auto_now_add=True)
182
+ updated_at = models.DateTimeField(auto_now=True)
183
+
184
+ class Meta:
185
+ verbose_name = 'Tool Approval Request'
186
+ verbose_name_plural = 'Tool Approval Requests'
187
+ ordering = ['-created_at']
188
+
189
+ def __str__(self):
190
+ return f"{self.proposed_name} - {self.get_status_display()}"
191
+
@@ -0,0 +1,14 @@
1
+ """
2
+ Services for Django Agent Studio.
3
+ """
4
+
5
+ from django_agent_studio.services.permissions import (
6
+ DynamicToolPermissionService,
7
+ get_permission_service,
8
+ )
9
+
10
+ __all__ = [
11
+ 'DynamicToolPermissionService',
12
+ 'get_permission_service',
13
+ ]
14
+