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:
parent
0f6c9ededd
commit
f2b5dce63a
27 changed files with 1239 additions and 51 deletions
96
backend/alembic/versions/026_seed_code_interpreter_tool.py
Normal file
96
backend/alembic/versions/026_seed_code_interpreter_tool.py
Normal 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'"),
|
||||
)
|
||||
33
backend/alembic/versions/027_add_enable_code_interpreter.py
Normal file
33
backend/alembic/versions/027_add_enable_code_interpreter.py
Normal 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')
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
102
backend/app/api/v1/endpoints/agent_analytics.py
Normal file
102
backend/app/api/v1/endpoints/agent_analytics.py
Normal 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,
|
||||
)
|
||||
243
backend/app/api/v1/endpoints/agent_execute.py
Normal file
243
backend/app/api/v1/endpoints/agent_execute.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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=[],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
backend/app/schemas/agent_analytics.py
Normal file
23
backend/app/schemas/agent_analytics.py
Normal 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]
|
||||
27
backend/app/schemas/agent_execute.py
Normal file
27
backend/app/schemas/agent_execute.py
Normal 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]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
90
backend/app/services/agent_usage.py
Normal file
90
backend/app/services/agent_usage.py
Normal 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,
|
||||
}
|
||||
38
backend/app/tasks/agent_sync.py
Normal file
38
backend/app/tasks/agent_sync.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
160
backend/app/tools/code_interpreter.py
Normal file
160
backend/app/tools/code_interpreter.py
Normal 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),
|
||||
)
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ function ChatPageInner() {
|
|||
updateToolCall(assistantMessageId, tc.id, {
|
||||
status: 'success',
|
||||
display: tr.display,
|
||||
data: tr.data,
|
||||
duration_ms: tr.duration_ms,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
113
frontend/components/chat/code-execution-result.tsx
Normal file
113
frontend/components/chat/code-execution-result.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue