feat: code interpreter, agent analytics/execute APIs, usage sync, RAG scoping fixes

**Phase 1 — Agent Usage Sync to AgentHub Collector**
- Add agent_usage service: per-agent stats (messages, tokens, conversations, unique users, first/last used)
- Collector sync now includes usage data in payload; sync_agent accepts optional db session
- Celery beat task runs every 6h to sync all active agents with fresh usage stats

**Phase 2 — LibreCodeInterpreter Integration**
- Add code-interpreter, redis, minio services to docker-compose.prod.yml
- CodeInterpreterTool (BaseTool): sandboxed execution via /exec, 13 languages, Python session persistence via conversation_id
- ToolContext extended with conversation_id and agent_slug
- enable_code_interpreter boolean on Agent model (migration 027), tool seeded in tool_definitions (migration 026)
- Code interpreter auto-injected into agent tools when enabled
- Frontend: CodeExecutionResult component with terminal-style stdout/stderr/files rendering

**Phase 3 — Agent API Endpoints**
- GET /api/v1/agents/{slug}/analytics — per-agent usage stats + daily time series
- POST /api/v1/agents/{slug}/execute — synchronous programmatic agent execution (non-SSE)
- Sub-routes registered before /{slug} to avoid FastAPI route conflict

**Phase 4 — Fix Department & Region RAG Scoping**
- Department filter now OR-includes global (null department) docs, matching region filter behaviour
- retriever.search_documents/retrieve_and_prepare/query accept department_ids/region_codes lists
- MatchAny used for multi-value Qdrant filters; chat.py passes full arrays from knowledge_scope
- Admin PATCH /users/{id} now validates region_code against the regions table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-30 20:13:27 +01:00
parent 0f6c9ededd
commit f2b5dce63a
27 changed files with 1239 additions and 51 deletions

View file

@ -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'"),
)

View file

@ -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')

View file

@ -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()

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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=[],

View file

@ -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

View file

@ -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"])

View file

@ -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"

View file

@ -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:

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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]

View file

@ -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]

View file

@ -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)

View file

@ -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,
}

View file

@ -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)

View file

@ -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

View file

@ -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),
)

View file

@ -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
},
},
)

View file

@ -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:

View file

@ -153,6 +153,7 @@ function ChatPageInner() {
updateToolCall(assistantMessageId, tc.id, {
status: 'success',
display: tr.display,
data: tr.data,
duration_ms: tr.duration_ms,
});
}

View file

@ -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
/>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">Code Interpreter</p>
<p className="text-xs text-muted-foreground">Allow agent to execute code in a sandbox</p>
</div>
<button
type="button"
onClick={() => set('enable_code_interpreter', !form.enable_code_interpreter)}
className={cn(
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
form.enable_code_interpreter ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow-lg ring-0 transition-transform',
form.enable_code_interpreter && 'translate-x-[18px]'
)}
/>
</button>
</div>
</div>
{/* Tools */}

View file

