Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 228 additions & 13 deletions backend/core/agentpress/response_processor.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions backend/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .notifications.api import router as novu_notifications_router
from .notifications.presence_api import router as presence_router
from .feedback import router as feedback_router
from .routes.permissions import router as permissions_router

router = APIRouter()

Expand All @@ -32,6 +33,7 @@
router.include_router(novu_notifications_router)
router.include_router(presence_router)
router.include_router(feedback_router)
router.include_router(permissions_router)

# Re-export the initialize and cleanup functions
__all__ = ['router', 'initialize', 'cleanup']
9 changes: 7 additions & 2 deletions backend/core/api_models/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# Import PaginationInfo directly to avoid forward reference issues
from .common import PaginationInfo
from .permissions import ToolPermissionSettings


class AgentCreateRequest(BaseModel):
Expand All @@ -18,6 +19,7 @@ class AgentCreateRequest(BaseModel):
icon_name: Optional[str] = None
icon_color: Optional[str] = None
icon_background: Optional[str] = None
permission_settings: Optional[ToolPermissionSettings] = None


class AgentUpdateRequest(BaseModel):
Expand All @@ -34,6 +36,7 @@ class AgentUpdateRequest(BaseModel):
icon_color: Optional[str] = None
icon_background: Optional[str] = None
replace_mcps: Optional[bool] = None
permission_settings: Optional[ToolPermissionSettings] = None


class AgentVersionResponse(BaseModel):
Expand All @@ -51,6 +54,7 @@ class AgentVersionResponse(BaseModel):
created_at: str
updated_at: str
created_by: Optional[str] = None
permission_settings: Optional[ToolPermissionSettings] = None


class AgentVersionCreateRequest(BaseModel):
Expand All @@ -60,6 +64,7 @@ class AgentVersionCreateRequest(BaseModel):
custom_mcps: Optional[List[Dict[str, Any]]] = []
agentpress_tools: Optional[Dict[str, Any]] = {}
version_name: Optional[str] = None
permission_settings: Optional[ToolPermissionSettings] = None


class AgentResponse(BaseModel):
Expand All @@ -85,6 +90,7 @@ class AgentResponse(BaseModel):
current_version: Optional[AgentVersionResponse] = None
metadata: Optional[Dict[str, Any]] = None
account_id: Optional[str] = None # Internal field, may not always be needed in response
permission_settings: Optional[ToolPermissionSettings] = None


class AgentsResponse(BaseModel):
Expand Down Expand Up @@ -112,6 +118,7 @@ class AgentExportData(BaseModel):
export_version: str = "1.1"
exported_at: str
exported_by: Optional[str] = None
permission_settings: Optional[ToolPermissionSettings] = None


class AgentImportRequest(BaseModel):
Expand All @@ -131,5 +138,3 @@ class AgentIconGenerationResponse(BaseModel):
icon_name: str
icon_color: str
icon_background: str


33 changes: 33 additions & 0 deletions backend/core/api_models/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from enum import Enum
from typing import Optional, List, Dict, Any, Union
from pydantic import BaseModel, Field

class PermissionMode(str, Enum):
TURBO = "turbo"
AGENT_DECIDE = "agent_decide"
STRICT = "strict"

class ParameterConstraint(BaseModel):
parameter_name: str
constraint_type: str # e.g., "regex", "exact_match", "list"
constraint_value: Any

class ToolPermissionOverride(BaseModel):
tool_name: str
mode: Optional[PermissionMode] = None
parameter_constraints: Optional[List[ParameterConstraint]] = None

class ToolPermissionSettings(BaseModel):
default_mode: PermissionMode = Field(default=PermissionMode.TURBO)
global_whitelist: List[str] = Field(default_factory=list)
global_blacklist: List[str] = Field(default_factory=list)
tool_overrides: Dict[str, ToolPermissionOverride] = Field(default_factory=dict)

class PermissionGrant(BaseModel):
"""
Represents a temporary permission grant for a tool.
Used in thread metadata to track approved tools.
"""
tool_name: str
granted_at: str # ISO timestamp
expires_on_success: bool = True
5 changes: 5 additions & 0 deletions backend/core/prompts/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,11 @@

# 3. TOOLKIT & METHODOLOGY

