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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ yarn test # Vitest
./launch/dev.sh -d # Start all services
```

## Database Migrations

When creating or running migrations, use `docker exec` to access the container:

```bash
# Generate migration
docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic revision --autogenerate -m 'Description'"
# Apply migrations
docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic upgrade head"
```
**Note**: Register new models in `models/__init__.py` before generating migrations.

## Code Style

**Python**: Use `list[T]`, `dict[K,V]`, `str | None` (not `List`, `Dict`, `Optional`)
Expand Down
89 changes: 82 additions & 7 deletions service/app/agents/agent_tools.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""
LangChain tool preparation from MCP servers.
LangChain tool preparation for agents.

Converts MCP tool definitions into LangChain-compatible StructuredTool instances
for use with LangChain agents.
Prepares tools from:
1. Builtin tools (web_search, knowledge_* etc.) - always loaded when available
2. MCP servers (via mcp_server_ids on Agent)

Tool filtering is handled by graph_config.tool_config.tool_filter at execution time.
"""

from __future__ import annotations
Expand All @@ -28,22 +31,39 @@ async def prepare_langchain_tools(
db: AsyncSession,
agent: "Agent | None",
session_id: "UUID | None" = None,
user_id: str | None = None,
session_knowledge_set_id: "UUID | None" = None,
) -> list[BaseTool]:
"""
Prepare LangChain tools from MCP servers (both agent-level and session-level).
Prepare LangChain tools from builtin tools and MCP servers.

All available tools are loaded. Filtering is done by graph_config at runtime.

Tool loading order:
1. Builtin tools (web_search, knowledge_* etc.) - always loaded when available
2. MCP tools from agent.mcp_server_ids (custom user MCPs)

Args:
db: Database session
agent: Agent instance (optional)
session_id: Session UUID for session-level MCP tools (optional)
user_id: User ID for knowledge tools context (optional)
session_knowledge_set_id: Session-level knowledge set override (optional).
If provided, overrides agent.knowledge_set_id for this session.

Returns:
List of LangChain BaseTool instances ready for agent use
"""
langchain_tools: list[BaseTool] = []

# 1. Load all available builtin tools
builtin_tools = _load_all_builtin_tools(agent, user_id, session_knowledge_set_id)
langchain_tools.extend(builtin_tools)

# 2. Load MCP tools (custom user MCPs)
from app.agents.mcp_tools import prepare_mcp_tools

mcp_tools = await prepare_mcp_tools(db, agent, session_id)
langchain_tools: list[BaseTool] = []

for tool in mcp_tools:
tool_name = tool.get("name", "")
Expand All @@ -60,12 +80,67 @@ async def prepare_langchain_tools(
)
langchain_tools.append(structured_tool)

logger.info(f"Loaded {len(langchain_tools)} tools")
logger.debug(f"Loaded {langchain_tools}")
logger.info(f"Loaded {len(langchain_tools)} tools (builtin + MCP)")
logger.debug(f"Tool names: {[t.name for t in langchain_tools]}")

return langchain_tools


def _load_all_builtin_tools(
agent: "Agent | None",
user_id: str | None = None,
session_knowledge_set_id: "UUID | None" = None,
) -> list[BaseTool]:
"""
Load all available builtin tools.

- Web search: loaded if SearXNG is enabled
- Knowledge tools: loaded if effective knowledge_set_id exists and user_id is available
- Image tools: loaded if image generation is enabled and user_id is available

Args:
agent: Agent instance (for knowledge_set_id fallback)
user_id: User ID for knowledge and image tools
session_knowledge_set_id: Session-level knowledge set override.
If provided, takes priority over agent.knowledge_set_id.

Returns:
List of available builtin BaseTool instances
"""
from app.configs import configs
from app.tools import BuiltinToolRegistry

tools: list[BaseTool] = []

# Load web_search if available in registry (registered at startup if SearXNG enabled)
web_search = BuiltinToolRegistry.get("web_search")
if web_search:
tools.append(web_search)

# Determine effective knowledge_set_id
# Priority: session override > agent config
effective_knowledge_set_id = session_knowledge_set_id or (agent.knowledge_set_id if agent else None)

# Load knowledge tools if we have an effective knowledge_set_id
if effective_knowledge_set_id and user_id:
from app.tools.knowledge import create_knowledge_tools_for_agent

knowledge_tools = create_knowledge_tools_for_agent(
user_id=user_id,
knowledge_set_id=effective_knowledge_set_id,
)
tools.extend(knowledge_tools)

# Load image tools if enabled and user_id is available
if configs.Image.Enable and user_id:
from app.tools.image import create_image_tools_for_agent

image_tools = create_image_tools_for_agent(user_id=user_id)
tools.extend(image_tools)

return tools


async def _create_structured_tool(
tool_name: str,
tool_description: str,
Expand Down
24 changes: 14 additions & 10 deletions service/app/agents/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,21 @@ async def create_chat_agent(
session_repo = SessionRepository(db)
session: "Session | None" = await session_repo.get_session_by_id(topic.session_id)

# Check if built-in search is enabled
google_search_enabled: bool = session.google_search_enabled if session else False
# Get user_id for knowledge tool context binding
user_id: str | None = session.user_id if session else None

# Prepare tools from MCP servers
# Get session-level knowledge_set_id override (if any)
session_knowledge_set_id: "UUID | None" = session.knowledge_set_id if session else None

# Prepare tools from builtin tools and MCP servers
session_id: "UUID | None" = topic.session_id if topic else None
tools: list[BaseTool] = await prepare_langchain_tools(db, agent_config, session_id)
tools: list[BaseTool] = await prepare_langchain_tools(
db,
agent_config,
session_id,
user_id,
session_knowledge_set_id=session_knowledge_set_id,
)

# Determine how to execute this agent
agent_type_str, system_key = _resolve_agent_config(agent_config)
Expand All @@ -109,7 +118,6 @@ async def create_llm(**kwargs: Any) -> "BaseChatModel":
# (some providers like Google don't accept temperature=None)
model_kwargs: dict[str, Any] = {
"model": override_model,
"google_search_enabled": google_search_enabled,
}
if override_temp is not None:
model_kwargs["temperature"] = override_temp
Expand All @@ -135,7 +143,6 @@ async def create_llm(**kwargs: Any) -> "BaseChatModel":
create_llm,
tools,
system_prompt,
google_search_enabled,
event_ctx,
)

Expand Down Expand Up @@ -202,7 +209,6 @@ async def _create_system_agent(
llm_factory: LLMFactory,
tools: list["BaseTool"],
system_prompt: str,
google_search_enabled: bool,
event_ctx: AgentEventContext,
) -> tuple[CompiledStateGraph[Any, None, Any, Any], AgentEventContext]:
"""
Expand All @@ -214,7 +220,6 @@ async def _create_system_agent(
llm_factory: Factory function to create LLM
tools: List of tools available to the agent
system_prompt: System prompt for the agent
google_search_enabled: Whether Google search is enabled
event_ctx: Event context for tracking

Returns:
Expand All @@ -233,13 +238,12 @@ async def _create_system_agent(
if not system_agent:
raise ValueError(f"System agent not found: {system_key}")

# Special handling for react agent - pass system_prompt and google_search
# Special handling for react agent - pass system_prompt
if system_key == "react":
from app.agents.system.react import ReActAgent

if isinstance(system_agent, ReActAgent):
system_agent.system_prompt = system_prompt
system_agent.google_search_enabled = google_search_enabled

# Build graph
compiled_graph = system_agent.build_graph()
Expand Down
9 changes: 1 addition & 8 deletions service/app/agents/system/react/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,16 @@ class ReActAgent(BaseSystemAgent):

# Additional configuration options
system_prompt: str
google_search_enabled: bool

def __init__(
self,
system_prompt: str = "",
google_search_enabled: bool = False,
) -> None:
"""
Initialize the ReAct agent.

Args:
system_prompt: System prompt to guide agent behavior
google_search_enabled: Enable Google's builtin web search
"""
super().__init__(
name="ReAct Agent",
Expand All @@ -81,23 +78,19 @@ def __init__(
author="Xyzen",
)
self.system_prompt = system_prompt
self.google_search_enabled = google_search_enabled

def build_graph(self) -> CompiledStateGraph[Any, None, Any, Any]:
"""
Build the ReAct agent graph using LangGraph's prebuilt implementation.

When google_search_enabled is True, binds both the google_search
provider tool and MCP tools together to the model.

Returns:
Compiled StateGraph ready for execution
"""
if not self.llm:
raise RuntimeError("LLM not configured. Call configure() first.")

tools = self.tools or []
logger.info(f"Building ReAct agent with {len(tools)} tools, google_search={self.google_search_enabled}")
logger.info(f"Building ReAct agent with {len(tools)} tools")

# Combine all tools for binding
# MCP tools (client-side) are passed as-is
Expand Down
2 changes: 2 additions & 0 deletions service/app/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .providers import router as providers_router
from .redemption import router as redemption_router
from .sessions import router as sessions_router
from .tools import router as tools_router
from .topics import router as topics_router

# Don't add tags here to avoid duplication in docs
Expand Down Expand Up @@ -91,3 +92,4 @@ async def root() -> RootResponse:
v1_router.include_router(knowledge_sets_router, prefix="/knowledge-sets")
v1_router.include_router(marketplace_router, prefix="/marketplace")
v1_router.include_router(avatar_router, prefix="/avatar")
v1_router.include_router(tools_router, prefix="/tools")
33 changes: 33 additions & 0 deletions service/app/api/v1/tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
Tools API - Builtin tools listing endpoint.
"""

from fastapi import APIRouter

from app.tools.registry import BuiltinToolRegistry, ToolInfo

router = APIRouter(prefix="/tools", tags=["tools"])


@router.get("", response_model=list[ToolInfo])
async def list_builtin_tools() -> list[ToolInfo]:
"""
List all available builtin tools.

Returns tools that can be enabled per-agent via the tool_ids field.
"""
return BuiltinToolRegistry.list_all()


@router.get("/{tool_id}", response_model=ToolInfo | None)
async def get_tool(tool_id: str) -> ToolInfo | None:
"""
Get details of a specific builtin tool.

Args:
tool_id: The tool ID (e.g., "web_search", "knowledge_read")

Returns:
Tool info or None if not found
"""
return BuiltinToolRegistry.get_info(tool_id)
6 changes: 6 additions & 0 deletions service/app/configs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .auth import AuthConfig
from .database import DatabaseConfig
from .dynamic_mcp_server import DynamicMCPConfig
from .image import ImageConfig
from .lab import LabConfig
from .llm import LLMConfig
from .logger import LoggerConfig
Expand Down Expand Up @@ -96,6 +97,11 @@ class AppConfig(BaseSettings):
description="SearXNG search configuration",
)

Image: ImageConfig = Field(
default_factory=lambda: ImageConfig(),
description="Image generation configuration",
)


configs: AppConfig = AppConfig()

Expand Down
17 changes: 17 additions & 0 deletions service/app/configs/image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Image generation configuration."""

from pydantic import BaseModel, Field


class ImageConfig(BaseModel):
"""Configuration for image generation tools."""

Enable: bool = Field(default=True, description="Enable image generation tools")
Provider: str = Field(
default="google_vertex",
description="Provider for image generation (e.g., google_vertex, openai)",
)
Model: str = Field(
default="gemini-3-pro-image-preview",
description="Model for image generation",
)
6 changes: 5 additions & 1 deletion service/app/configs/oss.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ class OSSConfig(BaseModel):

Endpoint: str = Field(
default="http://host.docker.internal:9000",
description="MinIO endpoint",
description="MinIO endpoint for backend-to-storage communication (internal)",
)
PublicEndpoint: str = Field(
default="http://localhost:9000",
description="MinIO endpoint for browser-accessible URLs (external)",
)
AccessKey: str = Field(
default="minioadmin",
Expand Down
14 changes: 14 additions & 0 deletions service/app/core/celery_app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from celery import Celery
from celery.signals import worker_process_init

from app.configs import configs

Expand All @@ -16,3 +17,16 @@
timezone="Asia/Shanghai",
enable_utc=True,
)


@worker_process_init.connect
def init_worker_process(**kwargs: object) -> None:
"""
Initialize builtin tools when Celery worker process starts.

This is required because the BuiltinToolRegistry uses class variables
that are not shared between the FastAPI process and Celery worker process.
"""
from app.tools.registry import register_builtin_tools

register_builtin_tools()
Loading
Loading