diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8c446e2ce..dae0ca7af 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -12,7 +12,7 @@ body: id: version attributes: label: What version of eigent are you using? - placeholder: E.g., 0.0.90 + placeholder: E.g., 0.0.91 validations: required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d39e22b2c..1aad2305f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -347,7 +347,7 @@ jobs: files: | gh-release-assets/* - # Extract version from tag (e.g., v0.0.85 -> 0.0.90) + # Extract version from tag (e.g., v0.0.85 -> 0.0.91) - name: Extract version if: startsWith(github.ref, 'refs/tags/') id: version diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index cf9a8724f..b8c810dd4 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -20,6 +20,9 @@ from camel.toolkits import ToolkitMessageIntegration from app.agent.agent_model import agent_model +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, +) from app.agent.listen_chat_agent import logger from app.agent.prompt import BROWSER_SYS_PROMPT from app.agent.toolkit.human_toolkit import HumanToolkit @@ -279,6 +282,15 @@ def browser_agent(options: Chat): *search_tools, *skill_toolkit.get_tools(), ] + tool_names = [ + SearchToolkit.toolkit_name(), + HybridBrowserToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + TerminalToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] # Build external browser notice external_browser_notice = "" @@ -300,6 +312,16 @@ def browser_agent(options: Chat): now_str=NOW_STR, external_browser_notice=external_browser_notice, ) + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=Agents.browser_agent, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=system_message, + local_tool_description="local browser, search, or terminal actions", + message_integration=message_integration, + ) agent = agent_model( Agents.browser_agent, @@ -310,15 +332,7 @@ def browser_agent(options: Chat): options, tools, prune_tool_calls_from_memory=True, - tool_names=[ - SearchToolkit.toolkit_name(), - HybridBrowserToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ web_toolkit_for_agent_registration, screenshot_toolkit_for_agent_registration, diff --git a/backend/app/agent/factory/developer.py b/backend/app/agent/factory/developer.py index c52ce3e14..0dd3a52c9 100644 --- a/backend/app/agent/factory/developer.py +++ b/backend/app/agent/factory/developer.py @@ -18,6 +18,9 @@ from camel.toolkits import ToolkitMessageIntegration from app.agent.agent_model import agent_model +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, +) from app.agent.listen_chat_agent import logger from app.agent.prompt import DEVELOPER_SYS_PROMPT from app.agent.toolkit.human_toolkit import HumanToolkit @@ -103,12 +106,32 @@ async def developer_agent(options: Chat): *skill_toolkit.get_tools(), *search_tools, ] + tool_names = [ + HumanToolkit.toolkit_name(), + TerminalToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + WebDeployToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + SearchToolkit.toolkit_name(), + ] + system_message = DEVELOPER_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), working_directory=working_directory, now_str=NOW_STR, ) + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=Agents.developer_agent, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=system_message, + local_tool_description="local `shell_exec` or local file writes", + message_integration=message_integration, + ) return agent_model( Agents.developer_agent, @@ -118,15 +141,7 @@ async def developer_agent(options: Chat): ), options, tools, - tool_names=[ - HumanToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - WebDeployToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/factory/document.py b/backend/app/agent/factory/document.py index edabefc70..6fb8872a3 100644 --- a/backend/app/agent/factory/document.py +++ b/backend/app/agent/factory/document.py @@ -17,6 +17,9 @@ from camel.toolkits import ToolkitMessageIntegration from app.agent.agent_model import agent_model +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, +) from app.agent.listen_chat_agent import logger from app.agent.prompt import DOCUMENT_SYS_PROMPT from app.agent.toolkit.excel_toolkit import ExcelToolkit @@ -126,12 +129,37 @@ async def document_agent(options: Chat): *skill_toolkit.get_tools(), *search_tools, ] + tool_names = [ + FileToolkit.toolkit_name(), + PPTXToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + MarkItDownToolkit.toolkit_name(), + ExcelToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + TerminalToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + GoogleDriveMCPToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + SearchToolkit.toolkit_name(), + ] system_message = DOCUMENT_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), working_directory=working_directory, now_str=NOW_STR, ) + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=Agents.document_agent, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=system_message, + local_tool_description=( + "local document, file, terminal, or search tools" + ), + message_integration=message_integration, + ) return agent_model( Agents.document_agent, @@ -141,19 +169,7 @@ async def document_agent(options: Chat): ), options, tools, - tool_names=[ - FileToolkit.toolkit_name(), - PPTXToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - MarkItDownToolkit.toolkit_name(), - ExcelToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - GoogleDriveMCPToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/factory/mcp.py b/backend/app/agent/factory/mcp.py index f49a8be3e..679e8078f 100644 --- a/backend/app/agent/factory/mcp.py +++ b/backend/app/agent/factory/mcp.py @@ -16,25 +16,38 @@ import uuid from camel.models import ModelFactory +from camel.toolkits import ToolkitMessageIntegration from camel.types import ModelPlatformType +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, +) from app.agent.listen_chat_agent import ListenChatAgent, logger from app.agent.prompt import MCP_SYS_PROMPT +from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.mcp_search_toolkit import McpSearchToolkit from app.agent.tools import get_mcp_tools from app.model.chat import Chat from app.model.model_platform import patch_bedrock_cloud_config from app.service.task import ActionCreateAgentData, Agents, get_task_lock +from app.utils.file_utils import get_working_directory async def mcp_agent(options: Chat): + working_directory = get_working_directory(options) logger.info( f"Creating MCP agent for project: {options.project_id} " f"with {len(options.installed_mcp['mcpServers'])} MCP servers" ) + message_integration = ToolkitMessageIntegration( + message_handler=HumanToolkit( + options.project_id, Agents.mcp_agent + ).send_message_to_user + ) tools = [ *McpSearchToolkit(options.project_id).get_tools(), ] + tool_names = [McpSearchToolkit.toolkit_name()] if len(options.installed_mcp["mcpServers"]) > 0: try: mcp_tools = await get_mcp_tools(options.installed_mcp) @@ -43,7 +56,7 @@ async def mcp_agent(options: Chat): f"for task {options.project_id}" ) if mcp_tools: - tool_names = [ + mcp_tool_names = [ ( tool.get_function_name() if hasattr(tool, "get_function_name") @@ -51,7 +64,8 @@ async def mcp_agent(options: Chat): ) for tool in mcp_tools ] - logger.debug(f"MCP tools: {tool_names}") + logger.debug(f"MCP tools: {mcp_tool_names}") + tool_names.extend(mcp_tool_names) tools = [*tools, *mcp_tools] except Exception as e: logger.debug(repr(e)) @@ -92,6 +106,17 @@ async def mcp_agent(options: Chat): extra_params = dict(extra_params) extra_params.setdefault("api_version", "2024-12-01-preview") + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=Agents.mcp_agent, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=MCP_SYS_PROMPT, + local_tool_description="local MCP or search tools", + message_integration=message_integration, + ) + # Build model_config_dict with prompt caching model_config_dict = {} if options.is_cloud(): @@ -116,7 +141,7 @@ async def mcp_agent(options: Chat): return ListenChatAgent( options.project_id, Agents.mcp_agent, - system_message=MCP_SYS_PROMPT, + system_message=system_message, model=ModelFactory.create( model_platform=options.model_platform, model_type=options.model_type, diff --git a/backend/app/agent/factory/multi_modal.py b/backend/app/agent/factory/multi_modal.py index f01756f57..d7c356ddd 100644 --- a/backend/app/agent/factory/multi_modal.py +++ b/backend/app/agent/factory/multi_modal.py @@ -19,6 +19,9 @@ from camel.types import ModelPlatformType from app.agent.agent_model import agent_model +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, +) from app.agent.listen_chat_agent import logger from app.agent.prompt import MULTI_MODAL_SYS_PROMPT from app.agent.toolkit.audio_analysis_toolkit import AudioAnalysisToolkit @@ -149,12 +152,35 @@ def multi_modal_agent(options: Chat): ) tools.extend(audio_analysis_toolkit.get_tools()) + tool_names = [ + VideoDownloaderToolkit.toolkit_name(), + AudioAnalysisToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + OpenAIImageToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + TerminalToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + SearchToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] system_message = MULTI_MODAL_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), working_directory=working_directory, now_str=NOW_STR, ) + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=Agents.multi_modal_agent, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=system_message, + local_tool_description=( + "local media, terminal, file, or search tools" + ), + message_integration=message_integration, + ) return agent_model( Agents.multi_modal_agent, @@ -164,17 +190,7 @@ def multi_modal_agent(options: Chat): ), options, tools, - tool_names=[ - VideoDownloaderToolkit.toolkit_name(), - AudioAnalysisToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - OpenAIImageToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/factory/remote_sub_agent.py b/backend/app/agent/factory/remote_sub_agent.py new file mode 100644 index 000000000..db493ff95 --- /dev/null +++ b/backend/app/agent/factory/remote_sub_agent.py @@ -0,0 +1,63 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from typing import Any + +from camel.toolkits import ToolkitMessageIntegration + +from app.agent.prompt import build_remote_sub_agent_usage_notice +from app.agent.toolkit.remote_sub_agent_toolkit import RemoteSubAgentToolkit +from app.model.chat import Chat + + +def remote_sub_agent_enabled(options: Chat, working_directory: str) -> bool: + return RemoteSubAgentToolkit.is_enabled( + options.remote_sub_agent_config, working_directory + ) + + +def attach_remote_sub_agent_if_enabled( + *, + options: Chat, + agent_name: str, + working_directory: str, + tools: list[Any], + tool_names: list[str], + system_message: str, + local_tool_description: str, + message_integration: ToolkitMessageIntegration | None = None, +) -> str: + if not remote_sub_agent_enabled(options, working_directory): + return system_message + + toolkit_name = RemoteSubAgentToolkit.toolkit_name() + if toolkit_name not in tool_names: + remote_sub_agent_toolkit = RemoteSubAgentToolkit( + api_task_id=options.project_id, + agent_name=agent_name, + working_directory=working_directory, + remote_sub_agent_config=options.remote_sub_agent_config, + ) + if message_integration is not None: + remote_sub_agent_toolkit = message_integration.register_toolkits( + remote_sub_agent_toolkit + ) + tools.extend(remote_sub_agent_toolkit.get_tools()) + tool_names.append(toolkit_name) + + remote_sub_agent_notice = build_remote_sub_agent_usage_notice( + working_directory=working_directory, + local_tool_description=local_tool_description, + ) + return f"{system_message}\n{remote_sub_agent_notice}" diff --git a/backend/app/agent/factory/social_media.py b/backend/app/agent/factory/social_media.py index 3f1855224..d1cb32349 100644 --- a/backend/app/agent/factory/social_media.py +++ b/backend/app/agent/factory/social_media.py @@ -12,8 +12,12 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= from camel.messages import BaseMessage +from camel.toolkits import ToolkitMessageIntegration from app.agent.agent_model import agent_model +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, +) from app.agent.listen_chat_agent import logger from app.agent.prompt import SOCIAL_MEDIA_SYS_PROMPT from app.agent.toolkit.google_calendar_toolkit import GoogleCalendarToolkit @@ -47,6 +51,11 @@ async def social_media_agent(options: Chat): f"Creating social media agent for project: {options.project_id} " f"in directory: {working_directory}" ) + message_integration = ToolkitMessageIntegration( + message_handler=HumanToolkit( + options.project_id, Agents.social_media_agent + ).send_message_to_user + ) tools = [ *WhatsAppToolkit.get_can_use_tools(options.project_id), *TwitterToolkit.get_can_use_tools(options.project_id), @@ -84,28 +93,42 @@ async def social_media_agent(options: Chat): # *DiscordToolkit(options.project_id).get_tools(), # *GoogleSuiteToolkit(options.project_id).get_tools(), ] + tool_names = [ + WhatsAppToolkit.toolkit_name(), + TwitterToolkit.toolkit_name(), + LinkedInToolkit.toolkit_name(), + RedditToolkit.toolkit_name(), + NotionMCPToolkit.toolkit_name(), + GoogleGmailMCPToolkit.toolkit_name(), + GoogleCalendarToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + TerminalToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + SearchToolkit.toolkit_name(), + ] + system_message = SOCIAL_MEDIA_SYS_PROMPT.format( + working_directory=working_directory, now_str=NOW_STR + ) + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=Agents.social_media_agent, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=system_message, + local_tool_description=( + "local social, calendar, email, terminal, or search tools" + ), + message_integration=message_integration, + ) return agent_model( Agents.social_media_agent, BaseMessage.make_assistant_message( role_name="Social Media Agent", - content=SOCIAL_MEDIA_SYS_PROMPT.format( - working_directory=working_directory, now_str=NOW_STR - ), + content=system_message, ), options, tools, - tool_names=[ - WhatsAppToolkit.toolkit_name(), - TwitterToolkit.toolkit_name(), - LinkedInToolkit.toolkit_name(), - RedditToolkit.toolkit_name(), - NotionMCPToolkit.toolkit_name(), - GoogleGmailMCPToolkit.toolkit_name(), - GoogleCalendarToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - ], + tool_names=tool_names, ) diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 40f8d1bc0..634926d47 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -13,6 +13,102 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= # flake8: noqa +REMOTE_SUB_AGENT_USAGE_NOTICE = """ + +RemoteSubAgent is configured for this task. Use `run_remote_sub_agent` for +remote-suitable work, even when the user does not explicitly say "remote". + +You MUST call `run_remote_sub_agent` first when: +- The user or subtask asks for RemoteSubAgent, remote sub-agent, remote + sandbox, cloud sandbox, or isolated remote execution. +- The work is likely long-running, exploratory, or benefits from an isolated + environment: installing dependencies, running scripts, scraping/analyzing + data, processing logs, benchmark/CI investigation, machine learning or + data-science audits, or bounded repo/evidence analysis that does not require + directly editing the local workspace. + +Do not satisfy remote-suitable work with {local_tool_description}. Local output +from `{working_directory}` is not valid evidence of remote execution. After the +remote result returns, you may use local tools only to inspect local artifacts, +register notes, assemble the final report, or prepare minimal non-sensitive +context that the remote agent needs. + +Remote input boundary: +- The remote agent can only use content included in the instruction or + readable HTTP(S) URLs included in the task context. When the user provides a + readable URL, pass it verbatim to `run_remote_sub_agent` and ask the remote + agent to fetch/read it from the remote environment. +- Do not claim a remote agent inspected a local file unless the relevant + content was included in the instruction or the file was made available + through a readable HTTP(S) URL. +- If required local files are not reachable by URL, use local tools only to + extract the minimal evidence needed for the remote task: relevant snippets, + file paths, metrics, thresholds, commands, calculations, and code references. + Then call `run_remote_sub_agent` for the reasoning-heavy analysis and final + adjudication. Keep any file-only work local and clearly label the limitation; + do not claim the remote sandbox read local files. +- The remote sandbox cannot read locally installed skills. If a loaded skill + contains instructions needed by the remote task, pass a concise relevant + excerpt using `skill_context`. + +Cost control: +- Make at most one full remote execution call per subtask unless the tool/API + explicitly fails. +- If the remote result is complete but the formatting is imperfect, do not + rerun the remote job. Produce a best-effort report and clearly label any + evidence limitation. +- If a follow-up is truly needed, set `reuse_session=True` and ask the same + remote session to clarify or reformat existing outputs instead of recreating + the environment or repeating expensive setup. + +""" + +REMOTE_SUB_AGENT_PLANNING_NOTICE = """ + +RemoteSubAgent is configured for this project. During task decomposition, +explicitly route bounded remote-suitable subtasks to RemoteSubAgent. + +Create a RemoteSubAgent subtask when the work is likely long-running, +sandbox-worthy, or independently executable, such as ML/CI failure audits, +large log or evidence analysis, data processing, scraping, dependency install, +script execution, benchmark investigation, or isolated exploratory research. +The subtask should say to call `run_remote_sub_agent` first, include the +available evidence and readable URL references, state hard constraints such as +"do not rerun GPU training", and request a structured result that the local +worker can validate and assemble. + +When the task depends on files, the plan should include any user-provided +readable HTTP(S) URLs verbatim and instruct the worker to ask RemoteSubAgent to +fetch/read them remotely. If the needed files are local-only and not reachable +by URL, the plan should instruct the worker to prepare minimal local evidence +excerpts or derived facts, then route the reasoning-heavy analysis and final +adjudication to RemoteSubAgent. When the task depends on a loaded skill, the +plan should instruct the worker to pass the relevant skill instructions as +`skill_context`. + +Do not route tiny local reads, simple edits, or tasks that require direct +modification of the current workspace. Do not imply that local files are +available remotely unless the plan includes how their content or references +will be supplied to the remote run. + +""" + + +def build_remote_sub_agent_usage_notice( + *, + working_directory: str, + local_tool_description: str, +) -> str: + return REMOTE_SUB_AGENT_USAGE_NOTICE.format( + working_directory=working_directory, + local_tool_description=local_tool_description, + ) + + +def build_remote_sub_agent_planning_notice() -> str: + return REMOTE_SUB_AGENT_PLANNING_NOTICE + + SOCIAL_MEDIA_SYS_PROMPT = """\ You are a Social Media Management Assistant with comprehensive capabilities across multiple platforms. You MUST use the `send_message_to_user` tool to diff --git a/backend/app/agent/toolkit/remote_sub_agent_toolkit.py b/backend/app/agent/toolkit/remote_sub_agent_toolkit.py new file mode 100644 index 000000000..dd5b89e17 --- /dev/null +++ b/backend/app/agent/toolkit/remote_sub_agent_toolkit.py @@ -0,0 +1,192 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging +from typing import Any + +from camel.toolkits import BaseToolkit, FunctionTool + +from app.agent.toolkit.abstract_toolkit import AbstractToolkit +from app.remote_sub_agent.policy import build_configured_policy +from app.remote_sub_agent.provider_registry import ( + build_remote_sub_agent_provider, + get_configured_provider_name, + is_remote_sub_agent_provider_configured, +) +from app.remote_sub_agent.runtime import RemoteSubAgentRuntime +from app.remote_sub_agent.types import RemoteSubAgentRequest +from app.service.task import Agents + +logger = logging.getLogger(__name__) + + +class RemoteSubAgentToolkit(BaseToolkit, AbstractToolkit): + agent_name: str = Agents.developer_agent + + def __init__( + self, + api_task_id: str, + agent_name: str | None = None, + working_directory: str | None = None, + remote_sub_agent_config: Any | None = None, + timeout: float | None = None, + ) -> None: + super().__init__(timeout=timeout) + self.api_task_id = api_task_id + self.working_directory = working_directory + self.remote_sub_agent_config = remote_sub_agent_config + if agent_name is not None: + self.agent_name = agent_name + + @staticmethod + def is_enabled( + remote_sub_agent_config: Any | None = None, + working_directory: str | None = None, + ) -> bool: + return _is_config_enabled(remote_sub_agent_config, working_directory) + + async def run_remote_sub_agent( + self, + instruction: str, + remote_agent_name: str | None = None, + system_instruction: str | None = None, + reuse_session: bool = True, + skill_context: str | None = None, + ) -> str: + """Delegate a bounded task to the configured remote sub-agent. + + Use this tool whenever the user or task explicitly asks for + RemoteSubAgent, remote sub-agent, remote sandbox, cloud sandbox, or + isolated remote execution. Also use it for bounded work that is likely + long-running or better suited to an isolated environment, such as + dependency installation, script execution, scraping, data/log analysis, + ML or CI failure audits, and remote research. Do not replace those + requests with local terminal execution; local terminal output is not + remote evidence. + + The remote agent only has access to content supplied in the + instruction or through HTTP(S) URLs included in the task context. Pass + user-provided readable URLs verbatim and ask the remote agent to + fetch/read them from the remote environment. Do not claim that it + inspected local workspace files unless the needed content was included + in the instruction or made available through a readable URL. When the + remote work depends on a locally loaded skill, pass the relevant skill + instructions in `skill_context`; the remote sandbox cannot read local + skill files by itself. + To control cost, do not repeat a completed remote job only to improve + formatting. Reuse the existing session for clarifications when needed. + + Args: + instruction: The exact task for the remote sub-agent. + remote_agent_name: Optional provider-specific remote agent id. + system_instruction: Optional behavior constraints for this run. + reuse_session: Continue the previous provider interaction when true. + skill_context: Optional relevant skill instructions to include in + the remote prompt. + + Returns: + The remote sub-agent's final text answer plus minimal run metadata. + """ + if not instruction.strip(): + return "RemoteSubAgent instruction cannot be empty." + + policy = build_configured_policy( + self.remote_sub_agent_config, self.working_directory + ) + if not _is_config_enabled( + self.remote_sub_agent_config, self.working_directory + ): + return ( + "RemoteSubAgent is disabled or incomplete. Enable and " + "configure it in Agents > Sub Agents first." + ) + + provider_name = get_configured_provider_name( + self.remote_sub_agent_config + ) + prompt_parts = [] + if skill_context and skill_context.strip(): + prompt_parts.append( + f"\n{skill_context.strip()}\n" + ) + prompt_parts.append(instruction) + + request = RemoteSubAgentRequest( + api_task_id=self.api_task_id, + prompt="\n\n".join(prompt_parts), + provider=provider_name, + remote_agent_name=remote_agent_name, + system_instruction=system_instruction, + reuse_session=reuse_session, + ) + logger.info( + "RemoteSubAgent tool invoked", + extra={ + "api_task_id": self.api_task_id, + "agent_name": self.agent_name, + "remote_agent_name": remote_agent_name, + "reuse_session": reuse_session, + }, + ) + runtime = RemoteSubAgentRuntime( + provider=build_remote_sub_agent_provider( + self.remote_sub_agent_config + ), + policy=policy, + ) + result = await runtime.run(request) + logger.info( + "RemoteSubAgent tool completed", + extra={ + "api_task_id": self.api_task_id, + "provider": result.session.provider, + "session_id": result.session.session_id, + "interaction_id": result.session.remote_interaction_id, + "events_count": len(result.events), + "has_usage": result.usage is not None, + }, + ) + + metadata = [ + f"provider: {result.session.provider}", + f"session_id: {result.session.session_id}", + ] + if result.session.remote_interaction_id: + metadata.append( + f"interaction_id: {result.session.remote_interaction_id}" + ) + if result.usage: + total_tokens = result.usage.get("total_tokens") + if total_tokens is not None: + metadata.append(f"total_tokens: {total_tokens}") + + return ( + f"{result.final_text}\n\n[RemoteSubAgent: {'; '.join(metadata)}]" + ) + + def get_tools(self) -> list[FunctionTool]: + return [FunctionTool(self.run_remote_sub_agent)] + + +def _is_config_enabled( + remote_sub_agent_config: Any | None = None, + working_directory: str | None = None, +) -> bool: + policy = build_configured_policy( + remote_sub_agent_config, working_directory + ) + if not policy.enabled: + return False + + return is_remote_sub_agent_provider_configured(remote_sub_agent_config) diff --git a/backend/app/agent/toolkit/terminal_toolkit.py b/backend/app/agent/toolkit/terminal_toolkit.py index 3306e7c37..87411cb12 100644 --- a/backend/app/agent/toolkit/terminal_toolkit.py +++ b/backend/app/agent/toolkit/terminal_toolkit.py @@ -41,7 +41,7 @@ # App version - should match electron app version # TODO: Consider getting this from a shared config -APP_VERSION = "0.0.90" +APP_VERSION = "0.0.91" def get_terminal_base_venv_path() -> str: diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index c0880b13a..1c20353a0 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -34,6 +34,7 @@ from app.agent.toolkit.pptx_toolkit import PPTXToolkit from app.agent.toolkit.rag_toolkit import RAGToolkit from app.agent.toolkit.reddit_toolkit import RedditToolkit +from app.agent.toolkit.remote_sub_agent_toolkit import RemoteSubAgentToolkit from app.agent.toolkit.search_toolkit import SearchToolkit from app.agent.toolkit.slack_toolkit import SlackToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit @@ -68,6 +69,7 @@ async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str): "pptx_toolkit": PPTXToolkit, "rag_toolkit": RAGToolkit, "reddit_toolkit": RedditToolkit, + "remote_sub_agent_toolkit": RemoteSubAgentToolkit, "search_toolkit": SearchToolkit, "slack_toolkit": SlackToolkit, "terminal_toolkit": TerminalToolkit, diff --git a/backend/app/component/environment.py b/backend/app/component/environment.py index 285a1fb14..64812a243 100644 --- a/backend/app/component/environment.py +++ b/backend/app/component/environment.py @@ -17,6 +17,7 @@ import logging import os import threading +from collections.abc import Iterable from pathlib import Path from typing import Any, overload @@ -28,13 +29,63 @@ # Thread-local storage for user-specific environment _thread_local = threading.local() -# Default global environment path -default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env") -load_dotenv(dotenv_path=default_env_path) - # Safe base directory for user environment files env_base_dir = os.path.join(os.path.expanduser("~"), ".eigent") +# Default global environment path +default_env_path = os.path.join(env_base_dir, ".env") + + +def _resolve_initial_env_paths() -> tuple[Path, ...]: + backend_dir = Path(__file__).resolve().parents[2] + repo_root = backend_dir.parent + return ( + Path(default_env_path), + backend_dir / ".env", + backend_dir / ".env.development", + repo_root / ".env", + repo_root / ".env.development", + ) + + +def _load_initial_env_files(paths: Iterable[Path]) -> list[Path]: + """ + Load backend env files for both Electron and standalone web development. + + Precedence is: + 1. Real process environment, always highest. + 2. Later files in `paths`. + 3. Earlier files in `paths`. + """ + original_env = dict(os.environ) + loaded_paths: list[Path] = [] + seen: set[str] = set() + + for path in paths: + resolved = path.expanduser().resolve() + resolved_key = str(resolved) + if resolved_key in seen: + continue + seen.add(resolved_key) + if not resolved.exists(): + continue + load_dotenv(dotenv_path=resolved, override=True) + loaded_paths.append(resolved) + + # Keep shell / service-manager env vars authoritative over dotenv files. + for key, value in original_env.items(): + os.environ[key] = value + + if loaded_paths: + logger.info( + "Loaded backend env files: %s", + ", ".join(str(path) for path in loaded_paths), + ) + return loaded_paths + + +_load_initial_env_files(_resolve_initial_env_paths()) + def sanitize_env_path(env_path: str | None) -> str | None: """ diff --git a/backend/app/controller/remote_sub_agent_controller.py b/backend/app/controller/remote_sub_agent_controller.py new file mode 100644 index 000000000..8f914f7fc --- /dev/null +++ b/backend/app/controller/remote_sub_agent_controller.py @@ -0,0 +1,149 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from app.remote_sub_agent.constants import DEFAULT_REMOTE_SUB_AGENT_PROVIDER +from app.remote_sub_agent.provider_registry import ( + ProviderBuildOptions, + validate_remote_sub_agent_provider, +) + +logger = logging.getLogger("remote_sub_agent_controller") + +router = APIRouter() + + +class ValidateRemoteSubAgentRequest(BaseModel): + provider: str = Field( + DEFAULT_REMOTE_SUB_AGENT_PROVIDER, + description="Sub-agent provider", + ) + api_key: str = Field("", description="Provider API key") + base_url: str = Field("", description="Provider API base URL") + agent_name: str = Field("", description="Provider agent id") + timeout_seconds: float = Field( + 45, + ge=1, + le=120, + description="Validation request timeout in seconds", + ) + + +class ValidateRemoteSubAgentResponse(BaseModel): + is_valid: bool + message: str + provider: str + agent_name: str | None = None + interaction_id: str | None = None + environment_id: str | None = None + status: str | None = None + + +@router.post("/remote-sub-agent/validate") +async def validate_remote_sub_agent( + request: ValidateRemoteSubAgentRequest, +) -> ValidateRemoteSubAgentResponse: + """Validate a remote sub-agent provider before saving user config.""" + provider = request.provider.strip() or DEFAULT_REMOTE_SUB_AGENT_PROVIDER + api_key = request.api_key.strip() + base_url = request.base_url.strip() + agent_name = request.agent_name.strip() + + if not api_key or not base_url or not agent_name: + return ValidateRemoteSubAgentResponse( + is_valid=False, + message="API Key, API Host, and Agent ID are required.", + provider=provider, + agent_name=agent_name or None, + ) + + logger.info( + "RemoteSubAgent validation started", + extra={ + "provider": provider, + "base_url": base_url, + "agent_name": agent_name, + }, + ) + + try: + data = await validate_remote_sub_agent_provider( + { + "enabled": True, + "provider": provider, + provider: { + "api_key": api_key, + "base_url": base_url, + "agent_name": agent_name, + }, + }, + options=ProviderBuildOptions( + timeout_seconds=request.timeout_seconds, + ), + ) + except Exception as exc: + logger.warning( + "RemoteSubAgent validation failed", + extra={ + "provider": provider, + "base_url": base_url, + "agent_name": agent_name, + "error": str(exc), + }, + ) + return ValidateRemoteSubAgentResponse( + is_valid=False, + message=str(exc), + provider=provider, + agent_name=agent_name, + ) + + interaction_id = data.get("id") + is_valid = isinstance(interaction_id, str) and bool(interaction_id) + message = ( + "Remote sub agent validation successful." + if is_valid + else "Remote sub agent validation failed: missing interaction id." + ) + logger.info( + "RemoteSubAgent validation completed", + extra={ + "provider": provider, + "base_url": base_url, + "agent_name": agent_name, + "is_valid": is_valid, + "interaction_id": interaction_id, + "status": data.get("status"), + }, + ) + environment_id = data.get("environment_id") + status = data.get("status") + + return ValidateRemoteSubAgentResponse( + is_valid=is_valid, + message=message, + provider=provider, + agent_name=agent_name, + interaction_id=interaction_id + if isinstance(interaction_id, str) + else None, + environment_id=environment_id + if isinstance(environment_id, str) + else None, + status=status if isinstance(status, str) else None, + ) diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 0aaaca00a..c07fd2960 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -26,6 +26,7 @@ NormalizedModelPlatform, NormalizedOptionalModelPlatform, ) +from app.remote_sub_agent.config import RemoteSubAgentConfig logger = logging.getLogger("chat_model") @@ -78,6 +79,7 @@ class Chat(BaseModel): search_config: dict[str, str] | None = None # User identifier for user-specific skill configurations user_id: str | None = None + remote_sub_agent_config: RemoteSubAgentConfig | None = None @field_validator("model_type") @classmethod diff --git a/backend/app/remote_sub_agent/__init__.py b/backend/app/remote_sub_agent/__init__.py new file mode 100644 index 000000000..cc2531977 --- /dev/null +++ b/backend/app/remote_sub_agent/__init__.py @@ -0,0 +1,49 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from app.remote_sub_agent.config import ( + GeminiRemoteSubAgentConfig, + RemoteSubAgentConfig, +) +from app.remote_sub_agent.policy import ( + RemoteSubAgentPolicy, + build_default_policy, +) +from app.remote_sub_agent.runtime import RemoteSubAgentRuntime +from app.remote_sub_agent.session_store import ( + GLOBAL_REMOTE_SUB_AGENT_SESSIONS, + RemoteSubAgentSessionStore, +) +from app.remote_sub_agent.types import ( + RemoteSubAgentEvent, + RemoteSubAgentEventType, + RemoteSubAgentRequest, + RemoteSubAgentRunResult, + RemoteSubAgentSession, +) + +__all__ = [ + "GLOBAL_REMOTE_SUB_AGENT_SESSIONS", + "GeminiRemoteSubAgentConfig", + "RemoteSubAgentEvent", + "RemoteSubAgentEventType", + "RemoteSubAgentConfig", + "RemoteSubAgentPolicy", + "RemoteSubAgentRequest", + "RemoteSubAgentRunResult", + "RemoteSubAgentRuntime", + "RemoteSubAgentSession", + "RemoteSubAgentSessionStore", + "build_default_policy", +] diff --git a/backend/app/remote_sub_agent/config.py b/backend/app/remote_sub_agent/config.py new file mode 100644 index 000000000..ba15e9a7d --- /dev/null +++ b/backend/app/remote_sub_agent/config.py @@ -0,0 +1,31 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pydantic import BaseModel + +from app.remote_sub_agent.constants import DEFAULT_REMOTE_SUB_AGENT_PROVIDER + + +class GeminiRemoteSubAgentConfig(BaseModel): + api_key: str | None = None + base_url: str | None = None + agent_name: str | None = None + max_wall_time_seconds: int | None = None + poll_interval_seconds: float | None = None + + +class RemoteSubAgentConfig(BaseModel): + enabled: bool = False + provider: str = DEFAULT_REMOTE_SUB_AGENT_PROVIDER + gemini_agents: GeminiRemoteSubAgentConfig | None = None diff --git a/backend/app/remote_sub_agent/constants.py b/backend/app/remote_sub_agent/constants.py new file mode 100644 index 000000000..b30239c35 --- /dev/null +++ b/backend/app/remote_sub_agent/constants.py @@ -0,0 +1,15 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +DEFAULT_REMOTE_SUB_AGENT_PROVIDER = "gemini_agents" diff --git a/backend/app/remote_sub_agent/policy.py b/backend/app/remote_sub_agent/policy.py new file mode 100644 index 000000000..3b441db1e --- /dev/null +++ b/backend/app/remote_sub_agent/policy.py @@ -0,0 +1,180 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from dataclasses import dataclass +from fnmatch import fnmatch +from pathlib import Path +from typing import Any + +from app.component.environment import env +from app.remote_sub_agent.constants import DEFAULT_REMOTE_SUB_AGENT_PROVIDER +from app.remote_sub_agent.provider_registry import ( + get_configured_provider_name, + get_provider_max_wall_time_seconds, +) + +_TRUTHY = {"1", "true", "yes", "y", "on"} +_DEFAULT_MAX_SNAPSHOT_BYTES = 50 * 1024 * 1024 + + +def _parse_bool(value: object, default: bool = False) -> bool: + if value is None: + return default + return str(value).strip().lower() in _TRUTHY + + +def _parse_int(value: object, default: int) -> int: + try: + return int(str(value)) + except (TypeError, ValueError): + return default + + +def _parse_providers(raw: str | None) -> tuple[str, ...]: + if not raw: + return (DEFAULT_REMOTE_SUB_AGENT_PROVIDER,) + providers = tuple(item.strip() for item in raw.split(",") if item.strip()) + return providers or (DEFAULT_REMOTE_SUB_AGENT_PROVIDER,) + + +def _config_value(config: Any, key: str, default: Any = None) -> Any: + if config is None: + return default + if isinstance(config, dict): + return config.get(key, default) + return getattr(config, key, default) + + +@dataclass(frozen=True, slots=True) +class RemoteSubAgentPolicy: + enabled: bool = False + allowed_providers: tuple[str, ...] = (DEFAULT_REMOTE_SUB_AGENT_PROVIDER,) + allow_snapshot_download: bool = False + max_wall_time_seconds: int = 600 + max_snapshot_bytes: int = _DEFAULT_MAX_SNAPSHOT_BYTES + working_directory: Path | None = None + deny_path_globs: tuple[str, ...] = ( + ".env", + ".env.*", + "**/.env", + "**/.env.*", + ".ssh/**", + "**/.ssh/**", + "**/*_rsa", + "**/*_dsa", + "**/*_ecdsa", + "**/*_ed25519", + "**/id_rsa", + "**/id_dsa", + "**/id_ecdsa", + "**/id_ed25519", + "**/*token*", + "**/*secret*", + "**/*credential*", + ) + + def ensure_enabled(self) -> None: + if not self.enabled: + raise PermissionError( + "RemoteSubAgent is disabled. Enable and configure it in " + "Agents > Models > Sub Agents." + ) + + def ensure_provider_allowed(self, provider: str) -> None: + if provider not in self.allowed_providers: + allowed = ", ".join(self.allowed_providers) + raise PermissionError( + f"RemoteSubAgent provider '{provider}' is not allowed. " + f"Allowed providers: {allowed}" + ) + + def ensure_file_in_scope(self, path: str | Path) -> Path: + if self.working_directory is None: + raise PermissionError( + "RemoteSubAgent file access requires a working directory." + ) + + resolved = Path(path).expanduser().resolve() + root = self.working_directory.expanduser().resolve() + + try: + relative = resolved.relative_to(root) + except ValueError: + raise PermissionError( + f"RemoteSubAgent file access is outside scope: {resolved}" + ) + + relative_posix = relative.as_posix() + if self._matches_denylist(relative_posix, resolved.name): + raise PermissionError( + f"RemoteSubAgent file access denied by policy: {relative}" + ) + + return resolved + + def _matches_denylist(self, relative_posix: str, name: str) -> bool: + return any( + fnmatch(relative_posix, pattern) or fnmatch(name, pattern) + for pattern in self.deny_path_globs + ) + + +def build_default_policy( + working_directory: str | Path | None = None, +) -> RemoteSubAgentPolicy: + snapshot_mb = _parse_int( + env("EIGENT_REMOTE_SUB_AGENT_MAX_SNAPSHOT_MB"), + _DEFAULT_MAX_SNAPSHOT_BYTES // (1024 * 1024), + ) + return RemoteSubAgentPolicy( + enabled=_parse_bool(env("EIGENT_REMOTE_SUB_AGENT_ENABLED"), False), + allowed_providers=_parse_providers( + env("EIGENT_REMOTE_SUB_AGENT_ALLOWED_PROVIDERS") + ), + allow_snapshot_download=_parse_bool( + env("EIGENT_REMOTE_SUB_AGENT_ALLOW_SNAPSHOT_DOWNLOAD"), + False, + ), + max_wall_time_seconds=_parse_int( + env("EIGENT_REMOTE_SUB_AGENT_MAX_WALL_TIME_SECONDS"), + 600, + ), + max_snapshot_bytes=snapshot_mb * 1024 * 1024, + working_directory=( + Path(working_directory) if working_directory is not None else None + ), + ) + + +def build_configured_policy( + remote_sub_agent_config: Any | None = None, + working_directory: str | Path | None = None, +) -> RemoteSubAgentPolicy: + enabled = _parse_bool(_config_value(remote_sub_agent_config, "enabled")) + provider = get_configured_provider_name(remote_sub_agent_config) + max_wall_time_seconds = get_provider_max_wall_time_seconds( + remote_sub_agent_config, + default=600, + ) + + return RemoteSubAgentPolicy( + enabled=enabled, + allowed_providers=(provider,), + allow_snapshot_download=False, + max_wall_time_seconds=max_wall_time_seconds, + max_snapshot_bytes=_DEFAULT_MAX_SNAPSHOT_BYTES, + working_directory=( + Path(working_directory) if working_directory is not None else None + ), + ) diff --git a/backend/app/remote_sub_agent/provider_registry.py b/backend/app/remote_sub_agent/provider_registry.py new file mode 100644 index 000000000..7a98a16c5 --- /dev/null +++ b/backend/app/remote_sub_agent/provider_registry.py @@ -0,0 +1,187 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from app.remote_sub_agent.constants import DEFAULT_REMOTE_SUB_AGENT_PROVIDER +from app.remote_sub_agent.providers.gemini_agents import GeminiAgentsProvider +from app.remote_sub_agent.types import RemoteSubAgentProvider + + +@dataclass(frozen=True, slots=True) +class ProviderBuildOptions: + timeout_seconds: float | None = None + + +@dataclass(frozen=True, slots=True) +class RemoteSubAgentProviderAdapter: + name: str + build: Callable[[Any, ProviderBuildOptions], RemoteSubAgentProvider] + is_configured: Callable[[Any], bool] + + +def get_configured_provider_name( + remote_sub_agent_config: Any | None, +) -> str: + return ( + _config_str( + remote_sub_agent_config, + "provider", + DEFAULT_REMOTE_SUB_AGENT_PROVIDER, + ) + or DEFAULT_REMOTE_SUB_AGENT_PROVIDER + ) + + +def get_provider_config( + remote_sub_agent_config: Any | None, + provider_name: str | None = None, +) -> Any: + provider = provider_name or get_configured_provider_name( + remote_sub_agent_config + ) + return _config_value(remote_sub_agent_config, provider, {}) + + +def get_provider_max_wall_time_seconds( + remote_sub_agent_config: Any | None, + default: int = 600, +) -> int: + provider_config = get_provider_config(remote_sub_agent_config) + return _config_int(provider_config, "max_wall_time_seconds", default) + + +def is_remote_sub_agent_provider_configured( + remote_sub_agent_config: Any | None, +) -> bool: + provider_name = get_configured_provider_name(remote_sub_agent_config) + adapter = _PROVIDER_ADAPTERS.get(provider_name) + if adapter is None: + return False + return adapter.is_configured(get_provider_config(remote_sub_agent_config)) + + +def build_remote_sub_agent_provider( + remote_sub_agent_config: Any | None, + *, + options: ProviderBuildOptions | None = None, +) -> RemoteSubAgentProvider: + provider_name = get_configured_provider_name(remote_sub_agent_config) + adapter = _PROVIDER_ADAPTERS.get(provider_name) + if adapter is None: + known = ", ".join(sorted(_PROVIDER_ADAPTERS)) + raise ValueError( + f"Unsupported remote sub agent provider: {provider_name}. " + f"Known providers: {known}" + ) + return adapter.build( + get_provider_config(remote_sub_agent_config, provider_name), + options or ProviderBuildOptions(), + ) + + +async def validate_remote_sub_agent_provider( + remote_sub_agent_config: Any | None, + *, + options: ProviderBuildOptions | None = None, +) -> dict[str, Any]: + provider = build_remote_sub_agent_provider( + remote_sub_agent_config, + options=options, + ) + validate_connection = getattr(provider, "validate_connection", None) + if not callable(validate_connection): + raise ValueError( + f"Remote sub agent provider '{provider.name}' does not support " + "connection validation." + ) + + result = validate_connection() + if isinstance(result, Awaitable): + return await result + return result + + +def _build_gemini_provider( + config: Any, + options: ProviderBuildOptions, +) -> GeminiAgentsProvider: + return GeminiAgentsProvider( + api_key=_config_str(config, "api_key"), + base_url=_config_str(config, "base_url") or None, + agent_name=_config_str(config, "agent_name") or None, + poll_interval_seconds=_config_float( + config, + "poll_interval_seconds", + ), + timeout=options.timeout_seconds, + ) + + +def _is_gemini_configured(config: Any) -> bool: + return bool( + _config_str(config, "api_key") + and _config_str(config, "base_url") + and _config_str(config, "agent_name") + ) + + +def _config_value(config: Any, key: str, default: Any = None) -> Any: + if config is None: + return default + if isinstance(config, dict): + return config.get(key, default) + return getattr(config, key, default) + + +def _config_str( + config: Any, + key: str, + default: str | None = "", +) -> str: + value = _config_value(config, key, default) + if value is None: + return "" + return str(value).strip() + + +def _config_float(config: Any, key: str) -> float | None: + value = _config_value(config, key) + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _config_int(config: Any, key: str, default: int) -> int: + value = _config_value(config, key) + if value in (None, ""): + return default + try: + return int(str(value)) + except (TypeError, ValueError): + return default + + +_PROVIDER_ADAPTERS: dict[str, RemoteSubAgentProviderAdapter] = { + GeminiAgentsProvider.name: RemoteSubAgentProviderAdapter( + name=GeminiAgentsProvider.name, + build=_build_gemini_provider, + is_configured=_is_gemini_configured, + ), +} diff --git a/backend/app/remote_sub_agent/providers/__init__.py b/backend/app/remote_sub_agent/providers/__init__.py new file mode 100644 index 000000000..65376aae3 --- /dev/null +++ b/backend/app/remote_sub_agent/providers/__init__.py @@ -0,0 +1,17 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from app.remote_sub_agent.providers.gemini_agents import GeminiAgentsProvider + +__all__ = ["GeminiAgentsProvider"] diff --git a/backend/app/remote_sub_agent/providers/gemini_agents.py b/backend/app/remote_sub_agent/providers/gemini_agents.py new file mode 100644 index 000000000..06b86f22f --- /dev/null +++ b/backend/app/remote_sub_agent/providers/gemini_agents.py @@ -0,0 +1,547 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from app.component.environment import env +from app.remote_sub_agent.types import ( + RemoteSubAgentEvent, + RemoteSubAgentEventType, + RemoteSubAgentRequest, + RemoteSubAgentSession, +) + +logger = logging.getLogger(__name__) + +_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" +_DEFAULT_AGENT = "" +_TERMINAL_SUCCESS_STATUSES = {"completed"} +_TERMINAL_FAILURE_STATUSES = {"failed", "cancelled", "expired", "incomplete"} +_UNSUPPORTED_ACTION_STATUSES = {"requires_action"} + + +class GeminiAgentsProvider: + name = "gemini_agents" + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + agent_name: str | None = None, + timeout: float | None = None, + poll_interval_seconds: float | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.api_key = ( + api_key + if api_key is not None + else env("GEMINI_API_KEY") or env("GOOGLE_API_KEY") or "" + ) + self.base_url = ( + base_url + or env("GEMINI_INTERACTIONS_BASE_URL") + or env("GEMINI_AGENTS_BASE_URL") + or _DEFAULT_BASE_URL + ).rstrip("/") + configured_agent_name = ( + agent_name + if agent_name is not None + else env("GEMINI_INTERACTIONS_AGENT") or env("GEMINI_AGENTS_AGENT") + ) + self.agent_name = configured_agent_name or _DEFAULT_AGENT + self._agent_name_is_configured = bool(configured_agent_name) + self.timeout = timeout or float( + env("GEMINI_INTERACTIONS_TIMEOUT_SECONDS", "300") + ) + self.poll_interval_seconds = ( + poll_interval_seconds + if poll_interval_seconds is not None + else float(env("GEMINI_INTERACTIONS_POLL_INTERVAL_SECONDS", "5")) + ) + self._transport = transport + + async def run( + self, + request: RemoteSubAgentRequest, + session: RemoteSubAgentSession, + ) -> AsyncIterator[RemoteSubAgentEvent]: + body = self._build_request_body(request, session) + if request.stream: + async for event in self._stream_interaction(body): + yield event + return + + data = await self._create_interaction(body) + for event in self._events_from_interaction(data): + yield event + + if self._should_poll_background_interaction(body, data): + async for event in self._poll_interaction(data["id"]): + yield event + + async def validate_connection(self) -> dict[str, Any]: + """Validate credentials and agent routing with a minimal interaction. + + The goal is to fail fast for bad API keys, base URLs, or agent ids + before a real task is started. We only require the API to accept the + interaction and return an id; the validation probe is intentionally not + polled to completion. + """ + body: dict[str, Any] = { + "input": [ + { + "type": "text", + "text": "Connectivity check. Reply exactly OK.", + } + ], + "stream": False, + "background": True, + "environment": {"enabled": True}, + "agent": self.agent_name, + } + try: + return await self._create_interaction(body) + except RuntimeError as exc: + if not self._looks_like_background_unsupported(exc): + raise + fallback_body = dict(body) + fallback_body.pop("background", None) + return await self._create_interaction(fallback_body) + + async def _create_interaction( + self, + body: dict[str, Any], + ) -> dict[str, Any]: + logger.info( + "Gemini Agents interaction request: " + "base_url=%s agent=%s stream=%s background=%s environment=%s " + "has_previous_interaction_id=%s", + self.base_url, + body.get("agent"), + body.get("stream"), + body.get("background"), + body.get("environment"), + bool(body.get("previous_interaction_id")), + ) + async with httpx.AsyncClient( + timeout=self.timeout, + transport=self._transport, + ) as client: + response = await client.post( + f"{self.base_url}/interactions", + headers=self._headers(), + json=body, + ) + self._raise_for_status(response) + data = response.json() + logger.info( + "Gemini Agents interaction response: " + "status_code=%s interaction_id=%s environment_id=%s status=%s " + "outputs_count=%s has_usage=%s", + response.status_code, + data.get("id"), + data.get("environment_id"), + data.get("status"), + len(data.get("outputs") or []), + data.get("usage") is not None, + ) + return data + + async def _get_interaction(self, interaction_id: str) -> dict[str, Any]: + async with httpx.AsyncClient( + timeout=self.timeout, + transport=self._transport, + ) as client: + response = await client.get( + f"{self.base_url}/interactions/{interaction_id}", + headers=self._headers(), + ) + self._raise_for_status(response) + data = response.json() + logger.info( + "Gemini Agents interaction poll response: " + "interaction_id=%s environment_id=%s status=%s outputs_count=%s " + "has_usage=%s", + interaction_id, + data.get("environment_id"), + data.get("status"), + len(data.get("outputs") or []), + data.get("usage") is not None, + ) + return data + + async def _poll_interaction( + self, + interaction_id: str, + ) -> AsyncIterator[RemoteSubAgentEvent]: + while True: + if self.poll_interval_seconds > 0: + await asyncio.sleep(self.poll_interval_seconds) + + data = await self._get_interaction(interaction_id) + for event in self._events_from_interaction(data): + yield event + + status = self._status(data.get("status")) + if self._is_terminal_status(status): + return + + async def _stream_interaction( + self, + body: dict[str, Any], + ) -> AsyncIterator[RemoteSubAgentEvent]: + headers = {**self._headers(), "Accept": "text/event-stream"} + logger.info( + "Gemini Agents stream request: " + "base_url=%s agent=%s background=%s has_previous_interaction_id=%s", + self.base_url, + body.get("agent"), + body.get("background"), + bool(body.get("previous_interaction_id")), + ) + async with httpx.AsyncClient( + timeout=self.timeout, + transport=self._transport, + ) as client: + async with client.stream( + "POST", + f"{self.base_url}/interactions", + headers=headers, + json=body, + ) as response: + self._raise_for_status(response) + async for line in response.aiter_lines(): + async for event in self._events_from_sse_line(line): + yield event + + async def _events_from_sse_line( + self, + line: str, + ) -> AsyncIterator[RemoteSubAgentEvent]: + stripped = line.strip() + if not stripped or not stripped.startswith("data:"): + return + + payload = stripped.removeprefix("data:").strip() + if payload == "[DONE]": + return + + try: + data = json.loads(payload) + except json.JSONDecodeError: + logger.debug("Ignoring non-json Gemini stream payload: %s", line) + return + + for event in self._events_from_stream_payload(data): + yield event + + def _build_request_body( + self, + request: RemoteSubAgentRequest, + session: RemoteSubAgentSession, + ) -> dict[str, Any]: + body: dict[str, Any] = { + "input": [{"type": "text", "text": request.prompt}], + "stream": request.stream, + "environment": self._environment_body(request, session), + } + + agent_name = self.agent_name + if request.remote_agent_name and not self._agent_name_is_configured: + agent_name = request.remote_agent_name + body["agent"] = agent_name + if request.system_instruction: + body["input"] = self._merge_system_instruction_into_input( + request.system_instruction, + request.prompt, + ) + + previous_interaction_id = ( + session.remote_interaction_id if request.reuse_session else None + ) + if previous_interaction_id: + body["previous_interaction_id"] = previous_interaction_id + + body.update(request.extra_body) + return body + + def _environment_body( + self, + request: RemoteSubAgentRequest, + session: RemoteSubAgentSession, + ) -> dict[str, Any]: + if request.reuse_session and session.remote_environment_id: + return {"env_id": session.remote_environment_id} + return {"enabled": True} + + def _merge_system_instruction_into_input( + self, + system_instruction: str, + prompt: str, + ) -> list[dict[str, str]]: + return [ + { + "type": "text", + "text": ( + "\n" + f"{system_instruction.strip()}\n" + "\n\n" + f"{prompt}" + ), + } + ] + + def _headers(self) -> dict[str, str]: + if not self.api_key: + raise RuntimeError( + "Gemini Agents provider requires an API key from the " + "RemoteSubAgent configuration." + ) + return { + "Content-Type": "application/json", + "x-goog-api-key": self.api_key, + } + + def _raise_for_status(self, response: httpx.Response) -> None: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + body = response.text[:2000] + raise RuntimeError( + "Gemini Agents API request failed: " + f"{response.status_code} {body}" + ) from exc + + def _looks_like_background_unsupported( + self, + error: RuntimeError, + ) -> bool: + message = str(error).lower() + return "background" in message and ( + "unsupported" in message + or "not supported" in message + or "unknown" in message + or "invalid" in message + ) + + def _events_from_interaction( + self, + data: dict[str, Any], + ) -> list[RemoteSubAgentEvent]: + interaction_id = data.get("id") + environment_id = data.get("environment_id") + events: list[RemoteSubAgentEvent] = [] + if interaction_id: + events.append( + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.SESSION_UPDATED, + interaction_id=interaction_id, + environment_id=environment_id, + status=data.get("status"), + raw=data, + ) + ) + + status = self._status(data.get("status")) + if status in _TERMINAL_SUCCESS_STATUSES: + events.append( + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.COMPLETED, + content=self._extract_outputs_text(data), + interaction_id=interaction_id, + environment_id=environment_id, + status=data.get("status"), + usage=data.get("usage"), + raw=data, + ) + ) + elif status in _TERMINAL_FAILURE_STATUSES: + events.append( + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.FAILED, + content=self._extract_failure_text(data), + interaction_id=interaction_id, + environment_id=environment_id, + status=data.get("status"), + usage=data.get("usage"), + raw=data, + ) + ) + elif status in _UNSUPPORTED_ACTION_STATUSES: + events.append( + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.FAILED, + content=( + "Gemini interaction requires client-side action, " + "which RemoteSubAgent does not support yet." + ), + interaction_id=interaction_id, + environment_id=environment_id, + status=data.get("status"), + raw=data, + ) + ) + return events + + def _events_from_stream_payload( + self, + data: dict[str, Any], + ) -> list[RemoteSubAgentEvent]: + interaction_id = data.get("id") or data.get("interaction_id") + environment_id = data.get("environment_id") + event_id = data.get("event_id") + status = data.get("status") + event_type = data.get("event_type") + + if event_type in {"step.delta", "content.delta"}: + delta = data.get("delta") + content = self._extract_delta_text(delta) + if content: + return [ + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.OUTPUT_DELTA, + content=content, + provider_event_id=event_id, + interaction_id=interaction_id, + environment_id=environment_id, + status=status, + raw=data, + ) + ] + + if event_type == "thought_summary.delta": + return [ + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.THOUGHT_DELTA, + content=self._extract_delta_text(data.get("delta")), + provider_event_id=event_id, + interaction_id=interaction_id, + environment_id=environment_id, + status=status, + raw=data, + ) + ] + + normalized_status = self._status(status) + if self._is_terminal_status(normalized_status): + return [ + RemoteSubAgentEvent( + event_type=self._terminal_event_type(normalized_status), + content=self._extract_outputs_text(data), + provider_event_id=event_id, + interaction_id=interaction_id, + environment_id=environment_id, + status=status, + usage=data.get("usage"), + raw=data, + ) + ] + + if interaction_id: + return [ + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.SESSION_UPDATED, + provider_event_id=event_id, + interaction_id=interaction_id, + environment_id=environment_id, + status=status, + raw=data, + ) + ] + + return [ + RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.RAW_EVENT, + provider_event_id=event_id, + status=status, + raw=data, + ) + ] + + def _terminal_event_type( + self, + status: str | None, + ) -> RemoteSubAgentEventType: + if status in _TERMINAL_FAILURE_STATUSES: + return RemoteSubAgentEventType.FAILED + return RemoteSubAgentEventType.COMPLETED + + def _should_poll_background_interaction( + self, + _body: dict[str, Any], + data: dict[str, Any], + ) -> bool: + status = self._status(data.get("status")) + return ( + isinstance(data.get("id"), str) + and not self._is_terminal_status(status) + and status not in _UNSUPPORTED_ACTION_STATUSES + ) + + def _is_terminal_status(self, status: str | None) -> bool: + return ( + status in _TERMINAL_SUCCESS_STATUSES + or status in _TERMINAL_FAILURE_STATUSES + ) + + def _status(self, value: Any) -> str | None: + if not isinstance(value, str): + return None + return value.strip().lower() + + def _extract_failure_text(self, data: dict[str, Any]) -> str: + error = data.get("error") + if isinstance(error, dict): + message = error.get("message") + if isinstance(message, str): + return message + return self._extract_outputs_text(data) or str(data.get("status")) + + def _extract_outputs_text(self, data: dict[str, Any]) -> str: + outputs = data.get("outputs") or [] + texts: list[str] = [] + for output in outputs: + text = self._extract_delta_text(output) + if text: + texts.append(text) + return "".join(texts) + + def _extract_delta_text(self, value: Any) -> str: + if isinstance(value, str): + return value + if not isinstance(value, dict): + return "" + + text = value.get("text") + if isinstance(text, str): + return text + + content = value.get("content") + if isinstance(content, dict): + return self._extract_delta_text(content) + if isinstance(content, list): + return "".join(self._extract_delta_text(item) for item in content) + + delta = value.get("delta") + if isinstance(delta, dict): + return self._extract_delta_text(delta) + + return "" diff --git a/backend/app/remote_sub_agent/runtime.py b/backend/app/remote_sub_agent/runtime.py new file mode 100644 index 000000000..9571e1868 --- /dev/null +++ b/backend/app/remote_sub_agent/runtime.py @@ -0,0 +1,136 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import asyncio +import logging +from dataclasses import replace + +from app.remote_sub_agent.policy import ( + RemoteSubAgentPolicy, + build_default_policy, +) +from app.remote_sub_agent.session_store import ( + GLOBAL_REMOTE_SUB_AGENT_SESSIONS, + RemoteSubAgentSessionStore, +) +from app.remote_sub_agent.types import ( + RemoteSubAgentEvent, + RemoteSubAgentEventType, + RemoteSubAgentProvider, + RemoteSubAgentRequest, + RemoteSubAgentRunResult, +) + +logger = logging.getLogger(__name__) + + +class RemoteSubAgentRuntime: + def __init__( + self, + provider: RemoteSubAgentProvider, + policy: RemoteSubAgentPolicy | None = None, + session_store: RemoteSubAgentSessionStore | None = None, + ) -> None: + self.provider = provider + self.policy = policy or build_default_policy() + self.session_store = session_store or GLOBAL_REMOTE_SUB_AGENT_SESSIONS + + async def run( + self, + request: RemoteSubAgentRequest, + session_key: str | None = None, + ) -> RemoteSubAgentRunResult: + self.policy.ensure_enabled() + self.policy.ensure_provider_allowed(self.provider.name) + + session = self.session_store.get_or_create( + api_task_id=request.api_task_id, + provider=self.provider.name, + remote_agent_name=request.remote_agent_name, + session_key=session_key, + ) + if not request.reuse_session and ( + session.remote_interaction_id or session.remote_environment_id + ): + logger.info( + "RemoteSubAgent existing session found; reusing it", + extra={ + "api_task_id": request.api_task_id, + "provider": self.provider.name, + "session_id": session.session_id, + "interaction_id": session.remote_interaction_id, + "environment_id": session.remote_environment_id, + }, + ) + request = replace(request, reuse_session=True) + + output_chunks: list[str] = [] + events: list[RemoteSubAgentEvent] = [] + usage: dict | None = None + + try: + async with asyncio.timeout(self.policy.max_wall_time_seconds): + async for event in self.provider.run(request, session): + events.append(event) + if event.interaction_id: + session.remote_interaction_id = event.interaction_id + if event.environment_id: + session.remote_environment_id = event.environment_id + if event.usage: + usage = event.usage + if event.event_type is RemoteSubAgentEventType.FAILED: + detail = event.content or event.status or "unknown" + raise RuntimeError( + f"RemoteSubAgent provider failed: {detail}" + ) + if ( + event.event_type + is RemoteSubAgentEventType.OUTPUT_DELTA + and event.content + ): + output_chunks.append(event.content) + except TimeoutError: + logger.warning( + "RemoteSubAgent run timed out", + extra={ + "api_task_id": request.api_task_id, + "provider": self.provider.name, + }, + ) + raise + finally: + self.session_store.update(session) + + final_text = "".join(output_chunks).strip() + if not final_text: + final_text = self._last_completed_content(events) + + return RemoteSubAgentRunResult( + final_text=final_text, + session=session, + events=events, + usage=usage, + ) + + def _last_completed_content( + self, + events: list[RemoteSubAgentEvent], + ) -> str: + for event in reversed(events): + if ( + event.event_type is RemoteSubAgentEventType.COMPLETED + and event.content + ): + return event.content.strip() + return "" diff --git a/backend/app/remote_sub_agent/session_store.py b/backend/app/remote_sub_agent/session_store.py new file mode 100644 index 000000000..cbf7d7c8f --- /dev/null +++ b/backend/app/remote_sub_agent/session_store.py @@ -0,0 +1,76 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import threading +import uuid + +from app.remote_sub_agent.types import RemoteSubAgentSession + + +class RemoteSubAgentSessionStore: + def __init__(self) -> None: + self._lock = threading.RLock() + self._sessions: dict[str, RemoteSubAgentSession] = {} + + def get_or_create( + self, + *, + api_task_id: str, + provider: str, + remote_agent_name: str | None, + session_key: str | None = None, + ) -> RemoteSubAgentSession: + key = session_key or self._default_key( + api_task_id=api_task_id, + provider=provider, + remote_agent_name=remote_agent_name, + ) + with self._lock: + existing = self._sessions.get(key) + if existing is not None: + return existing + + session = RemoteSubAgentSession( + session_id=f"rsa_{uuid.uuid4().hex}", + provider=provider, + api_task_id=api_task_id, + remote_agent_name=remote_agent_name, + metadata={"session_key": key}, + ) + self._sessions[key] = session + return session + + def update(self, session: RemoteSubAgentSession) -> None: + key = session.metadata.get("session_key") + if not isinstance(key, str) or not key: + return + with self._lock: + self._sessions[key] = session + + def clear(self) -> None: + with self._lock: + self._sessions.clear() + + def _default_key( + self, + *, + api_task_id: str, + provider: str, + remote_agent_name: str | None, + ) -> str: + agent_part = remote_agent_name or "default" + return f"{api_task_id}:{provider}:{agent_part}" + + +GLOBAL_REMOTE_SUB_AGENT_SESSIONS = RemoteSubAgentSessionStore() diff --git a/backend/app/remote_sub_agent/types.py b/backend/app/remote_sub_agent/types.py new file mode 100644 index 000000000..b638ee639 --- /dev/null +++ b/backend/app/remote_sub_agent/types.py @@ -0,0 +1,84 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Protocol + +from app.remote_sub_agent.constants import DEFAULT_REMOTE_SUB_AGENT_PROVIDER + + +class RemoteSubAgentEventType(StrEnum): + SESSION_UPDATED = "session_updated" + OUTPUT_DELTA = "output_delta" + THOUGHT_DELTA = "thought_delta" + TOOL_EVENT = "tool_event" + COMPLETED = "completed" + FAILED = "failed" + RAW_EVENT = "raw_event" + + +@dataclass(slots=True) +class RemoteSubAgentSession: + session_id: str + provider: str + api_task_id: str + remote_agent_name: str | None = None + remote_interaction_id: str | None = None + remote_environment_id: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class RemoteSubAgentRequest: + api_task_id: str + prompt: str + provider: str = DEFAULT_REMOTE_SUB_AGENT_PROVIDER + remote_agent_name: str | None = None + system_instruction: str | None = None + reuse_session: bool = True + stream: bool = False + extra_body: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class RemoteSubAgentEvent: + event_type: RemoteSubAgentEventType + content: str | None = None + provider_event_id: str | None = None + interaction_id: str | None = None + environment_id: str | None = None + status: str | None = None + usage: dict[str, Any] | None = None + raw: dict[str, Any] | None = None + + +@dataclass(slots=True) +class RemoteSubAgentRunResult: + final_text: str + session: RemoteSubAgentSession + events: list[RemoteSubAgentEvent] + usage: dict[str, Any] | None = None + + +class RemoteSubAgentProvider(Protocol): + name: str + + async def run( + self, + request: RemoteSubAgentRequest, + session: RemoteSubAgentSession, + ) -> AsyncIterator[RemoteSubAgentEvent]: ... diff --git a/backend/app/router.py b/backend/app/router.py index 2909eaae0..638e85398 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -25,6 +25,7 @@ chat_controller, health_controller, model_controller, + remote_sub_agent_controller, task_controller, tool_controller, ) @@ -61,6 +62,11 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["model"], "description": "Model validation and configuration", }, + { + "router": remote_sub_agent_controller.router, + "tags": ["remote-sub-agent"], + "description": "Remote sub-agent validation", + }, { "router": task_controller.router, "tags": ["task"], diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index b0ea4e606..aa8d9218b 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -37,7 +37,12 @@ question_confirm_agent, task_summary_agent, ) +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, + remote_sub_agent_enabled, +) from app.agent.listen_chat_agent import ListenChatAgent +from app.agent.prompt import build_remote_sub_agent_planning_notice from app.agent.toolkit.human_toolkit import HumanToolkit from app.agent.toolkit.note_taking_toolkit import NoteTakingToolkit from app.agent.toolkit.skill_toolkit import SkillToolkit @@ -2128,6 +2133,11 @@ async def construct_workforce( set_main_event_loop(asyncio.get_running_loop()) working_directory = get_working_directory(options) + remote_sub_agent_planning_notice = ( + build_remote_sub_agent_planning_notice() + if remote_sub_agent_enabled(options, working_directory) + else "" + ) # ======================================================================== # Define agent creation functions @@ -2155,6 +2165,7 @@ def _create_coordinator_and_task_agents() -> list[ListenChatAgent]: The current date is {datetime.date.today()}. \ For any date-related tasks, you MUST use this as \ the current date. +{remote_sub_agent_planning_notice} """, Agents.task_agent: f""" You are a helpful task planner. @@ -2168,15 +2179,43 @@ def _create_coordinator_and_task_agents() -> list[ListenChatAgent]: The current date is {datetime.date.today()}. \ For any date-related tasks, you MUST use this as \ the current date. +{remote_sub_agent_planning_notice} """, }.items() ] def _create_new_worker_agent() -> ListenChatAgent: """Create new worker agent (sync, runs in thread pool).""" - return agent_model( + message_integration = ToolkitMessageIntegration( + message_handler=HumanToolkit( + options.project_id, Agents.new_worker_agent + ).send_message_to_user + ) + note_toolkit = NoteTakingToolkit( + options.project_id, + agent_name=Agents.new_worker_agent, + working_directory=working_directory, + ) + note_toolkit = message_integration.register_toolkits(note_toolkit) + skill_toolkit = SkillToolkit( + options.project_id, Agents.new_worker_agent, - f""" + working_directory=working_directory, + user_id=options.skill_config_user_id(), + ) + tools = [ + *HumanToolkit.get_can_use_tools( + options.project_id, Agents.new_worker_agent + ), + *note_toolkit.get_tools(), + *skill_toolkit.get_tools(), + ] + tool_names = [ + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] + system_message = f""" You are a helpful assistant. - You are now working in system {platform.system()} with architecture {platform.machine()} at working directory \ @@ -2188,31 +2227,23 @@ def _create_new_worker_agent() -> ListenChatAgent: The current date is {datetime.date.today()}. \ For any date-related tasks, you MUST use this as \ the current date. - """, + """ + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=Agents.new_worker_agent, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=system_message, + local_tool_description="local note, terminal, or skill tools", + message_integration=message_integration, + ) + return agent_model( + Agents.new_worker_agent, + system_message, options, - [ - *HumanToolkit.get_can_use_tools( - options.project_id, Agents.new_worker_agent - ), - *( - ToolkitMessageIntegration( - message_handler=HumanToolkit( - options.project_id, Agents.new_worker_agent - ).send_message_to_user - ).register_toolkits( - NoteTakingToolkit( - options.project_id, - working_directory=working_directory, - ) - ) - ).get_tools(), - *SkillToolkit( - options.project_id, - Agents.new_worker_agent, - working_directory=working_directory, - user_id=options.skill_config_user_id(), - ).get_tools(), - ], + tools, + tool_names=tool_names, ) # ======================================================================== @@ -2361,8 +2392,13 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): ) working_directory = get_working_directory(options) tool_names = [] - tools = [*await get_toolkits(data.tools, data.name, options.project_id)] - for item in data.tools: + requested_tools = [ + item for item in data.tools if item != "remote_sub_agent_toolkit" + ] + tools = [ + *await get_toolkits(requested_tools, data.name, options.project_id) + ] + for item in requested_tools: tool_names.append(titleize(item)) # Always include terminal_toolkit with proper working directory terminal_toolkit = TerminalToolkit( @@ -2396,6 +2432,21 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): For any date-related tasks, you MUST use this as \ the current date. """ + message_integration = ToolkitMessageIntegration( + message_handler=HumanToolkit( + options.project_id, data.name + ).send_message_to_user + ) + enhanced_description = attach_remote_sub_agent_if_enabled( + options=options, + agent_name=data.name, + working_directory=working_directory, + tools=tools, + tool_names=tool_names, + system_message=enhanced_description, + local_tool_description="local custom-agent tools or terminal actions", + message_integration=message_integration, + ) # Pass per-agent custom model config if available custom_model_config = getattr(data, "custom_model_config", None) diff --git a/backend/tests/app/component/test_environment.py b/backend/tests/app/component/test_environment.py index bb7202e11..7d4bea78f 100644 --- a/backend/tests/app/component/test_environment.py +++ b/backend/tests/app/component/test_environment.py @@ -18,7 +18,11 @@ import pytest -from app.component.environment import env_base_dir, sanitize_env_path +from app.component.environment import ( + _load_initial_env_files, + env_base_dir, + sanitize_env_path, +) def test_none_input_returns_none(): @@ -185,3 +189,40 @@ def test_current_directory_traversal(): result = sanitize_env_path("././project.env") assert result is not None assert result.startswith(env_base_dir) + + +def test_initial_env_files_precedence(monkeypatch, temp_dir: Path): + """Standalone backend env loading should not depend on Electron.""" + low_priority = temp_dir / "global.env" + high_priority = temp_dir / ".env.development" + low_priority.write_text( + "\n".join( + [ + "SHARED_KEY=from_global", + "GLOBAL_ONLY=from_global", + "PROCESS_KEY=from_global", + ] + ) + ) + high_priority.write_text( + "\n".join( + [ + "SHARED_KEY=from_development", + "DEVELOPMENT_ONLY=from_development", + "PROCESS_KEY=from_development", + ] + ) + ) + + monkeypatch.delenv("SHARED_KEY", raising=False) + monkeypatch.delenv("GLOBAL_ONLY", raising=False) + monkeypatch.delenv("DEVELOPMENT_ONLY", raising=False) + monkeypatch.setenv("PROCESS_KEY", "from_process") + + loaded_paths = _load_initial_env_files((low_priority, high_priority)) + + assert loaded_paths == [low_priority.resolve(), high_priority.resolve()] + assert os.environ["SHARED_KEY"] == "from_development" + assert os.environ["GLOBAL_ONLY"] == "from_global" + assert os.environ["DEVELOPMENT_ONLY"] == "from_development" + assert os.environ["PROCESS_KEY"] == "from_process" diff --git a/backend/tests/app/controller/test_remote_sub_agent_controller.py b/backend/tests/app/controller/test_remote_sub_agent_controller.py new file mode 100644 index 000000000..21bca142a --- /dev/null +++ b/backend/tests/app/controller/test_remote_sub_agent_controller.py @@ -0,0 +1,106 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import pytest + +from app.controller.remote_sub_agent_controller import ( + ValidateRemoteSubAgentRequest, + validate_remote_sub_agent, +) + +pytestmark = pytest.mark.unit + + +@pytest.mark.asyncio +async def test_validate_remote_sub_agent_rejects_missing_fields(): + response = await validate_remote_sub_agent( + ValidateRemoteSubAgentRequest( + provider="gemini_agents", + api_key="", + base_url="https://example.test/v1beta", + agent_name="antigravity-preview-05-2026", + ) + ) + + assert response.is_valid is False + assert "required" in response.message + + +@pytest.mark.asyncio +async def test_validate_remote_sub_agent_success(monkeypatch): + seen: dict[str, object] = {} + + async def fake_validate(config, *, options=None): + seen["config"] = config + seen["timeout"] = options.timeout_seconds if options else None + return { + "id": "interaction-1", + "environment_id": "env-1", + "status": "in_progress", + } + + monkeypatch.setattr( + "app.controller.remote_sub_agent_controller." + "validate_remote_sub_agent_provider", + fake_validate, + ) + + response = await validate_remote_sub_agent( + ValidateRemoteSubAgentRequest( + provider="gemini_agents", + api_key=" test-key ", + base_url=" https://example.test/v1beta ", + agent_name=" antigravity-preview-05-2026 ", + timeout_seconds=12, + ) + ) + + assert response.is_valid is True + assert response.interaction_id == "interaction-1" + assert response.environment_id == "env-1" + assert response.status == "in_progress" + assert seen["config"] == { + "enabled": True, + "provider": "gemini_agents", + "gemini_agents": { + "api_key": "test-key", + "base_url": "https://example.test/v1beta", + "agent_name": "antigravity-preview-05-2026", + }, + } + assert seen["timeout"] == 12 + + +@pytest.mark.asyncio +async def test_validate_remote_sub_agent_failure(monkeypatch): + async def fake_validate(*_: object, **__: object): + raise RuntimeError("Gemini Agents API request failed: 401 invalid") + + monkeypatch.setattr( + "app.controller.remote_sub_agent_controller." + "validate_remote_sub_agent_provider", + fake_validate, + ) + + response = await validate_remote_sub_agent( + ValidateRemoteSubAgentRequest( + provider="gemini_agents", + api_key="bad-key", + base_url="https://example.test/v1beta", + agent_name="antigravity-preview-05-2026", + ) + ) + + assert response.is_valid is False + assert "401" in response.message diff --git a/backend/tests/app/remote_sub_agent/test_gemini_agents_provider.py b/backend/tests/app/remote_sub_agent/test_gemini_agents_provider.py new file mode 100644 index 000000000..1bdfa4501 --- /dev/null +++ b/backend/tests/app/remote_sub_agent/test_gemini_agents_provider.py @@ -0,0 +1,418 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import json + +import httpx +import pytest + +from app.remote_sub_agent.providers.gemini_agents import GeminiAgentsProvider +from app.remote_sub_agent.types import ( + RemoteSubAgentEventType, + RemoteSubAgentRequest, + RemoteSubAgentSession, +) + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def clean_gemini_env(monkeypatch): + monkeypatch.delenv("GEMINI_INTERACTIONS_MODEL", raising=False) + monkeypatch.delenv("GEMINI_INTERACTIONS_AGENT", raising=False) + monkeypatch.delenv("GEMINI_AGENTS_AGENT", raising=False) + monkeypatch.delenv( + "GEMINI_INTERACTIONS_POLL_INTERVAL_SECONDS", raising=False + ) + + +@pytest.mark.asyncio +async def test_provider_posts_agent_interaction(): + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["headers"] = request.headers + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "interaction-1", + "agent": "antigravity-preview-05-2026", + "environment_id": "env-1", + "status": "completed", + "outputs": [{"type": "text", "text": "done"}], + "usage": {"total_tokens": 12}, + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + agent_name="antigravity-preview-05-2026", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + session = RemoteSubAgentSession( + session_id="local-session", + provider=provider.name, + api_task_id="task-1", + ) + request = RemoteSubAgentRequest( + api_task_id="task-1", + prompt="research this", + stream=False, + ) + + events = [event async for event in provider.run(request, session)] + + body = seen["body"] + assert isinstance(body, dict) + assert body["agent"] == "antigravity-preview-05-2026" + assert body["input"] == [{"type": "text", "text": "research this"}] + assert body["stream"] is False + assert body["environment"] == {"enabled": True} + assert "background" not in body + assert "previous_interaction_id" not in body + assert seen["headers"]["x-goog-api-key"] == "test-key" + assert events[0].event_type is RemoteSubAgentEventType.SESSION_UPDATED + assert events[0].environment_id == "env-1" + assert events[-1].event_type is RemoteSubAgentEventType.COMPLETED + assert events[-1].content == "done" + assert events[-1].usage == {"total_tokens": 12} + + +@pytest.mark.asyncio +async def test_provider_validation_posts_background_probe(): + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["headers"] = request.headers + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "interaction-validation", + "agent": "antigravity-preview-05-2026", + "environment_id": "env-validation", + "status": "in_progress", + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + agent_name="antigravity-preview-05-2026", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + + data = await provider.validate_connection() + + body = seen["body"] + assert isinstance(body, dict) + assert body["agent"] == "antigravity-preview-05-2026" + assert body["stream"] is False + assert body["background"] is True + assert body["environment"] == {"enabled": True} + assert "Connectivity check" in body["input"][0]["text"] + assert seen["headers"]["x-goog-api-key"] == "test-key" + assert data["id"] == "interaction-validation" + + +@pytest.mark.asyncio +async def test_provider_validation_falls_back_without_background(): + bodies: list[dict[str, object]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + bodies.append(body) + if body.get("background") is True: + return httpx.Response( + 400, + json={ + "error": { + "message": "background is not supported", + } + }, + ) + return httpx.Response( + 200, + json={ + "id": "interaction-validation", + "agent": "antigravity-preview-05-2026", + "status": "completed", + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + agent_name="antigravity-preview-05-2026", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + + data = await provider.validate_connection() + + assert len(bodies) == 2 + assert bodies[0]["background"] is True + assert "background" not in bodies[1] + assert data["id"] == "interaction-validation" + + +@pytest.mark.asyncio +async def test_provider_uses_configured_agent_over_display_name(): + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "interaction-1", + "agent": "antigravity-preview-05-2026", + "status": "completed", + "outputs": [{"type": "text", "text": "done"}], + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + agent_name="antigravity-preview-05-2026", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + session = RemoteSubAgentSession( + session_id="local-session", + provider=provider.name, + api_task_id="task-1", + ) + request = RemoteSubAgentRequest( + api_task_id="task-1", + prompt="research this", + remote_agent_name="Senior Research Analyst", + ) + + events = [event async for event in provider.run(request, session)] + + body = seen["body"] + assert isinstance(body, dict) + assert body["agent"] == "antigravity-preview-05-2026" + assert events[-1].content == "done" + + +@pytest.mark.asyncio +async def test_provider_concatenates_chunked_outputs_without_extra_newlines(): + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "id": "interaction-1", + "agent": "antigravity-preview-05-2026", + "status": "completed", + "outputs": [ + {"type": "text", "text": "sha256sum orders"}, + {"type": "text", "text": ".csv\n"}, + {"type": "text", "text": "order_id,region"}, + {"type": "text", "text": ",category\n"}, + ], + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + agent_name="antigravity-preview-05-2026", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + session = RemoteSubAgentSession( + session_id="local-session", + provider=provider.name, + api_task_id="task-1", + ) + request = RemoteSubAgentRequest(api_task_id="task-1", prompt="run") + + events = [event async for event in provider.run(request, session)] + + assert events[-1].content == ( + "sha256sum orders.csv\norder_id,region,category\n" + ) + + +@pytest.mark.asyncio +async def test_provider_merges_system_instruction_for_agent(): + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "interaction-1", + "agent": "antigravity-preview-05-2026", + "status": "completed", + "outputs": [{"type": "text", "text": "done"}], + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + agent_name="antigravity-preview-05-2026", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + session = RemoteSubAgentSession( + session_id="local-session", + provider=provider.name, + api_task_id="task-1", + ) + request = RemoteSubAgentRequest( + api_task_id="task-1", + prompt="research this", + system_instruction="Use official docs.", + ) + + events = [event async for event in provider.run(request, session)] + + body = seen["body"] + assert isinstance(body, dict) + assert "system_instruction" not in body + assert body["input"][0]["text"].startswith("") + assert "Use official docs." in body["input"][0]["text"] + assert "research this" in body["input"][0]["text"] + assert events[-1].content == "done" + + +@pytest.mark.asyncio +async def test_provider_polls_background_agent_until_completed(): + requests: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path)) + if request.method == "POST": + return httpx.Response( + 200, + json={ + "id": "interaction-1", + "agent": "antigravity-preview-05-2026", + "status": "in_progress", + }, + ) + return httpx.Response( + 200, + json={ + "id": "interaction-1", + "agent": "antigravity-preview-05-2026", + "status": "completed", + "outputs": [{"type": "text", "text": "done"}], + "usage": {"total_tokens": 42}, + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + agent_name="antigravity-preview-05-2026", + base_url="https://example.test/v1beta", + poll_interval_seconds=0, + transport=httpx.MockTransport(handler), + ) + session = RemoteSubAgentSession( + session_id="local-session", + provider=provider.name, + api_task_id="task-1", + ) + request = RemoteSubAgentRequest(api_task_id="task-1", prompt="research") + request.extra_body["background"] = True + + events = [event async for event in provider.run(request, session)] + + assert requests == [ + ("POST", "/v1beta/interactions"), + ("GET", "/v1beta/interactions/interaction-1"), + ] + assert events[0].event_type is RemoteSubAgentEventType.SESSION_UPDATED + assert events[-1].event_type is RemoteSubAgentEventType.COMPLETED + assert events[-1].content == "done" + assert events[-1].usage == {"total_tokens": 42} + + +@pytest.mark.asyncio +async def test_provider_reuses_previous_interaction(): + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "interaction-2", + "status": "completed", + "outputs": [{"type": "text", "text": "follow-up"}], + }, + ) + + provider = GeminiAgentsProvider( + api_key="test-key", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + session = RemoteSubAgentSession( + session_id="local-session", + provider=provider.name, + api_task_id="task-1", + remote_interaction_id="interaction-1", + remote_environment_id="env-1", + ) + request = RemoteSubAgentRequest( + api_task_id="task-1", + prompt="continue", + reuse_session=True, + ) + + events = [event async for event in provider.run(request, session)] + + body = seen["body"] + assert isinstance(body, dict) + assert body["previous_interaction_id"] == "interaction-1" + assert body["environment"] == {"env_id": "env-1"} + assert events[-1].content == "follow-up" + + +@pytest.mark.asyncio +async def test_provider_raises_helpful_error_on_http_failure(): + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="bad key") + + provider = GeminiAgentsProvider( + api_key="test-key", + base_url="https://example.test/v1beta", + transport=httpx.MockTransport(handler), + ) + session = RemoteSubAgentSession( + session_id="local-session", + provider=provider.name, + api_task_id="task-1", + ) + request = RemoteSubAgentRequest(api_task_id="task-1", prompt="hello") + + with pytest.raises(RuntimeError, match="401 bad key"): + [event async for event in provider.run(request, session)] + + +def test_provider_explicit_empty_api_key_does_not_fall_back_to_env( + monkeypatch, +): + monkeypatch.setenv("GEMINI_API_KEY", "env-key") + + provider = GeminiAgentsProvider(api_key="") + + with pytest.raises(RuntimeError, match="requires an API key"): + provider._headers() diff --git a/backend/tests/app/remote_sub_agent/test_policy.py b/backend/tests/app/remote_sub_agent/test_policy.py new file mode 100644 index 000000000..d5bfe28a1 --- /dev/null +++ b/backend/tests/app/remote_sub_agent/test_policy.py @@ -0,0 +1,107 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pathlib import Path + +import pytest + +from app.remote_sub_agent.policy import ( + RemoteSubAgentPolicy, + build_configured_policy, + build_default_policy, +) + +pytestmark = pytest.mark.unit + + +def test_policy_is_disabled_by_default(monkeypatch): + monkeypatch.delenv("EIGENT_REMOTE_SUB_AGENT_ENABLED", raising=False) + + policy = build_default_policy() + + assert policy.enabled is False + with pytest.raises(PermissionError): + policy.ensure_enabled() + + +def test_policy_allows_enabled_provider(monkeypatch): + monkeypatch.setenv("EIGENT_REMOTE_SUB_AGENT_ENABLED", "true") + monkeypatch.setenv( + "EIGENT_REMOTE_SUB_AGENT_ALLOWED_PROVIDERS", + "gemini_agents,other_provider", + ) + + policy = build_default_policy() + + assert policy.enabled is True + policy.ensure_provider_allowed("gemini_agents") + + +def test_configured_policy_does_not_read_env_enablement(monkeypatch): + monkeypatch.setenv("EIGENT_REMOTE_SUB_AGENT_ENABLED", "true") + + policy = build_configured_policy(None) + + assert policy.enabled is False + + +def test_configured_policy_uses_user_selected_provider(temp_dir: Path): + policy = build_configured_policy( + { + "enabled": True, + "provider": "gemini_agents", + "gemini_agents": {"max_wall_time_seconds": 900}, + }, + temp_dir, + ) + + assert policy.enabled is True + assert policy.allowed_providers == ("gemini_agents",) + assert policy.max_wall_time_seconds == 900 + assert policy.working_directory == temp_dir + + +def test_policy_rejects_disallowed_provider(): + policy = RemoteSubAgentPolicy( + enabled=True, + allowed_providers=("gemini_agents",), + ) + + with pytest.raises(PermissionError): + policy.ensure_provider_allowed("unexpected_provider") + + +def test_policy_rejects_sensitive_files(temp_dir: Path): + secret_file = temp_dir / ".env" + secret_file.write_text("TOKEN=secret") + policy = RemoteSubAgentPolicy( + enabled=True, + working_directory=temp_dir, + ) + + with pytest.raises(PermissionError): + policy.ensure_file_in_scope(secret_file) + + +def test_policy_accepts_workspace_file(temp_dir: Path): + workspace_file = temp_dir / "notes.txt" + workspace_file.write_text("hello") + policy = RemoteSubAgentPolicy( + enabled=True, + working_directory=temp_dir, + ) + + assert ( + policy.ensure_file_in_scope(workspace_file) == workspace_file.resolve() + ) diff --git a/backend/tests/app/remote_sub_agent/test_provider_registry.py b/backend/tests/app/remote_sub_agent/test_provider_registry.py new file mode 100644 index 000000000..7594d8c32 --- /dev/null +++ b/backend/tests/app/remote_sub_agent/test_provider_registry.py @@ -0,0 +1,81 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import pytest + +from app.remote_sub_agent.provider_registry import ( + build_remote_sub_agent_provider, + get_configured_provider_name, + get_provider_max_wall_time_seconds, + is_remote_sub_agent_provider_configured, +) +from app.remote_sub_agent.providers.gemini_agents import GeminiAgentsProvider + +pytestmark = pytest.mark.unit + + +def _config() -> dict: + return { + "enabled": True, + "provider": "gemini_agents", + "gemini_agents": { + "api_key": "test-key", + "base_url": "https://example.test/v1beta", + "agent_name": "antigravity-preview-05-2026", + "max_wall_time_seconds": 900, + "poll_interval_seconds": 7, + }, + } + + +def test_registry_reads_selected_provider(): + assert get_configured_provider_name(_config()) == "gemini_agents" + + +def test_registry_checks_provider_specific_required_fields(): + assert is_remote_sub_agent_provider_configured(_config()) is True + + incomplete = _config() + incomplete["gemini_agents"] = { + **incomplete["gemini_agents"], + "api_key": "", + } + + assert is_remote_sub_agent_provider_configured(incomplete) is False + + +def test_registry_builds_gemini_provider_from_provider_config(): + provider = build_remote_sub_agent_provider(_config()) + + assert isinstance(provider, GeminiAgentsProvider) + assert provider.name == "gemini_agents" + assert provider.api_key == "test-key" + assert provider.base_url == "https://example.test/v1beta" + assert provider.agent_name == "antigravity-preview-05-2026" + assert provider.poll_interval_seconds == 7 + + +def test_registry_rejects_unknown_provider(): + with pytest.raises(ValueError, match="Unsupported remote sub agent"): + build_remote_sub_agent_provider( + { + "enabled": True, + "provider": "other_provider", + "other_provider": {}, + } + ) + + +def test_registry_reads_max_wall_time_from_selected_provider_config(): + assert get_provider_max_wall_time_seconds(_config()) == 900 diff --git a/backend/tests/app/remote_sub_agent/test_runtime.py b/backend/tests/app/remote_sub_agent/test_runtime.py new file mode 100644 index 000000000..b84319db7 --- /dev/null +++ b/backend/tests/app/remote_sub_agent/test_runtime.py @@ -0,0 +1,133 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import pytest + +from app.remote_sub_agent.policy import RemoteSubAgentPolicy +from app.remote_sub_agent.runtime import RemoteSubAgentRuntime +from app.remote_sub_agent.session_store import RemoteSubAgentSessionStore +from app.remote_sub_agent.types import ( + RemoteSubAgentEvent, + RemoteSubAgentEventType, + RemoteSubAgentRequest, + RemoteSubAgentSession, +) + +pytestmark = pytest.mark.unit + + +class FakeProvider: + name = "fake_provider" + + async def run( + self, + request: RemoteSubAgentRequest, + session: RemoteSubAgentSession, + ): + yield RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.SESSION_UPDATED, + interaction_id="remote-1", + environment_id="env-1", + ) + yield RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.OUTPUT_DELTA, + content="hello ", + ) + yield RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.OUTPUT_DELTA, + content="world", + ) + yield RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.COMPLETED, + usage={"total_tokens": 3}, + ) + + +class RecordingProvider: + name = "fake_provider" + + def __init__(self): + self.reuse_values: list[bool] = [] + + async def run( + self, + request: RemoteSubAgentRequest, + session: RemoteSubAgentSession, + ): + self.reuse_values.append(request.reuse_session) + yield RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.SESSION_UPDATED, + interaction_id=f"remote-{len(self.reuse_values)}", + environment_id=f"env-{len(self.reuse_values)}", + ) + yield RemoteSubAgentEvent( + event_type=RemoteSubAgentEventType.COMPLETED, + content="done", + ) + + +@pytest.mark.asyncio +async def test_runtime_collects_output_and_updates_session(): + runtime = RemoteSubAgentRuntime( + provider=FakeProvider(), + policy=RemoteSubAgentPolicy( + enabled=True, + allowed_providers=("fake_provider",), + ), + session_store=RemoteSubAgentSessionStore(), + ) + request = RemoteSubAgentRequest( + api_task_id="task-1", + provider="fake_provider", + prompt="do it", + ) + + result = await runtime.run(request) + + assert result.final_text == "hello world" + assert result.session.remote_interaction_id == "remote-1" + assert result.session.remote_environment_id == "env-1" + assert result.usage == {"total_tokens": 3} + + +@pytest.mark.asyncio +async def test_runtime_reuses_existing_remote_session_on_followup(): + provider = RecordingProvider() + runtime = RemoteSubAgentRuntime( + provider=provider, + policy=RemoteSubAgentPolicy( + enabled=True, + allowed_providers=("fake_provider",), + ), + session_store=RemoteSubAgentSessionStore(), + ) + + await runtime.run( + RemoteSubAgentRequest( + api_task_id="task-1", + provider="fake_provider", + prompt="first", + reuse_session=False, + ) + ) + await runtime.run( + RemoteSubAgentRequest( + api_task_id="task-1", + provider="fake_provider", + prompt="format previous result", + reuse_session=False, + ) + ) + + assert provider.reuse_values == [False, True] diff --git a/backend/tests/app/remote_sub_agent/test_toolkit_config.py b/backend/tests/app/remote_sub_agent/test_toolkit_config.py new file mode 100644 index 000000000..548c4745f --- /dev/null +++ b/backend/tests/app/remote_sub_agent/test_toolkit_config.py @@ -0,0 +1,91 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from types import SimpleNamespace + +import pytest + +from app.agent.factory.remote_sub_agent import ( + attach_remote_sub_agent_if_enabled, +) +from app.agent.toolkit.remote_sub_agent_toolkit import RemoteSubAgentToolkit + +pytestmark = pytest.mark.unit + + +def _valid_config() -> dict: + return { + "enabled": True, + "provider": "gemini_agents", + "gemini_agents": { + "api_key": "test-key", + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "agent_name": "antigravity-preview-05-2026", + "max_wall_time_seconds": 900, + "poll_interval_seconds": 5, + }, + } + + +def test_toolkit_disabled_without_user_config_even_if_env_enabled(monkeypatch): + monkeypatch.setenv("EIGENT_REMOTE_SUB_AGENT_ENABLED", "true") + monkeypatch.setenv("GEMINI_API_KEY", "env-key") + + assert RemoteSubAgentToolkit.is_enabled(None) is False + + +def test_toolkit_enabled_with_complete_user_config(): + assert RemoteSubAgentToolkit.is_enabled(_valid_config()) is True + + +@pytest.mark.asyncio +async def test_toolkit_returns_message_when_config_is_incomplete(): + toolkit = RemoteSubAgentToolkit( + api_task_id="task-1", + remote_sub_agent_config={ + **_valid_config(), + "gemini_agents": { + "api_key": "", + "base_url": "https://generativelanguage.googleapis.com/v1beta", + "agent_name": "antigravity-preview-05-2026", + }, + }, + ) + + result = await toolkit.run_remote_sub_agent("research this") + + assert "disabled or incomplete" in result + + +def test_factory_helper_attaches_remote_tool_and_notice_when_enabled(): + options = SimpleNamespace( + project_id="task-1", remote_sub_agent_config=_valid_config() + ) + tools = [] + tool_names = [] + + system_message = attach_remote_sub_agent_if_enabled( + options=options, + agent_name="test_agent", + working_directory="/tmp/work", + tools=tools, + tool_names=tool_names, + system_message="base prompt", + local_tool_description="local tools", + ) + + assert RemoteSubAgentToolkit.toolkit_name() in tool_names + assert [tool.func.__name__ for tool in tools] == ["run_remote_sub_agent"] + assert "remote-suitable work" in system_message + assert "/tmp/work" in system_message diff --git a/package.json b/package.json index c9b1f4480..d916d4e75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eigent", - "version": "0.0.90", + "version": "0.0.91", "main": "dist-electron/main/index.js", "description": "Eigent", "author": "Eigent.AI", diff --git a/server/alembic/env.py b/server/alembic/env.py index 1157f9754..479a92356 100644 --- a/server/alembic/env.py +++ b/server/alembic/env.py @@ -44,6 +44,7 @@ auto_import("app.model.config") auto_import("app.model.chat") auto_import("app.model.provider") +auto_import("app.model.remote_sub_agent") auto_import("app.model.trigger") # target_metadata = mymodel.Base.metadata diff --git a/server/alembic/versions/2026_05_11_1330-add_remote_sub_agent_provider.py b/server/alembic/versions/2026_05_11_1330-add_remote_sub_agent_provider.py new file mode 100644 index 000000000..922d49bd9 --- /dev/null +++ b/server/alembic/versions/2026_05_11_1330-add_remote_sub_agent_provider.py @@ -0,0 +1,90 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +"""add remote sub agent provider + +Revision ID: add_remote_sub_agent_provider +Revises: 9464b9d89de7 +Create Date: 2026-05-11 13:30:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "add_remote_sub_agent_provider" +down_revision: str | None = "9464b9d89de7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "remote_sub_agent_provider", + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("provider_name", sa.String(), nullable=False), + sa.Column( + "enabled", + sa.Boolean(), + server_default=sa.text("false"), + nullable=True, + ), + sa.Column("api_key", sa.String(), nullable=False), + sa.Column("endpoint_url", sa.String(), nullable=False), + sa.Column("agent_name", sa.String(), nullable=False), + sa.Column("model_name", sa.String(), nullable=False), + sa.Column("config", sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_remote_sub_agent_provider_provider_name"), + "remote_sub_agent_provider", + ["provider_name"], + unique=False, + ) + op.create_index( + op.f("ix_remote_sub_agent_provider_user_id"), + "remote_sub_agent_provider", + ["user_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_remote_sub_agent_provider_user_id"), + table_name="remote_sub_agent_provider", + ) + op.drop_index( + op.f("ix_remote_sub_agent_provider_provider_name"), + table_name="remote_sub_agent_provider", + ) + op.drop_table("remote_sub_agent_provider") diff --git a/server/app/domains/remote_sub_agent/__init__.py b/server/app/domains/remote_sub_agent/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/remote_sub_agent/__init__.py @@ -0,0 +1,13 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= diff --git a/server/app/domains/remote_sub_agent/api/__init__.py b/server/app/domains/remote_sub_agent/api/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/remote_sub_agent/api/__init__.py @@ -0,0 +1,13 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= diff --git a/server/app/domains/remote_sub_agent/api/provider_controller.py b/server/app/domains/remote_sub_agent/api/provider_controller.py new file mode 100644 index 000000000..59d972324 --- /dev/null +++ b/server/app/domains/remote_sub_agent/api/provider_controller.py @@ -0,0 +1,120 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi_babel import _ + +from app.domains.remote_sub_agent.service.provider_service import ( + RemoteSubAgentProviderService, +) +from app.model.remote_sub_agent.provider import ( + RemoteSubAgentProviderIn, + RemoteSubAgentProviderOut, +) +from app.shared.auth import auth_must +from app.shared.auth.user_auth import V1UserAuth + +router = APIRouter(tags=["Remote Sub Agent Provider Management"]) + + +@router.get( + "/remote-sub-agent-providers", + name="list remote sub agent providers", + response_model=list[RemoteSubAgentProviderOut], +) +async def list_remote_sub_agent_providers( + provider_name: str | None = None, + enabled: Optional[bool] = Query(None), + auth: V1UserAuth = Depends(auth_must), +) -> list[RemoteSubAgentProviderOut]: + return RemoteSubAgentProviderService.list_for_user( + auth.id, + provider_name=provider_name, + enabled=enabled, + ) + + +@router.get( + "/remote-sub-agent-providers/{provider_id}", + name="get remote sub agent provider detail", + response_model=RemoteSubAgentProviderOut, +) +async def get_remote_sub_agent_provider( + provider_id: int, + auth: V1UserAuth = Depends(auth_must), +): + model = RemoteSubAgentProviderService.get(provider_id, auth.id) + if not model: + raise HTTPException( + status_code=404, + detail=_("Remote sub agent provider not found"), + ) + return model + + +@router.post( + "/remote-sub-agent-providers", + name="create remote sub agent provider", + response_model=RemoteSubAgentProviderOut, +) +async def create_remote_sub_agent_provider( + data: RemoteSubAgentProviderIn, + auth: V1UserAuth = Depends(auth_must), +): + result = RemoteSubAgentProviderService.create( + auth.id, + data.model_dump(), + ) + return result["provider"] + + +@router.put( + "/remote-sub-agent-providers/{provider_id}", + name="update remote sub agent provider", + response_model=RemoteSubAgentProviderOut, +) +async def update_remote_sub_agent_provider( + provider_id: int, + data: RemoteSubAgentProviderIn, + auth: V1UserAuth = Depends(auth_must), +): + result = RemoteSubAgentProviderService.update( + provider_id, + auth.id, + data.model_dump(), + ) + if not result["success"]: + raise HTTPException( + status_code=404, + detail=_("Remote sub agent provider not found"), + ) + return result["provider"] + + +@router.delete( + "/remote-sub-agent-providers/{provider_id}", + name="delete remote sub agent provider", +) +async def delete_remote_sub_agent_provider( + provider_id: int, + auth: V1UserAuth = Depends(auth_must), +): + if not RemoteSubAgentProviderService.delete(provider_id, auth.id): + raise HTTPException( + status_code=404, + detail=_("Remote sub agent provider not found"), + ) + return Response(status_code=204) diff --git a/server/app/domains/remote_sub_agent/schema/__init__.py b/server/app/domains/remote_sub_agent/schema/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/remote_sub_agent/schema/__init__.py @@ -0,0 +1,13 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= diff --git a/server/app/domains/remote_sub_agent/service/__init__.py b/server/app/domains/remote_sub_agent/service/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/domains/remote_sub_agent/service/__init__.py @@ -0,0 +1,13 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= diff --git a/server/app/domains/remote_sub_agent/service/provider_service.py b/server/app/domains/remote_sub_agent/service/provider_service.py new file mode 100644 index 000000000..d3f7954d4 --- /dev/null +++ b/server/app/domains/remote_sub_agent/service/provider_service.py @@ -0,0 +1,136 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from sqlalchemy import update +from sqlmodel import col, select + +from app.core.database import session_make +from app.model.remote_sub_agent.provider import RemoteSubAgentProvider + + +class RemoteSubAgentProviderService: + """Remote sub-agent provider CRUD. + + A user can store multiple providers, but only one remote sub-agent provider + should be enabled at a time for now. This keeps the runtime selection + deterministic while still leaving room for additional providers later. + """ + + @staticmethod + def list_for_user( + user_id: int, + provider_name: str | None = None, + enabled: bool | None = None, + ) -> list[RemoteSubAgentProvider]: + with session_make() as s: + stmt = select(RemoteSubAgentProvider).where( + RemoteSubAgentProvider.user_id == user_id, + RemoteSubAgentProvider.no_delete(), + ) + if provider_name: + stmt = stmt.where( + RemoteSubAgentProvider.provider_name == provider_name + ) + if enabled is not None: + stmt = stmt.where(RemoteSubAgentProvider.enabled == enabled) + stmt = stmt.order_by( + col(RemoteSubAgentProvider.created_at).desc(), + col(RemoteSubAgentProvider.id).desc(), + ) + return list(s.exec(stmt).all()) + + @staticmethod + def get( + provider_id: int, + user_id: int, + ) -> RemoteSubAgentProvider | None: + with session_make() as s: + return s.exec( + select(RemoteSubAgentProvider).where( + RemoteSubAgentProvider.user_id == user_id, + RemoteSubAgentProvider.no_delete(), + RemoteSubAgentProvider.id == provider_id, + ) + ).first() + + @staticmethod + def create(user_id: int, data: dict) -> dict: + with session_make() as s: + if data.get("enabled"): + RemoteSubAgentProviderService._clear_enabled(s, user_id) + model = RemoteSubAgentProvider(**data, user_id=user_id) + model.save(s) + s.refresh(model) + return {"success": True, "provider": model} + + @staticmethod + def update(provider_id: int, user_id: int, data: dict) -> dict: + with session_make() as s: + model = s.exec( + select(RemoteSubAgentProvider).where( + RemoteSubAgentProvider.user_id == user_id, + RemoteSubAgentProvider.no_delete(), + RemoteSubAgentProvider.id == provider_id, + ) + ).first() + if not model: + return { + "success": False, + "error_code": "REMOTE_SUB_AGENT_PROVIDER_NOT_FOUND", + } + + if data.get("enabled"): + RemoteSubAgentProviderService._clear_enabled(s, user_id) + + for key in ( + "provider_name", + "enabled", + "api_key", + "endpoint_url", + "agent_name", + "model_name", + "config", + ): + if key in data: + setattr(model, key, data[key]) + model.save(s) + s.refresh(model) + return {"success": True, "provider": model} + + @staticmethod + def delete(provider_id: int, user_id: int) -> bool: + with session_make() as s: + model = s.exec( + select(RemoteSubAgentProvider).where( + RemoteSubAgentProvider.user_id == user_id, + RemoteSubAgentProvider.no_delete(), + RemoteSubAgentProvider.id == provider_id, + ) + ).first() + if not model: + return False + model.delete(s) + return True + + @staticmethod + def _clear_enabled(s, user_id: int) -> None: + s.exec( + update(RemoteSubAgentProvider) + .where( + RemoteSubAgentProvider.user_id == user_id, + RemoteSubAgentProvider.no_delete(), + ) + .values(enabled=False) + ) + s.commit() diff --git a/server/app/model/remote_sub_agent/__init__.py b/server/app/model/remote_sub_agent/__init__.py new file mode 100644 index 000000000..fa7455a0c --- /dev/null +++ b/server/app/model/remote_sub_agent/__init__.py @@ -0,0 +1,13 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= diff --git a/server/app/model/remote_sub_agent/provider.py b/server/app/model/remote_sub_agent/provider.py new file mode 100644 index 000000000..cd1b5365b --- /dev/null +++ b/server/app/model/remote_sub_agent/provider.py @@ -0,0 +1,65 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pydantic import BaseModel, model_validator +from sqlalchemy import Boolean, Column, text +from sqlmodel import JSON, Field + +from app.model.abstract.model import AbstractModel, DefaultTimes + + +class RemoteSubAgentProvider(AbstractModel, DefaultTimes, table=True): + id: int = Field(default=None, primary_key=True) + user_id: int = Field(index=True) + provider_name: str = Field(index=True) + enabled: bool = Field( + default=False, + sa_column=Column(Boolean, server_default=text("false")), + ) + api_key: str = "" + endpoint_url: str = "" + agent_name: str = "" + model_name: str = "" + config: dict | None = Field(default=None, sa_column=Column(JSON)) + + +class RemoteSubAgentProviderIn(BaseModel): + provider_name: str + enabled: bool = False + api_key: str = "" + endpoint_url: str = "" + agent_name: str = "" + model_name: str = "" + config: dict | None = None + + @model_validator(mode="after") + def validate_provider_config(self): + if not self.provider_name.strip(): + raise ValueError("Remote sub agent provider requires provider_name.") + + if ( + not self.api_key.strip() + or not self.endpoint_url.strip() + or not self.agent_name.strip() + ): + raise ValueError( + "Remote sub agent provider requires api_key, endpoint_url, " + "and agent_name." + ) + return self + + +class RemoteSubAgentProviderOut(RemoteSubAgentProviderIn): + id: int + user_id: int diff --git a/server/app/model/user/key.py b/server/app/model/user/key.py index 1ee51c3a1..f9ca34909 100644 --- a/server/app/model/user/key.py +++ b/server/app/model/user/key.py @@ -26,6 +26,7 @@ class ModelType(StrEnum): gpt4_1 = "gpt-4.1" gpt4_mini = "gpt-4.1-mini" gpt5_4 = "gpt-5.4" + gemini_3_5_flash = "gemini-3.5-flash" gemini_3_pro = "gemini-3-pro-preview" minimax_m2_5 = "minimax_m2_5" diff --git a/server/tests/test_remote_sub_agent_provider.py b/server/tests/test_remote_sub_agent_provider.py new file mode 100644 index 000000000..47957ac47 --- /dev/null +++ b/server/tests/test_remote_sub_agent_provider.py @@ -0,0 +1,98 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import inspect + +import pytest +from pydantic import ValidationError + +from app.model.remote_sub_agent.provider import RemoteSubAgentProviderIn + + +class TestRemoteSubAgentProviderSchema: + def test_disabled_provider_requires_credentials(self): + with pytest.raises(ValidationError): + RemoteSubAgentProviderIn( + provider_name="gemini_agents", + enabled=False, + ) + + def test_disabled_provider_accepts_agent_configuration(self): + config = RemoteSubAgentProviderIn( + provider_name="gemini_agents", + enabled=False, + api_key="test-key", + endpoint_url="https://generativelanguage.googleapis.com/v1beta", + agent_name="antigravity-preview-05-2026", + config={ + "max_wall_time_seconds": 900, + "poll_interval_seconds": 5, + }, + ) + + assert config.provider_name == "gemini_agents" + assert config.enabled is False + + def test_enabled_provider_requires_credentials(self): + with pytest.raises(ValidationError): + RemoteSubAgentProviderIn( + provider_name="gemini_agents", + enabled=True, + api_key="", + endpoint_url="", + agent_name="", + model_name="", + ) + + def test_enabled_provider_accepts_agent_configuration(self): + config = RemoteSubAgentProviderIn( + provider_name="gemini_agents", + enabled=True, + api_key="test-key", + endpoint_url="https://generativelanguage.googleapis.com/v1beta", + agent_name="antigravity-preview-05-2026", + config={ + "max_wall_time_seconds": 900, + "poll_interval_seconds": 5, + }, + ) + + assert config.enabled is True + + def test_enabled_provider_rejects_model_only_configuration(self): + with pytest.raises(ValidationError): + RemoteSubAgentProviderIn( + provider_name="gemini_agents", + enabled=True, + api_key="test-key", + endpoint_url="https://generativelanguage.googleapis.com/v1beta", + agent_name="", + model_name="gemini-3-flash-preview", + ) + + +class TestRemoteSubAgentProviderAuth: + def test_provider_endpoints_require_auth(self): + from app.domains.remote_sub_agent.api import provider_controller + + endpoints = [ + provider_controller.list_remote_sub_agent_providers, + provider_controller.get_remote_sub_agent_provider, + provider_controller.create_remote_sub_agent_provider, + provider_controller.update_remote_sub_agent_provider, + provider_controller.delete_remote_sub_agent_provider, + ] + + for endpoint in endpoints: + assert "auth" in inspect.signature(endpoint).parameters diff --git a/src/i18n/locales/ar/agents.json b/src/i18n/locales/ar/agents.json index 521624d12..f39dafae6 100644 --- a/src/i18n/locales/ar/agents.json +++ b/src/i18n/locales/ar/agents.json @@ -1,6 +1,7 @@ { "skills": "المهارات", "memory": "الذاكرة", + "sub-agents": "الوكلاء الفرعيون", "preview": "معاينة", "skills-description": "أضف مهارات مخصصة لتوسيع قدرات الوكيل الخاص بك.", "memory-description": "إدارة ذاكرة الوكيل وقاعدة المعرفة الخاصة به.", diff --git a/src/i18n/locales/ar/setting.json b/src/i18n/locales/ar/setting.json index 214fc7957..c433e3981 100644 --- a/src/i18n/locales/ar/setting.json +++ b/src/i18n/locales/ar/setting.json @@ -178,9 +178,19 @@ "models-default-setting-title": "الإعداد الافتراضي", "models-default-setting-description": "اختر أحد النماذج المكوّنة ليكون النموذج الافتراضي لـ Eigent، وسيتم تطبيقه عالميًا عبر مساحة العمل الخاصة بك.", "models-configuration": "التكوين", + "sub-agents": "الوكلاء الفرعيون", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "فوّض المهام المناسبة إلى صندوق عزل مُدار لوكيل بعيد.", + "agent-provider": "موفّر الوكيل", + "agent-id": "معرّف الوكيل", + "max-wall-time-seconds": "الحد الأقصى لوقت التشغيل (بالثواني)", + "poll-interval-seconds": "فاصل الاستعلام (بالثواني)", + "remote-sub-agent-required-fields": "API Key و API Host و Agent ID مطلوبة عند تفعيل Sub Agent.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/de/agents.json b/src/i18n/locales/de/agents.json index 18bc2dd25..0faeddc88 100644 --- a/src/i18n/locales/de/agents.json +++ b/src/i18n/locales/de/agents.json @@ -1,6 +1,7 @@ { "skills": "Fähigkeiten", "memory": "Speicher", + "sub-agents": "Sub-Agents", "preview": "Vorschau", "skills-description": "Fügen Sie benutzerdefinierte Fähigkeiten hinzu, um die Funktionen Ihres Agenten zu erweitern.", "memory-description": "Verwalten Sie den Speicher und die Wissensbasis Ihres Agenten.", diff --git a/src/i18n/locales/de/setting.json b/src/i18n/locales/de/setting.json index b3d6b66d5..957ff6cc7 100644 --- a/src/i18n/locales/de/setting.json +++ b/src/i18n/locales/de/setting.json @@ -238,9 +238,19 @@ "models-default-setting-title": "Standard-Einstellung", "models-default-setting-description": "Wähle eines deiner konfigurierten Modelle als Standardmodell für Eigent. Es wird global in deinem Arbeitsbereich angewendet.", "models-configuration": "Konfiguration", + "sub-agents": "Sub-Agents", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "Delegiere geeignete Aufgaben an eine verwaltete Remote-Agent-Sandbox.", + "agent-provider": "Agent-Anbieter", + "agent-id": "Agent-ID", + "max-wall-time-seconds": "Max. Laufzeit (Sekunden)", + "poll-interval-seconds": "Abfrageintervall (Sekunden)", + "remote-sub-agent-required-fields": "API-Schlüssel, API-Host und Agent-ID sind erforderlich, wenn Sub Agent aktiviert ist.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/en-us/agents.json b/src/i18n/locales/en-us/agents.json index 13f215331..40640fd5f 100644 --- a/src/i18n/locales/en-us/agents.json +++ b/src/i18n/locales/en-us/agents.json @@ -1,6 +1,7 @@ { "skills": "Skills", "memory": "Memory", + "sub-agents": "Sub Agents", "preview": "Preview", "skills-description": "Add custom skills to extend your agent's capabilities.", "memory-description": "Manage your agent's memory and knowledge base.", diff --git a/src/i18n/locales/en-us/setting.json b/src/i18n/locales/en-us/setting.json index 532c469c4..8464ac86f 100644 --- a/src/i18n/locales/en-us/setting.json +++ b/src/i18n/locales/en-us/setting.json @@ -206,9 +206,19 @@ "models-default-setting-title": "Default setting", "models-default-setting-description": "Pick one of your configured models as the default model for Eigent. It will be applied globally across your workspace.", "models-configuration": "Configuration", + "sub-agents": "Sub Agents", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "Delegate suitable tasks to a managed remote agent sandbox.", + "agent-provider": "Agent Provider", + "agent-id": "Agent ID", + "max-wall-time-seconds": "Max Wall Time (Seconds)", + "poll-interval-seconds": "Poll Interval (Seconds)", + "remote-sub-agent-required-fields": "API Key, API Host, and Agent ID are required when Sub Agent is enabled.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/es/agents.json b/src/i18n/locales/es/agents.json index c16ad29b1..5186a4cc0 100644 --- a/src/i18n/locales/es/agents.json +++ b/src/i18n/locales/es/agents.json @@ -1,6 +1,7 @@ { "skills": "Habilidades", "memory": "Memoria", + "sub-agents": "Subagentes", "preview": "Vista previa", "skills-description": "Agregue habilidades personalizadas para ampliar las capacidades de su agente.", "memory-description": "Administre la memoria y la base de conocimientos de su agente.", diff --git a/src/i18n/locales/es/setting.json b/src/i18n/locales/es/setting.json index 0a87dd082..240c90964 100644 --- a/src/i18n/locales/es/setting.json +++ b/src/i18n/locales/es/setting.json @@ -238,9 +238,19 @@ "models-default-setting-title": "Configuración predeterminada", "models-default-setting-description": "Elige uno de tus modelos configurados como modelo predeterminado para Eigent. Se aplicará globalmente en tu espacio de trabajo.", "models-configuration": "Configuración", + "sub-agents": "Subagentes", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "Delega tareas adecuadas a un sandbox de agente remoto administrado.", + "agent-provider": "Proveedor de agente", + "agent-id": "ID de agente", + "max-wall-time-seconds": "Tiempo máximo de ejecución (segundos)", + "poll-interval-seconds": "Intervalo de sondeo (segundos)", + "remote-sub-agent-required-fields": "API Key, API Host y Agent ID son obligatorios cuando Sub Agent está habilitado.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/fr/agents.json b/src/i18n/locales/fr/agents.json index 45a457600..17a34248a 100644 --- a/src/i18n/locales/fr/agents.json +++ b/src/i18n/locales/fr/agents.json @@ -1,6 +1,7 @@ { "skills": "Compétences", "memory": "Mémoire", + "sub-agents": "Sous-agents", "preview": "Aperçu", "skills-description": "Ajoutez des compétences personnalisées pour étendre les capacités de votre agent.", "memory-description": "Gérez la mémoire et la base de connaissances de votre agent.", diff --git a/src/i18n/locales/fr/setting.json b/src/i18n/locales/fr/setting.json index 5712f782c..8ab1b1c0d 100644 --- a/src/i18n/locales/fr/setting.json +++ b/src/i18n/locales/fr/setting.json @@ -221,9 +221,19 @@ "models-default-setting-title": "Paramètre par défaut", "models-default-setting-description": "Choisissez l'un de vos modèles configurés comme modèle par défaut pour Eigent. Il sera appliqué globalement dans votre espace de travail.", "models-configuration": "Configuration", + "sub-agents": "Sous-agents", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "Déléguez les tâches adaptées à un bac à sable d'agent distant géré.", + "agent-provider": "Fournisseur d'agent", + "agent-id": "ID de l'agent", + "max-wall-time-seconds": "Temps d'exécution maximal (secondes)", + "poll-interval-seconds": "Intervalle d'interrogation (secondes)", + "remote-sub-agent-required-fields": "La clé API, l'hôte API et l'ID de l'agent sont requis lorsque Sub Agent est activé.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/it/agents.json b/src/i18n/locales/it/agents.json index b9855f585..d4d45dd8e 100644 --- a/src/i18n/locales/it/agents.json +++ b/src/i18n/locales/it/agents.json @@ -1,6 +1,7 @@ { "skills": "Competenze", "memory": "Memoria", + "sub-agents": "Sub-agent", "preview": "Anteprima", "skills-description": "Aggiungi competenze personalizzate per estendere le capacità del tuo agente.", "memory-description": "Gestisci la memoria e la base di conoscenza del tuo agente.", diff --git a/src/i18n/locales/it/setting.json b/src/i18n/locales/it/setting.json index b39695eb5..d5a225ef3 100644 --- a/src/i18n/locales/it/setting.json +++ b/src/i18n/locales/it/setting.json @@ -238,9 +238,19 @@ "models-default-setting-title": "Impostazione predefinita", "models-default-setting-description": "Seleziona uno dei modelli configurati come modello predefinito per Eigent. Verrà applicato globalmente nel tuo spazio di lavoro.", "models-configuration": "Configurazione", + "sub-agents": "Sub-agent", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "Delega le attività adatte a una sandbox di agent remoto gestita.", + "agent-provider": "Provider agent", + "agent-id": "ID agent", + "max-wall-time-seconds": "Tempo massimo di esecuzione (secondi)", + "poll-interval-seconds": "Intervallo di polling (secondi)", + "remote-sub-agent-required-fields": "API Key, API Host e Agent ID sono obbligatori quando Sub Agent è abilitato.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/ja/agents.json b/src/i18n/locales/ja/agents.json index eef61ac5a..b7e59ff71 100644 --- a/src/i18n/locales/ja/agents.json +++ b/src/i18n/locales/ja/agents.json @@ -1,6 +1,7 @@ { "skills": "スキル", "memory": "メモリ", + "sub-agents": "サブエージェント", "preview": "プレビュー", "skills-description": "カスタムスキルを追加してエージェントの能力を拡張します。", "memory-description": "エージェントのメモリとナレッジベースを管理します。", diff --git a/src/i18n/locales/ja/setting.json b/src/i18n/locales/ja/setting.json index eb3af2faa..c5c3bf527 100644 --- a/src/i18n/locales/ja/setting.json +++ b/src/i18n/locales/ja/setting.json @@ -239,9 +239,19 @@ "models-default-setting-title": "デフォルト設定", "models-default-setting-description": "設定済みモデルの中から、Eigent のデフォルトモデルを1つ選択します。ワークスペース全体にグローバルに適用されます。", "models-configuration": "構成", + "sub-agents": "サブエージェント", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "適したタスクを管理されたリモートエージェントのサンドボックスに委任します。", + "agent-provider": "エージェントプロバイダー", + "agent-id": "エージェント ID", + "max-wall-time-seconds": "最大実行時間(秒)", + "poll-interval-seconds": "ポーリング間隔(秒)", + "remote-sub-agent-required-fields": "Sub Agent を有効にするには、API Key、API Host、Agent ID が必要です。", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/ko/agents.json b/src/i18n/locales/ko/agents.json index 5649fb6d1..6024227c0 100644 --- a/src/i18n/locales/ko/agents.json +++ b/src/i18n/locales/ko/agents.json @@ -1,6 +1,7 @@ { "skills": "스킬", "memory": "메모리", + "sub-agents": "하위 에이전트", "preview": "미리보기", "skills-description": "사용자 정의 스킬을 추가하여 에이전트 기능을 확장하세요.", "memory-description": "에이전트의 메모리와 지식 베이스를 관리하세요.", diff --git a/src/i18n/locales/ko/setting.json b/src/i18n/locales/ko/setting.json index 838361fe0..386392234 100644 --- a/src/i18n/locales/ko/setting.json +++ b/src/i18n/locales/ko/setting.json @@ -239,9 +239,19 @@ "models-default-setting-title": "기본 설정", "models-default-setting-description": "구성된 모델 중 하나를 Eigent의 기본 모델로 선택하세요. 워크스페이스 전체에 전역으로 적용됩니다.", "models-configuration": "구성", + "sub-agents": "서브 에이전트", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "적합한 작업을 관리형 원격 에이전트 샌드박스에 위임합니다.", + "agent-provider": "에이전트 제공자", + "agent-id": "에이전트 ID", + "max-wall-time-seconds": "최대 실행 시간(초)", + "poll-interval-seconds": "폴링 간격(초)", + "remote-sub-agent-required-fields": "Sub Agent를 활성화하려면 API Key, API Host, Agent ID가 필요합니다.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/ru/agents.json b/src/i18n/locales/ru/agents.json index ef7c027f3..15f627366 100644 --- a/src/i18n/locales/ru/agents.json +++ b/src/i18n/locales/ru/agents.json @@ -1,6 +1,7 @@ { "skills": "Навыки", "memory": "Память", + "sub-agents": "Субагенты", "preview": "Предпросмотр", "skills-description": "Добавьте пользовательские навыки для расширения возможностей вашего агента.", "memory-description": "Управляйте памятью и базой знаний вашего агента.", diff --git a/src/i18n/locales/ru/setting.json b/src/i18n/locales/ru/setting.json index b4f9121d5..e4844c3ac 100644 --- a/src/i18n/locales/ru/setting.json +++ b/src/i18n/locales/ru/setting.json @@ -238,9 +238,19 @@ "models-default-setting-title": "Настройка по умолчанию", "models-default-setting-description": "Выберите одну из настроенных моделей как модель по умолчанию для Eigent. Она будет применяться глобально во всём рабочем пространстве.", "models-configuration": "Конфигурация", + "sub-agents": "Суб-агенты", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "Делегируйте подходящие задачи управляемой песочнице удалённого агента.", + "agent-provider": "Поставщик агента", + "agent-id": "ID агента", + "max-wall-time-seconds": "Макс. время выполнения (секунды)", + "poll-interval-seconds": "Интервал опроса (секунды)", + "remote-sub-agent-required-fields": "API Key, API Host и Agent ID обязательны, когда Sub Agent включён.", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/zh-Hans/agents.json b/src/i18n/locales/zh-Hans/agents.json index adedc1988..3da8424e3 100644 --- a/src/i18n/locales/zh-Hans/agents.json +++ b/src/i18n/locales/zh-Hans/agents.json @@ -1,6 +1,7 @@ { "skills": "技能", "memory": "记忆", + "sub-agents": "子智能体", "preview": "预览", "skills-description": "添加自定义技能以扩展您的智能体能力。", "memory-description": "管理您的智能体的记忆和知识库。", diff --git a/src/i18n/locales/zh-Hans/setting.json b/src/i18n/locales/zh-Hans/setting.json index e9a66dc65..7c5d87abb 100644 --- a/src/i18n/locales/zh-Hans/setting.json +++ b/src/i18n/locales/zh-Hans/setting.json @@ -196,9 +196,19 @@ "models-default-setting-title": "默认设置", "models-default-setting-description": "从已配置的模型中选择一个作为 Eigent 的默认模型,它将全局应用到您的工作区。", "models-configuration": "配置", + "sub-agents": "Sub Agents", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "把适合远程执行的任务委托给托管远程智能体沙盒。", + "agent-provider": "Agent Provider", + "agent-id": "Agent ID", + "max-wall-time-seconds": "最长运行时间(秒)", + "poll-interval-seconds": "轮询间隔(秒)", + "remote-sub-agent-required-fields": "启用 Sub Agent 时,需要填写 API Key、API Host 和 Agent ID。", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/i18n/locales/zh-Hant/agents.json b/src/i18n/locales/zh-Hant/agents.json index 2884243db..203fb0b0f 100644 --- a/src/i18n/locales/zh-Hant/agents.json +++ b/src/i18n/locales/zh-Hant/agents.json @@ -1,6 +1,7 @@ { "skills": "技能", "memory": "記憶", + "sub-agents": "子智能體", "preview": "預覽", "skills-description": "新增自訂技能以擴展您的智能體能力。", "memory-description": "管理您的智能體的記憶和知識庫。", diff --git a/src/i18n/locales/zh-Hant/setting.json b/src/i18n/locales/zh-Hant/setting.json index b4436e14a..a229d1b1e 100644 --- a/src/i18n/locales/zh-Hant/setting.json +++ b/src/i18n/locales/zh-Hant/setting.json @@ -167,9 +167,19 @@ "models-default-setting-title": "預設設定", "models-default-setting-description": "從已配置的模型中選擇一個作為 Eigent 的預設模型,它將全域套用於您的工作區。", "models-configuration": "配置", + "sub-agents": "Sub Agents", + "gemini-agent": "Gemini Agents API", + "gemini-remote-sub-agent": "Gemini Agents API", + "remote-sub-agent-description": "把適合遠端執行的任務委託給託管遠端智能體沙盒。", + "agent-provider": "Agent Provider", + "agent-id": "Agent ID", + "max-wall-time-seconds": "最長執行時間(秒)", + "poll-interval-seconds": "輪詢間隔(秒)", + "remote-sub-agent-required-fields": "啟用 Sub Agent 時,需要填寫 API Key、API Host 和 Agent ID。", "gemini-3-pro-preview-name": "Gemini 3 Pro Preview", "gemini-3.1-pro-preview-name": "Gemini 3.1 Pro Preview", + "gemini-3.5-flash-name": "Gemini 3.5 Flash", "gemini-3-flash-preview-name": "Gemini 3 Flash Preview", "gpt-5.4-name": "GPT-5.4", "gpt-5.5-name": "GPT-5.5", diff --git a/src/lib/remoteSubAgent.ts b/src/lib/remoteSubAgent.ts new file mode 100644 index 000000000..10a9c440f --- /dev/null +++ b/src/lib/remoteSubAgent.ts @@ -0,0 +1,150 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +export const REMOTE_SUB_AGENT_PROVIDER = 'gemini_agents'; +export const REMOTE_SUB_AGENT_PROVIDER_ID = REMOTE_SUB_AGENT_PROVIDER; +export const REMOTE_SUB_AGENT_DEFAULT_BASE_URL = + 'https://generativelanguage.googleapis.com/v1beta'; +export const REMOTE_SUB_AGENT_DEFAULT_AGENT = ''; +export const REMOTE_SUB_AGENT_DEFAULT_MAX_WALL_TIME_SECONDS = '900'; +export const REMOTE_SUB_AGENT_DEFAULT_POLL_INTERVAL_SECONDS = '5'; + +export type GeminiRemoteSubAgentSettings = { + api_key: string; + base_url: string; + agent_name?: string; + max_wall_time_seconds?: number; + poll_interval_seconds?: number; +}; + +export type RemoteSubAgentSettings = { + enabled: boolean; + provider: typeof REMOTE_SUB_AGENT_PROVIDER; + gemini_agents: GeminiRemoteSubAgentSettings; +}; + +export type RemoteSubAgentFormState = { + provider_id?: number; + enabled: boolean; + provider: typeof REMOTE_SUB_AGENT_PROVIDER; + apiKey: string; + baseUrl: string; + agentName: string; + maxWallTimeSeconds: string; + pollIntervalSeconds: string; +}; + +export const createDefaultRemoteSubAgentForm = (): RemoteSubAgentFormState => ({ + enabled: false, + provider: REMOTE_SUB_AGENT_PROVIDER, + apiKey: '', + baseUrl: REMOTE_SUB_AGENT_DEFAULT_BASE_URL, + agentName: REMOTE_SUB_AGENT_DEFAULT_AGENT, + maxWallTimeSeconds: REMOTE_SUB_AGENT_DEFAULT_MAX_WALL_TIME_SECONDS, + pollIntervalSeconds: REMOTE_SUB_AGENT_DEFAULT_POLL_INTERVAL_SECONDS, +}); + +export function normalizeRemoteSubAgentProvider(provider: any) { + if (!provider) { + return createDefaultRemoteSubAgentForm(); + } + + const config = provider.config || provider.encrypted_config || {}; + return { + provider_id: provider.id, + enabled: Boolean(provider.enabled ?? config.enabled), + provider: REMOTE_SUB_AGENT_PROVIDER, + apiKey: provider.api_key || '', + baseUrl: + provider.endpoint_url || + config.base_url || + REMOTE_SUB_AGENT_DEFAULT_BASE_URL, + agentName: + provider.agent_name || + config.agent_name || + provider.model_type || + REMOTE_SUB_AGENT_DEFAULT_AGENT, + maxWallTimeSeconds: String( + config.max_wall_time_seconds || + REMOTE_SUB_AGENT_DEFAULT_MAX_WALL_TIME_SECONDS + ), + pollIntervalSeconds: String( + config.poll_interval_seconds || + REMOTE_SUB_AGENT_DEFAULT_POLL_INTERVAL_SECONDS + ), + } satisfies RemoteSubAgentFormState; +} + +export function isRemoteSubAgentConfigured( + form: RemoteSubAgentFormState +): boolean { + return Boolean( + form.enabled && + form.apiKey.trim() && + form.baseUrl.trim() && + form.agentName.trim() + ); +} + +export function toRemoteSubAgentProviderPayload(form: RemoteSubAgentFormState) { + const agentName = form.agentName.trim(); + const maxWallTimeSeconds = Number(form.maxWallTimeSeconds); + const pollIntervalSeconds = Number(form.pollIntervalSeconds); + const fallbackMaxWallTimeSeconds = Number( + REMOTE_SUB_AGENT_DEFAULT_MAX_WALL_TIME_SECONDS + ); + const fallbackPollIntervalSeconds = Number( + REMOTE_SUB_AGENT_DEFAULT_POLL_INTERVAL_SECONDS + ); + + return { + provider_name: REMOTE_SUB_AGENT_PROVIDER_ID, + enabled: form.enabled, + api_key: form.apiKey.trim(), + endpoint_url: form.baseUrl.trim() || REMOTE_SUB_AGENT_DEFAULT_BASE_URL, + agent_name: agentName, + model_name: '', + config: { + max_wall_time_seconds: + Number.isFinite(maxWallTimeSeconds) && maxWallTimeSeconds > 0 + ? maxWallTimeSeconds + : fallbackMaxWallTimeSeconds, + poll_interval_seconds: + Number.isFinite(pollIntervalSeconds) && pollIntervalSeconds > 0 + ? pollIntervalSeconds + : fallbackPollIntervalSeconds, + }, + }; +} + +export function toRemoteSubAgentRuntimeConfig( + form: RemoteSubAgentFormState +): RemoteSubAgentSettings | null { + if (!isRemoteSubAgentConfigured(form)) { + return null; + } + + const payload = toRemoteSubAgentProviderPayload(form); + return { + enabled: true, + provider: REMOTE_SUB_AGENT_PROVIDER, + gemini_agents: { + api_key: payload.api_key, + base_url: payload.endpoint_url, + agent_name: payload.agent_name, + max_wall_time_seconds: payload.config.max_wall_time_seconds, + poll_interval_seconds: payload.config.poll_interval_seconds, + }, + }; +} diff --git a/src/pages/Agents/Models.tsx b/src/pages/Agents/Models.tsx index 33ec8b210..40d67d805 100644 --- a/src/pages/Agents/Models.tsx +++ b/src/pages/Agents/Models.tsx @@ -88,6 +88,13 @@ import sglangImage from '@/assets/model/sglang.svg'; import vllmImage from '@/assets/model/vllm.svg'; import zaiImage from '@/assets/model/zai.svg'; +import { + fetchProviderModels, + loadCachedModels, + saveCachedModels, + type ProviderModelGroup, +} from '@/lib/providerModels'; +import { ProviderModelCombobox } from './components/ProviderModelCombobox'; import { appendV1ToEndpoint, canAutoFixOllamaEndpoint, @@ -105,13 +112,6 @@ import { toEndpointBaseUrl, VLLM_PROVIDER_ID, } from './localModels'; -import { ProviderModelCombobox } from './components/ProviderModelCombobox'; -import { - fetchProviderModels, - loadCachedModels, - saveCachedModels, - type ProviderModelGroup, -} from '@/lib/providerModels'; // Sidebar tab types type SidebarTab = @@ -360,6 +360,7 @@ export default function SettingModels() { try { const res = await proxyFetchGet('/api/v1/providers'); const providerList = Array.isArray(res) ? res : res.items || []; + // Handle custom models setForm((f) => f.map((fi, idx) => { @@ -576,6 +577,7 @@ export default function SettingModels() { // Cloud model options const cloudModelOptions = [ + { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash' }, { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro Preview' }, { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview' }, { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview' }, @@ -1046,6 +1048,7 @@ export default function SettingModels() { toast.error(t('setting.reset-failed')); } }; + const handleDelete = async (idx: number) => { try { const { provider_id } = form[idx]; @@ -1476,6 +1479,9 @@ export default function SettingModels() { + + {t('setting.gemini-3.5-flash-name')} + {t('setting.gemini-3.1-pro-preview-name')} diff --git a/src/pages/Agents/SubAgents.tsx b/src/pages/Agents/SubAgents.tsx new file mode 100644 index 000000000..857eda4f7 --- /dev/null +++ b/src/pages/Agents/SubAgents.tsx @@ -0,0 +1,437 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { + fetchPost, + proxyFetchDelete, + proxyFetchGet, + proxyFetchPost, + proxyFetchPut, +} from '@/api/http'; +import geminiImage from '@/assets/model/gemini.svg'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + createDefaultRemoteSubAgentForm, + isRemoteSubAgentConfigured, + normalizeRemoteSubAgentProvider, + REMOTE_SUB_AGENT_DEFAULT_AGENT, + REMOTE_SUB_AGENT_PROVIDER, + REMOTE_SUB_AGENT_PROVIDER_ID, + toRemoteSubAgentProviderPayload, + type RemoteSubAgentFormState, +} from '@/lib/remoteSubAgent'; +import { Eye, EyeOff } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +export default function SubAgents() { + const { t } = useTranslation(); + const [remoteSubAgentForm, setRemoteSubAgentForm] = useState( + createDefaultRemoteSubAgentForm + ); + const [showRemoteSubAgentKey, setShowRemoteSubAgentKey] = useState(false); + const [remoteSubAgentSaving, setRemoteSubAgentSaving] = useState(false); + const [remoteSubAgentError, setRemoteSubAgentError] = useState( + null + ); + const [selectedProvider, setSelectedProvider] = useState( + REMOTE_SUB_AGENT_PROVIDER + ); + + const loadRemoteSubAgentProvider = useCallback(async () => { + const res = await proxyFetchGet('/api/v1/remote-sub-agent-providers', { + provider_name: REMOTE_SUB_AGENT_PROVIDER_ID, + }); + const providerList = Array.isArray(res) ? res : res.items || []; + setRemoteSubAgentForm(normalizeRemoteSubAgentProvider(providerList[0])); + }, []); + + useEffect(() => { + loadRemoteSubAgentProvider().catch((error) => { + console.error('Error fetching remote sub agent providers:', error); + }); + }, [loadRemoteSubAgentProvider]); + + const remoteSubAgentValidateMessage = (error: any) => { + const detail = error?.response?.data?.detail; + if (typeof detail === 'string') return detail; + if (detail?.message) return detail.message; + if (error?.response?.data?.message) return error.response.data.message; + if (error?.message) return error.message; + return t('setting.validate-failed'); + }; + + const getRemoteSubAgentRequiredError = ( + form: RemoteSubAgentFormState, + requireFields = form.enabled + ) => { + const maxWallTimeSeconds = Number(form.maxWallTimeSeconds.trim()); + const pollIntervalSeconds = Number(form.pollIntervalSeconds.trim()); + + if ( + requireFields && + (!form.apiKey.trim() || + !form.baseUrl.trim() || + !form.agentName.trim() || + !Number.isFinite(maxWallTimeSeconds) || + maxWallTimeSeconds <= 0 || + !Number.isFinite(pollIntervalSeconds) || + pollIntervalSeconds <= 0) + ) { + return t('setting.remote-sub-agent-required-fields'); + } + return null; + }; + + const validateRemoteSubAgentConfig = async ( + form: RemoteSubAgentFormState + ) => { + const res = await fetchPost('/remote-sub-agent/validate', { + provider: form.provider, + api_key: form.apiKey.trim(), + base_url: form.baseUrl.trim(), + agent_name: form.agentName.trim(), + }); + + if (!res?.is_valid) { + throw new Error(res?.message || t('setting.validate-failed')); + } + + toast.success(t('setting.validate-success'), { + description: res.message, + closeButton: true, + }); + }; + + const persistRemoteSubAgentForm = async ( + form: RemoteSubAgentFormState, + { + requireFields = form.enabled, + validateConnection = form.enabled, + }: { + requireFields?: boolean; + validateConnection?: boolean; + } = {} + ) => { + const requiredError = getRemoteSubAgentRequiredError(form, requireFields); + if (requiredError) { + setRemoteSubAgentError(requiredError); + toast.error(requiredError); + return false; + } + + setRemoteSubAgentSaving(true); + setRemoteSubAgentError(null); + try { + if (validateConnection) { + await validateRemoteSubAgentConfig(form); + } + + const data = toRemoteSubAgentProviderPayload(form); + if (form.provider_id) { + await proxyFetchPut( + `/api/v1/remote-sub-agent-providers/${form.provider_id}`, + data + ); + } else { + await proxyFetchPost('/api/v1/remote-sub-agent-providers', data); + } + + await loadRemoteSubAgentProvider(); + toast.success(t('setting.configuration-saved-successfully')); + return true; + } catch (error) { + console.error('Error saving remote sub agent:', error); + const message = remoteSubAgentValidateMessage(error); + setRemoteSubAgentError(message); + toast.error(message); + return false; + } finally { + setRemoteSubAgentSaving(false); + } + }; + + const handleRemoteSubAgentSave = async () => { + await persistRemoteSubAgentForm(remoteSubAgentForm, { + requireFields: true, + validateConnection: true, + }); + }; + + const handleRemoteSubAgentToggle = async (checked: boolean) => { + const previousForm = remoteSubAgentForm; + const nextForm = { + ...remoteSubAgentForm, + enabled: checked, + }; + + if (!checked && !remoteSubAgentForm.provider_id) { + setRemoteSubAgentForm(nextForm); + setRemoteSubAgentError(null); + return; + } + + const requiredError = getRemoteSubAgentRequiredError(nextForm, checked); + if (requiredError) { + setRemoteSubAgentError(requiredError); + toast.error(requiredError); + return; + } + + setRemoteSubAgentForm(nextForm); + const saved = await persistRemoteSubAgentForm(nextForm, { + requireFields: checked, + validateConnection: checked, + }); + if (!saved) { + setRemoteSubAgentForm(previousForm); + } + }; + + const handleRemoteSubAgentReset = async () => { + try { + if (remoteSubAgentForm.provider_id) { + await proxyFetchDelete( + `/api/v1/remote-sub-agent-providers/${remoteSubAgentForm.provider_id}` + ); + } + setRemoteSubAgentForm(createDefaultRemoteSubAgentForm()); + setRemoteSubAgentError(null); + toast.success(t('setting.reset-success')); + } catch (error) { + console.error('Error resetting remote sub agent:', error); + toast.error(t('setting.reset-failed')); + } + }; + + const isConfigured = isRemoteSubAgentConfigured(remoteSubAgentForm); + + const renderProviderItem = () => { + const isActive = selectedProvider === REMOTE_SUB_AGENT_PROVIDER; + + return ( + + ); + }; + + const renderGeminiProviderContent = () => ( +
+
+
+
+ {t('setting.gemini-remote-sub-agent')} +
+
+ {isConfigured ? ( +
+ ) : ( +
+ )} + +
+
+
+ {t('setting.remote-sub-agent-description')} +
+
+ +
+ + ) : ( + + ) + } + onBackIconClick={() => + setShowRemoteSubAgentKey((visible) => !visible) + } + onChange={(e) => { + setRemoteSubAgentForm((prev) => ({ + ...prev, + apiKey: e.target.value, + })); + setRemoteSubAgentError(null); + }} + /> + + { + setRemoteSubAgentForm((prev) => ({ + ...prev, + baseUrl: e.target.value, + })); + setRemoteSubAgentError(null); + }} + /> + + { + setRemoteSubAgentForm((prev) => ({ + ...prev, + agentName: e.target.value, + })); + setRemoteSubAgentError(null); + }} + /> + +
+ + setRemoteSubAgentForm((prev) => ({ + ...prev, + maxWallTimeSeconds: e.target.value, + })) + } + /> + + setRemoteSubAgentForm((prev) => ({ + ...prev, + pollIntervalSeconds: e.target.value, + })) + } + /> +
+ + {remoteSubAgentError && ( + + {remoteSubAgentError} + + )} +
+ +
+ + +
+
+ ); + + return ( +
+
+
+
+
+ {t('setting.sub-agents')} +
+
+
+
+ +
+
+
+ {t('setting.models-configuration')} +
+ +
+
+
+
+
+ {t('setting.agent-provider')} +
+ {renderProviderItem()} +
+
+
+
+ {renderGeminiProviderContent()} +
+
+
+
+
+ ); +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 97d34b194..2509a4775 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next'; import Memory from './Memory'; import Models from './Models'; import Skills from './Skills'; +import SubAgents from './SubAgents'; export default function Capabilities() { const { t } = useTranslation(); @@ -34,6 +35,10 @@ export default function Capabilities() { id: 'skills', name: t('agents.skills'), }, + { + id: 'sub-agents', + name: t('agents.sub-agents'), + }, { id: 'memory', name: t('agents.memory'), @@ -46,8 +51,8 @@ export default function Capabilities() { return (
-
-
+
+
({ @@ -59,16 +64,17 @@ export default function Capabilities() { } value={activeTab} onValueChange={handleTabChange} - className="min-h-0 gap-0 h-full w-full flex-1" + className="h-full min-h-0 w-full flex-1 gap-0" listClassName="w-full h-full overflow-y-auto" contentClassName="hidden" />
-
+
{activeTab === 'models' && } {activeTab === 'skills' && } + {activeTab === 'sub-agents' && } {activeTab === 'memory' && }
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index c4a8b76b4..103f830d0 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -21,6 +21,7 @@ type ModelType = 'cloud' | 'local' | 'custom'; type PreferredIDE = 'vscode' | 'cursor' | 'system'; export type CloudModelType = | 'gemini-3.1-pro-preview' + | 'gemini-3.5-flash' | 'gemini-3-pro-preview' | 'gemini-3-flash-preview' | 'claude-haiku-4-5' diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index eb1d419c3..8a49eca14 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -26,6 +26,11 @@ import { import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import { generateUniqueId, uploadLog } from '@/lib'; +import { + normalizeRemoteSubAgentProvider, + REMOTE_SUB_AGENT_PROVIDER_ID, + toRemoteSubAgentRuntimeConfig, +} from '@/lib/remoteSubAgent'; import { proxyUpdateTriggerExecution } from '@/service/triggerApi'; import { ExecutionStatus } from '@/types'; import { @@ -211,6 +216,7 @@ type CloudModelPlatform = // prettier-ignore const CLOUD_MODEL_PLATFORM_MAP: Record = { 'gemini-3.1-pro-preview': 'gemini', + 'gemini-3.5-flash': 'gemini', 'gemini-3-pro-preview': 'gemini', 'gemini-3-flash-preview': 'gemini', 'claude-haiku-4-5': 'aws-bedrock-converse', @@ -895,6 +901,23 @@ const chatStore = (initial?: Partial) => } } + let remoteSubAgentConfig = null; + try { + const providersRes = await proxyFetchGet( + '/api/v1/remote-sub-agent-providers', + { provider_name: REMOTE_SUB_AGENT_PROVIDER_ID, enabled: true } + ); + const providerList = Array.isArray(providersRes) + ? providersRes + : providersRes.items || []; + const remoteSubAgentProvider = providerList[0]; + remoteSubAgentConfig = toRemoteSubAgentRuntimeConfig( + normalizeRemoteSubAgentProvider(remoteSubAgentProvider) + ); + } catch (error) { + console.error('Failed to load remote sub agent configuration:', error); + } + const addWorkers = workerList.map((worker) => { return { name: worker.workerInfo?.name, @@ -1028,6 +1051,7 @@ const chatStore = (initial?: Partial) => cdp_browsers: cdp_browsers, env_path: envPath, search_config: searchConfig, + remote_sub_agent_config: remoteSubAgentConfig, }) : undefined, diff --git a/test/unit/electron/githubReleaseCdnProvider.test.ts b/test/unit/electron/githubReleaseCdnProvider.test.ts index bfb88f7e0..14ed05945 100644 --- a/test/unit/electron/githubReleaseCdnProvider.test.ts +++ b/test/unit/electron/githubReleaseCdnProvider.test.ts @@ -44,18 +44,18 @@ describe('githubReleaseCdnProvider', () => { expect( buildVersionedReleaseBaseUrl( 'https://cdn.eigent.ai/releases/', - '0.0.90', + '0.0.91', 'mac-arm64' ) - ).toBe('https://cdn.eigent.ai/releases/v0.0.90/mac-arm64/'); + ).toBe('https://cdn.eigent.ai/releases/v0.0.91/mac-arm64/'); expect( buildVersionedReleaseBaseUrl( 'https://cdn.eigent.ai/releases', - '0.0.90', + '0.0.91', 'win-x64' ) - ).toBe('https://cdn.eigent.ai/releases/v0.0.90/win-x64/'); + ).toBe('https://cdn.eigent.ai/releases/v0.0.91/win-x64/'); }); it('maps mac builds to the GitHub release channels used in CI', () => {