diff --git a/Makefile b/Makefile index 573988f..51b4efa 100644 --- a/Makefile +++ b/Makefile @@ -166,6 +166,7 @@ dev: @echo " → Nginx: Reverse Proxy" @echo "" @cleanup() { \ + trap - INT TERM; \ echo ""; \ echo "Shutting down services..."; \ pkill -f "langgraph dev" 2>/dev/null || true; \ diff --git a/backend/src/agents/lead_agent/agent.py b/backend/src/agents/lead_agent/agent.py index 2212e2e..5195b0a 100644 --- a/backend/src/agents/lead_agent/agent.py +++ b/backend/src/agents/lead_agent/agent.py @@ -14,6 +14,7 @@ from src.agents.middlewares.title_middleware import TitleMiddleware from src.agents.middlewares.uploads_middleware import UploadsMiddleware from src.agents.middlewares.view_image_middleware import ViewImageMiddleware from src.agents.thread_state import ThreadState +from src.config.agents_config import load_agent_config from src.config.app_config import get_app_config from src.config.summarization_config import get_summarization_config from src.models import create_chat_model @@ -22,14 +23,12 @@ from src.sandbox.middleware import SandboxMiddleware logger = logging.getLogger(__name__) -def _resolve_model_name(requested_model_name: str | None) -> str: +def _resolve_model_name(requested_model_name: str | None = None) -> str: """Resolve a runtime model name safely, falling back to default if invalid. Returns None if no models are configured.""" app_config = get_app_config() default_model_name = app_config.models[0].name if app_config.models else None if default_model_name is None: - raise ValueError( - "No chat models are configured. Please configure at least one model in config.yaml." - ) + raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.") if requested_model_name and app_config.get_model_config(requested_model_name): return requested_model_name @@ -205,11 +204,12 @@ Being proactive with task management demonstrates thoroughness and ensures all r # MemoryMiddleware queues conversation for memory update (after TitleMiddleware) # ViewImageMiddleware should be before ClarificationMiddleware to inject image details before LLM # ClarificationMiddleware should be last to intercept clarification requests after model calls -def _build_middlewares(config: RunnableConfig, model_name: str | None): +def _build_middlewares(config: RunnableConfig, model_name: str | None, agent_name: str | None = None): """Build middleware chain based on runtime configuration. Args: config: Runtime configuration containing configurable options like is_plan_mode. + agent_name: If provided, MemoryMiddleware will use per-agent memory storage. Returns: List of middleware instances. @@ -231,7 +231,7 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None): middlewares.append(TitleMiddleware()) # Add MemoryMiddleware (after TitleMiddleware) - middlewares.append(MemoryMiddleware()) + middlewares.append(MemoryMiddleware(agent_name=agent_name)) # Add ViewImageMiddleware only if the current model supports vision. # Use the resolved runtime model_name from make_lead_agent to avoid stale config values. @@ -254,28 +254,36 @@ def _build_middlewares(config: RunnableConfig, model_name: str | None): def make_lead_agent(config: RunnableConfig): # Lazy import to avoid circular dependency from src.tools import get_available_tools + from src.tools.builtins import setup_agent thinking_enabled = config.get("configurable", {}).get("thinking_enabled", True) reasoning_effort = config.get("configurable", {}).get("reasoning_effort", None) - requested_model_name = config.get("configurable", {}).get("model_name") or config.get("configurable", {}).get("model") - model_name = _resolve_model_name(requested_model_name) - if model_name is None: - raise ValueError( - "No chat model could be resolved. Please configure at least one model in " - "config.yaml or provide a valid 'model_name'/'model' in the request." - ) + requested_model_name: str | None = config.get("configurable", {}).get("model_name") or config.get("configurable", {}).get("model") is_plan_mode = config.get("configurable", {}).get("is_plan_mode", False) subagent_enabled = config.get("configurable", {}).get("subagent_enabled", False) max_concurrent_subagents = config.get("configurable", {}).get("max_concurrent_subagents", 3) + is_bootstrap = config.get("configurable", {}).get("is_bootstrap", False) + agent_name = config.get("configurable", {}).get("agent_name") + + agent_config = load_agent_config(agent_name) + # Custom agent model or fallback to global/default model resolution + agent_model_name = agent_config.model if agent_config and agent_config.model else _resolve_model_name() + + # Final model name resolution with request override, then agent config, then global default + model_name = requested_model_name or agent_model_name app_config = get_app_config() model_config = app_config.get_model_config(model_name) if model_name else None - if thinking_enabled and model_config is not None and not model_config.supports_thinking: + + if model_config is None: + raise ValueError("No chat model could be resolved. Please configure at least one model in config.yaml or provide a valid 'model_name'/'model' in the request.") + if thinking_enabled and not model_config.supports_thinking: logger.warning(f"Thinking mode is enabled but model '{model_name}' does not support it; fallback to non-thinking mode.") thinking_enabled = False logger.info( - "thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s", + "Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s", + agent_name or "default", thinking_enabled, reasoning_effort, model_name, @@ -287,8 +295,10 @@ def make_lead_agent(config: RunnableConfig): # Inject run metadata for LangSmith trace tagging if "metadata" not in config: config["metadata"] = {} + config["metadata"].update( { + "agent_name": agent_name or "default", "model_name": model_name or "default", "thinking_enabled": thinking_enabled, "reasoning_effort": reasoning_effort, @@ -297,10 +307,23 @@ def make_lead_agent(config: RunnableConfig): } ) + if is_bootstrap: + # Special bootstrap agent with minimal prompt for initial custom agent creation flow + system_prompt = apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, available_skills=set(["bootstrap"])) + + return create_agent( + model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled), + tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled) + [setup_agent], + middleware=_build_middlewares(config, model_name=model_name), + system_prompt=system_prompt, + state_schema=ThreadState, + ) + + # Default lead agent (unchanged behavior) return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort), - tools=get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled), - middleware=_build_middlewares(config, model_name=model_name), - system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents), + tools=get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled), + middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_name), + system_prompt=apply_prompt_template(subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=agent_name), state_schema=ThreadState, ) diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py index dfe53be..4988a7b 100644 --- a/backend/src/agents/lead_agent/prompt.py +++ b/backend/src/agents/lead_agent/prompt.py @@ -1,5 +1,6 @@ from datetime import datetime +from src.config.agents_config import load_agent_soul from src.skills import load_skills @@ -148,9 +149,10 @@ bash("npm test") # Direct execution, not task() SYSTEM_PROMPT_TEMPLATE = """ -You are DeerFlow 2.0, an open-source super agent. +You are {agent_name}, an open-source super agent. +{soul} {memory_context} @@ -280,9 +282,12 @@ Recent breakthroughs in language models have also accelerated progress """ -def _get_memory_context() -> str: +def _get_memory_context(agent_name: str | None = None) -> str: """Get memory context for injection into system prompt. + Args: + agent_name: If provided, loads per-agent memory. If None, loads global memory. + Returns: Formatted memory context string wrapped in XML tags, or empty string if disabled. """ @@ -294,7 +299,7 @@ def _get_memory_context() -> str: if not config.enabled or not config.injection_enabled: return "" - memory_data = get_memory_data() + memory_data = get_memory_data(agent_name) memory_content = format_memory_for_injection(memory_data, max_tokens=config.max_injection_tokens) if not memory_content.strip(): @@ -309,7 +314,7 @@ def _get_memory_context() -> str: return "" -def get_skills_prompt_section() -> str: +def get_skills_prompt_section(available_skills: set[str] | None = None) -> str: """Generate the skills prompt section with available skills list. Returns the ... block listing all enabled skills, @@ -328,6 +333,9 @@ def get_skills_prompt_section() -> str: if not skills: return "" + if available_skills is not None: + skills = [skill for skill in skills if skill.name in available_skills] + skill_items = "\n".join( f" \n {skill.name}\n {skill.description}\n {skill.get_container_file_path(container_base_path)}\n " for skill in skills ) @@ -350,9 +358,17 @@ You have access to skills that provide optimized workflows for specific tasks. E """ -def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagents: int = 3) -> str: +def get_agent_soul(agent_name: str | None) -> str: + # Append SOUL.md (agent personality) if present + soul = load_agent_soul(agent_name) + if soul: + return f"\n{soul}\n\n" if soul else "" + return "" + + +def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagents: int = 3, *, agent_name: str | None = None, available_skills: set[str] | None = None) -> str: # Get memory context - memory_context = _get_memory_context() + memory_context = _get_memory_context(agent_name) # Include subagent section only if enabled (from runtime parameter) n = max_concurrent_subagents @@ -377,10 +393,12 @@ def apply_prompt_template(subagent_enabled: bool = False, max_concurrent_subagen ) # Get skills section - skills_section = get_skills_prompt_section() + skills_section = get_skills_prompt_section(available_skills) # Format the prompt with dynamic skills and memory prompt = SYSTEM_PROMPT_TEMPLATE.format( + agent_name=agent_name or "DeerFlow 2.0", + soul=get_agent_soul(agent_name), skills_section=skills_section, memory_context=memory_context, subagent_section=subagent_section, diff --git a/backend/src/agents/memory/queue.py b/backend/src/agents/memory/queue.py index e11e7c2..9e4a757 100644 --- a/backend/src/agents/memory/queue.py +++ b/backend/src/agents/memory/queue.py @@ -16,6 +16,7 @@ class ConversationContext: thread_id: str messages: list[Any] timestamp: datetime = field(default_factory=datetime.utcnow) + agent_name: str | None = None class MemoryUpdateQueue: @@ -33,12 +34,13 @@ class MemoryUpdateQueue: self._timer: threading.Timer | None = None self._processing = False - def add(self, thread_id: str, messages: list[Any]) -> None: + def add(self, thread_id: str, messages: list[Any], agent_name: str | None = None) -> None: """Add a conversation to the update queue. Args: thread_id: The thread ID. messages: The conversation messages. + agent_name: If provided, memory is stored per-agent. If None, uses global memory. """ config = get_memory_config() if not config.enabled: @@ -47,6 +49,7 @@ class MemoryUpdateQueue: context = ConversationContext( thread_id=thread_id, messages=messages, + agent_name=agent_name, ) with self._lock: @@ -108,6 +111,7 @@ class MemoryUpdateQueue: success = updater.update_memory( messages=context.messages, thread_id=context.thread_id, + agent_name=context.agent_name, ) if success: print(f"Memory updated successfully for thread {context.thread_id}") diff --git a/backend/src/agents/memory/updater.py b/backend/src/agents/memory/updater.py index 6736e03..a9ad72a 100644 --- a/backend/src/agents/memory/updater.py +++ b/backend/src/agents/memory/updater.py @@ -15,8 +15,19 @@ from src.config.paths import get_paths from src.models import create_chat_model -def _get_memory_file_path() -> Path: - """Get the path to the memory file.""" +def _get_memory_file_path(agent_name: str | None = None) -> Path: + """Get the path to the memory file. + + Args: + agent_name: If provided, returns the per-agent memory file path. + If None, returns the global memory file path. + + Returns: + Path to the memory file. + """ + if agent_name is not None: + return get_paths().agent_memory_file(agent_name) + config = get_memory_config() if config.storage_path: p = Path(config.storage_path) @@ -44,24 +55,24 @@ def _create_empty_memory() -> dict[str, Any]: } -# Global memory data cache -_memory_data: dict[str, Any] | None = None -# Track file modification time for cache invalidation -_memory_file_mtime: float | None = None +# Per-agent memory cache: keyed by agent_name (None = global) +# Value: (memory_data, file_mtime) +_memory_cache: dict[str | None, tuple[dict[str, Any], float | None]] = {} -def get_memory_data() -> dict[str, Any]: +def get_memory_data(agent_name: str | None = None) -> dict[str, Any]: """Get the current memory data (cached with file modification time check). The cache is automatically invalidated if the memory file has been modified since the last load, ensuring fresh data is always returned. + Args: + agent_name: If provided, loads per-agent memory. If None, loads global memory. + Returns: The memory data dictionary. """ - global _memory_data, _memory_file_mtime - - file_path = _get_memory_file_path() + file_path = _get_memory_file_path(agent_name) # Get current file modification time try: @@ -69,41 +80,48 @@ def get_memory_data() -> dict[str, Any]: except OSError: current_mtime = None + cached = _memory_cache.get(agent_name) + # Invalidate cache if file has been modified or doesn't exist - if _memory_data is None or _memory_file_mtime != current_mtime: - _memory_data = _load_memory_from_file() - _memory_file_mtime = current_mtime + if cached is None or cached[1] != current_mtime: + memory_data = _load_memory_from_file(agent_name) + _memory_cache[agent_name] = (memory_data, current_mtime) + return memory_data - return _memory_data + return cached[0] -def reload_memory_data() -> dict[str, Any]: +def reload_memory_data(agent_name: str | None = None) -> dict[str, Any]: """Reload memory data from file, forcing cache invalidation. + Args: + agent_name: If provided, reloads per-agent memory. If None, reloads global memory. + Returns: The reloaded memory data dictionary. """ - global _memory_data, _memory_file_mtime + file_path = _get_memory_file_path(agent_name) + memory_data = _load_memory_from_file(agent_name) - file_path = _get_memory_file_path() - _memory_data = _load_memory_from_file() - - # Update file modification time after reload try: - _memory_file_mtime = file_path.stat().st_mtime if file_path.exists() else None + mtime = file_path.stat().st_mtime if file_path.exists() else None except OSError: - _memory_file_mtime = None + mtime = None - return _memory_data + _memory_cache[agent_name] = (memory_data, mtime) + return memory_data -def _load_memory_from_file() -> dict[str, Any]: +def _load_memory_from_file(agent_name: str | None = None) -> dict[str, Any]: """Load memory data from file. + Args: + agent_name: If provided, loads per-agent memory file. If None, loads global. + Returns: The memory data dictionary. """ - file_path = _get_memory_file_path() + file_path = _get_memory_file_path(agent_name) if not file_path.exists(): return _create_empty_memory() @@ -117,17 +135,17 @@ def _load_memory_from_file() -> dict[str, Any]: return _create_empty_memory() -def _save_memory_to_file(memory_data: dict[str, Any]) -> bool: +def _save_memory_to_file(memory_data: dict[str, Any], agent_name: str | None = None) -> bool: """Save memory data to file and update cache. Args: memory_data: The memory data to save. + agent_name: If provided, saves to per-agent memory file. If None, saves to global. Returns: True if successful, False otherwise. """ - global _memory_data, _memory_file_mtime - file_path = _get_memory_file_path() + file_path = _get_memory_file_path(agent_name) try: # Ensure directory exists @@ -145,11 +163,12 @@ def _save_memory_to_file(memory_data: dict[str, Any]) -> bool: temp_path.replace(file_path) # Update cache and file modification time - _memory_data = memory_data try: - _memory_file_mtime = file_path.stat().st_mtime + mtime = file_path.stat().st_mtime except OSError: - _memory_file_mtime = None + mtime = None + + _memory_cache[agent_name] = (memory_data, mtime) print(f"Memory saved to {file_path}") return True @@ -175,12 +194,13 @@ class MemoryUpdater: model_name = self._model_name or config.model_name return create_chat_model(name=model_name, thinking_enabled=False) - def update_memory(self, messages: list[Any], thread_id: str | None = None) -> bool: + def update_memory(self, messages: list[Any], thread_id: str | None = None, agent_name: str | None = None) -> bool: """Update memory based on conversation messages. Args: messages: List of conversation messages. thread_id: Optional thread ID for tracking source. + agent_name: If provided, updates per-agent memory. If None, updates global memory. Returns: True if update was successful, False otherwise. @@ -194,7 +214,7 @@ class MemoryUpdater: try: # Get current memory - current_memory = get_memory_data() + current_memory = get_memory_data(agent_name) # Format conversation for prompt conversation_text = format_conversation_for_update(messages) @@ -225,7 +245,7 @@ class MemoryUpdater: updated_memory = self._apply_updates(current_memory, update_data, thread_id) # Save - return _save_memory_to_file(updated_memory) + return _save_memory_to_file(updated_memory, agent_name) except json.JSONDecodeError as e: print(f"Failed to parse LLM response for memory update: {e}") @@ -305,15 +325,16 @@ class MemoryUpdater: return current_memory -def update_memory_from_conversation(messages: list[Any], thread_id: str | None = None) -> bool: +def update_memory_from_conversation(messages: list[Any], thread_id: str | None = None, agent_name: str | None = None) -> bool: """Convenience function to update memory from a conversation. Args: messages: List of conversation messages. thread_id: Optional thread ID. + agent_name: If provided, updates per-agent memory. If None, updates global memory. Returns: True if successful, False otherwise. """ updater = MemoryUpdater() - return updater.update_memory(messages, thread_id) + return updater.update_memory(messages, thread_id, agent_name) diff --git a/backend/src/agents/middlewares/memory_middleware.py b/backend/src/agents/middlewares/memory_middleware.py index 115cac9..c7c74ea 100644 --- a/backend/src/agents/middlewares/memory_middleware.py +++ b/backend/src/agents/middlewares/memory_middleware.py @@ -62,6 +62,15 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): state_schema = MemoryMiddlewareState + def __init__(self, agent_name: str | None = None): + """Initialize the MemoryMiddleware. + + Args: + agent_name: If provided, memory is stored per-agent. If None, uses global memory. + """ + super().__init__() + self._agent_name = agent_name + @override def after_agent(self, state: MemoryMiddlewareState, runtime: Runtime) -> dict | None: """Queue conversation for memory update after agent completes. @@ -102,6 +111,6 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): # Queue the filtered conversation for memory update queue = get_memory_queue() - queue.add(thread_id=thread_id, messages=filtered_messages) + queue.add(thread_id=thread_id, messages=filtered_messages, agent_name=self._agent_name) return None diff --git a/backend/src/config/agents_config.py b/backend/src/config/agents_config.py new file mode 100644 index 0000000..9d92a00 --- /dev/null +++ b/backend/src/config/agents_config.py @@ -0,0 +1,120 @@ +"""Configuration and loaders for custom agents.""" + +import logging +import re +from typing import Any + +import yaml +from pydantic import BaseModel + +from src.config.paths import get_paths + +logger = logging.getLogger(__name__) + +SOUL_FILENAME = "SOUL.md" +AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") + + +class AgentConfig(BaseModel): + """Configuration for a custom agent.""" + + name: str + description: str = "" + model: str | None = None + tool_groups: list[str] | None = None + + +def load_agent_config(name: str | None) -> AgentConfig | None: + """Load the custom or default agent's config from its directory. + + Args: + name: The agent name. + + Returns: + AgentConfig instance. + + Raises: + FileNotFoundError: If the agent directory or config.yaml does not exist. + ValueError: If config.yaml cannot be parsed. + """ + + if name is None: + return None + + if not AGENT_NAME_PATTERN.match(name): + raise ValueError(f"Invalid agent name '{name}'. Must match pattern: {AGENT_NAME_PATTERN.pattern}") + agent_dir = get_paths().agent_dir(name) + config_file = agent_dir / "config.yaml" + + if not agent_dir.exists(): + raise FileNotFoundError(f"Agent directory not found: {agent_dir}") + + if not config_file.exists(): + raise FileNotFoundError(f"Agent config not found: {config_file}") + + try: + with open(config_file, encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) or {} + except yaml.YAMLError as e: + raise ValueError(f"Failed to parse agent config {config_file}: {e}") from e + + # Ensure name is set from directory name if not in file + if "name" not in data: + data["name"] = name + + # Strip unknown fields before passing to Pydantic (e.g. legacy prompt_file) + known_fields = set(AgentConfig.model_fields.keys()) + data = {k: v for k, v in data.items() if k in known_fields} + + return AgentConfig(**data) + + +def load_agent_soul(agent_name: str | None) -> str | None: + """Read the SOUL.md file for a custom agent, if it exists. + + SOUL.md defines the agent's personality, values, and behavioral guardrails. + It is injected into the lead agent's system prompt as additional context. + + Args: + agent_name: The name of the agent or None for the default agent. + + Returns: + The SOUL.md content as a string, or None if the file does not exist. + """ + agent_dir = get_paths().agent_dir(agent_name) if agent_name else get_paths().base_dir + soul_path = agent_dir / SOUL_FILENAME + if not soul_path.exists(): + return None + content = soul_path.read_text(encoding="utf-8").strip() + return content or None + + +def list_custom_agents() -> list[AgentConfig]: + """Scan the agents directory and return all valid custom agents. + + Returns: + List of AgentConfig for each valid agent directory found. + """ + agents_dir = get_paths().agents_dir + + if not agents_dir.exists(): + return [] + + agents: list[AgentConfig] = [] + + for entry in sorted(agents_dir.iterdir()): + if not entry.is_dir(): + continue + + config_file = entry / "config.yaml" + if not config_file.exists(): + logger.debug(f"Skipping {entry.name}: no config.yaml") + continue + + try: + agent_cfg = load_agent_config(entry.name) + agents.append(agent_cfg) + except Exception as e: + logger.warning(f"Skipping agent '{entry.name}': {e}") + + return agents diff --git a/backend/src/config/paths.py b/backend/src/config/paths.py index 828f7c4..1c48c69 100644 --- a/backend/src/config/paths.py +++ b/backend/src/config/paths.py @@ -15,6 +15,12 @@ class Paths: Directory layout (host side): {base_dir}/ ├── memory.json + ├── USER.md <-- global user profile (injected into all agents) + ├── agents/ + │ └── {agent_name}/ + │ ├── config.yaml + │ ├── SOUL.md <-- agent personality/identity (injected alongside lead prompt) + │ └── memory.json └── threads/ └── {thread_id}/ └── user-data/ <-- mounted as /mnt/user-data/ inside sandbox @@ -52,6 +58,24 @@ class Paths: """Path to the persisted memory file: `{base_dir}/memory.json`.""" return self.base_dir / "memory.json" + @property + def user_md_file(self) -> Path: + """Path to the global user profile file: `{base_dir}/USER.md`.""" + return self.base_dir / "USER.md" + + @property + def agents_dir(self) -> Path: + """Root directory for all custom agents: `{base_dir}/agents/`.""" + return self.base_dir / "agents" + + def agent_dir(self, name: str) -> Path: + """Directory for a specific agent: `{base_dir}/agents/{name}/`.""" + return self.agents_dir / name.lower() + + def agent_memory_file(self, name: str) -> Path: + """Per-agent memory file: `{base_dir}/agents/{name}/memory.json`.""" + return self.agent_dir(name) / "memory.json" + def thread_dir(self, thread_id: str) -> Path: """ Host path for a thread's data: `{base_dir}/threads/{thread_id}/` @@ -64,10 +88,7 @@ class Paths: or `..`) that could cause directory traversal. """ if not _SAFE_THREAD_ID_RE.match(thread_id): - raise ValueError( - f"Invalid thread_id {thread_id!r}: only alphanumeric characters, " - "hyphens, and underscores are allowed." - ) + raise ValueError(f"Invalid thread_id {thread_id!r}: only alphanumeric characters, hyphens, and underscores are allowed.") return self.base_dir / "threads" / thread_id def sandbox_work_dir(self, thread_id: str) -> Path: diff --git a/backend/src/gateway/app.py b/backend/src/gateway/app.py index f8643a0..48c1bed 100644 --- a/backend/src/gateway/app.py +++ b/backend/src/gateway/app.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from src.config.app_config import get_app_config from src.gateway.config import get_gateway_config -from src.gateway.routers import artifacts, mcp, memory, models, skills, uploads +from src.gateway.routers import agents, artifacts, mcp, memory, models, skills, uploads # Configure logging logging.basicConfig( @@ -100,6 +100,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an "name": "uploads", "description": "Upload and manage user files for threads", }, + { + "name": "agents", + "description": "Create and manage custom agents with per-agent config and prompts", + }, { "name": "health", "description": "Health check and system status endpoints", @@ -128,6 +132,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Uploads API is mounted at /api/threads/{thread_id}/uploads app.include_router(uploads.router) + # Agents API is mounted at /api/agents + app.include_router(agents.router) + @app.get("/health", tags=["health"]) async def health_check() -> dict: """Health check endpoint. diff --git a/backend/src/gateway/routers/agents.py b/backend/src/gateway/routers/agents.py new file mode 100644 index 0000000..0203240 --- /dev/null +++ b/backend/src/gateway/routers/agents.py @@ -0,0 +1,383 @@ +"""CRUD API for custom agents.""" + +import logging +import re +import shutil + +import yaml +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from src.config.agents_config import AgentConfig, list_custom_agents, load_agent_config, load_agent_soul +from src.config.paths import get_paths + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api", tags=["agents"]) + +AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$") + + +class AgentResponse(BaseModel): + """Response model for a custom agent.""" + + name: str = Field(..., description="Agent name (hyphen-case)") + description: str = Field(default="", description="Agent description") + model: str | None = Field(default=None, description="Optional model override") + tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") + soul: str | None = Field(default=None, description="SOUL.md content (included on GET /{name})") + + +class AgentsListResponse(BaseModel): + """Response model for listing all custom agents.""" + + agents: list[AgentResponse] + + +class AgentCreateRequest(BaseModel): + """Request body for creating a custom agent.""" + + name: str = Field(..., description="Agent name (must match ^[A-Za-z0-9-]+$, stored as lowercase)") + description: str = Field(default="", description="Agent description") + model: str | None = Field(default=None, description="Optional model override") + tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") + soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails") + + +class AgentUpdateRequest(BaseModel): + """Request body for updating a custom agent.""" + + description: str | None = Field(default=None, description="Updated description") + model: str | None = Field(default=None, description="Updated model override") + tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist") + soul: str | None = Field(default=None, description="Updated SOUL.md content") + + +def _validate_agent_name(name: str) -> None: + """Validate agent name against allowed pattern. + + Args: + name: The agent name to validate. + + Raises: + HTTPException: 422 if the name is invalid. + """ + if not AGENT_NAME_PATTERN.match(name): + raise HTTPException( + status_code=422, + detail=f"Invalid agent name '{name}'. Must match ^[A-Za-z0-9-]+$ (letters, digits, and hyphens only).", + ) + + +def _normalize_agent_name(name: str) -> str: + """Normalize agent name to lowercase for filesystem storage.""" + return name.lower() + + +def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False) -> AgentResponse: + """Convert AgentConfig to AgentResponse.""" + soul: str | None = None + if include_soul: + soul = load_agent_soul(agent_cfg.name) or "" + + return AgentResponse( + name=agent_cfg.name, + description=agent_cfg.description, + model=agent_cfg.model, + tool_groups=agent_cfg.tool_groups, + soul=soul, + ) + + +@router.get( + "/agents", + response_model=AgentsListResponse, + summary="List Custom Agents", + description="List all custom agents available in the agents directory.", +) +async def list_agents() -> AgentsListResponse: + """List all custom agents. + + Returns: + List of all custom agents with their metadata (without soul content). + """ + try: + agents = list_custom_agents() + return AgentsListResponse(agents=[_agent_config_to_response(a) for a in agents]) + except Exception as e: + logger.error(f"Failed to list agents: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") + + +@router.get( + "/agents/check", + summary="Check Agent Name", + description="Validate an agent name and check if it is available (case-insensitive).", +) +async def check_agent_name(name: str) -> dict: + """Check whether an agent name is valid and not yet taken. + + Args: + name: The agent name to check. + + Returns: + ``{"available": true/false, "name": ""}`` + + Raises: + HTTPException: 422 if the name is invalid. + """ + _validate_agent_name(name) + normalized = _normalize_agent_name(name) + available = not get_paths().agent_dir(normalized).exists() + return {"available": available, "name": normalized} + + +@router.get( + "/agents/{name}", + response_model=AgentResponse, + summary="Get Custom Agent", + description="Retrieve details and SOUL.md content for a specific custom agent.", +) +async def get_agent(name: str) -> AgentResponse: + """Get a specific custom agent by name. + + Args: + name: The agent name. + + Returns: + Agent details including SOUL.md content. + + Raises: + HTTPException: 404 if agent not found. + """ + _validate_agent_name(name) + name = _normalize_agent_name(name) + + try: + agent_cfg = load_agent_config(name) + return _agent_config_to_response(agent_cfg, include_soul=True) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + except Exception as e: + logger.error(f"Failed to get agent '{name}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") + + +@router.post( + "/agents", + response_model=AgentResponse, + status_code=201, + summary="Create Custom Agent", + description="Create a new custom agent with its config and SOUL.md.", +) +async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse: + """Create a new custom agent. + + Args: + request: The agent creation request. + + Returns: + The created agent details. + + Raises: + HTTPException: 409 if agent already exists, 422 if name is invalid. + """ + _validate_agent_name(request.name) + normalized_name = _normalize_agent_name(request.name) + + agent_dir = get_paths().agent_dir(normalized_name) + + if agent_dir.exists(): + raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists") + + try: + agent_dir.mkdir(parents=True, exist_ok=True) + + # Write config.yaml + config_data: dict = {"name": normalized_name} + if request.description: + config_data["description"] = request.description + if request.model is not None: + config_data["model"] = request.model + if request.tool_groups is not None: + config_data["tool_groups"] = request.tool_groups + + config_file = agent_dir / "config.yaml" + with open(config_file, "w", encoding="utf-8") as f: + yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) + + # Write SOUL.md + soul_file = agent_dir / "SOUL.md" + soul_file.write_text(request.soul, encoding="utf-8") + + logger.info(f"Created agent '{normalized_name}' at {agent_dir}") + + agent_cfg = load_agent_config(normalized_name) + return _agent_config_to_response(agent_cfg, include_soul=True) + + except HTTPException: + raise + except Exception as e: + # Clean up on failure + if agent_dir.exists(): + shutil.rmtree(agent_dir) + logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") + + +@router.put( + "/agents/{name}", + response_model=AgentResponse, + summary="Update Custom Agent", + description="Update an existing custom agent's config and/or SOUL.md.", +) +async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse: + """Update an existing custom agent. + + Args: + name: The agent name. + request: The update request (all fields optional). + + Returns: + The updated agent details. + + Raises: + HTTPException: 404 if agent not found. + """ + _validate_agent_name(name) + name = _normalize_agent_name(name) + + try: + agent_cfg = load_agent_config(name) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + + agent_dir = get_paths().agent_dir(name) + + try: + # Update config if any config fields changed + config_changed = any(v is not None for v in [request.description, request.model, request.tool_groups]) + + if config_changed: + updated: dict = { + "name": agent_cfg.name, + "description": request.description if request.description is not None else agent_cfg.description, + } + new_model = request.model if request.model is not None else agent_cfg.model + if new_model is not None: + updated["model"] = new_model + + new_tool_groups = request.tool_groups if request.tool_groups is not None else agent_cfg.tool_groups + if new_tool_groups is not None: + updated["tool_groups"] = new_tool_groups + + config_file = agent_dir / "config.yaml" + with open(config_file, "w", encoding="utf-8") as f: + yaml.dump(updated, f, default_flow_style=False, allow_unicode=True) + + # Update SOUL.md if provided + if request.soul is not None: + soul_path = agent_dir / "SOUL.md" + soul_path.write_text(request.soul, encoding="utf-8") + + logger.info(f"Updated agent '{name}'") + + refreshed_cfg = load_agent_config(name) + return _agent_config_to_response(refreshed_cfg, include_soul=True) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update agent '{name}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") + + +class UserProfileResponse(BaseModel): + """Response model for the global user profile (USER.md).""" + + content: str | None = Field(default=None, description="USER.md content, or null if not yet created") + + +class UserProfileUpdateRequest(BaseModel): + """Request body for setting the global user profile.""" + + content: str = Field(default="", description="USER.md content — describes the user's background and preferences") + + +@router.get( + "/user-profile", + response_model=UserProfileResponse, + summary="Get User Profile", + description="Read the global USER.md file that is injected into all custom agents.", +) +async def get_user_profile() -> UserProfileResponse: + """Return the current USER.md content. + + Returns: + UserProfileResponse with content=None if USER.md does not exist yet. + """ + try: + user_md_path = get_paths().user_md_file + if not user_md_path.exists(): + return UserProfileResponse(content=None) + raw = user_md_path.read_text(encoding="utf-8").strip() + return UserProfileResponse(content=raw or None) + except Exception as e: + logger.error(f"Failed to read user profile: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}") + + +@router.put( + "/user-profile", + response_model=UserProfileResponse, + summary="Update User Profile", + description="Write the global USER.md file that is injected into all custom agents.", +) +async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse: + """Create or overwrite the global USER.md. + + Args: + request: The update request with the new USER.md content. + + Returns: + UserProfileResponse with the saved content. + """ + try: + paths = get_paths() + paths.base_dir.mkdir(parents=True, exist_ok=True) + paths.user_md_file.write_text(request.content, encoding="utf-8") + logger.info(f"Updated USER.md at {paths.user_md_file}") + return UserProfileResponse(content=request.content or None) + except Exception as e: + logger.error(f"Failed to update user profile: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}") + + +@router.delete( + "/agents/{name}", + status_code=204, + summary="Delete Custom Agent", + description="Delete a custom agent and all its files (config, SOUL.md, memory).", +) +async def delete_agent(name: str) -> None: + """Delete a custom agent. + + Args: + name: The agent name. + + Raises: + HTTPException: 404 if agent not found. + """ + _validate_agent_name(name) + name = _normalize_agent_name(name) + + agent_dir = get_paths().agent_dir(name) + + if not agent_dir.exists(): + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + + try: + shutil.rmtree(agent_dir) + logger.info(f"Deleted agent '{name}' from {agent_dir}") + except Exception as e: + logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") diff --git a/backend/src/tools/builtins/__init__.py b/backend/src/tools/builtins/__init__.py index a4f4711..706d5d3 100644 --- a/backend/src/tools/builtins/__init__.py +++ b/backend/src/tools/builtins/__init__.py @@ -1,9 +1,11 @@ from .clarification_tool import ask_clarification_tool from .present_file_tool import present_file_tool +from .setup_agent_tool import setup_agent from .task_tool import task_tool from .view_image_tool import view_image_tool __all__ = [ + "setup_agent", "present_file_tool", "ask_clarification_tool", "view_image_tool", diff --git a/backend/src/tools/builtins/setup_agent_tool.py b/backend/src/tools/builtins/setup_agent_tool.py new file mode 100644 index 0000000..e107e9f --- /dev/null +++ b/backend/src/tools/builtins/setup_agent_tool.py @@ -0,0 +1,62 @@ +import logging + +import yaml +from langchain_core.messages import ToolMessage +from langchain_core.tools import tool +from langgraph.prebuilt import ToolRuntime +from langgraph.types import Command + +from src.config.paths import get_paths + +logger = logging.getLogger(__name__) + + +@tool +def setup_agent( + soul: str, + description: str, + runtime: ToolRuntime, +) -> Command: + """Setup the custom DeerFlow agent. + + Args: + soul: Full SOUL.md content defining the agent's personality and behavior. + description: One-line description of what the agent does. + """ + + agent_name: str | None = runtime.context.get("agent_name") + + try: + paths = get_paths() + agent_dir = paths.agent_dir(agent_name) if agent_name else paths.base_dir + agent_dir.mkdir(parents=True, exist_ok=True) + + if agent_name: + # If agent_name is provided, we are creating a custom agent in the agents/ directory + config_data: dict = {"name": agent_name} + if description: + config_data["description"] = description + + config_file = agent_dir / "config.yaml" + with open(config_file, "w", encoding="utf-8") as f: + yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) + + soul_file = agent_dir / "SOUL.md" + soul_file.write_text(soul, encoding="utf-8") + + logger.info(f"[agent_creator] Created agent '{agent_name}' at {agent_dir}") + return Command( + update={ + "created_agent_name": agent_name, + "messages": [ToolMessage(content=f"Agent '{agent_name}' created successfully!", tool_call_id=runtime.tool_call_id)], + } + ) + + except Exception as e: + import shutil + + if agent_name and agent_dir.exists(): + # Cleanup the custom agent directory only if it was created but an error occurred during setup + shutil.rmtree(agent_dir) + logger.error(f"[agent_creator] Failed to create agent '{agent_name}': {e}", exc_info=True) + return Command(update={"messages": [ToolMessage(content=f"Error: {e}", tool_call_id=runtime.tool_call_id)]}) diff --git a/backend/tests/test_client_live.py b/backend/tests/test_client_live.py index 15d40a9..46f1a9c 100644 --- a/backend/tests/test_client_live.py +++ b/backend/tests/test_client_live.py @@ -226,13 +226,12 @@ class TestLiveFileUpload: # =========================================================================== class TestLiveConfigQueries: - def test_list_models_returns_ark(self, client): - """list_models() returns the configured ARK model.""" + def test_list_models_returns_configured_model(self, client): + """list_models() returns at least one configured model with Gateway-aligned fields.""" result = client.list_models() assert "models" in result assert len(result["models"]) >= 1 names = [m["name"] for m in result["models"]] - assert "ark-model" in names # Verify Gateway-aligned fields for m in result["models"]: assert "display_name" in m @@ -240,10 +239,12 @@ class TestLiveConfigQueries: print(f" models: {names}") def test_get_model_found(self, client): - """get_model() returns details for existing model.""" - model = client.get_model("ark-model") + """get_model() returns details for the first configured model.""" + result = client.list_models() + first_model_name = result["models"][0]["name"] + model = client.get_model(first_model_name) assert model is not None - assert model["name"] == "ark-model" + assert model["name"] == first_model_name assert "display_name" in model assert "supports_thinking" in model print(f" model detail: {model}") diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py new file mode 100644 index 0000000..2cfee11 --- /dev/null +++ b/backend/tests/test_custom_agent.py @@ -0,0 +1,515 @@ +"""Tests for custom agent support.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml +from fastapi.testclient import TestClient + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_paths(base_dir: Path): + """Return a Paths instance pointing to base_dir.""" + from src.config.paths import Paths + + return Paths(base_dir=base_dir) + + +def _write_agent(base_dir: Path, name: str, config: dict, soul: str = "You are helpful.") -> None: + """Write an agent directory with config.yaml and SOUL.md.""" + agent_dir = base_dir / "agents" / name + agent_dir.mkdir(parents=True, exist_ok=True) + + config_copy = dict(config) + if "name" not in config_copy: + config_copy["name"] = name + + with open(agent_dir / "config.yaml", "w") as f: + yaml.dump(config_copy, f) + + (agent_dir / "SOUL.md").write_text(soul, encoding="utf-8") + + +# =========================================================================== +# 1. Paths class – agent path methods +# =========================================================================== + + +class TestPaths: + def test_agents_dir(self, tmp_path): + paths = _make_paths(tmp_path) + assert paths.agents_dir == tmp_path / "agents" + + def test_agent_dir(self, tmp_path): + paths = _make_paths(tmp_path) + assert paths.agent_dir("code-reviewer") == tmp_path / "agents" / "code-reviewer" + + def test_agent_memory_file(self, tmp_path): + paths = _make_paths(tmp_path) + assert paths.agent_memory_file("code-reviewer") == tmp_path / "agents" / "code-reviewer" / "memory.json" + + def test_user_md_file(self, tmp_path): + paths = _make_paths(tmp_path) + assert paths.user_md_file == tmp_path / "USER.md" + + def test_paths_are_different_from_global(self, tmp_path): + paths = _make_paths(tmp_path) + assert paths.memory_file != paths.agent_memory_file("my-agent") + assert paths.memory_file == tmp_path / "memory.json" + assert paths.agent_memory_file("my-agent") == tmp_path / "agents" / "my-agent" / "memory.json" + + +# =========================================================================== +# 2. AgentConfig – Pydantic parsing +# =========================================================================== + + +class TestAgentConfig: + def test_minimal_config(self): + from src.config.agents_config import AgentConfig + + cfg = AgentConfig(name="my-agent") + assert cfg.name == "my-agent" + assert cfg.description == "" + assert cfg.model is None + assert cfg.tool_groups is None + + def test_full_config(self): + from src.config.agents_config import AgentConfig + + cfg = AgentConfig( + name="code-reviewer", + description="Specialized for code review", + model="deepseek-v3", + tool_groups=["file:read", "bash"], + ) + assert cfg.name == "code-reviewer" + assert cfg.model == "deepseek-v3" + assert cfg.tool_groups == ["file:read", "bash"] + + def test_config_from_dict(self): + from src.config.agents_config import AgentConfig + + data = {"name": "test-agent", "description": "A test", "model": "gpt-4"} + cfg = AgentConfig(**data) + assert cfg.name == "test-agent" + assert cfg.model == "gpt-4" + assert cfg.tool_groups is None + + +# =========================================================================== +# 3. load_agent_config +# =========================================================================== + + +class TestLoadAgentConfig: + def test_load_valid_config(self, tmp_path): + config_dict = {"name": "code-reviewer", "description": "Code review agent", "model": "deepseek-v3"} + _write_agent(tmp_path, "code-reviewer", config_dict) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import load_agent_config + + cfg = load_agent_config("code-reviewer") + + assert cfg.name == "code-reviewer" + assert cfg.description == "Code review agent" + assert cfg.model == "deepseek-v3" + + def test_load_missing_agent_raises(self, tmp_path): + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import load_agent_config + + with pytest.raises(FileNotFoundError): + load_agent_config("nonexistent-agent") + + def test_load_missing_config_yaml_raises(self, tmp_path): + # Create directory without config.yaml + (tmp_path / "agents" / "broken-agent").mkdir(parents=True) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import load_agent_config + + with pytest.raises(FileNotFoundError): + load_agent_config("broken-agent") + + def test_load_config_infers_name_from_dir(self, tmp_path): + """Config without 'name' field should use directory name.""" + agent_dir = tmp_path / "agents" / "inferred-name" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text("description: My agent\n") + (agent_dir / "SOUL.md").write_text("Hello") + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import load_agent_config + + cfg = load_agent_config("inferred-name") + + assert cfg.name == "inferred-name" + + def test_load_config_with_tool_groups(self, tmp_path): + config_dict = {"name": "restricted", "tool_groups": ["file:read", "file:write"]} + _write_agent(tmp_path, "restricted", config_dict) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import load_agent_config + + cfg = load_agent_config("restricted") + + assert cfg.tool_groups == ["file:read", "file:write"] + + def test_legacy_prompt_file_field_ignored(self, tmp_path): + """Unknown fields like the old prompt_file should be silently ignored.""" + agent_dir = tmp_path / "agents" / "legacy-agent" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text("name: legacy-agent\nprompt_file: system.md\n") + (agent_dir / "SOUL.md").write_text("Soul content") + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import load_agent_config + + cfg = load_agent_config("legacy-agent") + + assert cfg.name == "legacy-agent" + + +# =========================================================================== +# 4. load_agent_soul +# =========================================================================== + + +class TestLoadAgentSoul: + def test_reads_soul_file(self, tmp_path): + expected_soul = "You are a specialized code review expert." + _write_agent(tmp_path, "code-reviewer", {"name": "code-reviewer"}, soul=expected_soul) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import AgentConfig, load_agent_soul + + cfg = AgentConfig(name="code-reviewer") + soul = load_agent_soul(cfg.name) + + assert soul == expected_soul + + def test_missing_soul_file_returns_none(self, tmp_path): + agent_dir = tmp_path / "agents" / "no-soul" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text("name: no-soul\n") + # No SOUL.md created + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import AgentConfig, load_agent_soul + + cfg = AgentConfig(name="no-soul") + soul = load_agent_soul(cfg.name) + + assert soul is None + + def test_empty_soul_file_returns_none(self, tmp_path): + agent_dir = tmp_path / "agents" / "empty-soul" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text("name: empty-soul\n") + (agent_dir / "SOUL.md").write_text(" \n ") + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import AgentConfig, load_agent_soul + + cfg = AgentConfig(name="empty-soul") + soul = load_agent_soul(cfg.name) + + assert soul is None + + +# =========================================================================== +# 5. list_custom_agents +# =========================================================================== + + +class TestListCustomAgents: + def test_empty_when_no_agents_dir(self, tmp_path): + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import list_custom_agents + + agents = list_custom_agents() + + assert agents == [] + + def test_discovers_multiple_agents(self, tmp_path): + _write_agent(tmp_path, "agent-a", {"name": "agent-a"}) + _write_agent(tmp_path, "agent-b", {"name": "agent-b", "description": "B"}) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import list_custom_agents + + agents = list_custom_agents() + + names = [a.name for a in agents] + assert "agent-a" in names + assert "agent-b" in names + + def test_skips_dirs_without_config_yaml(self, tmp_path): + # Valid agent + _write_agent(tmp_path, "valid-agent", {"name": "valid-agent"}) + # Invalid dir (no config.yaml) + (tmp_path / "agents" / "invalid-dir").mkdir(parents=True) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import list_custom_agents + + agents = list_custom_agents() + + assert len(agents) == 1 + assert agents[0].name == "valid-agent" + + def test_skips_non_directory_entries(self, tmp_path): + # Create the agents dir with a file (not a dir) + agents_dir = tmp_path / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "not-a-dir.txt").write_text("hello") + _write_agent(tmp_path, "real-agent", {"name": "real-agent"}) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import list_custom_agents + + agents = list_custom_agents() + + assert len(agents) == 1 + assert agents[0].name == "real-agent" + + def test_returns_sorted_by_name(self, tmp_path): + _write_agent(tmp_path, "z-agent", {"name": "z-agent"}) + _write_agent(tmp_path, "a-agent", {"name": "a-agent"}) + _write_agent(tmp_path, "m-agent", {"name": "m-agent"}) + + with patch("src.config.agents_config.get_paths", return_value=_make_paths(tmp_path)): + from src.config.agents_config import list_custom_agents + + agents = list_custom_agents() + + names = [a.name for a in agents] + assert names == sorted(names) + + +# =========================================================================== +# 7. Memory isolation: _get_memory_file_path +# =========================================================================== + + +class TestMemoryFilePath: + def test_global_memory_path(self, tmp_path): + """None agent_name should return global memory file.""" + import src.agents.memory.updater as updater_mod + + with patch("src.agents.memory.updater.get_paths", return_value=_make_paths(tmp_path)): + path = updater_mod._get_memory_file_path(None) + assert path == tmp_path / "memory.json" + + def test_agent_memory_path(self, tmp_path): + """Providing agent_name should return per-agent memory file.""" + import src.agents.memory.updater as updater_mod + + with patch("src.agents.memory.updater.get_paths", return_value=_make_paths(tmp_path)): + path = updater_mod._get_memory_file_path("code-reviewer") + assert path == tmp_path / "agents" / "code-reviewer" / "memory.json" + + def test_different_paths_for_different_agents(self, tmp_path): + import src.agents.memory.updater as updater_mod + + with patch("src.agents.memory.updater.get_paths", return_value=_make_paths(tmp_path)): + path_global = updater_mod._get_memory_file_path(None) + path_a = updater_mod._get_memory_file_path("agent-a") + path_b = updater_mod._get_memory_file_path("agent-b") + + assert path_global != path_a + assert path_global != path_b + assert path_a != path_b + + +# =========================================================================== +# 8. Gateway API – Agents endpoints +# =========================================================================== + + +def _make_test_app(tmp_path: Path): + """Create a FastAPI app with the agents router, patching paths to tmp_path.""" + from fastapi import FastAPI + + from src.gateway.routers.agents import router + + app = FastAPI() + app.include_router(router) + return app + + +@pytest.fixture() +def agent_client(tmp_path): + """TestClient with agents router, using tmp_path as base_dir.""" + paths_instance = _make_paths(tmp_path) + + with patch("src.config.agents_config.get_paths", return_value=paths_instance), patch("src.gateway.routers.agents.get_paths", return_value=paths_instance): + app = _make_test_app(tmp_path) + with TestClient(app) as client: + client._tmp_path = tmp_path # type: ignore[attr-defined] + yield client + + +class TestAgentsAPI: + def test_list_agents_empty(self, agent_client): + response = agent_client.get("/api/agents") + assert response.status_code == 200 + data = response.json() + assert data["agents"] == [] + + def test_create_agent(self, agent_client): + payload = { + "name": "code-reviewer", + "description": "Reviews code", + "soul": "You are a code reviewer.", + } + response = agent_client.post("/api/agents", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "code-reviewer" + assert data["description"] == "Reviews code" + assert data["soul"] == "You are a code reviewer." + + def test_create_agent_invalid_name(self, agent_client): + payload = {"name": "Code Reviewer!", "soul": "test"} + response = agent_client.post("/api/agents", json=payload) + assert response.status_code == 422 + + def test_create_duplicate_agent_409(self, agent_client): + payload = {"name": "my-agent", "soul": "test"} + agent_client.post("/api/agents", json=payload) + + # Second create should fail + response = agent_client.post("/api/agents", json=payload) + assert response.status_code == 409 + + def test_list_agents_after_create(self, agent_client): + agent_client.post("/api/agents", json={"name": "agent-one", "soul": "p1"}) + agent_client.post("/api/agents", json={"name": "agent-two", "soul": "p2"}) + + response = agent_client.get("/api/agents") + assert response.status_code == 200 + names = [a["name"] for a in response.json()["agents"]] + assert "agent-one" in names + assert "agent-two" in names + + def test_get_agent(self, agent_client): + agent_client.post("/api/agents", json={"name": "test-agent", "soul": "Hello world"}) + + response = agent_client.get("/api/agents/test-agent") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "test-agent" + assert data["soul"] == "Hello world" + + def test_get_missing_agent_404(self, agent_client): + response = agent_client.get("/api/agents/nonexistent") + assert response.status_code == 404 + + def test_update_agent_soul(self, agent_client): + agent_client.post("/api/agents", json={"name": "update-me", "soul": "original"}) + + response = agent_client.put("/api/agents/update-me", json={"soul": "updated"}) + assert response.status_code == 200 + assert response.json()["soul"] == "updated" + + def test_update_agent_description(self, agent_client): + agent_client.post("/api/agents", json={"name": "desc-agent", "description": "old desc", "soul": "p"}) + + response = agent_client.put("/api/agents/desc-agent", json={"description": "new desc"}) + assert response.status_code == 200 + assert response.json()["description"] == "new desc" + + def test_update_missing_agent_404(self, agent_client): + response = agent_client.put("/api/agents/ghost-agent", json={"soul": "new"}) + assert response.status_code == 404 + + def test_delete_agent(self, agent_client): + agent_client.post("/api/agents", json={"name": "del-me", "soul": "bye"}) + + response = agent_client.delete("/api/agents/del-me") + assert response.status_code == 204 + + # Verify it's gone + response = agent_client.get("/api/agents/del-me") + assert response.status_code == 404 + + def test_delete_missing_agent_404(self, agent_client): + response = agent_client.delete("/api/agents/does-not-exist") + assert response.status_code == 404 + + def test_create_agent_with_model_and_tool_groups(self, agent_client): + payload = { + "name": "specialized", + "description": "Specialized agent", + "model": "deepseek-v3", + "tool_groups": ["file:read", "bash"], + "soul": "You are specialized.", + } + response = agent_client.post("/api/agents", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["model"] == "deepseek-v3" + assert data["tool_groups"] == ["file:read", "bash"] + + def test_create_persists_files_on_disk(self, agent_client, tmp_path): + agent_client.post("/api/agents", json={"name": "disk-check", "soul": "disk soul"}) + + agent_dir = tmp_path / "agents" / "disk-check" + assert agent_dir.exists() + assert (agent_dir / "config.yaml").exists() + assert (agent_dir / "SOUL.md").exists() + assert (agent_dir / "SOUL.md").read_text() == "disk soul" + + def test_delete_removes_files_from_disk(self, agent_client, tmp_path): + agent_client.post("/api/agents", json={"name": "remove-me", "soul": "bye"}) + agent_dir = tmp_path / "agents" / "remove-me" + assert agent_dir.exists() + + agent_client.delete("/api/agents/remove-me") + assert not agent_dir.exists() + + +# =========================================================================== +# 9. Gateway API – User Profile endpoints +# =========================================================================== + + +class TestUserProfileAPI: + def test_get_user_profile_empty(self, agent_client): + response = agent_client.get("/api/user-profile") + assert response.status_code == 200 + assert response.json()["content"] is None + + def test_put_user_profile(self, agent_client, tmp_path): + content = "# User Profile\n\nI am a developer." + response = agent_client.put("/api/user-profile", json={"content": content}) + assert response.status_code == 200 + assert response.json()["content"] == content + + # File should be written to disk + user_md = tmp_path / "USER.md" + assert user_md.exists() + assert user_md.read_text(encoding="utf-8") == content + + def test_get_user_profile_after_put(self, agent_client): + content = "# Profile\n\nI work on data science." + agent_client.put("/api/user-profile", json={"content": content}) + + response = agent_client.get("/api/user-profile") + assert response.status_code == 200 + assert response.json()["content"] == content + + def test_put_empty_user_profile_returns_none(self, agent_client): + response = agent_client.put("/api/user-profile", json={"content": ""}) + assert response.status_code == 200 + assert response.json()["content"] is None diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 0d7d77a..a1c979b 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -80,7 +80,7 @@ def test_make_lead_agent_disables_thinking_when_model_does_not_support_it(monkey monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: []) - monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name: []) + monkeypatch.setattr(lead_agent_module, "_build_middlewares", lambda config, model_name, agent_name=None: []) captured: dict[str, object] = {} diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 6d7a4ce..9513cac 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -120,6 +120,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # Custom API: Agents endpoint + location /api/agents { + proxy_pass http://gateway; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Custom API: Artifacts endpoint location ~ ^/api/threads/[^/]+/artifacts { proxy_pass http://gateway; diff --git a/docker/nginx/nginx.local.conf b/docker/nginx/nginx.local.conf index 0cdcf9a..bf525c4 100644 --- a/docker/nginx/nginx.local.conf +++ b/docker/nginx/nginx.local.conf @@ -116,6 +116,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # Custom API: Agents endpoint + location /api/agents { + proxy_pass http://gateway; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Custom API: Artifacts endpoint location ~ ^/api/threads/[^/]+/artifacts { proxy_pass http://gateway; diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/layout.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/layout.tsx new file mode 100644 index 0000000..68f51b6 --- /dev/null +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/layout.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; +import { ArtifactsProvider } from "@/components/workspace/artifacts"; +import { SubtasksProvider } from "@/core/tasks/context"; + +export default function AgentChatLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx new file mode 100644 index 0000000..506e053 --- /dev/null +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { BotIcon } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useCallback } from "react"; + +import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { AgentWelcome } from "@/components/workspace/agent-welcome"; +import { ArtifactTrigger } from "@/components/workspace/artifacts"; +import { ChatBox, useThreadChat } from "@/components/workspace/chats"; +import { InputBox } from "@/components/workspace/input-box"; +import { MessageList } from "@/components/workspace/messages"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import { ThreadTitle } from "@/components/workspace/thread-title"; +import { TodoList } from "@/components/workspace/todo-list"; +import { useAgent } from "@/core/agents"; +import { useI18n } from "@/core/i18n/hooks"; +import { useNotification } from "@/core/notification/hooks"; +import { useLocalSettings } from "@/core/settings"; +import { useThreadStream } from "@/core/threads/hooks"; +import { textOfMessage } from "@/core/threads/utils"; +import { env } from "@/env"; +import { cn } from "@/lib/utils"; + +export default function AgentChatPage() { + const { t } = useI18n(); + const [settings, setSettings] = useLocalSettings(); + + const { agent_name, thread_id: threadIdFromPath } = useParams<{ + agent_name: string; + thread_id: string; + }>(); + + const { agent } = useAgent(agent_name); + + const { threadId, isNewThread, setIsNewThread } = useThreadChat(); + + const { showNotification } = useNotification(); + const [thread, sendMessage] = useThreadStream({ + threadId: threadIdFromPath !== "new" ? threadIdFromPath : undefined, + context: { ...settings.context, agent_name: agent_name }, + onStart: () => { + setIsNewThread(false); + history.replaceState( + null, + "", + `/workspace/agents/${agent_name}/chats/${threadId}`, + ); + }, + onFinish: (state) => { + if (document.hidden || !document.hasFocus()) { + let body = "Conversation finished"; + const lastMessage = state.messages[state.messages.length - 1]; + if (lastMessage) { + const textContent = textOfMessage(lastMessage); + if (textContent) { + body = + textContent.length > 200 + ? textContent.substring(0, 200) + "..." + : textContent; + } + } + showNotification(state.title, { body }); + } + }, + }); + + const handleSubmit = useCallback( + (message: PromptInputMessage) => { + void sendMessage(threadId, message, { agent_name }); + }, + [sendMessage, threadId, agent_name], + ); + + const handleStop = useCallback(async () => { + await thread.stop(); + }, [thread]); + + return ( + + +
+
+ {/* Agent badge */} +
+ + + {agent?.name ?? agent_name} + +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+
+
+ + + ) + } + disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} + onContextChange={(context) => setSettings("context", context)} + onSubmit={handleSubmit} + onStop={handleStop} + /> + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( +
+ {t.common.notAvailableInDemoMode} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/workspace/agents/new/page.tsx b/frontend/src/app/workspace/agents/new/page.tsx new file mode 100644 index 0000000..0273e98 --- /dev/null +++ b/frontend/src/app/workspace/agents/new/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; + +import { + PromptInput, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, +} from "@/components/ai-elements/prompt-input"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArtifactsProvider } from "@/components/workspace/artifacts"; +import { MessageList } from "@/components/workspace/messages"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import type { Agent } from "@/core/agents"; +import { checkAgentName, getAgent } from "@/core/agents/api"; +import { useI18n } from "@/core/i18n/hooks"; +import { useThreadStream } from "@/core/threads/hooks"; +import { uuid } from "@/core/utils/uuid"; +import { cn } from "@/lib/utils"; + +type Step = "name" | "chat"; + +const NAME_RE = /^[A-Za-z0-9-]+$/; + +export default function NewAgentPage() { + const { t } = useI18n(); + const router = useRouter(); + + // ── Step 1: name form ────────────────────────────────────────────────────── + const [step, setStep] = useState("name"); + const [nameInput, setNameInput] = useState(""); + const [nameError, setNameError] = useState(""); + const [isCheckingName, setIsCheckingName] = useState(false); + const [agentName, setAgentName] = useState(""); + const [agent, setAgent] = useState(null); + // ── Step 2: chat ─────────────────────────────────────────────────────────── + + // Stable thread ID — all turns belong to the same thread + const threadId = useMemo(() => uuid(), []); + + const [thread, sendMessage] = useThreadStream({ + context: { + mode: "flash", + is_bootstrap: true, + }, + onToolEnd({ name }) { + if (name !== "setup_agent" || !agentName) return; + getAgent(agentName) + .then((fetched) => setAgent(fetched)) + .catch(() => { + // agent write may not be flushed yet — ignore silently + }); + }, + }); + + // ── Handlers ─────────────────────────────────────────────────────────────── + + const handleConfirmName = useCallback(async () => { + const trimmed = nameInput.trim(); + if (!trimmed) return; + if (!NAME_RE.test(trimmed)) { + setNameError(t.agents.nameStepInvalidError); + return; + } + setNameError(""); + setIsCheckingName(true); + try { + const result = await checkAgentName(trimmed); + if (!result.available) { + setNameError(t.agents.nameStepAlreadyExistsError); + return; + } + } catch { + setNameError(t.agents.nameStepCheckError); + return; + } finally { + setIsCheckingName(false); + } + setAgentName(trimmed); + setStep("chat"); + await sendMessage(threadId, { + text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed), + files: [], + }); + }, [ + nameInput, + sendMessage, + threadId, + t.agents.nameStepBootstrapMessage, + t.agents.nameStepInvalidError, + t.agents.nameStepAlreadyExistsError, + t.agents.nameStepCheckError, + ]); + + const handleNameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleConfirmName(); + } + }; + + const handleChatSubmit = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed || thread.isLoading) return; + await sendMessage( + threadId, + { text: trimmed, files: [] }, + { agent_name: agentName }, + ); + }, + [thread.isLoading, sendMessage, threadId, agentName], + ); + + // ── Shared header ────────────────────────────────────────────────────────── + + const header = ( +
+ +

{t.agents.createPageTitle}

+
+ ); + + // ── Step 1: name form ────────────────────────────────────────────────────── + + if (step === "name") { + return ( +
+ {header} +
+
+
+
+ +
+
+

