Commit b2ab4b51 by DigHuang Committed by GitHub

feat(sandbox): support sandbox and skill version entrypoint scripts (#7145)

* feat(sandbox): support sandbox and skill version entrypoint scripts

* refactor(sandbox): preserve sandbox entrypoint when disabled and simplify deployment marker

* refactor(sandbox): migrate sandbox initialization locking to Redis lease

* refactor(sandbox): split sandbox sub-module and support after-entrypoint

* refactor(sandbox): enforce strict Redis lease with auto-renewal

* refactor(sandbox): support platform bootstrap callback

* docs(agent): Agent V2 settings, debug, and skill initialization guides
parent f5f1e58b
---
title: Assisted Generation and Debugging
description: Detailed guide to generating Prompts via the AI Helper Bot and testing and debugging the Agent using the Chat Preview window and runtime details.
---
## Assisted generation
An AI Helper Bot optimized for Agent V2 is integrated under the "Assisted generation" tab.
![AI Helper Bot](/imgs/agent_helper_bot.png)
### 1. Smart Analysis and Scheme Optimization
The AI Helper Bot automatically reads the current model, datasets, and candidate tools configured in your left panel. You only need to describe your expectations in natural language (e.g., "I want an assistant that analyzes sales data uploaded in Excel and generates visual charts"), and the Helper Bot will automatically:
- **Generate and Refine Structured System Prompts**, setting reasonable boundaries and execution rules.
- **Recommend the Most Suitable Tools** or virtual machine commands for the task.
- **Suggest Relevant Datasets** to supplement domain background knowledge.
### 2. Automatic Apply
Once the Helper Bot generates the optimized scheme, the system will automatically write back the recommended prompts, tool list, associated datasets, and sandbox toggles to the left configuration panel. The entire process is fully automated without requiring manual clicks, streamlining the setup workflow.
---
## Chat Preview
The "Chat Preview" tab provides a real-time conversational testing environment, allowing you to interact with the Agent as a real user and verify the application's effectiveness before publishing.
### 1. General Debugging
Regardless of whether the virtual machine is enabled, you can use the following general debugging features:
![General Debugging](/imgs/agent_chat_debug.png)
- **Restart**: Click the "Restart" button in the upper right corner of the chat preview window to clear the current chat history and state, allowing you to start a fresh round of testing.
- **Bubble Action Bar and Runtime Details**: Below each generated AI chat bubble, a row of auxiliary debugging tools is provided:
- **Copy**: Click to copy the text content of this AI response.
- **Read Aloud**: Click to convert the response text into speech and play it back.
- **Mark**: Allows developers to mark the question and expected answer, saving it to a designated dataset to correct and guide the model's future responses.
- **Retry**: Triggers the AI to regenerate the response for the last user input.
- **Runtime Details**: Click to expand the tree-structured decision-making chain. This logs the complete LLM reasoning process, internal plan updates, and tool execution logs. It also shows the unique Request ID, model model, response duration, and precise points consumption for performance auditing and cost control.
- **Response Duration** (e.g., `34.07 s`): Displays the total time in seconds spent from sending the request to receiving the full response.
- **Plan Card**: If the Agent initiates planning for a complex task, the chat interface will stream a visual "Plan Card." Color-coded steps and animations indicate the progress of each step (In Progress, Completed, Pending, Blocked). If a step is blocked, the card displays the cause of the blockage, helping you optimize your System Prompt or troubleshoot tool configurations.
![Plan Card](/imgs/agent_plan_card.png)
### 2. Virtual Machine Debugging
For details on using the virtual machine sandbox for file management, dependency installations, and self-correcting debugging workflows, please refer to [Virtual Machine](./vm#virtual-machine-debugging).
---
title: 辅助生成与调试
description: 详细介绍如何通过 AI 助手辅助生成 Prompt,以及如何利用调试预览窗口、运行详情等功能测试与排查 Agent。
---
## 辅助生成
在“辅助生成”选项卡中,内置了针对 Agent V2 优化的 AI 协作助手。
![辅助生成小助手](/imgs/agent_helper_bot.png)
### 1. 智能分析与方案优化
AI 协作助手能够综合读取您当前在左侧面板配置的模型、知识库及候选工具。您只需在对话框中以自然语言输入您的期望(例如:“我想要一个能够分析用户上传的 Excel 销售数据并直接生成可视化图表的助理”),协作助手将自动:
- **润色并生成结构化的 System Prompt**,设定合理的约束与引导规则。
- **智能筛选并推荐**适合当前任务的外部工具(Tools)或虚拟机命令。
- **推荐适配的知识库**以补充背景知识。
### 2. 配置自动应用
当协作助手根据您的诉求生成最佳方案后,系统会自动将推荐的提示词、工具列表、关联的知识库以及虚拟机开关状态,直接同步并填充到左侧的配置面板中。整个过程完全自动化,无需任何手动点击与二次配置,极大地简化了应用搭建流程。
---
## 调试预览
“调试预览”选项卡提供了一个实时的应用对话测试环境,让您能够像真实用户一样与 Agent 进行交互,在发布前验证应用效果。
### 1. 通用调试
无论是否启用虚拟机,您都可以使用以下通用调试功能:
![通用调试](/imgs/agent_chat_debug.png)
- **重开对话**:点击调试窗口右上角的“重开对话”按钮,可以一键清空当前的聊天历史,方便您从头开始重新测试。
- **气泡操作栏与运行详情**:在每次对话生成的 AI 气泡下方,提供了一排辅助调试按钮:
- **复制**:点击可一键复制该条 AI 回复的文本内容。
- **朗读**:点击可将该条回复文本转化为语音进行播放。
- **标注**:允许开发者对该条对话的问答数据进行标注并保存至指定的知识库中,通过设定预期回答来纠错并引导模型在后续对话中给出更符合预期的回复。
- **重试**:让 AI 针对上一条用户输入重新生成一遍回复。
- **运行详情**:点击可展开树状的决策链路视图。这里完整记录了本次交互中 AI 的思考历程、内部参数动作和工具调用日志,并展示了单次请求的请求 Id、模型型号及精准的积分消耗,便于性能审计与成本核算。
- **响应耗时**(如 `34.07 s`):直观展示该次交互从发送请求到完全响应所耗费的秒数。
- **步骤规划卡片**:如果 AI 面对复杂任务启动了任务规划,对话中会实时流式渲染“计划步骤卡片”(Plan Card),并通过颜色(进行中、已完成、待处理、已阻塞)直观体现执行节点。若遇到阻塞,您可以根据卡片上的阻塞原因来优化您的 System Prompt 或排查工具配置。
![步骤规划卡片](/imgs/agent_plan_card.png)
### 2. 虚拟机调试
关于如何使用虚拟机进行调试、查看文件、文件注入以及运行状态查看等专属能力,请参考 [虚拟机](./vm#虚拟机调试)。
{
"title": "Chat Agent V2",
"root": false,
"pages": [
"settings",
"debug",
"vm",
"startup"
]
}
{
"title": "对话 Agent V2",
"root": false,
"pages": [
"settings",
"debug",
"vm",
"startup"
]
}
---
title: Configuration Panel
description: Detailed configuration guide for LLM parameters, virtual machine environment, and skill integration in Chat Agent V2.
---
The Configuration Panel is used to configure and bind all the core capabilities and execution environments required by your Agent.
![Configuration Panel](/imgs/agent_settings_panel.png)
---
## AI Configuration & Virtual Machine
- **AI Model**: Choose the dialogue model and configure parameters, for more general LLM parameters, refer to [AI Settings](../general/ai_settings).
- **Prompt**: Define the core persona, objectives, and specific rules for the Agent. The editor supports rich text, and you can type `@` to quickly reference and bind tools, etc.
| | |
| :---------------------------------------------------------: | :---------------------------------------------------------------: |
| ![`@` Tool Quick Binding](/imgs/agent_prompt_editor_at.png) | ![Rich Text Prompt Editor](/imgs/agent_prompt_editor_mention.png) |
- **Virtual Machine**: Once enabled, the system assigns a dedicated Linux sandbox environment for each session, supporting code execution, file operations, and startup command configuration. For architecture and debugging details, refer to [Virtual Machine](./vm). For startup script details and execution limits, see [Startup Script](./startup).
---
## Associate SKILL & Tools
- **Associated SKILLs**: Select and bind published SKILL packages from the skill library. The entrypoint script in the SKILL package will execute automatically when the VM spins up. If you associate SKILLs without enabling the VM sandbox, a warning "Virtual Machine Not Ready" will be displayed. To learn how to write and package custom SKILLs, please refer to [Development & Debugging](../skill/development).
![Virtual Machine Not Ready Warning](/imgs/agent_skill_vm_not_ready.png)
- **Tools**: You can choose to bind system built-in tools (e.g., search engines, charts), custom tools created by yourself or the team (including HTTP/MCP tools), or created applications.
![Tools](/imgs/agent_integrate_tools.png)
---
## Knowledge Base & File Uploads
- **Knowledge Base**: Associate specific corporate documents and adjust search settings (Hybrid Search, Re-ranking, etc.). It also supports configuring team member authorization permissions.
- **File Uploads**: Toggle file uploads for end-users, permitting images, audio, video, or custom file extensions. File upload capabilities automatically adapt based on the multimodal features of the selected LLM. For detailed configurations, see [File Input](../general/fileInput).
---
## Welcome Text & Voice Input
- **Welcome Text**: The initial welcome message sent automatically by the system before each conversation starts, supporting standard Markdown. It also supports `[Quick Question]` format, allowing users to send preset questions with a single click.
- **Voice Input**: Enables users to input voice messages which are automatically converted to text. Once enabled, you can configure **Auto Send** (automatically send the message after recording completes) and **Auto Voice Response** (the AI will reply in audio format directly, requiring voice playback to be enabled).
---
title: 配置面板
description: 详细介绍对话 Agent V2 编辑面板中 AI 核心参数、虚拟机环境与技能集成的配置指南。
---
配置面板用于调整和绑定您的 Agent 所需的所有核心能力与运行环境。
![配置面板](/imgs/agent_settings_panel.png)
---
## AI 配置与虚拟机
- **AI 模型**:选择对话大模型并配置参数,更多大模型通用配置选项请参考 [AI 配置说明](../general/ai_settings)。
- **提示词**:定义 Agent 的核心人设、工作目标和具体规则。编辑器支持富文本,且支持通过 `@` 快速唤起并绑定部分工具等。
| | |
| :----------------------------------------------------: | :--------------------------------------------------------: |
| ![提示词 @ 快速绑定](/imgs/agent_prompt_editor_at.png) | ![提示词富文本编辑](/imgs/agent_prompt_editor_mention.png) |
- **虚拟机**:开启后,系统会为每个独立会话在后台分配一个专属的 Linux 沙盒运行环境,支持执行代码、读写文件及配置启动脚本等。具体设计与联调操作请参考 [虚拟机](./vm),启动脚本去重与执行细节请参考 [启动脚本](./startup)。
---
## 关联 SKILL 与工具
- **关联 SKILL**:可以选择已发布的 SKILL 插件包,其自带的入口脚本会随虚拟机拉起自动执行。如果未开启虚拟机但关联了 SKILL,系统会展示“虚拟机未就绪”的警告。若需了解如何自定义编写与打包 SKILL,请参考 [开发与调试](../skill/development)。
![虚拟机未就绪警告](/imgs/agent_skill_vm_not_ready.png)
- **工具**:您可以选择绑定系统内置工具(如搜索引擎、图表绘制)、个人或团队创建的自定义工具(包括 HTTP/MCP 工具),或是创建的应用。
![工具](/imgs/agent_integrate_tools.png)
---
## 知识库与文件上传
- **关联知识库**:绑定企业专属知识文档,可精细调整搜索参数(混合搜索、重排等),并可配置团队成员身份验证权限。
- **文件上传配置**:控制前台用户在对话时是否可上传图片、音频、视频或特定扩展名文件。文件上传能力会根据所选大模型的多模态特性自动进行自适应调整。具体配置细节参考 [文件输入功能介绍](../general/fileInput)。
---
## 对话开场白与语音输入
- **对话开场白**:每次新对话开始前由系统自动发送的欢迎词,支持标准 Markdown 语法。支持使用 `[快捷问题]` 特殊格式,允许用户在界面上通过点击按钮直接发送预设的提问。
- **语音输入**:支持用户在前台对话中进行语音录入并自动识别转换为文字。开启后可进一步配置**自动发送**(语音录入完成后自动发出无需手动确认)和**自动语音回复**(语音发送后,AI 的回复也自动以语音播放响应,需开启语音播报)。
---
title: Startup Script
description: In-depth guide on the Agent V2 virtual machine startup script configuration, execution lifecycle, hash-based deduplication, and runtime execution limits.
---
import { Alert } from '@/components/docs/Alert';
When the **Computer** option is enabled, you can configure a **Startup Script** to automatically execute shell commands right after the sandbox environment spins up and before the AI workflow officially starts. This is commonly used for configuring environment variables, modifying software package sources, or installing Python packages (`pip`) and system-level utilities.
---
## Script Configuration & Lifecycle
Under the "Computer Configuration" section of the Agent Configuration Panel, you can write standard Shell commands directly inside the **Startup script (sh)** code editor.
![Startup Script Editor](/imgs/agent_startup_script_editor.png)
### Script Execution Sequence and Scope
When a new session starts or the virtual machine is reconstructed, the system executes the scripts sequentially in the background:
1. **Application Startup Script**: The custom Shell script configured in your Agent panel. It executes inside the virtual machine's working directory (usually `/workspace`) to prepare specific dependencies and runtime environments required by this application.
2. **Skill Entrypoint**: If your Agent is associated with skills, the [initialization entrypoint script](../skill/initialization) (e.g., `entrypoint.sh`) bundled inside the published skill package will be extracted and executed in the skill's deployment directory right after the application startup script completes.
<Alert icon="💡" context="warning">**Transactional Skill Deployment**: During skill deployment, packages are first extracted to a temporary folder (e.g., `.tmp-<versionId>-<random>`). Upon successful decompression, the folder is atomically renamed to the formal version directory to prevent corrupted partial extractions.</Alert>
### Lifecycle Flowchart
![Lifecycle Flowchart](/imgs/sandbox_lifecycle_flow_en.jpg)
---
## Status Deduplication
To prevent latency from running commands repeatedly during subsequent turns (such as reinstalling packages via `pip`), the system employs an efficient **status deduplication mechanism**:
- **Execution State Record**: The system maintains an execution state file inside the sandbox at `~/.fastgpt/agent-skill-entrypoints/state.json`.
- **Hash-based Deduplication (Application Startup Script)**: For your custom "Startup script (sh)", the system computes a **SHA-256 hash value** based on the script text and compares it with the executed hashes in `state.json`. If the script remains unmodified, the system **automatically skips execution** on subsequent requests, ensuring fast starts. The script will only run again if you edit its content or click "Clear Chat" to completely rebuild the sandbox.
- **Version ID-based Deduplication (Skill Entrypoint)**: Associated skills are deduplicated using their immutable skill **Version ID**. Since published skill versions are read-only, the entrypoint script executes only once during the sandbox's cold start as long as the bound version remains unchanged.
- **State Lifecycle**: The deduplication state is managed along with the virtual machine instance. When the virtual machine is rebuilt (due to clicking "Clear Chat" or system reclamation), a fresh environment is allocated, and all scripts will run again during the next cold start.
---
## Execution Constraints & Fault Tolerance
To ensure sandbox stability and responsiveness, the startup script is subject to the following system rules:
- **Character Length Limit**: Due to front-end validation and input constraints, the startup script supports a maximum of **16,384 characters (approx. 16KB)**. Any script exceeding this limit is truncated on save. For complex initialization logic, write it inside a separate skill entrypoint or fetch and execute remote scripts.
- **Timeout Protection**: Script execution is protected by a timeout limit controlled by the environment variable `AGENT_SANDBOX_ENTRYPOINT_TIMEOUT_SECONDS`, with a **default timeout of 30 seconds** (clamped between 1 and 600 seconds). The process is forcefully terminated if execution exceeds this duration.
- **Non-blocking Workflow**: If your startup script errors out (exits with a non-zero code), times out, or fails to read/write the state file, the system **will not block** the main chat workflow. The AI continues executing subsequent workflow nodes or tools, though it may hit runtime exceptions later if critical dependencies are missing. You can troubleshoot these errors in the preview logs or through the Computer File Manager.
- **Log Truncation**: Combined standard output (stdout) and standard error (stderr) logs for the startup script are capped at approximately **8KB**. When logged to the system, output is truncated to 4,000 characters to prevent excessive resource utilization.
---
## Technical Solution Details
To ensure virtual machine environment initialization is robust under complex concurrency, tight latency budgets, and low-code orchestrations, the platform implements the following core designs:
### VM Initialization Flowchart (Technical Details)
![VM Startup and Script Initialization Flow](/imgs/sandbox_startup_flow_en.jpg)
### 1. Redis Lease Distributed Lock
When facing concurrent chat requests or multiple processes cold-starting a virtual machine, having several processes simultaneously write files, decompress skill packages, or run startup scripts can lead to lock conflicts or container filesystem corruption.
- **Locking Mechanism**: The backend uses an exclusive Redis-based lease lock to guard initialization, with the lock key formatted as `agent-sandbox:init:${sandboxId}`.
- **Renewal and Conflict Avoidance**: The initialization lease lock has a default TTL of 3 minutes. During execution, a background timer automatically invokes a secure Lua heartbeat script every 30 seconds (1/6 of the lease duration) to renew the lease.
- **Initialization Error Handling**: If a concurrent request fails to acquire the lease lock, the backend catches the Redis lease conflict and immediately throws an `AgentSandboxInitializingError`, effectively preventing deadlocks, split-brain states, or concurrent filesystem corruptions.
### 2. State Reconcile & Delta Sync
Before a session becomes ready, the system reconciles the virtual machine state with the expected skill deployments:
- **Delta Synchronization & Cleanups**: In each run, the system scans directories inside the VM, compares them with the target skill version list, fetches only delta packages, and performs a differential `rm -rf` cleanup on decommissioned versions.
- **Deduplication State Reconciliation**: After reading the `state.json` file inside the sandbox, the system filters the executed skill ID list against the current session's active skill set. It automatically removes any obsolete skill version IDs no longer in use (marking it as dirty and rewrites the state), keeping `state.json` clean.
- **Atomic Extraction**: To protect the environment against Zip Bomb attacks or incomplete unpacking, skill packages are extracted and validated in a temporary workspace directory (e.g., `.tmp-<versionId>-<random>`). Upon complete successful extraction, it is atomically renamed to the formal version directory using a single `mv` command, hiding any intermediate corrupted state.
### 3. Deduplication, Secure Run & HOME Path
The execution state `state.json` is preserved inside the user's HOME directory (`~/.fastgpt/agent-skill-entrypoints/state.json`) inside the container.
- **Dynamic HOME Path Resolution**: The HOME path of the container is resolved dynamically. The system first executes `printf "%s" "$HOME"` to check the env variable. If that fails, it falls back to `sh -c "echo ~"`. This guarantees the state file is always generated under the correct home directory path.
- **Base64 Secure Run (Application Script)**: To prevent script escaping, line break issues, or syntax corruption caused by special characters, the custom startup script is encoded in Base64 on the server and safely decoded in the sandbox using `printf %s '<base64>' | base64 -d | /bin/bash`. If a `workDirectory` is set, it will `cd` into that workspace first.
- **Instant Step-by-Step State Saves (Skill Script)**: During execution of multiple skill entrypoints, `state.json` is **instantly updated and saved** right after each `entrypoint.sh` runs successfully. Even if a subsequent script fails or times out, the successful states of previously run scripts are locked in and skipped on the next run.
- **Bidirectional Output Truncation**: Both stdout and stderr of the scripts are piped and truncated independently to a maximum of 8KB using `> >(tail -c 8192) 2> >(tail -c 8192 >&2)`. This protects log storage and network bandwidth while keeping standard outputs separate from error traces.
### 4. Concurrency Lock Isolation
To prevent the Agent decision loop and associated external tools (ToolCall) from repeatedly reconnecting or spinning up multiple VM clients during a single turns:
- **Shared SandboxClient**: The system stores and propagates the active `SandboxClient` instance across the execution dispatch layers, ensuring all steps in a single chat turn share a single virtual machine connection context.
---
title: 启动脚本
description: 深入理解 Agent V2 虚拟机的启动脚本配置、执行生命周期、哈希去重与运行环境限制。
---
import { Alert } from '@/components/docs/Alert';
在启用虚拟机后,您可以通过配置 **启动脚本**,在沙盒环境拉起后、AI 工作流正式开始执行前,自动执行指定的 Shell 命令。这通常用于配置环境变量、更换软件源、安装 Python 依赖(pip)或系统级工具等。
---
## 脚本配置与生命周期
在 Agent 配置面板的“虚拟机配置”中,您可以直接在 **启动脚本(sh)** 的代码编辑器中编写您的 Shell 脚本。
![启动脚本编辑器](/imgs/agent_startup_script_editor.png)
### 脚本执行顺序与范围
当一个新会话启动或虚拟机重新拉起时,系统会按顺序在后台执行相应的脚本:
1. **应用启动脚本**:即您在配置面板中自定义的 Shell 脚本。该脚本会在虚拟机的工作目录(通常为 `/workspace`)下执行,用于准备当前应用所需的特定运行依赖和环境。
2. **技能入口脚本(Skill Entrypoint)**:若您的 Agent 关联了技能(Skills),技能发布包中自带的[初始化脚本](../skill/initialization)(如 `entrypoint.sh`)会在上述“应用启动脚本”执行完毕后,在每个技能包自身的部署目录下自动执行。
<Alert icon="💡" context="warning">**技能包部署的原子性保障**:技能包在解压部署时,会先在临时目录(如 `.tmp-<versionId>-<random>`)中解压,解压完全成功后,再以原子操作整体替换为正式版本目录,有效避免解压失败导致出现损坏的半截目录。</Alert>
### 生命周期流程图
![生命周期流程图](/imgs/sandbox_lifecycle_flow_zh.jpg)
---
## 状态去重
为了避免每次对话交互(热启动)时重复执行命令(例如重复通过 `pip install` 安装依赖包)带来等待延迟,系统设计了高效的 **状态去重机制**:
- **运行状态记录**:系统在虚拟机内部维护了一个状态文件:`~/.fastgpt/agent-skill-entrypoints/state.json`。
- **哈希去重(应用启动脚本)**:对于您手动编写的“应用启动脚本”,系统会计算其文本内容的 **SHA-256 哈希特征值(Hash)**,并与 `state.json` 中已执行过的哈希值进行对比。如果脚本内容没有任何修改,系统在后续交互中将 **自动跳过执行**,确保实现快速启动;仅当您修改了脚本内容,或在调试预览中点击了“重开对话”触发沙盒彻底重建时,脚本才会重新执行。
- **版本 ID 去重(技能入口脚本)**:关联技能对应的入口脚本则基于 **技能版本 ID** 进行比对去重。因为发布的技能版本是不可变的,只要绑定的技能版本未改变,其入口脚本也仅会在沙箱首次冷启动时执行一次。
- **状态的生命周期**:去重状态随虚拟机实例生命周期进行管理。当虚拟机重建(点击“重开对话”或闲置被系统回收)时,由于分配的是全新环境,所有的脚本都将在首次冷启动时重新执行。
---
## 执行限制与容错机制
为保障沙箱的稳定运行与响应时效,虚拟机启动脚本在运行时受以下系统规则约束:
- **字符长度限制**:由于前端校验与输入限制,虚拟机启动脚本最大支持 **16,384 个字符(约 16KB)**。超出此长度的脚本在保存时会被自动截断。对于复杂的初始化逻辑,建议编写在单独的技能入口脚本中,或在启动脚本中拉取远程脚本执行。
- **超时终止限制**:脚本执行存在超时保护限制,由系统环境变量 `AGENT_SANDBOX_ENTRYPOINT_TIMEOUT_SECONDS` 控制,**默认超时时间为 30 秒**(限制在 1 秒到 600 秒之间)。如果超过该时间脚本仍未执行完毕,系统将强制终止该进程。
- **非阻塞主流程**:即使您的启动脚本在执行时报错(退出状态码非 0)、超时终止,或是状态文件读写发生异常,系统也**不会阻断主对话流程**。AI 依然会继续执行后续的工作流或工具调用,但可能会因缺少特定依赖而在代码运行时抛出异常。您可以在调试预览的日志或虚拟机文件管理器中排查此类问题。
- **日志长度截断**:启动脚本标准输出(stdout)和标准错误(stderr)的最大日志输出量限制在 **8KB** 左右。在系统记录日志时,日志内容将被截断至 4,000 个字符,以防止过大的日志输出占用过多的系统与网络资源。
---
## 技术方案说明
为确保虚拟机环境初始化在复杂并发、高时效要求与低代码编排场景下的健壮性,系统在底层采用了以下核心设计:
### 虚拟机初始化流程图(技术细节)
![虚拟机启动与初始化流程](/imgs/sandbox_startup_flow_zh.jpg)
### 1. Redis Lease 分布式锁保障
当面临并发对话请求或多进程冷启动虚拟机时,如果多个进程同时触发文件注入、技能解压或运行启动脚本,可能导致文件锁交错冲突甚至容器文件损坏。
- **锁机制**:系统在后端服务端基于 Redis 独占式获取并维护租约锁,锁的 Key 格式为 `agent-sandbox:init:${sandboxId}`。
- **续期与防碰撞**:初始化租约锁的 TTL 默认为 3 分钟,在执行期间,系统通过后台定时器每 30 秒(租约周期的 1/6)自动调用专用的心跳 Lua 脚本进行安全续期。
- **初始化独占报错**:一旦并发请求获取锁失败并捕获为 Redis 租约冲突,系统将立即抛出 `AgentSandboxInitializingError`(沙箱初始化中)异常,有效防止死锁、脑裂或文件错乱冲突。
### 2. 状态调和与失效清理
在每次会话就绪前,系统会对沙箱内部的状态和部署的技能包进行“状态调和”:
- **已部署包差量同步**:系统会在虚拟机内列出已部署目录,并与当前版本关联的技能列表比对。只下载增量缺少的技能包,并用 `rm -rf` 差量清理掉已废弃的技能版本。
- **去重状态调和(Reconciliation)**:系统在读取虚拟机内部的 `state.json` 后,会将其中记录的已执行技能 ID 列表与当前会话实际使用的技能 ID 集合进行交集过滤,自动剔除所有已经不再被当前会话选用的旧技能版本 ID(标记为 Dirty 并重写),确保去重状态文件的纯净性。
- **解压与原子替换**:为了防止 Zip 炸弹攻击及解压异常损坏原本正常的部署,技能包会优先在临时目录(如 `.tmp-<versionId>-<random>`)中完成解压和完整性验证。当解压步骤完全成功后,再以原子操作 `mv` 整体重命名为正式版本目录,从而屏蔽中间不确定状态。
### 3. 去重、安全执行与 Home 路径解析
状态记录文件 `state.json` 保存在虚拟机容器的用户 HOME 目录(即 `~/.fastgpt/agent-skill-entrypoints/state.json`)中。
- **动态解析 HOME 路径**:虚拟机容器的 HOME 目录非硬编码写入,而是优先通过执行 `printf "%s" "$HOME"` 动态尝试解析;若读取失败则以 `sh -c "echo ~"` 作为备用方案,以此保证状态文件总是被正确存放在当前用户的 home 目录下。
- **Base64 防逃逸执行(应用脚本)**:为避免直接拼接 Shell 文本由于特殊字符、换行或非法字符导致语法解析错乱或注入风险,您配置的应用启动脚本会整体进行 base64 编码,并通过 `printf %s '<base64>' | base64 -d | /bin/bash` 的方式在虚拟机中安全解码运行。若配置了 `workDirectory`,则会先 `cd` 切换到指定工作区。
- **单步即时保存(技能脚本)**:在遍历执行多个关联技能的 `entrypoint.sh` 时,系统每成功完成一个技能的入口脚本执行,便会**即时更新并保存**一次 `state.json`。这样即使中途因后续某个技能超时或报错而中断,先前已成功初始化的技能状态也已被固化,下次对话将自动跳过它们,实现最大化复用。
- **日志标准输出与标准错误双向截断**:脚本执行时,系统通过 `> >(tail -c 8192) 2> >(tail -c 8192 >&2)` 管道命令,将 stdout 和 stderr 独立限制在最大 8KB。既保护了整体日志吞吐量,又完全分离了常规输出与错误堆栈。
### 4. 并发锁隔离与客户端共享
为了避免多轮对话的 Agent 决策环(AI 思考)与外部工具(ToolCall)重复建立沙盒连接或分配不同实例:
- **共享 SandboxClient**:系统将当前会话分配的 `SandboxClient` 作为局部上下文在执行调度层进行跨模块透传与隔离,保证同一会话内多轮连续交互及工具调用共享并共用同一个虚拟机客户端实例。
---
title: Virtual Machine
description: Understand the core concepts, runtime design, and debugging workflow for the Agent V2 Virtual Machine (Computer Sandbox).
---
![Virtual Machine Sandbox](/imgs/agent_vm_intro.png)
In FastGPT Agent V2, the **Virtual Machine** is a dedicated, physically isolated, and secure lightweight Linux running sandbox environment provisioned for each chat session. It equips the Agent with real-world computation, code execution, and file read/write capabilities, allowing the AI to not only "think" but also execute code to solve complex tasks like a human programmer.
---
## What is Virtual Machine
When you toggle the "Enable Computer" option, the system dynamically provisions and binds a dedicated sandbox container for each individual chat session in the background.
![Enable Computer](/imgs/agent_vm_enable.png)
With the virtual machine, the Agent can:
- **Execute Dynamic Code**: Run Python, Node.js, or Shell scripts via the code executor to perform complex calculations and data manipulations.
- **Read and Write Local Files**: Create, modify, and read files in the isolated `/workspace` directory, including generating charts or processing uploaded CSV/Excel sheets.
- **Customize the Environment & Startup Script**: Dynamically customize the runtime environment by binding SKILL packages, or configuring custom [Startup Scripts](./startup) (which automatically execute specified Shell initialization commands, such as installing dependencies or setting environment variables, after the VM spins up but before the AI workflow starts).
---
## Virtual Machine Design
To balance security, latency, and resource footprint across high-concurrency and multi-tenant environments, the system features the following core designs:
### 1. Session-Level Isolation and Lifecycle Management
- The virtual machine is tightly coupled with the user's chat session. Different users and sessions run in entirely isolated environments.
- The system utilizes a keepalive mechanism to sustain active containers. When a session remains idle for too long (exceeding the configurable timeout, typically a few minutes), the container is automatically collected and destroyed to release host resources.
### 2. Session Persistence
The virtual machine remains active throughout the duration of a chat session. Dependencies downloaded, temporary files written, or environment variables set in the previous turns remain accessible in subsequent turns.
### 3. Security Constraints & Escape Prevention
- Strict resource quotas (CPU, Memory, Disk IO) and network firewall policies are applied to the sandbox to prevent malicious resource exhaustion or internal network access.
- All executed commands are encoded in Base64 and decoded securely inside the container to prevent Command Injection risks from string concatenations.
### 4. Instant Sandbox Reconstruction
Clicking "Restart" during testing will thoroughly destroy the current virtual machine. The next request will provision and initialize a clean, brand-new container to prevent historical files from contaminating the session.
---
## Virtual Machine Debugging
When you enable the **Computer** option in the left configuration panel, the Chat Preview window will automatically unlock the following sandbox-exclusive debugging features:
### Virtual Machine File Manager
Shortcut entries to the VM file manager are provided at the top of the chat window and below chat bubbles that involve VM operations. Clicking them pops up a modal to browse, edit, upload, or download files (such as charts, code, and HTML previews) inside the container, achieving a closed debugging loop.
| | |
| :------------------------------------------------------------: | :--------------------------------------------------------------: |
| ![Virtual Machine File Bubble](/imgs/agent_vm_file_bubble.png) | ![Virtual Machine File Manager](/imgs/agent_vm_file_manager.png) |
### Automatic Dialog File Injection
Any files you upload via the chat input box (such as CSV or Excel sheets) are automatically downloaded and written into the VM's `user_files/` directory before execution, allowing the AI to read and process them as local files via code.
### Code Execution and Self-Correction
The virtual machine provides a real execution environment. If code fails due to missing dependencies or syntax errors, the error logs are real-time fed back to the LLM, enabling the AI to self-correct and re-run within multi-turn planning.
### Pre-emptive Initialization and State Persistence
With the Computer option enabled, the system automatically spins up and prepares the sandbox environment before each chat session starts. All files, dependencies, and execution states are persisted across chat steps to support continuous multi-turn debugging.
### Real-time Startup Status Tracking
During testing, the chat bubble header streams real-time VM provisioning updates, allowing you to audit the startup progress and duration.
### Complete Sandbox Reconstruction upon Reset
Clicking the "Restart" button resets the test session, and the next interaction will provision and initialize a clean, brand-new VM container to prevent historical file contamination.
---
title: 虚拟机
description: 深入了解 Agent V2 虚拟机(沙箱)的核心概念、运行设计与联调指南。
---
![虚拟机运行环境](/imgs/agent_vm_intro.png)
在 FastGPT Agent V2 中,**虚拟机** 是专为每个会话分配的、物理隔离且安全的轻量级 Linux 运行沙盒环境。它为 Agent 提供了真实的计算、代码执行和文件读写操作能力,使得 AI 不仅仅能“思考”,还能像人类程序员一样通过实际运行代码来解决复杂任务。
---
## 什么是虚拟机
每当用户开启“启用虚拟机”选项,系统都会在后台为每一个对话会话(Session)动态预置并绑定一个专用的沙箱容器。
![启用虚拟机](/imgs/agent_vm_enable.png)
有了虚拟机,Agent 可以:
- **执行动态代码**:通过代码执行器运行 Python、Node.js 甚至 Shell 脚本,自主进行复杂计算或数据处理。
- **读写本地文件**:在独立的 `/workspace` 目录下创建、修改和读取文件,包括生成图表、处理上传的 CSV/Excel 电子表格等。
- **环境自定义与启动脚本**:通过关联 SKILL 包,或配置自定义的 [启动脚本](./startup)(在虚拟机拉起后且 AI 正式开始前自动在后台执行的 Shell 命令,用于安装特定软件源、Python 依赖包或系统级工具等),动态准备专属于您应用的运行环境。
---
## 虚拟机设计
为了在多并发和多租户场景下兼顾安全性、响应速度与资源消耗,系统在底层采用了以下核心设计:
### 1. 会话级隔离与生命周期管理
- 虚拟机与用户的对话会话(Session)强绑定。不同用户、不同会话之间的运行环境完全物理隔离。
- 系统通过心跳(Keepalive)机制维持活动容器的存活。当会话长时间闲置(如超过数分钟无新请求)时,系统会自动回收并销毁该虚拟机实例以释放服务器资源。
### 2. 状态存续(Session Persistence)
在同一个会话的生命周期内,虚拟机是持续存活的。这意味着上一轮对话中下载的依赖、写入的临时文件以及配置的环境变量,在下一轮对话中依然有效,支持多轮交互的连续性。
### 3. 安全防逃逸与限制
- 沙盒环境采用了严格的资源配额限制(CPU、内存、磁盘 IO)和网络防火墙策略,防止恶意脚本消耗宿主机资源或访问内部网络。
- 所有执行的命令均通过 base64 编码在容器内安全解码运行,规避了 Shell 拼接造成的命令注入风险。
### 4. 一键快速重建
当用户在调试中点击“重开对话”或重新建立会话时,系统将彻底销毁当前的旧虚拟机,并为下一次请求分配一个纯净、全新的隔离容器,确保环境干净不被污染。
---
## 虚拟机调试
当您在左侧配置面板中**启用了虚拟机**时,调试预览窗口将自动激活以下沙盒专属调试能力:
### 虚拟机文件管理器
调试窗口顶部以及涉及虚拟机操作的对话气泡下方会提供“虚拟机文件管理器”入口。点击可弹出管理器弹窗,实时浏览、编辑、上传或下载容器内的文件(如图表、临时代码、HTML 预览等),实现调试闭环。
| | |
| :-----------------------------------------------: | :--------------------------------------------------: |
| ![虚拟机文件气泡](/imgs/agent_vm_file_bubble.png) | ![虚拟机文件管理器](/imgs/agent_vm_file_manager.png) |
### 对话上传文件自动注入
对话输入框中上传的所有测试文件,会在对话开始前自动同步注入到虚拟机的 `user_files/` 目录下,使得 AI 可以通过代码以本地路径直接读取和处理这些文件。
### 代码执行与自我纠错
虚拟机提供了真实的代码运行环境。若代码由于依赖缺失或语法错误执行失败,报错信息会实时回传给大模型,AI 能够在多步规划中尝试自我修正并重新运行,实现闭环纠错调试。
### 前置拉起与状态存续
开启虚拟机后,系统会在每次对话启动前自动前置拉起并准备好沙盒环境。环境内的文件、依赖和运行状态在当前会话内跨步骤持久保留,支持连续的上下文联调。
### 启动状态实时展示
在对话调试过程中,气泡上方会流式展示虚拟机的创建与启动状态,方便感知沙箱所处阶段与启动耗时。
### 重置对话重建沙箱
点击“重开对话”按钮重置测试时,下一次交互会重新拉起并初始化一个干净、全新的虚拟机容器,避免历史测试生成的文件污染新一轮的调试。
......@@ -3,6 +3,7 @@
"root": false,
"pages": [
"general",
"agentv2",
"workflow",
"tools",
"skill",
......
......@@ -3,6 +3,7 @@
"root": false,
"pages": [
"general",
"agentv2",
"workflow",
"tools",
"skill",
......
---
title: Initialization Script
description: Learn how to configure and execute initialization scripts in skill packages to prepare the skill running environment.
---
The skill initialization script is an optional pre-execution script provided by the skill developer. After the system successfully deploys and extracts your skill in an application, it automatically runs this script in an isolated virtual machine before executing the actual AI tasks.
Through the initialization script, you can automatically install third-party dependencies or perform necessary configurations before the skill code runs.
---
## 1. Skill Script Configuration and Execution Timing
To add an initialization script to your skill, simply place a Shell script named `entrypoint.sh` in the root directory of your skill package.
![Skill Initialization Script Example](/imgs/skill_initialization_entrypoint.png)
### Execution Timing
1. **Deployment and Extraction**: When a user runs an application referencing the skill, the system first deploys and extracts the skill package to the virtual machine at `./projects/<versionId>/`.
2. **Script Execution**: The system runs the `entrypoint.sh` script located in the root of the skill directory.
---
## 2. Smart Deduplication Mechanism
To prevent running environment initialization scripts repeatedly in subsequent conversations (e.g., executing dependency installation packages on every turn would cause severe latency), FastGPT designs a deduplication mechanism for skill scripts.
The execution state is stored in the `~/.fastgpt/agent-skill-entrypoints/state.json` file inside the virtual machine.
- **Version ID Deduplication**: Since the code and script content of a specific skill version (`versionId`) are immutable once published, the system tracks the successfully executed `versionId` inside the virtual machine.
- **Skipped Execution**: When the same virtual machine instance is reused in subsequent turns, if the corresponding `versionId` has already executed successfully, the system will **skip** running the script, enabling hot starts.
- **New Version Trigger**: Whenever a new skill version is published, the system will automatically deploy and run its initialization script upon the next conversation, regardless of whether it is an existing (old) or a new chat window.
---
## 3. Execution Constraints and Fault Tolerance
To ensure the smooth execution of the AI workflow, the skill initialization script must adhere to the same execution constraints and fault tolerance rules as the application startup script:
- **Execution Constraints and Non-blocking Fault Tolerance**: The timeout protection (default 30 seconds), non-blocking workflow (failures do not block main execution), and 8KB log truncation limits are identical to those of the application startup script. For detailed parameters, please refer to [Application Startup Script Execution Constraints](../agentv2/startup#execution-constraints--fault-tolerance).
- **Debug Mode Limitation**: In the skill edit mode, the virtual machine will not automatically execute the `entrypoint.sh` script. To verify the script's behavior, the skill developer can manually execute the commands inside the Workspace Terminal.
---
title: 初始化脚本
description: 了解如何在技能包中配置和执行初始化脚本,准备技能运行环境。
---
技能初始化脚本是技能开发者提供的一个前置脚本。当应用成功部署并解压了您开发的技能后,系统在实际执行 AI 任务前,会在独立的虚拟机环境中自动运行该脚本。
通过初始化脚本,您可以在技能代码执行前,自动安装技能特有的第三方依赖,或进行必要的配置预处理。
---
## 1. 技能脚本配置与执行时机
要为您的技能添加初始化脚本,只需在技能压缩包的根目录下放置一个名为 `entrypoint.sh` 的 Shell 脚本。
![技能初始化脚本示例](/imgs/skill_initialization_entrypoint.png)
### 执行时机
1. **技能包部署与解压**:当用户运行引用了该技能的应用时,系统会首先将技能包部署并解压到虚拟机的 `./projects/<versionId>/` 目录下。
2. **执行初始化脚本**:系统会在虚拟机中执行该技能根目录下的 `entrypoint.sh` 脚本。
---
## 2. 智能去重机制
为了避免在多次对话中重复运行环境初始化脚本(例如重复执行依赖包安装会导致每次对话产生严重的延迟),系统为技能脚本设计了去重机制。
去重状态记录在虚拟机内的 `~/.fastgpt/agent-skill-entrypoints/state.json` 状态文件中。
- **版本 ID 去重**:由于同一个技能版本(`versionId`)的代码和脚本内容在发布后是不可变的,系统会记录当前虚拟机中已成功运行过的技能 `versionId`。
- **跳过执行**:当同一个虚拟机实例在后续对话中被复用时,只要对应的 `versionId` 已经成功执行过,系统就会**直接跳过**该脚本的运行,实现秒级热启动。
- **新版本触发**:只要技能发布了新版本,无论是在旧的对话窗口还是新的对话窗口,在下一次对话触发时,系统都将在重新部署该技能后自动运行该版本的初始化脚本。
---
## 3. 执行约束与容错机制
为保障 AI 流程的流畅运行,技能初始化脚本需要遵循与应用启动脚本一致的执行限制与容错规则:
- **执行约束与非阻断容错**:技能入口脚本的超时时间限制(默认 30 秒)、非阻塞设计(执行报错或超时不阻断主流程)以及 8KB 日志输出截断规则,均与应用启动脚本保持一致。具体细节指标请参考 [应用启动脚本的执行限制](../agentv2/startup#执行限制与容错机制)。
- **调试预览限制**:在技能的编辑模式下,虚拟机不会自动执行技能的 `entrypoint.sh` 脚本。如果需要验证脚本效果,技能开发者可以直接在侧边栏调试区的控制台终端(Workspace Terminal)中手动执行相关命令。
......@@ -4,6 +4,7 @@
"pages": [
"intro",
"development",
"initialization",
"version",
"integration"
]
......
......@@ -4,6 +4,7 @@
"pages": [
"intro",
"development",
"initialization",
"version",
"integration"
]
......
......@@ -6,6 +6,10 @@ description: FastGPT Toc
- [/en/faq/chat](/en/faq/chat)
- [/en/guide/admin/sso](/en/guide/admin/sso)
- [/en/guide/admin/teamMode](/en/guide/admin/teamMode)
- [/en/guide/build/agentv2/debug](/en/guide/build/agentv2/debug)
- [/en/guide/build/agentv2/settings](/en/guide/build/agentv2/settings)
- [/en/guide/build/agentv2/startup](/en/guide/build/agentv2/startup)
- [/en/guide/build/agentv2/vm](/en/guide/build/agentv2/vm)
- [/en/guide/build/evaluation](/en/guide/build/evaluation)
- [/en/guide/build/faq](/en/guide/build/faq)
- [/en/guide/build/general/ai_settings](/en/guide/build/general/ai_settings)
......@@ -20,6 +24,7 @@ description: FastGPT Toc
- [/en/guide/build/publish/wechat](/en/guide/build/publish/wechat)
- [/en/guide/build/publish/wecom](/en/guide/build/publish/wecom)
- [/en/guide/build/skill/development](/en/guide/build/skill/development)
- [/en/guide/build/skill/initialization](/en/guide/build/skill/initialization)
- [/en/guide/build/skill/integration](/en/guide/build/skill/integration)
- [/en/guide/build/skill/intro](/en/guide/build/skill/intro)
- [/en/guide/build/skill/version](/en/guide/build/skill/version)
......
......@@ -6,6 +6,10 @@ description: FastGPT 文档目录
- [/faq/chat](/faq/chat)
- [/guide/admin/sso](/guide/admin/sso)
- [/guide/admin/teamMode](/guide/admin/teamMode)
- [/guide/build/agentv2/debug](/guide/build/agentv2/debug)
- [/guide/build/agentv2/settings](/guide/build/agentv2/settings)
- [/guide/build/agentv2/startup](/guide/build/agentv2/startup)
- [/guide/build/agentv2/vm](/guide/build/agentv2/vm)
- [/guide/build/evaluation](/guide/build/evaluation)
- [/guide/build/faq](/guide/build/faq)
- [/guide/build/general/ai_settings](/guide/build/general/ai_settings)
......@@ -20,6 +24,7 @@ description: FastGPT 文档目录
- [/guide/build/publish/wechat](/guide/build/publish/wechat)
- [/guide/build/publish/wecom](/guide/build/publish/wecom)
- [/guide/build/skill/development](/guide/build/skill/development)
- [/guide/build/skill/initialization](/guide/build/skill/initialization)
- [/guide/build/skill/integration](/guide/build/skill/integration)
- [/guide/build/skill/intro](/guide/build/skill/intro)
- [/guide/build/skill/version](/guide/build/skill/version)
......
......@@ -7,6 +7,12 @@
"content/guide/admin/sso.mdx": "2026-06-02T16:55:40+08:00",
"content/guide/admin/teamMode.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/admin/teamMode.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/agentv2/debug.en.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/agentv2/debug.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/agentv2/settings.en.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/agentv2/settings.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/agentv2/startup.en.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/agentv2/startup.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/evaluation.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/evaluation.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/faq.en.mdx": "2026-06-04T16:10:15+08:00",
......@@ -27,14 +33,16 @@
"content/guide/build/publish/mcp_server.mdx": "2026-06-22T11:01:59+08:00",
"content/guide/build/publish/official_account.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/publish/official_account.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/publish/openapi.en.mdx": "2026-06-23T11:27:33+08:00",
"content/guide/build/publish/openapi.mdx": "2026-06-23T11:27:33+08:00",
"content/guide/build/publish/openapi.en.mdx": "2026-06-23T13:54:06+08:00",
"content/guide/build/publish/openapi.mdx": "2026-06-23T13:54:06+08:00",
"content/guide/build/publish/wechat.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/publish/wechat.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/publish/wecom.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/publish/wecom.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/skill/development.en.mdx": "2026-06-16T14:52:43+08:00",
"content/guide/build/skill/development.mdx": "2026-06-16T14:52:43+08:00",
"content/guide/build/skill/initialization.en.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/skill/initialization.mdx": "2026-06-23T18:38:31+08:00",
"content/guide/build/skill/integration.en.mdx": "2026-06-16T14:52:43+08:00",
"content/guide/build/skill/integration.mdx": "2026-06-16T14:52:43+08:00",
"content/guide/build/skill/intro.en.mdx": "2026-06-16T14:52:43+08:00",
......@@ -139,14 +147,14 @@
"content/guide/workspace/team/team_roles_permissions.mdx": "2026-05-07T15:06:40+08:00",
"content/openapi/app.en.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/app.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/chat.en.mdx": "2026-06-23T11:27:33+08:00",
"content/openapi/chat.mdx": "2026-06-23T12:10:31+08:00",
"content/openapi/chat.en.mdx": "2026-06-23T13:54:06+08:00",
"content/openapi/chat.mdx": "2026-06-23T13:54:06+08:00",
"content/openapi/dataset.en.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/dataset.mdx": "2026-05-29T19:31:16+08:00",
"content/openapi/index.en.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/index.mdx": "2026-04-26T21:08:47+08:00",
"content/openapi/intro.en.mdx": "2026-06-23T11:27:33+08:00",
"content/openapi/intro.mdx": "2026-06-23T12:10:31+08:00",
"content/openapi/intro.en.mdx": "2026-06-23T13:54:06+08:00",
"content/openapi/intro.mdx": "2026-06-23T13:54:06+08:00",
"content/plugin/index.en.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/index.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/intro.en.mdx": "2026-06-09T16:03:58+08:00",
......@@ -155,8 +163,8 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/system-tool-development.mdx": "2026-06-09T16:03:58+08:00",
"content/self-host/config/env.en.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/env.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/env.en.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/config/env.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/config/json.en.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/json.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
......@@ -277,17 +285,17 @@
"content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-15/41500.en.mdx": "2026-06-23T11:27:33+08:00",
"content/self-host/upgrading/4-15/41500.mdx": "2026-06-23T11:27:33+08:00",
"content/self-host/upgrading/4-15/41500.en.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/upgrading/4-15/41500.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/upgrading/4-15/41501.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-06-22T22:05:59+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-10T19:02:59+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-15T23:34:43+08:00",
"content/self-host/upgrading/4-15/41505.en.mdx": "2026-06-12T20:47:04+08:00",
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-23T11:27:33+08:00",
"content/self-host/upgrading/4-15/41505.en.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -355,7 +363,7 @@
"content/self-host/upgrading/outdated/4810.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4810.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4811.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4811.mdx": "2026-06-22T22:05:59+08:00",
"content/self-host/upgrading/outdated/4811.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/upgrading/outdated/4812.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4812.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4813.en.mdx": "2026-05-07T15:06:40+08:00",
......@@ -428,6 +436,6 @@
"content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00",
"content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
"content/toc.en.mdx": "2026-06-22T12:20:51+08:00",
"content/toc.mdx": "2026-06-22T12:20:51+08:00"
"content/toc.en.mdx": "2026-06-23T18:38:31+08:00",
"content/toc.mdx": "2026-06-23T18:38:31+08:00"
}
\ No newline at end of file
......@@ -5,13 +5,19 @@ import type { ErrType } from '../errorCode';
const startCode = 510000;
export enum SandboxErrEnum {
agentSandboxPermissionDenied = 'agentSandboxPermissionDenied'
agentSandboxPermissionDenied = 'agentSandboxPermissionDenied',
agentSandboxInitializing = 'agentSandboxInitializing'
}
const sandboxErr = [
{
statusText: SandboxErrEnum.agentSandboxPermissionDenied,
message: i18nT('common:code_error.sandbox_error.agent_sandbox_permission_denied')
},
{
statusText: SandboxErrEnum.agentSandboxInitializing,
message: i18nT('common:code_error.sandbox_error.agent_sandbox_initializing'),
httpStatus: 409
}
];
......@@ -23,7 +29,7 @@ export default sandboxErr.reduce((acc, cur, index) => {
statusText: cur.statusText,
message: cur.message,
data: null,
httpStatus: 403
httpStatus: cur.httpStatus ?? 403
}
};
}, {} as ErrType<`${SandboxErrEnum}`>);
......@@ -25,6 +25,7 @@ export const generateSandboxId = (appId: string, userId: string, chatId: string)
// Prompt
export const SANDBOX_USER_FILES_PATH = 'user_files/';
export const SANDBOX_ENTRYPOINT_MAX_LENGTH = 16 * 1024;
export const SANDBOX_SYSTEM_PROMPT = `## 沙盒能力
你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 sandbox 工具操作文件和执行命令。
- 系统预装:bash / python3 / node / bun / git / curl
......
......@@ -3,6 +3,7 @@ import z from 'zod';
import { AppChatConfigTypeSchema, AppDatasetSearchParamsTypeSchema } from '../type';
import { FlowNodeTemplateTypeSchema } from '../../workflow/type/node';
import { NodeInputKeyEnum } from '../../workflow/constants';
import { SANDBOX_ENTRYPOINT_MAX_LENGTH } from '../../ai/sandbox/constants';
export type AgentSubAppItemType = object;
......@@ -43,7 +44,8 @@ export const AppFormEditFormV1TypeSchema = z.object({
[NodeInputKeyEnum.aiChatStopSign]: z.string().optional(),
[NodeInputKeyEnum.aiChatResponseFormat]: z.string().optional(),
[NodeInputKeyEnum.aiChatJsonSchema]: z.string().optional(),
[NodeInputKeyEnum.useAgentSandbox]: z.boolean().default(false).optional()
[NodeInputKeyEnum.useAgentSandbox]: z.boolean().default(false).optional(),
[NodeInputKeyEnum.sandboxEntrypoint]: z.string().max(SANDBOX_ENTRYPOINT_MAX_LENGTH).optional()
}),
dataset: AppDatasetSearchParamsTypeSchema.extend({
datasets: z.array(SelectedDatasetSchema)
......
......@@ -177,6 +177,7 @@ export enum NodeInputKeyEnum {
datasetParams = 'agent_datasetParams',
skills = 'skills',
useAgentSandbox = 'useAgentSandbox',
sandboxEntrypoint = 'sandboxEntrypoint',
editSkillId = 'editSkillId',
// dataset
......
......@@ -131,6 +131,20 @@ export const AgentNode: FlowNodeTemplateType = {
},
Input_Template_File_Link,
Input_Template_UserChatInput,
{
key: NodeInputKeyEnum.useAgentSandbox,
renderTypeList: [FlowNodeInputTypeEnum.switch],
label: i18nT('app:use_agent_sandbox'),
description: i18nT('app:use_computer_desc'),
valueType: WorkflowIOValueTypeEnum.boolean,
value: false
},
{
key: NodeInputKeyEnum.sandboxEntrypoint,
renderTypeList: [FlowNodeInputTypeEnum.custom],
label: '',
valueType: WorkflowIOValueTypeEnum.string
},
// Skill
{
key: NodeInputKeyEnum.skills,
......
......@@ -132,6 +132,12 @@ export const ToolCallNode: FlowNodeTemplateType = {
value: false
},
{
key: NodeInputKeyEnum.sandboxEntrypoint,
renderTypeList: [FlowNodeInputTypeEnum.custom],
label: '',
valueType: WorkflowIOValueTypeEnum.string
},
{
...Input_Template_System_Prompt,
label: i18nT('common:core.ai.Prompt'),
description: systemPromptTip,
......
import crypto from 'crypto';
import { getGlobalRedisConnection } from './index';
import { getLogger, LogCategories } from '../logger';
const logger = getLogger(LogCategories.INFRA.REDIS);
const LOCK_KEY_PREFIX = 'lock:';
const RELEASE_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
const RENEW_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
end
return 0
`;
const getLeaseKey = (key: string) => `${LOCK_KEY_PREFIX}${key}`;
export class RedisLeaseUnavailableError extends Error {
constructor({ key, label }: { key: string; label: string }) {
super(`Redis lease is already held for ${label}: ${key}`);
this.name = 'RedisLeaseUnavailableError';
}
}
export class RedisLeaseLostError extends Error {
constructor({ key, label }: { key: string; label: string }) {
super(`Redis lease was lost while running ${label}: ${key}`);
this.name = 'RedisLeaseLostError';
}
}
export class RedisLeaseAcquireError extends Error {
constructor({ key, label, cause }: { key: string; label: string; cause: unknown }) {
super(`Failed to acquire Redis lease for ${label}: ${key}`);
this.name = 'RedisLeaseAcquireError';
this.cause = cause;
}
}
export const isRedisLeaseError = (error: unknown) =>
error instanceof RedisLeaseUnavailableError ||
error instanceof RedisLeaseLostError ||
error instanceof RedisLeaseAcquireError;
/**
* 基于 Redis SET NX PX 的服务端租约。
*
* 获取失败或 Redis 异常会向上抛错,不会无锁执行临界区。执行期间通过 token 校验续期,
* 释放时同样只删除当前 token 持有的 lease,避免误删后续请求重新获得的 lease。
*/
export async function withRedisLease<T>({
key,
label,
ttlMs,
renewIntervalMs = Math.floor(ttlMs / 6),
fn
}: {
key: string;
label: string;
ttlMs: number;
renewIntervalMs?: number;
fn: () => Promise<T>;
}): Promise<T> {
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new Error('ttlMs must be a positive number');
}
if (!Number.isFinite(renewIntervalMs) || renewIntervalMs <= 0 || renewIntervalMs >= ttlMs) {
throw new Error('renewIntervalMs must be a positive number smaller than ttlMs');
}
const redis = getGlobalRedisConnection();
const leaseKey = getLeaseKey(key);
const token = crypto.randomUUID();
let leaseLostError: RedisLeaseLostError | undefined;
let leaseExpiresAt = Date.now() + ttlMs;
let active = true;
const acquire = async () => redis.set(leaseKey, token, 'PX', ttlMs, 'NX');
const renew = async () => {
if (!active || leaseLostError) return;
try {
const renewed = await redis.eval(RENEW_LOCK_SCRIPT, 1, leaseKey, token, ttlMs);
if (!active) return;
if (renewed === 1) {
leaseExpiresAt = Date.now() + ttlMs;
return;
}
leaseLostError = new RedisLeaseLostError({ key: leaseKey, label });
logger.warn('Redis lease renew failed because token no longer matches', {
key: leaseKey,
label
});
} catch (error) {
logger.warn('Redis lease renew failed', { key: leaseKey, label, error });
if (Date.now() >= leaseExpiresAt) {
leaseLostError = new RedisLeaseLostError({ key: leaseKey, label });
}
}
};
const acquired = await acquire().catch((error) => {
logger.warn('Redis lease acquire failed', { key: leaseKey, label, error });
throw new RedisLeaseAcquireError({ key: leaseKey, label, cause: error });
});
if (acquired !== 'OK') {
throw new RedisLeaseUnavailableError({ key: leaseKey, label });
}
const renewTimer = setInterval(() => {
void renew();
}, renewIntervalMs);
renewTimer.unref?.();
try {
const result = await fn();
if (leaseLostError) {
throw leaseLostError;
}
return result;
} finally {
active = false;
clearInterval(renewTimer);
await redis.eval(RELEASE_LOCK_SCRIPT, 1, leaseKey, token).catch((error: unknown) => {
logger.warn('Redis lease release failed', { key: leaseKey, label, error });
});
}
}
......@@ -9,3 +9,12 @@ import { UserError } from '@fastgpt/global/common/error/utils';
*/
export const createAgentSandboxPermissionDeniedError = () =>
new UserError(SandboxErrEnum.agentSandboxPermissionDenied);
/**
* 生成 Agent 虚拟机运行态初始化占用错误。
*
* 该错误表示同一个 sandbox 正在执行文件/skill/entrypoint reconcile。本轮不能无锁继续,
* 否则可能重新引入 skill 目录清理与扫描交错问题。
*/
export const createAgentSandboxInitializingError = () =>
new UserError(SandboxErrEnum.agentSandboxInitializing);
......@@ -25,13 +25,11 @@ const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
export type SandboxClientQuery =
| {
sandboxId: string;
teamId?: string;
}
| {
appId: string;
userId?: string;
chatId: string;
teamId?: string;
};
type SandboxClientProps = {
......@@ -39,7 +37,6 @@ type SandboxClientProps = {
appId?: string;
userId?: string;
chatId?: string;
teamId?: string;
};
type SandboxClientOptions = {
......@@ -50,8 +47,6 @@ type SandboxClientOptions = {
restoreArchived?: boolean;
};
type NormalizedSandboxClientQuery = SandboxClientProps;
/**
* 当前会话运行态 sandbox client。
*
......@@ -172,18 +167,13 @@ export class SandboxClient {
}
}
export function resolveSandboxId(props: SandboxClientQuery): string {
return normalizeSandboxClientQuery(props).sandboxId;
}
function normalizeSandboxClientQuery(props: SandboxClientQuery): NormalizedSandboxClientQuery {
const resolveSandboxClientProps = (props: SandboxClientQuery): SandboxClientProps => {
if ('sandboxId' in props) {
if (!props.sandboxId) {
throw new Error('sandboxId is required');
}
return {
sandboxId: props.sandboxId,
teamId: props.teamId
sandboxId: props.sandboxId
};
}
......@@ -196,10 +186,9 @@ function normalizeSandboxClientQuery(props: SandboxClientQuery): NormalizedSandb
sandboxId: generateSandboxId(props.appId, sandboxUserId, props.chatId),
appId: props.appId,
userId: props.userId,
chatId: props.chatId,
teamId: props.teamId
chatId: props.chatId
};
}
};
/**
* 获取当前业务会话的运行态 sandbox client。
......@@ -211,8 +200,8 @@ export const getSandboxClient = async (
props: SandboxClientQuery,
opts: Omit<SandboxClientOptions, 'vmConfig'> = {}
) => {
const sandboxContext = normalizeSandboxClientQuery(props);
const { sandboxId, appId, userId, chatId, teamId } = sandboxContext;
const sandboxClientProps = resolveSandboxClientProps(props);
const { sandboxId, appId, userId, chatId } = sandboxClientProps;
const providerName = opts.providerName ?? getConfiguredSandboxProvider();
let vmConfig: VolumeManagerResult | undefined;
......@@ -242,7 +231,7 @@ export const getSandboxClient = async (
});
}
vmConfig ??= providerName === 'opensandbox' ? await getSessionVolumeConfig(sandboxId) : undefined;
const sandbox = new SandboxClient(sandboxContext, {
const sandbox = new SandboxClient(sandboxClientProps, {
...opts,
providerName,
vmConfig
......
......@@ -77,9 +77,7 @@ export const runSandboxTools = async ({
const instance =
sandboxClient ??
(await getSandboxClient(
sandboxId ? { sandboxId, appId, userId, chatId } : { appId, userId, chatId }
));
(await getSandboxClient(sandboxId ? { sandboxId } : { appId, userId, chatId }));
const result = await tool.execute({
appId,
userId,
......@@ -97,11 +95,12 @@ export const runSandboxTools = async ({
};
/**
* 将用户输入文件注入到当前会话 sandbox。
* 准备 ToolCall 使用的运行态 sandbox。
*
* 该入口会确保运行态 sandbox 可用,然后把远端 URL 文件下载并写入 provider 文件系统。
* 该入口会获取当前会话 sandbox client,并把本轮用户输入文件写入 provider 文件系统。
* 即使没有文件也会返回 sandbox client,供 sandbox entrypoint 和后续工具调用复用。
*/
export const injectSandboxFiles = async ({
export const prepareSandboxToolRuntime = async ({
appId,
userId,
chatId,
......@@ -114,11 +113,9 @@ export const injectSandboxFiles = async ({
sandboxId?: string;
files: { path: string; url: string }[];
}) => {
const instance = await getSandboxClient(
sandboxId ? { sandboxId, appId, userId, chatId } : { appId, userId, chatId }
);
await instance.ensureAvailable();
const instance = await getSandboxClient(sandboxId ? { sandboxId } : { appId, userId, chatId });
await writeUrlFilesToSandbox(instance.provider, files);
return instance;
};
/**
......
......@@ -7,3 +7,8 @@ export type DeployedSkillInfo = {
directory: string;
skillMdPath: string;
};
export type DeployedSkillVersion = {
versionId: string;
targetDir: string;
};
......@@ -203,7 +203,7 @@ export const joinSandboxPath = (basePath: string, path: string): string =>
`${trimSandboxPathRight(basePath)}/${path}`;
/**
* 获取沙盒内 Skill 一级根目录。
* 获取运行态 selected skill version 的 projects 根目录。
*/
export const getSkillsRootPath = (workDirectory: string): string =>
joinSandboxPath(workDirectory, 'projects');
......@@ -233,17 +233,6 @@ export const getSafeSkillDirectoryName = (skillName: string): string => {
: 'skill';
};
/**
* 获取运行态沙盒下的 Skill 物理存储目标路径。
*/
export const getSkillTargetPath = ({
workDirectory,
skillId
}: {
workDirectory: string;
skillId: string;
}): string => joinSandboxPath(getSkillsRootPath(workDirectory), getSafeSkillDirectoryName(skillId));
export type GitignoreParsedResult = {
customExcludes: string[];
pruneClause: string;
......
......@@ -33,10 +33,15 @@ import {
import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { useSandbox } from './sub/sandbox';
import {
agentSandboxBootstrap,
ensureAgentSandboxRuntime,
streamAgentSandboxInitStatus
} from './sub/sandbox';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
import type { RuntimeNodeResponseSummary } from '../../type';
import { createAgentNodeResponseCollector } from './nodeResponseCollector';
import { createAgentSandboxPermissionDeniedError } from '../../../../ai/sandbox/error';
export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.history]?: ChatItemMiniType[];
......@@ -70,6 +75,7 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.datasetSearchExtensionBg]?: string;
[NodeInputKeyEnum.authTmbId]?: boolean;
[NodeInputKeyEnum.useAgentSandbox]?: boolean;
[NodeInputKeyEnum.sandboxEntrypoint]?: string;
}> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
};
......@@ -145,6 +151,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
skills: selectedSkills = [],
editSkillId,
useAgentSandbox = false,
sandboxEntrypoint,
model,
aiChatReasoning
}
......@@ -162,6 +169,13 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
}
const skillIds = editSkillId ? [editSkillId] : selectedSkills.map(({ skillId }) => skillId);
const hasSandboxRuntimeDependency = !!editSkillId || skillIds.length > 0;
const effectiveUseAgentSandbox =
hasSandboxRuntimeDependency || (!!useAgentSandbox && !!global.feConfigs?.show_agent_sandbox);
const effectiveSandboxEntrypoint =
effectiveUseAgentSandbox && useAgentSandbox && global.feConfigs?.show_agent_sandbox
? sandboxEntrypoint
: undefined;
// 初始化对话框输入的文件
const fileUrlInput = inputs.find((item) => item.key === NodeInputKeyEnum.fileUrlList);
......@@ -171,6 +185,10 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
: undefined;
try {
if (hasSandboxRuntimeDependency && !global.feConfigs?.show_agent_sandbox) {
throw createAgentSandboxPermissionDeniedError();
}
const userContext = await useUserContext({
history,
histories,
......@@ -186,14 +204,26 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20
});
if (effectiveUseAgentSandbox) {
streamAgentSandboxInitStatus({
workflowStreamResponse,
appId: runningAppInfo.id,
userId: uid,
chatId,
sandboxId: runningAppInfo.sandboxId
});
}
// 初始化 sandbox:初始化、注入 skills、files
const { sandboxClient, currentWorkingDirectory, skillInfos } = await useSandbox({
const { sandboxClient, currentWorkingDirectory, skillInfos } = await ensureAgentSandboxRuntime({
appId: runningAppInfo.id,
userId: uid,
chatId,
sandboxId: runningAppInfo.sandboxId,
teamId: runningAppInfo.teamId,
useAgentSandbox,
needSandboxRuntime: effectiveUseAgentSandbox,
sandboxBootstrap: agentSandboxBootstrap,
sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds,
editSkillId,
currentFiles: userContext.currentFiles
......
......@@ -15,7 +15,11 @@ import { getLogger, LogCategories } from '../../../../../../common/logger';
import type { DispatchAgentModuleProps } from '..';
import { parseUserSystemPrompt } from '../adapter/prompt';
import { useUserContext } from '../adapter/userContext';
import { useSandbox } from '../sub/sandbox';
import {
agentSandboxBootstrap,
ensureAgentSandboxRuntime,
streamAgentSandboxInitStatus
} from '../sub/sandbox';
import { getAgentDatasetParams, getSubapps, type ToolDispatchContext } from '../utils';
import {
createPiAgentWorkflowRuntime,
......@@ -26,6 +30,7 @@ import { buildPiModel, getModelApiKey, getPiThinkingLevel } from './modelBridge'
import { buildAgentTools, createPiAgentToolEventHandler } from './toolAdapter';
import type { RuntimeNodeResponseSummary } from '../../../type';
import { createAgentNodeResponseCollector } from '../nodeResponseCollector';
import { createAgentSandboxPermissionDeniedError } from '../../../../../ai/sandbox/error';
type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.answerText]: string;
......@@ -60,6 +65,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
skills: selectedSkills = [],
editSkillId,
useAgentSandbox = false,
sandboxEntrypoint,
aiChatVision,
aiChatReasoning,
aiChatReasoningEffort
......@@ -113,6 +119,17 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
? fileLinksInput
: undefined;
const skillIds = editSkillId ? [editSkillId] : selectedSkills.map(({ skillId }) => skillId);
const hasSandboxRuntimeDependency = !!editSkillId || skillIds.length > 0;
if (hasSandboxRuntimeDependency && !global.feConfigs?.show_agent_sandbox) {
throw createAgentSandboxPermissionDeniedError();
}
const effectiveUseAgentSandbox =
hasSandboxRuntimeDependency || (!!useAgentSandbox && !!global.feConfigs?.show_agent_sandbox);
const effectiveSandboxEntrypoint =
effectiveUseAgentSandbox && useAgentSandbox && global.feConfigs?.show_agent_sandbox
? sandboxEntrypoint
: undefined;
const userContext = await useUserContext({
history,
histories,
......@@ -127,12 +144,24 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
requestOrigin,
maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20
});
const { sandboxClient, currentWorkingDirectory, skillInfos } = await useSandbox({
if (effectiveUseAgentSandbox) {
streamAgentSandboxInitStatus({
workflowStreamResponse,
appId: runningAppInfo.id,
userId: uid,
chatId,
sandboxId: runningAppInfo.sandboxId
});
}
const { sandboxClient, currentWorkingDirectory, skillInfos } = await ensureAgentSandboxRuntime({
appId: runningAppInfo.id,
userId: uid,
chatId,
sandboxId: runningAppInfo.sandboxId,
teamId: runningAppInfo.teamId,
useAgentSandbox,
needSandboxRuntime: effectiveUseAgentSandbox,
sandboxBootstrap: agentSandboxBootstrap,
sandboxEntrypoint: effectiveSandboxEntrypoint,
skillIds,
editSkillId,
currentFiles: userContext.currentFiles
......
import type { AgentSandboxBootstrap } from './runtime';
/**
* 平台侧 Agent sandbox bootstrap 回调。
*
* 回调会在 per-sandbox Redis lease 内执行,且早于用户文件注入、skill 包部署和
* sandbox entrypoint。需要配置镜像源、写平台内置文件或做其他基础环境准备时,直接把
* 这里的 undefined 替换成 async 回调;回调内部自行维护幂等、版本判断和错误处理。
*
* 示例:
* export const agentSandboxBootstrap: AgentSandboxBootstrap = async ({ sandbox, workDirectory }) => {
* await sandbox.writeFiles([{ path: '.npmrc', data: 'registry=https://registry.npmmirror.com' }]);
* await sandbox.execute(`cd ${workDirectory} && ...`);
* };
*/
export const agentSandboxBootstrap: AgentSandboxBootstrap | undefined = undefined;
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { SANDBOX_ICON, SANDBOX_NAME } from '@fastgpt/global/core/ai/sandbox/tools';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type { localeType } from '@fastgpt/global/common/i18n/type';
import { runSandboxTools } from '../../../../../../ai/sandbox/toolCall';
import type { DispatchSubAppResponse } from '../../type';
import type { SandboxClient } from '../../../../../../ai/sandbox/service/runtime';
export const dispatchSandboxTool = async ({
toolName,
rawArgs,
appId,
userId,
chatId,
sandboxId,
lang,
sandboxClient
}: {
toolName: string;
rawArgs: string;
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
lang?: localeType;
sandboxClient?: SandboxClient;
}): Promise<DispatchSubAppResponse> => {
const { input, response } = await runSandboxTools({
toolName,
args: rawArgs,
appId,
userId,
chatId,
sandboxId,
sandboxClient
});
return {
response,
nodeResponse: {
moduleType: FlowNodeTypeEnum.tool,
moduleName: parseI18nString(SANDBOX_NAME, lang),
moduleLogo: SANDBOX_ICON,
toolId: toolName,
toolInput: input,
toolRes: response
}
};
};
export { useSandbox } from './useSandbox';
export { ensureAgentSandboxRuntime } from './runtime';
export { agentSandboxBootstrap } from './bootstrap';
export { dispatchSandboxTool } from './tool';
export { streamAgentSandboxInitStatus } from './status';
......@@ -6,29 +6,59 @@ import {
injectAgentSkillFilesToSandbox,
type DeployedSkillInfo
} from '../../../../../../ai/skill/runtime';
import {
runAgentSandboxEntrypoint,
runAgentSkillVersionEntrypoints,
withAgentSandboxInitLease
} from '../../../../../../ai/skill/runtime/entrypoint';
import { getSandboxRuntimeProfile } from '../../../../../../ai/sandbox/runtime/profile';
import { getSandboxClient, type SandboxClient } from '../../../../../../ai/sandbox/service/runtime';
import { pickOutboundAxios } from '../../../../../../../common/api/axios';
import { checkTeamSandboxPermission } from '../../../../../../../support/permission/teamLimit';
import { createAgentSandboxPermissionDeniedError } from '../../../../../../ai/sandbox/error';
type UseSandboxParams = {
export type AgentSandboxBootstrap = (context: {
sandboxClient: SandboxClient;
sandbox: ISandbox;
workDirectory: string;
}) => Promise<void>;
type EnsureAgentSandboxRuntimeParams = {
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
teamId: string;
useAgentSandbox: boolean;
needSandboxRuntime: boolean;
sandboxBootstrap?: AgentSandboxBootstrap;
sandboxEntrypoint?: string;
skillIds: string[];
editSkillId?: string;
currentFiles: AgentInputFile[];
};
type UseSandboxResult = {
type EnsureAgentSandboxRuntimeResult = {
sandboxClient?: SandboxClient;
currentWorkingDirectory?: string;
skillInfos: DeployedSkillInfo[];
};
type SandboxRuntimeContext = {
sandboxClient: SandboxClient;
workDirectory: string;
};
type InitRuntimeSandboxParams = SandboxRuntimeContext & {
teamId: string;
skillIds: string[];
sandboxBootstrap?: AgentSandboxBootstrap;
sandboxEntrypoint?: string;
currentFiles: AgentInputFile[];
};
type InitEditSkillSandboxParams = SandboxRuntimeContext & {
currentFiles: AgentInputFile[];
};
/**
* 读取 sandbox 当前目录,仅作为 user reminder 的提示增强。
* 如果命令失败或没有输出,返回 undefined,让提示词侧完全跳过 pwd 区块。
......@@ -72,28 +102,120 @@ const injectInputFilesToSandbox = async (sandbox: ISandbox, files: AgentInputFil
};
/**
* 初始化 Agent 本轮 sandbox。
* 初始化 edit-debug sandbox。
*
* 编辑调试包已解压到当前工作目录,这里只补齐用户输入文件和 SKILL.md 扫描。
*/
const initEditSkillSandbox = async ({
sandboxClient,
workDirectory,
currentFiles
}: InitEditSkillSandboxParams): Promise<Omit<EnsureAgentSandboxRuntimeResult, 'sandboxClient'>> => {
const [, currentWorkingDirectory, skillInfos] = await Promise.all([
injectInputFilesToSandbox(sandboxClient.provider, currentFiles),
readSandboxPwd(sandboxClient),
getAgentSkillInfos({
sandbox: sandboxClient.provider,
workDirectory
})
]);
return {
currentWorkingDirectory,
skillInfos
};
};
/**
* 初始化普通 Agent sandbox。
*
* 顺序固定为:平台 bootstrap 回调 -> 准备文件和 skill 包 -> sandbox entrypoint -> skill entrypoint -> 扫描 SKILL.md。
*/
const initRuntimeSandbox = async ({
sandboxClient,
workDirectory,
teamId,
skillIds,
sandboxBootstrap,
sandboxEntrypoint,
currentFiles
}: InitRuntimeSandboxParams): Promise<Omit<EnsureAgentSandboxRuntimeResult, 'sandboxClient'>> => {
return withAgentSandboxInitLease({
sandboxId: sandboxClient.getSandboxId(),
fn: async () => {
const sandbox = sandboxClient.provider;
await sandboxBootstrap?.({
sandboxClient,
sandbox,
workDirectory
});
const [deployedSkillVersions, , currentWorkingDirectory] = await Promise.all([
injectAgentSkillFilesToSandbox({
sandbox,
skillIds,
teamId,
workDirectory
}),
injectInputFilesToSandbox(sandbox, currentFiles),
readSandboxPwd(sandboxClient)
]);
const effectiveSandboxEntrypoint = sandboxEntrypoint?.trim();
if (effectiveSandboxEntrypoint) {
await runAgentSandboxEntrypoint({
sandbox,
sandboxEntrypoint: effectiveSandboxEntrypoint,
workDirectory
});
}
if (deployedSkillVersions.length > 0) {
await runAgentSkillVersionEntrypoints({
sandbox,
versions: deployedSkillVersions
});
}
const skillInfos =
deployedSkillVersions.length > 0
? await getAgentSkillInfos({
sandbox,
skillDirectories: deployedSkillVersions.map(({ targetDir }) => targetDir)
})
: [];
return {
currentWorkingDirectory,
skillInfos
};
}
});
};
/**
* 确保 Agent 本轮 sandbox runtime 可用。
*
* 只要显式启用 sandbox 或本轮有 skill,就在 agent-loop 前启动同一个
* appId/userId/chatId sandbox,并完成本轮文件注入、skill 包注入和 SKILL.md 扫描。
* 返回的 sandboxClient 会继续传给 sandbox tool,避免工具执行阶段重新定位实例。
*/
export async function useSandbox({
export async function ensureAgentSandboxRuntime({
appId,
userId,
chatId,
sandboxId,
teamId,
useAgentSandbox,
needSandboxRuntime,
sandboxBootstrap,
sandboxEntrypoint,
skillIds,
editSkillId,
currentFiles
}: UseSandboxParams): Promise<UseSandboxResult> {
}: EnsureAgentSandboxRuntimeParams): Promise<EnsureAgentSandboxRuntimeResult> {
const hasEditSkill = !!editSkillId;
const hasAgentSkills = skillIds.length > 0;
const needSandbox = useAgentSandbox || hasEditSkill || hasAgentSkills;
if (needSandbox) {
if (needSandboxRuntime) {
try {
await checkTeamSandboxPermission(teamId);
} catch {
......@@ -101,7 +223,7 @@ export async function useSandbox({
}
}
if (!needSandbox) {
if (!needSandboxRuntime) {
return {
skillInfos: []
};
......@@ -109,33 +231,35 @@ export async function useSandbox({
// 确认使用沙盒,启动沙盒实例
const sandboxClient = await getSandboxClient(
sandboxId ? { sandboxId, appId, userId, chatId } : { appId, userId, chatId }
sandboxId ? { sandboxId } : { appId, userId, chatId }
);
const getSkillsInfo = async () => {
// 编辑调试包已解压到当前工作目录,调度侧需要扫描同一目录才能读取 SKILL.md。
if (hasEditSkill) {
const runtimeProfile = getSandboxRuntimeProfile();
return getAgentSkillInfos({
sandbox: sandboxClient.provider,
workDirectory: runtimeProfile.workDirectory
});
} else if (hasAgentSkills) {
return injectAgentSkillFilesToSandbox({
sandbox: sandboxClient.provider,
skillIds,
teamId,
workDirectory: '.'
});
}
return [];
const runtimeProfile = getSandboxRuntimeProfile();
const context = {
sandboxClient,
workDirectory: runtimeProfile.workDirectory
};
const [, skillInfos, currentWorkingDirectory] = await Promise.all([
injectInputFilesToSandbox(sandboxClient.provider, currentFiles),
getSkillsInfo(),
readSandboxPwd(sandboxClient)
]);
if (hasEditSkill) {
const { currentWorkingDirectory, skillInfos } = await initEditSkillSandbox({
...context,
currentFiles
});
return {
sandboxClient,
currentWorkingDirectory,
skillInfos
};
}
const { currentWorkingDirectory, skillInfos } = await initRuntimeSandbox({
...context,
teamId,
skillIds,
sandboxBootstrap,
sandboxEntrypoint,
currentFiles
});
return {
sandboxClient,
......
import { generateSandboxId } from '@fastgpt/global/core/ai/sandbox/constants';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { EDIT_DEBUG_SANDBOX_CHAT_ID } from '../../../../../../ai/skill/edit/config';
import type { WorkflowResponseType } from '../../../../type';
/**
* 发送 Agent 对话中的 sandbox runtime 准备状态。
*
* 这里仅负责 UI 侧的粗粒度提示,不参与 sandbox 资源状态流转。
*/
export const streamAgentSandboxInitStatus = ({
workflowStreamResponse,
appId,
userId,
chatId,
sandboxId
}: {
workflowStreamResponse?: WorkflowResponseType;
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
}) => {
const effectiveSandboxId =
sandboxId ||
generateSandboxId(appId, chatId === EDIT_DEBUG_SANDBOX_CHAT_ID ? '' : userId, chatId);
workflowStreamResponse?.({
event: SseResponseEventEnum.sandboxStatus,
data: {
sandboxId: effectiveSandboxId,
phase: 'lazyInit'
}
});
};
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { SANDBOX_ICON, SANDBOX_NAME } from '@fastgpt/global/core/ai/sandbox/tools';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type { localeType } from '@fastgpt/global/common/i18n/type';
import { runSandboxTools } from '../../../../../../ai/sandbox/toolCall';
import type { DispatchSubAppResponse } from '../../type';
import type { SandboxClient } from '../../../../../../ai/sandbox/service/runtime';
/**
* Agent 子工具层的 sandbox tool 适配器。
*
* 真实工具执行在 ai/sandbox/toolCall 中完成,这里只补齐 Agent runtime 需要的
* nodeResponse 元数据,和其他 sub app/tool dispatch 保持同一返回结构。
*/
export const dispatchSandboxTool = async ({
toolName,
rawArgs,
appId,
userId,
chatId,
sandboxId,
lang,
sandboxClient
}: {
toolName: string;
rawArgs: string;
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
lang?: localeType;
sandboxClient?: SandboxClient;
}): Promise<DispatchSubAppResponse> => {
const { input, response } = await runSandboxTools({
toolName,
args: rawArgs,
appId,
userId,
chatId,
sandboxId,
sandboxClient
});
return {
response,
nodeResponse: {
moduleType: FlowNodeTypeEnum.tool,
moduleName: parseI18nString(SANDBOX_NAME, lang),
moduleLogo: SANDBOX_ICON,
toolId: toolName,
toolInput: input,
toolRes: response
}
};
};
......@@ -2,12 +2,21 @@ import type {
ChatCompletionMessageParam,
ChatCompletionTool
} from '@fastgpt/global/core/ai/llm/type';
import { SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants';
import {
generateSandboxId,
SANDBOX_SYSTEM_PROMPT
} from '@fastgpt/global/core/ai/sandbox/constants';
import { SANDBOX_TOOLS } from '@fastgpt/global/core/ai/sandbox/tools';
import { nodeInputs2JsonSchema } from '@fastgpt/global/core/app/jsonschema';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import type { localeType } from '@fastgpt/global/common/i18n/type';
import { getSandboxToolInfo, injectSandboxFiles } from '../../../../../ai/sandbox/toolCall';
import { getSandboxToolInfo, prepareSandboxToolRuntime } from '../../../../../ai/sandbox/toolCall';
import { getSandboxRuntimeProfile } from '../../../../../ai/sandbox/runtime/profile';
import {
runAgentSandboxEntrypoint,
withAgentSandboxInitLease
} from '../../../../../ai/skill/runtime/entrypoint';
import type { SandboxClient } from '../../../../../ai/sandbox/service/runtime';
import type { FileInputType, ToolNodeItemType } from '../type';
import { ReadFileTooData, ReadFileToolSchema } from '../tools/file';
......@@ -63,7 +72,8 @@ export const useToolCatalog = async ({
appId,
userId,
chatId,
sandboxId
sandboxId,
sandboxEntrypoint
}: {
messages: ChatCompletionMessageParam[];
toolNodes: ToolNodeItemType[];
......@@ -74,8 +84,10 @@ export const useToolCatalog = async ({
userId: string;
chatId: string;
sandboxId?: string;
sandboxEntrypoint?: string;
}) => {
let finalMessages = messages;
let sandboxClient: SandboxClient | undefined;
const toolNodesMap = new Map<string, ToolNodeItemType>();
/**
......@@ -103,16 +115,36 @@ export const useToolCatalog = async ({
finalMessages = [{ role: 'system', content: SANDBOX_SYSTEM_PROMPT }, ...messages];
}
if (currentInputFiles.length > 0) {
await injectSandboxFiles({
appId,
userId,
chatId,
sandboxId,
files: currentInputFiles.map((file) => ({
path: file.sandboxPath!,
url: file.url
}))
const effectiveSandboxEntrypoint = sandboxEntrypoint?.trim();
const shouldPrepareSandboxRuntime =
currentInputFiles.length > 0 || !!effectiveSandboxEntrypoint;
if (shouldPrepareSandboxRuntime) {
const sandboxLeaseId =
sandboxId || generateSandboxId(appId, chatId === 'edit-debug' ? '' : userId, chatId);
sandboxClient = await withAgentSandboxInitLease({
sandboxId: sandboxLeaseId,
fn: async () => {
const runtime = await prepareSandboxToolRuntime({
appId,
userId,
chatId,
sandboxId,
files: currentInputFiles.map((file) => ({
path: file.sandboxPath!,
url: file.url
}))
});
if (effectiveSandboxEntrypoint) {
await runAgentSandboxEntrypoint({
sandbox: runtime.provider,
sandboxEntrypoint: effectiveSandboxEntrypoint,
workDirectory: getSandboxRuntimeProfile().workDirectory
});
}
return runtime;
}
});
}
}
......@@ -149,6 +181,7 @@ export const useToolCatalog = async ({
return {
finalMessages,
tools,
getToolInfo
getToolInfo,
sandboxClient
};
};
......@@ -7,6 +7,7 @@ import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { AgentLoopChildrenInteractiveParams } from '../../../../../ai/llm/agentLoop';
import { runSandboxTools } from '../../../../../ai/sandbox/toolCall';
import type { SandboxClient } from '../../../../../ai/sandbox/service/runtime';
import { parseJsonArgs } from '../../../../../ai/utils';
import { runWorkflow } from '../../../index';
import { getRuntimeNodeResponseSummary } from '../../../utils';
......@@ -85,6 +86,7 @@ export const useToolRunner = ({
runtimeNodes,
runtimeEdges,
allFiles,
sandboxClient,
fileUrls = [],
getToolInfo,
cacheToolFlowResponse,
......@@ -95,6 +97,7 @@ export const useToolRunner = ({
runtimeNodes: DispatchToolModuleProps['runtimeNodes'];
runtimeEdges: DispatchToolModuleProps['runtimeEdges'];
allFiles: Map<string, FileInputType>;
sandboxClient?: SandboxClient;
fileUrls?: string[];
getToolInfo: (name: string) => ToolInfo | undefined;
cacheToolFlowResponse: (args: {
......@@ -134,14 +137,16 @@ export const useToolRunner = ({
throw createAgentSandboxPermissionDeniedError();
}
const { input, response, durationSeconds } = await runSandboxTools({
const sandboxToolParams = {
toolName: call.function.name,
args: call.function.arguments ?? '',
appId: workflowProps.runningAppInfo.id,
userId: workflowProps.uid,
chatId: workflowProps.chatId,
sandboxId: workflowProps.runningAppInfo.sandboxId
});
sandboxId: workflowProps.runningAppInfo.sandboxId,
...(sandboxClient ? { sandboxClient } : {})
};
const { input, response, durationSeconds } = await runSandboxTools(sandboxToolParams);
const flowResponse = getSandboxToolWorkflowResponse({
name: toolInfo.name,
......
......@@ -41,7 +41,8 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
aiChatVideo,
aiChatReasoning,
isResponseAnswerText = true,
useAgentSandbox
useAgentSandbox,
sandboxEntrypoint
}
} = props;
......@@ -73,6 +74,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
props.params.aiChatReasoning = aiChatReasoning && toolModel.reasoning;
props.params.fileUrlList = fileLinks;
props.params.useAgentSandbox = useSandbox;
props.params.sandboxEntrypoint = useSandbox ? sandboxEntrypoint : undefined;
const toolNodes = useToolNodeList({
nodeId,
......
......@@ -66,11 +66,12 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
aiChatReasoningEffort,
isResponseAnswerText = true,
useAgentSandbox,
sandboxEntrypoint,
fileUrlList
}
} = workflowProps;
const { finalMessages, tools, getToolInfo } = await useToolCatalog({
const { finalMessages, tools, getToolInfo, sandboxClient } = await useToolCatalog({
messages,
toolNodes,
currentInputFiles,
......@@ -79,7 +80,8 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
appId: workflowProps.runningAppInfo.id,
userId: workflowProps.uid,
chatId: workflowProps.chatId,
sandboxId: workflowProps.runningAppInfo.sandboxId
sandboxId: workflowProps.runningAppInfo.sandboxId,
sandboxEntrypoint
});
// ToolCall 的一次运行会横跨 LLM loop、真实工具执行、SSE 预览和运行详情落库。
// 这里按职责拆成 hook,toolCall.ts 只保留主流程编排。
......@@ -106,6 +108,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
runtimeNodes,
runtimeEdges,
allFiles,
sandboxClient,
fileUrls: fileUrlList,
getToolInfo,
cacheToolFlowResponse,
......
......@@ -33,6 +33,7 @@ export type DispatchToolModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.aiChatResponseFormat]?: string;
[NodeInputKeyEnum.aiChatJsonSchema]?: string;
[NodeInputKeyEnum.useAgentSandbox]?: boolean;
[NodeInputKeyEnum.sandboxEntrypoint]?: string;
}> & {
messages: ChatCompletionMessageParam[];
toolNodes: ToolNodeItemType[];
......
......@@ -105,6 +105,9 @@ export const serviceEnv = createEnv({
description: 'Agent sandbox IDE 单文件读写和上传大小上限(MB)'
}),
AGENT_SANDBOX_MAX_EDIT_DEBUG: NumSchema.default(100),
AGENT_SANDBOX_ENTRYPOINT_TIMEOUT_SECONDS: IntSchema.min(1).max(600).default(30).meta({
description: 'Agent sandbox entrypoint 执行超时时间(秒)'
}),
// ==================== 数据库与缓存 ====================
// Redisg
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getGlobalRedisConnection } from '@fastgpt/service/common/redis';
import {
RedisLeaseLostError,
RedisLeaseUnavailableError,
withRedisLease
} from '@fastgpt/service/common/redis/lock';
const getRedis = () => getGlobalRedisConnection() as any;
describe('withRedisLease', () => {
beforeEach(() => {
vi.useRealTimers();
const redis = getRedis();
redis._storage.clear();
redis.set.mockClear();
redis.eval.mockClear();
});
it('acquires the lease, renews it and releases it with token check', async () => {
vi.useFakeTimers();
const redis = getRedis();
const work = vi.fn(() => new Promise<string>((resolve) => setTimeout(() => resolve('ok'), 40)));
const resultPromise = withRedisLease({
key: 'lease-test',
label: 'lease-test',
ttlMs: 60,
renewIntervalMs: 10,
fn: work
});
await vi.advanceTimersByTimeAsync(25);
await vi.advanceTimersByTimeAsync(30);
await expect(resultPromise).resolves.toBe('ok');
expect(work).toHaveBeenCalledTimes(1);
expect(redis.set).toHaveBeenCalledWith('lock:lease-test', expect.any(String), 'PX', 60, 'NX');
expect(await redis.get('lock:lease-test')).toBeNull();
});
it('does not run without lease when another holder exists', async () => {
const redis = getRedis();
await redis.set('lock:lease-test', 'other-token', 'PX', 60_000);
const work = vi.fn();
await expect(
withRedisLease({
key: 'lease-test',
label: 'lease-test',
ttlMs: 60_000,
fn: work
})
).rejects.toBeInstanceOf(RedisLeaseUnavailableError);
expect(work).not.toHaveBeenCalled();
});
it('throws when renewal detects the lease was replaced', async () => {
vi.useFakeTimers();
const redis = getRedis();
let resolveWork!: () => void;
const workPromise = new Promise<void>((resolve) => {
resolveWork = resolve;
});
const resultPromise = withRedisLease({
key: 'lease-test',
label: 'lease-test',
ttlMs: 60,
renewIntervalMs: 10,
fn: () => workPromise
});
await Promise.resolve();
await redis.set('lock:lease-test', 'other-token', 'PX', 60_000);
await vi.advanceTimersByTimeAsync(15);
resolveWork();
await expect(resultPromise).rejects.toBeInstanceOf(RedisLeaseLostError);
expect(await redis.get('lock:lease-test')).toBe('other-token');
});
});
......@@ -122,7 +122,9 @@ describe('sandbox runtime service', () => {
});
it('gets a sandbox client by stable sandbox id and ensures it is available', async () => {
const client = await getSandboxClient({ sandboxId: 'sandbox-ready-check' });
const client = await getSandboxClient({
sandboxId: 'sandbox-ready-check'
});
expect(client.getSandboxId()).toBe('sandbox-ready-check');
expect(mocks.getSessionVolumeConfig).not.toHaveBeenCalled();
......
......@@ -38,7 +38,7 @@ vi.mock('@fastgpt/service/common/s3/utils', () => ({
import {
getSandboxToolInfo,
injectSandboxFiles,
prepareSandboxToolRuntime,
runSandboxTools
} from '@fastgpt/service/core/ai/sandbox/toolCall';
......@@ -137,14 +137,16 @@ describe('sandbox toolCall index', () => {
runtimeMock.getSandboxClient.mockResolvedValueOnce(sandbox);
const files = [{ path: '/workspace/a.txt', url: 'https://example.com/a.txt' }];
await injectSandboxFiles({
appId: 'app',
userId: 'user',
chatId: 'chat',
files
});
await expect(
prepareSandboxToolRuntime({
appId: 'app',
userId: 'user',
chatId: 'chat',
files
})
).resolves.toBe(sandbox);
expect(sandbox.ensureAvailable).toHaveBeenCalledTimes(1);
expect(sandbox.ensureAvailable).not.toHaveBeenCalled();
expect(fileServiceMock.writeUrlFilesToSandbox).toHaveBeenCalledWith(sandbox.provider, files);
});
......
import { describe, expect, it, vi } from 'vitest';
import {
runAgentSandboxEntrypoint,
runAgentSkillVersionEntrypoints
} from '@fastgpt/service/core/ai/skill/runtime/entrypoint';
import type { DeployedSkillVersion } from '@fastgpt/service/core/ai/skill/runtime';
type ExecuteResult = {
exitCode: number;
stdout: string;
stderr: string;
truncated?: boolean;
};
const createSandbox = ({
initialState,
entrypointExitCode = 0,
entrypointThrows = false
}: {
initialState?: Record<string, unknown>;
entrypointExitCode?: number;
entrypointThrows?: boolean;
} = {}) => {
let stateContent = initialState ? JSON.stringify(initialState) : undefined;
const sandbox = {
execute: vi.fn(async (command: string): Promise<ExecuteResult> => {
if (command === 'printf "%s" "$HOME"') {
return { exitCode: 0, stdout: '/home/test', stderr: '' };
}
if (command.startsWith("mkdir -p '/home/test/.fastgpt/agent-skill-entrypoints'")) {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (command.startsWith("[ -f '/workspace/projects/version-1/entrypoint.sh' ]")) {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (isSkillEntrypointCommand(command) || isSandboxEntrypointCommand(command)) {
if (entrypointThrows) {
throw new Error('execute failed');
}
return { exitCode: entrypointExitCode, stdout: 'ok', stderr: '' };
}
throw new Error(`Unexpected command: ${command}`);
}),
readFiles: vi.fn(async (paths: string[]) =>
paths.map((path) => ({
path,
content: Buffer.from(stateContent || ''),
error: stateContent ? null : new Error('not found')
}))
),
writeFiles: vi.fn(async (entries: Array<{ path: string; data: string }>) => {
stateContent = String(entries[0].data);
return entries.map((entry) => ({
path: entry.path,
bytesWritten: entry.data.length,
error: null
}));
}),
getState: () => (stateContent ? JSON.parse(stateContent) : undefined)
};
return sandbox;
};
const version: DeployedSkillVersion = {
versionId: 'version-1',
targetDir: '/workspace/projects/version-1'
};
const isSandboxEntrypointCommand = (command: string) =>
command.includes('base64 -d | /bin/bash') && command.includes('tail -c 8192');
const isSkillEntrypointCommand = (command: string) =>
command.startsWith("cd '/workspace/projects/version-1' && /bin/bash -c ") &&
command.includes('entrypoint.sh') &&
command.includes('tail -c 8192');
describe('runtime entrypoint', () => {
it('skips empty sandbox entrypoint', async () => {
const sandbox = createSandbox();
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandboxEntrypoint: ' '
});
expect(sandbox.execute).not.toHaveBeenCalled();
});
it('skips sandbox entrypoint when the current script hash matches and overwrites after changes', async () => {
const sandbox = createSandbox();
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandboxEntrypoint: 'echo first'
});
const firstHash = sandbox.getState()?.sandboxEntrypointHash;
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandboxEntrypoint: 'echo first'
});
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandboxEntrypoint: 'echo second'
});
const entrypointCommands = sandbox.execute.mock.calls
.map(([command]) => command)
.filter(isSandboxEntrypointCommand);
expect(entrypointCommands).toHaveLength(2);
expect(sandbox.getState()?.sandboxEntrypointHash).toMatch(/^sha256:/);
expect(sandbox.getState()?.sandboxEntrypointHash).not.toBe(firstHash);
});
it('runs sandbox entrypoint from the configured work directory', async () => {
const sandbox = createSandbox();
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandboxEntrypoint: 'pwd',
workDirectory: '/workspace'
});
const entrypointCommand = sandbox.execute.mock.calls
.map(([command]) => command)
.find(isSandboxEntrypointCommand);
expect(entrypointCommand).toMatch(/^cd '\/workspace' && \/bin\/bash -c /);
});
it('does not write sandbox entrypoint state when execution fails', async () => {
const sandbox = createSandbox({ entrypointExitCode: 1 });
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandboxEntrypoint: 'exit 1'
});
expect(sandbox.getState()?.sandboxEntrypointHash).toBeUndefined();
});
it('does not throw or write state when sandbox entrypoint execution throws', async () => {
const sandbox = createSandbox({ entrypointThrows: true });
await expect(
runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandboxEntrypoint: 'echo throw'
})
).resolves.toBeUndefined();
expect(sandbox.getState()?.sandboxEntrypointHash).toBeUndefined();
});
it('uses skill version state to skip successful skill entrypoints', async () => {
const sandbox = createSandbox();
await runAgentSkillVersionEntrypoints({
sandbox: sandbox as any,
versions: [version]
});
await runAgentSkillVersionEntrypoints({
sandbox: sandbox as any,
versions: [version]
});
const runCommands = sandbox.execute.mock.calls
.map(([command]) => command)
.filter(isSkillEntrypointCommand);
expect(runCommands).toHaveLength(1);
expect(sandbox.getState()?.skillEntrypoints).toEqual(['version-1']);
});
it('retries skill entrypoint after a failed run', async () => {
const sandbox = createSandbox({ entrypointExitCode: 1 });
await runAgentSkillVersionEntrypoints({
sandbox: sandbox as any,
versions: [version]
});
await runAgentSkillVersionEntrypoints({
sandbox: sandbox as any,
versions: [version]
});
const runCommands = sandbox.execute.mock.calls
.map(([command]) => command)
.filter(isSkillEntrypointCommand);
expect(runCommands).toHaveLength(2);
expect(sandbox.getState()?.skillEntrypoints).toBeUndefined();
});
});
......@@ -32,6 +32,7 @@ This is the documentation.`;
const files = Object.keys(zip.files);
expect(files).toContain(`${name}/SKILL.md`);
expect(await zip.file('.gitignore')?.async('string')).toContain('node_modules/');
// JSZip may or may not include directory entries depending on how it's called
// but SKILL.md should definitely be there with the prefix
......
......@@ -50,6 +50,12 @@ vi.mock('@fastgpt/service/core/ai/skill/runtime', async (importOriginal) => {
};
});
vi.mock('@fastgpt/service/core/ai/skill/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: vi.fn(),
runAgentSkillVersionEntrypoints: vi.fn(),
withAgentSandboxInitLease: vi.fn(async ({ fn }: { fn: () => Promise<unknown> }) => fn())
}));
vi.mock('@fastgpt/service/core/ai/sandbox/service/runtime', async (importOriginal) => {
const original =
await importOriginal<typeof import('@fastgpt/service/core/ai/sandbox/service/runtime')>();
......@@ -236,22 +242,19 @@ describe('dispatchRunAgent user context', () => {
exec: sandboxClientExecMock,
getSandboxId: () => 'sandbox_prepared'
});
getAgentSkillInfosMock.mockResolvedValue([
injectAgentSkillFilesToSandboxMock.mockResolvedValue([
{
id: './SKILL.md',
name: 'Edit Skill',
description: 'Edit skill description',
directory: '.',
skillMdPath: './SKILL.md'
versionId: 'version_1',
targetDir: '/workspace/projects/version_1'
}
]);
injectAgentSkillFilesToSandboxMock.mockResolvedValue([
getAgentSkillInfosMock.mockResolvedValue([
{
id: './skills/Report-skill_1/SKILL.md',
id: '/workspace/projects/version_1/Report/SKILL.md',
name: 'Report',
description: 'Write reports',
directory: './skills/Report-skill_1',
skillMdPath: './skills/Report-skill_1/SKILL.md'
directory: '/workspace/projects/version_1/Report',
skillMdPath: '/workspace/projects/version_1/Report/SKILL.md'
}
]);
getAgentRuntimeToolsMock.mockResolvedValue([]);
......@@ -386,17 +389,8 @@ describe('dispatchRunAgent user context', () => {
const { dispatchRunAgent } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent');
const props = createProps();
props.params.useAgentSandbox = true;
let sandboxReadyBeforeLoop = false;
runUnifiedAgentLoopMock.mockImplementationOnce(async () => {
sandboxReadyBeforeLoop = sandboxWriteFilesMock.mock.calls.length > 0;
return {
status: 'done',
answerText: 'ok',
completeMessages: [],
assistantMessages: [],
requestIds: []
};
});
props.params.sandboxEntrypoint = 'pip install -r requirements.txt';
injectAgentSkillFilesToSandboxMock.mockResolvedValueOnce([]);
let result: any;
runWithContext(
......@@ -418,7 +412,6 @@ describe('dispatchRunAgent user context', () => {
userId: 'user_1',
chatId: 'chat_1'
});
expect(sandboxReadyBeforeLoop).toBe(true);
const writeFiles = sandboxWriteFilesMock.mock.calls[0][0];
expect(writeFiles.map((file: { path: string }) => file.path)).toEqual([
'user_files/current.pdf'
......@@ -481,6 +474,15 @@ describe('dispatchRunAgent user context', () => {
props.params.useAgentSandbox = false;
props.params.skills = [];
props.params.editSkillId = 'edit_skill_1';
getAgentSkillInfosMock.mockResolvedValueOnce([
{
id: './SKILL.md',
name: 'Edit Skill',
description: 'Edit skill description',
directory: '.',
skillMdPath: './SKILL.md'
}
]);
let result: any;
runWithContext(
......
......@@ -91,6 +91,12 @@ vi.mock('@fastgpt/service/core/ai/skill/runtime', async (importOriginal) => {
};
});
vi.mock('@fastgpt/service/core/ai/skill/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: vi.fn(),
runAgentSkillVersionEntrypoints: vi.fn(),
withAgentSandboxInitLease: vi.fn(async ({ fn }: { fn: () => Promise<unknown> }) => fn())
}));
vi.mock('@fastgpt/service/core/ai/sandbox/service/runtime', async (importOriginal) => {
const original =
await importOriginal<typeof import('@fastgpt/service/core/ai/sandbox/service/runtime')>();
......@@ -283,22 +289,19 @@ describe('dispatchPiAgent user context', () => {
exec: sandboxClientExecMock,
getSandboxId: () => 'sandbox_prepared'
});
getAgentSkillInfosMock.mockResolvedValue([
injectAgentSkillFilesToSandboxMock.mockResolvedValue([
{
id: './skills/EditSkill-SKILL.md',
name: 'Edit Skill',
description: 'Edit skill description',
directory: './skills/EditSkill',
skillMdPath: './skills/EditSkill/SKILL.md'
versionId: 'version_1',
targetDir: '/workspace/projects/version_1'
}
]);
injectAgentSkillFilesToSandboxMock.mockResolvedValue([
getAgentSkillInfosMock.mockResolvedValue([
{
id: './skills/Report-skill_1/SKILL.md',
id: '/workspace/projects/version_1/Report/SKILL.md',
name: 'Report',
description: 'Write reports',
directory: './skills/Report-skill_1',
skillMdPath: './skills/Report-skill_1/SKILL.md'
directory: '/workspace/projects/version_1/Report',
skillMdPath: '/workspace/projects/version_1/Report/SKILL.md'
}
]);
getAgentRuntimeToolsMock.mockResolvedValue([]);
......@@ -484,10 +487,7 @@ describe('dispatchPiAgent user context', () => {
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent');
const props = createProps();
props.params.useAgentSandbox = true;
let sandboxReadyBeforePrompt = false;
agentPromptMock.mockImplementationOnce(async () => {
sandboxReadyBeforePrompt = sandboxWriteFilesMock.mock.calls.length > 0;
});
injectAgentSkillFilesToSandboxMock.mockResolvedValueOnce([]);
let resultPromise: Promise<any>;
runWithContext(
......@@ -509,11 +509,16 @@ describe('dispatchPiAgent user context', () => {
userId: 'user_1',
chatId: 'chat_1'
});
expect(sandboxReadyBeforePrompt).toBe(true);
const writeFiles = sandboxWriteFilesMock.mock.calls[0][0];
expect(writeFiles.map((file: { path: string }) => file.path)).toEqual([
'user_files/current.pdf'
]);
expect(injectAgentSkillFilesToSandboxMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
skillIds: [],
teamId: 'team_1',
workDirectory: getSandboxRuntimeProfile().workDirectory
});
expect(agentConstructorArgs[0].initialState.systemPrompt).not.toContain('pwd: /workspace');
expect(agentPromptMock.mock.calls[0][0]).toContain('当前 sandbox 工作目录: /workspace');
expect(buildAgentToolsMock.mock.calls[0][0].ctx.sandboxClient).toBeDefined();
......@@ -525,6 +530,7 @@ describe('dispatchPiAgent user context', () => {
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/piAgent');
const props = createProps();
props.params.useAgentSandbox = false;
props.params.sandboxEntrypoint = 'echo should-not-run';
props.params.skills = [{ skillId: 'skill_1' }];
let resultPromise: Promise<any>;
......@@ -546,13 +552,10 @@ describe('dispatchPiAgent user context', () => {
userId: 'user_1',
chatId: 'chat_1'
});
expect(injectAgentSkillFilesToSandboxMock).toHaveBeenCalledWith({
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
skillIds: ['skill_1'],
teamId: 'team_1',
workDirectory: '.'
skillDirectories: ['/workspace/projects/version_1']
});
expect(getAgentSkillInfosMock).not.toHaveBeenCalled();
const completionToolNames = buildAgentToolsMock.mock.calls[0][0].ctx.completionTools.map(
(tool: any) => tool.function.name
......@@ -565,7 +568,7 @@ describe('dispatchPiAgent user context', () => {
const prompt = agentPromptMock.mock.calls[0][0];
expect(prompt).toContain('## 技能');
expect(prompt).toContain('<name>Report</name>');
expect(prompt).toContain('<path>./skills/Report-skill_1/SKILL.md</path>');
expect(prompt).toContain('<path>/workspace/projects/version_1/Report/SKILL.md</path>');
expect(prompt).toContain('当前 sandbox 工作目录: /workspace');
expect(agentConstructorArgs[0].initialState.systemPrompt).toContain('## 沙盒能力');
expect(agentConstructorArgs[0].initialState.systemPrompt).toContain('sandbox_shell');
......@@ -578,6 +581,15 @@ describe('dispatchPiAgent user context', () => {
props.params.useAgentSandbox = false;
props.params.skills = [];
props.params.editSkillId = 'edit_skill_1';
getAgentSkillInfosMock.mockResolvedValueOnce([
{
id: './skills/EditSkill-SKILL.md',
name: 'Edit Skill',
description: 'Edit skill description',
directory: './skills/EditSkill',
skillMdPath: './skills/EditSkill/SKILL.md'
}
]);
let resultPromise: Promise<any>;
runWithContext(
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
const {
getSandboxClientMock,
checkTeamSandboxPermissionMock,
pickOutboundAxiosGetMock,
injectAgentSkillFilesToSandboxMock,
getAgentSkillInfosMock,
runAgentSandboxEntrypointMock,
runAgentSkillVersionEntrypointsMock,
withAgentSandboxInitLeaseMock,
sandboxWriteFilesMock,
sandboxExecMock
} = vi.hoisted(() => ({
getSandboxClientMock: vi.fn(),
checkTeamSandboxPermissionMock: vi.fn(),
pickOutboundAxiosGetMock: vi.fn(),
injectAgentSkillFilesToSandboxMock: vi.fn(),
getAgentSkillInfosMock: vi.fn(),
runAgentSandboxEntrypointMock: vi.fn(),
runAgentSkillVersionEntrypointsMock: vi.fn(),
withAgentSandboxInitLeaseMock: vi.fn(async ({ fn }: { fn: () => Promise<unknown> }) => fn()),
sandboxWriteFilesMock: vi.fn(),
sandboxExecMock: vi.fn()
}));
vi.mock('@fastgpt/service/core/ai/sandbox/service/runtime', () => ({
getSandboxClient: getSandboxClientMock
}));
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/profile', () => ({
getSandboxRuntimeProfile: () => ({
workDirectory: '/workspace'
})
}));
vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
checkTeamSandboxPermission: checkTeamSandboxPermissionMock
}));
vi.mock('@fastgpt/service/common/api/axios', () => ({
pickOutboundAxios: () => ({
get: pickOutboundAxiosGetMock
})
}));
vi.mock('@fastgpt/service/core/ai/skill/runtime', () => ({
injectAgentSkillFilesToSandbox: injectAgentSkillFilesToSandboxMock,
getAgentSkillInfos: getAgentSkillInfosMock
}));
vi.mock('@fastgpt/service/core/ai/skill/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock,
runAgentSkillVersionEntrypoints: runAgentSkillVersionEntrypointsMock,
withAgentSandboxInitLease: withAgentSandboxInitLeaseMock
}));
describe('ensureAgentSandboxRuntime', () => {
beforeEach(() => {
vi.clearAllMocks();
checkTeamSandboxPermissionMock.mockResolvedValue(undefined);
pickOutboundAxiosGetMock.mockResolvedValue({ data: new ArrayBuffer(1) });
sandboxWriteFilesMock.mockResolvedValue(undefined);
sandboxExecMock.mockResolvedValue({
exitCode: 0,
stdout: '/workspace\n',
stderr: ''
});
getSandboxClientMock.mockResolvedValue({
provider: {
writeFiles: sandboxWriteFilesMock
},
exec: sandboxExecMock,
getSandboxId: () => 'sandbox_1'
});
injectAgentSkillFilesToSandboxMock.mockResolvedValue([
{
versionId: 'version_1',
targetDir: '/workspace/projects/version_1'
}
]);
getAgentSkillInfosMock.mockResolvedValue([
{
id: '/workspace/projects/version_1/SKILL.md',
name: 'Report',
description: 'Write reports',
directory: '/workspace/projects/version_1',
skillMdPath: '/workspace/projects/version_1/SKILL.md'
}
]);
});
it('initializes runtime sandbox once and scans deployed skill directories', async () => {
const { ensureAgentSandboxRuntime } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/runtime');
const sandboxBootstrapMock = vi.fn(async () => undefined);
let bootstrapReadyBeforeSkillInject = false;
injectAgentSkillFilesToSandboxMock.mockImplementationOnce(async () => {
bootstrapReadyBeforeSkillInject = sandboxBootstrapMock.mock.calls.length > 0;
return [
{
versionId: 'version_1',
targetDir: '/workspace/projects/version_1'
}
];
});
const result = await ensureAgentSandboxRuntime({
appId: 'app_1',
userId: 'user_1',
chatId: 'chat_1',
teamId: 'team_1',
needSandboxRuntime: true,
sandboxBootstrap: sandboxBootstrapMock,
sandboxEntrypoint: 'pip install -r requirements.txt',
skillIds: ['skill_1'],
currentFiles: [
{
id: 'file_1',
name: 'current.pdf',
type: ChatFileTypeEnum.file,
url: 'https://files/current.pdf'
}
]
});
expect(checkTeamSandboxPermissionMock).toHaveBeenCalledWith('team_1');
expect(withAgentSandboxInitLeaseMock).toHaveBeenCalledWith({
sandboxId: 'sandbox_1',
fn: expect.any(Function)
});
expect(sandboxBootstrapMock).toHaveBeenCalledWith({
sandboxClient: expect.any(Object),
sandbox: expect.any(Object),
workDirectory: '/workspace'
});
expect(bootstrapReadyBeforeSkillInject).toBe(true);
expect(injectAgentSkillFilesToSandboxMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
skillIds: ['skill_1'],
teamId: 'team_1',
workDirectory: '/workspace'
});
expect(sandboxWriteFilesMock).toHaveBeenCalledWith([
{
path: 'user_files/current.pdf',
data: expect.any(ArrayBuffer)
}
]);
expect(runAgentSandboxEntrypointMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
sandboxEntrypoint: 'pip install -r requirements.txt',
workDirectory: '/workspace'
});
expect(runAgentSkillVersionEntrypointsMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
versions: [{ versionId: 'version_1', targetDir: '/workspace/projects/version_1' }]
});
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
skillDirectories: ['/workspace/projects/version_1']
});
expect(result.currentWorkingDirectory).toBe('/workspace');
expect(result.skillInfos).toHaveLength(1);
});
});
......@@ -6,9 +6,16 @@ import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants
import { useToolCatalog } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/hooks/useToolCatalog';
import { ReadFileTooData } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/tools/file';
const { getSandboxToolInfoMock, injectSandboxFilesMock } = vi.hoisted(() => ({
const {
getSandboxToolInfoMock,
prepareSandboxToolRuntimeMock,
runAgentSandboxEntrypointMock,
withAgentSandboxInitLeaseMock
} = vi.hoisted(() => ({
getSandboxToolInfoMock: vi.fn(),
injectSandboxFilesMock: vi.fn()
prepareSandboxToolRuntimeMock: vi.fn(),
runAgentSandboxEntrypointMock: vi.fn(),
withAgentSandboxInitLeaseMock: vi.fn(async ({ fn }: { fn: () => Promise<unknown> }) => fn())
}));
vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => {
......@@ -18,10 +25,15 @@ vi.mock('@fastgpt/service/core/ai/sandbox/toolCall', async (importOriginal) => {
return {
...original,
getSandboxToolInfo: getSandboxToolInfoMock,
injectSandboxFiles: injectSandboxFilesMock
prepareSandboxToolRuntime: prepareSandboxToolRuntimeMock
};
});
vi.mock('@fastgpt/service/core/ai/skill/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock,
withAgentSandboxInitLease: withAgentSandboxInitLeaseMock
}));
const createToolNode = (overrides: Record<string, any> = {}) =>
({
nodeId: 'search',
......@@ -37,7 +49,11 @@ describe('useToolCatalog', () => {
beforeEach(() => {
vi.clearAllMocks();
getSandboxToolInfoMock.mockReturnValue(undefined);
injectSandboxFilesMock.mockResolvedValue(undefined);
prepareSandboxToolRuntimeMock.mockResolvedValue({
provider: {
execute: vi.fn()
}
});
(global as any).feConfigs = {};
});
......@@ -89,7 +105,6 @@ describe('useToolCatalog', () => {
]
})
],
allFiles: new Map([['file_1', { id: 'file_1', url: 'https://files/a.pdf' } as any]]),
currentInputFiles: [],
useAgentSandbox: false,
lang: 'en' as any,
......@@ -182,7 +197,6 @@ describe('useToolCatalog', () => {
}
],
toolNodes: [],
allFiles: new Map(),
currentInputFiles: [
{
id: 'file_1',
......@@ -192,6 +206,7 @@ describe('useToolCatalog', () => {
} as any
],
useAgentSandbox: true,
sandboxEntrypoint: 'pip install -r requirements.txt',
lang: 'en' as any,
appId: 'app_1',
userId: 'user_1',
......@@ -203,7 +218,7 @@ describe('useToolCatalog', () => {
role: ChatCompletionRequestMessageRoleEnum.System,
content: `system prompt\n\n${SANDBOX_SYSTEM_PROMPT}`
});
expect(injectSandboxFilesMock).toHaveBeenCalledWith({
expect(prepareSandboxToolRuntimeMock).toHaveBeenCalledWith({
appId: 'app_1',
userId: 'user_1',
chatId: 'chat_1',
......@@ -214,6 +229,11 @@ describe('useToolCatalog', () => {
}
]
});
expect(runAgentSandboxEntrypointMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
sandboxEntrypoint: 'pip install -r requirements.txt',
workDirectory: expect.any(String)
});
expect(result.getToolInfo('sandbox_shell')).toEqual({
type: 'sandbox',
name: 'Run shell',
......@@ -234,7 +254,6 @@ describe('useToolCatalog', () => {
}
],
toolNodes: [],
allFiles: new Map(),
currentInputFiles: [],
useAgentSandbox: true,
appId: 'app_1',
......@@ -252,6 +271,8 @@ describe('useToolCatalog', () => {
content: 'hello'
}
]);
expect(injectSandboxFilesMock).not.toHaveBeenCalled();
expect(prepareSandboxToolRuntimeMock).not.toHaveBeenCalled();
expect(runAgentSandboxEntrypointMock).not.toHaveBeenCalled();
expect(result.sandboxClient).toBeUndefined();
});
});
......@@ -87,12 +87,14 @@ const createRunner = ({
runtimeNodes = [],
runtimeEdges = [],
allFiles = new Map(),
sandboxClient,
fileUrls = []
}: {
getToolInfo: (name: string) => any;
runtimeNodes?: any[];
runtimeEdges?: any[];
allFiles?: Map<string, any>;
sandboxClient?: any;
fileUrls?: string[];
}) => {
const cacheToolFlowResponse = vi.fn();
......@@ -116,6 +118,7 @@ const createRunner = ({
runtimeNodes,
runtimeEdges,
allFiles,
sandboxClient,
fileUrls,
getToolInfo,
cacheToolFlowResponse,
......@@ -157,6 +160,11 @@ describe('useToolRunner', () => {
});
it('runs sandbox tools and caches sandbox workflow response', async () => {
const sandboxClient = {
provider: {
execute: vi.fn()
}
};
runSandboxToolsMock.mockResolvedValue({
input: {
cmd: 'ls'
......@@ -165,6 +173,7 @@ describe('useToolRunner', () => {
durationSeconds: 0.5
});
const { runTool, cacheToolFlowResponse } = createRunner({
sandboxClient,
getToolInfo: () => ({
type: 'sandbox',
name: 'Run shell',
......@@ -184,7 +193,8 @@ describe('useToolRunner', () => {
args: '{"cmd":"ls"}',
appId: 'app_1',
userId: 'user_1',
chatId: 'chat_1'
chatId: 'chat_1',
sandboxClient
});
expect(result).toEqual({
response: 'sandbox ok',
......
......@@ -101,6 +101,7 @@ const createProps = (overrides: Record<string, any> = {}) =>
describe('dispatchRunTools file context', () => {
beforeEach(() => {
vi.clearAllMocks();
global.feConfigs = {};
getLLMModelMock.mockReturnValue({
model: 'gpt-5',
name: 'GPT-5',
......@@ -183,6 +184,34 @@ describe('dispatchRunTools file context', () => {
);
});
it('passes sandbox entrypoint through when sandbox is enabled', async () => {
global.feConfigs = { show_agent_sandbox: true };
await dispatchRunTools(
createProps({
params: {
...createProps().params,
useAgentSandbox: true,
sandboxEntrypoint: 'pip install -r requirements.txt'
}
})
);
expect(useToolMessagesMock).toHaveBeenCalledWith(
expect.objectContaining({
useSandbox: true
})
);
expect(runToolCallMock).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
useAgentSandbox: true,
sandboxEntrypoint: 'pip install -r requirements.txt'
})
})
);
});
it('should throw error when team has no permission', async () => {
vi.mocked(checkTeamSandboxPermission).mockRejectedValue(new Error('no permission'));
global.feConfigs = { show_agent_sandbox: true };
......
......@@ -14,6 +14,11 @@ function getLanguage(language: string | undefined): string {
case 'js':
fullName = 'javascript';
break;
case 'sh':
case 'shell':
case 'bash':
fullName = 'shell';
break;
default:
fullName = `javascript`;
break;
......@@ -24,8 +29,7 @@ function getLanguage(language: string | undefined): string {
const CodeEditor = (props: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const { language, ...otherProps } = props;
const fullName = getLanguage(language);
const fullName = getLanguage(props.language);
return (
<>
<MyEditor {...props} resize onOpenModal={onOpen} language={fullName} />
......
......@@ -505,7 +505,12 @@
"upload_method": "Upload method",
"url_upload": "File link",
"use_agent_sandbox": "Computer",
"enable_agent_sandbox": "Enable virtual machine",
"sandbox_config": "Virtual machine configuration",
"use_computer_desc": "After being turned on, AI will get a virtual machine environment where it can execute commands, operate files, and run code. \nEach session shares a virtual machine environment.",
"sandbox_entrypoint": "Startup script (sh)",
"sandbox_entrypoint_desc": "Script execution timing:\nRuns this script after virtual machine files and skill packages are injected.\n\nExecution rules:\n1. The script runs only once by default.\n2. If the script content changes, it runs again.\n3. Script failures do not block virtual machine execution.\n\nUse cases:\n1. Install dependency packages\n2. Initialize special files",
"sandbox_entrypoint_placeholder": "Example:\npip install -r requirements.txt",
"used_points": "Used point: ",
"variable.internal_type_desc": "Use only inside the workflow and will not appear in the dialog box",
"variable.select type_desc": "The input box will be displayed in the site conversation and run preview, and this variable will not be displayed in the sharing link.",
......
......@@ -199,7 +199,7 @@
"sandbox_status_failed": "Sandbox creation failed",
"sandbox_status_failed_with_message": "Sandbox creation failed: {{message}}",
"sandbox_status_fetchSkills": "Fetching skill metadata...",
"sandbox_status_lazyInit": "Preparing sandbox...",
"sandbox_status_lazyInit": "Starting virtual machine...",
"sandbox_status_ready_cold": "Sandbox is ready",
"sandbox_status_ready_warm": "Sandbox is ready (warm start)",
"sandbox_status_uploadingPackage": "Uploading skill package to sandbox...",
......
......@@ -184,6 +184,8 @@
"code_error.skill_error.requirements_too_long": "Requirements must be less than 8000 characters",
"code_error.skill_error.skill_name_too_long": "Skill name must be 50 characters or fewer",
"code_error.skill_error.un_auth_skill": "Unauthorized to Operate This Skill",
"code_error.sandbox_error.agent_sandbox_initializing": "The virtual machine is initializing. Please try again later.",
"code_error.sandbox_error.agent_sandbox_permission_denied": "The current app is not authorized to use the sandbox/VM. Please contact an administrator to configure it.",
"code_error.system_error.community_version_num_limit": "Exceeded Open Source Version Limit, Please Upgrade to Commercial Version: https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "Exceed the maximum number of applications in the system",
"code_error.system_error.license_dataset_amount_limit": "Exceed the maximum number of knowledge bases in the system",
......
......@@ -505,7 +505,12 @@
"upload_method": "上传方式",
"url_upload": "文件链接",
"use_agent_sandbox": "虚拟机",
"enable_agent_sandbox": "启用虚拟机",
"sandbox_config": "虚拟机配置",
"use_computer_desc": "开启后,AI 将获得一个虚拟机环境,可执行命令、操作文件、运行代码。每个会话共享一个虚拟机环境。",
"sandbox_entrypoint": "启动脚本(sh)",
"sandbox_entrypoint_desc": "脚本执行时机:\n虚拟机文件和技能包注入后,执行该脚本\n\n执行规则:\n1. 脚本默认仅会执行一次。\n2. 若脚本发生内容变更,会重新执行。\n3. 脚本执行失败不会阻断虚拟机运行。\n\n适用场景:\n1. 安装依赖包\n2. 初始化特殊文件",
"sandbox_entrypoint_placeholder": "例如:\npip install -r requirements.txt",
"used_points": "积分用量:",
"variable.internal_type_desc": "仅在工作流内部使用,不会出现在对话框中",
"variable.select type_desc": "会在站内对话和运行预览中显示输入框,在分享链接中不会显示此变量",
......
......@@ -199,7 +199,7 @@
"sandbox_status_failed": "沙箱创建失败",
"sandbox_status_failed_with_message": "沙箱创建失败:{{message}}",
"sandbox_status_fetchSkills": "正在获取技能信息...",
"sandbox_status_lazyInit": "正在准备沙箱环境...",
"sandbox_status_lazyInit": "虚拟机启动中...",
"sandbox_status_ready_cold": "沙箱环境就绪",
"sandbox_status_ready_warm": "沙箱环境就绪(热启动)",
"sandbox_status_uploadingPackage": "正在上传 Skill 包到沙箱...",
......
......@@ -184,6 +184,8 @@
"code_error.skill_error.requirements_too_long": "需求描述不能超过 8000 字符",
"code_error.skill_error.skill_name_too_long": "技能名称不能超过 50 字符",
"code_error.skill_error.un_auth_skill": "无权操作该技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虚拟机正在初始化,请稍后重试。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "当前应用无权使用虚拟机,请联系管理员配置。",
"code_error.system_error.community_version_num_limit": "超出社区版数量限制,请升级商业版: https://fastgpt.in",
"code_error.system_error.license_app_amount_limit": "超出系统最大应用数量",
"code_error.system_error.license_dataset_amount_limit": "超出系统最大知识库数量",
......
......@@ -491,7 +491,12 @@
"upload_method": "上傳方式",
"url_upload": "文件鏈接",
"use_agent_sandbox": "虛擬機",
"use_computer_desc": "開啟後,AI 將獲得一個虛擬機器環境,執行指令、操作檔案、執行程式碼。\n每个会话共享一个虚拟机环境。",
"enable_agent_sandbox": "啟用虛擬機",
"sandbox_config": "虛擬機配置",
"use_computer_desc": "開啟後,AI 將獲得一個虛擬機器環境,執行指令、操作檔案、執行程式碼。\n每個會話共享一個虛擬機器環境。",
"sandbox_entrypoint": "啟動腳本(sh)",
"sandbox_entrypoint_desc": "腳本執行時機:\n虛擬機檔案和技能包注入後,執行該腳本\n\n執行規則:\n1. 腳本預設僅會執行一次。\n2. 若腳本發生內容變更,會重新執行。\n3. 腳本執行失敗不會阻斷虛擬機運行。\n\n適用場景:\n1. 安裝依賴包\n2. 初始化特殊文件",
"sandbox_entrypoint_placeholder": "例如:\npip install -r requirements.txt",
"used_points": "積分用量:",
"variable.internal_type_desc": "僅在工作流內部使用,不會出現在對話框中",
"variable.select type_desc": "會在站內對話和運行預覽中顯示輸入框,在分享鏈接中不會顯示此變量",
......
......@@ -196,7 +196,7 @@
"sandbox_status_failed": "沙箱創建失敗",
"sandbox_status_failed_with_message": "沙箱創建失敗:{{message}}",
"sandbox_status_fetchSkills": "正在獲取技能資訊...",
"sandbox_status_lazyInit": "正在準備沙箱環境...",
"sandbox_status_lazyInit": "虛擬機啟動中...",
"sandbox_status_ready_cold": "沙箱環境就緒",
"sandbox_status_ready_warm": "沙箱環境就緒(熱啟動)",
"sandbox_status_uploadingPackage": "正在上傳 Skill 包到沙箱...",
......
......@@ -182,6 +182,8 @@
"code_error.skill_error.requirements_too_long": "需求描述不能超過 8000 字元",
"code_error.skill_error.skill_name_too_long": "技能名稱不能超過 50 字元",
"code_error.skill_error.un_auth_skill": "無權操作該技能",
"code_error.sandbox_error.agent_sandbox_initializing": "虛擬機正在初始化,請稍後重試。",
"code_error.sandbox_error.agent_sandbox_permission_denied": "當前應用無權使用虛擬機,請聯絡管理員配置。",
"code_error.system_error.community_version_num_limit": "超出開源版數量限制,請升級商業版:https://fastgpt.io",
"code_error.system_error.license_app_amount_limit": "超出系統最大應用數量",
"code_error.system_error.license_dataset_amount_limit": "超出系統最大知識庫數量",
......
......@@ -29,7 +29,7 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useSkillManager } from './hooks/useSkillManager';
import { AGENT_SANDBOX_TOOLSET_ID, SANDBOX_ICON } from '@fastgpt/global/core/ai/sandbox/tools';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import SandboxTipTag from '../../components/SandboxTipTag';
import SandboxConfigButton from '../../components/SandboxConfigButton';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useUserStore } from '@/web/support/user/useUserStore';
import MyTag from '@fastgpt/web/components/common/Tag/index';
......@@ -269,20 +269,21 @@ const EditForm = ({
<QuestionTip ml={1} label={t('app:use_computer_desc')} />
</Flex>
<Box mr={2}>
{showSandbox && enableSandbox ? (
<SandboxTipTag />
) : (
<MyTag>
{showSandbox
? t('app:sandbox_free_not_support')
: t('app:sandbox_not_support_tip')}
</MyTag>
)}
</Box>
<Switch
isChecked={appForm.aiSettings.useAgentSandbox ?? false}
onChange={(e) => onChangeAgentSandbox(e.target.checked)}
<SandboxConfigButton
showSandbox={!!showSandbox}
enableSandbox={enableSandbox}
isEnabled={appForm.aiSettings.useAgentSandbox ?? false}
entrypoint={appForm.aiSettings.sandboxEntrypoint}
onChangeSandbox={onChangeAgentSandbox}
onChangeEntrypoint={(value) => {
setAppForm((state) => ({
...state,
aiSettings: {
...state.aiSettings,
sandboxEntrypoint: value
}
}));
}}
/>
</Flex>
</Box>
......
......@@ -67,7 +67,11 @@ export const appWorkflow2AgentForm = ({
NodeInputKeyEnum.aiChatReasoningEffort
);
defaultAppForm.aiSettings.aiChatTopP = inputMap.get(NodeInputKeyEnum.aiChatTopP);
defaultAppForm.aiSettings.useAgentSandbox = inputMap.get(NodeInputKeyEnum.useAgentSandbox);
const useAgentSandbox = inputMap.get(NodeInputKeyEnum.useAgentSandbox);
defaultAppForm.aiSettings.useAgentSandbox = useAgentSandbox;
defaultAppForm.aiSettings.sandboxEntrypoint = inputMap.get(
NodeInputKeyEnum.sandboxEntrypoint
);
const tools = inputMap.get(NodeInputKeyEnum.selectedTools) as FlowNodeTemplateType[];
if (tools) {
......@@ -143,6 +147,7 @@ export function agentForm2AppWorkflow(
chatConfig: AppChatConfigType;
} {
const aiChatNodeId = '7BdojPlukIQw';
const normalizedSandboxEntrypoint = data.aiSettings.sandboxEntrypoint?.trim() || undefined;
const modelData = getWebLLMModel(data.aiSettings.model);
const modelMultimodal = {
vision: !!modelData?.vision,
......@@ -345,6 +350,13 @@ export function agentForm2AppWorkflow(
valueType: WorkflowIOValueTypeEnum.boolean,
value: data.aiSettings.useAgentSandbox ?? false
},
{
key: NodeInputKeyEnum.sandboxEntrypoint,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
valueType: WorkflowIOValueTypeEnum.string,
value: normalizedSandboxEntrypoint
},
// Skills configuration
...(data.selectedAgentSkills && data.selectedAgentSkills.length > 0
? [
......
import React from 'react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
import { Box, Button, Flex, Grid } from '@chakra-ui/react';
import { Box, Button, Flex, Grid, IconButton } from '@chakra-ui/react';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyBox from '@fastgpt/web/components/common/MyBox';
......@@ -70,7 +70,11 @@ const SkillSelectModal = ({
<MyBox isLoading={isLoadingSkillList} mt={2} pb={3} flex={'1 0 0'} h={0}>
<Box overflow={'overlay'} height={'100%'}>
{skillList.length > 0 ? (
<Grid gridTemplateColumns={['1fr', '1fr 1fr']} gap={3} px={[3, 6]}>
<Grid
gridTemplateColumns={['minmax(0, 1fr)', 'repeat(2, minmax(0, 1fr))']}
gap={3}
px={[3, 6]}
>
{skillList.map((item) => (
<SkillCard
key={item._id}
......@@ -150,8 +154,11 @@ const SkillCard = React.memo(function SkillCard({
</Box>
}
>
<Flex
<Grid
alignItems={'center'}
gridTemplateColumns={'auto minmax(0, 1fr) auto'}
columnGap={2}
minW={0}
py={3}
px={3}
_hover={{ bg: 'myWhite.600' }}
......@@ -168,13 +175,11 @@ const SkillCard = React.memo(function SkillCard({
flexShrink={0}
/>
)}
<Box flex={'1 0 0'} ml={3}>
<Box minW={0}>
<Box color={'myGray.900'} fontWeight={'500'} fontSize={'sm'} className={'textEllipsis'}>
{item.name}
</Box>
</Box>
<Box flex={1} />
{isFolder ? (
<Button
size={'sm'}
......@@ -187,16 +192,13 @@ const SkillCard = React.memo(function SkillCard({
{t('common:Open')}
</Button>
) : isSelected ? (
<Button
size={'sm'}
variant={'grayDanger'}
leftIcon={<MyIcon name={'delete'} w={'16px'} mr={-1} />}
<IconButton
aria-label={t('common:Remove')}
size={'xsSquare'}
variant={'whiteDanger'}
icon={<MyIcon name={'delete'} w={'13px'} />}
onClick={onRemove}
px={2}
fontSize={'mini'}
>
{t('common:Remove')}
</Button>
/>
) : (
<MyTooltip label={isAtLimit ? t('skill:skill_select_limit_tip') : undefined}>
<Button
......@@ -212,7 +214,7 @@ const SkillCard = React.memo(function SkillCard({
</Button>
</MyTooltip>
)}
</Flex>
</Grid>
</MyTooltip>
);
});
......@@ -24,7 +24,8 @@ export const useSkillSelectData = () => {
const { list } = await getSkillList({
source: 'mine',
parentId,
searchKey: searchKey || undefined
searchKey: searchKey || undefined,
withAppCount: false
});
return list;
},
......@@ -57,11 +58,6 @@ export const useSkillSelectData = () => {
setSearchKey('');
}, []);
const reset = useCallback(() => {
setSearchKey('');
setNavStack([]);
}, []);
return {
skillList,
isLoadingSkillList,
......@@ -69,7 +65,6 @@ export const useSkillSelectData = () => {
setSearchKey,
paths,
onEnterFolder,
onUpdateParentId,
reset
onUpdateParentId
};
};
......@@ -32,8 +32,7 @@ import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover'
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { SmallAddIcon } from '@chakra-ui/icons';
import { SANDBOX_ICON } from '@fastgpt/global/core/ai/sandbox/tools';
import SandboxTipTag from '../../components/SandboxTipTag';
import SandboxNotSupportTip from '../../components/SandboxNotSupportTip';
import SandboxConfigButton from '../../components/SandboxConfigButton';
import { useUserStore } from '@/web/support/user/useUserStore';
import DatasetCard from '@/components/core/app/DatasetCard';
......@@ -260,17 +259,12 @@ const EditForm = ({
<FormLabel ml={2}>{t('app:use_agent_sandbox')}</FormLabel>
<QuestionTip ml={1} label={t('app:use_computer_desc')} />
</Flex>
<Box mr={2}>
{showSandbox && enableSandbox ? (
<SandboxTipTag />
) : (
<SandboxNotSupportTip type={showSandbox ? 'freeDisable' : 'systemDisable'} />
)}
</Box>
<Switch
isChecked={isAgentSandboxEnabled}
onChange={(e) => {
const checked = e.target.checked;
<SandboxConfigButton
showSandbox={!!showSandbox}
enableSandbox={enableSandbox}
isEnabled={isAgentSandboxEnabled}
entrypoint={appForm.aiSettings.sandboxEntrypoint}
onChangeSandbox={(checked) => {
if (checked && (!showSandbox || !enableSandbox)) return;
setAppForm((state) => ({
......@@ -281,6 +275,15 @@ const EditForm = ({
}
}));
}}
onChangeEntrypoint={(value) => {
setAppForm((state) => ({
...state,
aiSettings: {
...state.aiSettings,
sandboxEntrypoint: value
}
}));
}}
/>
</Flex>
</Box>
......
......@@ -106,6 +106,10 @@ export const appWorkflow2Form = ({
node.inputs,
NodeInputKeyEnum.useAgentSandbox
);
defaultAppForm.aiSettings.sandboxEntrypoint = findInputValueByKey(
node.inputs,
NodeInputKeyEnum.sandboxEntrypoint
);
} else if (node.flowNodeType === FlowNodeTypeEnum.datasetSearchNode) {
defaultAppForm.dataset.datasets = findInputValueByKey(
node.inputs,
......@@ -583,6 +587,8 @@ export function form2AppWorkflow(
};
}
function toolTemplates(formData: AppFormEditFormType): WorkflowType {
const normalizedSandboxEntrypoint = formData.aiSettings.sandboxEntrypoint?.trim() || undefined;
const toolNodeId = getNanoid(6);
// Dataset tool config
......@@ -719,6 +725,13 @@ export function form2AppWorkflow(
value: formData.aiSettings.useAgentSandbox ?? false
},
{
key: NodeInputKeyEnum.sandboxEntrypoint,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
label: '',
valueType: WorkflowIOValueTypeEnum.string,
value: normalizedSandboxEntrypoint
},
{
key: NodeInputKeyEnum.aiSystemPrompt,
renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference],
max: 3000,
......
......@@ -42,12 +42,14 @@ import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants';
import type { AppDatasetSearchParamsType } from '@fastgpt/global/core/app/type';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import SandboxTipTag from '@/pageComponents/app/detail/components/SandboxTipTag';
import { RechargeModal } from '@/components/support/wallet/NotSufficientModal';
import { useToast } from '@fastgpt/web/hooks/useToast';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import DatasetCard from '@/components/core/app/DatasetCard';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import WorkflowSandboxConfig, {
createSandboxEntrypointInput
} from '../components/WorkflowSandboxConfig';
const PromptEditor = dynamic(() => import('@fastgpt/web/components/common/Textarea/PromptEditor'));
const SkillSelectModal = dynamic(
......@@ -253,12 +255,10 @@ const NodeAgent = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
() => inputs.find((i) => i.key === NodeInputKeyEnum.useAgentSandbox),
[inputs]
);
const sandboxRenderType = sandboxInput ? getRenderType(sandboxInput) : undefined;
const showSandboxInput =
!!sandboxInput &&
!(sandboxInput.isPro && !feConfigs?.isPlus) &&
sandboxRenderType !== FlowNodeInputTypeEnum.hidden &&
!sandboxInput.canEdit;
const sandboxEntrypointInput = useMemo(
() => inputs.find((i) => i.key === NodeInputKeyEnum.sandboxEntrypoint),
[inputs]
);
const toolsInput = useMemo(
() => inputs.find((i) => i.key === NodeInputKeyEnum.selectedTools),
[inputs]
......@@ -272,6 +272,7 @@ const NodeAgent = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
NodeInputKeyEnum.aiSystemPrompt,
NodeInputKeyEnum.skills,
NodeInputKeyEnum.useAgentSandbox,
NodeInputKeyEnum.sandboxEntrypoint,
NodeInputKeyEnum.selectedTools
]),
[]
......@@ -558,32 +559,28 @@ const NodeAgent = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
{/* 3. Chat inputs (fileLink, userChatInput) */}
{chatInputs.length > 0 && <RenderInput nodeId={nodeId} flowInputList={chatInputs} />}
{showSandboxInput && sandboxInput && (
<Box
mb={5}
position={'relative'}
display={'flex'}
alignItems={'center'}
justifyContent={'space-between'}
>
<InputLabel nodeId={nodeId} input={sandboxInput} />
<Flex alignItems={'center'} gap={1} className={'nodrag'}>
{showSandbox && enableSandbox ? (
<SandboxTipTag />
) : (
<MyTag>
{showSandbox
? t('app:sandbox_free_not_support')
: t('app:sandbox_not_support_tip')}
</MyTag>
)}
<Switch
isChecked={!!sandboxInput.value}
onChange={(e) => onChangeAgentSandbox(e.target.checked)}
/>
</Flex>
</Box>
)}
<WorkflowSandboxConfig
nodeId={nodeId}
sandboxInput={sandboxInput}
sandboxEntrypointInput={sandboxEntrypointInput}
showSandbox={!!showSandbox}
enableSandbox={enableSandbox}
isPlus={feConfigs?.isPlus}
onChangeSandbox={onChangeAgentSandbox}
onChangeEntrypoint={(value) => {
onChangeNode({
nodeId,
key: NodeInputKeyEnum.sandboxEntrypoint,
type: 'replaceInput',
value: sandboxEntrypointInput
? {
...sandboxEntrypointInput,
value
}
: createSandboxEntrypointInput(value)
});
}}
/>
{/* 4. Skills section (manual select / reference dual mode) */}
{showWorkflowAgentSkills && skillsInput && (
......
......@@ -14,21 +14,123 @@ import { useContextSelector } from 'use-context-selector';
import CatchError from './render/RenderOutput/CatchError';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { WorkflowUtilsContext } from '../../context/workflowUtilsContext';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import WorkflowSandboxConfig, {
createSandboxEntrypointInput
} from './components/WorkflowSandboxConfig';
import { WorkflowActionsContext } from '../../context/workflowActionsContext';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
const NodeToolCall = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, inputs, outputs, catchError } = data;
const { toast } = useToast();
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const splitOutput = useContextSelector(WorkflowUtilsContext, (ctx) => ctx.splitOutput);
const { feConfigs } = useSystemStore();
const { teamPlanStatus } = useUserStore();
const enableSandbox = !teamPlanStatus?.standard || !!teamPlanStatus?.standard?.enableSandbox;
const showSandbox = feConfigs.show_agent_sandbox;
const { successOutputs, errorOutputs } = useMemoEnhance(
() => splitOutput(outputs),
[outputs, splitOutput]
);
const sandboxInput = React.useMemo(
() => inputs.find((input) => input.key === NodeInputKeyEnum.useAgentSandbox),
[inputs]
);
const sandboxEntrypointInput = React.useMemo(
() => inputs.find((input) => input.key === NodeInputKeyEnum.sandboxEntrypoint),
[inputs]
);
const { beforeSandboxInputs, afterSandboxInputs } = React.useMemo(() => {
const visibleInputs = inputs.filter(
(input) =>
input.key !== NodeInputKeyEnum.useAgentSandbox &&
input.key !== NodeInputKeyEnum.sandboxEntrypoint
);
const sandboxIndex = inputs.findIndex(
(input) => input.key === NodeInputKeyEnum.useAgentSandbox
);
if (sandboxIndex < 0) {
return {
beforeSandboxInputs: visibleInputs,
afterSandboxInputs: []
};
}
return {
beforeSandboxInputs: visibleInputs.filter(
(input) => inputs.findIndex((item) => item.key === input.key) < sandboxIndex
),
afterSandboxInputs: visibleInputs.filter(
(input) => inputs.findIndex((item) => item.key === input.key) > sandboxIndex
)
};
}, [inputs]);
const onChangeSandbox = React.useCallback(
(checked: boolean) => {
if (!sandboxInput) return;
if (checked) {
if (!showSandbox) {
toast({
status: 'warning',
title: t('skill:sandbox_system_not_configured_toast')
});
return;
}
if (!enableSandbox) {
toast({
status: 'warning',
title: t('app:sandbox_free_not_support')
});
return;
}
}
onChangeNode({
nodeId,
key: NodeInputKeyEnum.useAgentSandbox,
type: 'updateInput',
value: {
...sandboxInput,
value: checked
}
});
},
[enableSandbox, nodeId, onChangeNode, sandboxInput, showSandbox, t, toast]
);
return (
<NodeCard minW={'480px'} selected={selected} {...data}>
<Container>
<IOTitle text={t('common:Input')} />
<RenderInput nodeId={nodeId} flowInputList={inputs} />
<RenderInput nodeId={nodeId} flowInputList={beforeSandboxInputs} />
<WorkflowSandboxConfig
nodeId={nodeId}
sandboxInput={sandboxInput}
sandboxEntrypointInput={sandboxEntrypointInput}
showSandbox={!!showSandbox}
enableSandbox={enableSandbox}
isPlus={feConfigs?.isPlus}
onChangeSandbox={onChangeSandbox}
onChangeEntrypoint={(value) => {
onChangeNode({
nodeId,
key: NodeInputKeyEnum.sandboxEntrypoint,
type: 'replaceInput',
value: sandboxEntrypointInput
? {
...sandboxEntrypointInput,
value
}
: createSandboxEntrypointInput(value)
});
}}
/>
<RenderInput nodeId={nodeId} flowInputList={afterSandboxInputs} />
</Container>
<Container>
<IOTitle text={t('common:Output')} nodeId={nodeId} catchError={catchError} />
......
import React from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import SandboxConfigButton from '@/pageComponents/app/detail/components/SandboxConfigButton';
import InputLabel from '../render/RenderInput/Label';
const getRenderType = (input: FlowNodeInputItemType) =>
input.renderTypeList?.[input.selectedTypeIndex || 0] || FlowNodeInputTypeEnum.custom;
export const createSandboxEntrypointInput = (value: string): FlowNodeInputItemType => ({
key: NodeInputKeyEnum.sandboxEntrypoint,
renderTypeList: [FlowNodeInputTypeEnum.custom],
label: '',
valueType: WorkflowIOValueTypeEnum.string,
value
});
/**
* 工作流节点里的 sandbox 开关与启动脚本编辑区。
*
* 启动脚本依赖 sandbox 开关,放在同一个组件里能避免 custom input
* 被通用 RenderInput 空渲染后无法编辑。
*/
const WorkflowSandboxConfig = ({
nodeId,
sandboxInput,
sandboxEntrypointInput,
showSandbox,
enableSandbox,
isPlus,
onChangeSandbox,
onChangeEntrypoint
}: {
nodeId: string;
sandboxInput?: FlowNodeInputItemType;
sandboxEntrypointInput?: FlowNodeInputItemType;
showSandbox: boolean;
enableSandbox: boolean;
isPlus?: boolean;
onChangeSandbox: (checked: boolean) => void;
onChangeEntrypoint: (value: string) => void;
}) => {
if (!sandboxInput) return null;
const sandboxRenderType = getRenderType(sandboxInput);
const showSandboxInput =
!(sandboxInput.isPro && !isPlus) &&
sandboxRenderType !== FlowNodeInputTypeEnum.hidden &&
!sandboxInput.canEdit;
if (!showSandboxInput) return null;
return (
<Box mb={5} position={'relative'}>
<Flex alignItems={'center'} justifyContent={'space-between'}>
<InputLabel nodeId={nodeId} input={sandboxInput} />
<SandboxConfigButton
className={'nodrag'}
showSandbox={showSandbox}
enableSandbox={enableSandbox}
isEnabled={!!sandboxInput.value}
entrypoint={(sandboxEntrypointInput?.value as string) || ''}
onChangeSandbox={onChangeSandbox}
onChangeEntrypoint={onChangeEntrypoint}
/>
</Flex>
</Box>
);
};
export default React.memo(WorkflowSandboxConfig);
import React from 'react';
import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { Box, Flex, Switch } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import dynamic from 'next/dynamic';
import InputLabel from './Label';
......@@ -9,12 +9,6 @@ import { useSystemStore } from '@/web/common/system/useSystemStore';
import VariableTip from '@/components/common/Textarea/MyTextarea/VariableTip';
import CommonInputForm from './templates/CommonInputForm';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import SandboxTipTag from '@/pageComponents/app/detail/components/SandboxTipTag';
import SandboxNotSupportTip from '@/pageComponents/app/detail/components/SandboxNotSupportTip';
import { useUserStore } from '@/web/support/user/useUserStore';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import { useTranslation } from 'next-i18next';
const RenderList: Record<
FlowNodeInputTypeEnum,
......@@ -107,11 +101,7 @@ type Props = {
mb?: number;
};
const RenderInput = ({ flowInputList, nodeId, CustomComponent, mb = 5 }: Props) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { teamPlanStatus } = useUserStore();
const enableSandbox = !teamPlanStatus?.standard || !!teamPlanStatus?.standard?.enableSandbox;
const showSandbox = feConfigs.show_agent_sandbox;
const filterProInputs = useMemoEnhance(() => {
return flowInputList.filter((input) => {
......@@ -147,37 +137,14 @@ const RenderInput = ({ flowInputList, nodeId, CustomComponent, mb = 5 }: Props)
if (!RenderItem) return null;
const renderInput =
input.key === NodeInputKeyEnum.useAgentSandbox
? {
...input,
customRender: ({
value,
onChange
}: {
value: boolean;
onChange?: (value: boolean) => void;
}) => (
<Switch
isChecked={!!value}
onChange={(e) => {
const checked = e.target.checked;
if (checked && (!showSandbox || !enableSandbox)) return;
onChange?.(checked);
}}
/>
)
}
: input;
return {
Component: (
<RenderItem.Component inputs={filterProInputs} item={renderInput} nodeId={nodeId} />
<RenderItem.Component inputs={filterProInputs} item={input} nodeId={nodeId} />
),
LableRightComponent: RenderItem.LableRightComponent ? (
<RenderItem.LableRightComponent
inputs={filterProInputs}
item={renderInput}
item={input}
nodeId={nodeId}
/>
) : undefined
......@@ -203,32 +170,10 @@ const RenderInput = ({ flowInputList, nodeId, CustomComponent, mb = 5 }: Props)
/>
)}
{/* tmp */}
{input.key === NodeInputKeyEnum.useAgentSandbox ? (
RenderComponent ? (
<Flex alignItems={'center'} gap={1}>
{showSandbox && enableSandbox ? (
<SandboxTipTag />
) : (
<MyTag>
{t(
showSandbox ? 'app:sandbox_free_not_support' : 'app:sandbox_not_support_tip'
)}
</MyTag>
)}
{RenderComponent.Component}
</Flex>
) : (
<SandboxNotSupportTip type={showSandbox ? 'freeDisable' : 'systemDisable'} />
)
) : (
<>
{!!RenderComponent && (
<Box mt={isRowUI ? 0 : 2} className={'nodrag'}>
{RenderComponent.Component}
</Box>
)}
</>
{!!RenderComponent && (
<Box mt={isRowUI ? 0 : 2} className={'nodrag'}>
{RenderComponent.Component}
</Box>
)}
</Box>
);
......
......@@ -196,7 +196,10 @@ export const WorkflowUtilsProvider = ({ children }: { children: ReactNode }) =>
// Sandbox unavailable check
const sandboxUnavailableNode = nodes.find((node) => {
if (node.data.flowNodeType === FlowNodeTypeEnum.agent) {
if (
node.data.flowNodeType === FlowNodeTypeEnum.agent ||
node.data.flowNodeType === FlowNodeTypeEnum.toolCall
) {
const useAgentSandbox = node.data.inputs.find(
(input) => input.key === NodeInputKeyEnum.useAgentSandbox
)?.value;
......
import React from 'react';
import { Box, Button, Flex, HStack, Switch, useDisclosure, type FlexProps } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import { useTranslation } from 'next-i18next';
import SandboxEntrypointEditor from './SandboxEntrypointEditor';
import SandboxNotSupportTip from './SandboxNotSupportTip';
type SandboxConfigButtonProps = Omit<FlexProps, 'onChange'> & {
showSandbox: boolean;
enableSandbox: boolean;
isEnabled: boolean;
entrypoint?: string;
onChangeSandbox: (checked: boolean) => void;
onChangeEntrypoint?: (value: string) => void;
};
/**
* 统一渲染 Agent sandbox 配置入口。
*
* 列表行只保留状态提示与“配置”按钮,实际开关和启动脚本放在弹窗内,避免不同编辑入口
* 各自维护一套开关与脚本编辑 UI。
*/
function SandboxConfigButton({
showSandbox,
enableSandbox,
isEnabled,
entrypoint,
onChangeSandbox,
onChangeEntrypoint,
...props
}: SandboxConfigButtonProps) {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const [localEnabled, setLocalEnabled] = React.useState(isEnabled);
const [localEntrypoint, setLocalEntrypoint] = React.useState(entrypoint || '');
const canOpenConfig = (showSandbox && enableSandbox) || isEnabled;
const openConfig = React.useCallback(() => {
setLocalEnabled(isEnabled);
setLocalEntrypoint(entrypoint || '');
onOpen();
}, [entrypoint, isEnabled, onOpen]);
const confirmConfig = React.useCallback(() => {
if (localEnabled !== isEnabled) {
onChangeSandbox(localEnabled);
}
if (onChangeEntrypoint && localEntrypoint !== (entrypoint || '')) {
onChangeEntrypoint(localEntrypoint);
}
onClose();
}, [
entrypoint,
isEnabled,
localEnabled,
localEntrypoint,
onChangeEntrypoint,
onChangeSandbox,
onClose
]);
return (
<Flex alignItems={'center'} gap={1} {...props}>
{showSandbox && enableSandbox ? (
<MyTag>{t('app:sandbox_free_tip')}</MyTag>
) : (
<SandboxNotSupportTip type={showSandbox ? 'freeDisable' : 'systemDisable'} />
)}
<MyTooltip label={t('common:Config')}>
<Button
variant={'transparentBase'}
leftIcon={<MyIcon name={'common/settingLight'} w={'14px'} />}
iconSpacing={1}
size={'sm'}
mr={'-5px'}
color={'myGray.600'}
isDisabled={!canOpenConfig}
onClick={openConfig}
>
{t('common:Config')}
</Button>
</MyTooltip>
{isOpen && (
<MyModal
title={t('app:sandbox_config')}
isOpen={isOpen}
onClose={onClose}
w={'640px'}
maxW={'90vw'}
isCentered
borderRadius={'10px'}
footer={
<Button onClick={confirmConfig} px={8}>
{t('common:Confirm')}
</Button>
}
>
<Flex alignItems={'center'}>
<HStack spacing={1}>
<FormLabel>{t('app:enable_agent_sandbox')}</FormLabel>
<QuestionTip label={t('app:use_computer_desc')} />
</HStack>
<Box flex={1} />
<Switch isChecked={localEnabled} onChange={(e) => setLocalEnabled(e.target.checked)} />
</Flex>
{onChangeEntrypoint && (
<SandboxEntrypointEditor value={localEntrypoint} onChange={setLocalEntrypoint} />
)}
</MyModal>
)}
</Flex>
);
}
export default React.memo(SandboxConfigButton);
import { Box, Flex, type BoxProps } from '@chakra-ui/react';
import { SANDBOX_ENTRYPOINT_MAX_LENGTH } from '@fastgpt/global/core/ai/sandbox/constants';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import CodeEditor from '@fastgpt/web/components/common/Textarea/CodeEditor';
import { useTranslation } from 'next-i18next';
type SandboxEntrypointEditorProps = Omit<BoxProps, 'onChange'> & {
value?: string;
onChange: (value: string) => void;
};
function SandboxEntrypointEditor({ value, onChange, ...props }: SandboxEntrypointEditorProps) {
const { t } = useTranslation();
return (
<Box mt={3} {...props}>
<Flex alignItems={'center'} mb={2}>
<FormLabel>{t('app:sandbox_entrypoint')}</FormLabel>
<QuestionTip ml={1} label={t('app:sandbox_entrypoint_desc')} />
</Flex>
<CodeEditor
value={value || ''}
language={'sh'}
bg={'myGray.50'}
defaultHeight={180}
options={{
wordWrap: 'on'
}}
onChange={(e) => onChange(e.slice(0, SANDBOX_ENTRYPOINT_MAX_LENGTH))}
/>
</Box>
);
}
export default SandboxEntrypointEditor;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment