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
1 change: 1 addition & 0 deletions ai_platform_engineering/dynamic_agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"PyJWT[crypto]==2.11.0",
"requests==2.32.3",
"beautifulsoup4==4.13.4",
"openshell>=0.0.7",
]

[project.optional-dependencies]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,12 @@ async def get_current_user(
# If auth is disabled, return a dev user with admin privileges
# This bypasses ALL auth, even if a token is sent
if not settings.auth_enabled:
logger.debug("Auth disabled (AUTH_ENABLED=false), returning dev user with admin privileges")
proxy_email = request.headers.get("X-User-Email")
email = proxy_email if proxy_email else "dev@localhost"
logger.debug("Auth disabled (AUTH_ENABLED=false), using identity: %s", email)
return UserContext(
email="dev@localhost",
name="Dev User",
email=email,
name=email.split("@")[0] if proxy_email else "Dev User",
groups=["admin"],
is_admin=True,
raw_claims={},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ class Settings(BaseSettings):
# Seed configuration path (for MCP servers and agents loaded at startup)
seed_config_path: str | None = None

# OpenShell sandbox
openshell_gateway: str | None = None # Override: connect directly to this endpoint
openshell_gateway_name: str = "openshell" # Gateway name used by auto-start
openshell_default_timeout: int = 1800 # Default command timeout (30 min)
openshell_cli_path: str = "openshell" # Path to openshell CLI binary


@lru_cache
def get_settings() -> Settings:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path

from dotenv import load_dotenv

from dynamic_agents.log_config import setup_logging

load_dotenv(Path(__file__).resolve().parents[2] / ".env", override=False)

# Setup logging before other imports that trigger cnoe-agent-utils
logger = setup_logging()

Expand Down Expand Up @@ -104,6 +109,9 @@ def create_app() -> FastAPI:
app.include_router(chat.router, prefix="/api/v1")
app.include_router(conversations.router, prefix="/api/v1")

from dynamic_agents.routes import sandbox
app.include_router(sandbox.router, prefix="/api/v1")

@app.get("/")
async def root():
"""Root endpoint."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class MCPServerConfigBase(BaseModel):
command: str | None = Field(None, description="Command for stdio transport")
args: list[str] | None = Field(None, description="Args for stdio transport")
env: dict[str, str] | None = Field(None, description="Env vars for stdio transport")
cwd: str | None = Field(None, description="Working directory for stdio transport")
enabled: bool = Field(True, description="Whether the server is enabled")


Expand All @@ -76,6 +77,7 @@ class MCPServerConfigUpdate(BaseModel):
command: str | None = None
args: list[str] | None = None
env: dict[str, str] | None = None
cwd: str | None = None
enabled: bool | None = None


Expand Down Expand Up @@ -143,6 +145,15 @@ class BuiltinToolDefinition(BaseModel):
name: str = Field(..., description="Display name")
description: str = Field(..., description="What the tool does")
enabled_by_default: bool = Field(True, description="Whether enabled by default for new agents")
runs_in_sandbox: bool = Field(
True,
description="Whether the tool runs inside the sandbox. "
"False means it runs on the host and bypasses sandbox policies.",
)
sandbox_warning: str | None = Field(
None,
description="Warning text shown when sandbox is enabled and this tool runs outside it.",
)
config_fields: list[BuiltinToolConfigField] = Field(
default_factory=list,
description="Configurable fields for this tool",
Expand Down Expand Up @@ -253,6 +264,41 @@ class InputField(BaseModel):
value: str | None = Field(None, description="User-provided value (populated when form is submitted)")


# =============================================================================
# Sandbox Config
# =============================================================================


class SandboxPolicyTemplate(str, Enum):
"""Available sandbox policy templates."""

PERMISSIVE = "permissive"
RESTRICTIVE = "restrictive"
CUSTOM = "custom"


class SandboxConfig(BaseModel):
"""OpenShell sandbox configuration for isolated agent execution."""

enabled: bool = Field(False, description="Enable OpenShell sandbox for this agent")
sandbox_name: str | None = Field(
None,
description="Persistent sandbox name. Auto-generated from agent name if not provided.",
)
gateway_url: str | None = Field(
None,
description="OpenShell gateway URL. Uses platform default if not provided.",
)
policy_template: SandboxPolicyTemplate = Field(
SandboxPolicyTemplate.PERMISSIVE,
description="Starting policy template (permissive pre-allows common tools, restrictive is minimal)",
)
policy_yaml: str | None = Field(
None,
description="Custom policy YAML. Only used when policy_template is 'custom'.",
)


# =============================================================================
# Agent UI Config
# =============================================================================
Expand Down Expand Up @@ -296,6 +342,10 @@ class DynamicAgentConfigBase(BaseModel):
None,
description="Configuration for built-in tools (fetch_url, etc.)",
)
sandbox: SandboxConfig | None = Field(
None,
description="OpenShell sandbox configuration for isolated execution",
)
ui: AgentUIConfig | None = Field(
None,
description="UI configuration (gradient theme, etc.)",
Expand All @@ -322,6 +372,7 @@ class DynamicAgentConfigUpdate(BaseModel):
shared_with_teams: list[str] | None = None
subagents: list[SubAgentRef] | None = None
builtin_tools: BuiltinToolsConfig | None = None
sandbox: SandboxConfig | None = None
ui: AgentUIConfig | None = None
enabled: bool | None = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import logging

from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query

from dynamic_agents.auth.access import can_view_agent
from dynamic_agents.auth.auth import UserContext, get_current_user, require_admin
from dynamic_agents.config import Settings, get_settings
from dynamic_agents.models import (
ApiResponse,
DynamicAgentConfigCreate,
Expand All @@ -15,6 +16,7 @@
VisibilityType,
)
from dynamic_agents.services.mongo import MongoDBService, get_mongo_service
from dynamic_agents.services.sandbox import get_sandbox_manager

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -107,12 +109,16 @@ async def list_agents(
@router.post("", response_model=ApiResponse)
async def create_agent(
config: DynamicAgentConfigCreate,
background_tasks: BackgroundTasks,
user: UserContext = Depends(require_admin),
mongo: MongoDBService = Depends(get_mongo_service),
settings: Settings = Depends(get_settings),
) -> ApiResponse:
"""Create a new dynamic agent configuration.

Requires admin role.
Requires admin role. When sandbox is enabled the OpenShell gateway
and sandbox container are provisioned in the background so they are
ready by the time the user opens a chat.
"""
# Validate subagent visibility compatibility
if config.subagents:
Expand All @@ -132,12 +138,55 @@ async def create_agent(

logger.info(f"Created dynamic agent '{config.name}' ({agent.id}) by {user.email}")

if config.sandbox and config.sandbox.enabled:
sandbox_name = config.sandbox.sandbox_name or f"da-{agent.id}"
template = config.sandbox.policy_template.value if config.sandbox.policy_template else "permissive"
custom_yaml = config.sandbox.policy_yaml
background_tasks.add_task(
_provision_sandbox, sandbox_name, template, custom_yaml, settings
)

return ApiResponse(
success=True,
data=agent.model_dump(by_alias=True),
)


def _provision_sandbox(
sandbox_name: str,
template: str,
custom_yaml: str | None,
settings: Settings,
) -> None:
"""Provision an OpenShell sandbox in the background.

Ensures the gateway is running, creates the named sandbox, and
applies the initial policy so everything is ready before the first
chat message.
"""
try:
mgr = get_sandbox_manager(settings)
mgr.get_or_create_sandbox(sandbox_name)
policy_result = mgr.initialize_policy(
sandbox_name, template=template, custom_yaml=custom_yaml,
)
policy_status = policy_result.get("status", "unknown")
if policy_status == "loaded":
logger.info(
"[sandbox] Provisioned sandbox '%s' at agent creation time",
sandbox_name,
)
else:
logger.error(
"[sandbox] Sandbox '%s' provisioned but policy failed: %s — %s",
sandbox_name,
policy_status,
policy_result.get("error", "unknown"),
)
except Exception:
logger.exception("[sandbox] Background provisioning failed for '%s'", sandbox_name)


@router.get("/{agent_id}", response_model=ApiResponse)
async def get_agent(
agent_id: str,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Chat endpoint for Dynamic Agents with SSE streaming."""

import asyncio
import json
import logging
from typing import AsyncGenerator
Expand All @@ -14,6 +15,11 @@
from dynamic_agents.models import ChatRequest, DynamicAgentConfig, UserContext
from dynamic_agents.services.agent_runtime import get_runtime_cache
from dynamic_agents.services.mongo import MongoDBService, get_mongo_service
from dynamic_agents.services.sandbox import get_sandbox_manager
from dynamic_agents.services.stream_events import (
make_sandbox_denial_event,
make_sandbox_policy_update_event,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -77,27 +83,56 @@ async def _generate_sse_events(
user=user,
)

# Stream response with trace_id for Langfuse tracing
async for event in runtime.stream(message, session_id, user.email, trace_id):
event_type = event.get("type", "event")
event_data = event.get("data", "")
namespace = event.get("namespace", [])

# Format as SSE - include namespace in data payload
if isinstance(event_data, dict):
# Add namespace to dict data
event_data["namespace"] = namespace
data = json.dumps(event_data)
else:
# For content events (string data), wrap with namespace
data = json.dumps({"text": event_data, "namespace": namespace})

# Use proper SSE encoding (handles newlines in content)
sse_data = _encode_sse_data(data)
yield f"event: {event_type}\n{sse_data}\n\n"

# Send done event
yield "event: done\ndata: {}\n\n"
# Subscribe to sandbox events if sandbox is enabled
sandbox_sub: asyncio.Queue | None = None
sandbox_name: str | None = getattr(runtime, "_sandbox_name", None)
mgr = None
if sandbox_name:
mgr = get_sandbox_manager()
sandbox_sub = mgr.subscribe(sandbox_name)
await mgr.start_watch(sandbox_name)

try:
# Stream response with trace_id for Langfuse tracing
async for event in runtime.stream(message, session_id, user.email, trace_id):
event_type = event.get("type", "event")
event_data = event.get("data", "")
namespace = event.get("namespace", [])

# Format as SSE - include namespace in data payload
if isinstance(event_data, dict):
event_data["namespace"] = namespace
data = json.dumps(event_data)
else:
data = json.dumps({"text": event_data, "namespace": namespace})

sse_data = _encode_sse_data(data)
yield f"event: {event_type}\n{sse_data}\n\n"

# Drain pending sandbox events (denials + policy updates)
if sandbox_sub:
while not sandbox_sub.empty():
try:
item = sandbox_sub.get_nowait()
if item.get("_type") == "policy_update":
evt = make_sandbox_policy_update_event(
sandbox_name=item["sandbox_name"],
status=item["status"],
rule_id=item.get("rule_id"),
)
else:
evt = make_sandbox_denial_event(item)
evt_data = json.dumps(evt["data"])
evt_sse = _encode_sse_data(evt_data)
yield f"event: {evt['type']}\n{evt_sse}\n\n"
except asyncio.QueueEmpty:
break

# Send done event
yield "event: done\ndata: {}\n\n"
finally:
if sandbox_sub and sandbox_name and mgr:
mgr.unsubscribe(sandbox_name, sandbox_sub)

except Exception as e:
logger.exception(f"Error streaming from agent '{agent_config.name}'")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,43 @@ class FileContentResponse(BaseModel):
content: str


@router.get("/by-agent")
async def list_conversations_by_agent(
agent_id: str = Query(..., description="Dynamic agent ID"),
limit: int = Query(20, ge=1, le=100),
user: UserContext = Depends(get_current_user),
mongo: MongoDBService = Depends(get_mongo_service),
) -> dict:
"""List recent conversations for a given agent.

Returns conversation metadata (id, title, timestamps) sorted by
most recently updated first.
"""
if mongo._client is None:
raise HTTPException(status_code=503, detail="Database not connected")
db = mongo._db
if db is None:
raise HTTPException(status_code=503, detail="Database not connected")

conversations_coll = db["conversations"]
query: dict = {"agent_id": agent_id}

if not getattr(user, "is_admin", False):
query["owner_id"] = user.email

cursor = (
conversations_coll.find(query, {"_id": 1, "title": 1, "created_at": 1, "updated_at": 1, "agent_id": 1})
.sort("updated_at", -1)
.limit(limit)
)
conversations = []
for doc in cursor:
doc["id"] = doc.pop("_id", doc.get("id"))
conversations.append(doc)

return {"conversations": conversations}


@router.get("/{conversation_id}/messages", response_model=ConversationMessagesResponse)
async def get_conversation_messages(
conversation_id: str,
Expand Down
Loading
Loading