@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronUp, Download, Terminal, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CodeExecutionData } from '@/types';
interface CodeExecutionResultProps {
data: CodeExecutionData;
duration_ms?: number;
}
export function CodeExecutionResult({ data, duration_ms }: CodeExecutionResultProps) {
const [outputOpen, setOutputOpen] = useState(true);
const hasOutput = Boolean(data.stdout || data.stderr);
const hasFiles = data.files && data.files.length > 0;
const hasErrors = Boolean(data.stderr);
return (
<div className="mt-2 rounded-lg border border-border/60 overflow-hidden text-xs">
{/* Header */}
<div className="flex items-center justify-between bg-muted/40 px-3 py-2 border-b border-border/40">
<div className="flex items-center gap-2">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground/80">
{data.language} execution
</span>
{hasErrors ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
)}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
{duration_ms != null && <span>{duration_ms}ms</span>}
{hasOutput && (
<button
onClick={() => setOutputOpen((o) => !o)}
className="hover:text-foreground transition-colors"
>
{outputOpen ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
)}
</div>
</div>
{/* Output */}
{hasOutput && outputOpen && (
<div className="divide-y divide-border/30">
{data.stdout && (
<div className="px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 mb-1">
stdout
</div>
<pre className={cn(
'whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed',
'text-foreground/80 max-h-64 overflow-y-auto'
)}>
{data.stdout}
</pre>
</div>
)}
{data.stderr && (
<div className="px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-amber-600/70 mb-1">
stderr
</div>
<pre className={cn(
'whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed',
'text-amber-700 dark:text-amber-400 max-h-32 overflow-y-auto'
)}>
{data.stderr}
</pre>
</div>
)}
</div>
)}
{/* Files */}
{hasFiles && (
<div className={cn('px-3 py-2', hasOutput && outputOpen && 'border-t border-border/30')}>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 mb-1.5">
Generated files
</div>
<div className="flex flex-wrap gap-2">
{data.files.map((file) => (
<a
key={file.id}
href={`/api/v1/code-interpreter/files/${file.session_id}/${file.id}`}
download={file.name}
className={cn(
'flex items-center gap-1.5 rounded-md border border-border/50 px-2.5 py-1',
'text-[11px] font-medium text-foreground/70 hover:text-foreground hover:bg-muted/50',
'transition-colors'
)}
>
<Download className="h-3 w-3" />
{file.name}
</a>
))}
</div>
</div>
)}
{!hasOutput && !hasFiles && (
<div className="px-3 py-2 text-muted-foreground italic">
Code executed successfully (no output)
</div>
)}
</div>
);
}

View file

@ -2,7 +2,8 @@
import { cn } from '@/lib/utils';
import { Loader2, CheckCircle2, XCircle, Link2 } from 'lucide-react';
import type { ToolCallStatus } from '@/types';
import type { ToolCallStatus, CodeExecutionData } from '@/types';
import { CodeExecutionResult } from './code-execution-result';
interface ToolCallCardProps {
toolCall: ToolCallStatus;
@ -10,7 +11,9 @@ interface ToolCallCardProps {
}
export function ToolCallCard({ toolCall, onConnectM365 }: ToolCallCardProps) {
const { status, display_name, display, error, requires_consent, duration_ms } = toolCall;
const { name, status, display_name, display, data, error, requires_consent, duration_ms } = toolCall;
const isCodeInterpreter = name === 'code_interpreter';
return (
<div
@ -23,18 +26,25 @@ export function ToolCallCard({ toolCall, onConnectM365 }: ToolCallCardProps) {
>
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-blue-500" />}
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-green-500" />}
{status === 'success' && !isCodeInterpreter && <CheckCircle2 className="h-4 w-4 text-green-500" />}
{status === 'error' && <XCircle className="h-4 w-4 text-red-500" />}
<span className="font-medium text-foreground">{display_name}</span>
{duration_ms != null && (
{!isCodeInterpreter && duration_ms != null && (
<span className="text-xs text-muted-foreground">({duration_ms}ms)</span>
)}
</div>
{status === 'success' && display && (
<div className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">{display}</div>
{status === 'success' && isCodeInterpreter && data ? (
<CodeExecutionResult
data={data as CodeExecutionData}
duration_ms={duration_ms}
/>
) : (
status === 'success' && display && (
<div className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">{display}</div>
)
)}
{status === 'error' && error && (

View file

@ -361,6 +361,7 @@ export interface AgentListItem {
is_template: boolean;
enable_rag: boolean;
enable_file_upload: boolean;
enable_code_interpreter: boolean;
tool_ids: string[];
welcome_message: string | null;
suggested_prompts: string[];
@ -399,6 +400,7 @@ export interface AgentCreate {
enable_rag?: boolean;
knowledge_scope?: Record<string, unknown>;
enable_file_upload?: boolean;
enable_code_interpreter?: boolean;
capabilities?: string[];
visibility?: AgentVisibility;
allowed_department_ids?: string[];
@ -461,12 +463,22 @@ export interface ToolDefinition {
export type ToolCallStatusType = 'running' | 'success' | 'error';
export interface CodeExecutionData {
language: string;
code: string;
stdout: string;
stderr: string;
files: Array<{ id: string; name: string; session_id: string }>;
session_id: string;
}
export interface ToolCallStatus {
id: string;
name: string;
display_name: string;
status: ToolCallStatusType;
arguments?: Record<string, unknown>;
data?: unknown;
display?: string;
error?: string;
requires_consent?: boolean;