Skip to content

Commit c148781

Browse files
committed
feat(sandbox): implement graceful degradation for app chat when sandbox is unavailable
1 parent eb8c613 commit c148781

44 files changed

Lines changed: 1173 additions & 249 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
# Sandbox 不可用时的对话降级设计
2+
3+
## 1. 背景
4+
5+
App Chat 的 Sandbox 可能因应用配置、团队套餐或系统配置变为不可用。当前不同入口的处理不一致:部分路径会跳过 Sandbox,部分路径会抛出 Agent 错误并中断对话,Sandbox 文件入口也无法向用户稳定说明不可用原因。
6+
7+
本需求统一 Sandbox 不可用时的运行行为和用户提示,使 Sandbox 从对话的强依赖降级为可选能力。
8+
9+
## 2. 需求范围
10+
11+
### 2.1 Sandbox 不可用原因
12+
13+
需要区分以下三种原因:
14+
15+
1. `appDisabled`:应用开发者关闭 Sandbox。
16+
2. `teamPlanUnavailable`:应用所属团队套餐不再提供 Sandbox 权限,包括套餐过期后降级到无 Sandbox 权限的套餐。
17+
3. `systemDisabled`:系统未配置或已下架 Sandbox 功能。
18+
19+
### 2.2 对话运行行为
20+
21+
普通 App Chat 运行时,只要命中任意一种不可用原因:
22+
23+
- 不向模型注入 Sandbox system tools。
24+
- 不注入 Sandbox system prompt。
25+
- 不创建、恢复、连接或调用 Sandbox runtime。
26+
- 不执行 Sandbox entrypoint。
27+
- 不再把 Sandbox 权限错误作为 Agent/ToolCall 节点错误写入对话。
28+
- 其他模型调用、普通工具、知识库和工作流节点继续正常运行。
29+
- 后端记录结构化日志,并能区分三种不可用原因。
30+
31+
### 2.3 前端行为
32+
33+
- 页面加载和正常对话过程中不主动弹出 Sandbox 不可用提示。
34+
- 用户点击右上角虚拟机入口时才进行提示。
35+
- 三种不可用原因使用统一 Toast 文案。
36+
- Sandbox 可用时维持现有行为,正常打开 Sandbox 文件编辑器。
37+
38+
## 3. 当前实现差异
39+
40+
### 3.1 应用开发者关闭
41+
42+
Agent/ToolCall 节点的 `useAgentSandbox``false` 时,普通 Sandbox tool 和 runtime 已基本不会启用。但 Agent 节点配置了 Skill 时,会因为 Skill 的 runtime 依赖强制启用 Sandbox,不能完全满足“应用关闭后不调用 Sandbox”。
43+
44+
### 3.2 团队套餐无权限
45+
46+
- Agent 路径在 `prepareAgentSandboxRuntime` 中校验套餐,失败后由 Agent dispatch catch 转成节点错误。
47+
- ToolCall 路径在 dispatch 开始阶段校验套餐并直接抛错。
48+
- 两条路径都会阻断本轮正常对话。
49+
50+
### 3.3 系统关闭
51+
52+
- 普通 Sandbox 开关会因 `show_agent_sandbox=false` 被忽略。
53+
- Agent 配置了 Skill 时会显式抛出 Sandbox 权限错误,阻断本轮对话。
54+
- `checkExist` 直接返回 `exists=false`,前端无法区分系统关闭和实例不存在。
55+
56+
## 4. 已确认验收标准
57+
58+
1. 三种 Sandbox 不可用状态均不导致对话报错或中断。
59+
2. 三种状态均不向模型暴露 Sandbox 工具,也不发生 Sandbox runtime 调用。
60+
3. 后端日志能通过稳定的原因字段区分三种状态。
61+
4. 前端只在用户点击虚拟机入口时显示 Toast。
62+
5. Toast 文案对三种状态保持一致。
63+
6. Sandbox 可用时不改变现有对话和文件编辑器行为。
64+
65+
## 5. 已确认产品决策
66+
67+
1. 简体中文 Toast 文案:`虚拟机功能已被关闭,暂时无法使用`
68+
2. Sandbox 不可用时,同时跳过依赖 Sandbox 的所有 Skill 和 Sandbox system tools。
69+
3. 虚拟机入口保持原有显示逻辑,不因为本需求新增常驻入口:
70+
- 后端确认 Sandbox 实例存在;或
71+
- 当前已加载对话历史中出现过 Sandbox 调用。
72+
4. 静默降级覆盖普通 App Chat,包括 Workflow ToolCall 和 Agent v2/agent loop。
73+
5. Skill 编辑和 Skill 调试继续保留现有强依赖与阻断行为。
74+
6. 英文文案按简体中文语义翻译为:`The virtual machine feature has been disabled and is temporarily unavailable.`
75+
7. 繁体中文文案按简体中文语义翻译为:`虛擬機功能已被關閉,暫時無法使用`
76+
77+
## 6. 开发设计
78+
79+
### 6.1 统一状态模型
80+
81+
在 Global Sandbox 类型中增加稳定的不可用原因枚举:
82+
83+
```typescript
84+
type SandboxUnavailableReason =
85+
| 'appDisabled'
86+
| 'teamPlanUnavailable'
87+
| 'systemDisabled';
88+
```
89+
90+
`undefined` 表示 Sandbox 可用。不可用原因只描述产品配置和权限状态,不混入 provider 故障、初始化失败、文件错误等运行时异常。
91+
92+
后端普通 App Chat 的判定顺序为:
93+
94+
1. 系统未启用 Sandbox:`systemDisabled`
95+
2. 当前 Agent/ToolCall 节点未开启 Sandbox:`appDisabled`
96+
3. 应用所属团队无 Sandbox 套餐权限:`teamPlanUnavailable`
97+
4. 以上均未命中:Sandbox 可用。
98+
99+
这样可以在系统下架时避免无意义的套餐查询,在应用关闭时也不会为每次对话额外查询套餐。
100+
101+
### 6.2 后端统一降级入口
102+
103+
在 Sandbox application 层提供普通 App Chat 专用的可用性判定,调用方传入:
104+
105+
- 节点是否开启 Sandbox;
106+
- 应用所属 `teamId`
107+
108+
返回不可用原因后,Agent 和 ToolCall 只决定是否组装 Sandbox 能力,不通过异常表达三种关闭状态。可用性判定函数在返回关闭状态时直接记录固定事件名和唯一的 `reason` 字段:
109+
110+
```text
111+
reason
112+
```
113+
114+
三种关闭状态属于可控降级,使用 `info` 级别;套餐查询本身出现异常时统一降级为 `teamPlanUnavailable`,保证对话可继续。调用方不再负责日志,也不暴露额外日志 helper。
115+
116+
Sandbox 文件 API 不能只依赖前端状态查询。服务端增加统一的 Session 访问编排层:
117+
118+
- `authSandboxSession` 只负责 Chat/App/Skill 的资源访问鉴权,不再混入套餐校验开关。
119+
- 普通 App Session 在资源鉴权后统一解析 `appDisabled/teamPlanUnavailable/systemDisabled`;不可用时拒绝 Ticket、上传、下载和预览请求,且不能创建、恢复或连接 Sandbox。
120+
- Skill Edit Session 使用明确的强可用性断言,系统关闭或团队无权限时继续抛出结构化 Sandbox 权限错误。
121+
- Agent runtime 的底层准备函数只负责实例和路径准备,不再暴露 `checkTeamPermission` 布尔绕过参数;App 静默降级和 Skill Edit 强校验都在进入 runtime 前完成。
122+
123+
文件/runtime API 与对话运行共用同一个拒绝日志,只输出稳定的 `reason` 字段,不透传 API 操作或资源身份等额外上下文。
124+
125+
### 6.3 Agent v2 / agent loop
126+
127+
`dispatchRunAgent` 对普通 App Chat 使用统一判定结果:
128+
129+
- 可用:维持现有 Sandbox runtime、entrypoint、Skill 注入和初始化流程。
130+
- 不可用:
131+
- `effectiveUseAgentSandbox=false`
132+
- 不调用 `ensureAppSandboxRuntimeReady`
133+
- 不发送 Sandbox 初始化 SSE;
134+
- `ensureAgentSandboxRuntime` 直接返回空 `skillInfos`,不创建或连接 Sandbox;
135+
- 不传 `sandboxClient`,因此 agent loop 不注入 Sandbox system tools;
136+
- 不拼接 `SANDBOX_SYSTEM_PROMPT`
137+
- 忽略本轮 `selectedSkills``skillIds` 和 Sandbox entrypoint;
138+
- 普通工具、数据集工具、plan/ask 和模型调用保持不变。
139+
140+
Skill Edit 通过 `sourceType=skillEdit` 保持原路径,不走静默降级。
141+
142+
### 6.4 Workflow ToolCall
143+
144+
`dispatchRunTools` 对普通 App Chat使用同一判定:
145+
146+
- 移除当前套餐失败时直接抛出 `agentSandboxPermissionDenied` 的行为。
147+
- 不可用时把运行参数中的 `useAgentSandbox` 归一化为 `false`
148+
- 不准备 Sandbox runtime,不注入输入文件,不执行 entrypoint。
149+
- ToolCall 的 agent loop 不接收 `sandboxClient`,因此不注入 Sandbox system tools。
150+
- `useToolCatalog` 不拼接 Sandbox system prompt。
151+
- 工作流配置的其他工具节点照常进入工具目录和执行。
152+
153+
### 6.5 `checkExist` 状态扩展
154+
155+
扩展 `/core/ai/sandbox/checkExist` 响应:
156+
157+
```typescript
158+
{
159+
exists: boolean;
160+
unavailableReason?: SandboxUnavailableReason;
161+
}
162+
```
163+
164+
接口继续使用 `parseApiInput` 校验请求,并使用响应 Schema 校验返回值。为返回套餐不可用原因,`checkExist` 只做 Chat/App 读取鉴权,不在鉴权 helper 内提前抛 Sandbox 套餐错误,再显式执行状态判定。
165+
166+
为保持原有入口显示逻辑:
167+
168+
- 三种关闭状态与可用状态都从本地实例表返回真实 `exists`,关闭原因只通过 `unavailableReason` 表达。
169+
- 本地实例查询不会创建、恢复或连接远端 Sandbox。
170+
- Skill Edit:沿用现有强权限校验,不返回普通 App Chat 的静默降级原因。
171+
172+
应用是否开启 Sandbox 根据实际会话类型计算:
173+
174+
- 线上、分享等普通 App Chat 使用当前已发布版本工作流。
175+
- App Chat Test 运行的是请求携带的当前编辑态节点,不能读取已发布版本代替。测试会话在每轮运行后保存本轮 Sandbox 开关状态,状态查询和文件 API 使用该服务端会话状态;旧测试会话再回退到 App 草稿节点。
176+
- 任意 Agent/ToolCall 节点的 `useAgentSandbox``true`,即视为该会话开启 Sandbox。
177+
178+
### 6.6 前端点击拦截
179+
180+
`useSandboxStatus` 在原有 `sandboxExists` 状态外保存当前 target/chat 对应的 `unavailableReason`
181+
182+
- 按钮是否渲染仍只取决于原有 `sandboxExists` 公式。
183+
- 页面加载、状态请求完成、对话生成时均不弹 Toast。
184+
- 桌面端 `SandboxEntryIcon` 点击时:
185+
- 每次点击都重新查询服务端状态,不使用页面加载时的缓存状态作为最终结论;请求失败时维持原有打开行为,由文件 API 做服务端兜底。
186+
-`unavailableReason`:显示统一 warning Toast,不打开编辑器;
187+
- 无不可用原因:正常打开编辑器。
188+
- 移动端 ToolMenu 使用同一个点击 guard,避免绕过桌面按钮组件直接打开编辑器。
189+
- AI 回复气泡底部的虚拟机入口也使用同一个点击 guard,不允许直接打开编辑器。
190+
- 如果入口已由历史记录显示、但状态请求尚未完成,点击时主动等待或补发一次状态请求,再决定提示或打开,避免竞态下误调用 `getTicket`
191+
192+
### 6.7 国际化
193+
194+
`chat.json` 增加统一 key,例如 `sandbox_unavailable_toast`
195+
196+
- `zh-CN`: `虚拟机功能已被关闭,暂时无法使用`
197+
- `zh-Hant`: `虛擬機功能已被關閉,暫時無法使用`
198+
- `en`: `The virtual machine feature has been disabled and is temporarily unavailable.`
199+
200+
### 6.8 测试策略
201+
202+
后端局部测试:
203+
204+
- 可用性判定覆盖系统关闭、应用关闭、套餐无权限和正常可用。
205+
- Agent v2 在三种关闭状态下继续完成模型调用,不创建 Sandbox、不注入 Sandbox tools/prompt、不注入 Skill。
206+
- Workflow ToolCall 在三种关闭状态下继续运行普通工具,不创建 Sandbox、不注入 Sandbox tools/prompt。
207+
- Skill Edit 在系统/套餐不可用时仍保持原有阻断行为。
208+
- `checkExist` 覆盖三种原因、实例存在性和读取鉴权。
209+
210+
最后按仓库要求执行全量测试。
211+
212+
## 7. TODO
213+
214+
- [x] 增加 Sandbox 不可用原因枚举、统一可用性判定和结构化日志。
215+
- [x] 改造 Agent v2:普通 App Chat 不可用时跳过 runtime、prompt、tools、Skills 和 entrypoint。
216+
- [x] 改造 Workflow ToolCall:普通 App Chat 不可用时继续运行其他工具。
217+
- [x] 扩展 `checkExist` 返回不可用原因,并保持虚拟机入口原有显示逻辑。
218+
- [x] 前端桌面端和移动端统一增加点击 guard 与 Toast,不在其他时机提示。
219+
- [x] 增加简体中文、繁体中文和英文文案。
220+
- [x] 更新三类关闭状态、Skill Edit 强依赖和状态接口的局部测试。
221+
- [ ] 清理被替代的异常分支、导入和旧测试,运行全量测试。
222+
- [x] 增加 Sandbox Session 服务端访问兜底,覆盖 Ticket、上传、下载和预览。
223+
- [x] 移除 `authSandboxSession` 和 Agent runtime 的 `checkTeamPermission` 布尔绕过参数。
224+
- [x] 区分线上发布版本与 App Chat Test 编辑态的 Sandbox 开关来源。
225+
- [x] 所有虚拟机入口统一点击守卫,并在每次点击时刷新状态。
226+
- [x] 补充服务端访问和编辑调试版本回归测试。

