diff --git a/.agents/design/core/ai/auxiliary-generation.md b/.agents/design/core/ai/auxiliary-generation.md index 3aa493fa4773..38ac3660905d 100644 --- a/.agents/design/core/ai/auxiliary-generation.md +++ b/.agents/design/core/ai/auxiliary-generation.md @@ -6,7 +6,7 @@ ## 适用范围 -辅助生成用于不经过 Workflow Dispatcher、但需要复用 Chat 身份、SSE、计费、停止和 Agent Loop 的生成场景。目前的核心调用方是 Chat Agent Helper。 +辅助生成用于不经过 Workflow Dispatcher、但需要复用 Chat 身份、SSE、计费、停止和 Agent Loop 的生成场景。目前的核心调用方是 Chat Agent Helper 和 Skill Edit 调试对话。 它不是第二套 Workflow runtime,也不负责: @@ -28,6 +28,10 @@ | `stop.ts` | 读取并清理统一停止标记 | | `type.ts` | processor、用户上下文和运行结果协议 | +Skill Edit 的鉴权、消息组装、Sandbox 准备、Agent Loop runtime、ChatBox 事件和聊天持久化保留在 +`packages/service/core/ai/skill/debugChat`。该目录调用辅助生成公共生命周期,但不依赖 Workflow Dispatcher +或 `agentLoopCore`。 + ## 执行流程 ```text @@ -84,6 +88,41 @@ Chat Agent Helper 读取历史时使用 `reserveTool: true`。除 interactive - `done`、`error` 和 `aborted` 都清除该 memory,避免后续普通消息恢复陈旧暂停点。 - 通用 `saveChat` 已支持 memories;辅助生成只扩展 processor 返回协议和 Chat Agent Helper 保存调用,不修改通用保存语义。 +Skill Edit 不复用 Chat Agent Helper wrapper,而是在自己的 processor 中直接调用 `runAgentLoop`,并通过公共 +`AgentLoopRuntime.systemTools` 显式启用 `plan`、`ask`、`sandbox` 和 `readFile`。Skill Edit 不注册 +runtime tools;Sandbox 和文件读取均使用 Agent Loop 标准 system tool 协议。 + +## Skill Edit 直连 + +Skill Edit 调试对话保留原 `/api/core/ai/skill/debugChat` 和 ChatBox SSE 协议,但移除 +`workflowStart -> agent` 临时 Workflow。执行流程如下: + +```text +debugChat API + |-- Skill 写权限、频控、运行中 edit sandbox 校验 + |-- preChatRound 与历史/文件 URL 恢复 + `-- runAuxiliaryGeneration + |-- Skill Edit processor 准备 sandbox 和当前用户上下文 + |-- runAgentLoop(systemTools: plan/ask/sandbox/readFile) + |-- Skill Edit event adapter 生成 SSE、assistantResponses、nodeResponses + `-- 写入 chat round、agent providerState 和 node response rows +``` + +边界约束: + +- Skill Edit 只依赖 Agent Loop 的 `interface` 和 Sandbox 的 `interface`,不调用 Workflow Dispatcher, + 也不复用 `packages/service/core/workflow/dispatch/ai/agentLoopCore`。 +- Pro 的内置 Skill prepare action 直接从 Sandbox interface 注入;Workflow 侧只保留兼容 re-export, + 不维护 Skill Edit 专用 adapter。 +- ChatBox 仍消费既有 answer、tool、plan、interactive、flowNodeResponse 和 duration 事件;这是传输兼容, + 不代表执行经过 Workflow。 +- ask 暂停时只持久化 opaque `providerState`;恢复时由 Agent Loop provider 解释。 +- ask 恢复统一使用 `continuation: { type: 'ask', answer, additionalMessages }`;回答作为对应 + tool response,同轮新上传的文件作为 `additionalMessages` 追加到暂停上下文, + 避免把回答文本重复作为 user message。 +- `read_files` 只允许读取当前聊天上下文中已授权的文件 URL,单文件失败转换为模型可见结果。 +- 正常、交互暂停和 Agent Loop error 都先完成聊天与 node response 持久化,再发送 SSE `[DONE]`。 + ## SSE 与断流续传 - Stream key 使用 `teamId/sourceType/sourceId/chatId`,与标准 Chat source 隔离规则一致。 @@ -113,7 +152,17 @@ agent_runtime_stopping::: ## 扩展规则 -- 新的辅助生成场景优先复用 `runAuxiliaryGeneration`,只新增 processor。 +- 新的辅助生成场景优先复用 `runAuxiliaryGeneration`,只在所属业务域新增 processor。 - 业务事件由 processor 显式写入,不扩展通用 stream 层去理解业务配置。 - 公共生命周期需求放在本模块;单场景数据组装保留在调用方业务目录。 - source 标识统一使用 `sourceType/sourceId`,不能恢复 App-only 的 `appId` 入口。 + +## TODO + +- [x] 将当前分支线性对齐到最新 `upstream/main`,保留旧分支恢复引用。 +- [x] 在 Agent Loop 公共 Input 和两个 provider 中统一 ask `continuation` 恢复协议。 +- [x] 扩展辅助生成生命周期,支持 usage 复用和 `[DONE]` 前业务持久化。 +- [x] 将 Skill Debug 从临时 Workflow 改为 Skill 域内的直接 Agent Loop processor。 +- [x] 将内置 Skill prepare action 下沉到 Sandbox interface,并更新 Pro Skill Debug 入口。 +- [x] 覆盖消息上下文、ask 恢复、runtime/event adapter、API 收尾和错误路径测试。 +- [x] 运行相关局部测试、lint 和 Pro 定向类型检查(按要求不运行全量测试)。 diff --git a/packages/global/core/ai/sandbox/constants.ts b/packages/global/core/ai/sandbox/constants.ts index d83bded3b3ba..7fc979a8b7f6 100644 --- a/packages/global/core/ai/sandbox/constants.ts +++ b/packages/global/core/ai/sandbox/constants.ts @@ -57,11 +57,12 @@ export const generateSandboxId = ({ // Prompt export const SANDBOX_USER_FILES_PATH = 'user_files/'; export const SANDBOX_ENTRYPOINT_MAX_LENGTH = 16 * 1024; -export const SANDBOX_SYSTEM_PROMPT = ` +const buildSandboxSystemPrompt = (includeUserFilesPrompt: boolean) => ` 你拥有一个独立的 Linux 沙盒环境(Ubuntu 22.04),可通过 sandbox 工具操作文件和执行命令。 - 系统预装:bash / python3 / node / bun / git / curl -- 用户对话上传的文件存储在 ${SANDBOX_USER_FILES_PATH} 目录下 -- 使用 ${SANDBOX_SHELL_TOOL_NAME} 执行命令、运行代码和安装依赖(apt / pip / npm) +${ + includeUserFilesPrompt ? `- 用户对话上传的文件存储在 ${SANDBOX_USER_FILES_PATH} 目录下\n` : '' +}- 使用 ${SANDBOX_SHELL_TOOL_NAME} 执行命令、运行代码和安装依赖(apt / pip / npm) - 使用 ${SANDBOX_READ_FILE_TOOL_NAME} 读取文本文件内容,可通过 offset/limit 分段读取 - 使用 ${SANDBOX_WRITE_FILE_TOOL_NAME} 创建或覆盖文本文件 - 使用 ${SANDBOX_EDIT_FILE_TOOL_NAME} 对已有文件做精确查找替换 @@ -72,3 +73,8 @@ export const SANDBOX_SYSTEM_PROMPT = ` - HTML 等多文件预览产物必须使用相对资源路径(例如 ./assets/app.js),不要使用 /assets/app.js 这类根路径 - 若需要将生成的文件链接,可使用 ${SANDBOX_GET_FILE_URL_TOOL_NAME} 获取临时访问链接 `; + +export const SANDBOX_SYSTEM_PROMPT = buildSandboxSystemPrompt(true); + +/** Skill Edit 不把对话附件写入 sandbox,附件统一通过 read_files 或多模态消息提供。 */ +export const SKILL_EDIT_SANDBOX_SYSTEM_PROMPT = buildSandboxSystemPrompt(false); diff --git a/packages/global/test/core/ai/sandbox/constants.test.ts b/packages/global/test/core/ai/sandbox/constants.test.ts index 1e3dc8f3c305..43873c437001 100644 --- a/packages/global/test/core/ai/sandbox/constants.test.ts +++ b/packages/global/test/core/ai/sandbox/constants.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { generateSandboxId } from '@fastgpt/global/core/ai/sandbox/constants'; +import { + generateSandboxId, + SANDBOX_SYSTEM_PROMPT, + SKILL_EDIT_SANDBOX_SYSTEM_PROMPT +} from '@fastgpt/global/core/ai/sandbox/constants'; import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; describe('generateSandboxId', () => { @@ -39,3 +43,11 @@ describe('generateSandboxId', () => { expect(sandboxId).toBe(sandboxId.toLowerCase()); }); }); + +describe('sandbox system prompts', () => { + it('only advertises injected user_files to runtimes that actually mount them', () => { + expect(SANDBOX_SYSTEM_PROMPT).toContain('user_files/'); + expect(SKILL_EDIT_SANDBOX_SYSTEM_PROMPT).not.toContain('user_files/'); + expect(SKILL_EDIT_SANDBOX_SYSTEM_PROMPT).toContain('sandbox_read_file'); + }); +}); diff --git a/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts b/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts index 014536cb0c40..49cb16e39b23 100644 --- a/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts +++ b/packages/service/core/ai/auxiliaryGeneration/agentLoop.ts @@ -91,7 +91,13 @@ export async function runAuxiliaryGenerationAgentLoop({ systemPrompt, messages, providerState, - userAnswer + continuation: + providerState && userAnswer !== undefined + ? { + type: 'ask', + answer: userAnswer + } + : undefined } }); diff --git a/packages/service/core/ai/auxiliaryGeneration/service.ts b/packages/service/core/ai/auxiliaryGeneration/service.ts index 256e9779edbb..3d18e4d47a14 100644 --- a/packages/service/core/ai/auxiliaryGeneration/service.ts +++ b/packages/service/core/ai/auxiliaryGeneration/service.ts @@ -27,16 +27,20 @@ export const runAuxiliaryGeneration = async ({ data, histories, usageSource, + usageId, processor, maxFiles, customPdfParse, - onStreamContextReady + onStreamContextReady, + onBeforeStreamDone }: AuxiliaryGenerationRunParams): Promise< AuxiliaryGenerationRunResult & { streamContext: Awaited>; } > => { let stopping = false; + let stopCheckRunning = false; + let stopCheckTimer: ReturnType | undefined; const startedAt = Date.now(); const streamContext = await createAuxiliaryGenerationStream({ req, @@ -46,28 +50,35 @@ export const runAuxiliaryGeneration = async ({ sourceId, chatId }); - onStreamContextReady?.(streamContext); + try { + onStreamContextReady?.(streamContext); + const usageContext = await createAuxiliaryGenerationUsage({ + teamId, + tmbId, + appName, + sourceType, + sourceId, + usageSource, + usageId + }); + await clearAuxiliaryGenerationStop({ sourceType, sourceId, chatId }); - const usageContext = await createAuxiliaryGenerationUsage({ - teamId, - tmbId, - appName, - sourceType, - sourceId, - usageSource - }); - await clearAuxiliaryGenerationStop({ sourceType, sourceId, chatId }); + res.once('close', () => { + stopping = true; + }); - res.once('close', () => { - stopping = true; - }); + stopCheckTimer = setInterval(async () => { + if (stopping || stopCheckRunning) return; - const stopCheckTimer = setInterval(async () => { - if (stopping) return; - stopping = await shouldAuxiliaryGenerationStop({ sourceType, sourceId, chatId }); - }, 100); + stopCheckRunning = true; + try { + const shouldStop = await shouldAuxiliaryGenerationStop({ sourceType, sourceId, chatId }); + stopping = stopping || shouldStop; + } finally { + stopCheckRunning = false; + } + }, 100); - try { const result = await processor({ query, userAnswer, @@ -78,6 +89,7 @@ export const runAuxiliaryGeneration = async ({ streamWriter: streamContext.write, checkIsStopping: () => stopping, usageSink: usageContext.pushUsage, + usageId: usageContext.usageId, maxFiles, customPdfParse, user: { @@ -89,15 +101,22 @@ export const runAuxiliaryGeneration = async ({ } }); + const durationSeconds = +((Date.now() - startedAt) / 1000).toFixed(2); + await onBeforeStreamDone?.({ + result, + durationSeconds + }); streamContext.writeDone(); return { ...result, - durationSeconds: +((Date.now() - startedAt) / 1000).toFixed(2), + durationSeconds, streamContext }; } finally { - clearInterval(stopCheckTimer); + if (stopCheckTimer) { + clearInterval(stopCheckTimer); + } await clearAuxiliaryGenerationStop({ sourceType, sourceId, chatId }); } }; diff --git a/packages/service/core/ai/auxiliaryGeneration/stream.ts b/packages/service/core/ai/auxiliaryGeneration/stream.ts index e091e798381f..d9748ea2b2a4 100644 --- a/packages/service/core/ai/auxiliaryGeneration/stream.ts +++ b/packages/service/core/ai/auxiliaryGeneration/stream.ts @@ -9,6 +9,7 @@ import { getStreamResumeMirror } from '../../chat/resume'; import { createChatCompletionDeltaResponse } from '@fastgpt/global/core/ai/llm/utils'; export type AuxiliaryGenerationStreamWriter = (params: { + id?: string; event?: `${AuxiliaryGenerationEventEnum}` | string; data: string | object; }) => void; @@ -64,8 +65,14 @@ export const createAuxiliaryGenerationStream = async ({ } }); - const write: AuxiliaryGenerationStreamWriter = ({ event, data }) => { - const payload = typeof data === 'string' ? data : JSON.stringify(data); + const write: AuxiliaryGenerationStreamWriter = ({ id, event, data }) => { + const payload = + typeof data === 'string' + ? data + : JSON.stringify({ + ...data, + ...(id ? { responseValueId: id } : {}) + }); sseContext.write({ event, data: payload }); }; diff --git a/packages/service/core/ai/auxiliaryGeneration/type.ts b/packages/service/core/ai/auxiliaryGeneration/type.ts index 02743d2d1290..31e98ab5bfe2 100644 --- a/packages/service/core/ai/auxiliaryGeneration/type.ts +++ b/packages/service/core/ai/auxiliaryGeneration/type.ts @@ -1,7 +1,11 @@ import type { NodeHttpRequest, NodeHttpResponse } from '../../../types/http'; import type { localeType } from '@fastgpt/global/common/i18n/type'; import type { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; -import type { AIChatItemValueItemType, ChatItemDBSchemaType } from '@fastgpt/global/core/chat/type'; +import type { + AIChatItemValueItemType, + ChatHistoryItemResType, + ChatItemMiniType +} from '@fastgpt/global/core/chat/type'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import type { AuxiliaryGenerationChatFileType } from '@fastgpt/global/core/ai/auxiliaryGeneration/type'; @@ -20,24 +24,26 @@ export type AuxiliaryGenerationProcessorParams = { userAnswer?: string; files: AuxiliaryGenerationChatFileType[]; data: T; - histories: ChatItemDBSchemaType[]; + histories: ChatItemMiniType[]; streamWriter?: AuxiliaryGenerationStreamWriter; requestOrigin?: string; maxFiles?: number; customPdfParse?: boolean; checkIsStopping?: () => boolean; usageSink?: (usages: ChatNodeUsageType[]) => void; + usageId: string; user: AuxiliaryGenerationUser; }; export type AuxiliaryGenerationProcessorResponse = { aiResponse: AIChatItemValueItemType[]; - memories?: Record; - usage: { + usage?: { model: string; inputTokens: number; outputTokens: number; }; + nodeResponses?: ChatHistoryItemResType[]; + memories?: Record; }; export type AuxiliaryGenerationRunParams = { @@ -56,8 +62,10 @@ export type AuxiliaryGenerationRunParams = { userAnswer?: string; files: AuxiliaryGenerationChatFileType[]; data: T; - histories: ChatItemDBSchemaType[]; + histories: ChatItemMiniType[]; usageSource: UsageSourceEnum; + /** 交互续答复用上一轮 usage,避免把一次逻辑调用拆成多条计费记录。 */ + usageId?: string; processor: ( params: AuxiliaryGenerationProcessorParams ) => Promise; @@ -65,6 +73,11 @@ export type AuxiliaryGenerationRunParams = { customPdfParse?: boolean; /** SSE 创建后立即暴露给路由层,用于失败时写 error 和 flush resume。 */ onStreamContextReady?: (streamContext: AuxiliaryGenerationStreamContext) => void; + /** 公共层写结束事件前的业务收尾,例如持久化本轮聊天。 */ + onBeforeStreamDone?: (params: { + result: AuxiliaryGenerationProcessorResponse; + durationSeconds: number; + }) => Promise | void; }; export type AuxiliaryGenerationRunResult = AuxiliaryGenerationProcessorResponse & { diff --git a/packages/service/core/ai/auxiliaryGeneration/usage.ts b/packages/service/core/ai/auxiliaryGeneration/usage.ts index 24c2c8b36a84..2c59886351d0 100644 --- a/packages/service/core/ai/auxiliaryGeneration/usage.ts +++ b/packages/service/core/ai/auxiliaryGeneration/usage.ts @@ -11,12 +11,14 @@ type CreateAuxiliaryGenerationUsageParams = { sourceType: ChatSourceTypeEnum; sourceId: string; usageSource: UsageSourceEnum; + usageId?: string; }; /** * 为辅助生成建立统一的扣费上下文。 * - * 余额校验在生成前执行;后续 processor 只需把各模型/工具用量推入 `pushUsage`。 + * 余额校验在生成前执行;交互续答可以复用已有 usageId,后续 processor 只需把 + * 各模型/工具用量推入 `pushUsage`。 */ export const createAuxiliaryGenerationUsage = async ({ teamId, @@ -24,7 +26,8 @@ export const createAuxiliaryGenerationUsage = async ({ appName, sourceType, sourceId, - usageSource + usageSource, + usageId: existingUsageId }: CreateAuxiliaryGenerationUsageParams) => { await checkTeamAIPoints(teamId); @@ -37,14 +40,17 @@ export const createAuxiliaryGenerationUsage = async ({ if (sourceType === ChatSourceTypeEnum.skillEdit) return sourceId; })(); - const usageId = await createChatUsageRecord({ - appName, - appId: usageAppId, - skillId: usageSkillId, - teamId, - tmbId, - source: usageSource - }); + // 交互追问的后续轮次沿用原 usage,确保一次逻辑调用只生成一条计费记录。 + const usageId = + existingUsageId ?? + (await createChatUsageRecord({ + appName, + appId: usageAppId, + skillId: usageSkillId, + teamId, + tmbId, + source: usageSource + })); return { usageId, diff --git a/packages/service/core/ai/llm/agentLoop/domain/continuation.ts b/packages/service/core/ai/llm/agentLoop/domain/continuation.ts index 3a6f09525fb0..97fe029deac3 100644 --- a/packages/service/core/ai/llm/agentLoop/domain/continuation.ts +++ b/packages/service/core/ai/llm/agentLoop/domain/continuation.ts @@ -12,3 +12,15 @@ export type AgentLoopPendingMainContext = { askToolCallId: string; activePlan?: AgentPlanType; }; + +/** + * 描述一次跨请求恢复动作。 + * + * providerState 只保存暂停上下文;continuation 携带本次恢复的用户决策, + * 以及必须在 tool response 之后继续消费的标准消息。 + */ +export type AgentLoopContinuation = { + type: 'ask'; + answer: string; + additionalMessages?: ChatCompletionMessageParam[]; +}; diff --git a/packages/service/core/ai/llm/agentLoop/domain/input.ts b/packages/service/core/ai/llm/agentLoop/domain/input.ts index c3362fb99e2c..18e1cf956323 100644 --- a/packages/service/core/ai/llm/agentLoop/domain/input.ts +++ b/packages/service/core/ai/llm/agentLoop/domain/input.ts @@ -1,12 +1,13 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; import type { AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; import type { AgentLoopChildrenInteractiveParams } from './interactive'; +import type { AgentLoopContinuation } from './continuation'; export type AgentLoopInput = { messages: ChatCompletionMessageParam[]; systemPrompt?: string; activePlan?: AgentPlanType; providerState?: unknown; - userAnswer?: string; + continuation?: AgentLoopContinuation; childrenInteractiveParams?: AgentLoopChildrenInteractiveParams; }; diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts index 992b7fe7f870..4182cd863903 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/index.ts @@ -128,7 +128,7 @@ export const runFastAgentLoop = async ({ systemPrompt: input.systemPrompt, activePlan: input.activePlan, pendingMainContext: providerState.pendingMainContext, - userAnswer: input.userAnswer, + continuation: input.continuation, childrenInteractiveParams: input.childrenInteractiveParams } }); @@ -141,10 +141,10 @@ export const runFastAgentLoop = async ({ } : undefined; - if (input.userAnswer !== undefined) { + if (input.continuation?.type === 'ask') { runtime.emitEvent?.({ type: 'ask_resume', - answer: input.userAnswer + answer: input.continuation.answer }); } diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts index 24afa3b5694b..27d165835025 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/index.ts @@ -174,10 +174,11 @@ export const runFastAgentMainLoop = async ({ } | undefined; + const askContinuation = input.continuation?.type === 'ask' ? input.continuation : undefined; // ask_user 暂停时会把当时的 LLM messages 保存到 pendingMainContext。 - // 恢复时追加用户回答作为对应 ask tool 的 Tool message,延续同一条消息链。 + // 恢复时先追加 ask tool response,再消费 continuation 携带的附加消息。 const messages = - input.pendingMainContext && input.userAnswer !== undefined + input.pendingMainContext && askContinuation ? [ ...input.pendingMainContext.messages, { @@ -187,10 +188,11 @@ export const runFastAgentMainLoop = async ({ formatAgentAskToolResponse({ messages: input.pendingMainContext.messages, askToolCallId: input.pendingMainContext.askToolCallId, - answer: input.userAnswer + answer: askContinuation.answer }) ) - } as ChatCompletionMessageParam + } as ChatCompletionMessageParam, + ...(askContinuation.additionalMessages ?? []) ] : buildInitialMessages({ input }); // 普通续轮通过 input.activePlan 恢复结构化 plan;ask_user 续跑则优先使用暂停时的完整快照。 diff --git a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts index d290ae7f01d5..a59bd3c00330 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/fastAgent/loop/type.ts @@ -11,6 +11,7 @@ import type { AgentLoopToolCatalog } from '../tools'; import type { AgentLoopDatasetSearchExecutor } from '../../../domain/systemTool/datasetSearch'; import type { AgentLoopChildrenInteractiveParams, + AgentLoopContinuation, AgentLoopEvent, AgentLoopInteractiveToolExecuteParams, AgentLoopPendingMainContext, @@ -74,7 +75,7 @@ export type FastAgentLoopInput = { systemPrompt?: string; activePlan?: AgentPlanType; pendingMainContext?: PendingMainContext; - userAnswer?: string; + continuation?: AgentLoopContinuation; childrenInteractiveParams?: AgentLoopChildrenInteractiveParams; }; diff --git a/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts b/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts index 4d5c53d23a5b..fce14f2d88ba 100644 --- a/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts +++ b/packages/service/core/ai/llm/agentLoop/provider/piAgent/run.ts @@ -209,12 +209,13 @@ export const runPiAgentLoop = async ({ ); const standardHistoryMessages = lastUserMessageIndex >= 0 ? requestMessages.slice(0, lastUserMessageIndex) : requestMessages; + const askContinuation = input.continuation?.type === 'ask' ? input.continuation : undefined; const shouldResumeStandardAsk = - !input.childrenInteractiveParams && !!pendingMainContext && input.userAnswer !== undefined; + !input.childrenInteractiveParams && !!pendingMainContext && !!askContinuation; const shouldResumeAsk = shouldResumeStandardAsk; const askResumeId = pendingMainContext?.askToolCallId; const standardAskResumeMessages = - shouldResumeStandardAsk && askResumeId + shouldResumeStandardAsk && askResumeId && askContinuation ? [ ...pendingMainContext!.messages, { @@ -224,10 +225,11 @@ export const runPiAgentLoop = async ({ formatAgentAskToolResponse({ messages: pendingMainContext!.messages, askToolCallId: askResumeId, - answer: input.userAnswer ?? '' + answer: askContinuation.answer }) ) - } as ChatCompletionMessageParam + } as ChatCompletionMessageParam, + ...(askContinuation.additionalMessages ?? []) ] : undefined; // ask resume follows the FastAgent contract: the new user input is represented by @@ -366,8 +368,8 @@ export const runPiAgentLoop = async ({ resumedInteractiveTool = true; } - if (shouldResumeAsk && askResumeId) { - const answer = normalizeToolResponseContent(input.userAnswer); + if (shouldResumeAsk && askResumeId && askContinuation) { + const answer = normalizeToolResponseContent(askContinuation.answer); resumedAsk = true; runtime.emitEvent?.({ type: 'ask_resume', diff --git a/packages/service/core/ai/sandbox/application/runtime/skill/builtin.ts b/packages/service/core/ai/sandbox/application/runtime/skill/builtin.ts index 38a03718aa8b..c7a1382f0f58 100644 --- a/packages/service/core/ai/sandbox/application/runtime/skill/builtin.ts +++ b/packages/service/core/ai/sandbox/application/runtime/skill/builtin.ts @@ -11,6 +11,8 @@ import type { } from '@fastgpt/global/core/ai/skill/runtime/builtin'; import { getSandboxBuiltinSkillsRootPath } from '../../../infrastructure/provider/runtimeProfile/utils'; import { buildRuntimeHash, joinSandboxPath } from '../../../utils'; +import { resolveSandboxHome } from '../home'; +import type { SandboxPrepareContext } from '../prepare'; import { getRuntimeStateValue, readSandboxRuntimeState, @@ -25,10 +27,57 @@ type BuiltinSkillSyncSource = BuiltinSkillSource & { etag: string; }; +export type BuiltinSkillPrepareContext = SandboxPrepareContext & { + skillScanDirectories: string[]; +}; + +export type BuiltinSkillPrepareAction = ( + context: Context +) => Promise; + export function getBuiltinSkillsRootPath(homeDirectory: string): string { return getSandboxBuiltinSkillsRootPath(homeDirectory); } +/** + * 创建“同步内置 Skill 到当前 sandbox”的通用 prepare step。 + * + * 业务调用方只负责延迟提供 Skill 文件;HOME、目标目录和扫描目录都由 sandbox runtime 管理, + * 因而 Skill Debug、Workflow 等入口无需各自维护一份适配逻辑。 + */ +export const createBuiltinSkillPrepareAction = + ({ + getSources, + injectToSandbox = syncBuiltinSkillsToSandbox + }: { + getSources: () => Promise; + injectToSandbox?: typeof syncBuiltinSkillsToSandbox; + }): BuiltinSkillPrepareAction => + async (context: Context): Promise => { + const sources = await getSources(); + if (sources.length === 0) return context; + + const homeDirectory = await resolveSandboxHome(context.sandbox); + if (!homeDirectory) { + throw new Error('Failed to resolve sandbox HOME for builtin skill sync'); + } + + await injectToSandbox({ + sandbox: context.sandbox, + homeDirectory, + sources + }); + + const builtinSkillsRootPath = getBuiltinSkillsRootPath(homeDirectory); + return { + ...context, + skillScanDirectories: [ + ...context.skillScanDirectories, + ...sources.map((source) => `${builtinSkillsRootPath}/${source.name}`) + ] + }; + }; + /** * 将内置 Skill 源码注入 sandbox 用户主目录。 * diff --git a/packages/service/core/ai/sandbox/application/runtime/skill/index.ts b/packages/service/core/ai/sandbox/application/runtime/skill/index.ts index 0dfd9ca4ef7f..229bc03a05d4 100644 --- a/packages/service/core/ai/sandbox/application/runtime/skill/index.ts +++ b/packages/service/core/ai/sandbox/application/runtime/skill/index.ts @@ -5,5 +5,10 @@ */ export type { DeployedSkillInfo, DeployedSkillVersion } from './types'; export { getAgentSkillInfos, injectAgentSkillFilesToSandbox } from './core'; -export { getBuiltinSkillsRootPath, syncBuiltinSkillsToSandbox } from './builtin'; +export { + createBuiltinSkillPrepareAction, + getBuiltinSkillsRootPath, + syncBuiltinSkillsToSandbox +} from './builtin'; +export type { BuiltinSkillPrepareAction, BuiltinSkillPrepareContext } from './builtin'; export { runAgentSkillVersionEntrypoints } from './entrypoint'; diff --git a/packages/service/core/ai/sandbox/interface/runtime.ts b/packages/service/core/ai/sandbox/interface/runtime.ts index d818b0b3982f..559be878db33 100644 --- a/packages/service/core/ai/sandbox/interface/runtime.ts +++ b/packages/service/core/ai/sandbox/interface/runtime.ts @@ -44,12 +44,17 @@ export { resolveSandboxHome } from '../application/runtime/home'; export { getSafeSandboxInputFilename, joinSandboxPath } from '../utils'; export type { DeployedSkillInfo, DeployedSkillVersion } from '../application/runtime/skill'; export { + createBuiltinSkillPrepareAction, getAgentSkillInfos, getBuiltinSkillsRootPath, injectAgentSkillFilesToSandbox, runAgentSkillVersionEntrypoints, syncBuiltinSkillsToSandbox } from '../application/runtime/skill'; +export type { + BuiltinSkillPrepareAction, + BuiltinSkillPrepareContext +} from '../application/runtime/skill'; type SandboxClientQueryWithId = SandboxClientQuery & { sandboxId: string; chatId: string }; diff --git a/packages/service/core/ai/skill/debugChat/eventAdapter.ts b/packages/service/core/ai/skill/debugChat/eventAdapter.ts new file mode 100644 index 000000000000..1eec0e20f800 --- /dev/null +++ b/packages/service/core/ai/skill/debugChat/eventAdapter.ts @@ -0,0 +1,374 @@ +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { i18nT } from '@fastgpt/global/common/i18n/utils'; +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; +import { sandboxToolMap } from '@fastgpt/global/core/ai/sandbox/tools'; +import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; +import type { + AIChatItemValueItemType, + ChatHistoryItemResType +} from '@fastgpt/global/core/chat/type'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse'; +import type { AgentLoopEvent, AgentLoopUsage } from '../../llm/agentLoop/interface'; +import { + askUserToolName, + READ_FILES_TOOL_NAME, + setPlanToolName, + updatePlanToolName +} from '../../llm/agentLoop/interface'; +import { parseJsonArgs } from '../../utils'; +import { getSandboxToolInfo } from '../../sandbox/interface/toolCall'; +import type { AuxiliaryGenerationStreamWriter } from '../../auxiliaryGeneration'; +import type { localeType } from '@fastgpt/global/common/i18n/type'; + +const SKILL_DEBUG_AGENT_NODE_ID = 'skill-debug-agent'; + +const nodeResponseDisplay = { + master: { + name: i18nT('chat:master_agent_call'), + avatar: 'core/app/type/agentFill' + }, + plan: { + name: i18nT('chat:plan_update'), + avatar: 'core/app/agent/child/plan' + }, + ask: { + name: i18nT('chat:collect_questions'), + avatar: 'core/app/agent/child/plan' + }, + contextCompress: { + name: i18nT('chat:compress_llm_messages'), + avatar: 'core/app/agent/child/contextCompress' + }, + toolResponseCompress: { + name: i18nT('chat:tool_response_compress'), + avatar: 'core/app/agent/child/contextCompress' + }, + readFile: { + name: i18nT('chat:read_file'), + avatar: 'core/workflow/template/readFiles' + } +} as const; + +const getUsagePoints = (usages?: AgentLoopUsage[]) => + usages?.reduce((sum, usage) => sum + usage.totalPoints, 0) ?? 0; + +const createCompressNodeResponse = ({ + name, + avatar, + usage, + requestIds, + seconds, + textOutput +}: { + name: string; + avatar: string; + usage?: AgentLoopUsage; + requestIds: string[]; + seconds: number; + textOutput?: string; +}): ChatHistoryItemResType => { + const validRequestIds = requestIds.filter(Boolean); + const id = validRequestIds[0] || getNanoid(); + + return { + id, + nodeId: id, + moduleName: name, + moduleType: FlowNodeTypeEnum.agent, + moduleLogo: avatar, + runningTime: seconds, + model: usage?.model, + llmRequestIds: validRequestIds.length > 0 ? validRequestIds : undefined, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + totalPoints: usage?.totalPoints, + textOutput + }; +}; + +/** + * 将 Skill Debug 的标准 Agent Loop 事件适配成 ChatBox SSE、可持久化 meta response 和 + * node response。该适配器只服务 Skill 域,不依赖 Workflow Dispatcher 或 agentLoopCore。 + */ +export const createSkillDebugEventAdapter = ({ + streamWriter, + lang +}: { + streamWriter?: AuxiliaryGenerationStreamWriter; + lang: localeType; +}) => { + const metaResponses: AIChatItemValueItemType[] = []; + const nodeResponses: ChatHistoryItemResType[] = []; + const callNameById = new Map(); + const completedCallIds = new Set(); + const completedRequestIds = new Set(); + const completedCompressKeys = new Set(); + const visibleToolNames = new Set([READ_FILES_TOOL_NAME, ...Object.keys(sandboxToolMap)]); + + const getToolInfo = (name: string) => { + if (name === READ_FILES_TOOL_NAME) { + return { + name: nodeResponseDisplay.readFile.name, + avatar: nodeResponseDisplay.readFile.avatar + }; + } + + const sandboxTool = getSandboxToolInfo(name, lang); + return { + name: sandboxTool?.name ?? name, + avatar: sandboxTool?.avatar ?? '' + }; + }; + + const appendPlanMetaResponse = (event: Extract) => { + if (event.success) metaResponses.push({ plan: event.plan }); + if (!event.id) return; + + metaResponses.push({ + id: event.id, + agentPlanUpdate: { + id: event.id, + functionName: event.operation === 'set_plan' ? setPlanToolName : updatePlanToolName, + params: event.params ?? '', + response: event.message + } + }); + }; + + const appendPlanNodeResponse = (event: Extract) => { + if (!event.id || completedCallIds.has(event.id)) return; + completedCallIds.add(event.id); + + nodeResponses.push({ + id: `${SKILL_DEBUG_AGENT_NODE_ID}-plan-${event.id}`, + nodeId: `${SKILL_DEBUG_AGENT_NODE_ID}-plan-${event.id}`, + moduleName: nodeResponseDisplay.plan.name, + moduleType: FlowNodeTypeEnum.agent, + moduleLogo: nodeResponseDisplay.plan.avatar, + runningTime: event.seconds, + agentPlanResult: event.message, + agentPlanStatus: event.operation === 'set_plan' ? 'set_plan' : 'update_plan' + }); + }; + + const appendAskResponse = (event: Extract) => { + if (!event.id) return; + metaResponses.push({ + id: event.id, + agentAsk: { + id: event.id, + askId: event.id, + functionName: askUserToolName, + params: event.params ?? '' + } + }); + if (completedCallIds.has(event.id)) return; + completedCallIds.add(event.id); + nodeResponses.push({ + id: `${SKILL_DEBUG_AGENT_NODE_ID}-ask-${event.id}`, + nodeId: `${SKILL_DEBUG_AGENT_NODE_ID}-ask-${event.id}`, + moduleName: nodeResponseDisplay.ask.name, + moduleType: FlowNodeTypeEnum.agent, + moduleLogo: nodeResponseDisplay.ask.avatar, + runningTime: event.seconds, + textOutput: event.ask.questions.map((question) => question.question).join('\n') + }); + }; + + const appendLlmNodeResponse = (event: Extract) => { + if (completedRequestIds.has(event.requestId)) return; + completedRequestIds.add(event.requestId); + const usage = event.usages?.[0]; + + nodeResponses.push({ + id: `${SKILL_DEBUG_AGENT_NODE_ID}-${event.requestIndex}-${event.requestId}`, + nodeId: `${SKILL_DEBUG_AGENT_NODE_ID}-main_agent-${event.requestIndex}`, + moduleName: nodeResponseDisplay.master.name, + moduleType: FlowNodeTypeEnum.agent, + moduleLogo: nodeResponseDisplay.master.avatar, + runningTime: event.seconds, + model: event.modelName, + llmRequestIds: [event.requestId], + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + totalPoints: usage?.totalPoints, + finishReason: event.finishReason, + textOutput: event.answerText, + reasoningText: event.reasoningText, + ...(event.error ? { errorText: getErrText(event.error) } : {}) + }); + }; + + const appendMessageCompressResponse = ( + event: Extract + ) => { + const key = event.requestIds.join(',') || event.contextCheckpoint; + if (key && completedCompressKeys.has(key)) return; + if (key) completedCompressKeys.add(key); + + if (event.contextCheckpoint) { + metaResponses.push({ + contextCheckpoint: event.contextCheckpoint, + hideInUI: true + }); + } + nodeResponses.push( + createCompressNodeResponse({ + name: nodeResponseDisplay.contextCompress.name, + avatar: nodeResponseDisplay.contextCompress.avatar, + usage: event.usages?.[0], + requestIds: event.requestIds, + seconds: event.seconds + }) + ); + }; + + const appendToolNodeResponse = (event: Extract) => { + if (!visibleToolNames.has(event.call.function.name)) return; + if (completedCallIds.has(event.call.id)) return; + completedCallIds.add(event.call.id); + const toolInfo = getToolInfo(event.call.function.name); + const compressResponse = event.toolResponseCompress + ? createCompressNodeResponse({ + name: nodeResponseDisplay.toolResponseCompress.name, + avatar: nodeResponseDisplay.toolResponseCompress.avatar, + usage: event.toolResponseCompress.usage, + requestIds: event.toolResponseCompress.requestIds, + seconds: event.toolResponseCompress.seconds, + textOutput: event.toolResponseCompress.response + }) + : undefined; + const childTotalPoints = compressResponse?.totalPoints ?? 0; + + nodeResponses.push({ + id: event.call.id, + nodeId: event.call.id, + moduleName: toolInfo.name, + moduleType: FlowNodeTypeEnum.tool, + moduleLogo: toolInfo.avatar, + runningTime: event.seconds, + toolId: event.call.function.name, + toolInput: parseJsonArgs(event.call.function.arguments) || undefined, + toolRes: event.response, + totalPoints: getUsagePoints(event.usages), + ...(event.errorMessage ? { errorText: event.errorMessage } : {}), + ...(compressResponse + ? { + childrenResponses: [compressResponse], + ...(childTotalPoints > 0 ? { childTotalPoints } : {}) + } + : {}) + }); + }; + + const emitEvent = (event: AgentLoopEvent) => { + switch (event.type) { + case 'llm_request_start': + streamWriter?.(workflowSseEvent.flowNodeStatus(event.modelName)); + return; + case 'llm_request_end': + appendLlmNodeResponse(event); + return; + case 'answer_delta': + streamWriter?.(workflowSseEvent.answerDelta(event.text)); + return; + case 'reasoning_delta': + streamWriter?.(workflowSseEvent.reasoningDelta(event.text)); + return; + case 'tool_call': { + callNameById.set(event.call.id, event.call.function.name); + if (!visibleToolNames.has(event.call.function.name)) return; + const toolInfo = getToolInfo(event.call.function.name); + streamWriter?.( + workflowSseEvent.toolCall({ + id: event.call.id, + toolName: toolInfo.name, + toolAvatar: toolInfo.avatar, + functionName: event.call.function.name, + params: event.call.function.arguments ?? '' + }) + ); + return; + } + case 'tool_params': + if (!visibleToolNames.has(callNameById.get(event.callId) ?? '')) return; + streamWriter?.(workflowSseEvent.toolParams({ id: event.callId, params: event.argsDelta })); + return; + case 'tool_run_end': + appendToolNodeResponse(event); + if (visibleToolNames.has(event.call.function.name)) { + streamWriter?.( + workflowSseEvent.toolResponse({ id: event.call.id, response: event.response }) + ); + } + return; + case 'plan_status': + streamWriter?.(workflowSseEvent.planStatus({ status: event.status })); + return; + case 'plan_operation': + appendPlanMetaResponse(event); + appendPlanNodeResponse(event); + if (event.success) streamWriter?.(workflowSseEvent.plan(event.plan)); + return; + case 'ask_start': + appendAskResponse(event); + return; + case 'after_message_compress': + appendMessageCompressResponse(event); + return; + default: + return; + } + }; + + const buildAssistantResponses = ( + assistantMessages: ChatCompletionMessageParam[] + ): AIChatItemValueItemType[] => { + const visibleCallIds = new Set( + assistantMessages.flatMap((message) => + message.role === 'assistant' + ? (message.tool_calls ?? []) + .filter((call) => visibleToolNames.has(call.function.name)) + .map((call) => call.id) + : [] + ) + ); + const visibleMessages = assistantMessages.flatMap((message) => { + if (message.role === 'tool') { + return visibleCallIds.has(message.tool_call_id) ? [message] : []; + } + if (message.role !== 'assistant') return []; + + const toolCalls = message.tool_calls?.filter((call) => visibleCallIds.has(call.id)); + const hasContent = + (typeof message.content === 'string' && !!message.content) || + (Array.isArray(message.content) && message.content.length > 0) || + !!message.reasoning_content; + if (!hasContent && !toolCalls?.length) return []; + + return [ + { + ...message, + tool_calls: toolCalls?.length ? toolCalls : undefined + } + ]; + }); + const transcriptResponses = GPTMessages2Chats({ + messages: visibleMessages, + reserveTool: true, + reserveReason: true, + getToolInfo + }).flatMap((item) => (item.obj === ChatRoleEnum.AI ? item.value : [])); + + return [...transcriptResponses, ...metaResponses]; + }; + + return { + nodeResponses, + emitEvent, + buildAssistantResponses + }; +}; diff --git a/packages/service/core/ai/skill/debugChat/handler.ts b/packages/service/core/ai/skill/debugChat/handler.ts index 78aac404e676..45426f7a75ab 100644 --- a/packages/service/core/ai/skill/debugChat/handler.ts +++ b/packages/service/core/ai/skill/debugChat/handler.ts @@ -1,82 +1,68 @@ import type { NodeApiRequest, NodeApiResponse } from '../../../../types/http'; -import { - DispatchNodeResponseKeyEnum, - SseResponseEventEnum -} from '@fastgpt/global/core/workflow/runtime/constants'; -import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse'; -import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import type { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type'; -import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; -import { concatHistories, removeEmptyUserInput } from '@fastgpt/global/core/chat/utils'; -import { WritePermissionVal } from '@fastgpt/global/support/permission/constant'; -import { getLastInteractiveValue } from '@fastgpt/global/core/workflow/runtime/utils'; +import { SkillDebugChatBodySchema } from '@fastgpt/global/core/ai/skill/api'; import { ChatGenerateStatusEnum, ChatRoleEnum, - ChatSourceTypeEnum, - ChatSourceEnum + ChatSourceEnum, + ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; -import { SkillDebugChatBodySchema } from '@fastgpt/global/core/ai/skill/api'; +import type { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type'; +import { GPTMessages2Chats, chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; +import { concatHistories, removeEmptyUserInput } from '@fastgpt/global/core/chat/utils'; +import { WritePermissionVal } from '@fastgpt/global/support/permission/constant'; import { UserError } from '@fastgpt/global/common/error/utils'; import { getNanoid } from '@fastgpt/global/common/string/tools'; -import { sseErrRes } from '../../../../common/response'; +import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; +import { getLastInteractiveValue } from '@fastgpt/global/core/workflow/runtime/utils'; +import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse'; import { parseApiInput } from '../../../../common/zod/requestParseError'; +import { sseErrRes } from '../../../../common/response'; import { authSkill } from '../../../../support/permission/skill/auth'; -import { teamFrequencyLimit, LimitTypeEnum } from '../../../../common/api/frequencyLimit'; import { getIpFromRequest } from '../../../../common/geo'; import { getLocale } from '../../../../common/middle/i18n'; +import { teamFrequencyLimit, LimitTypeEnum } from '../../../../common/api/frequencyLimit'; import { getLogger, LogCategories } from '../../../../common/logger'; -import { getRunningUserInfoByTmbId } from '../../../../support/user/team/utils'; -import { formatModelChars2Points } from '../../../../support/wallet/usage/utils'; -import { getDefaultLLMModel } from '../../model'; +import { createChatFilePreviewUrlGetter } from '../../../../common/s3/sources/chat'; +import { validateFileUrlDomain } from '../../../../common/security/fileUrlValidator'; +import { getDefaultLLMModel, getLLMModel } from '../../model'; import { getRunningSkillEditSandbox } from '../../sandbox/interface/skillEdit'; -import { dispatchWorkFlow } from '../../../workflow/dispatch'; -import { prepareWorkflowFileQuery } from '../../../workflow/utils/fileLimits'; -import { WORKFLOW_MAX_RUN_TIMES } from '../../../workflow/constants'; -import type { AppFileSelectConfigType } from '@fastgpt/global/core/app/type/config.schema'; import { getChatItems } from '../../../chat/controller'; +import { preChatRound, type PreChatRoundResult } from '../../../chat/utils/prepare'; import { failChatRound, finalizeChatRound, type Props as SaveChatProps, updateInteractiveChat } from '../../../chat/saveChat'; -import { preChatRound, type PreChatRoundResult } from '../../../chat/utils/prepare'; import { updateChatGenerateStatus } from '../../../chat/chatGenerateStatus'; +import { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage'; +import { addPreviewUrlToChatItems } from '../../../chat/utils'; +import { getUserChatInfo } from '../../../../support/user/team/utils'; import { - createWorkflowStreamResponseContext, - type WorkflowStreamResponseContext -} from '../../../workflow/utils/streamResponseContext'; -import { buildDebugRuntimeNodes } from './runtime'; -import type { AgentSandboxPrepareAction } from '../../../workflow/dispatch/ai/agent/sub/sandbox'; + runAuxiliaryGeneration, + type AuxiliaryGenerationStreamContext +} from '../../auxiliaryGeneration'; +import { createSkillDebugProcessor, type SkillDebugProcessorData } from './processor'; +import type { SkillDebugSandboxPrepareAction } from './runtime'; +import { SKILL_DEBUG_MAX_FILES } from './userContext'; const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS); -const skillDebugFileSelectConfig: AppFileSelectConfigType = { - maxFiles: 10, - canSelectFile: true, - canSelectImg: true, - customPdfParse: false, - canSelectVideo: true, - canSelectAudio: true, - canSelectCustomFileExtension: false, - customFileExtensionList: [] -}; /** - * 处理 Skill 调试对话的共享主流程。 + * 处理 Skill 调试对话。 * - * 开源 API 与 Pro API 都调用这里;差异只通过 options 显式传入,避免复制 chat round、 - * workflow 调度和 SSE 收尾逻辑。 + * API 保留原 ChatBox 协议,但执行层直接调用 Agent Loop;handler 只负责鉴权、chat round、 + * SSE 生命周期和持久化,不再构造或调度 Workflow。 */ export async function handleSkillDebugChat( req: NodeApiRequest, res: NodeApiResponse, options: { - agentSandboxPrepareActions?: AgentSandboxPrepareAction[]; + agentSandboxPrepareActions?: SkillDebugSandboxPrepareAction[]; } = {} ) { let skillId = ''; - let streamResponseContext: WorkflowStreamResponseContext | undefined; + let streamContext: AuxiliaryGenerationStreamContext | undefined; const roundState = { preparedRound: undefined as PreChatRoundResult | undefined, sourceType: undefined as ChatSourceTypeEnum | undefined, @@ -104,20 +90,21 @@ export async function handleSkillDebugChat( sourceId: skillId }; - if (!Array.isArray(messages) || messages.length === 0) { + if (messages.length === 0) { throw new UserError('messages is required'); } - const resolvedModel = model || getDefaultLLMModel().model; + const modelData = getLLMModel(model || getDefaultLLMModel().model); const originIp = getIpFromRequest(req); - - const { teamId, tmbId, skill } = await authSkill({ + const lang = getLocale(req); + const { teamId, tmbId, userId, isRoot, skill } = await authSkill({ req, authToken: true, authApiKey: true, skillId, per: WritePermissionVal }); + const { timezone, externalProvider } = await getUserChatInfo(tmbId); if (!(await teamFrequencyLimit({ teamId, type: LimitTypeEnum.chat, res }))) { return; @@ -132,7 +119,7 @@ export async function handleSkillDebugChat( logger.debug('Edit debug sandbox found', { skillId, sandboxId: sandboxInstance.sandboxId }); const chatMessages = GPTMessages2Chats({ messages }); - const userQuestion = chatMessages.pop() as UserChatItemType; + const userQuestion = chatMessages.pop() as UserChatItemType | undefined; if (!userQuestion) { throw new UserError('User question is empty'); } @@ -144,35 +131,38 @@ export async function handleSkillDebugChat( limit: 20, field: 'obj value memories' }); + const historiesWithPreview = await addPreviewUrlToChatItems( + concatHistories(histories, chatMessages), + 'chatFlow' + ); + const interactive = getLastInteractiveValue(historiesWithPreview); + const userQuestionValue = removeEmptyUserInput(userQuestion.value); + const { text: queryText = '', files: queryFiles = [] } = + chatValue2RuntimePrompt(userQuestionValue); + if (queryFiles.some((file) => file.url && !validateFileUrlDomain(file.url))) { + throw new UserError('Invalid file url'); + } - const newHistories = concatHistories(histories, chatMessages); - const interactive = getLastInteractiveValue(newHistories); - const chatConfig = { - fileSelectConfig: skillDebugFileSelectConfig - }; - const { - query: workflowQuery, - maxFileAmount, - maxBytesPerFile - } = await prepareWorkflowFileQuery({ - teamId, - chatConfig, - query: userQuestion.value - }); - const workflowUserQuestion: UserChatItemType = { - ...userQuestion, - value: workflowQuery - }; const preparedRound = await preChatRound({ ...chatSource, chatId, teamId, tmbId, source: ChatSourceEnum.test, - userContent: workflowUserQuestion, + userContent: userQuestion, responseChatItemId: responseChatItemIdFromBody, interactive }); + const getPreviewUrl = createChatFilePreviewUrlGetter(); + // preChatRound 会移除待持久化消息中的临时 URL;Agent 运行前按 key 重新签发预览地址。 + await Promise.all( + queryFiles.map(async (file) => { + if (!file.key) return; + const previewUrl = await getPreviewUrl(file.key); + if (previewUrl) file.url = previewUrl; + }) + ); + const runningChatId = preparedRound.chatId; const finalResponseChatItemId = preparedRound.responseChatItemId; roundState.preparedRound = preparedRound; @@ -181,152 +171,105 @@ export async function handleSkillDebugChat( roundState.chatId = runningChatId; roundState.responseChatItemId = finalResponseChatItemId; - const { runtimeNodes, runtimeEdges } = buildDebugRuntimeNodes( - skillId, - resolvedModel, - systemPrompt - ); - - streamResponseContext = await createWorkflowStreamResponseContext({ + const result = await runAuxiliaryGeneration({ req, res, - stream: true, - detail: true, teamId, + tmbId, + userId, + isRoot, + lang, + appName: skill.name, sourceType: ChatSourceTypeEnum.skillEdit, sourceId: skillId, chatId: runningChatId, - responseId: runningChatId, - showNodeStatus: true - }); - - logger.debug('Dispatching skill debug workflow', { skillId, chatId, model }); - - const { - flatNodeResponses, - assistantResponses, - system_memories, - durationSeconds, - customFeedbacks, - nodeResponseSummary - } = await dispatchWorkFlow({ - apiVersion: 'v2', - res, - lang: getLocale(req), - requestOrigin: req.headers.origin, - mode: 'test', + query: queryText, + files: [], + data: { + model: modelData.model, + systemPrompt, + currentUserValue: userQuestionValue, + timezone: timezone ?? 'Asia/Shanghai', + userKey: externalProvider.openaiAccount, + modelCapabilities: { + vision: modelData.vision, + audio: modelData.audio, + video: modelData.video + } + } satisfies SkillDebugProcessorData, + histories: historiesWithPreview, usageSource: UsageSourceEnum.fastgpt, - uid: tmbId, - runningAppInfo: { - sourceType: ChatSourceTypeEnum.skillEdit, - sourceId: skillId, - name: skill.name, - teamId, - tmbId - }, - runningUserInfo: await getRunningUserInfoByTmbId(tmbId), - chatId: runningChatId, - responseChatItemId: finalResponseChatItemId, - runtimeNodes, - runtimeEdges, - variables: {}, - query: removeEmptyUserInput(workflowQuery), - maxFileAmount, - maxBytesPerFile, - lastInteractive: interactive, - chatConfig, - histories: newHistories, - stream: true, - maxRunTimes: WORKFLOW_MAX_RUN_TIMES, - workflowStreamResponse: streamResponseContext.responseWrite, - responseDetail: true, - nodeResponseWriteConfig: { - persistToDb: true, - retainInMemory: true + usageId: interactive?.usageId, + maxFiles: SKILL_DEBUG_MAX_FILES, + customPdfParse: false, + processor: createSkillDebugProcessor({ + skillId, + responseChatItemId: finalResponseChatItemId, + isInteractiveResume: interactive?.type === 'agentAsk', + prepareActions: options.agentSandboxPrepareActions + }), + onStreamContextReady: (context) => { + streamContext = context; }, - agentSandboxPrepareActions: options.agentSandboxPrepareActions - }); + onBeforeStreamDone: async ({ result, durationSeconds }) => { + streamContext?.write(workflowSseEvent.workflowDuration(durationSeconds)); + + const nodeResponseWriter = new WorkflowNodeResponseWriter({ + ...chatSource, + chatId: runningChatId, + chatItemDataId: finalResponseChatItemId, + teamId, + persistToDb: true, + retainInMemory: false + }); + await nodeResponseWriter.record(result.nodeResponses); + await nodeResponseWriter.close(); - const computedFlowResponses = (flatNodeResponses || []).map((item) => { - if (item.totalPoints && item.totalPoints > 0) return item; + const aiResponse: AIChatItemType & { dataId?: string } = { + dataId: finalResponseChatItemId, + obj: ChatRoleEnum.AI, + value: result.aiResponse, + memories: result.memories + }; + const saveParams: SaveChatProps = { + ...chatSource, + chatId: runningChatId, + teamId, + tmbId, + nodes: [], + appChatConfig: {}, + variables: {}, + source: ChatSourceEnum.test, + userContent: userQuestion, + aiContent: aiResponse, + durationSeconds, + nodeResponseSummary: nodeResponseWriter.getSummary(), + metadata: { originIp } + }; - if (item.model && (item.inputTokens !== undefined || item.outputTokens !== undefined)) { - try { - const { totalPoints } = formatModelChars2Points({ - model: item.model, - inputTokens: item.inputTokens ?? 0, - outputTokens: item.outputTokens ?? 0 + if (interactive) { + await updateInteractiveChat({ + interactive, + shouldFinalizePreparedRound: preparedRound.shouldFinalizePreparedRound, + ...saveParams }); - if (totalPoints > 0) { - return { - ...item, - totalPoints - }; - } - } catch (e) { - logger.error('recompute debug points error', { error: e }); + } else if (preparedRound.shouldFinalizePreparedRound) { + await finalizeChatRound(saveParams); } - } - return item; - }); - - logger.debug('Skill debug workflow completed', { skillId, chatId, durationSeconds }); + roundState.finalized = true; - computedFlowResponses.forEach((nodeResponse) => { - streamResponseContext?.responseWrite(workflowSseEvent.flowNodeResponse(nodeResponse)); + if (!preparedRound.shouldFinalizePreparedRound && preparedRound.shouldPersistChatRound) { + await updateChatGenerateStatus({ + ...chatSource, + chatId: runningChatId, + status: ChatGenerateStatusEnum.done + }); + } + } }); - streamResponseContext.responseWrite(workflowSseEvent.workflowDuration(durationSeconds)); - - streamResponseContext.responseWrite(workflowSseEvent.answerStop()); - - const aiResponse: AIChatItemType & { dataId?: string } = { - dataId: finalResponseChatItemId, - obj: ChatRoleEnum.AI, - value: assistantResponses, - memories: system_memories, - [DispatchNodeResponseKeyEnum.nodeResponse]: computedFlowResponses, - customFeedbacks - }; - - const saveParams: SaveChatProps = { - ...chatSource, - chatId: runningChatId, - teamId, - tmbId, - nodes: [], - appChatConfig: {}, - variables: {}, - source: ChatSourceEnum.test, - userContent: workflowUserQuestion, - aiContent: aiResponse, - durationSeconds, - nodeResponseSummary, - metadata: { originIp } - }; - - if (interactive) { - await updateInteractiveChat({ - interactive, - shouldFinalizePreparedRound: preparedRound.shouldFinalizePreparedRound, - ...saveParams - }); - } else if (preparedRound.shouldFinalizePreparedRound) { - await finalizeChatRound(saveParams); - } - roundState.finalized = true; - - if (!preparedRound.shouldFinalizePreparedRound && preparedRound.shouldPersistChatRound) { - await updateChatGenerateStatus({ - ...chatSource, - chatId: runningChatId, - status: ChatGenerateStatusEnum.done - }); - } - - streamResponseContext.responseWrite(workflowSseEvent.done(SseResponseEventEnum.answer)); - - await streamResponseContext.flushResume(); - } catch (err: any) { + streamContext = result.streamContext; + await streamContext.flushResume(); + } catch (error) { const { preparedRound } = roundState; if ( !roundState.finalized && @@ -341,7 +284,7 @@ export async function handleSkillDebugChat( sourceId: roundState.sourceId, chatId: roundState.chatId, responseChatItemId: roundState.responseChatItemId, - error: err + error }); } else { await updateChatGenerateStatus({ @@ -353,12 +296,13 @@ export async function handleSkillDebugChat( } } - if (streamResponseContext) { - streamResponseContext.writeStreamError(err); + logger.error('Skill debug chat error', { error, skillId }); + if (streamContext) { + streamContext.writeError(error); + await streamContext.flushResume(); } else { - sseErrRes(res, err); + sseErrRes(res, error); } - await streamResponseContext?.flushResume(); } res.end(); diff --git a/packages/service/core/ai/skill/debugChat/index.ts b/packages/service/core/ai/skill/debugChat/index.ts index e8a964f7d15b..3d56cbced62f 100644 --- a/packages/service/core/ai/skill/debugChat/index.ts +++ b/packages/service/core/ai/skill/debugChat/index.ts @@ -1,2 +1 @@ export { handleSkillDebugChat } from './handler'; -export { buildDebugRuntimeNodes } from './runtime'; diff --git a/packages/service/core/ai/skill/debugChat/memory.ts b/packages/service/core/ai/skill/debugChat/memory.ts new file mode 100644 index 000000000000..2785e8f4ffdf --- /dev/null +++ b/packages/service/core/ai/skill/debugChat/memory.ts @@ -0,0 +1,116 @@ +import { AgentPlanReadSchema, type AgentPlanType } from '@fastgpt/global/core/ai/agent/type'; +import { hasUnfinishedAgentPlan } from '@fastgpt/global/core/ai/agent/utils'; +import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import type { AIChatItemValueItemType, ChatItemMiniType } from '@fastgpt/global/core/chat/type'; +import type { AgentAskPayload } from '../../llm/agentLoop/interface'; +import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; + +const SKILL_DEBUG_AGENT_NODE_ID = 'skill-debug-agent'; + +type SkillDebugAgentLoopMemory = { + providerState?: unknown; +}; + +export const getSkillDebugAgentLoopMemoryKey = () => `agentLoopMemory-${SKILL_DEBUG_AGENT_NODE_ID}`; + +/** 从最后一条 AI history 恢复 Agent Loop 的 opaque providerState。 */ +export const readSkillDebugAgentLoopMemory = ({ + histories +}: { + histories: ChatItemMiniType[]; +}): SkillDebugAgentLoopMemory => { + const lastHistory = histories.at(-1); + if (!lastHistory || lastHistory.obj !== ChatRoleEnum.AI) return {}; + + const memory = lastHistory.memories?.[getSkillDebugAgentLoopMemoryKey()]; + if (!memory || typeof memory !== 'object') return {}; + return memory as SkillDebugAgentLoopMemory; +}; + +/** 暂停态保存 providerState,完成态写 undefined 清理未完成状态。 */ +export const buildSkillDebugAgentLoopMemories = (providerState?: unknown) => ({ + [getSkillDebugAgentLoopMemoryKey()]: + providerState !== undefined + ? { + providerState + } + : undefined +}); + +/** 从聊天历史读取最后一个未完成计划,完成或 null 计划会阻止恢复更早状态。 */ +export const readSkillDebugActivePlan = ({ + histories +}: { + histories: ChatItemMiniType[]; +}): AgentPlanType | undefined => { + for (let historyIndex = histories.length - 1; historyIndex >= 0; historyIndex--) { + const history = histories[historyIndex]; + if (history.obj !== ChatRoleEnum.AI) continue; + + for (let valueIndex = history.value.length - 1; valueIndex >= 0; valueIndex--) { + const value = history.value[valueIndex]; + if (!Object.prototype.hasOwnProperty.call(value, 'plan')) continue; + if (value.plan === null) return; + + const parsedPlan = AgentPlanReadSchema.safeParse(value.plan); + if (!parsedPlan.success) return; + return hasUnfinishedAgentPlan(parsedPlan.data) ? parsedPlan.data : undefined; + } + } +}; + +/** 只保留最后一个计划快照;完成计划保存 null 终止标记。 */ +export const compactSkillDebugPlanSnapshots = ( + assistantResponses: AIChatItemValueItemType[] +): AIChatItemValueItemType[] => { + const lastPlanIndex = assistantResponses.findLastIndex( + (value) => Object.prototype.hasOwnProperty.call(value, 'plan') && value.plan !== undefined + ); + if (lastPlanIndex < 0) return assistantResponses; + + return assistantResponses.flatMap((value, index) => { + if (!Object.prototype.hasOwnProperty.call(value, 'plan') || value.plan === undefined) { + return [value]; + } + + const valueWithoutPlan = Object.fromEntries( + Object.entries(value).filter( + ([key, itemValue]) => key !== 'plan' && itemValue !== undefined && itemValue !== null + ) + ) as AIChatItemValueItemType; + if (index === lastPlanIndex) { + const plan = value.plan === null || !hasUnfinishedAgentPlan(value.plan) ? null : value.plan; + return [{ ...valueWithoutPlan, plan }]; + } + + const hasSemanticValue = Object.entries(valueWithoutPlan).some( + ([key, itemValue]) => key !== 'id' && itemValue !== undefined && itemValue !== null + ); + return hasSemanticValue ? [valueWithoutPlan] : []; + }); +}; + +/** 将 Agent Loop ask 暂停结果转换成 ChatBox 可恢复交互。 */ +export const createSkillDebugAskInteractive = ({ + askId, + ask, + usageId +}: { + askId: string; + ask: AgentAskPayload; + usageId: string; +}): WorkflowInteractiveResponseType => ({ + type: 'agentAsk', + askId, + usageId, + entryNodeIds: [], + memoryEdges: [], + nodeOutputs: [], + params: { + description: ask.reason, + questions: ask.questions.map((question) => ({ + ...question, + answer: '' + })) + } +}); diff --git a/packages/service/core/ai/skill/debugChat/processor.ts b/packages/service/core/ai/skill/debugChat/processor.ts new file mode 100644 index 000000000000..f6f13abd0cce --- /dev/null +++ b/packages/service/core/ai/skill/debugChat/processor.ts @@ -0,0 +1,224 @@ +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { getSystemTime } from '@fastgpt/global/common/time/timezone'; +import { SKILL_EDIT_SANDBOX_SYSTEM_PROMPT } from '@fastgpt/global/core/ai/sandbox/constants'; +import type { OpenaiAccountType } from '@fastgpt/global/support/user/team/type'; +import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; +import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse'; +import { runAgentLoop } from '../../llm/agentLoop/interface'; +import type { + AuxiliaryGenerationProcessorParams, + AuxiliaryGenerationProcessorResponse +} from '../../auxiliaryGeneration'; +import { getRunningSandboxId } from '../../sandbox/interface/runtime'; +import { getSandboxToolInfo } from '../../sandbox/interface/toolCall'; +import { createSkillDebugEventAdapter } from './eventAdapter'; +import { + buildSkillDebugAgentLoopMemories, + compactSkillDebugPlanSnapshots, + createSkillDebugAskInteractive, + readSkillDebugActivePlan, + readSkillDebugAgentLoopMemory +} from './memory'; +import { createSkillDebugReadFileExecutor } from './readFile'; +import { prepareSkillDebugRuntime, type SkillDebugSandboxPrepareAction } from './runtime'; +import { buildSkillDebugUserContext, SKILL_DEBUG_MAX_FILES } from './userContext'; + +export type SkillDebugProcessorData = { + model: string; + systemPrompt: string; + currentUserValue: UserChatItemValueItemType[]; + timezone: string; + userKey?: OpenaiAccountType; + modelCapabilities: { + vision?: boolean; + audio?: boolean; + video?: boolean; + }; +}; + +type SkillDebugProcessorContext = { + skillId: string; + responseChatItemId: string; + isInteractiveResume: boolean; + prepareActions?: SkillDebugSandboxPrepareAction[]; +}; + +const toolReferenceReg = /\{\{@([^@{}]+)@\}\}/g; + +/** 将 Skill system prompt 中的系统工具引用替换为模型实际可见的名称。 */ +const formatSkillDebugSystemPrompt = ({ + systemPrompt, + lang +}: { + systemPrompt: string; + lang: AuxiliaryGenerationProcessorParams['user']['lang']; +}) => + [ + systemPrompt.replace(toolReferenceReg, (raw, id: string) => { + const trimmedId = id.trim(); + const normalizedId = trimmedId.startsWith('t') ? trimmedId.slice(1) : trimmedId; + const name = + getSandboxToolInfo(trimmedId, lang)?.name || getSandboxToolInfo(normalizedId, lang)?.name; + return name ? `{{${name}}}` : raw; + }), + SKILL_EDIT_SANDBOX_SYSTEM_PROMPT + ] + .filter(Boolean) + .join('\n\n'); + +/** + * 创建 Skill Debug 处理器。 + * + * 处理器直接调用稳定 Agent Loop;Skill 域负责 sandbox、附件、ask 恢复和 ChatBox 产物, + * 不构造 Workflow 节点,也不经过 Workflow Dispatcher。 + */ +export const createSkillDebugProcessor = ({ + skillId, + responseChatItemId, + isInteractiveResume, + prepareActions +}: SkillDebugProcessorContext) => { + return async ({ + query, + data, + histories, + streamWriter, + requestOrigin, + maxFiles, + customPdfParse, + checkIsStopping, + usageSink, + usageId, + user + }: AuxiliaryGenerationProcessorParams): Promise => { + streamWriter?.( + workflowSseEvent.sandboxStatus({ + sandboxId: getRunningSandboxId({ + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: skillId, + userId: user.userId + }), + phase: 'lazyInit' + }) + ); + + const runtime = await prepareSkillDebugRuntime({ + skillId, + userId: user.userId, + prepareActions + }); + const userContext = buildSkillDebugUserContext({ + histories, + currentUserValue: data.currentUserValue, + currentDataId: responseChatItemId, + requestOrigin, + maxFiles: maxFiles ?? SKILL_DEBUG_MAX_FILES, + skillInfos: runtime.skillInfos, + currentWorkingDirectory: runtime.currentWorkingDirectory, + currentTime: getSystemTime(data.timezone) + }); + const adapter = createSkillDebugEventAdapter({ streamWriter, lang: user.lang }); + const restoredMemory = readSkillDebugAgentLoopMemory({ histories }); + const providerState = isInteractiveResume ? restoredMemory.providerState : undefined; + const continuation = + providerState !== undefined + ? { + type: 'ask' as const, + answer: query, + ...(userContext.askContinuationMessages.length > 0 + ? { additionalMessages: userContext.askContinuationMessages } + : {}) + } + : undefined; + + const loopResult = await runAgentLoop({ + runtime: { + teamId: user.teamId, + lang: user.lang, + llmParams: { + model: data.model, + userKey: data.userKey, + stream: true, + useVision: data.modelCapabilities.vision, + useAudio: data.modelCapabilities.audio, + useVideo: data.modelCapabilities.video + }, + systemTools: { + plan: { enabled: true }, + ask: { enabled: true }, + sandbox: { + enabled: true, + client: runtime.sandboxClient + }, + readFile: { + enabled: true, + maxFileAmount: maxFiles ?? SKILL_DEBUG_MAX_FILES, + execute: createSkillDebugReadFileExecutor({ + readableFileUrls: userContext.readableFileUrls, + maxFileAmount: maxFiles ?? SKILL_DEBUG_MAX_FILES, + teamId: user.teamId, + tmbId: user.tmbId, + customPdfParse, + usageId + }) + } + }, + toolCatalog: { + runtimeTools: [] + }, + executeTool: async () => { + throw new Error('Skill Debug does not register runtime tools'); + }, + checkIsStopping, + usagePush: usageSink, + emitEvent: adapter.emitEvent + }, + input: { + systemPrompt: formatSkillDebugSystemPrompt({ + systemPrompt: data.systemPrompt, + lang: user.lang + }), + messages: userContext.messages, + activePlan: readSkillDebugActivePlan({ histories }), + providerState, + continuation + } + }); + + const aiResponse = compactSkillDebugPlanSnapshots( + adapter.buildAssistantResponses(loopResult.assistantMessages) + ); + + if (loopResult.status === 'paused') { + if (loopResult.pause.type !== 'ask') { + throw new Error('Skill Debug does not support child tool interactive responses'); + } + + const interactive = createSkillDebugAskInteractive({ + askId: loopResult.pause.askId, + ask: loopResult.pause.ask, + usageId + }); + aiResponse.push({ interactive }); + streamWriter?.(workflowSseEvent.interactive(interactive)); + } else if (loopResult.status === 'error') { + const errorText = getErrText(loopResult.error, 'Skill Debug agent loop failed'); + if (errorText) { + aiResponse.push({ text: { content: errorText } }); + } + } + + adapter.nodeResponses.forEach((nodeResponse) => { + streamWriter?.(workflowSseEvent.flowNodeResponse(nodeResponse)); + }); + + return { + aiResponse, + nodeResponses: adapter.nodeResponses, + memories: buildSkillDebugAgentLoopMemories( + loopResult.status === 'paused' ? loopResult.providerState : undefined + ) + }; + }; +}; diff --git a/packages/service/core/ai/skill/debugChat/readFile.ts b/packages/service/core/ai/skill/debugChat/readFile.ts new file mode 100644 index 000000000000..453f1ff523fa --- /dev/null +++ b/packages/service/core/ai/skill/debugChat/readFile.ts @@ -0,0 +1,72 @@ +import { getErrText } from '@fastgpt/global/common/error/utils'; +import type { AgentLoopReadFileExecutor } from '../../llm/agentLoop/interface'; +import { ReadFilesToolParamsSchema } from '../../llm/agentLoop/interface'; +import { parseJsonArgs } from '../../utils'; +import { getFileContentByUrl } from '../../../chat/fileContext'; + +/** 创建只允许读取当前聊天附件的 Skill Debug read_files executor。 */ +export const createSkillDebugReadFileExecutor = ({ + readableFileUrls, + maxFileAmount, + teamId, + tmbId, + customPdfParse, + usageId +}: { + readableFileUrls: string[]; + maxFileAmount: number; + teamId: string; + tmbId: string; + customPdfParse?: boolean; + usageId: string; +}): AgentLoopReadFileExecutor => { + const readableFileUrlSet = new Set(readableFileUrls); + + return async ({ call }) => { + const parsedParams = ReadFilesToolParamsSchema.safeParse( + parseJsonArgs(call.function.arguments) + ); + if (!parsedParams.success) { + return { + response: parsedParams.error.message, + usages: [], + error: parsedParams.error + }; + } + + const requestedUrls = parsedParams.data.urls.slice(0, maxFileAmount); + const files = await Promise.all( + requestedUrls.map(async (url) => { + if (!readableFileUrlSet.has(url)) { + return { + url, + name: '', + content: 'File is not available in the current chat context.' + }; + } + + try { + const { name, content } = await getFileContentByUrl({ + url, + teamId, + tmbId, + customPdfParse, + usageId + }); + return { url, name, content }; + } catch (error) { + return { + url, + name: '', + content: getErrText(error, 'Load file error') + }; + } + }) + ); + + return { + response: JSON.stringify(files), + usages: [] + }; + }; +}; diff --git a/packages/service/core/ai/skill/debugChat/runtime.ts b/packages/service/core/ai/skill/debugChat/runtime.ts index 1b980752dbc9..dd006f24f70f 100644 --- a/packages/service/core/ai/skill/debugChat/runtime.ts +++ b/packages/service/core/ai/skill/debugChat/runtime.ts @@ -1,142 +1,83 @@ +import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; import { - NodeInputKeyEnum, - NodeOutputKeyEnum, - WorkflowIOValueTypeEnum -} from '@fastgpt/global/core/workflow/constants'; -import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; -import type { RuntimeEdgeItemType } from '@fastgpt/global/core/workflow/type/edge'; -import { - FlowNodeInputTypeEnum, - FlowNodeOutputTypeEnum, - FlowNodeTypeEnum -} from '@fastgpt/global/core/workflow/node/constant'; -import { getHandleId } from '@fastgpt/global/core/workflow/utils'; + getAgentSkillInfos, + prepareAgentSandboxRuntime, + preparePackageMirrors, + prepareSandbox, + readCurrentWorkingDirectory, + withAgentSandboxInitLease, + type DeployedSkillInfo, + type DeployedSkillVersion, + type SandboxClient, + type SandboxPrepareContext, + type SandboxPrepareStep +} from '../../sandbox/interface/runtime'; +import { EDIT_DEBUG_SANDBOX_CHAT_ID } from '../edit/config'; + +export type SkillDebugSandboxPrepareContext = SandboxPrepareContext & { + sandboxClient: SandboxClient; + deployedSkillVersions: DeployedSkillVersion[]; + skillInfos: DeployedSkillInfo[]; + skillScanDirectories: string[]; +}; + +export type SkillDebugSandboxPrepareAction = SandboxPrepareStep; -const START_NODE_ID = 'skill-debug-start'; -const AGENT_NODE_ID = 'skill-debug-agent'; +export type SkillDebugRuntime = { + sandboxClient: SandboxClient; + currentWorkingDirectory?: string; + skillInfos: DeployedSkillInfo[]; +}; /** - * 构造 Skill 调试对话使用的最小 workflow。 + * 准备 Skill Debug 使用的 edit sandbox runtime。 * - * 运行态只包含 workflowStart -> agent 两个节点;agent 通过 editSkillId 进入当前 - * Skill 的编辑沙盒,避免调试链路依赖真实应用配置。 + * 公共层只执行调用方传入的 prepare actions;内置 Skill 的来源和注入策略由 Pro/API 层决定。 */ -export function buildDebugRuntimeNodes( - skillId: string, - model: string, - systemPrompt: string -): { - runtimeNodes: RuntimeNodeItemType[]; - runtimeEdges: RuntimeEdgeItemType[]; -} { - const runtimeNodes: RuntimeNodeItemType[] = [ - { - nodeId: START_NODE_ID, - name: 'Workflow Start', - avatar: '', - intro: '', - flowNodeType: FlowNodeTypeEnum.workflowStart, - showStatus: false, - isEntry: true, - inputs: [ - { - key: NodeInputKeyEnum.userChatInput, - renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea], - valueType: WorkflowIOValueTypeEnum.string, - label: 'User Question', - toolDescription: 'user question', - required: true, - value: '' - } - ], - outputs: [ - { - id: NodeOutputKeyEnum.userChatInput, - key: NodeOutputKeyEnum.userChatInput, - label: 'User Question', - type: FlowNodeOutputTypeEnum.static, - valueType: WorkflowIOValueTypeEnum.string - } - ] - }, - { - nodeId: AGENT_NODE_ID, - name: 'Agent', - avatar: '', - intro: '', - flowNodeType: FlowNodeTypeEnum.agent, - showStatus: true, - isEntry: false, - inputs: [ - { - key: NodeInputKeyEnum.userChatInput, - renderTypeList: [FlowNodeInputTypeEnum.reference], - valueType: WorkflowIOValueTypeEnum.string, - label: 'User Question', - required: true, - value: [START_NODE_ID, NodeOutputKeyEnum.userChatInput] - }, - { - key: NodeInputKeyEnum.history, - renderTypeList: [FlowNodeInputTypeEnum.numberInput], - valueType: WorkflowIOValueTypeEnum.chatHistory, - label: 'Chat History', - required: true, - min: 0, - max: 50, - value: 20 - }, - { - key: NodeInputKeyEnum.aiModel, - renderTypeList: [FlowNodeInputTypeEnum.selectLLMModel], - label: 'AI Model', - required: true, - valueType: WorkflowIOValueTypeEnum.string, - value: model - }, - { - key: NodeInputKeyEnum.aiSystemPrompt, - renderTypeList: [FlowNodeInputTypeEnum.textarea], - valueType: WorkflowIOValueTypeEnum.string, - label: 'System Prompt', - value: systemPrompt - }, +export const prepareSkillDebugRuntime = async ({ + skillId, + userId, + prepareActions = [] +}: { + skillId: string; + userId: string; + prepareActions?: SkillDebugSandboxPrepareAction[]; +}): Promise => { + const sandboxContext = await prepareAgentSandboxRuntime({ + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: skillId, + userId, + chatId: EDIT_DEBUG_SANDBOX_CHAT_ID + }); + + const scanSkillInfos = (): SkillDebugSandboxPrepareAction => async (context) => ({ + ...context, + skillInfos: await getAgentSkillInfos({ + sandbox: context.sandbox, + skillDirectories: [context.workDirectory, ...context.skillScanDirectories] + }) + }); + const preparedContext = await withAgentSandboxInitLease({ + sandboxId: sandboxContext.sandboxClient.getSandboxId(), + fn: () => + prepareSandbox( { - key: NodeInputKeyEnum.aiChatVision, - renderTypeList: [FlowNodeInputTypeEnum.hidden], - valueType: WorkflowIOValueTypeEnum.boolean, - label: '', - value: true + ...sandboxContext, + sandbox: sandboxContext.sandboxClient.provider, + deployedSkillVersions: [], + skillInfos: [], + skillScanDirectories: [] }, - { - key: NodeInputKeyEnum.editSkillId, - renderTypeList: [FlowNodeInputTypeEnum.hidden], - valueType: WorkflowIOValueTypeEnum.string, - label: 'Edit Skill ID', - value: skillId - } - ], - outputs: [ - { - id: NodeOutputKeyEnum.answerText, - key: NodeOutputKeyEnum.answerText, - label: 'Answer', - type: FlowNodeOutputTypeEnum.static, - valueType: WorkflowIOValueTypeEnum.string - } - ] - } - ]; - - const runtimeEdges: RuntimeEdgeItemType[] = [ - { - source: START_NODE_ID, - sourceHandle: getHandleId(START_NODE_ID, 'source', 'right'), - target: AGENT_NODE_ID, - targetHandle: getHandleId(AGENT_NODE_ID, 'target', 'left'), - status: 'waiting' - } - ]; + preparePackageMirrors(), + ...prepareActions, + readCurrentWorkingDirectory(), + scanSkillInfos() + ) + }); - return { runtimeNodes, runtimeEdges }; -} + return { + sandboxClient: preparedContext.sandboxClient, + currentWorkingDirectory: preparedContext.currentWorkingDirectory, + skillInfos: preparedContext.skillInfos + }; +}; diff --git a/packages/service/core/ai/skill/debugChat/userContext.ts b/packages/service/core/ai/skill/debugChat/userContext.ts new file mode 100644 index 000000000000..aff158c29feb --- /dev/null +++ b/packages/service/core/ai/skill/debugChat/userContext.ts @@ -0,0 +1,271 @@ +import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { + chatValue2RuntimePrompt, + chats2GPTMessages, + runtimePrompt2ChatsValue +} from '@fastgpt/global/core/chat/adapt'; +import type { + ChatItemMiniType, + UserChatItemFileItemType, + UserChatItemValueItemType +} from '@fastgpt/global/core/chat/type'; +import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; +import { READ_FILES_TOOL_NAME } from '../../llm/agentLoop/interface'; +import { SANDBOX_READ_FILE_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools'; +import { parseUrlToChatFileType } from '../../../chat/fileContext'; +import { + getSafeSandboxInputFilename, + type DeployedSkillInfo +} from '../../sandbox/interface/runtime'; + +export const SKILL_DEBUG_MAX_FILES = 10; + +export type SkillDebugInputFile = { + name: string; + type: ChatFileTypeEnum; + url: string; +}; + +const escapePromptXml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const normalizeFileUrl = ({ url, requestOrigin }: { url: string; requestOrigin?: string }) => { + const normalizedUrl = url.trim(); + if (!normalizedUrl) return ''; + if (/^(https?:|data:|ws:|wss:)/i.test(normalizedUrl)) return normalizedUrl; + if (!requestOrigin) return normalizedUrl; + + try { + return new URL(normalizedUrl, requestOrigin).toString(); + } catch { + return ''; + } +}; + +/** 将 ChatBox 文件归一化为 Agent Loop 可使用的 URL、类型和安全文件名。 */ +export const parseSkillDebugInputFiles = ({ + files, + requestOrigin, + maxFiles = SKILL_DEBUG_MAX_FILES +}: { + files: UserChatItemFileItemType[]; + requestOrigin?: string; + maxFiles?: number; +}): SkillDebugInputFile[] => { + const normalizedFiles = files + .map((file) => ({ + file, + url: normalizeFileUrl({ url: file.url ?? '', requestOrigin }) + })) + .filter((item): item is { file: UserChatItemFileItemType; url: string } => !!item.url); + const uniqueFiles = Array.from( + normalizedFiles + .reduce((map, item) => { + if (!map.has(item.url)) map.set(item.url, item); + return map; + }, new Map()) + .values() + ); + const usedNames = new Map(); + + return uniqueFiles + .slice(0, maxFiles) + .map(({ file, url }, index) => { + const parsedFile = parseUrlToChatFileType({ + url, + urlTypeMap: file.type ? { [url]: file.type } : undefined + }); + if (!parsedFile) return; + + return { + name: getSafeSandboxInputFilename(file.name || parsedFile.name || url, index, usedNames), + type: file.type && file.type !== ChatFileTypeEnum.file ? file.type : parsedFile.type, + url: parsedFile.url + }; + }) + .filter((file): file is SkillDebugInputFile => !!file); +}; + +const buildSkillsPrompt = (skillInfos: DeployedSkillInfo[]) => { + if (skillInfos.length === 0) return ''; + + return `## 技能 + +以下技能为特定任务提供专门的操作说明: + +- 当用户任务与某个技能的描述匹配时,先使用 ${SANDBOX_READ_FILE_TOOL_NAME} 读取完整的技能文件,再继续执行。不要仅凭技能描述推断完整工作流。 +- 当技能文件引用相对路径时,以该技能文件所在目录为基准解析,并在工具调用中使用解析后的路径。 + + +${skillInfos + .map((info) => + [ + '', + `${escapePromptXml(info.name)}`, + `${escapePromptXml(info.description)}`, + `${escapePromptXml(info.skillMdPath)}`, + '' + ].join('\n') + ) + .join('\n')} +`; +}; + +const buildInputFilesPrompt = (files: SkillDebugInputFile[]) => { + if (files.length === 0) return ''; + + return `## 对话文件 +用户本次对话上传的文件,用途: +1. 可通过 ${READ_FILES_TOOL_NAME} 读取文档内容。 +2. 图片、音频和视频已作为当前消息的多模态输入提供;URL 也可作为模型参数。 + +${files + .map( + (file) => ` +${escapePromptXml(file.name)} +${escapePromptXml(file.type)} +${escapePromptXml(file.url)} +` + ) + .join('\n')}`; +}; + +const buildSandboxWriteBoundaryPrompt = (currentWorkingDirectory?: string) => { + if (!currentWorkingDirectory) return ''; + + return `## Sandbox 文件写入边界 +生成或修改文件时,必须严格区分系统目录和用户产物目录: +- 用户 Skill 产物根目录:${currentWorkingDirectory}/skills +- 如果任务需要创建或修改用户 Skill,只能写入:${currentWorkingDirectory}/skills// +- 用户 Skill 主文件必须是:${currentWorkingDirectory}/skills//SKILL.md +- 禁止写入:${currentWorkingDirectory}// 或 ${currentWorkingDirectory}/SKILL.md +- 禁止写入:/home/sandbox/.fastgpt/skills/、~/.fastgpt/skills/ 或任何 .fastgpt/skills/ 路径;这些路径只用于系统内置 Skill。`; +}; + +const buildUserReminderInput = ({ + query, + skillInfos, + files, + currentWorkingDirectory, + currentTime +}: { + query: string; + skillInfos: DeployedSkillInfo[]; + files: SkillDebugInputFile[]; + currentWorkingDirectory?: string; + currentTime?: string; +}) => { + const reminder = [ + buildSkillsPrompt(skillInfos), + buildSandboxWriteBoundaryPrompt(currentWorkingDirectory), + buildInputFilesPrompt(files), + currentTime || currentWorkingDirectory + ? `## 背景信息${currentTime ? `\n当前时间: ${currentTime}` : ''}${ + currentWorkingDirectory ? `\n当前 sandbox 工作目录: ${currentWorkingDirectory}` : '' + }` + : '' + ] + .filter(Boolean) + .join('\n\n'); + + if (!reminder) return query; + return ` +依据以下内容完成任务 + +${reminder} + +${query}`.trim(); +}; + +/** + * 构建 Skill Debug Agent Loop 上下文。 + * + * 文档只通过 reminder 和 read_files 暴露,多模态文件保留为模型 content part;ask 恢复时 + * 额外返回不含用户回答文本的文件消息,防止回答被同时当作 tool response 和 user message。 + */ +export const buildSkillDebugUserContext = ({ + histories, + currentUserValue, + currentDataId, + requestOrigin, + maxFiles = SKILL_DEBUG_MAX_FILES, + skillInfos, + currentWorkingDirectory, + currentTime +}: { + histories: ChatItemMiniType[]; + currentUserValue: UserChatItemValueItemType[]; + currentDataId: string; + requestOrigin?: string; + maxFiles?: number; + skillInfos: DeployedSkillInfo[]; + currentWorkingDirectory?: string; + currentTime: string; +}): { + messages: ChatCompletionMessageParam[]; + askContinuationMessages: ChatCompletionMessageParam[]; + readableFileUrls: string[]; +} => { + const readableFileUrls = new Set(); + let askContinuationMessages: ChatCompletionMessageParam[] = []; + const sourceMessages: ChatItemMiniType[] = [ + ...histories, + { + dataId: currentDataId, + obj: ChatRoleEnum.Human, + value: currentUserValue + } + ]; + const currentMessageIndex = sourceMessages.length - 1; + const rewrittenMessages = sourceMessages.map((message, index) => { + if (message.obj !== ChatRoleEnum.Human) return message; + + const { text = '', files = [] } = chatValue2RuntimePrompt(message.value); + const inputFiles = parseSkillDebugInputFiles({ files, requestOrigin, maxFiles }); + inputFiles.forEach((file) => { + if (file.type === ChatFileTypeEnum.file) readableFileUrls.add(file.url); + }); + const isCurrentMessage = index === currentMessageIndex; + const buildMessageValue = (query: string) => + runtimePrompt2ChatsValue({ + files: inputFiles + .filter((file) => file.type !== ChatFileTypeEnum.file) + .map(({ name, type, url }) => ({ name, type, url })), + text: buildUserReminderInput({ + query, + files: inputFiles, + skillInfos: isCurrentMessage ? skillInfos : [], + currentWorkingDirectory: isCurrentMessage ? currentWorkingDirectory : undefined, + currentTime: isCurrentMessage ? currentTime : undefined + }) + }); + + if (isCurrentMessage && inputFiles.length > 0) { + askContinuationMessages = chats2GPTMessages({ + messages: [{ ...message, value: buildMessageValue('') }], + reserveId: false, + reserveTool: true + }).filter((item) => item.role !== 'system'); + } + + return { + ...message, + value: buildMessageValue(text) + }; + }); + + return { + messages: chats2GPTMessages({ + messages: rewrittenMessages, + reserveId: false, + reserveTool: true + }).filter((message) => message.role !== 'system'), + askContinuationMessages, + readableFileUrls: [...readableFileUrls] + }; +}; diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts index b5a91b7832b6..49bf8a070d42 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts @@ -295,7 +295,12 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise systemPrompt: agentSystemPrompt, activePlan, providerState: runtimeProviderState, - userAnswer: isAskResume ? queryInput || userChatInput : undefined, + continuation: isAskResume + ? { + type: 'ask', + answer: queryInput || userChatInput + } + : undefined, childrenInteractiveParams: createAgentLoopCoreChildInteractiveParams({ lastInteractive }) diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.ts index 08ecece47719..a9773a107e4a 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.ts @@ -1,21 +1,17 @@ import type { AgentInputFile } from '../../adapter/userContext'; -import type { BuiltinSkillSource } from '@fastgpt/global/core/ai/skill/runtime/builtin'; import type { SelectedAgentSkillItemType } from '@fastgpt/global/core/app/formEdit/type'; import { type DeployedSkillInfo, type DeployedSkillVersion, getAgentSkillInfos, - getBuiltinSkillsRootPath, injectAgentSkillFilesToSandbox, injectCurrentInputFiles, prepareAgentSandboxRuntime, preparePackageMirrors, prepareSandbox, readCurrentWorkingDirectory, - resolveSandboxHome, runAgentSkillVersionEntrypoints, runSandboxEntrypoint, - syncBuiltinSkillsToSandbox, withAgentSandboxInitLease, type SandboxClient, type SandboxPrepareContext, @@ -57,6 +53,8 @@ type EnsureAgentSandboxRuntimeResult = { }; type AgentSandboxPrepareStep = SandboxPrepareStep; +export { createBuiltinSkillPrepareAction } from '../../../../../../ai/sandbox/interface/runtime'; + /** * 确保 Agent 本轮 sandbox runtime 可用。 * @@ -131,46 +129,6 @@ export async function ensureAgentSandboxRuntime({ }; } -/** - * 创建“同步内置 Skill 到当前 sandbox”的 prepare action。 - * - * 调用方只提供内置 Skill 文件来源;具体同步位置、HOME 解析和后续扫描目录登记 - * 都在 sandbox prepare 生命周期内完成,避免 API 层感知 sandbox 细节。 - */ -export const createBuiltinSkillPrepareAction = - ({ - getSources, - injectToSandbox = syncBuiltinSkillsToSandbox - }: { - getSources: () => Promise; - injectToSandbox?: typeof syncBuiltinSkillsToSandbox; - }): AgentSandboxPrepareAction => - async (context) => { - const sources = await getSources(); - if (sources.length === 0) return context; - - const homeDirectory = await resolveSandboxHome(context.sandbox); - if (!homeDirectory) { - throw new Error('Failed to resolve sandbox HOME for builtin skill sync'); - } - - await injectToSandbox({ - sandbox: context.sandbox, - homeDirectory, - sources - }); - - const builtinSkillsRootPath = getBuiltinSkillsRootPath(homeDirectory); - - return { - ...context, - skillScanDirectories: [ - ...context.skillScanDirectories, - ...sources.map((source) => `${builtinSkillsRootPath}/${source.name}`) - ] - }; - }; - const scanEditDebugSkillInfos = (): AgentSandboxPrepareStep => async (context) => ({ ...context, skillInfos: await getAgentSkillInfos({ diff --git a/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/context/input.ts b/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/context/input.ts index 95c2248e9d69..52f29598b5f6 100644 --- a/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/context/input.ts +++ b/packages/service/core/workflow/dispatch/ai/agentLoopCore/application/context/input.ts @@ -17,7 +17,7 @@ export const buildAgentLoopCoreInput = ( ...(params.systemPrompt !== undefined ? { systemPrompt: params.systemPrompt } : {}), ...(params.activePlan !== undefined ? { activePlan: params.activePlan } : {}), ...(params.providerState !== undefined ? { providerState: params.providerState } : {}), - ...(params.userAnswer !== undefined ? { userAnswer: params.userAnswer } : {}), + ...(params.continuation !== undefined ? { continuation: params.continuation } : {}), ...(params.childrenInteractiveParams !== undefined ? { childrenInteractiveParams: params.childrenInteractiveParams } : {}) diff --git a/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts b/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts index 0035add9cc23..3cea2d0bb0cc 100644 --- a/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts +++ b/packages/service/test/core/ai/auxiliaryGeneration/agentLoop.test.ts @@ -96,7 +96,10 @@ describe('runAuxiliaryGenerationAgentLoop', () => { systemPrompt: 'helper prompt', messages: [{ role: 'user', content: '创建客服 Agent' }], providerState, - userAnswer: JSON.stringify({ answers: ['小范围'] }) + continuation: { + type: 'ask', + answer: JSON.stringify({ answers: ['小范围'] }) + } } }) ); diff --git a/packages/service/test/core/ai/auxiliaryGeneration/service.test.ts b/packages/service/test/core/ai/auxiliaryGeneration/service.test.ts new file mode 100644 index 000000000000..ef9a08251ace --- /dev/null +++ b/packages/service/test/core/ai/auxiliaryGeneration/service.test.ts @@ -0,0 +1,153 @@ +import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + write: vi.fn(), + writeDone: vi.fn(), + writeError: vi.fn(), + flushResume: vi.fn(), + pushUsage: vi.fn(), + createUsage: vi.fn(), + clearStop: vi.fn(), + shouldStop: vi.fn() +})); + +vi.mock('@fastgpt/service/core/ai/auxiliaryGeneration/stream', () => ({ + createAuxiliaryGenerationStream: vi.fn(async () => ({ + write: mocks.write, + writeDone: mocks.writeDone, + writeError: mocks.writeError, + flushResume: mocks.flushResume + })) +})); + +vi.mock('@fastgpt/service/core/ai/auxiliaryGeneration/usage', () => ({ + createAuxiliaryGenerationUsage: mocks.createUsage +})); + +vi.mock('@fastgpt/service/core/ai/auxiliaryGeneration/stop', () => ({ + clearAuxiliaryGenerationStop: mocks.clearStop, + shouldAuxiliaryGenerationStop: mocks.shouldStop +})); + +import { runAuxiliaryGeneration } from '@fastgpt/service/core/ai/auxiliaryGeneration/service'; + +describe('runAuxiliaryGeneration', () => { + const runGeneration = ({ + processor, + resOnce = vi.fn(), + onBeforeStreamDone + }: { + processor: (params: any) => Promise; + resOnce?: ReturnType; + onBeforeStreamDone?: (params: any) => Promise | void; + }) => + runAuxiliaryGeneration({ + req: { headers: {} } as any, + res: { once: resOnce } as any, + teamId: 'team-id', + tmbId: 'tmb-id', + userId: 'user-id', + isRoot: false, + lang: 'zh', + appName: 'Test', + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: 'source-id', + chatId: 'chat-id', + query: 'hello', + files: [], + data: undefined, + histories: [], + usageSource: UsageSourceEnum.fastgpt, + usageId: 'existing-usage-id', + processor, + onBeforeStreamDone + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.shouldStop.mockResolvedValue(false); + mocks.clearStop.mockResolvedValue(undefined); + mocks.createUsage.mockImplementation(async ({ usageId }) => ({ + pushUsage: mocks.pushUsage, + usageId: usageId ?? 'new-usage-id' + })); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes the reused usage id and persists before closing the stream', async () => { + const processor = vi.fn(async () => ({ + aiResponse: [{ text: { content: 'answer' } }] + })); + const onBeforeStreamDone = vi.fn(); + + await runGeneration({ processor, onBeforeStreamDone }); + + expect(mocks.createUsage).toHaveBeenCalledWith( + expect.objectContaining({ usageId: 'existing-usage-id' }) + ); + expect(processor).toHaveBeenCalledWith( + expect.objectContaining({ + usageId: 'existing-usage-id', + usageSink: mocks.pushUsage + }) + ); + expect(onBeforeStreamDone).toHaveBeenCalledWith( + expect.objectContaining({ + result: expect.objectContaining({ + aiResponse: [{ text: { content: 'answer' } }] + }), + durationSeconds: expect.any(Number) + }) + ); + expect(onBeforeStreamDone.mock.invocationCallOrder[0]).toBeLessThan( + mocks.writeDone.mock.invocationCallOrder[0] + ); + }); + + it('does not let a late stop poll overwrite a close event', async () => { + vi.useFakeTimers(); + let closeHandler = () => undefined; + let resolvePoll = (_value: boolean) => undefined; + let markPollStarted = () => undefined; + const pollStarted = new Promise((resolve) => { + markPollStarted = resolve; + }); + mocks.shouldStop.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePoll = resolve; + markPollStarted(); + }) + ); + const processor = vi.fn(async ({ checkIsStopping }) => { + vi.advanceTimersByTime(100); + await pollStarted; + closeHandler(); + resolvePoll(false); + await Promise.resolve(); + await Promise.resolve(); + + expect(checkIsStopping()).toBe(true); + return { aiResponse: [] }; + }); + + await runGeneration({ + processor, + resOnce: vi.fn((event, handler) => { + if (event === 'close') closeHandler = handler; + }) + }); + }); + + it('clears the stop marker when usage initialization fails', async () => { + mocks.createUsage.mockRejectedValueOnce(new Error('usage failed')); + + await expect(runGeneration({ processor: vi.fn() })).rejects.toThrow('usage failed'); + expect(mocks.clearStop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/service/test/core/ai/auxiliaryGeneration/stream.test.ts b/packages/service/test/core/ai/auxiliaryGeneration/stream.test.ts new file mode 100644 index 000000000000..067b9bc190f5 --- /dev/null +++ b/packages/service/test/core/ai/auxiliaryGeneration/stream.test.ts @@ -0,0 +1,73 @@ +import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { workflowSseEvent } from '@fastgpt/global/core/workflow/runtime/sse'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + sseWrite: vi.fn(), + flushResume: vi.fn(), + getStreamResumeMirror: vi.fn() +})); + +vi.mock('@fastgpt/service/common/response/sse', () => ({ + createSseStreamContext: vi.fn(() => ({ + write: mocks.sseWrite, + flushResume: mocks.flushResume + })) +})); + +vi.mock('@fastgpt/service/core/chat/resume', () => ({ + getStreamResumeMirror: mocks.getStreamResumeMirror +})); + +import { createAuxiliaryGenerationStream } from '@fastgpt/service/core/ai/auxiliaryGeneration/stream'; + +describe('createAuxiliaryGenerationStream', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getStreamResumeMirror.mockResolvedValue(undefined); + }); + + const createStream = () => + createAuxiliaryGenerationStream({ + req: { headers: {} } as any, + res: {} as any, + teamId: 'team-id', + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: 'skill-id', + chatId: 'chat-id' + }); + + it('serializes an event id as responseValueId for ChatBox updates', async () => { + const streamContext = await createStream(); + + streamContext.write( + workflowSseEvent.toolParams({ + id: 'call-1', + params: '{"path":' + }) + ); + + expect(mocks.sseWrite).toHaveBeenCalledWith({ + event: SseResponseEventEnum.toolParams, + data: JSON.stringify({ + tool: { + id: 'call-1', + params: '{"path":' + }, + responseValueId: 'call-1' + }) + }); + }); + + it('writes the DONE marker without JSON encoding', async () => { + const streamContext = await createStream(); + + streamContext.writeDone(); + + expect(mocks.sseWrite).toHaveBeenLastCalledWith({ + event: SseResponseEventEnum.answer, + data: '[DONE]' + }); + }); +}); diff --git a/packages/service/test/core/ai/auxiliaryGeneration/usage.test.ts b/packages/service/test/core/ai/auxiliaryGeneration/usage.test.ts new file mode 100644 index 000000000000..471dc3248aec --- /dev/null +++ b/packages/service/test/core/ai/auxiliaryGeneration/usage.test.ts @@ -0,0 +1,78 @@ +import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + checkTeamAIPoints: vi.fn(), + createChatUsageRecord: vi.fn(), + pushChatItemUsage: vi.fn() +})); + +vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({ + checkTeamAIPoints: mocks.checkTeamAIPoints +})); + +vi.mock('@fastgpt/service/support/wallet/usage/controller', () => ({ + createChatUsageRecord: mocks.createChatUsageRecord, + pushChatItemUsage: mocks.pushChatItemUsage +})); + +import { createAuxiliaryGenerationUsage } from '@fastgpt/service/core/ai/auxiliaryGeneration/usage'; + +describe('createAuxiliaryGenerationUsage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.checkTeamAIPoints.mockResolvedValue(undefined); + mocks.createChatUsageRecord.mockResolvedValue('new-usage-id'); + }); + + it('creates a usage record associated with the edited skill', async () => { + const result = await createAuxiliaryGenerationUsage({ + teamId: 'team-id', + tmbId: 'tmb-id', + appName: 'Skill', + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: 'skill-id', + usageSource: UsageSourceEnum.fastgpt + }); + + expect(mocks.createChatUsageRecord).toHaveBeenCalledWith({ + appName: 'Skill', + appId: undefined, + skillId: 'skill-id', + teamId: 'team-id', + tmbId: 'tmb-id', + source: UsageSourceEnum.fastgpt + }); + expect(result.usageId).toBe('new-usage-id'); + }); + + it('reuses an ask usage id for subsequent node usage', async () => { + const result = await createAuxiliaryGenerationUsage({ + teamId: 'team-id', + tmbId: 'tmb-id', + appName: 'Skill', + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: 'skill-id', + usageSource: UsageSourceEnum.fastgpt, + usageId: 'existing-usage-id' + }); + const usages = [ + { + moduleName: 'Agent', + model: 'gpt-4o', + inputTokens: 10, + outputTokens: 2, + totalPoints: 1 + } + ]; + result.pushUsage(usages); + + expect(mocks.createChatUsageRecord).not.toHaveBeenCalled(); + expect(mocks.pushChatItemUsage).toHaveBeenCalledWith({ + teamId: 'team-id', + usageId: 'existing-usage-id', + nodeUsages: usages + }); + }); +}); diff --git a/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts b/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts index d3559c75e725..ae2e4e8bd05b 100644 --- a/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/fastAgentLoop.test.ts @@ -646,7 +646,10 @@ describe('runFastAgentMainLoop', () => { ], askToolCallId: 'call_ask' }, - userAnswer: '' + continuation: { + type: 'ask', + answer: '' + } } }); @@ -657,6 +660,69 @@ describe('runFastAgentMainLoop', () => { }); }); + it('appends new file messages after the ask tool response when resuming', async () => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ + requestId: 'req_after_file_resume', + content: 'continued with file' + }) + ]); + const resumeMessage = { + role: ChatCompletionRequestMessageRoleEnum.User, + content: [ + { type: 'text' as const, text: 'New file' }, + { + type: 'file_url' as const, + name: 'answer.mp4', + url: 'https://files.example/answer.mp4', + fileType: 'video' as const + } + ] + }; + + await runFastAgentMainLoop({ + runtime: createRuntime(), + input: { + messages: [], + pendingMainContext: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.Assistant, + tool_calls: [ + { + id: 'call_ask_file', + type: 'function', + function: { + name: 'ask_agent', + arguments: '{}' + } + } + ] + } + ], + askToolCallId: 'call_ask_file' + }, + continuation: { + type: 'ask', + answer: 'Use this clip', + additionalMessages: [resumeMessage] + } + } + }); + + const requestMessages = createLLMResponseMock.mock.calls[0][0].body.messages; + const toolResponseIndex = requestMessages.findIndex( + (message: any) => message.role === 'tool' && message.tool_call_id === 'call_ask_file' + ); + const resumeMessageIndex = requestMessages.findIndex( + (message: any) => + message.role === 'user' && JSON.stringify(message.content).includes('answer.mp4') + ); + expect(toolResponseIndex).toBeGreaterThanOrEqual(0); + expect(resumeMessageIndex).toBe(toolResponseIndex + 1); + expect(requestMessages[resumeMessageIndex]).toEqual(resumeMessage); + }); + it('does not restore active plan state from a compressed context in a new turn', async () => { const activePlan = { planId: 'plan_restored', @@ -768,7 +834,10 @@ describe('runFastAgentMainLoop', () => { askToolCallId: 'call_ask', activePlan }, - userAnswer: 'Continue' + continuation: { + type: 'ask', + answer: 'Continue' + } } }); diff --git a/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts b/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts index 8801c188819b..c5628eaa5c9c 100644 --- a/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/fastAgentProvider.test.ts @@ -141,6 +141,78 @@ describe('runFastAgentLoop', () => { ]); }); + it('resumes ask through the unified continuation contract', async () => { + mockCreateLLMResponseQueue(createLLMResponseMock, [ + text({ + requestId: 'req_after_ask_continuation', + content: 'continued with the uploaded file' + }) + ]); + const emitEvent = vi.fn(); + const additionalMessage = { + role: ChatCompletionRequestMessageRoleEnum.User, + content: [ + { type: 'text' as const, text: 'New file' }, + { + type: 'file_url' as const, + name: 'answer.mp4', + url: 'https://files.example/answer.mp4' + } + ] + }; + + const result = await runFastAgentLoop({ + input: { + messages: [], + providerState: { + pendingMainContext: { + messages: [ + { + role: ChatCompletionRequestMessageRoleEnum.Assistant, + content: null, + tool_calls: [ + { + id: 'call_ask_continuation', + type: 'function', + function: { + name: 'ask_user', + arguments: '{}' + } + } + ] + } + ], + askToolCallId: 'call_ask_continuation' + } + }, + continuation: { + type: 'ask', + answer: 'Use this clip', + additionalMessages: [additionalMessage] + } + }, + runtime: createRuntime({ emitEvent }) + }); + + expect(result.status).toBe('done'); + expect(createLLMResponseMock.mock.calls[0][0].body.messages.slice(0, 3)).toEqual([ + expect.objectContaining({ + role: 'assistant', + tool_calls: [expect.objectContaining({ id: 'call_ask_continuation' })] + }), + { + role: 'tool', + tool_call_id: 'call_ask_continuation', + content: 'Use this clip' + }, + additionalMessage + ]); + expect(emitEvent).toHaveBeenCalledWith({ + type: 'ask_resume', + answer: 'Use this clip' + }); + }); + it('injects system tools only when runtime systemTools enable them', async () => { mockCreateLLMResponseQueue(createLLMResponseMock, [ text({ diff --git a/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts b/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts index eb865feba690..b54f1bed05d6 100644 --- a/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts +++ b/packages/service/test/core/ai/llm/agentLoop/piAgentProvider.test.ts @@ -1556,7 +1556,23 @@ describe('runPiAgentLoop', () => { ] } }, - userAnswer: '我要分析销售数据' + continuation: { + type: 'ask', + answer: '我要分析销售数据', + additionalMessages: [ + { + role: 'user', + content: [ + { type: 'text', text: '本轮新增文件' }, + { + type: 'file_url', + name: 'sales.csv', + url: 'https://files.example/sales.csv' + } + ] + } + ] + } }, runtime: { llmParams: { @@ -1580,6 +1596,13 @@ describe('runPiAgentLoop', () => { role: 'toolResult', toolCallId: 'call_ask', content: [{ type: 'text', text: '我要分析销售数据' }] + }), + expect.objectContaining({ + role: 'user', + content: expect.arrayContaining([ + { type: 'text', text: '本轮新增文件' }, + { type: 'text', text: '[File: sales.csv] https://files.example/sales.csv' } + ]) }) ]); expect(agentContinueMock).toHaveBeenCalledTimes(1); @@ -1589,6 +1612,14 @@ describe('runPiAgentLoop', () => { tool_call_id: 'call_ask', content: '我要分析销售数据' }); + expect(result.completeMessages).toContainEqual( + expect.objectContaining({ + role: 'user', + content: expect.arrayContaining([ + expect.objectContaining({ type: 'file_url', name: 'sales.csv' }) + ]) + }) + ); expect(result.activePlan).toMatchObject({ planId: 'plan_1' }); expect(result.assistantMessages).not.toContainEqual( expect.objectContaining({ @@ -1611,7 +1642,10 @@ describe('runPiAgentLoop', () => { messages: [{ role: 'assistant', content: null }] } }, - userAnswer: '我要分析销售数据' + continuation: { + type: 'ask', + answer: '我要分析销售数据' + } }, runtime: { llmParams: { model: 'gpt-5' }, diff --git a/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts b/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts index 10bdddbfbf54..83b7d19d374d 100644 --- a/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts +++ b/packages/service/test/core/ai/sandbox/application/runtime/skill/builtin.test.ts @@ -1,11 +1,82 @@ import { describe, expect, it, vi } from 'vitest'; import { + createBuiltinSkillPrepareAction, getBuiltinSkillsRootPath, syncBuiltinSkillsToSandbox } from '@fastgpt/service/core/ai/sandbox/application/runtime/skill/builtin'; import { buildRuntimeHash } from '@fastgpt/service/core/ai/sandbox/utils'; describe('builtin skill runtime', () => { + it('creates a lazy prepare action and registers synced skill directories', async () => { + const sources = [ + { + name: 'skill-creator', + files: createBuiltinSkillSourceFiles() + } + ]; + const sandbox = { + execute: vi.fn(async () => ({ exitCode: 0, stdout: '/home/sandbox', stderr: '' })) + }; + const injectToSandbox = vi.fn(); + const context = { + sandbox: sandbox as any, + workDirectory: '/workspace', + skillScanDirectories: ['/existing-skill'] + }; + + const result = await createBuiltinSkillPrepareAction({ + getSources: vi.fn(async () => sources), + injectToSandbox + })(context); + + expect(injectToSandbox).toHaveBeenCalledWith({ + sandbox, + homeDirectory: '/home/sandbox', + sources + }); + expect(result.skillScanDirectories).toEqual([ + '/existing-skill', + '/home/sandbox/.fastgpt/skills/skill-creator' + ]); + }); + + it('does not inspect sandbox HOME when there are no builtin skills', async () => { + const sandbox = { execute: vi.fn() }; + const context = { + sandbox: sandbox as any, + workDirectory: '/workspace', + skillScanDirectories: [] + }; + + const result = await createBuiltinSkillPrepareAction({ + getSources: vi.fn(async () => []) + })(context); + + expect(result).toBe(context); + expect(sandbox.execute).not.toHaveBeenCalled(); + }); + + it('fails explicitly when builtin skills exist but sandbox HOME is unavailable', async () => { + const context = { + sandbox: { + execute: vi.fn(async () => ({ exitCode: 1, stdout: '', stderr: 'HOME unavailable' })) + } as any, + workDirectory: '/workspace', + skillScanDirectories: [] + }; + + await expect( + createBuiltinSkillPrepareAction({ + getSources: vi.fn(async () => [ + { + name: 'skill-creator', + files: createBuiltinSkillSourceFiles() + } + ]) + })(context) + ).rejects.toThrow('Failed to resolve sandbox HOME for builtin skill sync'); + }); + it('injects builtin skill files into runtime directory instead of user workspace', async () => { const skillCreatorSource = { name: 'skill-creator', diff --git a/packages/service/test/core/ai/skill/debugChat/eventAdapter.test.ts b/packages/service/test/core/ai/skill/debugChat/eventAdapter.test.ts new file mode 100644 index 000000000000..151b1f125e05 --- /dev/null +++ b/packages/service/test/core/ai/skill/debugChat/eventAdapter.test.ts @@ -0,0 +1,162 @@ +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { createSkillDebugEventAdapter } from '@fastgpt/service/core/ai/skill/debugChat/eventAdapter'; +import { describe, expect, it, vi } from 'vitest'; + +describe('Skill Debug Agent Loop event adapter', () => { + it('streams visible tools and builds ChatBox transcript plus node responses', () => { + const streamWriter = vi.fn(); + const adapter = createSkillDebugEventAdapter({ streamWriter, lang: 'en' }); + const call = { + id: 'call-1', + type: 'function' as const, + function: { + name: 'sandbox_read_file', + arguments: '{"path":"/workspace/SKILL.md"}' + } + }; + + adapter.emitEvent({ type: 'answer_delta', text: 'working' }); + adapter.emitEvent({ type: 'tool_call', call }); + adapter.emitEvent({ type: 'tool_params', callId: call.id, argsDelta: call.function.arguments }); + adapter.emitEvent({ + type: 'tool_run_end', + call, + rawResponse: 'skill content', + response: 'skill content', + seconds: 0.2, + usages: [] + }); + adapter.emitEvent({ + type: 'llm_request_end', + requestIndex: 0, + modelName: 'gpt-5', + requestId: 'request-1', + finishReason: 'stop', + answerText: 'done', + seconds: 0.5, + usages: [ + { + moduleName: 'Agent', + model: 'gpt-5', + inputTokens: 10, + outputTokens: 2, + totalPoints: 1 + } + ] + }); + + const responses = adapter.buildAssistantResponses([ + { role: 'assistant', content: 'done', tool_calls: [call] }, + { role: 'tool', tool_call_id: call.id, content: 'skill content' } + ]); + + expect(streamWriter).toHaveBeenCalledWith( + expect.objectContaining({ event: SseResponseEventEnum.answer }) + ); + expect(streamWriter).toHaveBeenCalledWith( + expect.objectContaining({ event: SseResponseEventEnum.toolCall, id: call.id }) + ); + expect(streamWriter).toHaveBeenCalledWith( + expect.objectContaining({ event: SseResponseEventEnum.toolResponse, id: call.id }) + ); + expect(responses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: { content: 'done' } }), + expect.objectContaining({ + tools: [ + expect.objectContaining({ + id: call.id, + functionName: 'sandbox_read_file', + response: 'skill content' + }) + ] + }) + ]) + ); + expect(adapter.nodeResponses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: call.id, + moduleType: FlowNodeTypeEnum.tool, + toolId: 'sandbox_read_file' + }), + expect.objectContaining({ + model: 'gpt-5', + llmRequestIds: ['request-1'], + totalPoints: 1 + }) + ]) + ); + }); + + it('persists plan and ask metadata without exposing control tools as tool cards', () => { + const streamWriter = vi.fn(); + const adapter = createSkillDebugEventAdapter({ streamWriter, lang: 'en' }); + const plan = { + planId: 'plan-1', + name: 'Edit skill', + steps: [{ id: 'step-1', name: 'Inspect', status: 'in_progress' as const }] + }; + + adapter.emitEvent({ type: 'plan_status', status: 'generating' }); + adapter.emitEvent({ + type: 'plan_operation', + operation: 'set_plan', + success: true, + id: 'plan-call', + params: '{}', + message: 'Plan created', + seconds: 0, + plan + }); + adapter.emitEvent({ + type: 'ask_start', + id: 'ask-call', + params: '{}', + seconds: 0, + ask: { + reason: 'Need a choice', + blockerType: 'user_choice', + questions: [ + { + question: 'Choose one', + options: [ + { summary: 'A', value: 'A' }, + { summary: 'B', value: 'B' } + ] + } + ] + } + }); + + const responses = adapter.buildAssistantResponses([ + { + role: 'assistant', + tool_calls: [ + { + id: 'ask-call', + type: 'function', + function: { name: 'ask_user', arguments: '{}' } + } + ] + } + ]); + + expect(responses).toEqual( + expect.arrayContaining([ + { plan }, + expect.objectContaining({ + agentPlanUpdate: expect.objectContaining({ id: 'plan-call' }) + }), + expect.objectContaining({ + agentAsk: expect.objectContaining({ askId: 'ask-call', functionName: 'ask_user' }) + }) + ]) + ); + expect(JSON.stringify(responses)).not.toContain('"tools"'); + expect(streamWriter).toHaveBeenCalledWith( + expect.objectContaining({ event: SseResponseEventEnum.planStatus }) + ); + }); +}); diff --git a/packages/service/test/core/ai/skill/debugChat/memory.test.ts b/packages/service/test/core/ai/skill/debugChat/memory.test.ts new file mode 100644 index 000000000000..096ddd614ab1 --- /dev/null +++ b/packages/service/test/core/ai/skill/debugChat/memory.test.ts @@ -0,0 +1,63 @@ +import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { + buildSkillDebugAgentLoopMemories, + compactSkillDebugPlanSnapshots, + getSkillDebugAgentLoopMemoryKey, + readSkillDebugActivePlan, + readSkillDebugAgentLoopMemory +} from '@fastgpt/service/core/ai/skill/debugChat/memory'; +import { describe, expect, it } from 'vitest'; + +const unfinishedPlan = { + planId: 'plan-1', + name: 'Edit skill', + steps: [{ id: 'step-1', name: 'Inspect files', status: 'in_progress' as const }] +}; + +describe('Skill Debug Agent Loop memory', () => { + it('restores provider state and the latest unfinished plan', () => { + const histories = [ + { + obj: ChatRoleEnum.AI, + value: [{ plan: unfinishedPlan }], + memories: { + [getSkillDebugAgentLoopMemoryKey()]: { providerState: { pending: true } } + } + } + ]; + + expect(readSkillDebugAgentLoopMemory({ histories })).toEqual({ + providerState: { pending: true } + }); + expect(readSkillDebugActivePlan({ histories })).toEqual(unfinishedPlan); + }); + + it('uses a completed plan marker to stop restoring older plans', () => { + expect( + readSkillDebugActivePlan({ + histories: [ + { obj: ChatRoleEnum.AI, value: [{ plan: unfinishedPlan }] }, + { obj: ChatRoleEnum.AI, value: [{ plan: null }] } + ] + }) + ).toBeUndefined(); + }); + + it('keeps only the final plan snapshot and clears completed state', () => { + const responses = compactSkillDebugPlanSnapshots([ + { plan: unfinishedPlan }, + { text: { content: 'done' } }, + { + plan: { + ...unfinishedPlan, + steps: [{ ...unfinishedPlan.steps[0], status: 'done' }] + } + } + ]); + + expect(responses).toEqual([{ text: { content: 'done' } }, { plan: null }]); + expect(buildSkillDebugAgentLoopMemories()).toEqual({ + [getSkillDebugAgentLoopMemoryKey()]: undefined + }); + }); +}); diff --git a/packages/service/test/core/ai/skill/debugChat/processor.test.ts b/packages/service/test/core/ai/skill/debugChat/processor.test.ts new file mode 100644 index 000000000000..6ad0b32e0573 --- /dev/null +++ b/packages/service/test/core/ai/skill/debugChat/processor.test.ts @@ -0,0 +1,236 @@ +import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + runAgentLoop: vi.fn(), + prepareRuntime: vi.fn(), + buildUserContext: vi.fn(), + emitEvent: vi.fn(), + buildAssistantResponses: vi.fn(), + nodeResponses: [ + { + id: 'node-1', + nodeId: 'node-1', + moduleType: 'agent', + moduleName: 'Agent' + } + ] +})); + +vi.mock('@fastgpt/service/core/ai/llm/agentLoop/interface', () => ({ + runAgentLoop: mocks.runAgentLoop +})); + +vi.mock('@fastgpt/service/core/ai/skill/debugChat/runtime', () => ({ + prepareSkillDebugRuntime: mocks.prepareRuntime +})); + +vi.mock('@fastgpt/service/core/ai/skill/debugChat/userContext', () => ({ + SKILL_DEBUG_MAX_FILES: 10, + buildSkillDebugUserContext: mocks.buildUserContext +})); + +vi.mock('@fastgpt/service/core/ai/skill/debugChat/eventAdapter', () => ({ + createSkillDebugEventAdapter: () => ({ + nodeResponses: mocks.nodeResponses, + emitEvent: mocks.emitEvent, + buildAssistantResponses: mocks.buildAssistantResponses + }) +})); + +vi.mock('@fastgpt/service/core/ai/sandbox/interface/runtime', () => ({ + getRunningSandboxId: vi.fn(() => 'skill-debug-sandbox-id') +})); + +import { createSkillDebugProcessor } from '@fastgpt/service/core/ai/skill/debugChat/processor'; +import { getSkillDebugAgentLoopMemoryKey } from '@fastgpt/service/core/ai/skill/debugChat/memory'; + +const unfinishedPlan = { + planId: 'plan-1', + name: 'Edit skill', + steps: [{ id: 'step-1', name: 'Inspect files', status: 'in_progress' as const }] +}; + +describe('createSkillDebugProcessor', () => { + const streamWriter = vi.fn(); + const usageSink = vi.fn(); + const providerState = { pendingMainContext: { askToolCallId: 'ask-1' } }; + const histories = [ + { + obj: ChatRoleEnum.AI, + value: [{ plan: unfinishedPlan }], + memories: { + [getSkillDebugAgentLoopMemoryKey()]: { providerState } + } + } + ]; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.prepareRuntime.mockResolvedValue({ + sandboxClient: { id: 'sandbox-client' }, + currentWorkingDirectory: '/workspace', + skillInfos: [] + }); + mocks.buildUserContext.mockReturnValue({ + messages: [{ role: 'user', content: 'answer plus file' }], + askContinuationMessages: [ + { + role: 'user', + content: [{ type: 'file_url', name: 'clip.mp4', url: '/clip.mp4' }] + } + ], + readableFileUrls: ['https://files.example.com/guide.pdf'] + }); + mocks.buildAssistantResponses.mockReturnValue([{ text: { content: 'partial answer' } }]); + }); + + const runProcessor = (isInteractiveResume: boolean) => + createSkillDebugProcessor({ + skillId: 'skill-id', + responseChatItemId: 'response-id', + isInteractiveResume + })({ + query: 'Use this file', + files: [], + data: { + model: 'gpt-5', + systemPrompt: 'Use {{@sandbox_read_file@}} carefully.', + currentUserValue: [{ text: { content: 'Use this file' } }], + timezone: 'Asia/Shanghai', + userKey: { baseUrl: 'https://provider.example/v1', key: 'provider-key' }, + modelCapabilities: { vision: true, audio: true, video: true } + }, + histories, + streamWriter, + requestOrigin: 'https://app.example.com', + maxFiles: 10, + customPdfParse: false, + checkIsStopping: () => false, + usageSink, + usageId: 'usage-id', + user: { + teamId: 'team-id', + tmbId: 'tmb-id', + userId: 'user-id', + isRoot: false, + lang: 'en' + } + }); + + it('resumes ask directly through Agent Loop and keeps new attachments after the tool answer', async () => { + const nextProviderState = { pendingMainContext: { askToolCallId: 'ask-2' } }; + mocks.runAgentLoop.mockResolvedValue({ + status: 'paused', + pause: { + type: 'ask', + askId: 'ask-2', + ask: { + reason: 'Need an output choice', + blockerType: 'user_choice', + questions: [ + { + question: 'Which format?', + options: [ + { summary: 'Markdown', value: 'Markdown' }, + { summary: 'JSON', value: 'JSON' } + ] + } + ] + } + }, + providerState: nextProviderState, + activePlan: unfinishedPlan, + completeMessages: [], + assistantMessages: [{ role: 'assistant', content: 'partial answer' }], + requestIds: ['request-1'], + finishReason: 'stop', + usages: [] + }); + + const result = await runProcessor(true); + + expect(mocks.runAgentLoop).toHaveBeenCalledWith( + expect.objectContaining({ + runtime: expect.objectContaining({ + teamId: 'team-id', + llmParams: expect.objectContaining({ + model: 'gpt-5', + userKey: { baseUrl: 'https://provider.example/v1', key: 'provider-key' }, + useVision: true, + useAudio: true, + useVideo: true + }), + systemTools: expect.objectContaining({ + plan: { enabled: true }, + ask: { enabled: true }, + sandbox: expect.objectContaining({ enabled: true }), + readFile: expect.objectContaining({ enabled: true, maxFileAmount: 10 }) + }), + toolCatalog: { runtimeTools: [] }, + usagePush: usageSink, + emitEvent: mocks.emitEvent + }), + input: expect.objectContaining({ + activePlan: unfinishedPlan, + providerState, + continuation: { + type: 'ask', + answer: 'Use this file', + additionalMessages: [ + { + role: 'user', + content: [{ type: 'file_url', name: 'clip.mp4', url: '/clip.mp4' }] + } + ] + } + }) + }) + ); + const loopInput = mocks.runAgentLoop.mock.calls[0][0].input; + expect(loopInput.systemPrompt).toContain('{{Sandbox/Read File}}'); + expect(loopInput.systemPrompt).not.toContain('用户对话上传的文件存储在'); + expect(result.aiResponse).toEqual([ + { text: { content: 'partial answer' } }, + expect.objectContaining({ + interactive: expect.objectContaining({ + type: 'agentAsk', + askId: 'ask-2', + usageId: 'usage-id' + }) + }) + ]); + expect(result.memories).toEqual({ + [getSkillDebugAgentLoopMemoryKey()]: { providerState: nextProviderState } + }); + expect(result.nodeResponses).toBe(mocks.nodeResponses); + }); + + it('persists a visible error and clears provider state when the loop fails', async () => { + mocks.runAgentLoop.mockResolvedValue({ + status: 'error', + error: new Error('model unavailable'), + activePlan: unfinishedPlan, + providerState: undefined, + completeMessages: [], + assistantMessages: [], + requestIds: [], + finishReason: 'error', + usages: [] + }); + mocks.buildAssistantResponses.mockReturnValueOnce([]); + + const result = await runProcessor(false); + + expect(mocks.runAgentLoop.mock.calls[0][0].input).toEqual( + expect.objectContaining({ + providerState: undefined, + continuation: undefined + }) + ); + expect(result.aiResponse).toEqual([{ text: { content: 'model unavailable' } }]); + expect(result.memories).toEqual({ + [getSkillDebugAgentLoopMemoryKey()]: undefined + }); + }); +}); diff --git a/packages/service/test/core/ai/skill/debugChat/readFile.test.ts b/packages/service/test/core/ai/skill/debugChat/readFile.test.ts new file mode 100644 index 000000000000..dd28d25517e2 --- /dev/null +++ b/packages/service/test/core/ai/skill/debugChat/readFile.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getFileContentByUrl: vi.fn() +})); + +vi.mock('@fastgpt/service/core/chat/fileContext', () => ({ + getFileContentByUrl: mocks.getFileContentByUrl +})); + +import { createSkillDebugReadFileExecutor } from '@fastgpt/service/core/ai/skill/debugChat/readFile'; + +describe('Skill Debug read_files executor', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getFileContentByUrl.mockResolvedValue({ name: 'guide.txt', content: 'file content' }); + }); + + const createExecutor = () => + createSkillDebugReadFileExecutor({ + readableFileUrls: ['https://files.example.com/guide.txt'], + maxFileAmount: 2, + teamId: 'team-id', + tmbId: 'tmb-id', + usageId: 'usage-id' + }); + + it('only reads URLs registered in the current chat context', async () => { + const result = await createExecutor()({ + call: { + id: 'call-1', + type: 'function', + function: { + name: 'read_files', + arguments: JSON.stringify({ + urls: ['https://files.example.com/guide.txt', 'https://files.example.com/private.txt'] + }) + } + }, + messages: [] + }); + + expect(mocks.getFileContentByUrl).toHaveBeenCalledOnce(); + expect(mocks.getFileContentByUrl).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://files.example.com/guide.txt', + usageId: 'usage-id' + }) + ); + expect(JSON.parse(result.response)).toEqual([ + { + url: 'https://files.example.com/guide.txt', + name: 'guide.txt', + content: 'file content' + }, + { + url: 'https://files.example.com/private.txt', + name: '', + content: 'File is not available in the current chat context.' + } + ]); + }); + + it('returns a tool-visible validation error for invalid arguments', async () => { + const result = await createExecutor()({ + call: { + id: 'call-1', + type: 'function', + function: { name: 'read_files', arguments: '{}' } + }, + messages: [] + }); + + expect(result.error).toBeDefined(); + expect(mocks.getFileContentByUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/service/test/core/ai/skill/debugChat/userContext.test.ts b/packages/service/test/core/ai/skill/debugChat/userContext.test.ts new file mode 100644 index 000000000000..50bc1c763706 --- /dev/null +++ b/packages/service/test/core/ai/skill/debugChat/userContext.test.ts @@ -0,0 +1,127 @@ +import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { + buildSkillDebugUserContext, + parseSkillDebugInputFiles +} from '@fastgpt/service/core/ai/skill/debugChat/userContext'; +import { describe, expect, it } from 'vitest'; + +describe('Skill Debug user context', () => { + it('normalizes, deduplicates and limits input files', () => { + const result = parseSkillDebugInputFiles({ + files: [ + { + type: ChatFileTypeEnum.file, + name: 'first.txt', + url: ' https://files.example.com/first.txt ' + }, + { + type: ChatFileTypeEnum.file, + name: 'duplicate.txt', + url: 'https://files.example.com/first.txt' + }, + ...Array.from({ length: 11 }, (_, index) => ({ + type: ChatFileTypeEnum.file, + name: `${index}.txt`, + url: `https://files.example.com/${index}.txt` + })) + ] + }); + + expect(result).toHaveLength(10); + expect(result[0]).toEqual({ + name: 'first.txt', + type: ChatFileTypeEnum.file, + url: 'https://files.example.com/first.txt' + }); + expect(result.filter((file) => file.url.endsWith('/first.txt'))).toHaveLength(1); + }); + + it('uses read_files for documents and retains multimodal current inputs', () => { + const result = buildSkillDebugUserContext({ + histories: [ + { + dataId: 'history-id', + obj: ChatRoleEnum.Human, + value: [ + { + file: { + type: ChatFileTypeEnum.file, + name: 'history.txt', + url: 'https://files.example.com/history.txt' + } + }, + { text: { content: 'previous question' } } + ] + } + ], + currentUserValue: [ + { + file: { + type: ChatFileTypeEnum.file, + name: 'guide.pdf', + url: 'https://files.example.com/guide.pdf' + } + }, + { + file: { + type: ChatFileTypeEnum.image, + name: 'diagram.png', + url: 'https://files.example.com/diagram.png' + } + }, + { + file: { + type: ChatFileTypeEnum.video, + name: 'demo.mp4', + url: 'https://files.example.com/demo.mp4' + } + }, + { text: { content: 'summarize this' } } + ], + currentDataId: 'current-id', + requestOrigin: 'https://app.example.com', + skillInfos: [ + { + name: 'test-skill', + description: 'Test skill', + directory: '/workspace/skills/test-skill', + skillMdPath: '/workspace/skills/test-skill/SKILL.md' + } + ], + currentWorkingDirectory: '/workspace', + currentTime: '2026-07-28 12:00:00' + }); + + expect(result.readableFileUrls).toEqual([ + 'https://files.example.com/history.txt', + 'https://files.example.com/guide.pdf' + ]); + expect(result.messages).toHaveLength(2); + const currentMessage = result.messages[1]; + expect(currentMessage).toMatchObject({ role: 'user' }); + expect(JSON.stringify(currentMessage.content)).toContain('read_files'); + expect(JSON.stringify(currentMessage.content)).toContain( + '/workspace/skills/test-skill/SKILL.md' + ); + expect(currentMessage.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'image_url', + image_url: { url: 'https://files.example.com/diagram.png' } + }), + expect.objectContaining({ + type: 'file_url', + name: 'demo.mp4', + url: 'https://files.example.com/demo.mp4', + fileType: ChatFileTypeEnum.video + }) + ]) + ); + expect(JSON.stringify(currentMessage.content)).not.toContain('"fileType":"file"'); + expect(result.askContinuationMessages).toHaveLength(1); + expect(JSON.stringify(result.askContinuationMessages)).toContain( + 'https://files.example.com/demo.mp4' + ); + expect(JSON.stringify(result.askContinuationMessages)).not.toContain('summarize this'); + }); +}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts index 356a67fce148..4aaaeb869893 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts @@ -930,7 +930,10 @@ describe('dispatchRunAgent user context', () => { expect.objectContaining({ provider: 'fastAgent', input: expect.objectContaining({ - userAnswer: '前端原始问题', + continuation: { + type: 'ask', + answer: '前端原始问题' + }, providerState: { pendingMainContext: expect.objectContaining({ askToolCallId: 'call_ask', @@ -1010,7 +1013,10 @@ describe('dispatchRunAgent user context', () => { expect(runAgentLoopMock).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ - userAnswer: '{"answers":["A",""]}' + continuation: { + type: 'ask', + answer: '{"answers":["A",""]}' + } }) }) ); @@ -1118,7 +1124,10 @@ describe('dispatchRunAgent user context', () => { expect.objectContaining({ provider: 'piAgent', input: expect.objectContaining({ - userAnswer: '前端原始问题', + continuation: { + type: 'ask', + answer: '前端原始问题' + }, providerState: expect.objectContaining({ pendingMainContext: expect.objectContaining({ activePlan: { diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.test.ts index c1a0f3bc5c6d..430b928ae1b2 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agent/sub/sandbox/prepare.test.ts @@ -293,41 +293,6 @@ describe('ensureAgentSandboxRuntime', () => { expect(runAgentSkillVersionEntrypointsMock).not.toHaveBeenCalled(); }); - it('creates builtin skill prepare action with lazy source loading', async () => { - const { createBuiltinSkillPrepareAction } = - await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare'); - const builtinSkillSources = [ - { - name: 'skill-creator', - files: [ - { - relativePath: 'SKILL.md', - content: Buffer.from('# Skill Creator') - } - ] - } - ]; - const getSources = vi.fn(async () => builtinSkillSources); - - const result = await createBuiltinSkillPrepareAction({ getSources })({ - sandbox: sandboxProviderMock, - sandboxClient: sandboxClientMock, - workDirectory: '/workspace', - deployedSkillVersions: [], - skillInfos: [], - skillScanDirectories: [] - }); - - expect(getSources).toHaveBeenCalledTimes(1); - expect(resolveSandboxHomeMock).toHaveBeenCalledWith(sandboxProviderMock); - expect(syncBuiltinSkillsToSandboxMock).toHaveBeenCalledWith({ - sandbox: sandboxProviderMock, - homeDirectory: '/home/sandbox', - sources: builtinSkillSources - }); - expect(result.skillScanDirectories).toEqual(['/home/sandbox/.fastgpt/skills/skill-creator']); - }); - it('returns empty skill infos when sandbox runtime is not needed', async () => { const { ensureAgentSandboxRuntime } = await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare'); diff --git a/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/context/messages.test.ts b/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/context/messages.test.ts index 918d1e5a0b5b..692087b9c798 100644 --- a/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/context/messages.test.ts +++ b/packages/service/test/core/workflow/dispatch/ai/agentLoopCore/context/messages.test.ts @@ -117,7 +117,10 @@ describe('buildAgentLoopCoreInput', () => { askToolCallId: 'call_ask' } }, - userAnswer: 'confirmed', + continuation: { + type: 'ask', + answer: 'confirmed' + }, childrenInteractiveParams }) ).toEqual({ @@ -133,7 +136,10 @@ describe('buildAgentLoopCoreInput', () => { askToolCallId: 'call_ask' } }, - userAnswer: 'confirmed', + continuation: { + type: 'ask', + answer: 'confirmed' + }, childrenInteractiveParams }); }); diff --git a/pro b/pro index 999c207143b8..ab5426a0a8cc 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 999c207143b8f3178e929958c8f4e9753120c407 +Subproject commit ab5426a0a8cc1f211a4601eeca1188ac08473bc5 diff --git a/projects/app/test/api/core/ai/skill/debugChat.test.ts b/projects/app/test/api/core/ai/skill/debugChat.test.ts index 53abb54e49e6..aaf280debed3 100644 --- a/projects/app/test/api/core/ai/skill/debugChat.test.ts +++ b/projects/app/test/api/core/ai/skill/debugChat.test.ts @@ -1,24 +1,12 @@ -import { buildDebugRuntimeNodes } from '@fastgpt/service/core/ai/skill/debugChat'; import * as debugChatApi from '@/pages/api/core/ai/skill/debugChat'; import { AgentSkillSourceEnum } from '@fastgpt/global/core/ai/skill/constants'; -import { - FlowNodeTypeEnum, - FlowNodeInputTypeEnum, - FlowNodeOutputTypeEnum -} from '@fastgpt/global/core/workflow/node/constant'; -import { - NodeInputKeyEnum, - NodeOutputKeyEnum, - WorkflowIOValueTypeEnum -} from '@fastgpt/global/core/workflow/constants'; -import { getHandleId } from '@fastgpt/global/core/workflow/utils'; import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema'; import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/infrastructure/instance/schema'; import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; import * as responseModule from '@fastgpt/service/common/response'; import { getUser } from '@test/datas/users'; import { Call } from '@test/utils/request'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getEditDebugSandboxId } from '@fastgpt/service/core/ai/skill/edit/config'; import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill'; @@ -33,17 +21,23 @@ import { } from '@fastgpt/global/core/chat/constants'; const debugChatMocks = vi.hoisted(() => ({ - dispatchWorkFlow: vi.fn(), + runAuxiliaryGeneration: vi.fn(), + createSkillDebugProcessor: vi.fn(), + skillDebugProcessor: vi.fn(), preChatRound: vi.fn(), finalizeChatRound: vi.fn(), failChatRound: vi.fn(), updateInteractiveChat: vi.fn(), updateChatGenerateStatus: vi.fn(), - getRunningUserInfoByTmbId: vi.fn(), responseWrite: vi.fn(), flushResume: vi.fn(), - writeStreamError: vi.fn(), - createWorkflowStreamResponseContext: vi.fn() + writeError: vi.fn(), + recordNodeResponses: vi.fn(), + closeNodeResponseWriter: vi.fn(), + getNodeResponseSummary: vi.fn(), + getPreviewUrl: vi.fn(), + getUserChatInfo: vi.fn(), + getChatItems: vi.fn() })); vi.mock('@fastgpt/service/env', async (importOriginal) => { @@ -63,8 +57,17 @@ vi.mock('@fastgpt/service/env', async (importOriginal) => { }; }); -vi.mock('@fastgpt/service/core/workflow/dispatch', () => ({ - dispatchWorkFlow: debugChatMocks.dispatchWorkFlow +vi.mock('@fastgpt/service/core/ai/auxiliaryGeneration', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + runAuxiliaryGeneration: debugChatMocks.runAuxiliaryGeneration + }; +}); + +vi.mock('@fastgpt/service/core/ai/skill/debugChat/processor', () => ({ + createSkillDebugProcessor: debugChatMocks.createSkillDebugProcessor })); vi.mock('@fastgpt/service/core/chat/utils/prepare', () => ({ @@ -81,262 +84,100 @@ vi.mock('@fastgpt/service/core/chat/chatGenerateStatus', () => ({ updateChatGenerateStatus: debugChatMocks.updateChatGenerateStatus })); -vi.mock('@fastgpt/service/support/user/team/utils', () => ({ - getRunningUserInfoByTmbId: debugChatMocks.getRunningUserInfoByTmbId +vi.mock('@fastgpt/service/core/chat/nodeResponseStorage', () => ({ + WorkflowNodeResponseWriter: vi.fn().mockImplementation(function () { + return { + record: debugChatMocks.recordNodeResponses, + close: debugChatMocks.closeNodeResponseWriter, + getSummary: debugChatMocks.getNodeResponseSummary + }; + }) })); -vi.mock('@fastgpt/service/core/workflow/utils/streamResponseContext', () => ({ - createWorkflowStreamResponseContext: debugChatMocks.createWorkflowStreamResponseContext +vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ + createChatFilePreviewUrlGetter: () => debugChatMocks.getPreviewUrl })); -// ── Constants mirrored from the implementation ── -const START_NODE_ID = 'skill-debug-start'; -const AGENT_NODE_ID = 'skill-debug-agent'; - -// ═══════════════════════════════════════════════ -// describe: buildDebugRuntimeNodes -// ═══════════════════════════════════════════════ -describe('buildDebugRuntimeNodes', () => { - const SKILL_ID = '507f1f77bcf86cd799439011'; - const MODEL = 'gpt-4o'; - const SYSTEM_PROMPT = 'You are a helpful assistant.'; - - it('should return exactly two nodes and one edge', () => { - const { runtimeNodes, runtimeEdges } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - expect(runtimeNodes).toHaveLength(2); - expect(runtimeEdges).toHaveLength(1); - }); - - // ── Start node ────────────────────────────── - describe('start node (workflowStart)', () => { - it('should be the first node with correct type and isEntry=true', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const startNode = runtimeNodes[0]; - - expect(startNode.nodeId).toBe(START_NODE_ID); - expect(startNode.flowNodeType).toBe(FlowNodeTypeEnum.workflowStart); - expect(startNode.isEntry).toBe(true); - expect(startNode.showStatus).toBe(false); - }); - - it('should have exactly one userChatInput input with empty default value', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const startNode = runtimeNodes[0]; - - expect(startNode.inputs).toHaveLength(1); - const input = startNode.inputs[0]; - expect(input.key).toBe(NodeInputKeyEnum.userChatInput); - expect(input.valueType).toBe(WorkflowIOValueTypeEnum.string); - expect(input.required).toBe(true); - expect(input.value).toBe(''); - }); - - it('should have exactly one userChatInput output with static type', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const startNode = runtimeNodes[0]; - - expect(startNode.outputs).toHaveLength(1); - const output = startNode.outputs[0]; - expect(output.key).toBe(NodeOutputKeyEnum.userChatInput); - expect(output.id).toBe(NodeOutputKeyEnum.userChatInput); - expect(output.type).toBe(FlowNodeOutputTypeEnum.static); - expect(output.valueType).toBe(WorkflowIOValueTypeEnum.string); - }); - }); - - // ── Agent node ────────────────────────────── - describe('agent node', () => { - it('should have correct type and isEntry=false with showStatus=true', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - expect(agentNode.nodeId).toBe(AGENT_NODE_ID); - expect(agentNode.flowNodeType).toBe(FlowNodeTypeEnum.agent); - expect(agentNode.isEntry).toBe(false); - expect(agentNode.showStatus).toBe(true); - }); - - it('userChatInput input should reference start node output', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const userInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.userChatInput); - expect(userInput).toBeDefined(); - // Reference format: [nodeId, outputKey] - expect(userInput!.value).toEqual([START_NODE_ID, NodeOutputKeyEnum.userChatInput]); - expect(userInput!.renderTypeList).toContain(FlowNodeInputTypeEnum.reference); - }); - - it('history input should be a number with value 20', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const historyInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.history); - expect(historyInput).toBeDefined(); - expect(historyInput!.value).toBe(20); - expect(historyInput!.valueType).toBe(WorkflowIOValueTypeEnum.chatHistory); - expect(historyInput!.min).toBe(0); - expect(historyInput!.max).toBe(50); - }); - - it('aiModel input should carry the provided model value', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const modelInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.aiModel); - expect(modelInput).toBeDefined(); - expect(modelInput!.value).toBe(MODEL); - expect(modelInput!.valueType).toBe(WorkflowIOValueTypeEnum.string); - expect(modelInput!.required).toBe(true); - }); - - it('aiSystemPrompt input should carry the provided system prompt', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const promptInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.aiSystemPrompt); - expect(promptInput).toBeDefined(); - expect(promptInput!.value).toBe(SYSTEM_PROMPT); - expect(promptInput!.valueType).toBe(WorkflowIOValueTypeEnum.string); - }); - - it('should enable vision preview for uploaded images', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const visionInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.aiChatVision); - - expect(visionInput).toMatchObject({ - renderTypeList: [FlowNodeInputTypeEnum.hidden], - valueType: WorkflowIOValueTypeEnum.boolean, - value: true - }); - }); - - it('editSkillId input should contain exactly the given skillId', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const editSkillInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.editSkillId); - expect(editSkillInput).toBeDefined(); - expect(editSkillInput!.value).toBe(SKILL_ID); - expect(editSkillInput!.valueType).toBe(WorkflowIOValueTypeEnum.string); - expect(editSkillInput!.renderTypeList).toContain(FlowNodeInputTypeEnum.hidden); - }); - - it('should not pass session skills or edit debug boolean', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - expect(agentNode.inputs.some((i) => i.key === NodeInputKeyEnum.skills)).toBe(false); - expect(agentNode.inputs.some((i) => i.key === 'useEditDebugSandbox')).toBe(false); - }); - - it('should have an answerText output with static type', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - expect(agentNode.outputs).toHaveLength(1); - const output = agentNode.outputs[0]; - expect(output.key).toBe(NodeOutputKeyEnum.answerText); - expect(output.id).toBe(NodeOutputKeyEnum.answerText); - expect(output.type).toBe(FlowNodeOutputTypeEnum.static); - expect(output.valueType).toBe(WorkflowIOValueTypeEnum.string); - }); - }); - - // ── Edge ──────────────────────────────────── - describe('edge (start -> agent)', () => { - it('should connect start to agent with waiting status', () => { - const { runtimeEdges } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const edge = runtimeEdges[0]; - - expect(edge.source).toBe(START_NODE_ID); - expect(edge.target).toBe(AGENT_NODE_ID); - expect(edge.status).toBe('waiting'); - }); - - it('should use correct handle IDs', () => { - const { runtimeEdges } = buildDebugRuntimeNodes(SKILL_ID, MODEL, SYSTEM_PROMPT); - const edge = runtimeEdges[0]; - - expect(edge.sourceHandle).toBe(getHandleId(START_NODE_ID, 'source', 'right')); - expect(edge.targetHandle).toBe(getHandleId(AGENT_NODE_ID, 'target', 'left')); - }); - }); - - // ── Dynamic input injection ───────────────── - describe('dynamic value injection', () => { - it('should inject different edit skill ids correctly', () => { - const anotherSkillId = '507f1f77bcf86cd799439022'; - const { runtimeNodes } = buildDebugRuntimeNodes(anotherSkillId, MODEL, SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const editSkillInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.editSkillId); - expect(editSkillInput!.value).toBe(anotherSkillId); - }); - - it('should inject different models correctly', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, 'claude-3-5-sonnet', SYSTEM_PROMPT); - const agentNode = runtimeNodes[1]; - - const modelInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.aiModel); - expect(modelInput!.value).toBe('claude-3-5-sonnet'); - }); - - it('should inject empty system prompt without error', () => { - const { runtimeNodes } = buildDebugRuntimeNodes(SKILL_ID, MODEL, ''); - const agentNode = runtimeNodes[1]; - - const promptInput = agentNode.inputs.find((i) => i.key === NodeInputKeyEnum.aiSystemPrompt); - expect(promptInput!.value).toBe(''); - }); - }); +vi.mock('@fastgpt/service/support/user/team/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getUserChatInfo: debugChatMocks.getUserChatInfo + }; }); -// ═══════════════════════════════════════════════ -// describe: debugChat API handler — parameter validation -// ═══════════════════════════════════════════════ -describe('debugChat handler — parameter validation', () => { +vi.mock('@fastgpt/service/core/chat/controller', () => ({ + getChatItems: debugChatMocks.getChatItems +})); + +describe('skill debug chat API', () => { let testUser: Awaited>; let skillId: string; - // Error written via sseErrRes can be checked through the vi.mocked spy const getSseErrResMock = () => vi.mocked(responseModule.sseErrRes); + const createRunningSandbox = () => + MongoSandboxInstance.create({ + provider: 'opensandbox', + sandboxId: getEditDebugSandboxId(skillId), + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: skillId, + userId: ChatSourceTypeEnum.skillEdit, + status: 'running', + teamId: testUser.teamId, + image: { repository: 'test-image', tag: 'latest' } + }); beforeEach(async () => { testUser = await getUser(`debug-chat-user-${getNanoid(6)}`); vi.clearAllMocks(); - debugChatMocks.preChatRound.mockResolvedValue({ - chatId: 'prepared-debug-chat-id', - responseChatItemId: 'prepared-debug-response-id', - shouldPersistChatRound: true, - shouldFinalizePreparedRound: true - }); - debugChatMocks.createWorkflowStreamResponseContext.mockResolvedValue({ - responseWrite: debugChatMocks.responseWrite, - flushResume: debugChatMocks.flushResume, - writeStreamError: debugChatMocks.writeStreamError - }); - debugChatMocks.dispatchWorkFlow.mockResolvedValue({ - assistantResponses: [{ text: { content: 'debug answer' } }], - system_memories: { memory: 'value' }, - durationSeconds: 1.2, - customFeedbacks: ['feedback-id'], - nodeResponseSummary: { - citeCollectionIds: [], - errorCount: 0, - totalPoints: 0 + + debugChatMocks.preChatRound.mockImplementation(async ({ userContent }) => { + userContent.value.forEach((item: any) => { + if (item.file?.key) item.file.url = ''; + }); + return { + chatId: 'prepared-debug-chat-id', + responseChatItemId: 'prepared-debug-response-id', + shouldPersistChatRound: true, + shouldFinalizePreparedRound: true + }; + }); + debugChatMocks.createSkillDebugProcessor.mockReturnValue(debugChatMocks.skillDebugProcessor); + debugChatMocks.getUserChatInfo.mockResolvedValue({ + timezone: 'America/New_York', + externalProvider: { + openaiAccount: { + baseUrl: 'https://provider.example/v1', + key: 'provider-key' + } } }); - debugChatMocks.finalizeChatRound.mockResolvedValue(undefined); - debugChatMocks.failChatRound.mockResolvedValue(undefined); - debugChatMocks.updateInteractiveChat.mockResolvedValue(undefined); - debugChatMocks.updateChatGenerateStatus.mockResolvedValue(undefined); - debugChatMocks.getRunningUserInfoByTmbId.mockResolvedValue({ - teamId: testUser.teamId, - tmbId: testUser.tmbId - }); + debugChatMocks.getChatItems.mockResolvedValue({ histories: [] }); + debugChatMocks.getPreviewUrl.mockImplementation(async (key: string) => `/preview/${key}`); + debugChatMocks.getNodeResponseSummary.mockReturnValue({ + citeCollectionIds: [], + errorCount: 0, + totalPoints: 2 + }); + debugChatMocks.runAuxiliaryGeneration.mockImplementation( + async ({ onStreamContextReady, onBeforeStreamDone }) => { + const streamContext = { + write: debugChatMocks.responseWrite, + writeDone: () => debugChatMocks.responseWrite({ data: '[DONE]' }), + writeError: debugChatMocks.writeError, + flushResume: debugChatMocks.flushResume + }; + onStreamContextReady?.(streamContext); + const result = { + aiResponse: [{ text: { content: 'debug answer' } }], + nodeResponses: [{ id: 'node-1', nodeId: 'node-1' }], + memories: { 'agentLoopMemory-skill-debug-agent': { providerState: 'state' } } + }; + await onBeforeStreamDone?.({ result, durationSeconds: 1.2 }); + streamContext.writeDone(); + return { ...result, durationSeconds: 1.2, streamContext }; + } + ); const skill = await MongoAgentSkills.create({ name: 'Test Debug Skill', @@ -347,92 +188,11 @@ describe('debugChat handler — parameter validation', () => { skillId = String(skill._id); }); - it('should call sseErrRes when skillId is missing', async () => { - await Call(debugChatApi.default, { - auth: testUser, - body: { - chatId: getNanoid(), - responseChatItemId: getNanoid(), - model: 'gpt-4o', - messages: [{ role: 'user', content: 'hello' }] - } - }); - expect(getSseErrResMock()).toHaveBeenCalled(); - const err = getSseErrResMock().mock.calls[0][1]; - expect(err?.message ?? err).toMatch(/skillId/i); - }); - - it('should call sseErrRes when chatId is missing', async () => { - await Call(debugChatApi.default, { - auth: testUser, - body: { - skillId, - responseChatItemId: getNanoid(), - model: 'gpt-4o', - messages: [{ role: 'user', content: 'hello' }] - } - }); - expect(getSseErrResMock()).toHaveBeenCalled(); - const err = getSseErrResMock().mock.calls[0][1]; - expect(err?.message ?? err).toMatch(/chatId/i); - }); - - it('should call sseErrRes when messages array is empty', async () => { - await Call(debugChatApi.default, { - auth: testUser, - cookies: {}, - body: { - skillId, - chatId: getNanoid(), - responseChatItemId: getNanoid(), - model: 'gpt-4o', - messages: [] - } - }); - expect(getSseErrResMock()).toHaveBeenCalled(); - const err = getSseErrResMock().mock.calls[0][1]; - expect(err?.message ?? err).toMatch(/messages/i); - }); - - it('should call sseErrRes when edit-debug sandbox does not exist', async () => { - await Call(debugChatApi.default, { - auth: testUser, - cookies: {}, - headers: { - origin: 'http://test.local' - }, - body: { - skillId, - chatId: getNanoid(), - responseChatItemId: getNanoid(), - model: 'gpt-4o', - messages: [{ role: 'user', content: 'hi' }] - } - }); - expect(getSseErrResMock()).toHaveBeenCalled(); - const err = getSseErrResMock().mock.calls[0][1]; - expect(err?.message ?? err).toMatch(/sandbox/i); - }); - - it('should NOT call sseErrRes with sandbox error when edit-debug sandbox exists', async () => { - // Create sandbox instance - await MongoSandboxInstance.create({ - provider: 'opensandbox', - sandboxId: getEditDebugSandboxId(skillId), - sourceType: ChatSourceTypeEnum.skillEdit, - sourceId: skillId, - userId: ChatSourceTypeEnum.skillEdit, - status: 'running', - teamId: testUser.teamId, - image: { repository: 'test-image', tag: 'latest' } - }); - + it('rejects a missing edit-debug sandbox', async () => { await Call(debugChatApi.default, { auth: testUser, cookies: {}, - headers: { - origin: 'http://test.local' - }, + headers: { origin: 'http://test.local' }, body: { skillId, chatId: getNanoid(), @@ -442,15 +202,13 @@ describe('debugChat handler — parameter validation', () => { } }); - // sseErrRes must NOT be called with a sandbox-not-found error - const calls = getSseErrResMock().mock.calls; - const hasSandboxError = calls.some(([, err]) => /sandbox/i.test(err?.message ?? '')); - expect(hasSandboxError).toBe(false); + expect(getSseErrResMock()).toHaveBeenCalled(); + expect(getSseErrResMock().mock.calls[0][1]?.message).toMatch(/sandbox/i); + expect(debugChatMocks.runAuxiliaryGeneration).not.toHaveBeenCalled(); }); - it('should reject read-only collaborators before running edit-debug sandbox', async () => { + it('rejects read-only collaborators before generation', async () => { const reader = await getUser(`debug-chat-reader-${getNanoid(6)}`, testUser.teamId); - await MongoResourcePermission.create({ resourceType: PerResourceTypeEnum.agentSkill, teamId: testUser.teamId, @@ -458,24 +216,12 @@ describe('debugChat handler — parameter validation', () => { tmbId: reader.tmbId, permission: ReadPermissionVal }); - - await MongoSandboxInstance.create({ - provider: 'opensandbox', - sandboxId: getEditDebugSandboxId(skillId), - sourceType: ChatSourceTypeEnum.skillEdit, - sourceId: skillId, - userId: ChatSourceTypeEnum.skillEdit, - status: 'running', - teamId: testUser.teamId, - image: { repository: 'test-image', tag: 'latest' } - }); + await createRunningSandbox(); await Call(debugChatApi.default, { auth: reader, cookies: {}, - headers: { - origin: 'http://test.local' - }, + headers: { origin: 'http://test.local' }, body: { skillId, chatId: getNanoid(), @@ -485,38 +231,39 @@ describe('debugChat handler — parameter validation', () => { } }); - expect(getSseErrResMock()).toHaveBeenCalled(); - const err = getSseErrResMock().mock.calls[0][1]; - expect(err?.message ?? err).toBe(SkillErrEnum.unAuthSkill); + const permissionError = getSseErrResMock().mock.calls[0][1]; + expect(permissionError?.message ?? permissionError).toBe(SkillErrEnum.unAuthSkill); expect(debugChatMocks.preChatRound).not.toHaveBeenCalled(); - expect(debugChatMocks.createWorkflowStreamResponseContext).not.toHaveBeenCalled(); - expect(debugChatMocks.dispatchWorkFlow).not.toHaveBeenCalled(); + expect(debugChatMocks.runAuxiliaryGeneration).not.toHaveBeenCalled(); }); - it('should prepare and finalize a skill debug chat round with prepared ids', async () => { - await MongoSandboxInstance.create({ - provider: 'opensandbox', - sandboxId: getEditDebugSandboxId(skillId), - sourceType: ChatSourceTypeEnum.skillEdit, - sourceId: skillId, - userId: ChatSourceTypeEnum.skillEdit, - status: 'running', - teamId: testUser.teamId, - image: { repository: 'test-image', tag: 'latest' } - }); + it('runs the auxiliary lifecycle and persists before the done event', async () => { + await createRunningSandbox(); await Call(debugChatApi.default, { auth: testUser, cookies: {}, - headers: { - origin: 'http://test.local' - }, + headers: { origin: 'http://test.local' }, body: { skillId, chatId: 'debug-chat-id', responseChatItemId: 'client-response-id', model: 'gpt-4o', - messages: [{ role: 'user', content: 'hi' }] + systemPrompt: 'Use {{@sandbox_read_file@}}.', + messages: [ + { + role: 'user', + content: [ + { + type: 'file_url', + name: 'guide.pdf', + url: '', + key: 'file-key-1' + }, + { type: 'text', text: 'summarize this' } + ] + } + ] } }); @@ -525,50 +272,158 @@ describe('debugChat handler — parameter validation', () => { sourceType: ChatSourceTypeEnum.skillEdit, sourceId: skillId, chatId: 'debug-chat-id', - teamId: testUser.teamId, - tmbId: testUser.tmbId, source: ChatSourceEnum.test, - responseChatItemId: 'client-response-id', - userContent: expect.objectContaining({ - obj: ChatRoleEnum.Human - }) + responseChatItemId: 'client-response-id' }) ); - expect(debugChatMocks.dispatchWorkFlow).toHaveBeenCalledWith( + expect(debugChatMocks.createSkillDebugProcessor).toHaveBeenCalledWith( expect.objectContaining({ - chatId: 'prepared-debug-chat-id', + skillId, responseChatItemId: 'prepared-debug-response-id', - chatConfig: { - fileSelectConfig: expect.objectContaining({ - canSelectFile: true, - canSelectImg: true, - maxFiles: 10 - }) - }, - agentSandboxPrepareActions: undefined + isInteractiveResume: false, + prepareActions: undefined }) ); + expect(debugChatMocks.runAuxiliaryGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: skillId, + chatId: 'prepared-debug-chat-id', + query: 'summarize this', + histories: [], + processor: debugChatMocks.skillDebugProcessor, + data: expect.objectContaining({ + currentUserValue: expect.arrayContaining([ + expect.objectContaining({ + file: expect.objectContaining({ url: '/preview/file-key-1' }) + }) + ]), + userKey: { + baseUrl: 'https://provider.example/v1', + key: 'provider-key' + } + }) + }) + ); + expect(debugChatMocks.recordNodeResponses).toHaveBeenCalledWith([ + { id: 'node-1', nodeId: 'node-1' } + ]); expect(debugChatMocks.finalizeChatRound).toHaveBeenCalledWith( expect.objectContaining({ chatId: 'prepared-debug-chat-id', sourceType: ChatSourceTypeEnum.skillEdit, sourceId: skillId, - source: ChatSourceEnum.test, aiContent: expect.objectContaining({ dataId: 'prepared-debug-response-id', - value: [{ text: { content: 'debug answer' } }], - memories: { memory: 'value' }, - customFeedbacks: ['feedback-id'] + obj: ChatRoleEnum.AI, + value: [{ text: { content: 'debug answer' } }] }) }) ); - - const doneWriteIndex = debugChatMocks.responseWrite.mock.calls.findIndex( + const doneCall = debugChatMocks.responseWrite.mock.calls.find( ([payload]) => payload.data === '[DONE]' ); - expect(doneWriteIndex).toBeGreaterThanOrEqual(0); + expect(doneCall).toBeDefined(); expect(debugChatMocks.finalizeChatRound.mock.invocationCallOrder[0]).toBeLessThan( - debugChatMocks.responseWrite.mock.invocationCallOrder[doneWriteIndex] + debugChatMocks.responseWrite.mock.invocationCallOrder.at(-1)! + ); + }); + + it('reuses an ask interactive usage and updates the existing chat', async () => { + const interactive = { + type: 'agentAsk' as const, + askId: 'ask-1', + usageId: 'usage-1', + entryNodeIds: [], + memoryEdges: [], + nodeOutputs: [], + params: { + description: 'Need a choice', + questions: [ + { + question: 'Choose one', + options: [ + { summary: 'A', value: 'A' }, + { summary: 'B', value: 'B' } + ], + answer: '' + } + ] + } + }; + debugChatMocks.getChatItems.mockResolvedValueOnce({ + histories: [ + { + dataId: 'previous-ai', + obj: ChatRoleEnum.AI, + value: [{ interactive }] + } + ] + }); + await createRunningSandbox(); + + await Call(debugChatApi.default, { + auth: testUser, + cookies: {}, + headers: { origin: 'http://test.local' }, + body: { + skillId, + chatId: 'debug-chat-id', + responseChatItemId: 'client-response-id', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'A' }] + } + }); + + expect(debugChatMocks.createSkillDebugProcessor).toHaveBeenCalledWith( + expect.objectContaining({ isInteractiveResume: true }) + ); + expect(debugChatMocks.runAuxiliaryGeneration).toHaveBeenCalledWith( + expect.objectContaining({ usageId: 'usage-1' }) + ); + expect(debugChatMocks.updateInteractiveChat).toHaveBeenCalledWith( + expect.objectContaining({ interactive }) + ); + expect(debugChatMocks.finalizeChatRound).not.toHaveBeenCalled(); + }); + + it('marks the prepared chat round failed when generation throws', async () => { + debugChatMocks.runAuxiliaryGeneration.mockImplementationOnce( + async ({ onStreamContextReady }) => { + onStreamContextReady?.({ + write: debugChatMocks.responseWrite, + writeDone: vi.fn(), + writeError: debugChatMocks.writeError, + flushResume: debugChatMocks.flushResume + }); + throw new Error('generation failed'); + } + ); + await createRunningSandbox(); + + await Call(debugChatApi.default, { + auth: testUser, + cookies: {}, + headers: { origin: 'http://test.local' }, + body: { + skillId, + chatId: 'debug-chat-id', + responseChatItemId: 'client-response-id', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hi' }] + } + }); + + expect(debugChatMocks.failChatRound).toHaveBeenCalledWith({ + sourceType: ChatSourceTypeEnum.skillEdit, + sourceId: skillId, + chatId: 'prepared-debug-chat-id', + responseChatItemId: 'prepared-debug-response-id', + error: expect.objectContaining({ message: 'generation failed' }) + }); + expect(debugChatMocks.writeError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'generation failed' }) ); + expect(debugChatMocks.flushResume).toHaveBeenCalled(); }); });