## 3.0 PERMISSION & AUTONOMY
- **AUTONOMY**: You have the autonomy to run tools directly when you are confident they are necessary for the task.
- **ASKING PERMISSION**: If you are unsure about running a tool, or if the action is sensitive (e.g., deleting data, spending money), you can use the `ask_permission` tool to explicitly request user approval before proceeding.
- **SYSTEM ENFORCEMENT**: The system may also enforce permissions based on user settings. If a tool requires permission, the system will pause execution and ask the user for you. You will be notified if a tool execution was blocked pending approval.

## 3.1 TOOL SELECTION PRINCIPLES
- CLI TOOLS PREFERENCE:
* Always prefer CLI tools over Python scripts when possible
Expand Down
214 changes: 214 additions & 0 deletions backend/core/routes/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel
from typing import Dict, Any, Optional

from core.utils.auth_utils import verify_and_get_user_id_from_jwt
from core.services.permission_service import PermissionService
from core.services.supabase import DBConnection
from core.utils.logger import logger
from core.tools.tool_registry import get_all_tools
from core.agentpress.tool import ToolResult

router = APIRouter(tags=["permissions"])

class PermissionActionRequest(BaseModel):
message_id: str

@router.post("/threads/{thread_id}/permissions/approve", summary="Approve a pending tool execution")
async def approve_tool_execution(
thread_id: str,
request: PermissionActionRequest,
user_id: str = Depends(verify_and_get_user_id_from_jwt)
):
"""
Approve a tool execution request.
1. Grants temporary permission.
2. Executes the tool.
3. Saves the result.
4. Returns the result to the caller.
"""
logger.info(f"Approving tool execution for thread {thread_id}, message {request.message_id}")

db = DBConnection()
client = await db.client

# 1. Fetch the request message to get tool details
msg_result = await client.table('messages').select('*').eq('message_id', request.message_id).eq('thread_id', thread_id).execute()
if not msg_result.data:
raise HTTPException(status_code=404, detail="Permission request message not found")

message = msg_result.data[0]
content = message.get('content', {})

# Validate it's a permission request
if message.get('type') != 'status' or content.get('status_type') != 'tool_permission_request':
raise HTTPException(status_code=400, detail="Invalid message type for approval")

tool_name = content.get('function_name')
arguments = content.get('arguments')
# tool_call_id might be missing for XML tools or implicit requests

if not tool_name:
raise HTTPException(status_code=400, detail="Tool name missing in request")

# 2. Grant temporary permission
# Fetch thread metadata
thread_result = await client.table('threads').select('metadata').eq('thread_id', thread_id).single().execute()
metadata = thread_result.data.get('metadata') or {}

updated_metadata = PermissionService.grant_temporary_permission(metadata, tool_name)
await client.table('threads').update({'metadata': updated_metadata}).eq('thread_id', thread_id).execute()

# 3. Execute the tool
# We need to instantiate the tool. This is tricky because some tools need dependencies.
# ResponseProcessor usually handles this via ToolRegistry/ToolManager.
# We can use the registry to get the tool class, but instantiation might need args.

# Simpler approach:
# Use ToolRegistry to get the function directly if it's stateless?
# Or create a minimal ToolManager?

# Let's try to get the tool function from the registry.
# Most tools registered via `ToolManager` are instantiated with context.
# We can try to re-instantiate the tool class.

from core.tools.tool_registry import ToolRegistry
# We need to know which class this tool belongs to.
# The registry stores instances. We don't have a global registry instance with live tools.
# We have `core.utils.tool_discovery.discover_tools()` which maps names to classes.

from core.utils.tool_discovery import discover_tools, STATELESS_TOOLS
tools_map = discover_tools()
tool_class = tools_map.get(tool_name)

# If not found in static map, it might be an MCP tool or dynamically registered.
# If it's an MCP tool, we might need to spin up the MCP wrapper.
# This is getting complex for a simple endpoint.

# Alternative Strategy:
# Just grant permission and return "Approved".
# The Frontend then triggers a "Run" (e.g. sends a hidden "continue" message or just calls run).
# BUT, the "Strict Mode" flow implies the system blocked a specific call.
# If we just grant permission and "continue", the agent might generate a NEW call.
# We want to execute THAT specific blocked call.

# If we can't easily execute it here, we should perhaps instruct the frontend to send a new message
# that *looks* like the tool call? No, that's messy.

