diff --git a/backend/alembic/versions/026_seed_code_interpreter_tool.py b/backend/alembic/versions/026_seed_code_interpreter_tool.py new file mode 100644 index 0000000..9c14ebb --- /dev/null +++ b/backend/alembic/versions/026_seed_code_interpreter_tool.py @@ -0,0 +1,96 @@ +"""seed code_interpreter into tool_definitions + +Revision ID: 026 +Revises: 025_agent_builder_tools +Create Date: 2026-03-30 + +Registers the code_interpreter tool in the tool_definitions table so it +can be assigned to agents via tool_ids and resolved by the chat endpoint. +""" +import json +import uuid + +import sqlalchemy as sa +from alembic import op + +revision = '026_seed_code_interpreter_tool' +down_revision = '025_agent_builder_tools' +branch_labels = None +depends_on = None + +_TOOL = { + "name": "code_interpreter", + "display_name": "Code Interpreter", + "description": ( + "Execute code in a secure sandboxed environment. " + "Supports Python, JavaScript, TypeScript, Go, Java, C, C++, Rust, PHP, R, Bash, and Fortran. " + "Use for calculations, data analysis, generating files, or any task that benefits from running code. " + "Python sessions maintain state between calls within the same conversation." + ), + "category": "code_execution", + "is_enabled": True, + "allowed_modes": [], + "requires_graph_consent": False, + "required_scopes": [], + "parameter_schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The code to execute.", + }, + "language": { + "type": "string", + "enum": [ + "python", "javascript", "typescript", "go", "java", + "c", "cpp", "rust", "php", "r", "bash", "fortran", + ], + "description": "Programming language to use. Defaults to python.", + "default": "python", + }, + }, + "required": ["code"], + }, +} + + +def upgrade(): + conn = op.get_bind() + conn.execute( + sa.text(""" + INSERT INTO tool_definitions ( + id, name, display_name, description, category, + is_enabled, allowed_modes, requires_graph_consent, + required_scopes, parameter_schema, created_at, updated_at + ) VALUES ( + CAST(:id AS uuid), :name, :display_name, :description, :category, + :is_enabled, CAST(:allowed_modes AS jsonb), :requires_graph_consent, + CAST(:required_scopes AS jsonb), CAST(:parameter_schema AS jsonb), + now(), now() + ) + ON CONFLICT (name) DO UPDATE SET + display_name = EXCLUDED.display_name, + description = EXCLUDED.description, + is_enabled = EXCLUDED.is_enabled, + updated_at = now() + """), + { + "id": str(uuid.uuid4()), + "name": _TOOL["name"], + "display_name": _TOOL["display_name"], + "description": _TOOL["description"], + "category": _TOOL["category"], + "is_enabled": _TOOL["is_enabled"], + "allowed_modes": json.dumps(_TOOL["allowed_modes"]), + "requires_graph_consent": _TOOL["requires_graph_consent"], + "required_scopes": json.dumps(_TOOL["required_scopes"]), + "parameter_schema": json.dumps(_TOOL["parameter_schema"]), + }, + ) + + +def downgrade(): + conn = op.get_bind() + conn.execute( + sa.text("DELETE FROM tool_definitions WHERE name = 'code_interpreter'"), + ) diff --git a/backend/alembic/versions/027_add_enable_code_interpreter.py b/backend/alembic/versions/027_add_enable_code_interpreter.py new file mode 100644 index 0000000..cde695e --- /dev/null +++ b/backend/alembic/versions/027_add_enable_code_interpreter.py @@ -0,0 +1,33 @@ +"""add enable_code_interpreter column to agents table + +Revision ID: 027 +Revises: 026_seed_code_interpreter_tool +Create Date: 2026-03-30 + +Adds a boolean flag to each agent that controls whether the code_interpreter +tool is automatically injected into the agent's available tools during chat, +regardless of the agent's tool_ids list. +""" +import sqlalchemy as sa +from alembic import op + +revision = '027_add_enable_code_interpreter' +down_revision = '026_seed_code_interpreter_tool' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'agents', + sa.Column( + 'enable_code_interpreter', + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade(): + op.drop_column('agents', 'enable_code_interpreter') diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index 330b1c7..3e36c94 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -165,6 +165,10 @@ async def update_user( raise HTTPException(status_code=400, detail="Department not found") target_user.department_id = request.department_id if request.department_id else None if request.region_code is not None: + if request.region_code: + region_row = await db.execute(select(Region).where(Region.code == request.region_code)) + if not region_row.scalar_one_or_none(): + raise HTTPException(status_code=400, detail=f"Region code '{request.region_code}' not found") target_user.region_code = request.region_code if request.region_code else None await db.commit() diff --git a/backend/app/api/v1/endpoints/agent_analytics.py b/backend/app/api/v1/endpoints/agent_analytics.py new file mode 100644 index 0000000..52b8a00 --- /dev/null +++ b/backend/app/api/v1/endpoints/agent_analytics.py @@ -0,0 +1,102 @@ +""" +Per-agent analytics endpoint + +GET /agents/{slug}/analytics?days=30 + +Returns usage statistics for a single agent: +- Total messages, tokens, conversations, unique users +- First/last used timestamps +- Daily message time series +""" +import logging +from datetime import datetime, timezone, timedelta +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.core.dependencies import get_current_user +from app.models.user import User +from app.models.agent import Agent +from app.models.conversation import Conversation, Message +from app.schemas.agent_analytics import AgentAnalyticsResponse, DailyCount +from app.services.agent_usage import get_agent_usage_stats + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _is_visible(agent: Agent, user: User) -> bool: + """Same visibility check as agents.py.""" + if agent.status != "active": + return str(agent.created_by) == str(user.id) + if agent.visibility == "public": + return True + if agent.visibility == "private": + return str(agent.created_by) == str(user.id) + if agent.visibility == "department": + dept_id = str(user.department_id) if user.department_id else None + return dept_id in (agent.allowed_department_ids or []) + return False + + +@router.get("/{slug}/analytics", response_model=AgentAnalyticsResponse) +async def get_agent_analytics( + slug: str, + days: int = Query(30, ge=1, le=365, description="Number of past days to include"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Get usage analytics for a specific agent. + + Returns total messages, tokens, conversations, unique users, + first/last used timestamps, and a daily message time series. + """ + result = await db.execute(select(Agent).where(Agent.slug == slug)) + agent = result.scalar_one_or_none() + if not agent or not _is_visible(agent, current_user): + raise HTTPException(status_code=404, detail="Agent not found") + + # All-time + period-bounded stats + stats = await get_agent_usage_stats(db, agent.id, days=days) + + # Daily message time series for the period + since = datetime.now(timezone.utc) - timedelta(days=days) + + # Subquery: conversation IDs for this agent in the period + conv_ids_subq = select(Conversation.id).where( + Conversation.agent_id == agent.id, + Conversation.created_at >= since, + ).scalar_subquery() + + daily_rows = await db.execute( + select( + func.date(Message.created_at).label("day"), + func.count(Message.id).label("count"), + ).where( + Message.conversation_id.in_(conv_ids_subq), + Message.role == "assistant", + Message.created_at >= since, + ).group_by("day").order_by("day") + ) + + daily_messages: List[DailyCount] = [ + DailyCount(date=str(row.day), count=row.count) + for row in daily_rows + ] + + return AgentAnalyticsResponse( + agent_slug=agent.slug, + period_days=days, + total_messages=stats["total_messages"], + total_tokens=stats["total_tokens"], + conversations=stats["conversations"], + unique_users=stats["unique_users"], + first_used=stats["first_used"], + last_used=stats["last_used"], + daily_messages=daily_messages, + ) diff --git a/backend/app/api/v1/endpoints/agent_execute.py b/backend/app/api/v1/endpoints/agent_execute.py new file mode 100644 index 0000000..3dfe271 --- /dev/null +++ b/backend/app/api/v1/endpoints/agent_execute.py @@ -0,0 +1,243 @@ +""" +Programmatic Agent Execution Endpoint + +POST /agents/{slug}/execute + +Synchronous (non-SSE) agent execution — runs the full agentic loop and returns +a single JSON response. Suitable for external system integrations and API clients. +""" +import logging +import time +from typing import Any, Dict, List, Optional +from uuid import UUID, uuid4 +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.core.dependencies import get_current_user +from app.models.user import User +from app.models.agent import Agent +from app.models.conversation import Conversation, Message +from app.core.llm import LLMFactory +from app.tools import ToolRegistry, ToolContext +from app.core.graph_token_manager import GraphTokenManager +from app.schemas.agent_execute import AgentExecuteRequest, AgentExecuteResponse, ToolCallRecord + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _is_visible(agent: Agent, user: User) -> bool: + if agent.status != "active": + return str(agent.created_by) == str(user.id) + if agent.visibility == "public": + return True + if agent.visibility == "private": + return str(agent.created_by) == str(user.id) + if agent.visibility == "department": + dept_id = str(user.department_id) if user.department_id else None + return dept_id in (agent.allowed_department_ids or []) + return False + + +async def _build_history(db: AsyncSession, conversation_id: UUID, limit: int = 20) -> List[Dict[str, Any]]: + result = await db.execute( + select(Message) + .where(Message.conversation_id == conversation_id) + .order_by(Message.created_at.asc()) + .limit(limit) + ) + messages = result.scalars().all() + return [{"role": m.role, "content": m.content} for m in messages] + + +@router.post("/{slug}/execute", response_model=AgentExecuteResponse) +async def execute_agent( + slug: str, + request: AgentExecuteRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Programmatically execute an agent with a message and receive a synchronous JSON response. + + Unlike the chat SSE endpoint, this returns the full response after the agentic loop completes. + Suitable for integrations, automation, and API clients. + """ + # Load and verify agent + result = await db.execute(select(Agent).where(Agent.slug == slug)) + agent = result.scalar_one_or_none() + if not agent or not _is_visible(agent, current_user): + raise HTTPException(status_code=404, detail="Agent not found") + + if agent.enable_rag: + raise HTTPException( + status_code=400, + detail="RAG agents do not support programmatic execution via this endpoint. Use the chat SSE endpoint.", + ) + + # Get or create conversation + conversation: Optional[Conversation] = None + if request.conversation_id: + try: + conv_id = UUID(request.conversation_id) + conv_result = await db.execute( + select(Conversation).where( + Conversation.id == conv_id, + Conversation.user_id == current_user.id, + Conversation.agent_id == agent.id, + ) + ) + conversation = conv_result.scalar_one_or_none() + except (ValueError, Exception): + pass + + if conversation is None: + conversation = Conversation( + id=uuid4(), + user_id=current_user.id, + mode=agent.slug, + agent_id=agent.id, + title=request.message[:80], + ) + db.add(conversation) + await db.flush() + + # Build conversation history + history = await _build_history(db, conversation.id) + + # Save user message + user_msg = Message( + id=uuid4(), + conversation_id=conversation.id, + role="user", + content=request.message, + ) + db.add(user_msg) + await db.flush() + + # Build messages for LLM + messages = [{"role": "system", "content": agent.system_prompt}] + messages.extend(history) + messages.append({"role": "user", "content": request.message}) + + # Resolve tools + if agent.tool_ids: + enabled_tools = await ToolRegistry.get_tools_by_names(agent.tool_ids, db) + else: + enabled_tools = await ToolRegistry.get_enabled_tools(agent.slug, db) + + if agent.enable_code_interpreter: + ci_tool = ToolRegistry.get_tool("code_interpreter") + if ci_tool and ci_tool not in enabled_tools: + enabled_tools = list(enabled_tools) + [ci_tool] + + # Execute + full_response = "" + output_tokens = 0 + collected_tool_calls: List[ToolCallRecord] = [] + + if enabled_tools: + graph_token = await GraphTokenManager.get_valid_token(db, current_user.id) + tool_context = ToolContext( + user_id=current_user.id, + user_email=current_user.email, + db_session=db, + graph_token=graph_token, + conversation_id=conversation.id, + agent_slug=agent.slug, + ) + + llm = LLMFactory.get_llm_from_config( + provider=agent.llm_provider, + model=agent.llm_model, + temperature=agent.temperature, + max_tokens=agent.max_tokens, + ) + + current_tool: Optional[Dict[str, Any]] = None + tool_start_ms: Optional[int] = None + + async for event in LLMFactory.stream_with_tools( + mode=agent.slug, + messages=messages, + tools=enabled_tools, + tool_context=tool_context, + llm_override=llm, + ): + if "token" in event: + full_response += event["token"] + output_tokens += 1 + elif "tool_start" in event: + ts = event["tool_start"] + current_tool = { + "name": ts["name"], + "display_name": ts.get("display_name", ts["name"]), + "arguments": ts.get("arguments", {}), + } + tool_start_ms = int(time.time() * 1000) + elif "tool_result" in event: + tr = event["tool_result"] + duration = None + if tool_start_ms is not None: + duration = int(time.time() * 1000) - tool_start_ms + collected_tool_calls.append(ToolCallRecord( + name=tr["name"], + display_name=tr.get("display_name", tr["name"]), + arguments=current_tool.get("arguments", {}) if current_tool else {}, + result=tr.get("data"), + success=True, + duration_ms=duration, + )) + current_tool = None + tool_start_ms = None + elif "tool_error" in event: + te = event["tool_error"] + duration = None + if tool_start_ms is not None: + duration = int(time.time() * 1000) - tool_start_ms + collected_tool_calls.append(ToolCallRecord( + name=te["name"], + display_name=te.get("display_name", te["name"]), + arguments=current_tool.get("arguments", {}) if current_tool else {}, + success=False, + error=te.get("error"), + duration_ms=duration, + )) + current_tool = None + tool_start_ms = None + else: + llm = LLMFactory.get_llm_from_config( + provider=agent.llm_provider, + model=agent.llm_model, + temperature=agent.temperature, + max_tokens=agent.max_tokens, + ) + async for token in LLMFactory.stream_completion_with_llm(llm, messages): + full_response += token + output_tokens += 1 + + # Save assistant message + assistant_msg = Message( + id=uuid4(), + conversation_id=conversation.id, + role="assistant", + content=full_response, + output_tokens=output_tokens, + llm_provider=agent.llm_provider, + llm_model=agent.llm_model, + ) + db.add(assistant_msg) + conversation.updated_at = datetime.utcnow() + await db.commit() + + return AgentExecuteResponse( + response=full_response, + conversation_id=str(conversation.id), + tokens_used={"input_tokens": 0, "output_tokens": output_tokens}, + tool_calls=collected_tool_calls, + ) diff --git a/backend/app/api/v1/endpoints/agents.py b/backend/app/api/v1/endpoints/agents.py index bcd425c..2d2fc22 100644 --- a/backend/app/api/v1/endpoints/agents.py +++ b/backend/app/api/v1/endpoints/agents.py @@ -68,6 +68,7 @@ def _agent_to_list_item(agent: Agent) -> dict: "is_template": agent.is_template, "enable_rag": agent.enable_rag, "enable_file_upload": agent.enable_file_upload, + "enable_code_interpreter": agent.enable_code_interpreter, "welcome_message": agent.welcome_message, "suggested_prompts": agent.suggested_prompts or [], "created_by": str(agent.created_by) if agent.created_by else None, @@ -181,6 +182,7 @@ async def create_agent( enable_rag=data.enable_rag, knowledge_scope=data.knowledge_scope, enable_file_upload=data.enable_file_upload, + enable_code_interpreter=data.enable_code_interpreter, capabilities=data.capabilities, visibility=visibility, allowed_department_ids=data.allowed_department_ids, @@ -314,6 +316,7 @@ async def duplicate_agent( enable_rag=source.enable_rag, knowledge_scope=source.knowledge_scope, enable_file_upload=source.enable_file_upload, + enable_code_interpreter=source.enable_code_interpreter, capabilities=source.capabilities, visibility="private", allowed_department_ids=[], diff --git a/backend/app/api/v1/endpoints/chat.py b/backend/app/api/v1/endpoints/chat.py index 3d0160c..8962746 100644 --- a/backend/app/api/v1/endpoints/chat.py +++ b/backend/app/api/v1/endpoints/chat.py @@ -379,18 +379,23 @@ async def chat( # Apply agent knowledge scope scope = agent_obj.knowledge_scope or {} if scope.get("all"): - department_id = str(current_user.department_id) if current_user.department_id else None - region_code = current_user.region_code + # User-scoped: filter by user's own department and region + scope_dept_ids = [str(current_user.department_id)] if current_user.department_id else [] + scope_region_codes = [current_user.region_code] if current_user.region_code else [] else: - dept_ids = scope.get("department_ids", []) - department_id = dept_ids[0] if dept_ids else (str(current_user.department_id) if current_user.department_id else None) - region_codes = scope.get("region_codes", []) - region_code = region_codes[0] if region_codes else current_user.region_code + # Agent-scoped: use full lists from knowledge_scope + scope_dept_ids = scope.get("department_ids", []) + scope_region_codes = scope.get("region_codes", []) + # Fall back to user's own scope if agent scope is not set + if not scope_dept_ids and current_user.department_id: + scope_dept_ids = [str(current_user.department_id)] + if not scope_region_codes and current_user.region_code: + scope_region_codes = [current_user.region_code] async for token in retriever.query( user_query=message, - department_id=department_id, - region_code=region_code, + department_ids=scope_dept_ids or None, + region_codes=scope_region_codes or None, top_k=10, conversation_history=history, user_display_name=current_user.display_name, @@ -450,6 +455,12 @@ async def chat( # Fall back to mode-based tool lookup for the assistant system agent enabled_tools = await ToolRegistry.get_enabled_tools(effective_mode, db) + # Inject code interpreter if enabled on this agent and service is registered + if agent_obj.enable_code_interpreter: + ci_tool = ToolRegistry.get_tool("code_interpreter") + if ci_tool and ci_tool not in enabled_tools: + enabled_tools = list(enabled_tools) + [ci_tool] + if enabled_tools and await _should_use_tools(message, history): # Tool-calling agentic loop graph_token = await GraphTokenManager.get_valid_token(db, current_user.id) @@ -458,6 +469,8 @@ async def chat( user_email=current_user.email, db_session=db, graph_token=graph_token, + conversation_id=conversation.id, + agent_slug=agent_slug, ) # Use LLMFactory with agent config diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 9d1d890..e2cf8fb 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -2,7 +2,11 @@ API v1 Router - Combines all endpoint routers """ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, chat, assistant, admin, knowledge, graph_consent, tools as tools_admin, sharepoint_browse, feedback, agents +from app.api.v1.endpoints import ( + auth, health, chat, assistant, admin, knowledge, graph_consent, + tools as tools_admin, sharepoint_browse, feedback, agents, + agent_analytics, agent_execute, +) router = APIRouter() @@ -17,4 +21,8 @@ router.include_router(graph_consent.router, prefix="/auth", tags=["graph-consent router.include_router(tools_admin.router, prefix="/admin/tools", tags=["admin-tools"]) router.include_router(sharepoint_browse.router, prefix="/admin/knowledge/sharepoint", tags=["sharepoint-browse"]) router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) +# Agent sub-routes must be registered BEFORE the main agents router to avoid +# /{slug} capturing "analytics" and "execute" as slug values. +router.include_router(agent_analytics.router, prefix="/agents", tags=["agent-analytics"]) +router.include_router(agent_execute.router, prefix="/agents", tags=["agent-execute"]) router.include_router(agents.router, prefix="/agents", tags=["agents"]) diff --git a/backend/app/config.py b/backend/app/config.py index a34996f..a1758e8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -91,6 +91,10 @@ class Settings(BaseSettings): # Cloud Run Processor (empty = use local DocumentProcessor) CLOUD_RUN_PROCESSOR_URL: str = "" + # Code Interpreter (LibreCodeInterpreter microservice, empty = disabled) + CODE_INTERPRETER_URL: str = "" + CODE_INTERPRETER_API_KEY: str = "" + # Celery CELERY_BROKER_URL: str = "redis://localhost:6379/0" CELERY_RESULT_BACKEND: str = "redis://localhost:6379/0" diff --git a/backend/app/main.py b/backend/app/main.py index b7a9717..ee0fb7f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,9 +28,13 @@ async def lifespan(app: FastAPI): from app.tools import ToolRegistry from app.tools.microsoft import ALL_GRAPH_TOOLS from app.tools.agent_builder import register_agent_builder_tools + from app.tools.code_interpreter import CodeInterpreterTool for tool in ALL_GRAPH_TOOLS: ToolRegistry.register(tool) register_agent_builder_tools() + if settings.CODE_INTERPRETER_URL: + ToolRegistry.register(CodeInterpreterTool()) + logger.info("Code interpreter tool registered (url=%s)", settings.CODE_INTERPRETER_URL) # Load API keys from app_settings table into runtime config try: diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 903f862..7dbd76b 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -44,6 +44,7 @@ class Agent(Base): knowledge_scope = Column(JSONB, default=dict, nullable=False, server_default='{}') # knowledge_scope: {"department_ids": [], "region_codes": [], "all": false} enable_file_upload = Column(Boolean, default=True, nullable=False) + enable_code_interpreter = Column(Boolean, default=False, nullable=False, server_default='false') capabilities = Column(JSONB, default=list, nullable=False, server_default='[]') # extensible future caps # Visibility & Access diff --git a/backend/app/rag/retriever.py b/backend/app/rag/retriever.py index be7a197..92b4a81 100644 --- a/backend/app/rag/retriever.py +++ b/backend/app/rag/retriever.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, asdict from typing import List, Dict, Optional, AsyncGenerator, Tuple from qdrant_client import QdrantClient -from qdrant_client.models import Filter, FieldCondition, MatchValue, ScoredPoint, IsNullCondition, IsEmptyCondition, PayloadField +from qdrant_client.models import Filter, FieldCondition, MatchValue, MatchAny, ScoredPoint, IsNullCondition, IsEmptyCondition, PayloadField from langchain_openai import OpenAIEmbeddings from app.config import settings @@ -70,34 +70,53 @@ class RAGRetriever: query_embedding: List[float], department_id: Optional[str] = None, region_code: Optional[str] = None, + department_ids: Optional[List[str]] = None, + region_codes: Optional[List[str]] = None, top_k: int = 5 ) -> List[Dict]: """ - Search Qdrant for relevant document chunks + Search Qdrant for relevant document chunks. + + Accepts either scalar (department_id / region_code) or list + (department_ids / region_codes) parameters. Lists take precedence. + + Global (unscoped) documents — those with null/empty department_id or + region_code — are always included when a filter is active. Returns: List of dicts with: text, file_name, file_url, chunk_index, score """ + # Normalise to lists; scalar params are kept for backwards compat + active_dept_ids = department_ids or ([department_id] if department_id else []) + active_region_codes = region_codes or ([region_code] if region_code else []) + filter_conditions = [ FieldCondition(key="is_active", match=MatchValue(value=True)) ] - if department_id: - filter_conditions.append( - FieldCondition(key="department_id", match=MatchValue(value=department_id)) - ) + if active_dept_ids: + # Match any of the specified departments, OR docs with no department (global). + dept_should = [ + IsNullCondition(is_null=PayloadField(key="department_id")), + IsEmptyCondition(is_empty=PayloadField(key="department_id")), + ] + if len(active_dept_ids) == 1: + dept_should.insert(0, FieldCondition(key="department_id", match=MatchValue(value=active_dept_ids[0]))) + else: + dept_should.insert(0, FieldCondition(key="department_id", match=MatchAny(any=active_dept_ids))) + filter_conditions.append(Filter(should=dept_should)) - if region_code: - # Include docs for this region OR global docs (no region_code set) - filter_conditions.append( - Filter( - should=[ - FieldCondition(key="region_code", match=MatchValue(value=region_code)), - IsNullCondition(is_null=PayloadField(key="region_code")), - IsEmptyCondition(is_empty=PayloadField(key="region_code")), - ] - ) - ) + if active_region_codes: + # Match any of the specified regions, OR docs with no region (global). + region_should = [ + IsNullCondition(is_null=PayloadField(key="region_code")), + IsEmptyCondition(is_empty=PayloadField(key="region_code")), + ] + if len(active_region_codes) == 1: + region_should.insert(0, FieldCondition(key="region_code", match=MatchValue(value=active_region_codes[0]))) + else: + region_should.insert(0, FieldCondition(key="region_code", match=MatchAny(any=active_region_codes))) + filter_conditions.append(Filter(should=region_should)) query_filter = Filter(must=filter_conditions) if filter_conditions else None @@ -439,12 +458,17 @@ Question: {query}""" user_query: str, department_id: Optional[str] = None, region_code: Optional[str] = None, + department_ids: Optional[List[str]] = None, + region_codes: Optional[List[str]] = None, top_k: int = 5, enable_corrective_rag: Optional[bool] = None, ) -> Tuple[str, Dict[int, Dict], List[Dict]]: """ Full retrieval pipeline: embed → search → (grade if corrective) → format. + Accepts either scalar (department_id / region_code) or list + (department_ids / region_codes) params. Lists take precedence. + Returns: (context_string, citations_dict, documents) """ @@ -460,6 +484,8 @@ Question: {query}""" query_embedding=emb, department_id=department_id, region_code=region_code, + department_ids=department_ids, + region_codes=region_codes, top_k=top_k, ) for emb in embeddings @@ -509,6 +535,8 @@ Question: {query}""" user_query: str, department_id: Optional[str] = None, region_code: Optional[str] = None, + department_ids: Optional[List[str]] = None, + region_codes: Optional[List[str]] = None, top_k: int = 5, system_prompt: Optional[str] = None, conversation_history: Optional[List[Dict]] = None, @@ -540,6 +568,8 @@ Question: {query}""" user_query=retrieval_query, department_id=department_id, region_code=region_code, + department_ids=department_ids, + region_codes=region_codes, top_k=top_k, enable_corrective_rag=enable_corrective_rag, ) diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index 5d6e04d..91a9a40 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -31,6 +31,7 @@ class AgentCreate(BaseModel): enable_rag: bool = False knowledge_scope: Dict[str, Any] = Field(default_factory=dict) enable_file_upload: bool = True + enable_code_interpreter: bool = False capabilities: List[str] = Field(default_factory=list) # Visibility @@ -67,6 +68,7 @@ class AgentUpdate(BaseModel): enable_rag: Optional[bool] = None knowledge_scope: Optional[Dict[str, Any]] = None enable_file_upload: Optional[bool] = None + enable_code_interpreter: Optional[bool] = None capabilities: Optional[List[str]] = None visibility: Optional[str] = Field(None, pattern=r'^(public|department|private)$') @@ -93,6 +95,7 @@ class AgentListItem(BaseModel): is_template: bool enable_rag: bool enable_file_upload: bool + enable_code_interpreter: bool welcome_message: Optional[str] suggested_prompts: List[str] created_by: Optional[str] @@ -123,6 +126,7 @@ class AgentResponse(BaseModel): enable_rag: bool knowledge_scope: Dict[str, Any] enable_file_upload: bool + enable_code_interpreter: bool capabilities: List[str] visibility: str diff --git a/backend/app/schemas/agent_analytics.py b/backend/app/schemas/agent_analytics.py new file mode 100644 index 0000000..90c054d --- /dev/null +++ b/backend/app/schemas/agent_analytics.py @@ -0,0 +1,23 @@ +""" +Pydantic schemas for per-agent analytics API +""" +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + + +class DailyCount(BaseModel): + date: str # ISO date string YYYY-MM-DD + count: int + + +class AgentAnalyticsResponse(BaseModel): + agent_slug: str + period_days: int + total_messages: int + total_tokens: int + conversations: int + unique_users: int + first_used: Optional[datetime] + last_used: Optional[datetime] + daily_messages: List[DailyCount] diff --git a/backend/app/schemas/agent_execute.py b/backend/app/schemas/agent_execute.py new file mode 100644 index 0000000..a549f74 --- /dev/null +++ b/backend/app/schemas/agent_execute.py @@ -0,0 +1,27 @@ +""" +Pydantic schemas for programmatic agent execution API +""" +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any + + +class AgentExecuteRequest(BaseModel): + message: str = Field(..., min_length=1, max_length=10000) + conversation_id: Optional[str] = None + + +class ToolCallRecord(BaseModel): + name: str + display_name: str + arguments: Dict[str, Any] + result: Optional[Any] = None + success: bool + error: Optional[str] = None + duration_ms: Optional[int] = None + + +class AgentExecuteResponse(BaseModel): + response: str + conversation_id: str + tokens_used: Dict[str, int] # {"input_tokens": N, "output_tokens": N} + tool_calls: List[ToolCallRecord] diff --git a/backend/app/services/agent_collector.py b/backend/app/services/agent_collector.py index 2aed81c..0b8a092 100644 --- a/backend/app/services/agent_collector.py +++ b/backend/app/services/agent_collector.py @@ -9,11 +9,14 @@ Errors are logged but never raise — sync is best-effort. """ import logging from datetime import timezone +from typing import Optional import httpx +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.agent import Agent +from app.services.agent_usage import get_agent_usage_stats logger = logging.getLogger(__name__) @@ -31,7 +34,15 @@ _STATUS_MAP = { _NEXUS_BASE_URL = "https://ai-sandbox.oliver.solutions/nexus" -def _build_payload(agent: Agent) -> dict: +def _iso(dt): + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc).isoformat() + return dt.isoformat() + + +def _build_payload(agent: Agent, usage_stats: Optional[dict] = None) -> dict: """Convert a Nexus Agent ORM object to the collector API payload.""" # Capabilities: explicit list + tool_ids + RAG flag @@ -43,6 +54,8 @@ def _build_payload(agent: Agent) -> dict: caps.append("RAG Knowledge Base") if agent.enable_file_upload and "File Upload" not in caps: caps.append("File Upload") + if getattr(agent, "enable_code_interpreter", False) and "Code Interpreter" not in caps: + caps.append("Code Interpreter") tags = list({ "nexus", @@ -52,14 +65,7 @@ def _build_payload(agent: Agent) -> dict: agent.llm_model, }) - def _iso(dt): - if dt is None: - return None - if dt.tzinfo is None: - return dt.replace(tzinfo=timezone.utc).isoformat() - return dt.isoformat() - - return { + payload = { # Required fields "name": f"[Nexus] {agent.name}", "description": agent.description or (agent.system_prompt[:300] if agent.system_prompt else ""), @@ -91,18 +97,22 @@ def _build_payload(agent: Agent) -> dict: }, } + # Include usage analytics when available + if usage_stats: + payload["usage"] = { + "total_messages": usage_stats.get("total_messages", 0), + "total_tokens": usage_stats.get("total_tokens", 0), + "conversations": usage_stats.get("conversations", 0), + "unique_users": usage_stats.get("unique_users", 0), + "first_used": _iso(usage_stats.get("first_used")), + "last_used": _iso(usage_stats.get("last_used")), + } -async def sync_agent(agent: Agent) -> None: - """ - Fire-and-forget POST to agent_collector. - Only syncs public/active or system agents; skips private drafts. - """ - # Skip private non-system agents — not relevant for org-wide catalog - if agent.visibility == "private" and not agent.is_system: - return + return payload - payload = _build_payload(agent) +async def _post_to_collector(agent: Agent, payload: dict) -> None: + """POST payload to the collector. Errors are logged but never raised.""" try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: resp = await client.post( @@ -126,3 +136,56 @@ async def sync_agent(agent: Agent) -> None: ) except Exception as exc: # noqa: BLE001 logger.error("agent_collector sync failed: agent=%s error=%s", agent.slug, exc) + + +async def sync_agent(agent: Agent, db: Optional[AsyncSession] = None) -> None: + """ + Fire-and-forget POST to agent_collector. + Only syncs public/active or system agents; skips private drafts. + + If db is provided, usage stats are included in the payload. + """ + # Skip private non-system agents — not relevant for org-wide catalog + if agent.visibility == "private" and not agent.is_system: + return + + usage_stats: Optional[dict] = None + if db is not None: + try: + usage_stats = await get_agent_usage_stats(db, agent.id) + except Exception as exc: # noqa: BLE001 + logger.warning("agent_collector: failed to fetch usage for %s: %s", agent.slug, exc) + + payload = _build_payload(agent, usage_stats) + await _post_to_collector(agent, payload) + + +async def sync_all_agents_with_usage(db: AsyncSession) -> None: + """ + Sync all active/public agents with usage stats to the collector. + Called by the Celery beat task every 6 hours. + """ + from sqlalchemy import select + from app.models.agent import Agent as AgentModel + + result = await db.execute( + select(AgentModel).where( + AgentModel.status == "active", + ) + ) + agents = result.scalars().all() + + synced = 0 + for agent in agents: + # Skip private non-system agents + if agent.visibility == "private" and not agent.is_system: + continue + try: + usage_stats = await get_agent_usage_stats(db, agent.id) + payload = _build_payload(agent, usage_stats) + await _post_to_collector(agent, payload) + synced += 1 + except Exception as exc: # noqa: BLE001 + logger.error("agent_collector batch sync failed for %s: %s", agent.slug, exc) + + logger.info("agent_collector batch sync complete: %d agents synced", synced) diff --git a/backend/app/services/agent_usage.py b/backend/app/services/agent_usage.py new file mode 100644 index 0000000..5682258 --- /dev/null +++ b/backend/app/services/agent_usage.py @@ -0,0 +1,90 @@ +""" +Agent Usage Statistics Service + +Computes per-agent usage metrics from conversations and messages tables. +Used by the agent collector sync and the analytics API endpoint. +""" +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional +from uuid import UUID + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.conversation import Conversation, Message + +logger = logging.getLogger(__name__) + + +async def get_agent_usage_stats( + db: AsyncSession, + agent_id: UUID, + days: Optional[int] = None, +) -> dict: + """ + Compute usage statistics for a single agent. + + Args: + db: Async database session + agent_id: Agent UUID + days: If set, restrict to the last N days. If None, all-time. + + Returns: + { + "total_messages": int, + "total_tokens": int, + "conversations": int, + "unique_users": int, + "first_used": datetime | None, + "last_used": datetime | None, + } + """ + since: Optional[datetime] = None + if days is not None: + since = datetime.now(timezone.utc) - timedelta(days=days) + + # Base filter: conversations linked to this agent + conv_filter = Conversation.agent_id == agent_id + if since: + conv_filter = conv_filter & (Conversation.created_at >= since) + + # Subquery: conversation IDs for this agent + conv_ids_subq = select(Conversation.id).where( + Conversation.agent_id == agent_id, + *([Conversation.created_at >= since] if since else []), + ).scalar_subquery() + + # Aggregate query over messages in those conversations + msg_query = select( + func.count(Message.id).label("total_messages"), + func.coalesce(func.sum(Message.output_tokens), 0).label("total_tokens"), + func.min(Message.created_at).label("first_used"), + func.max(Message.created_at).label("last_used"), + ).where( + Message.conversation_id.in_(conv_ids_subq), + Message.role == "assistant", + ) + + msg_result = await db.execute(msg_query) + msg_row = msg_result.one() + + # Count distinct conversations and users + conv_query = select( + func.count(Conversation.id.distinct()).label("conversations"), + func.count(Conversation.user_id.distinct()).label("unique_users"), + ).where(Conversation.agent_id == agent_id) + if since: + conv_query = conv_query.where(Conversation.created_at >= since) + + conv_result = await db.execute(conv_query) + conv_row = conv_result.one() + + return { + "total_messages": msg_row.total_messages or 0, + "total_tokens": int(msg_row.total_tokens or 0), + "conversations": conv_row.conversations or 0, + "unique_users": conv_row.unique_users or 0, + "first_used": msg_row.first_used, + "last_used": msg_row.last_used, + } diff --git a/backend/app/tasks/agent_sync.py b/backend/app/tasks/agent_sync.py new file mode 100644 index 0000000..54912c0 --- /dev/null +++ b/backend/app/tasks/agent_sync.py @@ -0,0 +1,38 @@ +""" +Celery Beat Task: Periodic Agent Collector Sync with Usage Stats + +Runs every 6 hours, syncs all active/public agents to AgentHub collector +including usage metrics (conversations, messages, tokens, unique users). +""" +import asyncio +import logging + +from celery_app import celery_app +from app.database import AsyncSessionLocal +from app.services.agent_collector import sync_all_agents_with_usage + +logger = logging.getLogger(__name__) + + +async def _run_sync() -> None: + async with AsyncSessionLocal() as db: + await sync_all_agents_with_usage(db) + + +@celery_app.task( + name="app.tasks.agent_sync.sync_agents_usage", + bind=True, + max_retries=1, + default_retry_delay=60, +) +def sync_agents_usage(self) -> dict: + """ + Sync all active/public Nexus agents to the AgentHub collector, + including current usage statistics (messages, tokens, conversations, users). + """ + try: + asyncio.run(_run_sync()) + return {"status": "ok"} + except Exception as exc: # noqa: BLE001 + logger.error("agent_sync beat task failed: %s", exc) + raise self.retry(exc=exc) diff --git a/backend/app/tools/base.py b/backend/app/tools/base.py index f982c44..2cbca41 100644 --- a/backend/app/tools/base.py +++ b/backend/app/tools/base.py @@ -15,6 +15,8 @@ class ToolContext: user_email: str db_session: AsyncSession graph_token: Optional[str] = None + conversation_id: Optional[UUID] = None + agent_slug: Optional[str] = None @dataclass diff --git a/backend/app/tools/code_interpreter.py b/backend/app/tools/code_interpreter.py new file mode 100644 index 0000000..fcc9f36 --- /dev/null +++ b/backend/app/tools/code_interpreter.py @@ -0,0 +1,160 @@ +""" +Code Interpreter Tool + +Executes code in a sandboxed environment via the LibreCodeInterpreter microservice. +Supports 13 languages. Python sessions are persistent within a conversation. +""" +import logging +from typing import Any, Dict + +import httpx + +from app.config import settings +from app.tools.base import BaseTool, ToolContext, ToolResult + +logger = logging.getLogger(__name__) + +# Map friendly language names to LibreCodeInterpreter language codes +_LANG_CODES: Dict[str, str] = { + "python": "py", + "javascript": "js", + "typescript": "ts", + "go": "go", + "java": "java", + "c": "c", + "cpp": "cpp", + "rust": "rs", + "php": "php", + "r": "r", + "bash": "bash", + "fortran": "f90", +} + + +class CodeInterpreterTool(BaseTool): + """Execute code in a secure sandboxed environment.""" + + @property + def name(self) -> str: + return "code_interpreter" + + @property + def display_name(self) -> str: + return "Code Interpreter" + + @property + def description(self) -> str: + return ( + "Execute code in a secure sandboxed environment. " + "Supports Python, JavaScript, TypeScript, Go, Java, C, C++, Rust, PHP, R, Bash, and Fortran. " + "Use for calculations, data analysis, generating files, visualizations, or any task " + "that benefits from running code. Returns stdout, stderr, and any generated files. " + "Python sessions maintain state between calls within the same conversation." + ) + + @property + def parameters_schema(self) -> Dict[str, Any]: + return { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "The code to execute.", + }, + "language": { + "type": "string", + "enum": list(_LANG_CODES.keys()), + "description": "Programming language to use. Defaults to python.", + "default": "python", + }, + }, + "required": ["code"], + } + + @property + def category(self) -> str: + return "code_execution" + + @property + def requires_graph_consent(self) -> bool: + return False + + async def execute(self, arguments: Dict[str, Any], context: ToolContext) -> ToolResult: + if not settings.CODE_INTERPRETER_URL: + return ToolResult( + success=False, + error="Code interpreter is not configured. Set CODE_INTERPRETER_URL.", + ) + + code = arguments.get("code", "").strip() + if not code: + return ToolResult(success=False, error="No code provided.") + + language = arguments.get("language", "python").lower() + lang_code = _LANG_CODES.get(language, "py") + + # Use conversation_id as session_id for state persistence (Python REPL) + session_id = str(context.conversation_id) if context.conversation_id else str(context.user_id) + + payload = { + "code": code, + "lang": lang_code, + "session_id": session_id, + "user_id": str(context.user_id), + } + if context.agent_slug: + payload["entity_id"] = context.agent_slug[:40] + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{settings.CODE_INTERPRETER_URL.rstrip('/')}/exec", + json=payload, + headers={ + "X-API-Key": settings.CODE_INTERPRETER_API_KEY, + "Content-Type": "application/json", + }, + ) + except httpx.TimeoutException: + return ToolResult(success=False, error="Code execution timed out after 60 seconds.") + except Exception as exc: # noqa: BLE001 + logger.error("code_interpreter: request failed: %s", exc) + return ToolResult(success=False, error=f"Failed to reach code interpreter: {exc}") + + if resp.status_code != 200: + logger.warning("code_interpreter: non-200 response %s: %s", resp.status_code, resp.text[:200]) + return ToolResult(success=False, error=f"Code interpreter returned HTTP {resp.status_code}.") + + try: + result = resp.json() + except Exception: + return ToolResult(success=False, error="Invalid response from code interpreter.") + + stdout = result.get("stdout", "") + stderr = result.get("stderr", "") + files = result.get("files", []) + + # Build human-readable display string + parts = [] + if stdout: + parts.append(f"Output:\n{stdout.rstrip()}") + if stderr: + parts.append(f"Errors:\n{stderr.rstrip()}") + if files: + names = [f.get("name", f.get("id", "file")) for f in files] + parts.append(f"Generated files: {', '.join(names)}") + if not parts: + parts.append("Code executed successfully (no output).") + + return ToolResult( + success=True, + data={ + "language": language, + "code": code, + "stdout": stdout, + "stderr": stderr, + "files": files, + "session_id": result.get("session_id", session_id), + }, + display="\n".join(parts), + ) diff --git a/backend/celery_app.py b/backend/celery_app.py index 89be776..40a6886 100644 --- a/backend/celery_app.py +++ b/backend/celery_app.py @@ -17,7 +17,11 @@ celery_app = Celery( "nexus", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND, - include=["app.tasks.sharepoint_sync", "app.tasks.knowledge_processing"], + include=[ + "app.tasks.sharepoint_sync", + "app.tasks.knowledge_processing", + "app.tasks.agent_sync", + ], ) celery_app.conf.update( @@ -41,6 +45,7 @@ celery_app.conf.update( task_routes={ "app.tasks.sharepoint_sync.*": {"queue": "sharepoint"}, "app.tasks.knowledge_processing.*": {"queue": "default"}, + "app.tasks.agent_sync.*": {"queue": "default"}, }, # Result retention: keep results for 24 hours @@ -61,6 +66,10 @@ celery_app.conf.update( "task": "app.tasks.sharepoint_sync.sync_all_active_sources", "schedule": crontab(minute=0), # hourly }, + "sync-agent-collector-usage": { + "task": "app.tasks.agent_sync.sync_agents_usage", + "schedule": crontab(minute=0, hour="*/6"), # every 6 hours + }, }, ) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a44a5ef..7bea8fb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -95,6 +95,8 @@ services: SHAREPOINT_WEBHOOK_CLIENT_STATE: ${SHAREPOINT_WEBHOOK_CLIENT_STATE:-} SHAREPOINT_TENANT_DOMAIN: ${SHAREPOINT_TENANT_DOMAIN:-company.sharepoint.com} CLOUD_RUN_PROCESSOR_URL: ${CLOUD_RUN_PROCESSOR_URL:-} + CODE_INTERPRETER_URL: ${CODE_INTERPRETER_URL:-http://code-interpreter:8000} + CODE_INTERPRETER_API_KEY: ${CODE_INTERPRETER_API_KEY:-code-interpreter-key-change-me} depends_on: db: condition: service_healthy @@ -160,6 +162,71 @@ services: - nexus-network mem_limit: 4g + code-interpreter-redis: + image: redis:7-alpine + container_name: nexus-code-redis + restart: unless-stopped + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + networks: + - nexus-network + mem_limit: 512m + + code-interpreter-minio: + image: minio/minio:latest + container_name: nexus-code-minio + restart: unless-stopped + command: server /data + environment: + MINIO_ROOT_USER: ${CODE_INTERPRETER_MINIO_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${CODE_INTERPRETER_MINIO_PASSWORD:-minioadmin} + volumes: + - code_minio_data:/data + networks: + - nexus-network + mem_limit: 1g + + code-interpreter-minio-init: + image: minio/mc:latest + container_name: nexus-code-minio-init + depends_on: + - code-interpreter-minio + entrypoint: > + /bin/sh -c " + sleep 5; + mc alias set local http://code-interpreter-minio:9000 $${CODE_INTERPRETER_MINIO_USER:-minioadmin} $${CODE_INTERPRETER_MINIO_PASSWORD:-minioadmin}; + mc mb --ignore-existing local/code-interpreter; + exit 0; + " + networks: + - nexus-network + + code-interpreter: + image: ghcr.io/usnavy13/librecodeinterpreter:latest + container_name: nexus-code-interpreter + restart: unless-stopped + cap_add: + - SYS_ADMIN + security_opt: + - apparmor:unconfined + environment: + API_KEY: ${CODE_INTERPRETER_API_KEY:-code-interpreter-key-change-me} + REDIS_HOST: code-interpreter-redis + REDIS_PORT: 6379 + MINIO_ENDPOINT: code-interpreter-minio:9000 + MINIO_ACCESS_KEY: ${CODE_INTERPRETER_MINIO_USER:-minioadmin} + MINIO_SECRET_KEY: ${CODE_INTERPRETER_MINIO_PASSWORD:-minioadmin} + MINIO_BUCKET: code-interpreter + MAX_EXECUTION_TIME: 30 + MAX_MEMORY_MB: 512 + SANDBOX_POOL_ENABLED: "true" + SANDBOX_POOL_PY: 3 + depends_on: + - code-interpreter-redis + - code-interpreter-minio + networks: + - nexus-network + mem_limit: 4g + volumes: postgres_data: driver: local @@ -169,6 +236,8 @@ volumes: driver: local uploads_data: driver: local + code_minio_data: + driver: local networks: nexus-network: diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index b7fc149..af0da45 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -153,6 +153,7 @@ function ChatPageInner() { updateToolCall(assistantMessageId, tc.id, { status: 'success', display: tr.display, + data: tr.data, duration_ms: tr.duration_ms, }); } diff --git a/frontend/components/admin/agent-editor.tsx b/frontend/components/admin/agent-editor.tsx index cf3bdd4..eec7c48 100644 --- a/frontend/components/admin/agent-editor.tsx +++ b/frontend/components/admin/agent-editor.tsx @@ -49,6 +49,7 @@ interface FormState { visibility: 'public' | 'department' | 'private'; enable_rag: boolean; enable_file_upload: boolean; + enable_code_interpreter: boolean; is_template: boolean; } @@ -68,6 +69,7 @@ const DEFAULT_FORM: FormState = { visibility: 'private', enable_rag: false, enable_file_upload: true, + enable_code_interpreter: false, is_template: false, }; @@ -130,6 +132,7 @@ export function AgentEditorModal({ open, agentSlug, onClose }: AgentEditorModalP visibility: a.visibility, enable_rag: a.enable_rag, enable_file_upload: a.enable_file_upload, + enable_code_interpreter: a.enable_code_interpreter ?? false, is_template: a.is_template, }); setSelectedToolIds(a.tool_ids || []); @@ -181,6 +184,7 @@ export function AgentEditorModal({ open, agentSlug, onClose }: AgentEditorModalP enable_rag: form.enable_rag, knowledge_scope: form.enable_rag ? { all: true } : {}, enable_file_upload: form.enable_file_upload, + enable_code_interpreter: form.enable_code_interpreter, visibility: form.visibility, allowed_department_ids: selectedDeptIds, is_template: form.is_template, @@ -433,6 +437,28 @@ export function AgentEditorModal({ open, agentSlug, onClose }: AgentEditorModalP /> + +
Code Interpreter
+Allow agent to execute code in a sandbox
+
+ {data.stdout}
+
+
+ {data.stderr}
+
+