+ {t.agents.nameStepTitle} +

+

+ {t.agents.nameStepHint} +

+
+
+ +
+ { + setNameInput(e.target.value); + setNameError(""); + }} + onKeyDown={handleNameKeyDown} + className={cn(nameError && "border-destructive")} + /> + {nameError && ( +

{nameError}

+ )} + +
+
+
+
+ ); + } + + // ── Step 2: chat ─────────────────────────────────────────────────────────── + + return ( + + +
+ {header} + +
+ {/* ── Message area ── */} +
+ +
+ + {/* ── Bottom action area ── */} +
+
+ {agent ? ( + // ✅ Success card +
+ +

{t.agents.agentCreated}

+
+ + +
+
+ ) : ( + // 📝 Normal input + void handleChatSubmit(text)} + > + + + + + + )} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/workspace/agents/page.tsx b/frontend/src/app/workspace/agents/page.tsx new file mode 100644 index 0000000..46fdbf2 --- /dev/null +++ b/frontend/src/app/workspace/agents/page.tsx @@ -0,0 +1,5 @@ +import { AgentGallery } from "@/components/workspace/agents/agent-gallery"; + +export default function AgentsPage() { + return ; +} diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index d414c31..da20424 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,366 +1,145 @@ "use client"; -import type { Message } from "@langchain/langgraph-sdk"; -import type { UseStream } from "@langchain/langgraph-sdk/react"; -import { FilesIcon, XIcon } from "lucide-react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback } from "react"; -import { ConversationEmptyState } from "@/components/ai-elements/conversation"; -import { usePromptInputController } from "@/components/ai-elements/prompt-input"; -import { Button } from "@/components/ui/button"; +import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { useSidebar } from "@/components/ui/sidebar"; -import { - ArtifactFileDetail, - ArtifactFileList, - useArtifacts, -} from "@/components/workspace/artifacts"; + ChatBox, + useSpecificChatMode, + useThreadChat, +} from "@/components/workspace/chats"; import { InputBox } from "@/components/workspace/input-box"; import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; -import { Tooltip } from "@/components/workspace/tooltip"; import { Welcome } from "@/components/workspace/welcome"; import { useI18n } from "@/core/i18n/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings } from "@/core/settings"; -import { type AgentThreadState } from "@/core/threads"; -import { useSubmitThread, useThreadStream } from "@/core/threads/hooks"; -import { - pathOfThread, - textOfMessage, -} from "@/core/threads/utils"; -import { uuid } from "@/core/utils/uuid"; +import { useThreadStream } from "@/core/threads/hooks"; +import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { cn } from "@/lib/utils"; export default function ChatPage() { const { t } = useI18n(); - const router = useRouter(); const [settings, setSettings] = useLocalSettings(); - const { setOpen: setSidebarOpen } = useSidebar(); - const { - artifacts, - open: artifactsOpen, - setOpen: setArtifactsOpen, - setArtifacts, - select: selectArtifact, - selectedArtifact, - } = useArtifacts(); - const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); - const searchParams = useSearchParams(); - const promptInputController = usePromptInputController(); - const inputInitialValue = useMemo(() => { - if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") { - return undefined; - } - return t.inputBox.createSkillPrompt; - }, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]); - const lastInitialValueRef = useRef(undefined); - const setInputRef = useRef(promptInputController.textInput.setInput); - setInputRef.current = promptInputController.textInput.setInput; - useEffect(() => { - if (inputInitialValue && inputInitialValue !== lastInitialValueRef.current) { - lastInitialValueRef.current = inputInitialValue; - setTimeout(() => { - setInputRef.current(inputInitialValue); - const textarea = document.querySelector("textarea"); - if (textarea) { - textarea.focus(); - textarea.selectionStart = textarea.value.length; - textarea.selectionEnd = textarea.value.length; - } - }, 100); - } - }, [inputInitialValue]); - const isNewThread = useMemo( - () => threadIdFromPath === "new", - [threadIdFromPath], - ); - const [threadId, setThreadId] = useState(null); - useEffect(() => { - if (threadIdFromPath !== "new") { - setThreadId(threadIdFromPath); - } else { - setThreadId(uuid()); - } - }, [threadIdFromPath]); + + const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); + useSpecificChatMode(); const { showNotification } = useNotification(); - const [finalState, setFinalState] = useState(null); - const thread = useThreadStream({ - isNewThread, - threadId, + + const [thread, sendMessage] = useThreadStream({ + threadId: isNewThread ? undefined : threadId, + context: settings.context, + isMock, + onStart: () => { + setIsNewThread(false); + history.replaceState(null, "", `/workspace/chats/${threadId}`); + }, onFinish: (state) => { - setFinalState(state); if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; const lastMessage = state.messages.at(-1); if (lastMessage) { const textContent = textOfMessage(lastMessage); if (textContent) { - if (textContent.length > 200) { - body = textContent.substring(0, 200) + "..."; - } else { - body = textContent; - } + body = + textContent.length > 200 + ? textContent.substring(0, 200) + "..." + : textContent; } } - showNotification(state.title, { - body, - }); + showNotification(state.title, { body }); } }, - }) as unknown as UseStream; - useEffect(() => { - if (thread.isLoading) setFinalState(null); - }, [thread.isLoading]); - - const title = thread.values?.title ?? "Untitled"; - useEffect(() => { - const pageTitle = isNewThread - ? t.pages.newChat - : thread.isThreadLoading - ? "Loading..." - : title === "Untitled" ? t.pages.untitled : title; - document.title = `${pageTitle} - ${t.pages.appName}`; - }, [ - isNewThread, - t.pages.newChat, - t.pages.untitled, - t.pages.appName, - title, - thread.isThreadLoading, - ]); - - const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); - useEffect(() => { - setArtifacts(thread.values.artifacts); - if ( - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && - autoSelectFirstArtifact - ) { - if (thread?.values?.artifacts?.length > 0) { - setAutoSelectFirstArtifact(false); - selectArtifact(thread.values.artifacts[0]!); - } - } - }, [ - autoSelectFirstArtifact, - selectArtifact, - setArtifacts, - thread.values.artifacts, - ]); - - const artifactPanelOpen = useMemo(() => { - if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { - return artifactsOpen && artifacts?.length > 0; - } - return artifactsOpen; - }, [artifactsOpen, artifacts]); - - const [todoListCollapsed, setTodoListCollapsed] = useState(true); - - const handleSubmit = useSubmitThread({ - isNewThread, - threadId, - thread, - threadContext: { - ...settings.context, - thinking_enabled: settings.context.mode !== "flash", - is_plan_mode: - settings.context.mode === "pro" || settings.context.mode === "ultra", - subagent_enabled: settings.context.mode === "ultra", - reasoning_effort: settings.context.reasoning_effort, - }, - afterSubmit() { - router.push(pathOfThread(threadId!)); - }, }); + + const handleSubmit = useCallback( + (message: PromptInputMessage) => { + void sendMessage(threadId, message); + }, + [sendMessage, threadId], + ); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); - if (!threadId) { - return null; - } - return ( - - - -
-
-
- {title !== "Untitled" && ( - - )} -
-
- {artifacts?.length > 0 && !artifactsOpen && ( - - - - )} -
-
-
-
- -
-
-
-
-
-
-
- - } - disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} - onContextChange={(context) => - setSettings("context", context) - } - onSubmit={handleSubmit} - onStop={handleStop} - /> - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( -
- {t.common.notAvailableInDemoMode} -
- )} -
-
-
-
-
- - -
+ +
+
- {selectedArtifact ? ( - + +
+
+ +
+ +
+
+ - ) : ( -
-
- +
+
+
+
+
+
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
+ + } + disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} + onContextChange={(context) => setSettings("context", context)} + onSubmit={handleSubmit} + onStop={handleStop} + /> + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( +
+ {t.common.notAvailableInDemoMode}
)}
- )} -
- - +
+
+
+
); } diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index aa67dfa..57a45be 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -1,12 +1,12 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { Toaster } from "sonner"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; -import { useLocalSettings } from "@/core/settings"; +import { getLocalSettings, useLocalSettings } from "@/core/settings"; const queryClient = new QueryClient(); @@ -14,7 +14,11 @@ export default function WorkspaceLayout({ children, }: Readonly<{ children: React.ReactNode }>) { const [settings, setSettings] = useLocalSettings(); - const [open, setOpen] = useState(() => !settings.layout.sidebar_collapsed); + const [open, setOpen] = useState(false); // SSR default: open (matches server render) + useLayoutEffect(() => { + // Runs synchronously before first paint on the client — no visual flash + setOpen(!getLocalSettings().layout.sidebar_collapsed); + }, []); useEffect(() => { setOpen(!settings.layout.sidebar_collapsed); }, [settings.layout.sidebar_collapsed]); diff --git a/frontend/src/components/landing/sections/case-study-section.tsx b/frontend/src/components/landing/sections/case-study-section.tsx index 9a1fd51..0ae2f66 100644 --- a/frontend/src/components/landing/sections/case-study-section.tsx +++ b/frontend/src/components/landing/sections/case-study-section.tsx @@ -55,7 +55,7 @@ export function CaseStudySection({ className }: { className?: string }) { {caseStudies.map((caseStudy) => ( diff --git a/frontend/src/components/workspace/agent-welcome.tsx b/frontend/src/components/workspace/agent-welcome.tsx new file mode 100644 index 0000000..7d30b9b --- /dev/null +++ b/frontend/src/components/workspace/agent-welcome.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { BotIcon } from "lucide-react"; + +import { type Agent } from "@/core/agents"; +import { cn } from "@/lib/utils"; + +export function AgentWelcome({ + className, + agent, + agentName, +}: { + className?: string; + agent: Agent | null | undefined; + agentName: string; +}) { + const displayName = agent?.name ?? agentName; + const description = agent?.description; + + return ( +
+
+ +
+
{displayName}
+ {description && ( +

{description}

+ )} +
+ ); +} diff --git a/frontend/src/components/workspace/agents/agent-card.tsx b/frontend/src/components/workspace/agents/agent-card.tsx new file mode 100644 index 0000000..6b2a510 --- /dev/null +++ b/frontend/src/components/workspace/agents/agent-card.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { BotIcon, MessageSquareIcon, Trash2Icon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useDeleteAgent } from "@/core/agents"; +import type { Agent } from "@/core/agents"; +import { useI18n } from "@/core/i18n/hooks"; + +interface AgentCardProps { + agent: Agent; +} + +export function AgentCard({ agent }: AgentCardProps) { + const { t } = useI18n(); + const router = useRouter(); + const deleteAgent = useDeleteAgent(); + const [deleteOpen, setDeleteOpen] = useState(false); + + function handleChat() { + router.push(`/workspace/agents/${agent.name}/chats/new`); + } + + async function handleDelete() { + try { + await deleteAgent.mutateAsync(agent.name); + toast.success(t.agents.deleteSuccess); + setDeleteOpen(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + return ( + <> + + +
+
+
+ +
+
+ + {agent.name} + + {agent.model && ( + + {agent.model} + + )} +
+
+
+ {agent.description && ( + + {agent.description} + + )} +
+ + {agent.tool_groups && agent.tool_groups.length > 0 && ( + +
+ {agent.tool_groups.map((group) => ( + + {group} + + ))} +
+
+ )} + + + +
+ +
+
+
+ + {/* Delete Confirm */} + + + + {t.agents.delete} + {t.agents.deleteConfirm} + + + + + + + + + ); +} diff --git a/frontend/src/components/workspace/agents/agent-gallery.tsx b/frontend/src/components/workspace/agents/agent-gallery.tsx new file mode 100644 index 0000000..7398626 --- /dev/null +++ b/frontend/src/components/workspace/agents/agent-gallery.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { BotIcon, PlusIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/ui/button"; +import { useAgents } from "@/core/agents"; +import { useI18n } from "@/core/i18n/hooks"; + +import { AgentCard } from "./agent-card"; + +export function AgentGallery() { + const { t } = useI18n(); + const { agents, isLoading } = useAgents(); + const router = useRouter(); + + const handleNewAgent = () => { + router.push("/workspace/agents/new"); + }; + + return ( +
+ {/* Page header */} +
+
+

{t.agents.title}

+

+ {t.agents.description} +

+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+ {t.common.loading} +
+ ) : agents.length === 0 ? ( +
+
+ +
+
+

{t.agents.emptyTitle}

+

+ {t.agents.emptyDescription} +

+
+ +
+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index ba62a14..c7d2495 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -39,6 +39,7 @@ import { env } from "@/env"; import { cn } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; +import { useThread } from "../messages/context"; import { Tooltip } from "../tooltip"; import { useArtifacts } from "./context"; @@ -79,7 +80,7 @@ export function ArtifactFileDetail({ } return checkCodeFile(filepath); }, [filepath, isWriteFile, isSkillFile]); - const previewable = useMemo(() => { + const isSupportPreview = useMemo(() => { return (language === "html" && !isWriteFile) || language === "markdown"; }, [isWriteFile, language]); const { content } = useArtifactContent({ @@ -92,14 +93,14 @@ export function ArtifactFileDetail({ const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [isInstalling, setIsInstalling] = useState(false); - + const { isMock } = useThread(); useEffect(() => { - if (previewable) { + if (isSupportPreview) { setViewMode("preview"); } else { setViewMode("code"); } - }, [previewable]); + }, [isSupportPreview]); const handleInstallSkill = useCallback(async () => { if (isInstalling) return; @@ -148,16 +149,18 @@ export function ArtifactFileDetail({
- {previewable && ( + {isSupportPreview && ( - setViewMode(value as "code" | "preview") - } + onValueChange={(value) => { + if (value) { + setViewMode(value as "code" | "preview"); + } + }} > @@ -232,7 +235,7 @@ export function ArtifactFileDetail({
- {previewable && + {isSupportPreview && viewMode === "preview" && (language === "markdown" || language === "html") && ( )} @@ -271,6 +274,7 @@ export function ArtifactFilePreview({ content: string; language: string; }) { + const { isMock } = useThread(); if (language === "markdown") { return (
@@ -288,10 +292,9 @@ export function ArtifactFilePreview({ return (