packages/global/core/ai/sandbox/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ export const SandboxStatusEnum = {
2323
running: 'running',
2424
stopped: 'stopped'
2525
} as const;
26+
27+
/** 普通 App Chat 中 Sandbox 不可用的产品态原因。 */
28+
export enum SandboxUnavailableReasonEnum {
29+
appDisabled = 'appDisabled',
30+
teamPlanUnavailable = 'teamPlanUnavailable',
31+
systemDisabled = 'systemDisabled'
32+
}
33+
34+
/** Chat Test 保存本轮实际 Sandbox 开关的 metadata key。 */
35+
export const APP_SANDBOX_ENABLED_CHAT_METADATA_KEY = 'appSandboxEnabled';
2636
export type SandboxStatusType = (typeof SandboxStatusEnum)[keyof typeof SandboxStatusEnum];
2737

2838
// ---- 沙盒实例类型 ----

packages/global/core/ai/sandbox/type.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import z from 'zod';
2+
import { SandboxUnavailableReasonEnum } from './constants';
3+
4+
export const SandboxUnavailableReasonSchema = z.enum(SandboxUnavailableReasonEnum);
5+
export type SandboxUnavailableReason = z.infer<typeof SandboxUnavailableReasonSchema>;
26

37
export const SandboxImageConfigSchema = z.object({
48
repository: z.string(),

packages/global/core/workflow/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ export const nodeInputIsReference = (input: FlowNodeInputItemType) => {
8181
/* node */
8282
export const getGuideModule = (nodes: StoreNodeItemType[]) =>
8383
nodes.find((item) => item.flowNodeType === FlowNodeTypeEnum.systemConfig);
84+
85+
/** 判断 App 工作流是否有 Agent 或 ToolCall 节点开启 Sandbox。 */
86+
export const isAppSandboxEnabledInNodes = (nodes: StoreNodeItemType[]) =>
87+
nodes.some(
88+
(node) =>
89+
(node.flowNodeType === FlowNodeTypeEnum.agent ||
90+
node.flowNodeType === FlowNodeTypeEnum.toolCall) &&
91+
node.inputs.some(
92+
(input) => input.key === NodeInputKeyEnum.useAgentSandbox && input.value === true
93+
)
94+
);
95+
8496
export const splitGuideModule = (guideModules?: StoreNodeItemType) => {
8597
const welcomeText: string =
8698
guideModules?.inputs?.find((item) => item.key === NodeInputKeyEnum.welcomeText)?.value ?? '';

packages/global/openapi/core/ai/sandbox/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OutLinkChatAuthSchema } from '../../../../support/permission/chat';
22
import z from 'zod';
33
import { createOutLinkChatTargetInputSchema, transformChatAuthTargetInput } from '../../chat/api';
4+
import { SandboxUnavailableReasonSchema } from '../../../../core/ai/sandbox/type';
45

56
const SandboxBaseShape = {
67
chatId: z.string().meta({
@@ -77,7 +78,10 @@ export type SandboxUploadResponse = z.infer<typeof SandboxUploadResponseSchema>;
7778
export const SandboxCheckExistBodyRawSchema = createOutLinkChatTargetInputSchema(SandboxBaseShape);
7879
export const SandboxCheckExistBodySchema = withSandboxTarget({});
7980
export const SandboxCheckExistResponseSchema = z.object({
80-
exists: z.boolean().describe('沙盒是否存在')
81+
exists: z.boolean().describe('沙盒是否存在'),
82+
unavailableReason: SandboxUnavailableReasonSchema.optional().describe(
83+
'普通 App Chat 中沙盒不可用的产品态原因'
84+
)
8185
});
8286
export type SandboxCheckExistBody = z.input<typeof SandboxCheckExistBodySchema>;
8387
export type SandboxCheckExistRuntimeBody = z.output<typeof SandboxCheckExistBodySchema>;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { SandboxUnavailableReasonEnum } from '@fastgpt/global/core/ai/sandbox/constants';
2+
import { getLogger, LogCategories } from '../../../../common/logger';
3+
import { checkTeamSandboxPermission } from '../../../../support/permission/teamLimit';
4+
import { createAgentSandboxPermissionDeniedError } from '../error';
5+
6+
const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
7+
8+
export type AppSandboxAvailability =
9+
| { available: true }
10+
| {
11+
available: false;
12+
reason: SandboxUnavailableReasonEnum;
13+
};
14+
15+
/**
16+
* 判断普通 App Chat 本轮能否启用 Sandbox。
17+
*
18+
* 系统和应用开关优先于套餐查询,避免关闭状态下产生无意义的权限请求。
19+
* 套餐查询异常统一视为套餐不可用,由调用方静默降级,不能中断普通对话。
20+
*/
21+
export async function resolveAppSandboxAvailability({
22+
appEnabled,
23+
teamId
24+
}: {
25+
appEnabled: boolean;
26+
teamId: string;
27+
}): Promise<AppSandboxAvailability> {
28+
if (!global.feConfigs?.show_agent_sandbox) {
29+
logger.info('App sandbox unavailable', {
30+
reason: SandboxUnavailableReasonEnum.systemDisabled
31+
});
32+
return {
33+
available: false,
34+
reason: SandboxUnavailableReasonEnum.systemDisabled
35+
};
36+
}
37+
38+
if (!appEnabled) {
39+
logger.info('App sandbox unavailable', {
40+
reason: SandboxUnavailableReasonEnum.appDisabled
41+
});
42+
return {
43+
available: false,
44+
reason: SandboxUnavailableReasonEnum.appDisabled
45+
};
46+
}
47+
48+
try {
49+
await checkTeamSandboxPermission(teamId);
50+
return { available: true };
51+
} catch {
52+
logger.info('App sandbox unavailable', {
53+
reason: SandboxUnavailableReasonEnum.teamPlanUnavailable
54+
});
55+
return {
56+
available: false,
57+
reason: SandboxUnavailableReasonEnum.teamPlanUnavailable
58+
};
59+
}
60+
}
61+
62+
/**
63+
* 断言强依赖场景可以使用 Sandbox。
64+
*
65+
* Skill Edit、调试和显式 Sandbox API 不允许静默降级,系统关闭或套餐无权限时统一抛出
66+
* Sandbox 权限错误;普通 App Chat 应使用 resolveAppSandboxAvailability。
67+
*/
68+
export async function assertSandboxAvailable(teamId: string): Promise<void> {
69+
if (!global.feConfigs?.show_agent_sandbox) {
70+
throw createAgentSandboxPermissionDeniedError();
71+
}
72+
73+
try {
74+
await checkTeamSandboxPermission(teamId);
75+
} catch {
76+
throw createAgentSandboxPermissionDeniedError();
77+
}
78+
}

0 commit comments

Comments
 (0)