# Best path: Re-use `ToolManager` logic if possible.
# `backend/core/run.py` sets up `ToolManager`.
# Maybe we can instantiate `ToolManager` here?

from core.run import ToolManager
from core.agentpress.thread_manager import ThreadManager

# We need project_id.
project_id = thread_result.data.get('project_id')
# We need agent_config (maybe)

thread_manager = ThreadManager()
# We need to register tools.
# This duplicates `run_agent` setup logic.

# Let's try a lighter approach:
# If it's a native tool, we can try to instantiate.
# If it's MCP, we need the MCP setup.

# Given the complexity of setting up the environment (MCP connections, etc.) just to run one tool,
# maybe we should rely on the `run_agent` loop?
# Logic:
# 1. Grant permission.
# 2. Add a special system message "User approved the execution of tool X".
# 3. Trigger `run_agent`?
# BUT `run_agent` will generate a *new* completion. We want to execute the *pending* one.

# Okay, let's look at Flow A again: "System automatically runs...".
# This implies the backend does it.

# We must instantiate the tools.
# Let's grab `AgentRunner` from `core.run` and use it to setup tools, then pick the specific tool to run.

from core.run import AgentConfig, AgentRunner

# Need to reconstruct AgentConfig
# Fetch agent info
agent_id = None # Need to find agent_id from thread or message?
# Messages have agent_id.
agent_id = message.get('agent_id')

# We need the agent config to setup MCPs properly.
agent_config = None
if agent_id:
from core.services.supabase import DBConnection
# ... fetch agent config ...
# This is heavy but necessary for correctness.
# (Skipping deep fetch code for brevity, assuming we can get a minimal runner)

# Wait, if we just grant permission, the user can click "Retry" on the frontend?
# Or we return "Permission Granted, please resume".

# Let's stick to: Grant Permission + Return Success.
# Let the Frontend trigger the tool execution via a new mechanism?
# No, the plan says "Execute the tool".

# Let's assume we can execute it if we set up the environment.
# If we can't easily do it, we'll mark it as approved and let the agent retry.
# "User approved X. Please try running X again." -> Agent runs X -> Permission check passes -> Success.
# This effectively implements Flow B but hidden from the user?
# User clicks Approve -> System adds "User approved" invisible message -> System triggers Agent Run.
# Agent sees "User approved" -> Generates Tool Call -> Runs.

# PRO: No complex tool instantiation in this endpoint.
# CON: Extra LLM token cost (generating the tool call again).
# CON: Non-deterministic (Agent might change its mind).

# Decision: We will attempt to execute if it's a simple tool.
# If it fails setup, we fallback to the "Resume" strategy?
# No, let's implement the "Resume Strategy" as the primary robust solution.
# It guarantees the environment is correct because `run_agent` sets it up.

# REVISED APPROVAL LOGIC:
# 1. Grant permission in DB.
# 2. Add a 'system' message to the thread: "User approved the execution of {tool_name} with arguments {arguments}."
# 3. Return "Approved".
# 4. Frontend receives "Approved" -> Triggers standard `agent/run` (or socket emit).

# Wait, if we insert a system message, the LLM sees it and generates the tool call again.
# This fits the "Autonomy" model well.
# "I need permission." -> "Permission granted." -> "Okay, executing [Tool]."

msg_content = f"User approved the execution of tool '{tool_name}'."
await client.table('messages').insert({
'thread_id': thread_id,
'type': 'system', # or 'user' acting as system? 'system' is better.
'content': msg_content,
'is_llm_message': True
}).execute()

return {"status": "approved", "message": "Permission granted. Resuming agent."}

@router.post("/threads/{thread_id}/permissions/deny", summary="Deny a pending tool execution")
async def deny_tool_execution(
thread_id: str,
request: PermissionActionRequest,
user_id: str = Depends(verify_and_get_user_id_from_jwt)
):
"""
Deny a tool execution request.
1. Adds a message "User denied permission".
2. Returns status.
"""
logger.info(f"Denying tool execution for thread {thread_id}")

db = DBConnection()
client = await db.client

msg_content = "User denied the execution of this tool."
await client.table('messages').insert({
'thread_id': thread_id,
'type': 'system',
'content': msg_content,
'is_llm_message': True
}).execute()

return {"status": "denied", "message": "Permission denied."}
Loading